feat(hair): 发型/换发色交互重排——页签单图区(顶对齐裁切)+ 点选+吸底按钮触发 + 换发色拆独立页 + 60款男女全量重分类

- 发型设计:原图/效果图改单区域+胶囊页签切换(无结果置灰);点选发型只选中不生成,吸底「试戴『×××』」按钮触发;移除页内换发色卡与「上次结果」回看(相册留存即可)
- 换发色独立页 HairColorPage(主页新入口卡):8 色圆点+强度滑杆,每次在原图上换色不叠加;两页图片均顶部对齐裁切(容器定高 clip,头顶不被居中裁掉)
- 60 款发型男女分类按缩略图模特呈现全量重分(男30/女30;旧版互串 18 款)
- 两页仍共用 hairstyle 每日额度(P0-5 合并计数)
This commit is contained in:
xsl
2026-09-11 23:45:15 +08:00
parent a850735d60
commit aafacbf020
5 changed files with 638 additions and 302 deletions
+14 -12
View File
@@ -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<string> = new Set<string>([
'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<string> = new Set<string>([
'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
@@ -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<void> {
await DailyLimiter.init()
try {
const q = await ApiClient.get<HairQuotaWire>('/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<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.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<void> {
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<void> {
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<string> {
try {
const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get())
const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE }
const uris: Array<string> = await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg])
return uris.length > 0 ? '已保存到相册' : '已取消保存'
} catch (_e) {
return '保存失败,请重试'
}
}
/** 今日额度耗尽的引导 overlay(P0-5):不发请求,给「做任务换额度」出路。 */
@Builder
QuotaBlockedDialog() {
Column() {
Column({ space: 14 }) {
Text('今日换发色额度已用完')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_PRIMARY)
Text('明天再来,或做任务多换几次')
.fontSize(13)
.fontColor(COLOR_TEXT_SECONDARY)
.textAlign(TextAlign.Center)
Row({ space: 12 }) {
Button('知道了')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor(COLOR_IMAGE_BG)
.fontColor(COLOR_TEXT_PRIMARY)
.onClick(() => {
this.showQuotaBlocked = false
})
Button('做任务换额度')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.showQuotaBlocked = false
this.showTaskPanel = true
})
}
.width('100%')
.margin({ top: 4 })
}
.width('82%')
.padding(20)
.borderRadius(16)
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
.backgroundColor(COLOR_BADGE_BG)
.justifyContent(FlexAlign.Center)
}
/** 「做任务换额度」半屏面板(P0-4):到账后重拉额度。 */
@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
}
}
+146 -288
View File
@@ -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<string, Object> | 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<void> {
if (this.photoUri.length === 0 || this.generating) {
/** 吸底按钮触发:对当前选中发型发起试戴(点选网格只选中,不发请求)。 */
private async tryOn(): Promise<void> {
if (this.photoUri.length === 0 || this.selectedStyleId.length === 0 || this.generating) {
return
}
let style: Hairstyle | undefined = undefined
for (const s of this.styles) {
if (s.id === this.selectedStyleId) {
style = s
break
}
}
if (style === undefined) {
return
}
// 每日额度耗尽(P0-5,与换发色合并计数):不发请求,给「做任务换额度」出路
@@ -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<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):优先复用本机留存文件(一次写、双用途);留存失败过的现场写缓存兜底。 */
/** 保存到相册:结果 Base64 现场写缓存文件 → 系统保存对话框(取消/失败三态文案)。 */
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`)
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<void> {
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
}
}
+3 -2
View File
@@ -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:额度拉取遇网络错误——横幅换占配额卡位置,重试可恢复
@@ -10,6 +10,7 @@
"pages/LlmTestPage",
"pages/TryonPage",
"pages/HairDesignPage",
"pages/HairColorPage",
"pages/ReportPdfPage",
"pages/FittingRoomPage",
"pages/DiagnosisLandingPage",