八角色并行开工分析,合计 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>
115 KiB
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.yamlv1.4.0creationschema 设计稿patbond-doc/docs/database/patbond_postgresql.sql:556-716(未进 Flyway、未实现,但状态机已定型)- 现状 demo
patbond-flutter/lib/features/create/create_page.dart(563 行,M4 的替换目标)
结论先行
- 界面 8 个单元:6 页(A1 创作首页 / A2 参数页 / A3 等待页 / A4 结果页 / A5 我的 AI 作品 / A6 全屏查看)+ 1 sheet(S1 配额说明)+ 1 全局层(G1 进行中指示位)。
- 等待态推荐方案:提交后自动进 A3,明确允许离开;进度用「阶段文字 + 不确定进度环」为主形态,
progress>0时才叠确定型数字;不展示预计剩余时间,改展示已用时;回来的路三条(G1 全局条 → Tab 角标 → A5 列表)。理由见 §2.3。 - 组件账:新建 8,复用 16(其中 6 需小改)。全局角标 / 全局悬浮层 / 跨页任务状态层在客户端零先例,是本迭代最大的一块新建(§4)。
widthPx/heightPx债在 M4 由隐性升为显性,严重度中高,但修复成本极低(AI 输出路径不需要图片解码)。判断与三处修法见 §8.3。- 待拍板决策 18 项(§9)。最关键三项:D1 等待态形态与「不承诺推送」的文案纪律、D2 参考图必填与否(任务描述的「可选参考图」与设计稿
input_asset_id NOT NULL冲突)、D3 配额契约(429 与两个新错误码尚不存在)。 - 两处必须先纠正的事实误解,否则整条链路会照错误前提施工:见 §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):
queued时 progress 恒 0 → 排队态绝不能画确定型进度条,否则用户看到 0% 不动会判定卡死。running时 progress 无下限(提供方可能不上报,一直是 0)→ UI 必须能画不确定型,确定型只作为可选增强。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 已核实:
- 全局角标 / 全局悬浮层 / 全局 banner —— 零先例。
Badge(Material)全 lib 零使用(唯一Badge相关命中是pet_avatar.dart的showEditBadge/_EditBadge,那是头像编辑徽标不是计数角标);FloatingActionButton零使用;Overlay/OverlayEntry零使用;MaterialBanner零使用(仅两个自绘页内横幅InlineErrorBanner、_RestoredDraftBanner)。G1 要在MainShellPage.build的 Scaffold 层新开插槽。 - 跨页 / 跨进程的任务状态层 —— 零先例。 全 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()、杀进程即全丢。 - 月份网格选择器 —— 零实现(§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
创作 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 - 圆角:卡片
xl24(Card 主题默认)、输入框lg18、网格单格与内嵌块sm12、chip/胶囊pill - 字级:分区标题
titleLarge18/w800;卡内标题titleMedium15/w700;正文bodyMedium14/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 落地的真实发布入口)保留不动——它是本页唯一已经真实的东西。
AppBar(主壳既有:leading 头像+Patbond / 标题「创作中心」/ 通知钮)
↑ 主壳 AppBar 不动;A1 自身的「历史」钮放在页面内容首行右侧
(不塞进主壳 actions —— 那是全 5 个 Tab 共用的,塞进去会在其他 Tab 露出)
┌ G1 进行中条(仅有在途任务时出现,§2.7)────────┐ ← 页面第一位
└──────────────────────────────────────────────┘
↓ 12
┌ 发布动态入口(既有 _ComposeEntryCard,原样保留)┐
└──────────────────────────────────────────────┘
↓ 16
┌ AiHeroCard(正典 .ai-hero)────────────────────┐
│ 变成毛孩子的写真 ink 18/w800│ ← 承字改 ink,非白字(§7.2)
│ 上传一张照片,AI 帮你生成专属宠物写真 ink 12/w500│
│ ┌────────────────────────────────┐ │
│ │ + 上传宠物照片 │ 白92%底 │ 正典 .upload-btn
│ └────────────────────────────────┘ primaryDark│
│ ─────────────────────────────────────────────│ 白 40% 分隔线
│ ✨ 今日还可生成 3 次 QuotaMeter │
└──────────────────────────────────────────────┘
↓ 24
热门风格 titleLarge 18/w800
↓ 12
┌──────┐┌──────┐┌──────┐ StyleCard 3 列网格
│ ││ ││ │ crossAxisSpacing 10 / mainAxisSpacing 10
│ 吉卜力││ 迪士尼││ 漫画风│ childAspectRatio 1/1.1(正典 :166)
└──────┘└──────┘└──────┘ 格内圆角 sm12
┌──────┐┌──────┐┌──────┐
│ 水彩 ││电影海报││拟人化 │
└──────┘└──────┘└──────┘
↓ 24
(若目录含 video 类)AI 视频 同款 3 列网格,M4 无 video 则整段不渲染
「历史」钮落位(正典 🕘,:325):内容首行右对齐一枚 IconButton(Icons.history, tooltip: '我的 AI 作品'),44×44,inkSoft 图标。不放进 MainShellPage 的 AppBar actions(main_shell_page.dart:251-262 是 5 个 Tab 共用的通知钮位,塞进去会在首页/档案/服务/我的四个 Tab 上错误露出)。
AiHeroCard 规格(新建):brandGradient(app_theme.dart:62-66)+ 圆角 xl 24(正典 20 收敛到 token)+ padding 18。承字 ink:标题 18/w800(最不利渐变端 4.90:1)、副标 12/w500(不用透明度降权,靠字号字重区分——白 92% 在此渐变上仅 2.0x:1)。CTA 白 92% 底 + primaryDark 12/w700 + 圆角 sm 12 + 高 44(正典 padding 9 撑不到 44)。分隔线白 40% 1px。
StyleCard 规格(新建,正典 .style-card):
| 态 | 视觉 |
|---|---|
| 默认 | surfaceTint 底(预览图未到手时的占位色,正典 peach)+ border 1px + 圆角 sm 12 + RemoteImage(fit: cover) 预览图;底部 transparent → ink 80% 竖向渐变 + 白字标题 12/w700 居中 + 副标 10(有则渲染),底 padding 10/6 |
| 选中 | 描边转 primaryStrong 2px(不用 primary:primary/canvas 仅 2.59:1 不达非文字 3:1,primaryStrong/canvas 4.23:1;现 create_page.dart:237 正是 primary,§8.5 修订)+ 右上角 20 圆 primaryStrong 实底 + 白 check 图标 13(双通道,不靠颜色单通道传达选中) |
| 按下 | InkWell ripple 圆角随格 |
| 不可用 | M4 不出现(目录端点只返回 enabled=true 的行——服务端已有 partial index WHERE enabled,patbond_postgresql.sql:601-603)。若拍板决定返回全量(D7),灰态形态为:整格 Opacity 0.5 + 移除 onTap + 底部字条改「暂不可用」+ 不渲染选中角标 |
| 预览图缺失 | preview_asset_id 是 nullable(patbond_postgresql.sql:586)→ 无预览图时不留空白格:surfaceTint 底 + 居中 auto_awesome 28 primary(纯装饰,与 EmptyState 的 muted 图标同类,不受对比度约束)+ 底部字条照常 |
风格预览图从哪来:creation.generation_styles.preview_asset_id → media.assets(patbond_postgresql.sql:586),即走既有两步上传 + 预签名 GET 的同一条路,客户端拿到的是时效性签名 URL,RemoteImage 直接消费(缓存 key 已剥签名参数,common.dart:25-28)。不得持久化 URL(纪律 R2)。运营侧如何把图灌进去(后台?种子数据?)未取证 —— 缺一个「风格目录内容管理」的答案,属后端/运营范畴,记入 §10 R7。
模型(model)怎么呈现 —— 与风格分开:generation_models 与 generation_styles 是两张表(:556 / :580)。设计判断:A1 只呈现风格,不呈现模型。模型是实现细节(provider_model_name),普通用户不需要在首屏做这个选择;默认取目录 sort_order 首个。多模型的选择下沉到 A2 的二级披露(§2.2)。现 create_page.dart:176-188 把「创作模型」做成首屏 DropdownButtonFormField(列出 Patbond-V1 / Pet-Art Pro / Cute Motion 三个假名),这个层级是错的,M4 修正。
media_kind 切换器:现 create_page.dart:138-157 的 SegmentedButton<CreationMode>(AI 图片 / AI 视频)。M4 处置 = 数据驱动:按目录返回的 media_kind 去重后决定;只有一种时整个控件不渲染(不给灰态占位——ADR-018 视频后置,契约 kind enum 只有 image,渲染一个永远点不动的「AI 视频」段是在承诺不存在的能力)。契约扩到 video 时 UI 自动长出第二段。副作用:M4 因此绕开了这枚粉底控件的这一个使用点,但另 5 处仍在,主题债照旧要偿(§8.1)。
状态矩阵:
| 态 | 呈现 |
|---|---|
| 首载 | hero 骨架(surfaceTint 圆角 xl 块)+ 6 格 surfaceTint 方块;呼吸动效沿 feed_skeleton.dart:26-46 同款(1200ms、disableAnimations 时静止在 1.0) |
| 空(目录 0 条) | EmptyStateIllustration(icon: auto_awesome, title: '暂时没有可用的风格', description: '我们正在准备新的风格,稍后再来看看'),不给 CTA(无处可去);hero 的上传 CTA 同时禁用(选不了风格就发不了任务) |
| 加载失败 | InlineErrorBanner + 其下「重试」TextButton;hero 保留可见但 CTA 禁用 |
| 进行中 | G1 条在页首(§2.7);hero 与目录照常可用(允许排多个任务,上限由配额约束) |
| 成功(就绪) | 如上线框 |
| 无权限 | 不作为页面态存在。全部端点 401,而 App 登录后才进主壳(splash_page.dart → login),未登录到不了 A1;在途 401 由既有 TokenRefresher 兜底 |
| 配额耗尽 | 不打断浏览:QuotaMeter 转耗尽态(§2.7),hero CTA 仍可点,到 A2 提交时才拦(不在 A1 就禁用——用户可能只是想逛风格,提前禁用会让人以为功能坏了) |
2.2 A2 生成参数页
push 全屏页(不用 sheet:有图片选择 + 长文本输入 + 两级披露,sheet 高度不够且键盘弹起时披露区会被压没)。
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)。 - 不满足时按钮走主题禁用态(
ink12% 底 + 38% 字,app_theme.dart:137-138),并在按钮上方给一行 12inkSoft的原因(「先选一张宠物照片」/「先选一个风格」)—— 只置灰不说原因是既有页面的通病,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。
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 铁律):
- 主形态是「阶段文字 + 不确定进度环」,不是百分比。 因为
queued时progress恒 0(硬约束),running时progress无下限约束(提供方可能全程不上报)。若把百分比当主形态,最常见的用户观感就是「0% 卡了半天」——这比没有百分比糟得多。 progress > 0时才叠数字,形态从不确定环切换为确定环 + 环心42%15/w800ink。切换时不做重置动画(环角度从当前位置续上)。- 绝不展示预计剩余时间。 ETA 需要提供方支持 + 队列深度模型,M4 两者都没有(AI 提供方 ADR 尚未产生)。倒计时走完还没完成是最伤信任的反模式。
- 改展示「已用时」。 已用时是客观事实、客户端自己就能算(提交时刻起,或服务端
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:'再试一次')→ 回 A1queued与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 非空是硬约束):
┌──────────────────────────────────────────┐
│ ⊘ 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——那是个已终结的等待页)。
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(不做「作品」与「任务」两个分开的列表——用户心智里它们是同一串东西的不同阶段)。
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 进发布页。
A4 点「发布到社区」
↓ POST /posts { status:'draft', category:'ai_creation',
│ generationJobId: <jobId>,
│ media:[{ assetId: <outputAssetId>, position:0, isCover:true }],
│ content:'' } ← 正文空,见下
↓ 拿到 { id: draftPostId, version }
↓ push PostComposePage(initialDraftPostId: draftPostId)
发布页:拉这一条草稿 → 渲染图 + 锁定分类 + 正文空待填
比较过的两条路:
| 路线 | 做法 | 判定 |
|---|---|---|
| 甲 服务端建草稿 | 如上;发布页从「拉最新草稿」改为「拉指定草稿」 | 采纳。ai_creation 分类与 generation_job_id 的挂接在服务端一次完成;且复用了发布页已有的草稿恢复通路(post_compose_page.dart:194-220 已经会恢复 draft.media),改动面最小 |
| 乙 客户端预填 | 发布页加 initialContent/initialCategory/initialAssetIds,首次存草稿时才带 jobId |
弃用。要新增 3 个参数 + 发布页要能提交 generationJobId(写侧 DTO 也得改),改动面更大,且「AI 帖的元数据挂接」这件事被摊到客户端 |
预填了什么 —— 逐项:
| 项 | 预填 | 理由 |
|---|---|---|
| 媒体 | ✅ AI 输出图 1 张(服务端已挂在草稿上) | 这是发帖的全部内容 |
| 分类 | ✅ ai_creation,且锁定不可改 |
改成 general 会让 generation_job_id 挂在一个非 AI 帖上,语义漂移。呈现为静态 TagPill 而不是可选 ChoiceChip |
| 正文 | ❌ 不预填,只给 hint | 见下,这是本节最关键的判断 |
| 话题 | ❌ | 话题在客户端完全不存在(post_compose_page.dart:288-289 topicCount: 0 硬编码,注释「话题无契约端点,M3 恒 0」;ADR-018 已把话题剪出)。M4 不引入 |
| 位置 | ❌ | 现为假入口(post_compose_page.dart:607-613),M5 服务域 |
| 提示词 | ❌ 默认不公开,见 D9 | prompt 可能含隐私(他人姓名、地点)。给一个「附上创作提示词」开关,默认关 |
为什么正文不预填 —— 与 M3.5 的既有纪律同源。 现 demo 会预填「刚刚用 Patbond 创作了新作品,快来看看豆豆的新造型吧!✨」(create_page.dart:90)。这与 M3.5 定下的「编辑页不预填 username」是同一类错误(iteration-3.5/05 §2.1):一个纯展示用的机器文案一旦预填进输入框,用户按一下发布就把它固化成真实数据。后果具体且可预期 —— Feed 会被同一句话灌满,AI 帖立刻变成可识别的垃圾内容。
替代方案(既不留空白页,也不代替用户说话):
- 正文
hint:「说说这张图的灵感…」(hint 用muted合法) - 正文框下一行可点的建议标签:
( #吉卜力风 )( 提示词 )( 我家豆豆 )—— 用户主动点才把对应内容插进正文。把「省事」和「代写」区分开 - 发布 gating 沿用既有
_canPublish(post_compose_page.dart:159-163):content非空是后端必填,所以用户必须自己写一句话才能发。这不是障碍,是防灌水的最后一道闸
发布页必须改的两处(否则这条链路是坏的):
initialDraftPostId参数 +_restoreLatestDraft改造。 现在它无条件拉「最新一条 draft」(post_compose_page.dart:196-199)并无条件覆盖正文(:203)。若用户本来就有一条旧草稿,从 A4 进来会恢复错的那条。需改为:给了initialDraftPostId就按 id 拉,否则保持现行为。- ⚠ 服务端草稿的图现在只显示计数文字,不渲染缩略图。 已核实
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读比例,这里也顺带正确。
- 注意:
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 的出口):
┌ drag handle ─────────────────────────────┐
│ 今日额度已用完 titleLarge │
│ │
│ 每天可以免费生成 5 次,明天 0 点重置 │ bodyMedium inkSoft
│ ▓▓▓▓▓▓▓▓▓▓ 5 / 5 QuotaMeter │
│ │
│ ┌──────────────────────────────────┐ │
│ │ 看看我的作品 │ │ FilledButton → A5
│ └──────────────────────────────────┘ │
│ 去社区逛逛 TextButton → 首页 │
└──────────────────────────────────────────┘
配额耗尽时不给「升级会员」按钮。 正典有会员卡「AI 会员 Plus / 无限 AI 生图」(AI宠物_iOS_UI设计稿.html:489-493),但 M4 没有任何支付能力(development-plan.md 把优惠/订单列在 M5)。给一个点了通向死路的升级按钮,比不给更伤——用户会认为产品在钓鱼。M4 只给两个真的能去的出口。拍板 D12。
两类 429 必须分开(契约层诉求 R4):
| 类别 | 文案 | 出口 | 为什么不能合并 |
|---|---|---|---|
| 配额耗尽 | 「今日额度已用完」+ 重置时间 | S1 sheet(看作品 / 逛社区) | 等的是明天,用户该走开 |
| 频率过快 | 「操作太频繁,请 12 秒后再试」+ 倒计时 | SnackBar,不弹 sheet | 等的是几秒,用户该原地待一下。弹 sheet 是过度反应 |
QuotaMeter 规格(新建,三处共用:A1 hero、A2 底条、S1):横向细条 LinearProgressIndicator minHeight 6 + 圆角 pill,值条 primaryStrong / 轨道 surfaceTint(3.80:1 达非文字 3:1;不能用 primary 值条 —— primary/surfaceTint 仅 2.33:1)+ 右侧 3/5 12/w600 inkSoft。耗尽态值条转 errorDark(6.50:1)+ 文字转「已用完」errorDark。紧凑形态(hero 内)只给一行文字不给条。
3. 状态矩阵总表
各页详细矩阵见 §2 对应小节,此处为交叉核对表(— = 该态在该页不存在,且是定型而非遗漏)。
| 界面 | 空 | 加载 | 进行中 | 成功 | 失败 | 无权限 |
|---|---|---|---|---|---|---|
| A1 创作首页 | EmptyStateIllustration 无 CTA + hero CTA 禁用 |
hero 块 + 6 格骨架 | G1 条在页首,目录照常可用 | 线框态 | InlineErrorBanner + 重试 |
— |
| A2 参数页 | 初始态(+格 / 空提示词 / 钮禁用 + 原因行) | 无首载;宠物区 3 圆占位 | 参考图 UploadProgressOverlay 四态 |
钮可用带「消耗 1 次」 | 上传格内失败 + 重试 / 提交失败顶部横幅不清内容 | — |
| A3 等待页 | — | 不确定环 +「正在提交…」无已用时 | 本页主态(排队 / 生成中 / 僵死三分支) | 环补满 → 自动进 A4 | 独立失败版式(非 EmptyState)+ 两个出口 | — |
| A4 结果页 | — | RemoteImage 自带(不叠外层骨架) |
建草稿中:主钮转圈 + 另两钮禁用 | 线框态 | 建草稿失败横幅,停在本页 | — |
| A5 作品列表 | EmptyStateIllustration + CTA「开始创作」 |
9 格 AiWorkGridSkeleton |
混在列表里的 queued/running 格 | 网格 | 首载横幅 + 重试 / 翻页失败保留已加载 | — |
| A6 全屏查看 | — | RemoteImage 自带 |
— | 黑底 + 缩放 | 图失败兜底(Icons.pets) |
— |
| S1 配额 sheet | — | — | — | 线框态 | — | — |
| G1 全局位 | 无在途任务 → 整条不渲染不占位 | — | 三种条文案 | 「生成好了 →」态(点过才消失) | 轮询连挂 3 次 → 条内加「网络不太稳」副行 | — |
「无权限」整列为 — 是定型:所有 AI 端点都需认证(401),而 App 登录后才进主壳(splash_page.dart → login),未登录到不了任何一页;在途 401 由既有 TokenRefresher 兜底,不构成页面态。这与 iteration-3.5/05 §3.2「资料页没有独立空态是定型而非遗漏」是同类的显式声明。
A3/A4/A6 的「空」态为 — 也是定型:到达它们的前提就是「有一个 job / 有一个 succeeded job / 有一张图」,空态在逻辑上不可达。jobId 查不到 → 404 → 走失败态,不是空态。
4. 组件清单(复用 vs 新建)
账目:新建 8,复用 16(其中 6 需小改)。
4.1 直接复用,零改动(10)
| # | 组件 | 位置 | M4 用在哪 |
|---|---|---|---|
| 1 | RemoteImage |
lib/widgets/common.dart:5-60 |
风格预览、结果图、A5 网格;三态(loading surfaceTint+转圈 / 失败 Icons.pets / 成功)全部够用 |
| 2 | SectionCard |
common.dart:62-77 |
A2 各表单分组 |
| 3 | TagPill |
common.dart:86-132 |
「已选风格」标签、发布页锁定的 ai_creation 分类标签;用其既有深变体映射 _defaultInkFor(:103-109) |
| 4 | EmptyStateIllustration |
empty_state_illustration.dart:10-78 |
A1 空态、A5 空态、A3 取消终态 |
| 5 | InlineErrorBanner |
inline_error_banner.dart:8-41 |
A1/A2/A4/A5 的区块错误 |
| 6 | PostMediaGrid 展示态 |
post_media_grid.dart:21-110 |
发布页渲染草稿图(§2.6 修法);A4 结果图不用它(4:3 回落) |
| 7 | PostMediaEditGrid |
post_media_grid.dart:128-182 |
A2 参考图(maxImages:1,「+」虚线格与删除角标全现成) |
| 8 | UploadProgressOverlay |
upload_progress_overlay.dart:17-131 |
A2 参考图六态 → 四视觉态,一格不用重画 |
| 9 | MediaUploader |
media_uploader.dart:127-565 |
A2 参考图编排:状态机、并发槽、凭据 30 s 余量换新(:170、:377-397)、压缩阶梯 [80,60](:173)、超限 retryable:false(:350-356)全部沿用 |
| 10 | PetAvatar + PrimaryButton |
pet_avatar.dart / primary_button.dart:8-47 |
A2 宠物选择器用 PetAvatarSize.md 44(恰为最小触控目标);全宽按钮沿用 |
(EmptyState,common.dart:134-156:M4 不用 —— 已核实它无 CTA 能力,M4 一律用 EmptyStateIllustration。列此以说明该判断是核实过的,不是漏看。)
4.2 复用但需小改(6)
| # | 组件 / 枚举 | 位置 | 要改什么 | 规模 |
|---|---|---|---|---|
| 11 | AppTextField |
app_text_field.dart:9-24 |
加 maxLines / minLines / maxLength 三个参数(现在没有,已核实)。顺带可收敛 post_compose_page.dart:575-585 的裸 TextField |
S |
| 12 | _MediaGalleryPage → MediaGalleryPage |
post_detail_page.dart:880-985 |
提为共享(移到 lib/core/widgets/media_gallery_page.dart 并公开),行为零改 |
S |
| 13 | PostComposePage |
post_compose_page.dart:39-46 |
加 initialDraftPostId;_restoreLatestDraft(:194-220)改为「有 id 按 id 拉」;_draftMedia 从计数文字(:620-638)改为 PostMediaGrid 渲染;分类区支持 ai_creation 锁定态(现 ChoiceChip×2,:589-605) |
M |
| 14 | MediaPurpose |
community_models.dart:67-75 |
加 aiInput('ai_input') / aiOutput('ai_output')(零 Flyway,白名单是配置项) |
S |
| 15 | PostEntryPoint |
post_analytics.dart:19-28 |
加 aiResult(埋点归因,口径交由埋点规格) |
S |
| 16 | 呼吸动效逻辑 | feed_skeleton.dart:26-46 |
不改 FeedSkeleton 本体,但 AiWorkGridSkeleton 须复用同一套参数(1200 ms、0.6↔1.0、disableAnimations 静止在 1.0)。建议提为共享 BreathingOpacity,两处共用一份 |
S |
4.3 必须新建(8)
| # | 组件 | 建议位置 | 用途 | 规模 |
|---|---|---|---|---|
| 17 | AiHeroCard |
features/create/ |
A1 渐变 hero + 上传 CTA + 紧凑配额行 | S |
| 18 | StyleCard |
lib/core/widgets/style_card.dart |
风格/模型卡,5 态(默认/选中/按下/不可用/无预览图) | M |
| 19 | GenerationParamSection |
features/create/ |
自绘渐进披露分组(不用 ExpansionTile,避免复制 SegmentedButton 粉底那笔债的成因) |
S |
| 20 | GenerationProgressPanel |
features/create/ |
A3 核心:不确定/确定环切换 + 阶段步进 + 已用时 + 僵死态 | L |
| 21 | GenerationJobCard |
lib/core/widgets/ |
A5 网格单格,五 status 态 + 已发帖角标 | M |
| 22 | QuotaMeter |
lib/core/widgets/quota_meter.dart |
三处共用(hero 紧凑行 / A2 底条 / S1 完整条),含耗尽态 | S |
| 23 | ActiveJobBar |
lib/core/widgets/active_job_bar.dart |
G1 全局条,三种文案态;客户端首个跨页状态条 | M |
| 24 | AiWorkGridSkeleton |
lib/core/widgets/ |
A5 首载 9 格骨架(FeedSkeleton 是单图卡形态,形状完全不同,不能直接用) |
S |
4.4 不是组件、但必须一并落的三件事
| 事项 | 说明 | 规模 |
|---|---|---|
| 客户端任务状态层 | 轮询(前台限定 + 退避阶梯)+ jobId 列表持久化 + 跨 Tab 单例 + 杀进程恢复。客户端零先例(§0.5 第 2 条),是本迭代最大的一块新建 | L |
Badge 首次引入 |
Material 内置组件,全 lib 零使用(已核实)。用在底栏创作 Tab 图标上(main_shell_page.dart:277-281) |
S |
segmentedButtonTheme |
偿 §8.1 的债。不做也不阻塞 M4(M4 的分段器数据驱动后不渲染),但另 5 处仍在 | S |
4.5 现状 demo 的消亡清单(create_page.dart,563 行)
| 现状 | 行号 | M4 处置 |
|---|---|---|
_ComposeEntryCard 真实发布入口 |
:394-419 | 保留不动(T3-17 落地的真东西) |
SegmentedButton<CreationMode> |
:138-157 | 改数据驱动;M4 无 video 时不渲染 |
_UploadCard(读 demo 宠物头像当"上传") |
:421-493 | 消亡 → A2 的 PostMediaEditGrid 真上传 |
| 「生成设置」卡(模型下拉 / 时长 / 分辨率 / 高清增强) | :170-214 | 部分继承:模型下沉二级披露;分辨率→画面比例;高清增强保留;_ChoiceRow(:495-530)可就地重写为比例 chip 组 |
横滑风格列表(SizedBox(height:150) + 125 宽卡) |
:218-292 | 消亡 → 正典 3 列网格 + StyleCard(正典 .style-grid :163-173;现状与正典不符) |
_GenerationProgress 四步假阶梯 |
:532-563 | 消亡 → GenerationProgressPanel 三段(或两段)真状态 |
generate() / simulateUpload() 假延时 |
:55-92 | 消亡 |
结果卡(标题/正文/话题 InputChip/位置/发布钮) |
:312-386 | 消亡 → A4 三动作 + 发布走 PostComposePage |
publish() 占位 SnackBar |
:122-129 | 消亡 |
CreationStyle 模型 + creationStyles 常量 |
models.dart:285-297、demo_data.dart:206-233 |
消亡 → 服务端目录 |
tags 本地话题数组 + addTag() AlertDialog |
:46、:94-120 | 消亡(话题在客户端不存在,ADR-018 已剪出) |
5. 中文文案表
不给 i18n key(§0.6b 已核实:仓库无 ARB / gen-l10n / AppLocalizations,业务文案是源码内硬编码中文)。Material 组件自带文案(对话框按钮、日期选择器等)交给三件套 delegate,不在业务代码重复硬编码(既有纪律 app_date_picker.dart:75-76)。
5.1 界面文案
| 落点 | 中文原文 | 备注 |
|---|---|---|
| A1 hero 标题 | 变成毛孩子的写真 |
正典是如果我的猫变成人?(:328)——那是一句具体的玩法标题,不适合做常驻 hero 标题(每次进来都问同一个问题会腻)。改为能力陈述 |
| A1 hero 副标 | 上传一张照片,AI 帮你生成专属宠物写真 |
正典原文(:329)沿用 |
| A1 hero CTA | + 上传宠物照片 |
正典原文(:330)。若 D2 改为参考图可选,须改为开始创作 |
| A1 分区标题 | 热门风格 / AI 视频 |
正典原文(:332、:341) |
| A1 历史钮 tooltip | 我的 AI 作品 |
|
| A1 空态 | 暂时没有可用的风格 / 我们正在准备新的风格,稍后再来看看 |
无 CTA |
| A2 标题 | 生成设置 |
|
| A2 参考图区标题 | 宠物照片 |
|
| A2 已选风格行 | 已选风格: + #吉卜力 / 更换 |
|
| A2 提示词标题 | 想让它变成什么样?(选填) |
|
| A2 提示词 hint | 例如:坐在樱花树下,暖阳,胶片质感 |
给具体例子,不写请输入提示词 |
| A2 提示词软上限提示 | 再长也不会更准,试试精简一点 |
到 500 字时出现,不硬截断 |
| A2 宠物区标题 | 这是哪只毛孩子?(选填) / 不指定 |
|
| A2 一级披露 | 更多设置 |
|
| A2 比例标题 | 画面比例 |
值:1:1 3:4 4:3 9:16 16:9 |
| A2 高清增强 | 高清增强 / 提升毛发与眼睛细节 |
副标沿用现状 create_page.dart:208 |
| A2 二级披露 | 高级 |
|
| A2 负面提示词 | 不想出现的元素(选填) |
不叫「负面提示词」(专业术语,普通用户读不懂) |
| A2 模型标题 | 创作模型 |
仅目录 >1 个模型时渲染 |
| A2 提交钮 | ✨ 开始生成(消耗 1 次) |
代价写在按钮上 |
| A2 提交中 | 正在提交… |
|
| A2 gating 原因行 | 先选一张宠物照片 / 先选一个风格 / 照片还在上传 |
只置灰不说原因是既有通病,M4 不复制 |
| A3 标题 | 正在生成 |
|
| A3 排队 | 已排队 / 正在等待空闲的算力 |
有 queuePosition 时副行改 前面还有 3 个任务 |
| A3 生成中 | 正在生成… |
|
| A3 已用时 | 已经等了 42 秒 / 已经等了 2 分 15 秒 |
不是预计剩余时间 |
| A3 阶段步进 | 已排队 / 生成中 / 收尾 |
第三段仅在 progress 可信时渲染(D4) |
| A3 可离开说明 | 可以先去做别的,回到 App 就能看到结果 |
⚠ 绝不能写「完成后通知你」(无推送能力,§2.3) |
| A3 僵死态 | 还在生成,比平时久一些 / 你可以继续等,或者取消这次生成 |
不是错误文案 |
| A3 轮询抖动 | 网络不太稳,正在重连… |
连续 3 次失败才出现 |
| A3 取消钮 / 确认 | 取消 / 取消这次生成? |
对话框内容按 D11:已消耗的额度不会退还 或 额度会退还给你 |
| A3 已取消终态 | 已取消这次生成 / CTA 再试一次 |
|
| A4 标题 | 创作完成 |
|
| A4 主 CTA | 发布到社区 |
|
| A4 次要钮 | 重新生成 / 暂不发布 |
不叫「丢弃」/「删除」(§2.4) |
| A4 重新生成代价 | 将消耗 1 次额度 |
|
| A4 底部说明 | 作品已存在「我的 AI 作品」里,之后也能发布 |
|
| A4 图加载失败 | 图片加载失败,下拉重试 |
三个动作钮仍可用 |
| A5 标题 | 我的 AI 作品 |
与正典菜单项一致(:497) |
| A5 格内字条 | 排队中 / 生成中 / 失败 / 已取消 |
|
| A5 空态 | 还没有 AI 作品 / 上传一张宠物照片,试试把它变成写真 / CTA 开始创作 |
|
| A5 长按 sheet | 保存到相册 / 删除 / 删除这个作品? |
删除是真删(与 A4「暂不发布」区分) |
| A5 翻页失败 | 加载失败,点击重试 / 没有更多了 |
|
| G1 条 | 正在生成你的作品… / 2 个作品正在生成… / 作品生成好了,来看看 → |
|
| G1 完成 SnackBar | 作品生成好了 + action 查看 |
不弹对话框 |
| S1 sheet | 今日额度已用完 / 每天可以免费生成 5 次,明天 0 点重置 |
次数与窗口须与服务端配额一致,不写死 |
| S1 出口 | 看看我的作品 / 去社区逛逛 |
不给「升级会员」(M4 无支付,D12) |
| 发布页分类锁定标签 | AI 创作 |
静态 TagPill,不可改 |
| 发布页正文 hint | 说说这张图的灵感… |
|
| 发布页建议标签 | #吉卜力风 / 提示词 / 我家豆豆 |
用户点了才插入正文,不预填 |
| 发布页提示词开关 | 附上创作提示词 |
默认关(D9) |
5.2 错误文案分层
⚠ 前提:以下错误码除 40405 / 42203 / 40902 外,其余全部尚不存在,需在 M4 契约新开(§10 R4)。号段是建议,须与后端一起定号(纪律:业务码永不复用或改号,openapi.yaml:28)。
| 情形 | 建议码 | 用户可见文案 | 出口 |
|---|---|---|---|
| 配额耗尽 | 429 / 42900 |
今日额度已用完,明天 0 点重置 |
S1 sheet |
| 频率过快 | 429 / 42901 |
操作太频繁,请 12 秒后再试 |
SnackBar + 倒计时,不弹 sheet |
| 参考图不存在 / 非本人 / 用途不符 | 404 / 40405(已存在) |
这张照片已失效,请重新选择 |
回 A2 参考图区 |
| 参考图非 ready | 422 / 42203(已存在) |
照片还没处理完,稍等一下再试 |
停在 A2 |
| 风格 / 模型已下架 | 404 / 新码 | 这个风格暂时下架了,换一个试试 |
回 A1 |
| 提示词违规 | 422 / 新码 | 提示词里有不能生成的内容,改一改再试 |
停在 A2,聚焦提示词框 |
| 生成失败:识别不到宠物 | error_code 分支 |
参考图里没有识别到宠物,换一张试试 |
A3 失败态「换个参考图」 |
| 生成失败:提供方不可用 | 503 / 50300(已存在)或 error_code |
生成服务暂时不可用,稍后再试 |
A3 失败态「用同样设置重试」 |
| 生成失败:兜底 | 任意未识别 error_code |
这次生成没成功,换个参考图或稍后再试 |
绝不写「未知错误」;折叠区可展开看 error_message |
| 重试次数已尽 | attempt_count == max_attempts |
同样的设置已经试过几次了,建议换张照片 |
「用同样设置重试」降为次要态 |
| 建草稿失败:写侧拒收分类 | 400 / 40000 |
发布准备失败,请重试 |
停在 A4(这是 §10 R5 未做时的表现,属实现缺陷不该到用户面前) |
| 草稿乐观锁冲突 | 409 / 40902(已存在) |
草稿已被更新,请重新操作 |
沿用 M3.5 既有文案(iteration-3.5/05 §1.2),不另造 |
| 网络不可达 | — | 网络好像不太好,检查一下再试 |
沿既有网络层文案惯例 |
6. 动效与反馈约定
6.1 动效清单
| 场景 | 动效 | 时长 / 曲线 | reduce-motion 降级 |
|---|---|---|---|
| 骨架呼吸(A1/A5) | 不透明度 0.6 ↔ 1.0 循环 | 1200 ms 往复 | 静止在 1.0(沿 feed_skeleton.dart:40-43) |
| 渐进披露展开 | AnimatedSize |
200 ms easeOut |
瞬变(duration: Duration.zero) |
| 风格卡选中 | 描边色 + 勾选角标淡入 | 150 ms | 瞬变 |
| 进度环(不确定) | Material 内置旋转 | 内置 | 不停转(这是状态指示不是装饰,停了会让人以为卡死);改用 LinearProgressIndicator 不确定态亦可 |
| 进度环(不确定 → 确定切换) | 从当前角度续上,不回零 | 300 ms easeOut |
直接跳到当前值 |
| 进度环补满 → 进 A4 | 补到 100% → 停 200 ms → pushReplacement |
合计 ≈500 ms | 直接跳转 |
| G1 条出现 / 消失 | 高度 + 不透明度 AnimatedSize |
200 ms easeOut |
瞬现 / 瞬隐 |
| G1 条「进行中 → 已完成」 | 底色与图标交叉淡入 | 250 ms | 瞬变 |
| Tab 角标出现 | Badge 内置 scale |
内置 | 瞬现 |
| 上传格进度层 | 沿既有 UploadProgressOverlay(成功态 150 ms 淡出,upload_progress_overlay.dart:64-70) |
150 ms | 沿既有 |
| 页面转场 | 系统默认 MaterialPageRoute |
内置 | 系统处理 |
reduce-motion 检测口径:用 MediaQuery.maybeOf(context)?.disableAnimations ?? false(maybeOf + 兜底,沿 like_button.dart:104-105 的稳妥写法,而不是 feed_skeleton.dart:40 的 MediaQuery.of)。
唯一不降级的动效是进度环的旋转,理由已写在表里。这是一个刻意的偏离,须在源码注释里记明(否则下一个人会"顺手统一"掉)。
6.2 反馈约定
| 反馈层 | 用在哪 | 不用在哪 |
|---|---|---|
字段级 errorText |
提示词违规、软上限提示 | 不用于网络错误 |
区块 InlineErrorBanner + 重试 |
A1 目录失败、A2 提交失败、A4 建草稿失败、A5 首载失败 | 不用于轮询抖动(§2.3:抖动不升级为错误) |
瞬态 SnackBar |
频率过快(带倒计时)、G1 完成通知、删除成功 | 不用于配额耗尽(那要给出口,用 S1 sheet) |
| Bottom sheet | S1 配额说明、A5 长按操作 | 不用于 A2 表单(要与其他项同屏) |
AlertDialog |
取消生成确认、删除作品确认 | 不用于「作品生成好了」(抢焦点,用户可能正在打字) |
| 页内终态版式 | A3 失败态、A3 取消终态 | — |
提交类操作的防重复三道闸(AI 生成是花钱的,比点赞严格得多):
- 点击后立即
AbsorbPointer整页 + 钮内转圈(视觉与交互双锁) - 幂等键:沿既有纪律(
post_compose_page.dart:98、:179-182表单一改即换新键),generation_jobs有UNIQUE(user_id, idempotency_key)(设计稿:643) - 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/grepsegmentedButtonTheme|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,没 overridesecondaryContainer- 精确色值(本机
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 完全不同:
- AI 生成图的比例是用户自己在 A2 里选的(1:1 / 3:4 / 9:16 …),他对"我要的是竖图"有明确预期
- 他刚在 A4 看过完整构图(A4 用 job 的 width/height,比例正确,§2.4)
- 然后同一张图在 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:350tabbar 就是这么画的)。首页再加一张"去 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 挡着 |
| 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