Files
meida/app/entry/src/main/ets/pages/HairDesignPage.ets
T
xsl aafacbf020 feat(hair): 发型/换发色交互重排——页签单图区(顶对齐裁切)+ 点选+吸底按钮触发 + 换发色拆独立页 + 60款男女全量重分类
- 发型设计:原图/效果图改单区域+胶囊页签切换(无结果置灰);点选发型只选中不生成,吸底「试戴『×××』」按钮触发;移除页内换发色卡与「上次结果」回看(相册留存即可)
- 换发色独立页 HairColorPage(主页新入口卡):8 色圆点+强度滑杆,每次在原图上换色不叠加;两页图片均顶部对齐裁切(容器定高 clip,头顶不被居中裁掉)
- 60 款发型男女分类按缩略图模特呈现全量重分(男30/女30;旧版互串 18 款)
- 两页仍共用 hairstyle 每日额度(P0-5 合并计数)
2026-09-11 23:45:15 +08:00

556 lines
22 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, Object> | undefined
if (params !== undefined && typeof params['hairstyle_id'] === 'string') {
this.selectedStyleId = params['hairstyle_id'] as string
}
}
private async loadStyles(): Promise<void> {
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<void> {
await DailyLimiter.init()
try {
const q = await ApiClient.get<HairQuotaWire>('/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<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.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<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,与换发色合并计数):不发请求,给「做任务换额度」出路
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<void> {
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<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):到账后重拉试戴额度(服务端 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
}
}