Files
meida/app/entry/src/main/ets/pages/HairDesignPage.ets
T
xsl a850735d60 feat(app): 任务换额度+端侧埋点+试穿限次+报告反馈预约卡+网络错误态——PM/开发双Agent迭代2-3
- 新增 core/Tracker.ets:埋点唯一入口,事件名正则校验,preferences 队列(≥20立即/
  30s节流/后台flush,每批50,上限200丢最旧),PII 键剔除,失败静默;首批10点位
- 新增 core/DailyLimiter.ets:试穿/试戴各3次/日本地软限制,自然日重置,失败不计数;
  setBonus 支持服务端 bonus_granted 加成,「做任务多换几次」真兑现
- 新增 components/f1/TaskBonusPanel.ets + core/TaskBonusState.ets:签到真实 claim
  链路,40910/40310 处理,已领态面板与报告页互通;个人中心与对话耗尽态接入
- 报告页:维度「准/不准」反馈卡(DimensionCard 无网络走回调)+ 静默 claim
  report_feedback 闭环;全面报告预约留资卡(card_view/reserve_result 埋点)
- 新增 components/f2/NetworkBanner.ets:首页/对话/个人中心三处网络错误统一横幅+重试;
  对话耗尽横幅恢复 reset_at 时间显示
- 文案纪律:无价格/购买/会员/升级字样;hvigor assembleHap 构建 0 error
- 子模块 meida_backend 指向 7bcbb8d(迭代1-3 后端)
2026-09-11 21:51:25 +08:00

698 lines
29 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 —— 发型设计(试戴 + 换发色),Phase A 直连线上服务。
*
* 流程(产品方案 §2.3):上传面部特写 → 点选发型即试戴(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'
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 { 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 响应(Phase B 配额接入后生效;Phase A 拉取失败静默用端侧)。 */
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 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 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 showFaceConsent: boolean = false
// —— 每日试戴额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2;试戴与换发色合并计数)——
@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`
}
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') {
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
}
/** 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.resultLocalPath = ''
this.selectedStyleId = ''
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(style: Hairstyle): Promise<void> {
if (this.photoUri.length === 0 || this.generating) {
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.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 = '试戴完成,点「对比」看原图'
// 成功才计数(失败不占额度):端侧记账 + 埋点上报
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
})
this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试'
} finally {
this.generating = false
}
}
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):优先复用本机留存文件(一次写、双用途);留存失败过的现场写缓存兜底。 */
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`)
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 '保存失败,请重试'
}
}
/** 「上次结果」卡的保存(#18):直接走本机副本,不依赖网络。 */
private async saveLastResult(): Promise<void> {
if (this.lastLocalPath.length === 0) {
return
}
this.statusText = await this.saveFileToGallery(this.lastLocalPath)
}
/** 今日试戴额度耗尽的引导 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 }) {
// 顶部双图区(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)
// 右图:效果图(点按在 效果图/原图 间切换对比,沿用现交互)
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)
}
// 右下「今日还可试戴 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)
}
}
.layoutWeight(1)
.onClick(() => {
if (this.resultPm !== undefined && !this.generating) {
this.showOriginal = !this.showOriginal
}
})
}
.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%')
}
// 上次发型结果回看(#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 款两段都展示
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.tryOn(style) })
}, (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 })
}
// 换发色卡(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('试戴与换发色合并计算每日额度')
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
}
.width('100%')
.padding(16)
}
.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 })
}
}
.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
})
}
}