/** * 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 | 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 } /** 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 { 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 { 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 { 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 { 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 { 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 '保存失败,请重试' } } /** 「上次结果」卡的保存(#18):直接走本机副本,不依赖网络。 */ private async saveLastResult(): Promise { 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 }) } }