Compare commits

...
9 Commits
Author SHA1 Message Date
xsl 0075c130bd docs(deploy): 生产部署手册-elane(含代码 review 结论:配置清单/隐患/待办);meida_backend 子模块指针 → 90349f7(深度诊断+邮箱登录+admin 看板) 2026-09-15 00:03:13 +08:00
xsl 2b0df49aa7 feat(auth): 正式登录系统——华为账号登录(Account Kit,client_id 占位拦截防 crash)+ 邮箱验证码登录(发码冷却倒计时);dev-token 与服务器地址挪入开发者高级设置;client_id 填入 6917613748100335131 2026-09-15 00:03:13 +08:00
xsl eb86810a77 fix(tryon): 再来一次回到初始进入界面——resetResult 漏清 resultSrc 导致效果图残留预览区;新增 resetToEntry 连本人照一起清(结果操作行与全屏查看器两个入口) 2026-09-15 00:03:13 +08:00
xsl 247da63509 feat(report): 诊断流四项修复 + 形象深度诊断端侧——表单草稿跨页保留(AppStorage);照片预览改竖版 3:4;40920 改中性兜底+历史报告入口(initialTab 参数);重新生成 40900 弹窗引导;深度诊断卡替换预约占位(生成/查看/防套娃)+ 色板/场景/30天计划渲染 + 「深度」徽标 2026-09-15 00:03:11 +08:00
xsl 1a6fde919b fix(hair): 图片区布局修复——Stack 显式定高 320(额度胶囊 Row height(100%) 在自适应 Stack 里会被 Scroll 视口撑飞,导致图片区下坠出大片空白);裁切回 Cover 居中(人脸通常居中,顶部锚定会只显示天花板);选图/拍照用户取消静默化(13900042 不再误报「读取照片失败」)
真机走查验证:布局紧贴页签、选中描边+「试戴『×××』」按钮、试戴成功自动切效果图页签、额度 3→2 递减
2026-09-12 13:23:54 +08:00
xsl f244f14ab8 feat(billing): 任务换额度端侧闭环(T2b 定稿 4 任务)
- TaskBonusPanel 重写:每日任务/新手任务分组、五态按钮、真实「去完成」跳转(first_report→诊断落地页;report_feedback/share_report→报告结果页)、40311/40310 文案、panel_view/task_tap 埋点
- ReportPdfPage 分享成功→静默 claim share_report;ReportResultPage SUCCEEDED 展示→静默 claim first_report(后端 40910 幂等兜底,老用户首进补发一次属预期回填)
- ChatPage/ReportResultPage 修 bonus_granted 类型误写(boolean→number)
- docs:T2b 任务书(验收 11 条)+ 产品方案 §3.2 任务目录定稿(签到对话+10/日、报告反馈发型+2/日、分享换装+3/日、首测重生成+1/终身;邀请好友砍)

偏差说明:share_report 落点为报告结果页而非 PDF 预览页(该页强依赖本地 pdfPath,直达需下迭代加持久化)。
2026-09-12 00:25:08 +08:00
xsl fcc506920f feat(report): 美学诊断照片必选——本地直传链路 + 报告创建照片卡 + 授权文案改属实
- ReportCreatePage:必选照片卡(人脸授权→相册/拍照→预览/重选),未选红字+汇总条拦截;提交先上传拿真 object_key 再建报告(上传失败不碰配额可重试);删除 buildMockPhotoKey 模拟链路;照片引导按需求文档 §3.4/§5.1(正面全身照)
- core/UploadApi.ets:POST /uploads/direct 原始字节上传(绕开真机 multiFormDataList 0 字节 bug),带鉴权信封解包
- FacePhotoConsent:加可选 usageText(诊断页传报告口径文案,试穿/发型页零影响);处理方/保存两行按本地磁盘降级路线改属实(去「第三方」「生成后即删除」),CONSENT_VERSION v1→v2 全体重询一次
- 后端配套:meida_backend a1d0171(本地直传+存在性校验);联调 E2E 已验证(真 key 全链路 succeeded / 假 key 404 零流水)
2026-09-12 00:24:29 +08:00
xsl 9161a2013c fix(chat): TTS 朗读净化——不念 Markdown/英文标点/表情符号(中文停顿标点保留做自然停顿) 2026-09-11 23:45:15 +08:00
xsl aafacbf020 feat(hair): 发型/换发色交互重排——页签单图区(顶对齐裁切)+ 点选+吸底按钮触发 + 换发色拆独立页 + 60款男女全量重分类
- 发型设计:原图/效果图改单区域+胶囊页签切换(无结果置灰);点选发型只选中不生成,吸底「试戴『×××』」按钮触发;移除页内换发色卡与「上次结果」回看(相册留存即可)
- 换发色独立页 HairColorPage(主页新入口卡):8 色圆点+强度滑杆,每次在原图上换色不叠加;两页图片均顶部对齐裁切(容器定高 clip,头顶不被居中裁掉)
- 60 款发型男女分类按缩略图模特呈现全量重分(男30/女30;旧版互串 18 款)
- 两页仍共用 hairstyle 每日额度(P0-5 合并计数)
2026-09-11 23:45:15 +08:00
26 changed files with 2746 additions and 559 deletions
@@ -1,21 +1,22 @@
/**
* TaskBonusPanel —— 「做任务换额度」任务列表面板(P0-4 / FR-BILL-03/04)。
* TaskBonusPanel —— 「做任务换额度」任务列表面板(P0-4 / FR-BILL-03/04,T2b 闭环)。
*
* 供弹层/区块复用:ProfileContent 入口卡、ChatPage 耗尽面板、试穿/发型软限制
* 耗尽面板都以 bindSheet 承载本组件。
*
* 纪律:
* - 任务表本地静态(icon/名称/说明),奖励数值一律不硬编码——展示文案统一
* 「领额度」,实际到账以 claim 响应 amount 为准;
* - 任务表本地静态(产品方案 §3.2 定稿 4 项,invite_friend/profile_complete 已
* 下架);desc 里的奖励数值是定稿展示文案,实际到账仍以 claim 响应 amount 为准;
* - 文案纪律(FR-BILL-07):不出现价格/购买/会员/升级字样;
* - 已领判定走 core/TaskBonusState(preferences meida_task_bonus 的共享读写,
* 与报告页反馈自动 claim 互通),跨重启保留;claim 返回 40910(本周期已领)
* 时同步标记——本地状态永远以服务端口径为准;
* - 本迭代只接 sign_in 的真实 claim 链路;其余 4 项为「去完成」轻引导 toast,
* 完成判定属后续迭代。
* 与报告页反馈/分享/首测的自动 claim 互通),跨重启保留;claim 返回 40910
* (本周期已领)时同步标记——本地状态永远以服务端口径为准;
* - 「去完成」真实跳转:first_report → 速测落地页;report_feedback / share_report
* → 报告链路页(无报告时给引导 toast,不报错);sign_in 点击即领。
* first_report 在服务端已有完成报告且未领过时直接变为「领额度」走 claim。
*/
import { promptAction } from '@kit.ArkUI'
import { promptAction, router } from '@kit.ArkUI'
import { ApiClient } from '../../core/ApiClient'
import { ApiError } from '../../core/ApiTypes'
import { TaskBonusState, PERIOD_DAY, PERIOD_LIFETIME } from '../../core/TaskBonusState'
@@ -37,7 +38,17 @@ interface ClaimWire {
remaining: number
}
/** 任务定义。direct=true 表示点击即调 claim;false 为「去完成」轻引导。 */
/** GET /api/v1/reports 列表元素(本面板只消费这两个字段)。 */
interface ReportListItemWire {
report_id: string
status: string
}
/**
* 任务定义。direct=true 表示点击即调 claim(签到);
* false 为「去完成」跳转(first_report 在服务端已有完成报告时升格为可领)。
* successToast 是领取成功的 toast 前缀,amount 取响应值拼接。
*/
interface TaskDef {
key: string
icon: string
@@ -45,36 +56,37 @@ interface TaskDef {
desc: string
period: string // day / lifetime
direct: boolean
guide: string // direct=false 时的引导 toast 文案
successToast: string
}
/** 五项任务(task_key 与后端 TASK_BONUS_RULES_JSON 对齐)。 */
const TASKS: TaskDef[] = [
/** 每日任务(task_key 与后端 TASK_BONUS_RULES_JSON 定稿目录对齐)。 */
const DAILY_TASKS: TaskDef[] = [
{
key: 'sign_in', icon: '✓', name: '每日签到', desc: '每天来一次,领 AI 对话额度',
period: PERIOD_DAY, direct: true, guide: ''
key: 'sign_in', icon: '✓', name: '每日签到', desc: '每日 · 领 10 轮 AI 对话额度',
period: PERIOD_DAY, direct: true, successToast: '签到成功'
},
{
key: 'profile_complete', icon: '☺', name: '完善画像资料', desc: '补全资料,建议更合身',
period: PERIOD_LIFETIME, direct: false, guide: '完善资料功能即将上线'
key: 'report_feedback', icon: '✎', name: '给报告结论打反馈', desc: '每日 · 领 2 次发型试戴额度',
period: PERIOD_DAY, direct: false, successToast: '感谢反馈'
},
{
key: 'report_feedback', icon: '✎', name: '给报告结论打反馈', desc: '告诉我们要不要调整',
period: PERIOD_DAY, direct: false, guide: '在报告页给结论打反馈可领额度'
},
{
key: 'share_report', icon: '↗', name: '分享我的报告', desc: '把形象报告分享给朋友',
period: PERIOD_DAY, direct: false, guide: '生成报告后可分享领额度'
},
{
key: 'invite_friend', icon: '♡', name: '邀请好友', desc: '邀请朋友一起用美搭',
period: PERIOD_DAY, direct: false, guide: '邀请功能即将上线'
key: 'share_report', icon: '↗', name: '分享我的报告', desc: '每日 · 领 3 次换装额度',
period: PERIOD_DAY, direct: false, successToast: '感谢分享'
}
]
/** 本周期已领 / 奖励封顶 / 未知任务(与后端 errors.py 对齐)。 */
/** 新手任务(一次性)。 */
const NEWBIE_TASKS: TaskDef[] = [
{
key: 'first_report', icon: '★', name: '完成首次形象速测', desc: '一次性 · 领 1 次报告重生成额度',
period: PERIOD_LIFETIME, direct: false, successToast: '形象速测完成'
}
]
/** 本周期已领 / 奖励封顶 / 任务未完成 / 未知任务(与后端 errors.py 对齐)。 */
const CODE_ALREADY_CLAIMED: number = 40910
const CODE_BONUS_CAPPED: number = 40310
const CODE_NOT_ELIGIBLE: number = 40311
@Component
export struct TaskBonusPanel {
@@ -83,15 +95,51 @@ export struct TaskBonusPanel {
@State claimingKey: string = '' // 领取中的 task_key(防空点)
@State claimsTick: number = 0 // claims 变更计数:驱动已领态重渲染
@State firstReportReady: boolean = false // 服务端已有完成报告:first_report 可直接领
@State latestReportId: string = '' // 报告链路跳转用(优先最新一份已完成报告)
aboutToAppear(): void {
this.hydrate()
}
/** 已领态由 TaskBonusState 共享(报告页反馈自动 claim 也要互通),这里只 hydrate + 触发重渲染。 */
/** 已领态由 TaskBonusState 共享(报告页自动 claim 也要互通):hydrate + 曝光埋点 + 报告态探测。 */
private async hydrate(): Promise<void> {
await TaskBonusState.init()
this.claimsTick += 1
Tracker.track('task_bonus.panel_view')
this.refreshReportState()
}
/** 拉我的报告列表,定 first_report 可领态与报告链路跳转目标;失败静默(点击时再实时拉)。 */
private async refreshReportState(): Promise<void> {
try {
const list = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports')
const succeeded: ReportListItemWire | undefined =
list.find((r: ReportListItemWire) => r.status === 'succeeded')
this.firstReportReady = succeeded !== undefined
this.latestReportId = succeeded !== undefined
? succeeded.report_id
: (list.length > 0 ? list[0].report_id : '')
} catch (_e) {
// 网络失败不打扰:面板其余功能照常,「去完成」点击时再拉一次
}
}
/** 实时取最新报告 id(跳转前兜底再拉一次,hydrate 失败也能补救);无报告返回 ''。 */
private async fetchLatestReportId(): Promise<string> {
try {
const list = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports')
const succeeded: ReportListItemWire | undefined =
list.find((r: ReportListItemWire) => r.status === 'succeeded')
const rid: string = succeeded !== undefined
? succeeded.report_id
: (list.length > 0 ? list[0].report_id : '')
this.latestReportId = rid
this.firstReportReady = succeeded !== undefined
return rid
} catch (_e) {
return ''
}
}
private isClaimed(task: TaskDef): boolean {
@@ -115,12 +163,36 @@ export struct TaskBonusPanel {
if (this.isClaimed(task) || this.claimingKey !== '') {
return
}
if (!task.direct) {
// 「去完成」轻引导:本迭代不接真实完成判定
this.toast(task.guide)
Tracker.track('task_bonus.task_tap', { 'task_key': task.key })
if (task.direct) {
this.claim(task)
return
}
this.claim(task)
if (task.key === 'first_report') {
if (this.firstReportReady) {
// 服务端已有完成报告且未领过:直接领(服务端还会再校验一次 rp_report)
this.claim(task)
} else {
router.pushUrl({ url: 'pages/DiagnosisLandingPage' })
}
return
}
// report_feedback / share_report:跳报告链路页(反馈在报告结果页,分享经
// 报告结果页导出 PDF 后到预览页;ReportPdfPage 需要本地 pdfPath 无法直达)
this.goReportChain()
}
private async goReportChain(): Promise<void> {
let rid: string = this.latestReportId
if (rid === '') {
rid = await this.fetchLatestReportId()
}
if (rid === '') {
this.toast('先生成你的形象速测,再来反馈')
return
}
const params: Record<string, Object> = { 'reportId': rid }
router.pushUrl({ url: 'pages/ReportResultPage', params: params })
}
private async claim(task: TaskDef): Promise<void> {
@@ -129,7 +201,7 @@ export struct TaskBonusPanel {
const req: Record<string, Object> = {}
const d = await ApiClient.post<ClaimWire>(`/api/v1/quota/tasks/${task.key}/claim`, req)
await this.markClaimed(task)
this.toast(`签到成功,额度 +${d.amount}`)
this.toast(`${task.successToast},额度 +${d.amount}`)
Tracker.track('task_bonus.claim_result', { 'task_key': task.key, 'ok': true })
this.onClaimed(d.quota_key)
} catch (e) {
@@ -139,7 +211,9 @@ export struct TaskBonusPanel {
await this.markClaimed(task)
this.toast('今天已经领过啦')
} else if (err.code === CODE_BONUS_CAPPED) {
this.toast('今日任务奖励已达上限')
this.toast('今日任务奖励已达上限,明天再来')
} else if (err.code === CODE_NOT_ELIGIBLE) {
this.toast('先完成任务,再来领奖励')
} else {
this.toast(err.message)
}
@@ -157,7 +231,10 @@ export struct TaskBonusPanel {
if (this.claimingKey === task.key) {
return '领取中…'
}
return task.direct ? '领额度' : '去完成'
if (task.direct || (task.key === 'first_report' && this.firstReportReady)) {
return '领额度'
}
return '去完成'
}
@Builder
@@ -197,6 +274,15 @@ export struct TaskBonusPanel {
.alignItems(VerticalAlign.Center)
}
@Builder
GroupHeader(title: string) {
Text(title)
.fontSize(13)
.fontColor(COLOR_TEXT_SECONDARY)
.width('100%')
.margin({ top: 8, bottom: 2 })
}
build() {
Column({ space: 4 }) {
Text('做任务换额度')
@@ -205,7 +291,12 @@ export struct TaskBonusPanel {
.fontColor(COLOR_TEXT_PRIMARY)
.width('100%')
.margin({ bottom: 4 })
ForEach(TASKS, (task: TaskDef) => {
this.GroupHeader('每日任务')
ForEach(DAILY_TASKS, (task: TaskDef) => {
this.TaskRow(task)
}, (task: TaskDef) => `${task.key}_${this.isClaimed(task)}`)
this.GroupHeader('新手任务')
ForEach(NEWBIE_TASKS, (task: TaskDef) => {
this.TaskRow(task)
}, (task: TaskDef) => `${task.key}_${this.isClaimed(task)}`)
Text('奖励以实际到账为准,额度每日重置')
@@ -22,7 +22,7 @@ import { COLOR_PRIMARY } from '../../core/Theme'
const CONSENT_STORAGE_KEY: string = 'face_photo_consent_granted'
const CONSENT_PREF_NAME: string = 'meida_face_consent'
const CONSENT_RECORD_KEY: string = 'record'
const CONSENT_VERSION: string = 'v1'
const CONSENT_VERSION: string = 'v2' // v2:处理方/保存文案按本地磁盘降级路线改为属实表述(2026-09-12)
const TEXT_PRIMARY: string = '#222222'
const TEXT_SECONDARY: string = '#8A9099'
const ACCENT: string = COLOR_PRIMARY
@@ -103,6 +103,8 @@ export class FaceConsentStore {
export struct FacePhotoConsent {
onAgree: () => void = () => {}
onRefuse: () => void = () => {}
/** 用途说明行:默认试穿/发型场景口径;诊断报告等场景传入自己的文案。 */
usageText: string = '用途:照片仅用于为你生成试穿/发型效果图,不用于识别你的身份,也不做其他用途的分析。'
@Builder
ConsentLine(bullet: string, text: string) {
@@ -128,9 +130,9 @@ export struct FacePhotoConsent {
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
Column({ space: 8 }) {
this.ConsentLine('·', '用途:照片仅用于为你生成试穿/发型效果图,不用于识别你的身份,也不做其他用途的分析。')
this.ConsentLine('·', '处理方:照片将上传至第三方 AI 图像服务完成本次生成,不会共享给其他方。')
this.ConsentLine('·', '保存:原图在生成完成后即删除;你保存到相册的效果图可随时自行删除。')
this.ConsentLine('·', this.usageText)
this.ConsentLine('·', '处理方:照片将上传至美搭的图像服务完成本次生成,不会共享给其他方。')
this.ConsentLine('·', '保存:上传的原图不会公开展示;你保存到相册的效果图可随时自行删除。')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
@@ -115,10 +115,20 @@ export struct ReportListTab {
ReportItem(item: ReportListItemWire) {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(`形象报告 v${item.version ?? 1}`)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(TEXT_PRIMARY)
Row({ space: 6 }) {
Text(item.tier === 'full' ? `深度诊断报告 v${item.version ?? 1}` : `形象报告 v${item.version ?? 1}`)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(TEXT_PRIMARY)
if (item.tier === 'full') {
Text('深度')
.fontSize(10)
.fontColor(ACCENT)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6)
.backgroundColor('#F7E3EA')
}
}
Text(this.reportTime(item))
.fontSize(12)
.fontColor(TEXT_SECONDARY)
@@ -62,15 +62,62 @@ export interface ValidationWire {
degraded?: boolean // true = 规则引擎降级生成,UI 需黄条透明提示
}
/** 色板单色(result.full.palette.{best,neutral,avoid}[];hex 为 '#RRGGBB') */
export interface PaletteColorWire {
name?: string
hex?: string
}
/** 十二季色彩定位(result.full.season;analysis 为 LLM 季型解析文案) */
export interface FullSeasonWire {
code?: string
detailed?: string
name_cn?: string
analysis?: string
}
export interface FullPaletteWire {
best?: PaletteColorWire[]
neutral?: PaletteColorWire[]
avoid?: PaletteColorWire[]
}
export interface FullOutfitWire {
name?: string
points?: string[]
}
export interface FullSceneWire {
key?: string
title?: string
outfits?: FullOutfitWire[]
}
export interface PlanWeekWire {
week?: number
title?: string
tasks?: string[]
}
/** 深度诊断(tier=full)增量内容:色板 / 场景穿搭 / 30 天计划 */
export interface FullResultWire {
season?: FullSeasonWire
palette?: FullPaletteWire
scenes?: FullSceneWire[]
plan30?: PlanWeekWire[]
}
export interface ReportResultWire {
diagnosis?: DiagnosisWire
copy?: CopyWire
validation?: ValidationWire
full?: FullResultWire
}
/** GET /api/v1/reports/{id} 的响应 */
export interface ReportDetailWire {
report_id?: string
tier?: string // 'basic' | 'full'
status?: string
version?: number
result?: ReportResultWire
@@ -264,12 +311,6 @@ export function randomHex(len: number): string {
return out
}
/** 模拟照片对象键:report_photo/{userId}/{随机}.jpg(V1 骨架,COS 直传未接入) */
export function buildMockPhotoKey(userId: string): string {
const uid = userId === '' ? 'anonymous' : userId
return `report_photo/${uid}/${randomHex(12)}.jpg`
}
/** 32 维按 status 分两组:已分析(ok)与未覆盖(skipped,含 status 缺失的兜底) */
export function splitDimensions(dims: DimensionWire[]): DimensionGroups {
const groups: DimensionGroups = { analyzed: [], skipped: [] }
@@ -8,6 +8,8 @@
* - 不依赖 queueMode:官方 API 文档(0=flush)与 Codelabs(0=排队)说法
* 互相矛盾,新朗读前显式 engine.stop() 打断,语义自持
* - 播报状态回调 onStateChanged:页面据此复位 🔊 图标
* - V3:speak 前 sanitizeForSpeech 净化——不念 Markdown/英文标点/表情符号
* (中文停顿标点保留做自然停顿)
*
* 模型:offline 中文引擎(online: 1,系统内置);模拟器通常无此服务,须真机。
* 日志 tag MEIDA_TTS:hdc shell hilog | grep MEIDA_TTS
@@ -18,6 +20,18 @@ import { textToSpeech } from '@kit.CoreSpeechKit'
const SEGMENT_MAX_CHARS: number = 400
const SEGMENT_FALLBACK_MS: number = 150000
/**
* 朗读净化(V3):剥掉会被引擎念出来的符号——Markdown 标记(**、#、- 列表符)、
* 英文标点、表情等,只留中英文、数字与中文停顿标点(,。;:!?、……靠它们做自然停顿,
* 引擎不会逐字念)。顾问回答被要求不写 Markdown(agent_chat v1),这里是双保险。
*/
function sanitizeForSpeech(text: string): string {
return text
.replace(/[^一-龥a-zA-Z0-9\s。,、;:!?]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
/** 播报状态变化回调(speaking=false 表示本轮朗读完全结束或被停止)。 */
export type TtsStateChanged = (speaking: boolean) => void
@@ -78,7 +92,7 @@ export class TtsEngine {
* 由调用方决定是否提示(自动朗读静默、手动点击提示)。
*/
async speak(text: string): Promise<void> {
const trimmed: string = text.trim()
const trimmed: string = sanitizeForSpeech(text)
if (trimmed.length === 0) {
return
}
+39 -6
View File
@@ -4,8 +4,10 @@
* - access 短令牌放内存 + preferences(local 明文可接受:V1 骨架,无支付;
* 正式版应迁 @ohos.security.asset,注释留迁移点)
* - refresh 一次性轮换:每次 refresh 换新对
* - dev-token 登录:POST /auth/dev-token(后端 DEV_AUTH_ENABLED 本地可用;
* 华为账号/短信登录是后续通道,接口按多通道预留)
* - dev-token 登录:POST /auth/dev-token(后端 DEV_AUTH_ENABLED 本地可用,
* 挪进登录页「高级设置(开发者)」折叠区,仅供本机联调)
* - 华为账号登录:POST /auth/huawei(Account Kit idToken 换双令牌)
* - 邮箱验证码登录:POST /auth/email/code + /auth/email/login
* - ArkTS 约束:所有请求/响应都是具名 interface;字面量先声明类型再传
*/
@@ -29,6 +31,11 @@ interface AuthWireResp {
user_id: string
}
interface EmailCodeWireResp {
sent: boolean
cooldown_seconds: number
}
const PREF_NAME = 'meida_auth'
const KEY_TOKENS = 'tokens'
const KEY_BASE_URL = 'base_url'
@@ -74,12 +81,38 @@ export class AuthStore {
return await AuthStore.prefs!.get(KEY_BASE_URL, DEFAULT_BASE_URL) as string
}
/** dev-token 登录(本地后端开发通道)。失败由 ApiClient 抛 ApiError。 */
/** dev-token 登录(本机联调通道,入口在登录页高级设置折叠区)。 */
static async loginByDevToken(deviceKey: string): Promise<void> {
const req: Record<string, Object> = { 'device_key': deviceKey }
// 登录本身不能走带鉴权的 post()——ApiClient 对未登录一律 40100 短路,
// 登录接口会被自己的守卫拦死(鸡生蛋)。必须走无鉴权通道。
const data = await ApiClient.postWithoutAuth<AuthWireResp>('/api/v1/auth/dev-token', req)
await AuthStore.loginAndSave('/api/v1/auth/dev-token', req)
}
/** 华为账号登录:Account Kit idToken 换双令牌。失败由 ApiClient 抛 ApiError。 */
static async loginByHuaweiIdToken(idToken: string): Promise<void> {
const req: Record<string, Object> = { 'id_token': idToken }
await AuthStore.loginAndSave('/api/v1/auth/huawei', req)
}
/** 发送邮箱验证码。成功返回服务端给的 cooldown_seconds(前端冷却倒计时用)。 */
static async sendEmailCode(email: string): Promise<number> {
const req: Record<string, Object> = { 'email': email }
const data = await ApiClient.postWithoutAuth<EmailCodeWireResp>('/api/v1/auth/email/code', req)
return data.cooldown_seconds
}
/** 邮箱验证码登录:同双令牌持久化。 */
static async loginByEmail(email: string, code: string): Promise<void> {
const req: Record<string, Object> = { 'email': email, 'code': code }
await AuthStore.loginAndSave('/api/v1/auth/email/login', req)
}
/**
* 各登录通道公共收尾:无鉴权通道换双令牌并持久化。
* 登录本身不能走带鉴权的 post()——ApiClient 对未登录一律 40100 短路,
* 登录接口会被自己的守卫拦死(鸡生蛋)。必须走无鉴权通道。
*/
private static async loginAndSave(path: string, req: Record<string, Object>): Promise<void> {
const data = await ApiClient.postWithoutAuth<AuthWireResp>(path, req)
await AuthStore.saveTokens(AuthStore.fromWire(data))
}
@@ -0,0 +1,76 @@
/**
* 华为账号授权登录封装(Account Kit)。
*
* 本机 SDK 声明为准(sdk/default/hms/ets/api/@hms.core.authentication.d.ts):
* - `new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest()`
* - `new authentication.AuthenticationController(context).executeRequest(request)`
* → Promise<AuthenticationResponse>,成功时 as AuthorizationWithHuaweiIDResponse
* 取 `data.idToken`(即后端 POST /auth/huawei 的 id_token)
* - 用户取消授权:executeRequest reject,BusinessError.code = 1001502012
* (AuthenticationErrorCode.USER_CANCELED),UI 层静默处理
*
* client_id 来自 module.json5 模块级 metadata(AGC 平台申请后填入);
* 仍是占位值时 LoginPage 直接拦截给提示,不会真正调 SDK。
*/
import { authentication } from '@kit.AccountKit'
import { bundleManager } from '@kit.AbilityKit'
import { BusinessError } from '@kit.BasicServicesKit'
import { ContextHolder } from './ContextHolder'
/** module.json5 metadata 里的 client_id 占位值(未接 AGC 前保持此值)。 */
export const HUAWEI_CLIENT_ID_PLACEHOLDER: string = 'TODO_AGC_CLIENT_ID'
/** Account Kit 错误码:用户取消授权。 */
const ERR_USER_CANCELED: number = 1001502012
export class HuaweiLogin {
/** 读取本模块 module.json5 里配置的 client_id;读取失败或未配置返回占位值。 */
static async resolveClientId(): Promise<string> {
try {
const flag = bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_HAP_MODULE |
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_METADATA
const info = await bundleManager.getBundleInfoForSelf(flag)
for (const hap of info.hapModulesInfo) {
for (const item of hap.metadata) {
if (item.name === 'client_id') {
return item.value
}
}
}
} catch (_e) {
// 读取失败按未配置处理,页面给「暂未开放」提示
}
return HUAWEI_CLIENT_ID_PLACEHOLDER
}
/** 是否已完成 AGC 配置(真实 client_id 才能拉起授权页)。 */
static isConfigured(clientId: string): boolean {
return clientId !== '' && clientId !== HUAWEI_CLIENT_ID_PLACEHOLDER
}
/** 是否用户主动取消授权(取消不算错误,UI 静默返回)。 */
static isUserCancelled(e: Object): boolean {
if (!(e instanceof Error)) {
return false
}
const be = e as BusinessError
return be.code === ERR_USER_CANCELED
}
/** 拉起华为账号授权页(非静默,scopes: openid + profile),返回 idToken。 */
static async requestIdToken(): Promise<string> {
const controller = new authentication.AuthenticationController(ContextHolder.get())
const provider = new authentication.HuaweiIDProvider()
const request = provider.createAuthorizationWithHuaweiIDRequest()
request.scopes = ['openid', 'profile']
request.forceAuthorization = true
const response = await controller.executeRequest(request)
const authResp = response as authentication.AuthorizationWithHuaweiIDResponse
const idToken = authResp.data?.idToken
if (idToken === undefined || idToken === '') {
throw new Error('华为授权成功但未返回登录凭证')
}
return idToken
}
}
@@ -18,6 +18,7 @@
import { image } from '@kit.ImageKit'
import { util } from '@kit.ArkTS'
import { BusinessError } from '@kit.BasicServicesKit'
import { photoAccessHelper } from '@kit.MediaLibraryKit'
import { cameraPicker } from '@kit.CameraKit'
import { camera } from '@kit.CameraKit'
@@ -70,6 +71,12 @@ export class ImageCodec {
)
}
/** 用户取消判定:多数路径取消返回空数组,但预览态/部分 ROM 会 reject 13900042。 */
private static isUserCancel(e: Object): boolean {
const code: number | undefined = (e as BusinessError).code
return code === 13900042
}
/** 相册选 1 张。用户取消返回 undefined。 */
static async pickFromGallery(): Promise<PickedImage | undefined> {
const picker = new photoAccessHelper.PhotoViewPicker()
@@ -90,6 +97,9 @@ export class ImageCodec {
return { uri: res.photoUris[0], width: 0, height: 0 }
} catch (e) {
hilog.error(IMG_DOMAIN, IMG_TAG, 'gallery: pick failed: %{public}s', String(e))
if (ImageCodec.isUserCancel(e)) {
return undefined // 取消不报错(页面保持原状,不弹「读取照片失败」)
}
throw e as Error
}
}
@@ -109,6 +119,9 @@ export class ImageCodec {
return { uri: res.resultUri, width: 0, height: 0 }
} catch (e) {
hilog.error(IMG_DOMAIN, IMG_TAG, 'camera: pick failed: %{public}s', String(e))
if (ImageCodec.isUserCancel(e)) {
return undefined // 取消不报错
}
throw e as Error
}
}
+14 -12
View File
@@ -26,28 +26,30 @@ interface HairFormPart {
const HAIR_BOUNDARY: string = '----MeidaHairForm7MA4YWxkTrZu0gW'
/**
* 发型性别映射:服务端 hairstyles 接口无性别字段,60 款由缩略图视觉分类
* (2026-09-02,男 26 / 女 34,抽检校准)。新增未知 id 返回 unknown(前端两个 Tab 都展示)。
* 发型性别映射:服务端 hairstyles 接口无性别字段,60 款由缩略图逐张视觉分类。
* 2026-09-11 全量重分类(男 30 / 女 30,无 unknown):缩略图按男模/女模实拍渲染,
* 以模特呈现为准(此前 2026-09-02 版抽检不准,男/女 Tab 互串 18 款,用户反馈后重分)。
* 新增未知 id 返回 unknown(前端两个 Tab 都展示)。
*/
const MALE_HAIRSTYLE_IDS: Set<string> = new Set<string>([
'1953267161121464322', '1953267288284372994', '1953270724765720577', '1953271922151432194',
'1953272913265467394', '1953288800932503554', '1954536242109784066', '1954536297776586754',
'1954536402516746241', '1954536893304840194', '1954536956919848962', '1954537191729569793',
'1980314032369082369', '1980314209322573825', '1980314290419441665', '1980314381058351106',
'1980368588293578754', '1980413320612847618', '1980415346071609345', '1980415897056354306',
'1981177324700549122', '1981177378714796034', '1981177477423546370', '1981177594750812162',
'1980312945301954561', '1980314209322573825', '1980314710101499906', '1980314764421931009',
'1980314868713299969', '1980367781510815746', '1980367800787836929', '1980367852847538177',
'1980367968627105793', '1980368109866098690', '1980368205752082434', '1980368465631158274',
'1980368588293578754', '1981177378714796034', '1981177477423546370', '1981177594750812162',
'1981177742742634497', '1981177931859607554'
])
const FEMALE_HAIRSTYLE_IDS: Set<string> = new Set<string>([
'1953270523594317826', '1954537022875279362', '1954537085882114050', '1954537709436706817',
'1954537743532204034', '1954537961069780994', '1954538119216013313', '1954538156499181569',
'1954538259330932738', '1954538335306555393', '1980312945301954561', '1980313052302843905',
'1980313166576656385', '1980313210109337601', '1980313433502162946', '1980313576506957825',
'1980313740403580930', '1980313819915001858', '1980314477720281090', '1980314710101499906',
'1980314764421931009', '1980314868713299969', '1980315179934851074', '1980315238260842497',
'1980361636742209537', '1980367742952579074', '1980367781510815746', '1980367800787836929',
'1980367852847538177', '1980367968627105793', '1980368109866098690', '1980368205752082434',
'1980368368847593473', '1980368465631158274'
'1954538259330932738', '1954538335306555393', '1980313052302843905', '1980313166576656385',
'1980313210109337601', '1980313433502162946', '1980313576506957825', '1980313740403580930',
'1980313819915001858', '1980314032369082369', '1980314290419441665', '1980314381058351106',
'1980314477720281090', '1980315179934851074', '1980315238260842497', '1980361636742209537',
'1980367742952579074', '1980368368847593473', '1980413320612847618', '1980415346071609345',
'1980415897056354306', '1981177324700549122'
])
const TRYON_DOMAIN: number = 0x0200
+97
View File
@@ -0,0 +1,97 @@
/**
* UploadApi:诊断照片直传本机后端(无 COS 四件套的本地磁盘降级路线)。
*
* 后端 POST /api/v1/uploads/direct 收原始字节(后端仓库未引入
* python-multipart,不收 multipart 表单;这也正好绕开真机
* http.multiFormDataList 的 0 字节 bug):
* - body 即图片字节(ImageCodec.toJpegBuffer 压缩后的 JPEG)
* - Content-Type 头申报类型;scene 走 query(默认 report_photo)
* - 响应走统一信封,data.object_key 回传给 POST /api/v1/reports
*
* 错误一律抛 ApiError(与 ApiClient 约定一致,UI 层只 catch ApiError)。
* 鉴权头从 AuthStore 取;401 自动刷新不在此处做(与 TryonApi 同口径,
* 令牌过期由下一次 ApiClient 请求兜底刷新)。
*/
import http from '@ohos.net.http'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { ApiError } from './ApiTypes'
import { AuthStore } from './AuthStore'
const UPLOAD_DOMAIN: number = 0x0201
const UPLOAD_TAG: string = 'MEIDA_UPLOAD'
const UPLOAD_READ_TIMEOUT_MS: number = 60000 // 压缩后单张 ~300KB,60s 覆盖弱网
/** POST /uploads/direct 响应的 data 段(字段名对齐后端 JSON)。 */
export interface DirectUploadData {
object_key?: string
size_bytes?: number
content_type?: string
}
/** 直传响应信封(同 ApiEnvelope 形状,data 可选以防御后端缺省)。 */
interface DirectUploadWire {
code: number
message: string
data?: DirectUploadData
hint?: string
}
export class UploadApi {
/**
* 上传诊断照片(已压缩 JPEG 字节),返回真实 object_key。
* 失败抛 ApiError(message/hint 用服务端信封原文,可直接展示重试)。
*/
static async uploadReportPhoto(imageBuf: ArrayBuffer): Promise<string> {
const token = AuthStore.getAccessToken()
if (token === undefined) {
// 与 ApiClient 同一纪律:未登录直接短路,省一次必然 401 的往返
throw new ApiError(40100, '请先登录', '登录后即可继续')
}
const base = await AuthStore.getBaseUrl()
const url = `${base}/api/v1/uploads/direct?scene=report_photo`
const client = http.createHttp()
try {
const header: Record<string, string> = {
'Content-Type': 'image/jpeg',
'Authorization': `Bearer ${token}`
}
hilog.info(UPLOAD_DOMAIN, UPLOAD_TAG, 'direct post %{public}s, %{public}d bytes', url, imageBuf.byteLength)
const resp = await client.request(url, {
method: http.RequestMethod.POST,
header: header,
extraData: imageBuf,
expectDataType: http.HttpDataType.STRING,
connectTimeout: 10000,
readTimeout: UPLOAD_READ_TIMEOUT_MS
})
const bodyText: string = (resp.result ?? '') as string
hilog.info(UPLOAD_DOMAIN, UPLOAD_TAG, 'direct resp code=%{public}d body=%{public}s',
resp.responseCode, bodyText.substring(0, 300))
let wire: DirectUploadWire
try {
wire = JSON.parse(bodyText) as DirectUploadWire
} catch (_p) {
// 网关/代理层错误:body 不是信封 JSON,给统一话术(对齐 ApiClient)
throw new ApiError(50000, '服务开小差了', '请稍后重试', resp.responseCode)
}
const key = wire.data?.object_key ?? ''
if (wire.code === 0 && key !== '') {
return key
}
if (wire.code !== 0) {
throw new ApiError(wire.code, wire.message, wire.hint, resp.responseCode)
}
// code=0 但没有 object_key:契约破损,按服务端异常处理
throw new ApiError(50000, '照片上传结果异常', '请稍后重试', resp.responseCode)
} catch (e) {
if (e instanceof ApiError) {
throw e
}
hilog.error(UPLOAD_DOMAIN, UPLOAD_TAG, 'direct request failed: %{public}s', String(e))
throw new ApiError(50000, '照片上传失败,请检查网络', '请稍后重试')
} finally {
client.destroy()
}
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ interface QuotaWireResp {
quota_key: string
used: number
limit_value: number
bonus_granted: boolean
bonus_granted: number
remaining: number
reset_at: string
}
@@ -14,6 +14,8 @@
* params 键名精确为 gender/birthday/height_cm/weight_kg,值全部为 string
* (接收端 ReportCreatePage 按 string 读取);跳转失败页内红字兜底。
* - 职业仅本地留存(AppStorage),不进提交体(需求 §3.6/验收 4)。
* - 全部字段做 AppStorage 草稿(f3_diagnosis_*):aboutToAppear 恢复、
* 每次变更写回(write-through),离开/重进诊断流程不丢已填内容。
*/
import { router, componentUtils } from '@kit.ArkUI'
@@ -50,6 +52,11 @@ const BIRTHDAY_FALLBACK_SELECTED: Date = new Date(2000, 0, 1)
/** 职业为本地留存字段(后端问卷 schema 无 occupation,Phase B 随 schema 扩展提交) */
const OCCUPATION_STORAGE_KEY: string = 'f3_diagnosis_occupation'
// 表单草稿(F3 跨页面保留):离开/重进诊断流程不丢已填内容,提交成功也不清
const DRAFT_GENDER_KEY: string = 'f3_diagnosis_gender'
const DRAFT_BIRTHDAY_KEY: string = 'f3_diagnosis_birthday'
const DRAFT_HEIGHT_KEY: string = 'f3_diagnosis_height'
const DRAFT_WEIGHT_KEY: string = 'f3_diagnosis_weight'
function buildRangeOptions(min: number, max: number): string[] {
const arr: string[] = []
@@ -78,6 +85,39 @@ struct DiagnosisFormPage {
@State navError: string = ''
private scroller: Scroller = new Scroller()
aboutToAppear(): void {
// 恢复草稿:用户离开诊断流程(回首页/切页)后重进不丢已填内容
const gender = AppStorage.get<string>(DRAFT_GENDER_KEY)
if (gender !== undefined) {
this.gender = gender
}
const birthday = AppStorage.get<string>(DRAFT_BIRTHDAY_KEY)
if (birthday !== undefined) {
this.birthday = birthday
}
const height = AppStorage.get<string>(DRAFT_HEIGHT_KEY)
if (height !== undefined) {
this.heightInput = height
}
const weight = AppStorage.get<string>(DRAFT_WEIGHT_KEY)
if (weight !== undefined) {
this.weightInput = weight
}
const occupation = AppStorage.get<string>(OCCUPATION_STORAGE_KEY)
if (occupation !== undefined) {
this.occupation = occupation
}
}
/** 草稿写回(write-through):每次字段变更即落 AppStorage。 */
private saveDraft(): void {
AppStorage.setOrCreate<string>(DRAFT_GENDER_KEY, this.gender)
AppStorage.setOrCreate<string>(DRAFT_BIRTHDAY_KEY, this.birthday)
AppStorage.setOrCreate<string>(DRAFT_HEIGHT_KEY, this.heightInput)
AppStorage.setOrCreate<string>(DRAFT_WEIGHT_KEY, this.weightInput)
AppStorage.setOrCreate<string>(OCCUPATION_STORAGE_KEY, this.occupation)
}
/** 由出生日期派生只读年龄(周岁)。 */
private ageOf(): number {
if (this.birthday === '') {
@@ -114,6 +154,7 @@ struct DiagnosisFormPage {
onDateAccept: (value: Date): void => {
this.birthday = formatDate(value)
this.birthdayErr = ''
this.saveDraft()
}
})
}
@@ -131,6 +172,7 @@ struct DiagnosisFormPage {
if (idx >= 0 && idx < HEIGHT_OPTIONS.length) {
this.heightInput = HEIGHT_OPTIONS[idx]
this.heightErr = ''
this.saveDraft()
}
}
})
@@ -148,6 +190,7 @@ struct DiagnosisFormPage {
if (idx >= 0 && idx < WEIGHT_OPTIONS.length) {
this.weightInput = WEIGHT_OPTIONS[idx]
this.weightErr = ''
this.saveDraft()
}
}
})
@@ -215,8 +258,8 @@ struct DiagnosisFormPage {
this.scrollToFirstError()
return
}
// 职业仅本地留存,不进提交体(需求 §3.6/验收 4)
AppStorage.setOrCreate<string>(OCCUPATION_STORAGE_KEY, this.occupation)
// 职业仅本地留存,不进提交体(需求 §3.6/验收 4);整份草稿一并写回
this.saveDraft()
// 四个值全部为 string,接收端 ReportCreatePage 按 string 读取
const params: Record<string, Object> = {
'gender': this.gender,
@@ -260,6 +303,7 @@ struct DiagnosisFormPage {
if (idx >= 0 && idx < GENDER_OPTIONS.length) {
this.gender = GENDER_OPTIONS[idx]
this.genderErr = ''
this.saveDraft()
}
}
})
@@ -278,6 +322,7 @@ struct DiagnosisFormPage {
.margin({ right: 8, bottom: 8 })
.onClick(() => {
this.occupation = this.occupation === o ? '' : o
this.saveDraft()
})
}, (o: string) => o)
}
@@ -453,14 +453,21 @@ struct FittingRoomPage {
return false
}
/** 回到选人台:清掉上一轮生成/保存的残留。 */
/** 清掉上一轮生成/保存的残留(resultSrc 必须一起清,否则效果图仍挂在预览区)。 */
private resetResult(): void {
this.resultUrl = ''
this.resultLocalPath = ''
this.resultSrc = ''
this.showOriginal = false
this.statusText = ''
}
/** 「再来一次」:连本人照一起清,回到初始进入界面(可重新拍照/相册选图)。 */
private resetToEntry(): void {
this.humanUri = ''
this.resetResult()
}
/**
* 生成成功即转存本机:下载结果到 filesDir/results/tryon_last.jpg。
* 下载前先清旧文件——request.downloadFile 不能覆盖已存在文件(TryonPage #21 同款坑)。
@@ -1206,7 +1213,7 @@ struct FittingRoomPage {
.fontSize(13)
.fontColor(COLOR_PRIMARY)
.onClick(() => {
this.resetResult()
this.resetToEntry()
})
}
.width('100%')
@@ -1317,7 +1324,7 @@ struct FittingRoomPage {
},
onRetry: (): void => {
this.viewerOpen = false
this.resetResult()
this.resetToEntry()
},
onClose: (): void => {
this.viewerOpen = false
@@ -0,0 +1,471 @@
/**
* HairColorPage —— 换发色独立页(从发型设计页拆出,2026-09 按用户反馈),Phase A 直连线上服务。
*
* 交互:上传面部特写 → 点选发色(8 色圆点)+ 强度滑杆 → 吸底「换发色」按钮触发(5~10s)
* → 图片区「原图/效果图」页签对比(无结果时效果图页签置灰)→ 保存相册。
* 每次都在原图上换色(不在结果上叠加),换色效果可预期;失败保留当前图不覆盖。
* 每日额度与发型试戴共用 hairstyle key(P0-5 合并计数)。
*/
import { router } from '@kit.ArkUI'
import { image } from '@kit.ImageKit'
import { photoAccessHelper } from '@kit.MediaLibraryKit'
import { fileUri } from '@kit.CoreFileKit'
import { TryonApi } from '../core/TryonApi'
import { ImageCodec } from '../core/ImageCodec'
import { ApiClient } from '../core/ApiClient'
import { ApiError } from '../core/ApiTypes'
import { DailyLimiter } from '../core/DailyLimiter'
import { Tracker } from '../core/Tracker'
import { ContextHolder } from '../core/ContextHolder'
import {
COLOR_PRIMARY,
COLOR_PAGE_BG,
COLOR_TEXT_PRIMARY,
COLOR_TEXT_SECONDARY,
COLOR_TEXT_DISABLED,
COLOR_DIVIDER,
COLOR_IMAGE_BG,
COLOR_BADGE_BG
} from '../core/Theme'
import { ColorDot } from '../components/f2/ColorDot'
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
import { TaskBonusPanel } from '../components/f1/TaskBonusPanel'
/** GET /api/v1/quota?quota_key=hairstyle 响应(与发型设计页同一配额池)。 */
interface HairQuotaWire {
quota_key: string
used: number
limit_value: number
bonus_granted: number
remaining: number
reset_at: string | null
}
interface PresetColor {
name: string
rgb: number[]
}
const PRESET_COLORS: PresetColor[] = [
{ name: '自然黑', rgb: [30, 30, 30] },
{ name: '深棕', rgb: [74, 48, 36] },
{ name: '栗色', rgb: [128, 70, 42] },
{ name: '蜜茶', rgb: [156, 108, 66] },
{ name: '亚麻', rgb: [150, 140, 130] },
{ name: '酒红', rgb: [128, 32, 48] },
{ name: '奶奶灰', rgb: [160, 160, 165] },
{ name: '蓝黑', rgb: [40, 50, 70] }
]
@Entry
@Component
struct HairColorPage {
@State photoUri: string = ''
@State generating: boolean = false
@State statusText: string = ''
@State resultPm: image.PixelMap | undefined = undefined
@State resultBase64: string = '' // 保存用它,避免二次编码
@State imageTab: number = 0 // 图片区页签:0=原图 1=效果图(无结果时置灰)
@State colorIdx: number = 1 // 默认深棕
@State ratio: number = 0.9
@State showFaceConsent: boolean = false
// —— 每日额度(P0-5:与发型试戴共用 hairstyle key,免费档 3 次/天)——
@State hairRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余
@State showQuotaBlocked: boolean = false
@State showTaskPanel: boolean = false
private consentPickCamera: boolean = false
private imageTabs: string[] = ['原图', '效果图']
aboutToAppear(): void {
this.loadHairRemaining()
}
/** 额度口径与发型设计页一致:min(服务端, 端侧 DailyLimiter),拉取失败静默用端侧。 */
private async loadHairRemaining(): Promise<void> {
await DailyLimiter.init()
try {
const q = await ApiClient.get<HairQuotaWire>('/api/v1/quota?quota_key=hairstyle')
DailyLimiter.setBonus('hairstyle', q.bonus_granted)
this.hairRemaining = Math.min(q.remaining, DailyLimiter.remaining('hairstyle'))
} catch (_e) {
this.hairRemaining = DailyLimiter.remaining('hairstyle')
}
}
/** RGB 分量转两位 hex(发色圆点展示用)。 */
private hex2(v: number): string {
const s: string = v.toString(16)
return s.length < 2 ? `0${s}` : s
}
/** PRESET_COLORS 的 RGB 转 #RRGGBB:仅圆点展示,发色接口 rgb 传参格式不变。 */
private colorHex(rgb: number[]): string {
return `#${this.hex2(rgb[0])}${this.hex2(rgb[1])}${this.hex2(rgb[2])}`
}
/** 上传面部特写前须单独同意(CR-01:人脸属敏感个人信息)。 */
private pickPhoto(fromCamera: boolean): void {
if (!FaceConsentStore.isGranted()) {
this.consentPickCamera = fromCamera
this.showFaceConsent = true
return
}
this.doPickPhoto(fromCamera)
}
private async doPickPhoto(fromCamera: boolean): Promise<void> {
try {
const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery()
if (picked === undefined) {
return
}
this.photoUri = picked.uri
this.resultPm = undefined
this.resultBase64 = ''
this.imageTab = 0
this.statusText = ''
} catch (e) {
this.statusText = e instanceof ApiError ? e.message : '读取照片失败'
}
}
private onConsentAgree(): void {
FaceConsentStore.grant()
this.showFaceConsent = false
this.doPickPhoto(this.consentPickCamera)
}
private onConsentRefuse(): void {
this.showFaceConsent = false
this.statusText = '未同意照片授权,你可以先看看色板'
}
/** 吸底按钮触发:在当前原图上换发色(不在结果上叠加,效果可预期)。 */
private async applyColor(): Promise<void> {
if (this.photoUri.length === 0 || this.generating) {
return
}
// 每日额度耗尽(P0-5,与试戴合并计数同一 hairstyle key):不发请求,给出路
if (this.hairRemaining === 0) {
Tracker.track('haircolor.generate', { 'ok': false, 'duration_s': 0, 'blocked': true })
this.showQuotaBlocked = true
return
}
const startMs: number = Date.now()
this.generating = true
const c: PresetColor = PRESET_COLORS[this.colorIdx]
this.statusText = `正在换发色(${c.name})…`
try {
const buf = await ImageCodec.toJpegBuffer(this.photoUri)
const rgb: string = `[${c.rgb[0]}, ${c.rgb[1]}, ${c.rgb[2]}]`
this.resultBase64 = await TryonApi.changeHairColor(buf, rgb, this.ratio)
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
this.imageTab = 1 // 成功自动切到效果图页签
this.statusText = '换发色完成,点「原图」页签可对比'
// 成功才计数(与试戴同一 hairstyle key,合并计算每日额度)
DailyLimiter.tryConsume('hairstyle')
this.hairRemaining = DailyLimiter.remaining('hairstyle')
Tracker.track('haircolor.generate', {
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
})
} catch (e) {
Tracker.track('haircolor.generate', {
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
})
this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试'
} finally {
this.generating = false
}
}
/** 保存到相册:结果 Base64 现场写缓存文件 → 系统保存对话框(取消/失败三态文案)。 */
private async saveResult(): Promise<void> {
if (this.resultBase64.length === 0) {
return
}
try {
const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `haircolor_${Date.now()}.jpg`)
this.statusText = await this.saveFileToGallery(path)
} catch (_e) {
this.statusText = '保存失败,请重试'
}
}
/** 系统保存对话框 → 三态文案(与发型设计页统一口径)。 */
private async saveFileToGallery(path: string): Promise<string> {
try {
const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get())
const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE }
const uris: Array<string> = await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg])
return uris.length > 0 ? '已保存到相册' : '已取消保存'
} catch (_e) {
return '保存失败,请重试'
}
}
/** 今日额度耗尽的引导 overlay(P0-5):不发请求,给「做任务换额度」出路。 */
@Builder
QuotaBlockedDialog() {
Column() {
Column({ space: 14 }) {
Text('今日换发色额度已用完')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_PRIMARY)
Text('明天再来,或做任务多换几次')
.fontSize(13)
.fontColor(COLOR_TEXT_SECONDARY)
.textAlign(TextAlign.Center)
Row({ space: 12 }) {
Button('知道了')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor(COLOR_IMAGE_BG)
.fontColor(COLOR_TEXT_PRIMARY)
.onClick(() => {
this.showQuotaBlocked = false
})
Button('做任务换额度')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.showQuotaBlocked = false
this.showTaskPanel = true
})
}
.width('100%')
.margin({ top: 4 })
}
.width('82%')
.padding(20)
.borderRadius(16)
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
.backgroundColor(COLOR_BADGE_BG)
.justifyContent(FlexAlign.Center)
}
/** 「做任务换额度」半屏面板(P0-4):到账后重拉额度。 */
@Builder
TaskBonusSheet() {
TaskBonusPanel({
onClaimed: (_quotaKey: string): void => {
this.loadHairRemaining()
}
})
}
build() {
Stack() {
Column() {
// 顶部标题栏
Row() {
Text('‹ 返回').fontSize(15).fontColor(COLOR_TEXT_SECONDARY).onClick(() => router.back())
Text('换发色').fontSize(17).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
Text('').width(48)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.justifyContent(FlexAlign.SpaceBetween)
.backgroundColor(Color.White)
Scroll() {
Column({ space: 12 }) {
// 图片区页签:原图 | 效果图(无结果时效果图置灰不可点)
Row() {
ForEach(this.imageTabs, (label: string, idx: number) => {
Text(label)
.fontSize(13)
.fontColor(idx === 1 && this.resultPm === undefined
? COLOR_TEXT_DISABLED
: (this.imageTab === idx ? Color.White : COLOR_TEXT_PRIMARY))
.backgroundColor(this.imageTab === idx && !(idx === 1 && this.resultPm === undefined)
? COLOR_PRIMARY
: Color.Transparent)
.padding({ left: 20, right: 20, top: 7, bottom: 7 })
.borderRadius(16)
.onClick(() => {
if (idx === 1 && this.resultPm === undefined) {
return // 无结果:效果图页签不响应
}
this.imageTab = idx
})
}, (label: string) => label)
}
.padding(2)
.borderRadius(18)
.backgroundColor(COLOR_IMAGE_BG)
.alignSelf(ItemAlign.Start)
// 图片区(单区域,定高 320):无照片=上传入口;生成中=loading;按页签显示原图/效果图。
// 注意 Stack 必须显式定高:内部额度胶囊 Row 用 height(100%),不定高会被 Scroll 视口撑飞
Stack({ alignContent: Alignment.BottomStart }) {
if (this.photoUri.length === 0) {
Column({ space: 6 }) {
Text('+').fontSize(28).fontColor(COLOR_TEXT_SECONDARY)
Text('上传面部特写').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
.width('100%').height(320).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
.onClick(() => { this.pickPhoto(false) })
} else if (this.generating) {
Column({ space: 8 }) {
LoadingProgress().width(36).height(36).color(COLOR_PRIMARY)
Text('生成中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
.width('100%').height(320).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
} else if (this.imageTab === 1 && this.resultPm !== undefined) {
// Cover 居中裁切:人脸通常在画面中央,头顶/下巴各让一点比只切下巴安全
Image(this.resultPm)
.width('100%').height(320).objectFit(ImageFit.Cover).borderRadius(12)
Text('效果图')
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.margin(8)
} else {
Image(this.photoUri)
.width('100%').height(320).objectFit(ImageFit.Cover).borderRadius(12)
Text('原图')
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.margin(8)
}
// 右下「今日还可换发色 N 次」胶囊(P0-5,与试戴共用额度):仅展示不参与命中
if (this.hairRemaining >= 0 && this.photoUri.length > 0) {
Row() {
Text(`今日还可换发色 ${this.hairRemaining} 次`)
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
}
.width('100%').height('100%')
.justifyContent(FlexAlign.End)
.alignItems(VerticalAlign.Bottom)
.padding({ right: 8, bottom: 8 })
.hitTestBehavior(HitTestMode.None)
}
}
.width('100%')
.height(320)
// 入口文字链:相册/拍照 + 保存(有结果才可点)
Row({ space: 16 }) {
Text('相册').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(false) })
Text('拍照').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(true) })
Text('保存')
.fontSize(12)
.fontColor(this.resultPm !== undefined ? COLOR_PRIMARY : COLOR_TEXT_SECONDARY)
.onClick(() => { this.saveResult() })
}
.width('100%')
Text('请使用本人照片,上传即确认不侵犯他人肖像权')
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
if (this.statusText.length > 0) {
Text(this.statusText).fontSize(12).fontColor(COLOR_TEXT_SECONDARY).width('100%')
}
// 发色卡:8 色圆点+名称 + 强度滑杆(每次都在原图上换色)
Column({ space: 12 }) {
Text('选择发色').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(PRESET_COLORS, (c: PresetColor, idx: number) => {
Column() {
ColorDot({
colorHex: this.colorHex(c.rgb),
label: c.name,
selected: this.colorIdx === idx,
onSelect: (): void => { this.colorIdx = idx }
})
}
.margin({ right: 14, bottom: 12 })
}, (c: PresetColor) => c.name)
}
.width('100%')
Row({ space: 8 }) {
Text('强度').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
Slider({ value: this.ratio, min: 0, max: 1, step: 0.1 })
.layoutWeight(1)
.onChange((v: number) => { this.ratio = v })
Text(this.ratio.toFixed(1)).fontSize(12).fontColor(COLOR_TEXT_SECONDARY).width(28)
}
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(Color.White)
// 合并计数脚注(P0-5):与发型试戴共用 hairstyle 每日额度
Text('换发色与发型试戴共用每日额度')
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
}
.width('100%')
.padding(16)
}
.layoutWeight(1)
.scrollBar(BarState.Off)
// 吸底「换发色」:白底条+顶分隔线,高48圆角24主色;未上传照片或生成中置灰
Column() {
Divider().strokeWidth(0.5).color(COLOR_DIVIDER)
Button(this.generating ? '生成中…' : `换发色(${PRESET_COLORS[this.colorIdx].name})`)
.width('100%')
.height(48)
.borderRadius(24)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(this.canGenerate() ? Color.White : COLOR_TEXT_DISABLED)
.backgroundColor(this.canGenerate() ? COLOR_PRIMARY : COLOR_DIVIDER)
.enabled(this.canGenerate())
.onClick(() => { this.applyColor() })
}
.width('100%')
.backgroundColor(Color.White)
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
.width('100%')
.height('100%')
if (this.showFaceConsent) {
FacePhotoConsent({
onAgree: (): void => {
this.onConsentAgree()
},
onRefuse: (): void => {
this.onConsentRefuse()
}
})
}
if (this.showQuotaBlocked) {
this.QuotaBlockedDialog()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLOR_PAGE_BG)
.bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), {
height: '60%',
showClose: true
})
}
/** 吸底按钮可用条件:已上传照片 + 不在生成中。 */
private canGenerate(): boolean {
return !this.generating && this.photoUri.length > 0
}
}
+143 -288
View File
@@ -1,24 +1,18 @@
/**
* HairDesignPage —— 发型设计(试戴 + 换发色),Phase A 直连线上服务。
* HairDesignPage —— 发型设计(只试戴;换发色已拆到独立页 HairColorPage),Phase A 直连线上服务。
*
* 流程(产品方案 §2.3):上传面部特写 → 点选发型即试戴(5~10s)→ 可换发色 → 保存相册。
* 交互(2026-09 按用户反馈重排):
* 图片区单区域展示,顶部「原图/效果图」页签切换(无结果时效果图页签置灰不可点);
* 点选发型只选中不生成,吸底「换发型」按钮触发试戴(5~10s)→ 成功自动切到效果图页签;
* 保存走系统相册对话框;不再保留「上次结果」回看卡(相册留存即可)。
* 异常口径:400 用服务端 message 原样提示;失败保留当前图不覆盖。
* 发型库三态(#8/#18):加载中 / 空态 / 失败+重新加载。
* 结果归宿(#18,与 #17 统一):试戴/换发色成功即落 filesDir/results/hair_last.jpg
* (换发色后覆盖为最新版本),保存优先复用该文件,杀 App 后「上次结果」卡可回看。
*
* v2 视觉重排(《美搭配与美学诊断-UI规范》§4,交互全部保留):
* 顶部双图区(原图|效果图 等宽,左下角标 COLOR_BADGE_BG,点右图切换对比);
* 性别分栏改 Segment 组件(女生/男生);8 色发色改 ColorDot 圆点;
* 「应用发色」移至吸底主按钮(无结果时随发色卡整体隐藏,沿用现逻辑)。
*/
import { router } from '@kit.ArkUI'
import { image } from '@kit.ImageKit'
import { util } from '@kit.ArkTS'
import { photoAccessHelper } from '@kit.MediaLibraryKit'
import { fileUri } from '@kit.CoreFileKit'
import fs from '@ohos.file.fs'
import { TryonApi, Hairstyle } from '../core/TryonApi'
import { ImageCodec } from '../core/ImageCodec'
import { ApiClient } from '../core/ApiClient'
@@ -38,7 +32,6 @@ import {
COLOR_ERROR
} from '../core/Theme'
import { Segment } from '../components/f2/Segment'
import { ColorDot } from '../components/f2/ColorDot'
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
import { TaskBonusPanel } from '../components/f1/TaskBonusPanel'
@@ -52,22 +45,6 @@ interface HairQuotaWire {
reset_at: string | null
}
interface PresetColor {
name: string
rgb: number[]
}
const PRESET_COLORS: PresetColor[] = [
{ name: '自然黑', rgb: [30, 30, 30] },
{ name: '深棕', rgb: [74, 48, 36] },
{ name: '栗色', rgb: [128, 70, 42] },
{ name: '蜜茶', rgb: [156, 108, 66] },
{ name: '亚麻', rgb: [150, 140, 130] },
{ name: '酒红', rgb: [128, 32, 48] },
{ name: '奶奶灰', rgb: [160, 160, 165] },
{ name: '蓝黑', rgb: [40, 50, 70] }
]
@Entry
@Component
struct HairDesignPage {
@@ -79,36 +56,20 @@ struct HairDesignPage {
@State generating: boolean = false
@State statusText: string = ''
@State resultPm: image.PixelMap | undefined = undefined
@State resultBase64: string = '' // 换发色/保存都用它,避免二次编码
@State resultLocalPath: string = '' // 本机留存副本(#18):保存优先复用
@State lastLocalPath: string = '' // 「上次结果」卡数据源(aboutToAppear 回看,#18)
@State showOriginal: boolean = false
@State colorIdx: number = 1
@State ratio: number = 0.9
@State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款 34 / 男款 26)
@State resultBase64: string = '' // 保存用它,避免二次编码
@State imageTab: number = 0 // 图片区页签:0=原图 1=效果图(无结果时置灰)
@State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款多于男款)
@State showFaceConsent: boolean = false
// —— 每日试戴额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2;试戴与换发色合并计数)——
// —— 每日试戴额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2;与换发色共用 hairstyle key)——
@State hairRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余
@State showQuotaBlocked: boolean = false // 额度耗尽引导 overlay
@State showTaskPanel: boolean = false // 「做任务换额度」半屏面板
private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口
/** 本机结果留存路径(#18):filesDir/results(cacheDir 有系统配额会被清)。 */
private resultStorePath(): string {
return `${ContextHolder.get().filesDir}/results/hair_last.jpg`
}
private imageTabs: string[] = ['原图', '效果图']
aboutToAppear(): void {
this.loadStyles()
this.loadHairRemaining()
// 上次结果回看(#18):沙盒留存最近一次发型结果,杀 App/重启不丢
try {
if (fs.accessSync(this.resultStorePath())) {
this.lastLocalPath = this.resultStorePath()
}
} catch (_e) {
// 目录不存在等:视为无上次结果
}
// 预留:Agent 联动(V2)预选发型,见产品方案 §7
const params = router.getParams() as Record<string, Object> | undefined
if (params !== undefined && typeof params['hairstyle_id'] === 'string') {
@@ -158,15 +119,14 @@ struct HairDesignPage {
return out
}
/** RGB 分量转两位 hex(发色圆点展示用)。 */
private hex2(v: number): string {
const s: string = v.toString(16)
return s.length < 2 ? `0${s}` : s
}
/** PRESET_COLORS 的 RGB 转 #RRGGBB:仅圆点展示,发色接口 rgb 传参格式不变。 */
private colorHex(rgb: number[]): string {
return `#${this.hex2(rgb[0])}${this.hex2(rgb[1])}${this.hex2(rgb[2])}`
/** 当前选中的发型名(吸底按钮文案用);未选中为空串。 */
private selectedStyleName(): string {
for (const s of this.styles) {
if (s.id === this.selectedStyleId) {
return s.name
}
}
return ''
}
/** 上传面部特写前须单独同意(CR-01:人脸属敏感个人信息)。 */
@@ -188,8 +148,8 @@ struct HairDesignPage {
this.photoUri = picked.uri
this.resultPm = undefined
this.resultBase64 = ''
this.resultLocalPath = ''
this.selectedStyleId = ''
this.imageTab = 0
this.statusText = ''
} catch (e) {
this.statusText = e instanceof ApiError ? e.message : '读取照片失败'
@@ -207,8 +167,19 @@ struct HairDesignPage {
this.statusText = '未同意照片授权,你可以先浏览下方发型库'
}
private async tryOn(style: Hairstyle): Promise<void> {
if (this.photoUri.length === 0 || this.generating) {
/** 吸底按钮触发:对当前选中发型发起试戴(点选网格只选中,不发请求)。 */
private async tryOn(): Promise<void> {
if (this.photoUri.length === 0 || this.selectedStyleId.length === 0 || this.generating) {
return
}
let style: Hairstyle | undefined = undefined
for (const s of this.styles) {
if (s.id === this.selectedStyleId) {
style = s
break
}
}
if (style === undefined) {
return
}
// 每日额度耗尽(P0-5,与换发色合并计数):不发请求,给「做任务换额度」出路
@@ -219,21 +190,19 @@ struct HairDesignPage {
}
const startMs: number = Date.now()
this.generating = true
this.selectedStyleId = style.id
this.showOriginal = false
this.statusText = `正在试戴「${style.name}」,约 5~15 秒…`
try {
const buf = await ImageCodec.toJpegBuffer(this.photoUri)
this.resultBase64 = await TryonApi.changeHair(buf, style.id)
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
this.statusText = '试戴完成,点「对比」看原图'
this.imageTab = 1 // 成功自动切到效果图页签
this.statusText = '试戴完成,点「原图」页签可对比'
// 成功才计数(失败不占额度):端侧记账 + 埋点上报
DailyLimiter.tryConsume('hairstyle')
this.hairRemaining = DailyLimiter.remaining('hairstyle')
Tracker.track('hair.generate', {
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
})
this.persistResult()
} catch (e) {
Tracker.track('hair.generate', {
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
@@ -244,68 +213,13 @@ struct HairDesignPage {
}
}
private async changeColor(): Promise<void> {
if (this.resultBase64.length === 0 || this.generating) {
return
}
// 每日额度耗尽(P0-5,与试戴合并计数同一 hairstyle key):不发请求,给出路
if (this.hairRemaining === 0) {
Tracker.track('hair.generate', { 'ok': false, 'duration_s': 0, 'blocked': true })
this.showQuotaBlocked = true
return
}
const startMs: number = Date.now()
this.generating = true
const c: PresetColor = PRESET_COLORS[this.colorIdx]
this.statusText = `正在换发色(${c.name})…`
try {
const helper = new util.Base64Helper()
const pure: string = this.resultBase64.includes(',')
? this.resultBase64.substring(this.resultBase64.indexOf(',') + 1)
: this.resultBase64
// slice() 拷贝出精确长度的副本:decodeSync 返回视图,直接取 .buffer 会带尾部脏字节
const buf = helper.decodeSync(pure).slice().buffer as ArrayBuffer
const rgb: string = `[${c.rgb[0]}, ${c.rgb[1]}, ${c.rgb[2]}]`
this.resultBase64 = await TryonApi.changeHairColor(buf, rgb, this.ratio)
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
this.statusText = '换发色完成'
// 成功才计数(与试戴同一 hairstyle key,合并计算每日额度)
DailyLimiter.tryConsume('hairstyle')
this.hairRemaining = DailyLimiter.remaining('hairstyle')
Tracker.track('hair.generate', {
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
})
this.persistResult() // 落盘覆盖为最新发色版本(#18 验收④)
} catch (e) {
Tracker.track('hair.generate', {
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
})
this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试'
} finally {
this.generating = false
}
}
/** 结果留存(#18):当前结果 Base64 落 filesDir/results/hair_last.jpg;失败不阻断展示。 */
private persistResult(): void {
try {
const path: string = ImageCodec.base64ToPersistFile(this.resultBase64, 'hair_last.jpg')
this.resultLocalPath = path
this.lastLocalPath = path
} catch (_e) {
this.resultLocalPath = '' // 落盘失败:保存时走 base64 现场写缓存兜底
}
}
/** 保存到相册(#18):优先复用本机留存文件(一次写、双用途);留存失败过的现场写缓存兜底。 */
/** 保存到相册:结果 Base64 现场写缓存文件 → 系统保存对话框(取消/失败三态文案)。 */
private async saveResult(): Promise<void> {
if (this.resultBase64.length === 0) {
return
}
try {
const path: string = this.resultLocalPath.length > 0
? this.resultLocalPath
: ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`)
const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`)
this.statusText = await this.saveFileToGallery(path)
} catch (_e) {
this.statusText = '保存失败,请重试'
@@ -327,14 +241,6 @@ struct HairDesignPage {
}
}
/** 「上次结果」卡的保存(#18):直接走本机副本,不依赖网络。 */
private async saveLastResult(): Promise<void> {
if (this.lastLocalPath.length === 0) {
return
}
this.statusText = await this.saveFileToGallery(this.lastLocalPath)
}
/** 今日试戴额度耗尽的引导 overlay(P0-5):不发请求,给「做任务换额度」出路。 */
@Builder
QuotaBlockedDialog() {
@@ -408,92 +314,91 @@ struct HairDesignPage {
Scroll() {
Column({ space: 12 }) {
// 顶部双图区(v2):原图 | 效果图 等宽,左下角标当前显示态,点右图切换对比
Row({ space: 12 }) {
// 左图:原图(无照片时为上传入口占位)
Stack({ alignContent: Alignment.BottomStart }) {
if (this.photoUri.length > 0) {
Image(this.photoUri)
.width('100%').height(240).objectFit(ImageFit.Cover).borderRadius(12)
Text('原图')
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.margin(8)
} else {
Column({ space: 6 }) {
Text('+').fontSize(28).fontColor(COLOR_TEXT_SECONDARY)
Text('上传面部特写').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
.width('100%').height(240).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
.onClick(() => { this.pickPhoto(false) })
}
}
.layoutWeight(1)
// 图片区页签:原图 | 效果图(无结果时效果图置灰不可点)
Row() {
ForEach(this.imageTabs, (label: string, idx: number) => {
Text(label)
.fontSize(13)
.fontColor(idx === 1 && this.resultPm === undefined
? COLOR_TEXT_DISABLED
: (this.imageTab === idx ? Color.White : COLOR_TEXT_PRIMARY))
.backgroundColor(this.imageTab === idx && !(idx === 1 && this.resultPm === undefined)
? COLOR_PRIMARY
: Color.Transparent)
.padding({ left: 20, right: 20, top: 7, bottom: 7 })
.borderRadius(16)
.onClick(() => {
if (idx === 1 && this.resultPm === undefined) {
return // 无结果:效果图页签不响应
}
this.imageTab = idx
})
}, (label: string) => label)
}
.padding(2)
.borderRadius(18)
.backgroundColor(COLOR_IMAGE_BG)
.alignSelf(ItemAlign.Start)
// 右图:效果图(点按在 效果图/原图 间切换对比,沿用现交互)
Stack({ alignContent: Alignment.BottomStart }) {
if (this.generating) {
Column({ space: 8 }) {
LoadingProgress().width(36).height(36).color(COLOR_PRIMARY)
Text('生成中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
.width('100%').height(240).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
} else if (this.resultPm !== undefined && !this.showOriginal) {
Image(this.resultPm)
.width('100%').height(240).objectFit(ImageFit.Cover).borderRadius(12)
Text('效果图')
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.margin(8)
} else if (this.showOriginal && this.photoUri.length > 0) {
Image(this.photoUri)
.width('100%').height(240).objectFit(ImageFit.Cover).borderRadius(12)
Text('原图')
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.margin(8)
} else {
Column() {
Text('效果图').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
.width('100%').height(240).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
// 图片区(单区域,定高 320):无照片=上传入口;生成中=loading;按页签显示原图/效果图。
// 注意 Stack 必须显式定高:内部额度胶囊 Row 用 height(100%),不定高会被 Scroll 视口撑飞
Stack({ alignContent: Alignment.BottomStart }) {
if (this.photoUri.length === 0) {
Column({ space: 6 }) {
Text('+').fontSize(28).fontColor(COLOR_TEXT_SECONDARY)
Text('上传面部特写').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
// 右下「今日还可试戴 N 次」胶囊(P0-5,试戴+换发色合并计数):仅展示不参与命中
if (this.hairRemaining >= 0) {
Row() {
Text(`今日还可试戴 ${this.hairRemaining} 次`)
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
}
.width('100%').height('100%')
.justifyContent(FlexAlign.End)
.alignItems(VerticalAlign.Bottom)
.padding({ right: 8, bottom: 8 })
.hitTestBehavior(HitTestMode.None)
.width('100%').height(320).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
.onClick(() => { this.pickPhoto(false) })
} else if (this.generating) {
Column({ space: 8 }) {
LoadingProgress().width(36).height(36).color(COLOR_PRIMARY)
Text('生成中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
.width('100%').height(320).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
} else if (this.imageTab === 1 && this.resultPm !== undefined) {
// Cover 居中裁切:人脸通常在画面中央,头顶/下巴各让一点比只切下巴安全
Image(this.resultPm)
.width('100%').height(320).objectFit(ImageFit.Cover).borderRadius(12)
Text('效果图')
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.margin(8)
} else {
Image(this.photoUri)
.width('100%').height(320).objectFit(ImageFit.Cover).borderRadius(12)
Text('原图')
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.margin(8)
}
.layoutWeight(1)
.onClick(() => {
if (this.resultPm !== undefined && !this.generating) {
this.showOriginal = !this.showOriginal
// 右下「今日还可试戴 N 次」胶囊(P0-5,与换发色共用额度):仅展示不参与命中
if (this.hairRemaining >= 0 && this.photoUri.length > 0) {
Row() {
Text(`今日还可试戴 ${this.hairRemaining} 次`)
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
}
})
.width('100%').height('100%')
.justifyContent(FlexAlign.End)
.alignItems(VerticalAlign.Bottom)
.padding({ right: 8, bottom: 8 })
.hitTestBehavior(HitTestMode.None)
}
}
.width('100%')
.height(320)
// 入口文字链(沿用):相册/拍照 + 保存(保存沿用现交互与置灰口径)
// 入口文字链:相册/拍照 + 保存(有结果才可点)
Row({ space: 16 }) {
Text('相册').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(false) })
Text('拍照').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(true) })
@@ -511,31 +416,8 @@ struct HairDesignPage {
Text(this.statusText).fontSize(12).fontColor(COLOR_TEXT_SECONDARY).width('100%')
}
// 上次发型结果回看(#18):无本次结果时展示留存副本(保存/移除)
if (this.lastLocalPath.length > 0 && this.resultPm === undefined) {
Column({ space: 8 }) {
Text('上次发型结果')
.fontSize(13).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY).width('100%')
Image(fileUri.getUriFromPath(this.lastLocalPath))
.width('100%').height(280).objectFit(ImageFit.Contain)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
Row({ space: 16 }) {
Text('保存到相册').fontSize(13).fontColor(COLOR_PRIMARY)
.onClick(() => { this.saveLastResult() })
Text('知道了').fontSize(13).fontColor(COLOR_TEXT_SECONDARY)
.onClick(() => { this.lastLocalPath = '' })
}
.width('100%').justifyContent(FlexAlign.Center)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(Color.White)
}
// 发型网格(男女分栏,三态沿用不动)
Text('选择发型(点选即试戴)').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
// 性别分段(v2):女生/男生,映射 genderTab;unknown 款两段都展示
// 发型网格:点选只选中(描边),吸底「换发型」按钮触发试戴
Text('选择发型').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
Row() {
Segment({
items: ['女生', '男生'],
@@ -561,7 +443,9 @@ struct HairDesignPage {
Text(style.name).fontSize(11).fontColor(COLOR_TEXT_PRIMARY).maxLines(1)
}
}
.onClick(() => { this.tryOn(style) })
.onClick(() => {
this.selectedStyleId = style.id // 只选中不生成,底部按钮触发
})
}, (style: Hairstyle) => style.id)
}
.columnsTemplate('1fr 1fr 1fr')
@@ -605,41 +489,8 @@ struct HairDesignPage {
.padding({ top: 8, bottom: 8 })
}
// 换发色卡(v2):8 色圆点+名称 + 强度滑杆;无结果整体隐藏(沿用现逻辑)
if (this.resultPm !== undefined) {
Column({ space: 12 }) {
Text('换个发色').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(PRESET_COLORS, (c: PresetColor, idx: number) => {
Column() {
ColorDot({
colorHex: this.colorHex(c.rgb),
label: c.name,
selected: this.colorIdx === idx,
onSelect: (): void => { this.colorIdx = idx }
})
}
.margin({ right: 14, bottom: 12 })
}, (c: PresetColor) => c.name)
}
.width('100%')
Row({ space: 8 }) {
Text('强度').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
Slider({ value: this.ratio, min: 0, max: 1, step: 0.1 })
.layoutWeight(1)
.onChange((v: number) => { this.ratio = v })
Text(this.ratio.toFixed(1)).fontSize(12).fontColor(COLOR_TEXT_SECONDARY).width(28)
}
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(Color.White)
}
// 合并计数脚注(P0-5):试戴与换发色共用 hairstyle 每日额度
Text('试戴与换发色合并计算每日额度')
// 合并计数脚注(P0-5):试戴与换发色页共用 hairstyle 每日额度
Text('试戴与换发色共用每日额度')
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
}
.width('100%')
@@ -648,26 +499,25 @@ struct HairDesignPage {
.layoutWeight(1)
.scrollBar(BarState.Off)
// 吸底「应用发色」(v2):白底条+顶分隔线,高48圆角24主色;
// 与发色卡同条件渲染(无结果不出现),生成中置灰并显示「生成中…」
if (this.resultPm !== undefined) {
Column() {
Divider().strokeWidth(0.5).color(COLOR_DIVIDER)
Button(this.generating ? '生成中…' : '应用发色')
.width('100%')
.height(48)
.borderRadius(24)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(this.generating || this.resultBase64.length === 0 ? COLOR_TEXT_DISABLED : Color.White)
.backgroundColor(this.generating || this.resultBase64.length === 0 ? COLOR_DIVIDER : COLOR_PRIMARY)
.enabled(!this.generating && this.resultBase64.length > 0)
.onClick(() => { this.changeColor() })
}
.width('100%')
.backgroundColor(Color.White)
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
// 吸底「换发型」:白底条+顶分隔线,高48圆角24主色;未选照片/发型或生成中置灰
Column() {
Divider().strokeWidth(0.5).color(COLOR_DIVIDER)
Button(this.generating
? '生成中…'
: (this.selectedStyleName().length > 0 ? `试戴「${this.selectedStyleName()}」` : '换发型'))
.width('100%')
.height(48)
.borderRadius(24)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(this.canGenerate() ? Color.White : COLOR_TEXT_DISABLED)
.backgroundColor(this.canGenerate() ? COLOR_PRIMARY : COLOR_DIVIDER)
.enabled(this.canGenerate())
.onClick(() => { this.tryOn() })
}
.width('100%')
.backgroundColor(Color.White)
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
.width('100%')
.height('100%')
@@ -694,4 +544,9 @@ struct HairDesignPage {
showClose: true
})
}
/** 吸底按钮可用条件:已上传照片 + 已选发型 + 不在生成中。 */
private canGenerate(): boolean {
return !this.generating && this.photoUri.length > 0 && this.selectedStyleId.length > 0
}
}
+19 -3
View File
@@ -7,6 +7,8 @@
* (组件级复用允许 import,独立路由页无底部 Tab 会把用户锁死在页面里)
* - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态)
* - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数
* - 可选路由参数 initialTab(number 0~3):外部页面可指定落地 Tab
* (如报告结果页「查看历史报告」传 1 落地「报告」Tab)
*/
import { router } from '@kit.ArkUI'
@@ -45,14 +47,27 @@ struct HomePage {
@State quotaRemaining: string = '…'
@State quotaNetError: boolean = false // 额度拉取遇网络错误(P0-6):配额卡位置换 NetworkBanner
@State reportRefreshTick: number = 0 // 自增通知报告 Tab 刷新(N2)
private tabsController: TabsController = new TabsController() // 供 initialTab 参数落地指定 Tab
aboutToAppear(): void {
// 可选参数 initialTab:外部页面(如报告结果页「查看历史报告」)可指定落地 Tab
const params = router.getParams() as Record<string, Object> | undefined
if (params !== undefined && typeof params['initialTab'] === 'number') {
const idx = params['initialTab'] as number
if (idx >= 0 && idx <= 3) {
this.currentTabIndex = idx
}
}
this.dateLine = this.buildDateLine()
this.loadQuota()
}
/** 从报告结果页等返回(应用壳重新可见):当前停在报告 Tab 时刷新列表(N2)。 */
onPageShow(): void {
// initialTab 落地:Tabs 已挂载后 controller 才生效(aboutToAppear 里调用是空操作)
if (this.currentTabIndex !== 0) {
this.tabsController.changeIndex(this.currentTabIndex)
}
if (this.currentTabIndex === 1) {
this.reportRefreshTick += 1
}
@@ -168,7 +183,7 @@ struct HomePage {
.alignItems(VerticalAlign.Center)
}
/** Tab1 首页:问候区 + 两张功能入口卡 + 今日额度小卡。 */
/** Tab1 首页:问候区 + 功能入口卡 + 今日额度小卡。 */
@Builder
HomeTab() {
Scroll() {
@@ -188,7 +203,8 @@ struct HomePage {
this.EntryCard('美学诊断', '个人形象风格分析报告', '▣', ACCENT, 'pages/DiagnosisLandingPage', 'diagnosis')
this.EntryCard('美搭配', '我的衣橱,换装试穿', '◈', '#E8912D', 'pages/FittingRoomPage', 'fitting')
this.EntryCard('发型设计', '60 款发型试戴,还能换发色', '✂', '#2DB38E', 'pages/HairDesignPage', 'hair')
this.EntryCard('发型设计', '60 款发型,点选试戴', '✂', '#2DB38E', 'pages/HairDesignPage', 'hair')
this.EntryCard('换发色', '8 种发色,一键换着看', '◐', '#8E5BD3', 'pages/HairColorPage', 'haircolor')
this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage', 'chat')
if (this.quotaNetError) {
// P0-6:额度拉取遇网络错误——横幅换占配额卡位置,重试可恢复
@@ -247,7 +263,7 @@ struct HomePage {
build() {
Column() {
Tabs({ barPosition: BarPosition.End }) {
Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) {
TabContent() {
this.HomeTab()
}
+271 -60
View File
@@ -1,9 +1,11 @@
/**
* LoginPage(F1)—— dev-token 开发登录页。
* LoginPage(F1)—— 正式登录页:华为账号登录 + 邮箱验证码登录。
*
* 闭环:设备标识(随机生成一次并持久化,可改)→ AuthStore.loginByDevToken
* → 成功 router.replaceUrl HomePage;失败 catch ApiError 展示 message+hint。
* 高级设置:折叠区编辑 baseUrl(AuthStore.setBaseUrl),真机连开发机靠它。
* 主区(白卡):
* - 华为账号一键登录:Account Kit 拉起授权页拿 idToken → /auth/huawei
* (client_id 未配置时给「暂未开放」提示,不调 SDK)
* - 邮箱验证码登录:sendEmailCode(60s 冷却倒计时)→ /auth/email/login
* 高级设置(开发者,默认收起):dev-token 内测登录 + 服务器地址编辑,本机联调用。
* 已登录态进入本页:aboutToAppear 直接跳 HomePage。
*
* ArkTS 约束(对齐 core/AuthStore 风格):
@@ -13,14 +15,22 @@
import router from '@ohos.router'
import preferences from '@ohos.data.preferences'
import { promptAction } from '@kit.ArkUI'
import { ApiError } from '../core/ApiTypes'
import { AuthStore, DEFAULT_BASE_URL } from '../core/AuthStore'
import { ContextHolder } from '../core/ContextHolder'
import { HuaweiLogin } from '../core/HuaweiLogin'
import { COLOR_PRIMARY } from '../core/Theme'
import { ErrorBanner } from '../components/f1/ErrorBanner'
const PREF_NAME_LOGIN = 'meida_login'
const KEY_DEVICE_KEY = 'device_key'
const EMAIL_CODE_LENGTH: number = 6
/** 宽松邮箱校验:非空局部 @ 非空局部 . 非空局部。 */
function isEmail(text: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)
}
/** 随机设备标识:dev- + 16 位 hex,生成一次后随 preferences 持久化。 */
function generateDeviceKey(): string {
@@ -35,20 +45,32 @@ function generateDeviceKey(): string {
@Entry
@Component
struct LoginPage {
@State huaweiClientId: string = ''
@State huaweiLogging: boolean = false
@State emailInput: string = ''
@State emailCode: string = ''
@State sendingCode: boolean = false
@State sendCooldown: number = 0
@State emailLogging: boolean = false
@State deviceKey: string = ''
@State devLogging: boolean = false
@State baseUrlInput: string = ''
@State showAdvanced: boolean = false
@State logging: boolean = false
@State savingUrl: boolean = false
@State errMessage: string = ''
@State errHint: string = ''
@State savedTip: string = ''
private cooldownTimer: number = -1
aboutToAppear(): void {
this.bootstrap()
}
/** 具名 async 初始化:确保令牌已载入 → 已登录直跳 → 回填设备标识与地址。 */
aboutToDisappear(): void {
this.stopCooldownTick()
}
/** 具名 async 初始化:令牌载入 → 已登录直跳 → 回填表单与 client_id。 */
private async bootstrap(): Promise<void> {
try {
await AuthStore.init()
@@ -73,11 +95,112 @@ struct LoginPage {
this.deviceKey = generateDeviceKey()
}
this.baseUrlInput = await AuthStore.getBaseUrl()
this.huaweiClientId = await HuaweiLogin.resolveClientId()
}
/** 登录主流程:校验 → loading → dev-token → 跳首页;失败横幅展示。 */
private async doLogin(): Promise<void> {
if (this.logging) {
/** 华为账号登录:占位 client_id 直接提示;取消静默;其余 ApiError 走横幅。 */
private async doHuaweiLogin(): Promise<void> {
if (this.huaweiLogging) {
return
}
this.errMessage = ''
this.errHint = ''
if (!HuaweiLogin.isConfigured(this.huaweiClientId)) {
this.errMessage = '华为登录暂未开放'
this.errHint = '请先用邮箱验证码登录,正式开放后第一时间支持'
return
}
this.huaweiLogging = true
try {
const idToken = await HuaweiLogin.requestIdToken()
await AuthStore.loginByHuaweiIdToken(idToken)
router.replaceUrl({ url: 'pages/HomePage' })
} catch (e) {
if (HuaweiLogin.isUserCancelled(e)) {
// 用户取消授权:不报错
} else if (e instanceof ApiError) {
this.errMessage = e.message
this.errHint = e.hint !== undefined ? e.hint : ''
} else {
this.errMessage = '华为授权失败,请稍后重试'
this.errHint = '也可使用邮箱验证码登录'
}
} finally {
this.huaweiLogging = false
}
}
/** 发送邮箱验证码:校验格式 → 请求 → 按服务端 cooldown 启动倒计时。 */
private async doSendCode(): Promise<void> {
if (this.sendingCode || this.sendCooldown > 0) {
return
}
const email = this.emailInput.trim()
this.errMessage = ''
this.errHint = ''
if (!isEmail(email)) {
this.errMessage = '邮箱格式不正确'
this.errHint = '示例:name@example.com'
return
}
this.sendingCode = true
try {
const cooldown = await AuthStore.sendEmailCode(email)
this.sendCooldown = cooldown > 0 ? cooldown : 60
this.startCooldownTick()
promptAction.showToast({ message: '验证码已发送,请查收邮件', duration: 2000 })
} catch (e) {
if (e instanceof ApiError) {
this.errMessage = e.message
this.errHint = e.hint !== undefined ? e.hint : ''
} else {
this.errMessage = '验证码发送失败,请检查网络后重试'
this.errHint = ''
}
} finally {
this.sendingCode = false
}
}
/** 邮箱验证码登录:校验 → 请求 → 跳首页;失败横幅展示。 */
private async doEmailLogin(): Promise<void> {
if (this.emailLogging) {
return
}
const email = this.emailInput.trim()
const code = this.emailCode.trim()
this.errMessage = ''
this.errHint = ''
if (!isEmail(email)) {
this.errMessage = '邮箱格式不正确'
this.errHint = '示例:name@example.com'
return
}
if (code.length !== EMAIL_CODE_LENGTH) {
this.errMessage = '请输入 6 位数字验证码'
this.errHint = ''
return
}
this.emailLogging = true
try {
await AuthStore.loginByEmail(email, code)
router.replaceUrl({ url: 'pages/HomePage' })
} catch (e) {
if (e instanceof ApiError) {
this.errMessage = e.message
this.errHint = e.hint !== undefined ? e.hint : ''
} else {
this.errMessage = '登录失败,请检查网络后重试'
this.errHint = ''
}
} finally {
this.emailLogging = false
}
}
/** dev-token 内测登录(高级设置里,本机联调用)。 */
private async doDevLogin(): Promise<void> {
if (this.devLogging) {
return
}
const key = this.deviceKey.trim()
@@ -89,7 +212,7 @@ struct LoginPage {
this.errHint = '可点「随机生成」重新生成一个'
return
}
this.logging = true
this.devLogging = true
this.persistDeviceKey(key)
try {
await AuthStore.loginByDevToken(key)
@@ -99,12 +222,11 @@ struct LoginPage {
this.errMessage = e.message
this.errHint = e.hint !== undefined ? e.hint : ''
} else {
// 连接层失败(未进统一信封,#25):给内测口径指引,不改 ApiClient 全局文案
this.errMessage = '无法连接内测服务,请确认已连接指定网络,或联系测试人员'
this.errHint = ''
}
} finally {
this.logging = false
this.devLogging = false
}
}
@@ -119,6 +241,24 @@ struct LoginPage {
}
}
private startCooldownTick(): void {
this.stopCooldownTick()
this.cooldownTimer = setInterval(() => {
if (this.sendCooldown > 0) {
this.sendCooldown -= 1
} else {
this.stopCooldownTick()
}
}, 1000)
}
private stopCooldownTick(): void {
if (this.cooldownTimer !== -1) {
clearInterval(this.cooldownTimer)
this.cooldownTimer = -1
}
}
private async saveBaseUrl(): Promise<void> {
const url = this.baseUrlInput.trim()
this.savedTip = ''
@@ -170,7 +310,7 @@ struct LoginPage {
Column() {
Scroll() {
Column({ space: 22 }) {
// 品牌区
// 品牌区(正式产品口径)
Column({ space: 10 }) {
Text('美搭')
.fontSize(48)
@@ -180,54 +320,94 @@ struct LoginPage {
Text('AI 穿搭美学助手 · 每天三分钟,搭出好气质')
.fontSize(14)
.fontColor('#8A8A8A')
// 内测标识(#25):常驻,不藏进高级设置
Text('内测版 · 需连接指定网络后使用')
.fontSize(12)
.fontColor('#B3554D')
.padding({ left: 14, right: 14, top: 5, bottom: 5 })
.borderRadius(14)
.backgroundColor('#F7E3EA')
}
.margin({ top: 72, bottom: 8 })
// 内测账号标识(#25:话术去开发黑话,说明账号机制)
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('内测账号标识')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#2B2B2B')
Blank()
Text('随机生成')
.fontSize(13)
.fontColor(COLOR_PRIMARY)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor('#F7E3EA')
.onClick(() => this.regenDeviceKey())
// 主登录卡:华为账号 + 邮箱验证码
Column({ space: 14 }) {
Button(this.huaweiLogging ? '正在拉起华为授权…' : '华为账号一键登录')
.width('100%')
.height(50)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.borderRadius(25)
.backgroundColor(COLOR_PRIMARY)
.fontColor('#FFFFFF')
.enabled(!this.huaweiLogging)
.onClick(() => this.doHuaweiLogin())
// 分隔线:或使用邮箱登录
Row({ space: 12 }) {
Divider()
.layoutWeight(1)
.strokeWidth(1)
.color('#E5E7EB')
Text('或使用邮箱登录')
.fontSize(12)
.fontColor('#B3B3B3')
Divider()
.layoutWeight(1)
.strokeWidth(1)
.color('#E5E7EB')
}
.width('100%')
TextInput({ text: this.deviceKey, placeholder: 'dev-xxxxxxxxxxxxxxxx' })
TextInput({ text: this.emailInput, placeholder: '邮箱地址' })
.height(48)
.fontSize(15)
.type(InputType.Email)
.borderRadius(12)
.backgroundColor('#FFFFFF')
.backgroundColor('#F7F5F6')
.padding({ left: 14, right: 14 })
.onChange((value: string) => {
this.deviceKey = value
this.emailInput = value
})
Text('无需注册手机号;同一标识对应同一账号,换一个标识相当于新账号')
.fontSize(11)
.fontColor('#B3B3B3')
Row({ space: 10 }) {
TextInput({ text: this.emailCode, placeholder: '6 位验证码' })
.height(48)
.fontSize(15)
.type(InputType.Number)
.maxLength(EMAIL_CODE_LENGTH)
.borderRadius(12)
.backgroundColor('#F7F5F6')
.padding({ left: 14, right: 14 })
.layoutWeight(1)
.onChange((value: string) => {
this.emailCode = value.replace(/[^0-9]/g, '').slice(0, EMAIL_CODE_LENGTH)
})
Button(this.sendCooldown > 0 ? `${this.sendCooldown}s 后重发` : (this.sendingCode ? '发送中…' : '发送验证码'))
.fontSize(14)
.height(48)
.borderRadius(12)
.backgroundColor(this.sendCooldown > 0 ? '#F0E0E6' : COLOR_PRIMARY)
.fontColor(this.sendCooldown > 0 ? '#B3B3B3' : '#FFFFFF')
.enabled(!this.sendingCode && this.sendCooldown <= 0)
.onClick(() => this.doSendCode())
}
.width('100%')
Button(this.emailLogging ? '正在登录…' : '登录')
.width('100%')
.height(50)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.borderRadius(25)
.backgroundColor(COLOR_PRIMARY)
.fontColor('#FFFFFF')
.enabled(!this.emailLogging && isEmail(this.emailInput.trim()) && this.emailCode.trim().length === EMAIL_CODE_LENGTH)
.onClick(() => this.doEmailLogin())
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor('#FFFFFF')
// 高级设置(服务器地址,真机连开发机靠它)
// 高级设置(开发者):dev-token 内测登录 + 服务器地址,默认收起
Column({ space: 10 }) {
Row() {
Text('高级设置 · 服务器地址')
Text('高级设置(开发者)')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#2B2B2B')
@@ -244,11 +424,54 @@ struct LoginPage {
if (this.showAdvanced) {
Column({ space: 8 }) {
TextInput({ text: this.baseUrlInput, placeholder: DEFAULT_BASE_URL })
.height(46)
// dev-token 内测登录(本机联调通道)
Row({ space: 8 }) {
Text('内测账号标识')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#2B2B2B')
Blank()
Text('随机生成')
.fontSize(12)
.fontColor(COLOR_PRIMARY)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor('#F7E3EA')
.onClick(() => this.regenDeviceKey())
}
.width('100%')
TextInput({ text: this.deviceKey, placeholder: 'dev-xxxxxxxxxxxxxxxx' })
.height(44)
.fontSize(14)
.borderRadius(12)
.backgroundColor('#FFFFFF')
.backgroundColor('#F7F5F6')
.padding({ left: 14, right: 14 })
.onChange((value: string) => {
this.deviceKey = value
})
Button(this.devLogging ? '正在进入…' : '内测通道进入')
.width('100%')
.height(42)
.fontSize(15)
.borderRadius(21)
.backgroundColor('#F0E0E6')
.fontColor(COLOR_PRIMARY)
.enabled(!this.devLogging && this.deviceKey.trim() !== '')
.onClick(() => this.doDevLogin())
Divider()
.width('100%')
.strokeWidth(1)
.color('#E5E7EB')
.margin({ top: 4, bottom: 4 })
TextInput({ text: this.baseUrlInput, placeholder: DEFAULT_BASE_URL })
.height(44)
.fontSize(14)
.borderRadius(12)
.backgroundColor('#F7F5F6')
.padding({ left: 14, right: 14 })
.onChange((value: string) => {
this.baseUrlInput = value
@@ -281,7 +504,7 @@ struct LoginPage {
}
.width('100%')
Text('内测版需连接指定网络;如无法登录,请联系测试人员')
Text('内测通道仅供开发联调;如无法登录,请联系测试人员')
.fontSize(11)
.fontColor('#B3B3B3')
}
@@ -294,19 +517,7 @@ struct LoginPage {
.borderRadius(16)
.backgroundColor('#FFFFFF')
// 登录按钮
Button(this.logging ? '正在进入…' : '进入美搭')
.width('100%')
.height(50)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.borderRadius(25)
.backgroundColor(COLOR_PRIMARY)
.fontColor('#FFFFFF')
.enabled(!this.logging && this.deviceKey.trim() !== '')
.onClick(() => this.doLogin())
// 错误横幅(登录/地址保存失败)
// 错误横幅(登录/发送验证码/地址保存失败)
ErrorBanner({
message: this.errMessage,
hint: this.errHint,
+282 -39
View File
@@ -1,30 +1,36 @@
/**
* ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。
* ReportCreatePage —— 报告创建(F3):必选照片 + 问卷六字段 + 提交 + 任务轮询。
*
* 流程:
* 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验;
* 1) 个人照片(必选,正面全身照):先走 FacePhotoConsent 人脸单独授权(CR-01),
* 再相册/拍照;提交时先上传(POST /uploads/direct,本地磁盘降级路线)
* 拿真实 object_key,再建报告——模拟键已被服务端存在性校验封死
* 2) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验;
* 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入
* 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg
* 3) POST /api/v1/reports(request_key 随机 hex;任务 FAILED 后重试换新 key,
* 服务端幂等重放不过滤状态,沿用旧 key 只会重放回原失败任务)
* 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录,
* 无记录时实时拉报告列表兜底,重启后也找得回)
* 4) 40920(本期已生成)→ 页内对话框兜底:后端已不再抛 40920(重复生成改由
* report_basic 配额兜底),此处仅为兼容旧端的兜底分支,可跳最近一次报告
* (AppStorage 记录,无记录时实时拉报告列表兜底,重启后也找得回)
* 5) 成功 → 3 秒轮询任务(TaskPollingPanel,页面销毁自动清定时器,轮询期间允许返回)
* 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 新 key 重试
* 7) 提交前弹配额确认框(report_basic 终身 1 次,产品方案设计纪律 3「禁止静默扣减」)
* 7) 提交前弹配额确认框(report_basic 配额,产品方案设计纪律 3「禁止静默扣减」)
* 8) 表单字段做 AppStorage 草稿(f3_report_*):aboutToAppear 恢复、变更即写回,
* 提交成功不清(用户可能再生成);photoUri 不持久化(媒体 URI 会失效)
*/
import { router, componentUtils } from '@kit.ArkUI'
import { ApiClient } from '../core/ApiClient'
import { ApiError } from '../core/ApiTypes'
import { AuthStore } from '../core/AuthStore'
import { ImageCodec } from '../core/ImageCodec'
import { UploadApi } from '../core/UploadApi'
import { Tracker } from '../core/Tracker'
import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme'
import { TaskPollingPanel } from '../components/f3/TaskPollingPanel'
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
import {
ReportCreateResp,
ReportListItemWire,
buildMockPhotoKey,
formatDate,
randomHex
} from '../components/f3/ReportTypes'
@@ -33,6 +39,14 @@ const GENDER_OPTIONS: string[] = ['女', '男', '不便告知']
const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮肉', '修饰肩颈']
const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年']
const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id'
// 本页表单草稿(f3_ 前缀,与 DiagnosisFormPage 同套风格):跨页面保留已填内容,
// 提交成功不清(用户可能再生成);photoUri 不持久化(媒体 URI 会失效)。
const DRAFT_GENDER_KEY: string = 'f3_report_gender'
const DRAFT_HEIGHT_KEY: string = 'f3_report_height'
const DRAFT_WEIGHT_KEY: string = 'f3_report_weight'
const DRAFT_BIRTHDAY_KEY: string = 'f3_report_birthday'
const DRAFT_GOALS_KEY: string = 'f3_report_goals' // 多选用 '|' 拼接成串存储
const DRAFT_STYLE_KEY: string = 'f3_report_style'
// 身高/体重改为选择器(不允许键盘输入):数值范围与原校验一致
const HEIGHT_MIN: number = 100
@@ -79,7 +93,10 @@ struct ReportCreatePage {
@State birthday: Date = new Date(2000, 0, 15)
@State coreGoals: string[] = []
@State psychStyle: string = ''
@State photoKeyInput: string = ''
@State photoUri: string = '' // 已选照片(媒体 URI);空 = 未选,提交必校验
@State photoErr: string = ''
@State showFaceConsent: boolean = false
@State showPhotoSource: boolean = false
@State genderErr: string = ''
@State heightErr: string = ''
@State weightErr: string = ''
@@ -106,30 +123,85 @@ struct ReportCreatePage {
if (cached !== undefined && cached !== '') {
this.lastReportId = cached
}
// 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6):只填四字段,
// 核心诉求/心理风格仍由用户在本页补齐;无参数时行为与旧直达完全一致。
// 先恢复本页草稿(跨页面保留已填内容),再应用路由参数:
// 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6)时参数优先,
// 并同步更新草稿;无参数时行为与旧直达完全一致。
this.restoreDraft()
const params = router.getParams() as Record<string, Object> | undefined
if (params !== undefined) {
let paramsApplied = false
if (typeof params['gender'] === 'string' && params['gender'].length > 0) {
this.gender = params['gender'] as string
paramsApplied = true
}
if (typeof params['birthday'] === 'string' && params['birthday'].length >= 8) {
const d = new Date(params['birthday'] as string)
if (!isNaN(d.getTime())) {
this.birthday = d
paramsApplied = true
}
}
if (typeof params['height_cm'] === 'string' && (params['height_cm'] as string).length > 0) {
this.heightInput = params['height_cm'] as string
paramsApplied = true
}
if (typeof params['weight_kg'] === 'string' && (params['weight_kg'] as string).length > 0) {
this.weightInput = params['weight_kg'] as string
paramsApplied = true
}
if (paramsApplied) {
this.saveDraft()
}
}
}
/** 恢复草稿(photoUri 不在草稿内:媒体 URI 会失效,每次需重选)。 */
private restoreDraft(): void {
const gender = AppStorage.get<string>(DRAFT_GENDER_KEY)
if (gender !== undefined) {
this.gender = gender
}
const height = AppStorage.get<string>(DRAFT_HEIGHT_KEY)
if (height !== undefined) {
this.heightInput = height
}
const weight = AppStorage.get<string>(DRAFT_WEIGHT_KEY)
if (weight !== undefined) {
this.weightInput = weight
}
const birthday = AppStorage.get<string>(DRAFT_BIRTHDAY_KEY)
if (birthday !== undefined && birthday.length >= 8) {
const d = new Date(birthday)
if (!isNaN(d.getTime())) {
this.birthday = d
}
}
const goals = AppStorage.get<string>(DRAFT_GOALS_KEY)
if (goals !== undefined && goals !== '') {
this.coreGoals = goals.split('|')
}
const style = AppStorage.get<string>(DRAFT_STYLE_KEY)
if (style !== undefined) {
this.psychStyle = style
}
}
/** 草稿写回(write-through):每次字段变更即落 AppStorage;提交成功不清。 */
private saveDraft(): void {
AppStorage.setOrCreate<string>(DRAFT_GENDER_KEY, this.gender)
AppStorage.setOrCreate<string>(DRAFT_HEIGHT_KEY, this.heightInput)
AppStorage.setOrCreate<string>(DRAFT_WEIGHT_KEY, this.weightInput)
AppStorage.setOrCreate<string>(DRAFT_BIRTHDAY_KEY, formatDate(this.birthday))
AppStorage.setOrCreate<string>(DRAFT_GOALS_KEY, this.coreGoals.join('|'))
AppStorage.setOrCreate<string>(DRAFT_STYLE_KEY, this.psychStyle)
}
private validateForm(): boolean {
let ok = true
this.photoErr = this.photoUri === '' ? '请上传个人照片' : ''
if (this.photoErr !== '') {
ok = false
}
this.genderErr = this.gender === '' ? '请选择性别' : ''
if (this.genderErr !== '') {
ok = false
@@ -162,6 +234,7 @@ struct ReportCreatePage {
this.coreGoals.push(g)
}
this.goalsErr = ''
this.saveDraft()
}
private isSelectedGoal(g: string): boolean {
@@ -186,6 +259,7 @@ struct ReportCreatePage {
if (idx >= 0 && idx < HEIGHT_OPTIONS.length) {
this.heightInput = HEIGHT_OPTIONS[idx]
this.heightErr = ''
this.saveDraft()
}
}
})
@@ -203,11 +277,50 @@ struct ReportCreatePage {
if (idx >= 0 && idx < WEIGHT_OPTIONS.length) {
this.weightInput = WEIGHT_OPTIONS[idx]
this.weightErr = ''
this.saveDraft()
}
}
})
}
/**
* 照片入口:人脸属敏感个人信息,上传前须单独同意(CR-01 / FR-ACC-08);
* 已授权则直接弹来源选择(相册/拍照)。
*/
private onPhotoTap(): void {
this.photoErr = ''
if (!FaceConsentStore.isGranted()) {
this.showFaceConsent = true
return
}
this.showPhotoSource = true
}
private onConsentAgree(): void {
FaceConsentStore.grant()
this.showFaceConsent = false
this.showPhotoSource = true
}
private onConsentRefuse(): void {
this.showFaceConsent = false
this.photoErr = '需同意照片授权后才能上传'
}
private async doPickPhoto(fromCamera: boolean): Promise<void> {
this.showPhotoSource = false
try {
const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery()
if (picked === undefined) {
return // 用户取消:保持原状
}
this.photoUri = picked.uri
this.photoErr = ''
} catch (e) {
this.photoErr = e instanceof ApiError ? e.message : '读取照片失败,请重试'
}
}
private onSubmitTap(): void {
if (this.submitting) {
return
@@ -225,7 +338,7 @@ struct ReportCreatePage {
this.openQuotaConfirm()
}
/** 打开配额确认框并拉取当前剩余(首份报告为终身 1 次权益)。 */
/** 打开配额确认框并拉取当前剩余(report_basic 终身配额)。 */
private openQuotaConfirm(): void {
this.showQuotaConfirm = true
this.quotaLoaded = false
@@ -247,6 +360,9 @@ struct ReportCreatePage {
private missingLabels(): string[] {
const labels: string[] = []
if (this.photoErr !== '') {
labels.push('个人照片')
}
if (this.genderErr !== '') {
labels.push('性别')
}
@@ -267,7 +383,9 @@ struct ReportCreatePage {
private scrollToFirstError(): void {
let firstId = ''
if (this.genderErr !== '') {
if (this.photoErr !== '') {
firstId = 'field_photo'
} else if (this.genderErr !== '') {
firstId = 'field_gender'
} else if (this.heightErr !== '' || this.weightErr !== '') {
firstId = 'field_height'
@@ -310,8 +428,21 @@ struct ReportCreatePage {
'psych_style': this.psychStyle
}
const qObj: Object = questionnaire
const photoInput = this.photoKeyInput.trim()
const photoKey = photoInput !== '' ? photoInput : buildMockPhotoKey(AuthStore.getUserId() ?? 'anonymous')
// 先上传照片拿真实 object_key(本机后端磁盘直传),成功后再建报告;
// 上传失败不扣报告额度(服务端存在性校验也挡在配额预扣之前)。
let photoKey: string = ''
try {
const jpegBuf = await ImageCodec.toJpegBuffer(this.photoUri)
photoKey = await UploadApi.uploadReportPhoto(jpegBuf)
} catch (e) {
this.submitting = false
if (e instanceof ApiError) {
this.submitError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
} else {
this.submitError = '照片处理失败,请重试'
}
return
}
const req: Record<string, Object> = {
'questionnaire': qObj,
'photo_object_key': photoKey,
@@ -337,7 +468,8 @@ struct ReportCreatePage {
this.submitting = false
if (e instanceof ApiError) {
if (e.code === 40920) {
// 本期报告已生成:始终给「查看我的报告」入口(无本地记录也能从服务端找回)
// 兜底分支:后端已不再抛 40920(重复生成由 report_basic 配额兜底),
// 仅兼容旧端;始终给「查看我的报告」入口(无本地记录也能从服务端找回)
this.showDupDialog = true
return
}
@@ -447,7 +579,7 @@ struct ReportCreatePage {
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('填写 6 项基础信息,约 1~3 分钟生成 32 维报告')
Text('上传 1 张正面全身照 + 填写 6 项基础信息,约 1~3 分钟生成 32 维报告')
.fontSize(12)
.fontColor('#999999')
}
@@ -455,6 +587,65 @@ struct ReportCreatePage {
.alignItems(HorizontalAlign.Start)
.padding({ top: 4, bottom: 4 })
Column({ space: 14 }) {
Text('个人照片')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Column({ space: 8 }) {
this.FieldLabel({ label: '正面全身照', err: this.photoErr, required: true })
if (this.photoUri === '') {
Column({ space: 6 }) {
Text('+')
.fontSize(28)
.fontColor('#999999')
Text('上传个人照片')
.fontSize(13)
.fontColor('#999999')
}
.width('100%')
.aspectRatio(0.75) // 竖版 3:4:与全身照取景一致,不再用横条占位
.justifyContent(FlexAlign.Center)
.backgroundColor('#F5F6F8')
.borderRadius(10)
.border({ width: 1, color: '#DDDDDD', style: BorderStyle.Dashed })
.onClick(() => {
this.onPhotoTap()
})
} else {
Stack() {
Image(this.photoUri)
.width('100%')
.aspectRatio(0.75) // 竖版 3:4:竖屏全身照不再被上下截断
.objectFit(ImageFit.Cover)
.borderRadius(10)
Text('重选')
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#80000000')
.borderRadius(12)
.margin(8)
.onClick(() => {
this.onPhotoTap()
})
}
.alignContent(Alignment.BottomEnd)
}
Text('请用本人正面全身照:正面站立、双手自然下垂、纯色背景、自然光,头到脚完整入镜')
.fontSize(11)
.fontColor('#999999')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Start)
.id('field_photo')
Column({ space: 14 }) {
Text('基础信息')
.fontSize(15)
@@ -473,6 +664,7 @@ struct ReportCreatePage {
.onClick(() => {
this.gender = g
this.genderErr = ''
this.saveDraft()
})
}, (g: string) => g)
}
@@ -557,6 +749,7 @@ struct ReportCreatePage {
})
.onDateChange((d: Date) => {
this.birthday = d
this.saveDraft()
})
}
}
@@ -609,6 +802,7 @@ struct ReportCreatePage {
.onClick(() => {
this.psychStyle = s
this.styleErr = ''
this.saveDraft()
})
}, (s: string) => s)
}
@@ -623,23 +817,6 @@ struct ReportCreatePage {
.borderRadius(14)
.backgroundColor('#FFFFFF')
// 照片区(#6):上传能力未开放,只做预告;对象键输入框对用户隐藏
// (后端本就自动生成 report_photo/{userId}/{随机}.jpg,输入无用户价值)
Column({ space: 8 }) {
Text('照片(选填)')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Text('上传照片可以让报告更准,即将开放')
.fontSize(11)
.fontColor('#999999')
}
.width('100%')
.padding(16)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Start)
if (this.formSummary !== '') {
Text(this.formSummary)
.fontSize(13)
@@ -771,15 +948,15 @@ struct ReportCreatePage {
@Builder
DupDialog() {
Column({ space: 14 }) {
Text('本期报告已生成')
Text('已有生成过的报告')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('首份形象报告为一次性权益,已生成的报告可随时查看。')
Text('可直接查看最近一次的报告,也可返回修改后重新提交生成新报告。')
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
Text('将为你打开最近一次生成的报告。')
Text('选择「查看我的报告」将为你打开最近一次生成的报告。')
.fontSize(12)
.fontColor('#999999')
Row({ space: 12 }) {
@@ -810,7 +987,7 @@ struct ReportCreatePage {
.backgroundColor('#FFFFFF')
}
/** 提交前配额确认框(#5:终身 1 次权益禁止静默扣减)。 */
/** 提交前配额确认框(#5:消耗额度禁止静默扣减)。 */
@Builder
QuotaConfirmDialog() {
Column({ space: 14 }) {
@@ -818,7 +995,7 @@ struct ReportCreatePage {
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('首份形象报告为一次性权益,本次生成将使用 1 次。')
Text('生成报告将消耗 1 次报告额度。')
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
@@ -862,6 +1039,50 @@ struct ReportCreatePage {
.backgroundColor('#FFFFFF')
}
/** 照片来源选择(相册/拍照):授权通过后弹出。 */
@Builder
PhotoSourceDialog() {
Column({ space: 14 }) {
Text('上传个人照片')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('请上传本人正面全身照,照片仅用于本次形象诊断')
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
Button('相册选择')
.width('100%')
.height(42)
.fontSize(15)
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.doPickPhoto(false)
})
Button('拍照')
.width('100%')
.height(42)
.fontSize(15)
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.doPickPhoto(true)
})
Button('取消')
.width('100%')
.height(42)
.fontSize(14)
.backgroundColor('#F3F4F6')
.fontColor('#666666')
.onClick(() => {
this.showPhotoSource = false
})
}
.width('82%')
.padding(20)
.borderRadius(16)
.backgroundColor('#FFFFFF')
}
build() {
Stack() {
Column() {
@@ -877,6 +1098,28 @@ struct ReportCreatePage {
.width('100%')
.height('100%')
if (this.showFaceConsent) {
FacePhotoConsent({
usageText: '用途:照片仅用于为你生成形象诊断报告,不用于识别你的身份,也不做其他用途的分析。',
onAgree: (): void => {
this.onConsentAgree()
},
onRefuse: (): void => {
this.onConsentRefuse()
}
})
}
if (this.showPhotoSource) {
Column() {
this.PhotoSourceDialog()
}
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.justifyContent(FlexAlign.Center)
}
if (this.showDupDialog) {
Column() {
this.DupDialog()
+39 -1
View File
@@ -20,11 +20,27 @@ import { common } from '@kit.AbilityKit'
import { picker, fileUri } from '@kit.CoreFileKit'
import fs from '@ohos.file.fs'
import { ContextHolder } from '../core/ContextHolder'
import { ApiClient } from '../core/ApiClient'
import { ApiError } from '../core/ApiTypes'
import { TaskBonusState, PERIOD_DAY } from '../core/TaskBonusState'
import { Tracker } from '../core/Tracker'
import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme'
const PAGE_BG: string = '#F5F6F8'
const TEXT_PRIMARY: string = '#222222'
/** POST /quota/tasks/share_report/claim 响应(share_report 任务闭环,T2b)。 */
interface ClaimWire {
task_key: string
quota_key: string
amount: number
bonus_granted: number
remaining: number
}
/** 本周期已领(与后端 errors.py 对齐)。 */
const CODE_ALREADY_CLAIMED: number = 40910
interface PdfRouteParams {
pdfPath?: string
title?: string
@@ -100,13 +116,35 @@ struct ReportPdfPage {
previewMode: systemShare.SharePreviewMode.DEFAULT
}
await controller.show(ContextHolder.get() as common.UIAbilityContext, opts)
// 面板正常收起(含用户主动取消):不打扰,不提示
// 面板正常收起(含用户主动取消):不打扰,不提示。
// share_report 任务闭环(T2b):调起成功即视为完成(端侧自报,内测水位,
// 产品方案 §3.2 决策理由 4);埋点 + 静默 claim 互不阻断。
Tracker.track('report.share_invoke')
this.claimShareBonus()
} catch (e) {
console.error(`PDF share failed: ${(e as Error).message}`)
this.setActionText('分享失败,请重试')
}
}
/**
* share_report 任务真实闭环:奖励到账提示;已领(40910)静默但同步已领态,
* 其他失败静默——分享动作本身已完成,不拿奖励失败打扰用户。
*/
private async claimShareBonus(): Promise<void> {
try {
const req: Record<string, Object> = {}
const d = await ApiClient.post<ClaimWire>('/api/v1/quota/tasks/share_report/claim', req)
await TaskBonusState.markClaimed('share_report', PERIOD_DAY)
this.setActionText(`感谢分享,额度 +${d.amount}`)
} catch (e) {
if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) {
// 今日已领:静默不打扰,但同步已领态(任务面板显示一致)
await TaskBonusState.markClaimed('share_report', PERIOD_DAY)
}
}
}
private async saveAs(): Promise<void> {
try {
const docPicker = new picker.DocumentViewPicker(ContextHolder.get())
+514 -91
View File
@@ -16,6 +16,17 @@
* (服务端幂等重放不过滤状态,同 key 只会重放回原失败任务)
* - 导出 PDF / 重新生成均为消耗配额的操作:提交前弹确认框展示本次代价
* 与剩余次数(产品方案设计纪律 3「禁止静默扣减」)
* - 重新生成遇 40900(早期种子报告无 rp_input 输入行)弹对话框引导去生成
* 新报告,其它错误维持底部红字;底部另有「查看历史报告」次级入口
* (pushUrl HomePage + initialTab=1 落地「报告」Tab)
* - 深度诊断(tier=full):basic 报告页底部的入口卡——未生成显示内容清单 +
* 「生成深度诊断报告」(配额确认框 → POST /reports/{id}/full → 同页轮询 →
* 采纳新 report_id 重拉,幂等键 FAILED 换新,模式同 doRegenerate);
* 已生成显示「查看深度诊断报告」(进页时 GET /reports?limit=20 找最新
* tier=full 且 SUCCEEDED 的一份);当前页本身是 full 报告时卡片换成
* 「查看基础版报告」,防止 full→full 套娃
* - full 报告增量渲染:result.full 存在时在 32 维诊断区之后渲染十二季色板
* (色块 chips)、4 场景穿搭(分组卡)、30 天计划(按周)三个区块
*/
import { router, promptAction } from '@kit.ArkUI'
@@ -23,7 +34,7 @@ import { ApiClient } from '../core/ApiClient'
import { ApiError, isAuthError } from '../core/ApiTypes'
import { AuthStore } from '../core/AuthStore'
import { ContextHolder } from '../core/ContextHolder'
import { TaskBonusState, PERIOD_DAY } from '../core/TaskBonusState'
import { TaskBonusState, PERIOD_DAY, PERIOD_LIFETIME } from '../core/TaskBonusState'
import { Tracker } from '../core/Tracker'
import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme'
import request from '@ohos.request'
@@ -35,8 +46,14 @@ import { LoadingView } from '../components/f2/LoadingView'
import {
CopySectionWire,
DimensionWire,
FullOutfitWire,
FullResultWire,
FullSceneWire,
PaletteColorWire,
PlanWeekWire,
ReportCreateResp,
ReportDetailWire,
ReportListItemWire,
ReportRouteParams,
formatIsoTime,
randomHex,
@@ -52,7 +69,7 @@ interface QuotaWireResp {
quota_key: string
used: number
limit_value: number
bonus_granted: boolean
bonus_granted: number
remaining: number
reset_at: string
}
@@ -81,22 +98,16 @@ interface ClaimWire {
remaining: number
}
/** POST /reservations 响应(T-P3-22)。created=false 表示幂等命中已有记录。 */
interface ReservationWire {
topic: string
created: boolean
created_at: string
}
/** GET /reservations?topic=… 响应。 */
interface ReservationListWire {
topics: string[]
}
/** 本周期已领(与后端 errors.py 对齐)。 */
const CODE_ALREADY_CLAIMED: number = 40910
/** 预约主题(T-P3-22 首期唯一值)。 */
const RESERVE_TOPIC: string = 'full_report'
/** 状态冲突(与后端 errors.py 对齐):重新生成时输入缺失的早期种子报告走此码。 */
const CODE_STATE_CONFLICT: number = 40900
/** PaletteGroup @Builder 的引用传递参数($$ 形态,数组重赋值才能刷新)。 */
interface PaletteGroupParams {
title: string
colors: PaletteColorWire[]
}
@Entry
@Component
@@ -119,6 +130,7 @@ struct ReportResultPage {
@State regenerating: boolean = false
@State regenTaskId: string = ''
@State regenError: string = ''
@State showRegenMissingDialog: boolean = false // 40900:早期种子报告无 rp_input,引导去生成新报告
private regenRequestKey: string = '' // 任务 FAILED 后置空:重试换新 key(同 key 只会重放回失败任务)
@State exporting: boolean = false
@State exportTaskId: string = ''
@@ -131,9 +143,22 @@ struct ReportResultPage {
// —— 结论反馈(T-P3-21):dimension_key → verdict 回显表 ——
@State feedbackVerdicts: Record<string, string> = {}
@State feedbackSubmitting: string = '' // 提交中的 dimension_key(防空点)
// —— 全面报告预约留资卡(T-P3-22)——
@State reserved: boolean = false
@State reserveLoading: boolean = false
// —— 深度诊断(tier=full):入口卡 + 增量渲染 ——
@State tier: string = 'basic'
@State fullReportId: string = '' // 已有 SUCCEEDED 的 full 报告(进页时列表查出)
@State basicReportId: string = '' // full 报告页「查看基础版报告」的目标
@State fullGenerating: boolean = false
@State fullTaskId: string = ''
@State fullError: string = ''
private fullRequestKey: string = '' // 任务 FAILED 后置空:重试换新 key
@State hasFull: boolean = false
@State fullSeasonName: string = ''
@State fullSeasonAnalysis: string = ''
@State paletteBest: PaletteColorWire[] = []
@State paletteNeutral: PaletteColorWire[] = []
@State paletteAvoid: PaletteColorWire[] = []
@State fullScenes: FullSceneWire[] = []
@State fullPlan: PlanWeekWire[] = []
private cardViewTracked: boolean = false // full_report.card_view 每进入结果页只打一次
aboutToAppear(): void {
@@ -159,6 +184,7 @@ struct ReportResultPage {
this.loadErrorAuth = false
try {
const d = await ApiClient.get<ReportDetailWire>(`/api/v1/reports/${this.reportId}`)
this.tier = d.tier ?? 'basic'
this.versionLabel = d.version !== undefined && d.version !== null ? `v${d.version}` : 'v1'
// 后端报告状态为小写(succeeded/failed/…),统一转大写供下方状态门禁比较
this.statusRaw = (d.status ?? '').toUpperCase()
@@ -177,6 +203,16 @@ struct ReportResultPage {
this.totalLabel = total !== undefined && total !== null ? `${total}` : `${dims.length}`
const basicFull = result?.diagnosis?.coverage?.basic_full
this.basicFullText = basicFull === true ? '基础信息完整' : (basicFull === false ? '基础信息不完整' : '')
// 深度诊断增量内容(tier=full 才有;basic 报告恒空,区块不渲染)
const full: FullResultWire | undefined = result?.full
this.hasFull = full !== undefined && full !== null
this.fullSeasonName = full?.season?.name_cn ?? ''
this.fullSeasonAnalysis = full?.season?.analysis ?? ''
this.paletteBest = full?.palette?.best ?? []
this.paletteNeutral = full?.palette?.neutral ?? []
this.paletteAvoid = full?.palette?.avoid ?? []
this.fullScenes = full?.scenes ?? []
this.fullPlan = full?.plan30 ?? []
const rid = d.report_id
if (rid !== undefined && rid !== '') {
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, rid)
@@ -186,15 +222,19 @@ struct ReportResultPage {
// 一并换新,防止新报告复用旧导出键撞后端 IDEMPOTENCY_CONFLICT(#22)
this.regenRequestKey = ''
this.exportRequestKey = ''
this.fullRequestKey = ''
if (this.statusRaw === 'SUCCEEDED') {
// 反馈回显 + report_feedback 任务闭环的数据源(T-P3-21)
this.loadFeedback()
// 预约留资卡(T-P3-22):卡片渲染即曝光埋点,每进入结果页只打一次
// first_report 任务闭环(T2b):报告成功展示的入口路径上静默 claim
// (lifetime 周期,后端幂等;领过则 40910 静默同步已领态)
this.claimFirstReport()
// 深度诊断入口卡:卡片渲染即曝光埋点,每进入结果页只打一次
if (!this.cardViewTracked) {
this.cardViewTracked = true
Tracker.track('full_report.card_view')
}
this.loadReservation()
this.loadFullReportList()
}
} catch (e) {
this.loading = false
@@ -234,6 +274,12 @@ struct ReportResultPage {
this.regenerating = true // 同页复用轮询(TaskPollingPanel),SUCCEEDED 后自动重拉
} catch (e) {
if (e instanceof ApiError) {
if (e.code === CODE_STATE_CONFLICT) {
// 40900:早期种子报告没有 rp_input 输入行,无法复用照片/问卷直接重新生成。
// 一行红字感知太弱(像"点了没反应"),改弹对话框引导去生成新报告。
this.showRegenMissingDialog = true
return
}
this.regenError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
} else {
this.regenError = '请求失败,请检查网络'
@@ -333,9 +379,9 @@ struct ReportResultPage {
this.exportRequestKey = ''
}
/** 打开配额确认框(regen=report_regen 每 30 天 1 次 / export=hd_export 每日 1 次)。 */
/** 打开配额确认框(regen=report_regen 每 30 天 1 次 / export=hd_export 每日 1 次 / full=full_report 每日 1 次)。 */
private openConfirm(action: string): void {
if (action !== 'regen' && action !== 'export') {
if (action !== 'regen' && action !== 'export' && action !== 'full') {
return
}
if (action === 'regen' && (this.regenerating || this.reportId === '')) {
@@ -344,6 +390,9 @@ struct ReportResultPage {
if (action === 'export' && (this.exporting || this.reportId === '')) {
return
}
if (action === 'full' && (this.fullGenerating || this.reportId === '')) {
return
}
this.confirmAction = action
this.confirmLoaded = false
this.confirmRemaining = -1
@@ -352,7 +401,9 @@ struct ReportResultPage {
}
private async loadConfirmQuota(): Promise<void> {
const key = this.confirmAction === 'export' ? 'hd_export' : 'report_regen'
const key = this.confirmAction === 'export'
? 'hd_export'
: (this.confirmAction === 'full' ? 'full_report' : 'report_regen')
try {
const d = await ApiClient.get<QuotaWireResp>(`/api/v1/quota?quota_key=${key}`)
this.confirmRemaining = d.remaining
@@ -372,6 +423,8 @@ struct ReportResultPage {
this.doRegenerate()
} else if (action === 'export') {
this.doExport()
} else if (action === 'full') {
this.doFullGenerate()
}
}
@@ -447,39 +500,106 @@ struct ReportResultPage {
}
}
// ==================== 全面报告预约留资卡(T-P3-22) ====================
/** 拉预约态决定按钮;失败保持未预约态(用户可点击,服务端幂等兜底)。 */
private async loadReservation(): Promise<void> {
/**
* first_report 任务真实闭环(T2b):报告成功展示即完成首次速测,静默 claim;
* 已领(40910,含 lifetime 重复进页)静默同步已领态,其他失败静默——
* 报告展示是主流程,奖励失败不打扰。
*/
private async claimFirstReport(): Promise<void> {
try {
const d = await ApiClient.get<ReservationListWire>(`/api/v1/reservations?topic=${RESERVE_TOPIC}`)
this.reserved = d.topics.includes(RESERVE_TOPIC)
} catch (_e) {
this.reserved = false
const req: Record<string, Object> = {}
const d = await ApiClient.post<ClaimWire>('/api/v1/quota/tasks/first_report/claim', req)
await TaskBonusState.markClaimed('first_report', PERIOD_LIFETIME)
this.toast(`形象速测完成,额度 +${d.amount}`)
} catch (e) {
if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) {
await TaskBonusState.markClaimed('first_report', PERIOD_LIFETIME)
}
// 其他失败静默
}
}
private async doReserve(): Promise<void> {
if (this.reserved || this.reserveLoading) {
// ==================== 深度诊断(tier=full)入口卡 ====================
/**
* 查报告列表(limit=20 对齐首页报告 Tab):找最新一份 tier=full 且
* SUCCEEDED 的报告(已生成态 → 「查看深度诊断报告」),同时记下最新
* basic 报告 id(full 报告页「查看基础版报告」的跳转目标)。
* 失败静默:卡片按未生成态展示,生成按钮仍可用(服务端幂等兜底)。
*/
private async loadFullReportList(): Promise<void> {
try {
const list = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports?limit=20')
let fullId: string = ''
let basicId: string = ''
for (const item of list) {
const ok = (item.status ?? '').toUpperCase() === 'SUCCEEDED'
const rid = item.report_id ?? ''
if (!ok || rid === '') {
continue
}
if (fullId === '' && item.tier === 'full') {
fullId = rid
}
if (basicId === '' && item.tier !== 'full') {
basicId = rid
}
}
this.fullReportId = fullId
this.basicReportId = basicId
} catch (_e) {
this.fullReportId = ''
this.basicReportId = ''
}
}
/** 生成深度诊断:模式同 doRegenerate(新 report_id 必须采纳,轮询成功本页重拉)。 */
private async doFullGenerate(): Promise<void> {
if (this.fullGenerating || this.reportId === '') {
return
}
this.reserveLoading = true
try {
const req: Record<string, Object> = { 'topic': RESERVE_TOPIC }
const d = await ApiClient.post<ReservationWire>('/api/v1/reservations', req)
this.reserved = true // created=false(幂等命中)同样是已预约态
Tracker.track('full_report.reserve_result', { 'ok': true })
if (d.created) {
this.toast('预约成功,上线后第一时间告诉你')
}
} catch (e) {
Tracker.track('full_report.reserve_result', {
'ok': false, 'code': e instanceof ApiError ? e.code : 50000
})
this.toast(e instanceof ApiError ? e.message : '预约失败,请稍后重试')
} finally {
this.reserveLoading = false
this.fullError = ''
if (this.fullRequestKey === '') {
this.fullRequestKey = randomHex(16)
}
const req: Record<string, Object> = { 'request_key': this.fullRequestKey }
try {
const data = await ApiClient.post<ReportCreateResp>(`/api/v1/reports/${this.reportId}/full`, req)
const tid = data.task_id ?? ''
if (tid === '') {
this.fullError = '服务端未返回任务 ID,请稍后重试'
return
}
// 深度诊断在后端是新建一行报告(tier=full、新 report_id):必须采纳新 id,
// 否则轮询成功后 loadReport 拉回的仍是基础版报告
const newRid = data.report_id ?? ''
if (newRid !== '') {
this.reportId = newRid
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, newRid)
}
this.fullTaskId = tid
this.fullGenerating = true
} catch (e) {
if (e instanceof ApiError) {
this.fullError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
} else {
this.fullError = '请求失败,请检查网络'
}
}
}
private onFullSucceeded(): void {
this.fullGenerating = false
this.fullError = ''
this.fullReportId = this.reportId // 本页已是 full 报告
this.loadReport()
}
private onFullFailed(reason: string): void {
this.fullGenerating = false
this.fullError = `深度诊断生成失败:${reason}`
// 清空幂等键:重试以新 key 创建新任务(旧失败任务的额度已由服务端退还)
this.fullRequestKey = ''
}
private dimKey(d: DimensionWire): string {
@@ -566,24 +686,50 @@ struct ReportResultPage {
.padding({ left: 24, right: 24 })
}
private confirmTitle(): string {
if (this.confirmAction === 'export') {
return '确认导出 PDF'
}
if (this.confirmAction === 'full') {
return '确认生成深度诊断'
}
return '确认重新生成'
}
private confirmDesc(): string {
if (this.confirmAction === 'export') {
return '导出 PDF 每日可使用 1 次,本次将使用 1 次。'
}
if (this.confirmAction === 'full') {
return '深度诊断每日可生成 1 次,本次将使用 1 次。'
}
return '重新生成每 30 天可使用 1 次,本次将使用 1 次。'
}
private confirmRemainText(): string {
if (this.confirmAction === 'export') {
return `今日还可导出 ${this.confirmRemaining} 次`
}
if (this.confirmAction === 'full') {
return `今日还可生成 ${this.confirmRemaining} 次`
}
return `本周期还可重新生成 ${this.confirmRemaining} 次`
}
/** 消耗配额前的确认框(#5):展示本次代价与剩余次数,剩余 0 时阻止提交。 */
@Builder
QuotaConfirmDialog() {
Column({ space: 14 }) {
Text(this.confirmAction === 'export' ? '确认导出 PDF' : '确认重新生成')
Text(this.confirmTitle())
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(this.confirmAction === 'export'
? '导出 PDF 每日可使用 1 次,本次将使用 1 次。'
: '重新生成每 30 天可使用 1 次,本次将使用 1 次。')
Text(this.confirmDesc())
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
if (this.confirmLoaded && this.confirmRemaining > 0) {
Text(this.confirmAction === 'export'
? `今日还可导出 ${this.confirmRemaining} 次`
: `本周期还可重新生成 ${this.confirmRemaining} 次`)
Text(this.confirmRemainText())
.fontSize(13)
.fontColor(COLOR_PRIMARY)
} else if (this.confirmLoaded && this.confirmRemaining <= 0) {
@@ -623,12 +769,53 @@ struct ReportResultPage {
.backgroundColor('#FFFFFF')
}
/** 40900 兜底引导:早期种子报告缺输入行,无法直接重新生成 → 引导去生成新报告。 */
@Builder
RegenMissingDialog() {
Column({ space: 14 }) {
Text('无法直接重新生成')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('该报告为早期版本,缺少可复用的照片与问卷信息。可以重新拍摄照片,生成一份全新报告。')
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
Row({ space: 12 }) {
Button('取消')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor('#F3F4F6')
.fontColor('#666666')
.onClick(() => {
this.showRegenMissingDialog = false
})
Button('去生成新报告')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.showRegenMissingDialog = false
router.pushUrl({ url: 'pages/ReportCreatePage' })
})
}
.width('100%')
.margin({ top: 4 })
}
.width('82%')
.padding(20)
.borderRadius(16)
.backgroundColor('#FFFFFF')
}
@Builder
HeaderCard() {
Column({ space: 6 }) {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('形象诊断报告')
Text(this.tier === 'full' ? '形象深度诊断报告' : '形象诊断报告')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
@@ -802,51 +989,103 @@ struct ReportResultPage {
.alignItems(HorizontalAlign.Start)
}
/** 全面报告预约留资卡(T-P3-22):模糊目录 teaser,无价格字样(FR-BILL-07 纪律同 apply)。 */
@Builder
ReservationCard() {
FullGenView() {
Column({ space: 16 }) {
Text('正在生成深度诊断报告')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('基于你的基础报告生成更完整的色彩、场景与计划内容,完成后自动展示')
.fontSize(13)
.fontColor('#999999')
.textAlign(TextAlign.Center)
TaskPollingPanel({
taskId: this.fullTaskId,
onSucceeded: (): void => {
this.onFullSucceeded()
},
onFailed: (reason: string): void => {
this.onFullFailed(reason)
}
})
.width('86%')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ left: 24, right: 24 })
}
/**
* 深度诊断入口卡(内容区底部)。三态:当前页是 full →「查看基础版报告」
* (防 full→full 套娃);已有 full →「查看深度诊断报告」(本页内采纳 id
* 重拉,不压路由栈);未生成 → 内容清单 +「生成深度诊断报告」(配额确认框)。
*/
@Builder
FullReportCard() {
Column({ space: 10 }) {
Text('形象深度诊断 · 即将上线')
Text(this.tier === 'full' ? '当前为深度诊断报告' : '形象深度诊断')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
Column({ space: 6 }) {
Text('· 十二季色彩完整色板')
.fontSize(13)
.fontColor('#555555')
.width('100%')
Text('· 4+ 场景穿搭方案')
.fontSize(13)
.fontColor('#555555')
.width('100%')
Text('· 30 天形象提升计划')
.fontSize(13)
.fontColor('#555555')
.width('100%')
if (this.tier !== 'full' && this.fullReportId === '') {
Column({ space: 6 }) {
Text('· 十二季色彩完整色板')
.fontSize(13)
.fontColor('#555555')
.width('100%')
Text('· 4+ 场景穿搭方案')
.fontSize(13)
.fontColor('#555555')
.width('100%')
Text('· 30 天形象提升计划')
.fontSize(13)
.fontColor('#555555')
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
if (this.reserved) {
Text('已预约,上线后第一时间告诉你')
.fontSize(14)
.fontColor('#9AA0A6')
.width('100%')
.height(44)
.textAlign(TextAlign.Center)
.borderRadius(22)
.backgroundColor('#F3F4F6')
} else {
Button(this.reserveLoading ? '预约中…' : '抢先预约,上线提醒我')
if (this.tier === 'full') {
if (this.basicReportId !== '') {
Button('查看基础版报告')
.width('100%')
.height(44)
.fontSize(15)
.fontColor(COLOR_PRIMARY)
.backgroundColor('#F7E3EA')
.borderRadius(22)
.onClick(() => {
this.reportId = this.basicReportId
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, this.basicReportId)
this.loadReport()
})
}
} else if (this.fullReportId !== '') {
Button('查看深度诊断报告')
.width('100%')
.height(44)
.fontSize(15)
.fontColor(Color.White)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(22)
.enabled(!this.reserveLoading)
.onClick(() => {
this.doReserve()
this.reportId = this.fullReportId
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, this.fullReportId)
this.loadReport()
})
} else {
Button(this.fullError !== '' ? '重试生成深度诊断报告' : '生成深度诊断报告')
.width('100%')
.height(44)
.fontSize(15)
.fontColor(Color.White)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(22)
.onClick(() => {
this.openConfirm('full')
})
}
}
@@ -857,6 +1096,154 @@ struct ReportResultPage {
.alignItems(HorizontalAlign.Start)
}
// ==================== 深度诊断增量渲染(result.full 存在时) ====================
@Builder
PaletteGroup($$: PaletteGroupParams) {
if ($$.colors.length > 0) {
Column({ space: 8 }) {
Text(`${$$.title}(${$$.colors.length} 色)`)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#555555')
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach($$.colors, (c: PaletteColorWire, idx: number) => {
Column({ space: 4 }) {
Blank()
.width(44)
.height(30)
.borderRadius(6)
.backgroundColor(c.hex ?? '#CCCCCC')
.border({ width: 1, color: '#E5E7EB' })
Text(c.name ?? '')
.fontSize(10)
.fontColor('#666666')
.maxLines(1)
}
.width(56)
.alignItems(HorizontalAlign.Center)
.margin({ bottom: 8 })
}, (c: PaletteColorWire, idx: number) => `${idx}-${c.name ?? ''}`)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
@Builder
FullSeasonPaletteCard() {
Column({ space: 12 }) {
Text(this.fullSeasonName !== '' ? `十二季色彩定位 · ${this.fullSeasonName}` : '十二季色彩定位')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
if (this.fullSeasonAnalysis !== '') {
Text(this.fullSeasonAnalysis)
.fontSize(13)
.fontColor('#555555')
.lineHeight(21)
.width('100%')
}
this.PaletteGroup({ title: '适合色', colors: this.paletteBest })
this.PaletteGroup({ title: '中性色', colors: this.paletteNeutral })
this.PaletteGroup({ title: '避雷色', colors: this.paletteAvoid })
}
.width('100%')
.padding(16)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Start)
}
@Builder
FullScenesCard() {
Column({ space: 12 }) {
Text('场景穿搭方案')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
ForEach(this.fullScenes, (scene: FullSceneWire, sIdx: number) => {
Column({ space: 8 }) {
Text(scene.title ?? '')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#444444')
.width('100%')
ForEach(scene.outfits ?? [], (outfit: FullOutfitWire, oIdx: number) => {
Column({ space: 4 }) {
Text(outfit.name ?? '')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_PRIMARY)
.width('100%')
ForEach(outfit.points ?? [], (point: string, pIdx: number) => {
Text(`· ${point}`)
.fontSize(12)
.fontColor('#555555')
.lineHeight(19)
.width('100%')
}, (point: string, pIdx: number) => `${sIdx}-${oIdx}-${pIdx}`)
}
.width('100%')
.padding(10)
.borderRadius(10)
.backgroundColor('#F7F8FA')
.alignItems(HorizontalAlign.Start)
}, (outfit: FullOutfitWire, oIdx: number) => `${sIdx}-${oIdx}-${outfit.name ?? ''}`)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}, (scene: FullSceneWire, sIdx: number) => `${sIdx}-${scene.key ?? ''}`)
}
.width('100%')
.padding(16)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Start)
}
@Builder
FullPlanCard() {
Column({ space: 12 }) {
Text('30 天形象提升计划')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
ForEach(this.fullPlan, (week: PlanWeekWire, wIdx: number) => {
Column({ space: 6 }) {
Text(week.title ?? `第 ${week.week ?? wIdx + 1} 周`)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#444444')
.width('100%')
ForEach(week.tasks ?? [], (task: string, tIdx: number) => {
Text(`· ${task}`)
.fontSize(12)
.fontColor('#555555')
.lineHeight(19)
.width('100%')
}, (task: string, tIdx: number) => `${wIdx}-${tIdx}`)
}
.width('100%')
.padding(10)
.borderRadius(10)
.backgroundColor('#F7F8FA')
.alignItems(HorizontalAlign.Start)
}, (week: PlanWeekWire, wIdx: number) => `${wIdx}-${week.week ?? 0}`)
}
.width('100%')
.padding(16)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Start)
}
@Builder
ContentBody() {
Column() {
@@ -868,9 +1255,15 @@ struct ReportResultPage {
}
this.CopyCard()
this.DiagnosisArea()
// 预约留资卡(T-P3-22):内容区底部,导出/重新生成按钮区之前
// 深度诊断增量区块(仅 tier=full 报告有 result.full):色板 / 场景 / 30 天计划
if (this.hasFull) {
this.FullSeasonPaletteCard()
this.FullScenesCard()
this.FullPlanCard()
}
// 深度诊断入口卡:内容区底部,导出/重新生成按钮区之前
if (this.statusRaw === 'SUCCEEDED') {
this.ReservationCard()
this.FullReportCard()
}
}
.padding(16)
@@ -893,6 +1286,13 @@ struct ReportResultPage {
.width('100%')
.textAlign(TextAlign.Start)
}
if (this.fullError !== '') {
Text(this.fullError)
.fontSize(12)
.fontColor('#E5484D')
.width('100%')
.textAlign(TextAlign.Start)
}
// 仅成功结案的报告可导出 PDF(服务端同样校验,端侧只是提前挡)
if (this.statusRaw === 'SUCCEEDED') {
Button(this.exportError !== '' ? '重试导出 PDF' : '导出 PDF 报告')
@@ -913,6 +1313,17 @@ struct ReportResultPage {
.onClick(() => {
this.openConfirm('regen')
})
// 次级入口:回首页「报告」Tab 查看全部历史报告(initialTab=1)
Button('查看历史报告')
.width('100%')
.height(46)
.fontSize(15)
.fontColor(COLOR_PRIMARY)
.backgroundColor('#FFFFFF')
.onClick(() => {
const params: Record<string, Object> = { 'initialTab': 1 }
router.pushUrl({ url: 'pages/HomePage', params: params })
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 14 })
@@ -929,6 +1340,8 @@ struct ReportResultPage {
this.ExportView()
} else if (this.regenerating) {
this.RegenView()
} else if (this.fullGenerating) {
this.FullGenView()
} else if (this.loading) {
// N5':换用 f2 通用加载态(包一层 Column 承接 layoutWeight 占位)
Column() {
@@ -977,6 +1390,16 @@ struct ReportResultPage {
.backgroundColor('#66000000')
.justifyContent(FlexAlign.Center)
}
if (this.showRegenMissingDialog) {
Column() {
this.RegenMissingDialog()
}
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.justifyContent(FlexAlign.Center)
}
}
.width('100%')
.height('100%')
+6
View File
@@ -23,6 +23,12 @@
],
"installationFree": false,
"pages": "$profile:main_pages",
"metadata": [
{
"name": "client_id",
"value": "6917613748100335131"
}
],
"abilities": [
{
"name": "EntryAbility",
@@ -10,6 +10,7 @@
"pages/LlmTestPage",
"pages/TryonPage",
"pages/HairDesignPage",
"pages/HairColorPage",
"pages/ReportPdfPage",
"pages/FittingRoomPage",
"pages/DiagnosisLandingPage",
@@ -0,0 +1,193 @@
# T2b · 任务换额度:端侧闭环与完成判定
> 对应任务:`T-P1-26` 收尾件之二(T2 的后续迭代)| 需求:`FR-BILL-03`、`FR-BILL-04`、`FR-BILL-07` | 跨端任务:后端(meida_backend)+ 端侧(app/)
> 先读《README.md(协作规程)》与《README-FE批次.md》,遵守全部纪律。
> 产品定稿依据:产品方案 §3.2「任务奖励额度的获取方式」(2026-09-11 定稿表与决策理由)。
## 1. 目标
把「做任务换额度」从「只有签到能真领」补齐为完整闭环:
1. 任务目录按产品定稿落地(4 项上线 + `invite_friend` 下架),奖励数值由占位值 1 改为定稿值;
2. 有服务端可信信号的任务(`report_feedback` / `first_report`)在 claim 时做完成校验,无凭据拒绝;
3. 端侧面板从「toast 占位」升级为真实「去完成」跳转 + 完成点自动 claim;
4. 全程遵守 `FR-BILL-07`:文案不出现价格/购买/会员/升级字样。
## 2. 现状事实(2026-09-11 读码核实)
### 后端(meida_backend)
- ✅ `POST /quota/tasks/{task_key}/claim` 已实现(T2):幂等(`bl_ledger.idempotency_key` 快捷路径 + 唯一约束并发兜底)、封顶(`grant_bonus` 对 `task_bonus_max`)、配置化(`TASK_BONUS_RULES_JSON`)、错误码 40410/40910/40310 文案合规。见 `apps/api/routers/quota.py:65`、`packages/billing/service.py:306`、`packages/domain/billing/quota.py:146`。
- ✅ 配额种子与产品方案 §3.2 对齐(`migrations/versions/0003_align_quota_with_product_ssot.py`):`agent_chat` 10/10/40、`tryon` 3/3/8、`hairstyle` 3/2/6、`hd_export` 1/0/3、`report_basic` 终身 1、`report_regen` 月 1/1/2。
- ❌ **claim 不做任何完成凭据校验**:任何登录用户对任何 task_key 直接 POST 即领。`report_feedback`(`rp_feedback` 表)与 `first_report`(`rp_report` 表)明明有可信信号却没用上。
- ❌ `task_bonus_rules` 默认值(`packages/shared/config.py:136`)amount 全是占位值 1,且映射与 §3.2 总量对不上:换装最多 +2/日(表定 +3)、发型 0(表定 +2)、对话 +2(表定 +10)。
- ❌ 无任务目录下发接口——V1 决定端侧静态表,不做。
- 事实备注:`rp_share` 表已建结构但无路由(models.py 注释「V1 只建结构」);`POST /events/batch` 无事件白名单(事件字典待 TD-23)。
### 端侧(app/entry/src/main/ets/)
- ✅ `components/f1/TaskBonusPanel.ets`:五项静态任务、已领态走 `core/TaskBonusState.ets`(preferences `meida_task_bonus`,跨重启)、40910 同步已领态、文案合规;已挂载 5 处(`ProfileContent` / `ChatPage` / `FittingRoomPage` / `HairDesignPage` / `HairColorPage`)。
- ✅ 真实闭环已有两条:`sign_in` 点击即 claim(面板内);`report_feedback` 在报告页反馈提交成功后静默自动 claim(`ReportResultPage.ets:435`)。
- ✅ `DailyLimiter.setBonus` 已把 `bonus_granted` 计入试穿/试戴当日上限;`ChatPage` 对话额度服务端口径(remaining 含 bonus)。
- ❌ 其余 3 项任务是 toast 占位(「即将上线」),无「去完成」跳转。
- ❌ `share_report` 无联动:`ReportPdfPage.ets:90` 已有 Share Kit `systemShare` 分享 PDF,但分享动作与 claim 之间没有凭据、没有联动。
- ❌ 报告生成成功处无 `first_report` claim 联动。
- ❌ 面板不展示每项的奖励数值与周期(任务表纪律是"数值不硬编码",定稿后可写入展示文案,到账仍以 claim 响应为准)。
- 🐞 `ChatPage.ets:36` `QuotaWireResp.bonus_granted` 类型误写为 `boolean`(后端返回 number;该字段暂未消费,本迭代顺手修正)。
### 判定与埋点
- ✅ 埋点链路通:`Tracker` → `POST /events/batch` → `ob_event`(整批 fail-close 校验)。
- ❌ 任务完成事件目前只有 `task_bonus.claim_result`;无面板曝光、任务点击、分享调起事件。
- 产品决策(已写进 §3.2):**埋点只作观测,不作发放凭据**——埋点本身可伪造且有 20 条/30s 节流延迟,拿它做校验是自欺。
## 3. V1 任务目录(产品定稿,2026-09-11)
| task_key | 名称 | quota_key | amount | claim_period | 完成判定 | 状态 |
|----------|------|-----------|--------|--------------|---------|------|
| `sign_in` | 每日签到 | `agent_chat` | **10** | `day` | 点击签到即完成,无额外校验 | 上 |
| `report_feedback` | 给报告结论打反馈 | `hairstyle` | **2** | `day` | 服务端校验当日 `rp_feedback` 存在 | 上 |
| `share_report` | 分享我的报告 | `tryon` | **3** | `day` | 端侧自报,无服务端校验(内测水位) | 上 |
| `first_report` | 完成首次形象速测 | `report_regen` | **1** | `lifetime` | 服务端校验 `rp_report` 存在已完成报告 | 上 |
| `invite_friend` | 邀请好友 | — | — | — | 无可信信号 | **下架**(env 不下发该键) |
变更点:删除 `profile_complete`(资料维护 `FR-ACC-06` V1 未实现,无可信信号),新增 `first_report`;`report_feedback` 由 `report_regen` 改挂 `hairstyle`(理由见产品方案 §3.2 决策理由第 3 条)。
## 4. 实现要点
### 后端(worktree 纪律见《README.md》)
允许改动:`packages/billing/service.py`、`packages/billing/repo.py`(如需)、`packages/billing/schemas.py`(如需)、测试新文件。
1. **B1 · 定稿数值走 env**:部署/本地 `.env` 的 `TASK_BONUS_RULES_JSON` 改为:
```json
{
"sign_in": {"quota_key": "agent_chat", "amount": 10, "claim_period": "day"},
"report_feedback": {"quota_key": "hairstyle", "amount": 2, "claim_period": "day"},
"share_report": {"quota_key": "tryon", "amount": 3, "claim_period": "day"},
"first_report": {"quota_key": "report_regen", "amount": 1, "claim_period": "lifetime"}
}
```
不含 `invite_friend`——不下发即下架(claim 返回 40410「任务不存在或暂未开放」),这是配置化设计的红利,不要为它写分支代码。
⚠️ `config.py` 的默认值是占位且**禁改**;在提交信息里写明「默认值与定稿不一致,建议协调提交同步默认值与 `.env.example`」,由协调者统一处理。
2. **B2 · claim 完成校验钩子**:`BillingService.claim_task_bonus` 在解析规则后、幂等快捷路径**之前**,按 task_key 执行完成校验(映射写死在 service 内即可,不做配置化):
- `report_feedback`:`rp_feedback` 存在 `user_id` 匹配且 `created_at` 落在当日(UTC+8,与 `period_key` 同时区口径)的行;
- `first_report`:`rp_report` 存在 `user_id` 匹配且状态为已完成的行;
- `sign_in` / `share_report`:**不校验**,写注释说明产品决策(签到行为本身即任务;分享内测期自报,`hard_cap` 兜底,见 §3.2 决策理由 4)。
- 校验不过 → `AppError(ErrorCode.TASK_BONUS_NOT_ELIGIBLE)`。**该错误码尚不存在**(建议 40311,meta「先完成任务,再来领奖励」)——`errors.py` 禁改,在提交信息中列出需求,由协调提交统一加;本地开发可先在 service 里 `raise AppError(...)` 引用占位并在提交信息标注。
3. **B3 · 幂等与封顶语义不变**:40910/40310 路径、`delta=0` 流水口径不动;校验逻辑不得影响既有测试。
### 端侧(纯 Windows,构建门禁见《README-FE批次.md》§1)
允许改动:`components/f1/TaskBonusPanel.ets`、`pages/ReportResultPage.ets`、`pages/ReportPdfPage.ets`、`pages/ChatPage.ets`(仅 B 端 wire 类型修正)。其余页面如需挂回调点,在提交信息里说明。
1. **E1 · 面板任务表更新为定稿 4 项**:删 `invite_friend`;`profile_complete` 替换为 `first_report`(名称「完成首次形象速测」);每项 desc 写清奖励内容(如「领 10 轮 AI 对话额度」「领 2 次发型试戴额度」)并标注周期(「每日」/「一次性」)。到账数值仍以 claim 响应 `amount` 为准,desc 只是展示。
2. **E2 · 「去完成」真实跳转**(`router.pushUrl`,禁止 import 页面):
- `first_report` → `pages/DiagnosisLandingPage`;进入面板时若已可领(服务端已有报告且未领过)则按钮直接为「领额度」走 claim;
- `report_feedback` → 报告结果页(带 `reportId` 参数);无报告时 toast「先生成你的形象速测,再来反馈」;
- `share_report` → `pages/ReportPdfPage`(分享入口所在页);无报告时同上引导;
- `sign_in` 保持点击即领。
3. **E3 · 完成点自动 claim**(与既有 `claimReportFeedback` 同款静默闭环:成功 toast 到账、40910 静默同步已领态、其他失败静默):
- `ReportPdfPage.share()` 调起系统分享面板成功后:静默 claim `share_report` + `TaskBonusState.markClaimed('share_report', PERIOD_DAY)` + `Tracker.track('report.share_invoke', ...)`;
- 报告生成任务成功(`ReportResultPage` 展示 SUCCEEDED 结果的入口路径):静默 claim `first_report` + `markClaimed`。
4. **E4 · 状态与文案**:见 §5,逐字遵守。
5. **E5 · 埋点补齐**(事件名字面量、props 只放低维枚举/数值):`task_bonus.panel_view`、`task_bonus.task_tap`(props: `task_key`)、`report.share_invoke`、`task_bonus.claim_result`(已有,保留)。
6. **E6 · 修正** `ChatPage.ets:36` `bonus_granted: boolean` → `number`。
## 5. UX 与文案规格(FR-BILL-07,逐字遵守)
**面板信息架构**:标题「做任务换额度」;分组一「每日任务」(签到 / 打反馈 / 分享报告);分组二「新手任务」(完成首次形象速测);底部说明保留「奖励以实际到账为准,额度每日重置」。
**按钮五态**:
| 状态 | 文案 |
|------|------|
| 可领取(签到 / 已满足条件) | 「领额度」 |
| 需跳转完成 | 「去完成」 |
| 领取中 | 「领取中…」 |
| 已领(日周期) | 「今日已领」 |
| 已领(终身) | 「已领取」 |
**toast**:
| 场景 | 文案 |
|------|------|
| 领取成功 | 「签到成功,额度 +{amount}」/「感谢反馈,额度 +{amount}」(amount 取响应值,不硬编码) |
| 40910 本周期已领 | 「今天已经领过啦」 |
| 40310 奖励封顶 | 「今日任务奖励已达上限,明天再来」 |
| 40311 未完成 | 「先完成任务,再来领奖励」 |
| 无报告时去做反馈/分享 | 「先生成你的形象速测,再来反馈」 |
**红线**:全部新增文案不得出现价格、购买、会员、升级、充值等字样(`FR-BILL-07`);额度耗尽入口文案维持「今日额度已用完,明天再来,或做个任务多换几次」口径。
## 6. 测试要求
### 后端
- 单测:校验钩子纯逻辑(若抽纯函数)覆盖有/无凭据两分支。
- 集成测试新文件 `tests/integration/test_task_bonus_eligibility.py`(允许跑,`MEIDA_RUN_INTEGRATION=1`):
1. 无报告用户 claim `first_report` → 40311;创建报告完成后 → 200 且 `bonus_granted`=1;
2. 无反馈 claim `report_feedback` → 40311;提交一条反馈后 → 200 且发型 `bonus_granted`=2;
3. `sign_in` claim → 200,`agent_chat` `bonus_granted`=10;当日再 claim → 40910;
4. `invite_friend`(不在 env)→ 40410;
5. 40311 与 40310 路径均**不写流水**(查 `bl_ledger` 无新行)。
- 提交前全绿:`ruff check` / `ruff format --check` / `mypy`(范围同 T2)/ `lint-imports` / `pytest -q`。
### 端侧
- 构建门禁(唯一验收命令,见《README-FE批次.md》§1)`BUILD SUCCESSFUL`;
- ArkTS 严格模式铁律(README-FE批次 §3)逐条遵守:显式泛型、具名 wire interface、throw 只抛 ApiError。
## 7. 禁改清单(通用禁改之外)
- 后端:`packages/shared/config.py`、`packages/shared/errors.py`、`packages/shared/models.py`、`migrations/**`、`apps/api/routers/events.py`、`packages/analytics/**`。新错误码与默认值同步**只写进提交信息**,由协调者处理。
- 端侧:`core/**`(含 `TaskBonusState.ets`、`Tracker.ets`、`DailyLimiter.ets`——本迭代需求已够,不要顺手扩展)、`module.json5`、`main_pages.json`、resources、`oh-package*.json5`。
- 不改配额种子数值、不动 `grant_bonus` 语义、不改既有测试断言语义。
## 8. 验收标准(按组,每条可验收)
### 后端
1. 四个 task_key 按 §3 定稿数值 claim 成功,响应 `amount` 与定稿一致;`invite_friend` 返回 40410。
2. 无凭据 claim `report_feedback` / `first_report` 返回 40311 且无流水;有凭据后 claim 成功。
3. 重复 claim 40910、封顶 40310 行为与 T2 验收一致,无回归(既有 `test_task_bonus.py` 全绿)。
4. 后端检查全绿(§6)。
### 端侧
5. 面板只展示 4 项定稿任务,奖励文案、周期标注、五态按钮与 §5 一致。
6. 三项「去完成」跳转可达目标页;无报告场景给引导 toast 而非报错。
7. 分享调起成功、报告生成成功两处自动 claim 生效,面板已领态同步(含杀进程重启后)。
8. `ChatPage` wire 类型修正后构建绿;埋点新增事件在 `ob_event` 可见。
### 联调
9. 全链路走查(真机或模拟器连本地后端):签到 → 对话额度卡 +10;报告页打反馈 → 发型当日上限 +2(`DailyLimiter` 生效);PDF 页分享 → 换装当日上限 +3;完成首次速测 → 重生成额度 +1(个人中心额度卡可见)。
10. 异常路径走查:同日重复领取 →「今天已经领过啦」;未打反馈直接在面板点领取(若 UI 可达)→「先完成任务,再来领奖励」。
11. 文案红线检查:任务面板、toast、额度耗尽提示全文检索无「价格/购买/会员/升级/充值/¥」字样。
## 9. 提交信息
后端:
```
feat(billing): task bonus final rules + eligibility check (T2b)
TASK_BONUS_RULES_JSON 定稿(签到+10对话/反馈+2发型/分享+3换装/首测+1重生成,
invite_friend 下架);report_feedback/first_report claim 前完成校验(rp_feedback/
rp_report 可信信号),sign_in/share_report 不校验(产品决策见产品方案 §3.2)。
偏差说明:TASK_BONUS_NOT_ELIGIBLE(40311) 错误码与 config.py 默认值同步需协调提交;
<其余偏差写这里,无则写"无">
```
端侧:
```
feat(f1): task bonus panel final catalog + auto-claim hooks (T2b)
面板 4 项定稿任务与「去完成」真实跳转;分享调起/报告生成成功自动 claim;
新增 panel_view/task_tap/share_invoke 埋点;修正 ChatPage bonus_granted 类型。
偏差说明:<如有写在这里,无则写"无">
```
+281
View File
@@ -0,0 +1,281 @@
# 生产部署手册:elane.xiangsilian.com
> 创建日期:2026-09-14 | 适用:meida_backend 首次公网部署
> 目标服务器:elane.xiangsilian.com → 175.178.100.240(腾讯云 Ubuntu,已有 nginx)
> 前置:代码 review 已完成(12 项结论全部核实),本文所有命令均按当前代码核实过。
---
## 0. 部署前必读(三个硬提醒)
1. **⚠️ 华为登录还差一步 AGC 配置。** 登录系统已开发完成:邮箱验证码(前后端全
链路可用)+ 华为账号登录(端侧已接入,后端验签早就绪)。唯一缺口:AGC 开通
Account Kit 后把 **client_id** 填进 App 的 `entry/src/main/module.json5`
(替换 `TODO_AGC_CLIENT_ID` 占位值),并确保 AGC 上的包名/证书指纹与本机
签名一致。在此之前华为登录按钮会提示「暂未开放」,邮箱登录可正常使用。
2. **⚠️ 备案**:`elane.xiangsilian.com` 是现有备案下**新增的子域名**。需在腾讯云
控制台「备案 → 变更备案」中提交变更(主体不变、免费、线上办理、约 1~2 周)。
部署不依赖备案完成,但华为应用市场审核会核对。
3. **⚠️ 服务器安全组**:放行 443;**不要**对公网开放 55432/56379(数据库端口)。
## 1. 目标架构
```
App ──https──▶ elane.xiangsilian.com:443 (nginx + Let's Encrypt 证书)
└─ /api/v1/ ──▶ 127.0.0.1:8666 (gunicorn/uvicorn)
└─ /healthz、/readyz ──▶ 127.0.0.1:8666(监控用,可只内网)
本机进程:PostgreSQL 16+pgvector(docker, 127.0.0.1:55432)
Redis 7(docker, 127.0.0.1:56379)
celery worker -Q cpu,io(systemd)
数据卷: pgdata / redisdata(docker 卷)+ /home/ubuntu/meida_data/{uploads,exports}
```
说明:本部署为**单进程 api + 单 worker**(当前业务全部走 cpu/io 队列,gpu 队列
尚未启用,见 review §10)。不上 K8s,与架构文档 §14.1 的 V1 形态一致。
## 2. 服务器初始化
```bash
# 系统要求:Ubuntu 22.04+,2C4G 起步(报告生成是 LLM IO 密集型,不吃 CPU)
# 2.1 Docker(如未装)
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER && newgrp docker
# 2.2 uv(Python 包管理,项目要求 Python >= 3.12)
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
# 2.3 weasyprint 系统依赖(PDF 导出用,缺了首次导出必炸——review 重点项)
sudo apt-get update && sudo apt-get install -y \
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0
# 2.4 nginx + certbot(机器上已有 nginx 则跳过安装,直接进 §5)
sudo apt-get install -y nginx certbot python3-certbot-nginx
```
## 3. 拉代码 + 数据库
```bash
git clone https://git.xiangsilian.com/xsl/meida_backend.git ~/meida_backend
cd ~/meida_backend
# 3.1 改数据库口令:编辑 deploy/docker-compose.dev.yml
# POSTGRES_PASSWORD: meida → 换成强口令(记为 <PG_PASS>)
# 并把两个端口映射绑到本机(防公网暴露):
# "55432:5432" → "127.0.0.1:55432:5432"
# "56379:6379" → "127.0.0.1:56379:6379"
# 3.2 起 PG + Redis(只起数据层,不起应用)
docker compose -f deploy/docker-compose.dev.yml up -d postgres redis
# 3.3 应用依赖(生产:不装 dev 依赖、锁死版本)
uv sync --no-dev --frozen
```
## 4. 生产 .env
`cp .env.example .env` 后按下表修改。**带 ⚠️ 的行漏配会导致安全事故或拒启动**。
```bash
# ── 环境(最关键:prod 自动关闭 dev-token 登录和 /api/v1/docs)──
APP_ENV=prod # ⚠️ 不设这行 = dev-token 对外敞开
LOG_LEVEL=INFO
LOG_JSON=true
# ── 数据依赖(默认值指向 localhost:55432/56379,口令与 3.1 一致)──
DATABASE_URL=postgresql+asyncpg://meida:<PG_PASS>@127.0.0.1:55432/meida
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_BROKER_URL=redis://127.0.0.1:56379/1
CELERY_RESULT_BACKEND=redis://127.0.0.1:56379/2
# ── 鉴权 ──
JWT_SECRET=<执行 openssl rand -hex 32 生成> # ⚠️ <32 字符或占位符会拒启动(有意保护)
DEV_AUTH_ENABLED=false # 双保险(APP_ENV=prod 本身已关)
# ── LLM(DeepSeek 主 / 智谱备,key 从开发机 .env 抄)──
DEEPSEEK_API_KEY=sk-...
ZHIPU_API_KEY=...
# LLM_DAILY_BUDGET_CENTS=100000 # ⚠️ 当前是占位值(¥1000/天),按实测成本重算后再定
# ── 存储:无 COS 走本地磁盘降级(当前形态)──
# COS_* 全部留空;上传走 POST /api/v1/uploads/direct
UPLOAD_DIR=/home/ubuntu/meida_data/uploads # ⚠️ 绝对路径,相对 cwd 在 systemd 下不可靠
REPORT_EXPORT_DIR=/home/ubuntu/meida_data/exports
# ── 登录通道 ──
# 华为账号(后端已支持;HUAWEI_AUDIENCE 不配则华为登录 40102,是有意设计)
# HUAWEI_AUDIENCE=6917613748100335131
# HUAWEI_AUDIENCE 对应 AGC 应用的 App ID(前置开通清单已登记)
#
# 邮箱验证码(正式兜底通道,前后端已实现,2026-09-14):
# ⚠️ EMAIL_PROVIDER=fake 会把验证码明文打进日志,仅限本地开发,生产必须 smtp
EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.qq.com # 或 smtp.163.com
SMTP_PORT=465
SMTP_USER=your_sender@qq.com
SMTP_PASS=<邮箱 SMTP 授权码> # QQ/163 邮箱设置里生成,不是登录密码
SMTP_USE_SSL=true
MAIL_FROM=your_sender@qq.com # 留空则用 SMTP_USER
# 频控默认值(对齐短信口径):同邮箱 60s 冷却 / 1h≤5 / 24h≤10,IP 24h≤20,设备 24h≤10
#
# 短信通道:已废弃(个人主体办不下签名),SMS_* 保持默认不启用即可
```
> 注:短信通道已废弃不做。`SMS_PROVIDER` 保持默认 fake 即可——短信端点未接路由、
> App 端无入口,永远不会被触发;但生产日志审计时留意别让任何人把它接到路由上。
## 5. 迁移 + 内测配额口径
```bash
cd ~/meida_backend
# 5.1 迁移(alembic 走 DATABASE_URL;0003 会自动灌 bl_quota_rule 种子,
# 含 full_report 行——全新库不需要手动补)
uv run alembic upgrade head
# 5.2 (可选,内测期放宽;上架前必须改回)开发库当前口径:
docker exec meida-postgres psql -U meida -d meida -c \
"UPDATE bl_quota_rule SET base_limit=base_limit*10, task_bonus_max=task_bonus_max*10, hard_cap=hard_cap*10;"
```
## 6. 进程常驻(systemd)
```bash
sudo tee /etc/systemd/system/meida-api.service <<'EOF'
[Unit]
Description=meida api
After=network-online.target docker.service
Wants=network-online.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/meida_backend
Environment=PATH=/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/ubuntu/.local/bin/uv run --no-sync gunicorn apps.api.main:app \
--worker-class uvicorn.workers.UvicornWorker --workers 2 \
--bind 127.0.0.1:8666 --timeout 120 --graceful-timeout 30 --access-logfile -
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo tee /etc/systemd/system/meida-worker.service <<'EOF'
[Unit]
Description=meida celery worker (cpu,io)
After=network-online.target docker.service
Wants=network-online.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/meida_backend
Environment=PATH=/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/ubuntu/.local/bin/uv run --no-sync celery -A apps.worker.celery_app worker \
-Q cpu,io --pool=solo --loglevel=INFO
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now meida-api meida-worker
# 验证(本机)
curl -s http://127.0.0.1:8666/healthz # {"code":0,...,"status":"ok"}
curl -s http://127.0.0.1:8666/readyz # 依赖就绪(查 PG+Redis)
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8666/api/v1/auth/dev-token \
-H 'Content-Type: application/json' -d '{"device_key":"x"}' # 必须是 404(dev-token 已关)
```
要点:
- `--no-sync` 防止 systemd 环境下 uv 试图联网解析依赖;依赖已在 §3.3 装好。
- 不需要 celery beat(全仓库无定时任务;temp 目录清理等上线后再说,见 §8)。
- worker 与 api 的 UPLOAD_DIR/REPORT_EXPORT_DIR 是同一绝对路径,天然共享,无需挂卷。
## 7. nginx + HTTPS
```bash
# 7.1 签证书(自动改 nginx 配置 + 配置续期)
sudo certbot --nginx -d elane.xiangsilian.com
# 7.2 编辑 /etc/nginx/sites-enabled/ 下 elane 的 server 块,追加反代:
```
```nginx
server {
listen 443 ssl;
server_name elane.xiangsilian.com;
# certbot 自动填 ssl_certificate / ssl_certificate_key
location /api/v1/ {
proxy_pass http://127.0.0.1:8666;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 20m; # 上传上限与 UPLOAD_MAX_BYTES 对齐
proxy_read_timeout 120s; # 报告生成等长请求
}
# 监控探活(可选;不配则只在服务器本机 curl)
location = /healthz { proxy_pass http://127.0.0.1:8666/healthz; }
location = /readyz { proxy_pass http://127.0.0.1:8666/readyz; }
# /metrics 不要暴露公网(代码里它不受 PROMETHEUS_ENABLED 控制,见 §8)
}
```
```bash
sudo nginx -t && sudo systemctl reload nginx
# 7.3 安全组:腾讯云控制台放行 443/tcp;确认 55432/56379/8666 未对公网开放
# 7.4 公网验证
curl -s https://elane.xiangsilian.com/healthz
curl -s -o /dev/null -w '%{http_code}\n' https://elane.xiangsilian.com/api/v1/quota # 401 = 服务活了
curl -s -o /dev/null -w '%{http_code}\n' https://elane.xiangsilian.com/api/v1/docs # 404 = swagger 已关
```
## 8. 已知限制与待办(代码 review 结论,部署后按此跟进)
| # | 事项 | 处置 |
|---|---|---|
| 1 | **华为登录差 client_id**:端侧已接入(占位拦截不 crash),需 AGC 开通 Account Kit 后填 `module.json5` | 用户在 AGC 操作(前置开通清单 §1),拿到 client_id 替换 `TODO_AGC_CLIENT_ID` |
| 2 | ~~SMS_PROVIDER 默认 fake~~ 短信已废弃;邮箱通道已上线 | 生产 `.env` 用 `EMAIL_PROVIDER=smtp` + 真实邮箱授权码 |
| 3 | CORS 中间件不存在 | 原生 App 不受影响;admin 后台浏览器访问前必须加白名单 |
| 4 | COS temp 桶 2 小时生命周期 | 未接 COS 前, uploads 目录只增不减,需 cron 清理:如 `find /home/ubuntu/meida_data/uploads -type f -mtime +2 -delete`(temp 场景文件 2 小时后可删) |
| 5 | 数据备份脚本缺失(FR-SYS-09) | 最低限度:`pg_dump` 每日 cron + `meida_data/` 快照 |
| 6 | `LLM_DAILY_BUDGET_CENTS` 是占位值 | 用真实 token 成本重算后改 .env |
| 7 | `/metrics` 无条件暴露 | 已按 §7 不反代公网;要监控可后期加 IP 白名单 |
| 8 | DEBUG 配置项是死代码 | 无行为影响,忽略即可 |
| 9 | 本地上传的照片无对外 URL(已知缺口) | 报告场景服务端自用不受影响;需回显时接 COS |
| 10 | 运营后台(行为分析)需设 `ADMIN_TOKEN` 才装配 | 端点 `/admin/api/v1/users`、`/users/{id}`、`/events`、`/stats/overview`(X-Admin-Token 鉴权,PII 掩码后输出);admin 是独立应用需单独进程:`uv run uvicorn "apps.admin.app:create_admin_app" --host 127.0.0.1 --port 8001`,nginx 按需反代(注意先解决 #3 CORS) |
## 9. 故障速查
| 症状 | 排查 |
|---|---|
| `systemctl status meida-api` 启动即失败 | 多半是 JWT_SECRET 太短/占位符(config.py 启动校验)——`journalctl -u meida-api -n 50` 看原因 |
| readyz 非 2xx | PG/Redis 没起:`docker compose -f deploy/docker-compose.dev.yml ps` |
| 报告卡在生成中 | 看 worker:`journalctl -u meida-worker -f`;LLM key 失效是最常见原因 |
| PDF 导出失败报 weasyprint 相关错 | §2.3 的系统库没装 |
| nginx 502 | api 没起 / 8666 端口不对;`ss -ltnp \| grep 8666` |
| 华为登录 40102 | HUAWEI_AUDIENCE 没配(预期行为,配 AGC App ID) |
## 10. 上线前最终检查单
- [ ] 变更备案通过(elane.xiangsilian.com 在备案系统可见)
- [ ] dev-token 返回 404、docs 返回 404、metrics 不公网可达
- [ ] JWT_SECRET / PG 口令均为强随机值,`.env` 权限 `chmod 600`
- [ ] 华为登录真机回归通过(client_id 已填入 module.json5、AGC 指纹与签名一致)
- [ ] 邮箱验证码登录真机回归通过(生产 EMAIL_PROVIDER=smtp 实测收到邮件)
- [ ] 账号注销功能可用(华为审核硬性要求)
- [ ] 隐私政策/用户协议可公开访问,App 内有入口
- [ ] 配额规则从内测口径改回产品口径(若执行过 §5.2)
- [ ] pg_dump 定时备份已配并演练过一次恢复
- [ ] App `DEFAULT_BASE_URL` 切换为 `https://elane.xiangsilian.com`,Release 签名出包
@@ -464,7 +464,25 @@ V1 无付费、无会员、无点数购买,**所有注册用户使用同一套
2. **按设备维度计量**(设备指纹 + 匿名 ID),因此**必然可以被清数据绕过**。这是可接受的:游客额度极小,绕过的收益低于用户重装 App 的成本,不值得为它上强风控。
3. **额度用尽必须是"引导登录"而非"静默失败"** —— 这是游客模式唯一的转化时机,做成一个报错就白费了。
**任务奖励额度的获取方式**(全部不涉及金钱):每日签到、完善画像资料、给报告结论打反馈、邀请好友、分享报告长图。
**任务奖励额度的获取方式**(全部不涉及金钱)。V1 任务目录已于 **2026-09-11 定稿**(原为占位;研发任务书见《[开发任务包/T2b-任务换额度-端侧闭环](./开发任务包/T2b-任务换额度-端侧闭环.md)》):
| 任务 | task_key | 奖励 | 领取周期 | 完成判定 | 内测期状态 |
|------|----------|------|---------|---------|-----------|
| 每日签到 | `sign_in` | AI 对话 **+10 轮** | 每日 | 点击签到即完成(登录态 + 服务端日幂等),行为本身即任务 | ✅ 上 |
| 给报告结论打反馈 | `report_feedback` | 发型/发色 **+2 次** | 每日 | **服务端可信**:当日 `rp_feedback` 存在本人记录 | ✅ 上 |
| 分享我的报告 | `share_report` | 换装 **+3 次** | 每日 | **端侧自报**:调起系统分享面板即算完成,埋点观测 | ✅ 上(自报水位) |
| 完成首次形象速测 | `first_report` | 报告重生成 **+1 次** | 终身一次 | **服务端可信**:`rp_report` 存在本人已完成报告 | ✅ 上(替代原「完善画像资料」) |
| ~~邀请好友~~ | `invite_friend` | — | — | 无可信信号(无邀请码/回流链路) | ❌ **内测期下架**,V2 有邀请链路后重评 |
**数值决策理由(2026-09-11)**:
1. **与配额种子精确对齐**:四个任务的奖励合计恰好等于 `bl_quota_rule.task_bonus_max`(对话 10 / 换装 3 / 发型 2 / 重生成 1),只需改 `TASK_BONUS_RULES_JSON` 配置即生效,**不动配额种子、不发版**(`FR-BILL-04`)。
2. **签到独得对话 +10**:Agent 单轮成本最低(≤¥0.05),签到让对话额度翻倍(10→20 轮/日)是最直接的日活钩子;物理上限 `hard_cap` 40 不变,§5.2.1 成本模型不受影响。
3. **反馈奖发型 +2/日**(改掉原配置「反馈奖重生成 +1」):反馈是每日可重复行为,而重生成月封顶 1 次——原配置下用户当月第 2 天起再反馈必撞 40310,等于白做。且反馈数据是结论认同率/修正率指标(`FR-RPT-19`)的唯一数据来源,值得用高频日奖励驱动。
4. **分享只能端侧自报,内测期接受**:Share Kit 系统分享无「实际发送成功」回调,内测期不做服务端事件校验(埋点本身可伪造且批上报有节流,做校验是自欺)。刷满收益被 `hard_cap` 8 锁死,已含在 §5.2.1 物理上限成本模型内,**零增量成本风险**;埋点观测滥用率,内测后复评。
5. **邀请好友直接砍**:无邀请码与回流链路,任何形态都只能纯自报且无观测锚点,上了等于无条件每日 +1 换装。
6. **「完善画像资料」改造为「完成首次形象速测」**:资料维护功能(`FR-ACC-06`)V1 未实现,无可信信号;完成速测是产品核心漏斗(报告是画像生产者),服务端有 `rp_report` 记录可校验,语义闭环「觉得不准,本月可重来一次」。
7. 主表「报告重新生成 +1 次/月」在内测期由 `first_report` **一次性**兑现;月度循环获取途径等 V2 任务体系扩展时再补。
**三条不可违反的设计纪律**: