- 新增 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 后端)
698 lines
29 KiB
Plaintext
698 lines
29 KiB
Plaintext
/**
|
||
* 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
|
||
})
|
||
}
|
||
}
|