- 发型设计:原图/效果图改单区域+胶囊页签切换(无结果置灰);点选发型只选中不生成,吸底「试戴『×××』」按钮触发;移除页内换发色卡与「上次结果」回看(相册留存即可) - 换发色独立页 HairColorPage(主页新入口卡):8 色圆点+强度滑杆,每次在原图上换色不叠加;两页图片均顶部对齐裁切(容器定高 clip,头顶不被居中裁掉) - 60 款发型男女分类按缩略图模特呈现全量重分(男30/女30;旧版互串 18 款) - 两页仍共用 hairstyle 每日额度(P0-5 合并计数)
556 lines
22 KiB
Plaintext
556 lines
22 KiB
Plaintext
/**
|
||
* 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
|
||
}
|
||
}
|