diff --git a/app/entry/src/main/ets/core/TryonApi.ets b/app/entry/src/main/ets/core/TryonApi.ets index eb30043..4ed2d1b 100644 --- a/app/entry/src/main/ets/core/TryonApi.ets +++ b/app/entry/src/main/ets/core/TryonApi.ets @@ -26,28 +26,30 @@ interface HairFormPart { const HAIR_BOUNDARY: string = '----MeidaHairForm7MA4YWxkTrZu0gW' /** - * 发型性别映射:服务端 hairstyles 接口无性别字段,60 款由缩略图视觉分类 - * (2026-09-02,男 26 / 女 34,抽检校准)。新增未知 id 返回 unknown(前端两个 Tab 都展示)。 + * 发型性别映射:服务端 hairstyles 接口无性别字段,60 款由缩略图逐张视觉分类。 + * 2026-09-11 全量重分类(男 30 / 女 30,无 unknown):缩略图按男模/女模实拍渲染, + * 以模特呈现为准(此前 2026-09-02 版抽检不准,男/女 Tab 互串 18 款,用户反馈后重分)。 + * 新增未知 id 返回 unknown(前端两个 Tab 都展示)。 */ const MALE_HAIRSTYLE_IDS: Set = new Set([ '1953267161121464322', '1953267288284372994', '1953270724765720577', '1953271922151432194', '1953272913265467394', '1953288800932503554', '1954536242109784066', '1954536297776586754', '1954536402516746241', '1954536893304840194', '1954536956919848962', '1954537191729569793', - '1980314032369082369', '1980314209322573825', '1980314290419441665', '1980314381058351106', - '1980368588293578754', '1980413320612847618', '1980415346071609345', '1980415897056354306', - '1981177324700549122', '1981177378714796034', '1981177477423546370', '1981177594750812162', + '1980312945301954561', '1980314209322573825', '1980314710101499906', '1980314764421931009', + '1980314868713299969', '1980367781510815746', '1980367800787836929', '1980367852847538177', + '1980367968627105793', '1980368109866098690', '1980368205752082434', '1980368465631158274', + '1980368588293578754', '1981177378714796034', '1981177477423546370', '1981177594750812162', '1981177742742634497', '1981177931859607554' ]) const FEMALE_HAIRSTYLE_IDS: Set = new Set([ '1953270523594317826', '1954537022875279362', '1954537085882114050', '1954537709436706817', '1954537743532204034', '1954537961069780994', '1954538119216013313', '1954538156499181569', - '1954538259330932738', '1954538335306555393', '1980312945301954561', '1980313052302843905', - '1980313166576656385', '1980313210109337601', '1980313433502162946', '1980313576506957825', - '1980313740403580930', '1980313819915001858', '1980314477720281090', '1980314710101499906', - '1980314764421931009', '1980314868713299969', '1980315179934851074', '1980315238260842497', - '1980361636742209537', '1980367742952579074', '1980367781510815746', '1980367800787836929', - '1980367852847538177', '1980367968627105793', '1980368109866098690', '1980368205752082434', - '1980368368847593473', '1980368465631158274' + '1954538259330932738', '1954538335306555393', '1980313052302843905', '1980313166576656385', + '1980313210109337601', '1980313433502162946', '1980313576506957825', '1980313740403580930', + '1980313819915001858', '1980314032369082369', '1980314290419441665', '1980314381058351106', + '1980314477720281090', '1980315179934851074', '1980315238260842497', '1980361636742209537', + '1980367742952579074', '1980368368847593473', '1980413320612847618', '1980415346071609345', + '1980415897056354306', '1981177324700549122' ]) const TRYON_DOMAIN: number = 0x0200 diff --git a/app/entry/src/main/ets/pages/HairColorPage.ets b/app/entry/src/main/ets/pages/HairColorPage.ets new file mode 100644 index 0000000..c812b3c --- /dev/null +++ b/app/entry/src/main/ets/pages/HairColorPage.ets @@ -0,0 +1,474 @@ +/** + * HairColorPage —— 换发色独立页(从发型设计页拆出,2026-09 按用户反馈),Phase A 直连线上服务。 + * + * 交互:上传面部特写 → 点选发色(8 色圆点)+ 强度滑杆 → 吸底「换发色」按钮触发(5~10s) + * → 图片区「原图/效果图」页签对比(无结果时效果图页签置灰)→ 保存相册。 + * 每次都在原图上换色(不在结果上叠加),换色效果可预期;失败保留当前图不覆盖。 + * 每日额度与发型试戴共用 hairstyle key(P0-5 合并计数)。 + */ + +import { router } from '@kit.ArkUI' +import { image } from '@kit.ImageKit' +import { photoAccessHelper } from '@kit.MediaLibraryKit' +import { fileUri } from '@kit.CoreFileKit' +import { TryonApi } 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 +} from '../core/Theme' +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 响应(与发型设计页同一配额池)。 */ +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 HairColorPage { + @State photoUri: string = '' + @State generating: boolean = false + @State statusText: string = '' + @State resultPm: image.PixelMap | undefined = undefined + @State resultBase64: string = '' // 保存用它,避免二次编码 + @State imageTab: number = 0 // 图片区页签:0=原图 1=效果图(无结果时置灰) + @State colorIdx: number = 1 // 默认深棕 + @State ratio: number = 0.9 + @State showFaceConsent: boolean = false + // —— 每日额度(P0-5:与发型试戴共用 hairstyle key,免费档 3 次/天)—— + @State hairRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余 + @State showQuotaBlocked: boolean = false + @State showTaskPanel: boolean = false + private consentPickCamera: boolean = false + private imageTabs: string[] = ['原图', '效果图'] + + aboutToAppear(): void { + this.loadHairRemaining() + } + + /** 额度口径与发型设计页一致:min(服务端, 端侧 DailyLimiter),拉取失败静默用端侧。 */ + private async loadHairRemaining(): Promise { + await DailyLimiter.init() + try { + const q = await ApiClient.get('/api/v1/quota?quota_key=hairstyle') + DailyLimiter.setBonus('hairstyle', q.bonus_granted) + this.hairRemaining = Math.min(q.remaining, DailyLimiter.remaining('hairstyle')) + } catch (_e) { + this.hairRemaining = DailyLimiter.remaining('hairstyle') + } + } + + /** 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.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 applyColor(): Promise { + if (this.photoUri.length === 0 || this.generating) { + return + } + // 每日额度耗尽(P0-5,与试戴合并计数同一 hairstyle key):不发请求,给出路 + if (this.hairRemaining === 0) { + Tracker.track('haircolor.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 buf = await ImageCodec.toJpegBuffer(this.photoUri) + 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.imageTab = 1 // 成功自动切到效果图页签 + this.statusText = '换发色完成,点「原图」页签可对比' + // 成功才计数(与试戴同一 hairstyle key,合并计算每日额度) + DailyLimiter.tryConsume('hairstyle') + this.hairRemaining = DailyLimiter.remaining('hairstyle') + Tracker.track('haircolor.generate', { + 'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false + }) + } catch (e) { + Tracker.track('haircolor.generate', { + 'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false + }) + this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试' + } finally { + this.generating = false + } + } + + /** 保存到相册:结果 Base64 现场写缓存文件 → 系统保存对话框(取消/失败三态文案)。 */ + private async saveResult(): Promise { + if (this.resultBase64.length === 0) { + return + } + try { + const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `haircolor_${Date.now()}.jpg`) + this.statusText = await this.saveFileToGallery(path) + } catch (_e) { + this.statusText = '保存失败,请重试' + } + } + + /** 系统保存对话框 → 三态文案(与发型设计页统一口径)。 */ + 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 '保存失败,请重试' + } + } + + /** 今日额度耗尽的引导 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):到账后重拉额度。 */ + @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%') + } + + // 发色卡:8 色圆点+名称 + 强度滑杆(每次都在原图上换色) + 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) + + // 吸底「换发色」:白底条+顶分隔线,高48圆角24主色;未上传照片或生成中置灰 + Column() { + Divider().strokeWidth(0.5).color(COLOR_DIVIDER) + Button(this.generating ? '生成中…' : `换发色(${PRESET_COLORS[this.colorIdx].name})`) + .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.applyColor() }) + } + .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 + } +} diff --git a/app/entry/src/main/ets/pages/HairDesignPage.ets b/app/entry/src/main/ets/pages/HairDesignPage.ets index 3983039..9492ece 100644 --- a/app/entry/src/main/ets/pages/HairDesignPage.ets +++ b/app/entry/src/main/ets/pages/HairDesignPage.ets @@ -1,24 +1,18 @@ /** - * HairDesignPage —— 发型设计(试戴 + 换发色),Phase A 直连线上服务。 + * HairDesignPage —— 发型设计(只试戴;换发色已拆到独立页 HairColorPage),Phase A 直连线上服务。 * - * 流程(产品方案 §2.3):上传面部特写 → 点选发型即试戴(5~10s)→ 可换发色 → 保存相册。 + * 交互(2026-09 按用户反馈重排): + * 图片区单区域展示,顶部「原图/效果图」页签切换(无结果时效果图页签置灰不可点); + * 点选发型只选中不生成,吸底「换发型」按钮触发试戴(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' @@ -38,7 +32,6 @@ import { 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' @@ -52,22 +45,6 @@ interface HairQuotaWire { 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 { @@ -79,36 +56,20 @@ struct HairDesignPage { @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 resultBase64: string = '' // 保存用它,避免二次编码 + @State imageTab: number = 0 // 图片区页签:0=原图 1=效果图(无结果时置灰) + @State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款多于男款) @State showFaceConsent: boolean = false - // —— 每日试戴额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2;试戴与换发色合并计数)—— + // —— 每日试戴额度(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 // 同意后继续的拍照/相册入口 - - /** 本机结果留存路径(#18):filesDir/results(cacheDir 有系统配额会被清)。 */ - private resultStorePath(): string { - return `${ContextHolder.get().filesDir}/results/hair_last.jpg` - } + private imageTabs: string[] = ['原图', '效果图'] 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') { @@ -158,15 +119,14 @@ struct HairDesignPage { 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])}` + /** 当前选中的发型名(吸底按钮文案用);未选中为空串。 */ + private selectedStyleName(): string { + for (const s of this.styles) { + if (s.id === this.selectedStyleId) { + return s.name + } + } + return '' } /** 上传面部特写前须单独同意(CR-01:人脸属敏感个人信息)。 */ @@ -188,8 +148,8 @@ struct HairDesignPage { this.photoUri = picked.uri this.resultPm = undefined this.resultBase64 = '' - this.resultLocalPath = '' this.selectedStyleId = '' + this.imageTab = 0 this.statusText = '' } catch (e) { this.statusText = e instanceof ApiError ? e.message : '读取照片失败' @@ -207,8 +167,19 @@ struct HairDesignPage { this.statusText = '未同意照片授权,你可以先浏览下方发型库' } - private async tryOn(style: Hairstyle): Promise { - if (this.photoUri.length === 0 || this.generating) { + /** 吸底按钮触发:对当前选中发型发起试戴(点选网格只选中,不发请求)。 */ + private async tryOn(): Promise { + 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,与换发色合并计数):不发请求,给「做任务换额度」出路 @@ -219,21 +190,19 @@ struct HairDesignPage { } 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 = '试戴完成,点「对比」看原图' + 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 }) - this.persistResult() } catch (e) { Tracker.track('hair.generate', { 'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false @@ -244,68 +213,13 @@ struct HairDesignPage { } } - 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):优先复用本机留存文件(一次写、双用途);留存失败过的现场写缓存兜底。 */ + /** 保存到相册:结果 Base64 现场写缓存文件 → 系统保存对话框(取消/失败三态文案)。 */ 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`) + const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`) this.statusText = await this.saveFileToGallery(path) } catch (_e) { this.statusText = '保存失败,请重试' @@ -327,14 +241,6 @@ struct HairDesignPage { } } - /** 「上次结果」卡的保存(#18):直接走本机副本,不依赖网络。 */ - private async saveLastResult(): Promise { - if (this.lastLocalPath.length === 0) { - return - } - this.statusText = await this.saveFileToGallery(this.lastLocalPath) - } - /** 今日试戴额度耗尽的引导 overlay(P0-5):不发请求,给「做任务换额度」出路。 */ @Builder QuotaBlockedDialog() { @@ -408,92 +314,94 @@ struct HairDesignPage { 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) + // 图片区页签:原图 | 效果图(无结果时效果图置灰不可点) + 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) - // 右图:效果图(点按在 效果图/原图 间切换对比,沿用现交互) - 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) + // 图片区(单区域):无照片=上传入口;生成中=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) } - - // 右下「今日还可试戴 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) + .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) } - .layoutWeight(1) - .onClick(() => { - if (this.resultPm !== undefined && !this.generating) { - this.showOriginal = !this.showOriginal + + // 右下「今日还可试戴 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) }) @@ -511,31 +419,8 @@ struct HairDesignPage { 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 款两段都展示 + // 发型网格:点选只选中(描边),吸底「换发型」按钮触发试戴 + Text('选择发型').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY) Row() { Segment({ items: ['女生', '男生'], @@ -561,7 +446,9 @@ struct HairDesignPage { Text(style.name).fontSize(11).fontColor(COLOR_TEXT_PRIMARY).maxLines(1) } } - .onClick(() => { this.tryOn(style) }) + .onClick(() => { + this.selectedStyleId = style.id // 只选中不生成,底部按钮触发 + }) }, (style: Hairstyle) => style.id) } .columnsTemplate('1fr 1fr 1fr') @@ -605,41 +492,8 @@ struct HairDesignPage { .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('试戴与换发色合并计算每日额度') + // 合并计数脚注(P0-5):试戴与换发色页共用 hairstyle 每日额度 + Text('试戴与换发色共用每日额度') .fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%') } .width('100%') @@ -648,26 +502,25 @@ struct HairDesignPage { .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 }) + // 吸底「换发型」:白底条+顶分隔线,高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%') @@ -694,4 +547,9 @@ struct HairDesignPage { showClose: true }) } + + /** 吸底按钮可用条件:已上传照片 + 已选发型 + 不在生成中。 */ + private canGenerate(): boolean { + return !this.generating && this.photoUri.length > 0 && this.selectedStyleId.length > 0 + } } diff --git a/app/entry/src/main/ets/pages/HomePage.ets b/app/entry/src/main/ets/pages/HomePage.ets index ff4dc8d..3de1eba 100644 --- a/app/entry/src/main/ets/pages/HomePage.ets +++ b/app/entry/src/main/ets/pages/HomePage.ets @@ -168,7 +168,7 @@ struct HomePage { .alignItems(VerticalAlign.Center) } - /** Tab1 首页:问候区 + 两张功能入口卡 + 今日额度小卡。 */ + /** Tab1 首页:问候区 + 功能入口卡 + 今日额度小卡。 */ @Builder HomeTab() { Scroll() { @@ -188,7 +188,8 @@ struct HomePage { this.EntryCard('美学诊断', '个人形象风格分析报告', '▣', ACCENT, 'pages/DiagnosisLandingPage', 'diagnosis') this.EntryCard('美搭配', '我的衣橱,换装试穿', '◈', '#E8912D', 'pages/FittingRoomPage', 'fitting') - this.EntryCard('发型设计', '60 款发型试戴,还能换发色', '✂', '#2DB38E', 'pages/HairDesignPage', 'hair') + this.EntryCard('发型设计', '60 款发型,点选试戴', '✂', '#2DB38E', 'pages/HairDesignPage', 'hair') + this.EntryCard('换发色', '8 种发色,一键换着看', '◐', '#8E5BD3', 'pages/HairColorPage', 'haircolor') this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage', 'chat') if (this.quotaNetError) { // P0-6:额度拉取遇网络错误——横幅换占配额卡位置,重试可恢复 diff --git a/app/entry/src/main/resources/base/profile/main_pages.json b/app/entry/src/main/resources/base/profile/main_pages.json index 3e40c1c..e6adef7 100644 --- a/app/entry/src/main/resources/base/profile/main_pages.json +++ b/app/entry/src/main/resources/base/profile/main_pages.json @@ -10,6 +10,7 @@ "pages/LlmTestPage", "pages/TryonPage", "pages/HairDesignPage", + "pages/HairColorPage", "pages/ReportPdfPage", "pages/FittingRoomPage", "pages/DiagnosisLandingPage",