/** * HairDesignPage —— 发型设计(只试戴;换发色已拆到独立页 HairColorPage),Phase A 直连线上服务。 * * 交互(2026-09 按用户反馈重排): * 图片区单区域展示,顶部「原图/效果图」页签切换(无结果时效果图页签置灰不可点); * 点选发型只选中不生成,吸底「换发型」按钮触发试戴(5~10s)→ 成功自动切到效果图页签; * 保存走系统相册对话框;不再保留「上次结果」回看卡(相册留存即可)。 * 异常口径:400 用服务端 message 原样提示;失败保留当前图不覆盖。 * 发型库三态(#8/#18):加载中 / 空态 / 失败+重新加载。 */ import { router } from '@kit.ArkUI' import { image } from '@kit.ImageKit' import { photoAccessHelper } from '@kit.MediaLibraryKit' import { fileUri } from '@kit.CoreFileKit' import { TryonApi, Hairstyle } 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, COLOR_ERROR } from '../core/Theme' import { Segment } from '../components/f2/Segment' import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent' import { TaskBonusPanel } from '../components/f1/TaskBonusPanel' /** GET /api/v1/quota?quota_key=hairstyle 响应(Phase B 配额接入后生效;Phase A 拉取失败静默用端侧)。 */ interface HairQuotaWire { quota_key: string used: number limit_value: number bonus_granted: number remaining: number reset_at: string | null } @Entry @Component struct HairDesignPage { @State photoUri: string = '' @State styles: Hairstyle[] = [] @State listError: string = '' @State listLoaded: boolean = false // 列表三态(#18):false=加载中,true 后看空/错误 @State selectedStyleId: string = '' @State generating: boolean = false @State statusText: string = '' @State resultPm: image.PixelMap | undefined = undefined @State resultBase64: string = '' // 保存用它,避免二次编码 @State imageTab: number = 0 // 图片区页签:0=原图 1=效果图(无结果时置灰) @State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款多于男款) @State showFaceConsent: boolean = false // —— 每日试戴额度(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 // 同意后继续的拍照/相册入口 private imageTabs: string[] = ['原图', '效果图'] aboutToAppear(): void { this.loadStyles() this.loadHairRemaining() // 预留:Agent 联动(V2)预选发型,见产品方案 §7 const params = router.getParams() as Record | undefined if (params !== undefined && typeof params['hairstyle_id'] === 'string') { this.selectedStyleId = params['hairstyle_id'] as string } } private async loadStyles(): Promise { this.listError = '' this.listLoaded = false try { this.styles = await TryonApi.listHairstyles() } catch (e) { this.styles = [] this.listError = e instanceof ApiError ? e.message : '发型列表加载失败' } finally { this.listLoaded = true } } /** * 试戴剩余额度(P0-5):端侧 DailyLimiter 为兜底权威(Phase A 直连不经后端), * 服务端配额可拉时取 min(服务端, 端侧);拉取失败静默用端侧值,不阻断功能。 */ private async loadHairRemaining(): Promise { await DailyLimiter.init() try { const q = await ApiClient.get('/api/v1/quota?quota_key=hairstyle') // 奖励加成(迭代 3 修正):服务端 bonus_granted 抬当日端侧上限,再取 min DailyLimiter.setBonus('hairstyle', q.bonus_granted) this.hairRemaining = Math.min(q.remaining, DailyLimiter.remaining('hairstyle')) } catch (_e) { // 拉取失败保持 bonus=0(兜底 3 次不变) this.hairRemaining = DailyLimiter.remaining('hairstyle') } } /** 当前 Tab 的发型(unknown 款两 Tab 都展示,服务端新增款不丢)。 */ private visibleStyles(): Hairstyle[] { const out: Hairstyle[] = [] for (const s of this.styles) { const g: string = TryonApi.hairstyleGender(s.id) if (g === this.genderTab || g === 'unknown') { out.push(s) } } return out } /** 当前选中的发型名(吸底按钮文案用);未选中为空串。 */ private selectedStyleName(): string { for (const s of this.styles) { if (s.id === this.selectedStyleId) { return s.name } } return '' } /** 上传面部特写前须单独同意(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 { try { const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery() if (picked === undefined) { return } this.photoUri = picked.uri this.resultPm = undefined this.resultBase64 = '' this.selectedStyleId = '' 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 tryOn(): Promise { 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,与换发色合并计数):不发请求,给「做任务换额度」出路 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 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.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 }) } 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}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试' } finally { this.generating = false } } /** 保存到相册:结果 Base64 现场写缓存文件 → 系统保存对话框(取消/失败三态文案)。 */ private async saveResult(): Promise { if (this.resultBase64.length === 0) { return } try { const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`) this.statusText = await this.saveFileToGallery(path) } catch (_e) { this.statusText = '保存失败,请重试' } } /** * 系统保存对话框 → 三态文案(#17/#18 统一口径): * 返回 URI 列表非空=已保存;空数组=用户取消;抛异常=保存失败(磁盘满等不再谎报成取消)。 */ private async saveFileToGallery(path: string): Promise { try { const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get()) const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE } const uris: Array = 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):到账后重拉试戴额度(服务端 Phase B 接入后生效)。 */ @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) // 图片区(单区域):无照片=上传入口;生成中=loading;按页签显示原图/效果图 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) { // 顶部对齐裁切:容器定高 clip,图片只定宽按原比缩放,头顶/发型不被居中裁掉 Column() { Image(this.resultPm).width('100%').objectFit(ImageFit.Cover) } .width('100%').height(320).clip(true).borderRadius(12).backgroundColor(COLOR_IMAGE_BG) 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() { Image(this.photoUri).width('100%').objectFit(ImageFit.Cover) } .width('100%').height(320).clip(true).borderRadius(12).backgroundColor(COLOR_IMAGE_BG) 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%') // 入口文字链:相册/拍照 + 保存(有结果才可点) 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%') } // 发型网格:点选只选中(描边),吸底「换发型」按钮触发试戴 Text('选择发型').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY) Row() { Segment({ items: ['女生', '男生'], selected: this.genderTab === 'female' ? 0 : 1, onSelect: (idx: number): void => { this.genderTab = idx === 0 ? 'female' : 'male' } }) } .width('100%') .justifyContent(FlexAlign.Center) if (this.visibleStyles().length > 0) { Grid() { ForEach(this.visibleStyles(), (style: Hairstyle) => { GridItem() { Column({ space: 4 }) { Image(TryonApi.hairstyleImageUrl(style)) .width('100%').aspectRatio(0.72).objectFit(ImageFit.Cover) .borderRadius(8) .border(this.selectedStyleId === style.id ? { width: 2, color: COLOR_PRIMARY } : { width: 0 }) Text(style.name).fontSize(11).fontColor(COLOR_TEXT_PRIMARY).maxLines(1) } } .onClick(() => { this.selectedStyleId = style.id // 只选中不生成,底部按钮触发 }) }, (style: Hairstyle) => style.id) } .columnsTemplate('1fr 1fr 1fr') .columnsGap(8) .rowsGap(8) .width('100%') } else if (this.listError.length > 0) { // 失败态(#8/#18):错误文案 + 重新加载出口 Column({ space: 8 }) { Text(this.listError).fontSize(12).fontColor(COLOR_ERROR) Text('重新加载') .fontSize(13).fontColor(COLOR_PRIMARY) .padding({ left: 16, right: 16, top: 6, bottom: 6 }) .borderRadius(14).backgroundColor(Color.White) .onClick(() => { this.loadStyles() }) } .width('100%') .alignItems(HorizontalAlign.Center) .padding({ top: 8, bottom: 8 }) } else if (this.listLoaded) { // 空态(#18):加载成功但无上架发型 Column({ space: 8 }) { Text('发型暂未上架,稍后再试').fontSize(12).fontColor(COLOR_TEXT_SECONDARY) Text('重新加载') .fontSize(13).fontColor(COLOR_PRIMARY) .padding({ left: 16, right: 16, top: 6, bottom: 6 }) .borderRadius(14).backgroundColor(Color.White) .onClick(() => { this.loadStyles() }) } .width('100%') .alignItems(HorizontalAlign.Center) .padding({ top: 8, bottom: 8 }) } else { // 加载中(#18):不再让空列表永远停在「加载中」 Row({ space: 8 }) { LoadingProgress().width(18).height(18).color(COLOR_PRIMARY) Text('发型加载中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY) } .width('100%') .justifyContent(FlexAlign.Center) .padding({ top: 8, bottom: 8 }) } // 合并计数脚注(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 ? '生成中…' : (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%') 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 && this.selectedStyleId.length > 0 } }