feat(hair): 发型/换发色交互重排——页签单图区(顶对齐裁切)+ 点选+吸底按钮触发 + 换发色拆独立页 + 60款男女全量重分类
- 发型设计:原图/效果图改单区域+胶囊页签切换(无结果置灰);点选发型只选中不生成,吸底「试戴『×××』」按钮触发;移除页内换发色卡与「上次结果」回看(相册留存即可) - 换发色独立页 HairColorPage(主页新入口卡):8 色圆点+强度滑杆,每次在原图上换色不叠加;两页图片均顶部对齐裁切(容器定高 clip,头顶不被居中裁掉) - 60 款发型男女分类按缩略图模特呈现全量重分(男30/女30;旧版互串 18 款) - 两页仍共用 hairstyle 每日额度(P0-5 合并计数)
This commit is contained in:
@@ -26,28 +26,30 @@ interface HairFormPart {
|
|||||||
const HAIR_BOUNDARY: string = '----MeidaHairForm7MA4YWxkTrZu0gW'
|
const HAIR_BOUNDARY: string = '----MeidaHairForm7MA4YWxkTrZu0gW'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发型性别映射:服务端 hairstyles 接口无性别字段,60 款由缩略图视觉分类
|
* 发型性别映射:服务端 hairstyles 接口无性别字段,60 款由缩略图逐张视觉分类。
|
||||||
* (2026-09-02,男 26 / 女 34,抽检校准)。新增未知 id 返回 unknown(前端两个 Tab 都展示)。
|
* 2026-09-11 全量重分类(男 30 / 女 30,无 unknown):缩略图按男模/女模实拍渲染,
|
||||||
|
* 以模特呈现为准(此前 2026-09-02 版抽检不准,男/女 Tab 互串 18 款,用户反馈后重分)。
|
||||||
|
* 新增未知 id 返回 unknown(前端两个 Tab 都展示)。
|
||||||
*/
|
*/
|
||||||
const MALE_HAIRSTYLE_IDS: Set<string> = new Set<string>([
|
const MALE_HAIRSTYLE_IDS: Set<string> = new Set<string>([
|
||||||
'1953267161121464322', '1953267288284372994', '1953270724765720577', '1953271922151432194',
|
'1953267161121464322', '1953267288284372994', '1953270724765720577', '1953271922151432194',
|
||||||
'1953272913265467394', '1953288800932503554', '1954536242109784066', '1954536297776586754',
|
'1953272913265467394', '1953288800932503554', '1954536242109784066', '1954536297776586754',
|
||||||
'1954536402516746241', '1954536893304840194', '1954536956919848962', '1954537191729569793',
|
'1954536402516746241', '1954536893304840194', '1954536956919848962', '1954537191729569793',
|
||||||
'1980314032369082369', '1980314209322573825', '1980314290419441665', '1980314381058351106',
|
'1980312945301954561', '1980314209322573825', '1980314710101499906', '1980314764421931009',
|
||||||
'1980368588293578754', '1980413320612847618', '1980415346071609345', '1980415897056354306',
|
'1980314868713299969', '1980367781510815746', '1980367800787836929', '1980367852847538177',
|
||||||
'1981177324700549122', '1981177378714796034', '1981177477423546370', '1981177594750812162',
|
'1980367968627105793', '1980368109866098690', '1980368205752082434', '1980368465631158274',
|
||||||
|
'1980368588293578754', '1981177378714796034', '1981177477423546370', '1981177594750812162',
|
||||||
'1981177742742634497', '1981177931859607554'
|
'1981177742742634497', '1981177931859607554'
|
||||||
])
|
])
|
||||||
const FEMALE_HAIRSTYLE_IDS: Set<string> = new Set<string>([
|
const FEMALE_HAIRSTYLE_IDS: Set<string> = new Set<string>([
|
||||||
'1953270523594317826', '1954537022875279362', '1954537085882114050', '1954537709436706817',
|
'1953270523594317826', '1954537022875279362', '1954537085882114050', '1954537709436706817',
|
||||||
'1954537743532204034', '1954537961069780994', '1954538119216013313', '1954538156499181569',
|
'1954537743532204034', '1954537961069780994', '1954538119216013313', '1954538156499181569',
|
||||||
'1954538259330932738', '1954538335306555393', '1980312945301954561', '1980313052302843905',
|
'1954538259330932738', '1954538335306555393', '1980313052302843905', '1980313166576656385',
|
||||||
'1980313166576656385', '1980313210109337601', '1980313433502162946', '1980313576506957825',
|
'1980313210109337601', '1980313433502162946', '1980313576506957825', '1980313740403580930',
|
||||||
'1980313740403580930', '1980313819915001858', '1980314477720281090', '1980314710101499906',
|
'1980313819915001858', '1980314032369082369', '1980314290419441665', '1980314381058351106',
|
||||||
'1980314764421931009', '1980314868713299969', '1980315179934851074', '1980315238260842497',
|
'1980314477720281090', '1980315179934851074', '1980315238260842497', '1980361636742209537',
|
||||||
'1980361636742209537', '1980367742952579074', '1980367781510815746', '1980367800787836929',
|
'1980367742952579074', '1980368368847593473', '1980413320612847618', '1980415346071609345',
|
||||||
'1980367852847538177', '1980367968627105793', '1980368109866098690', '1980368205752082434',
|
'1980415897056354306', '1981177324700549122'
|
||||||
'1980368368847593473', '1980368465631158274'
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const TRYON_DOMAIN: number = 0x0200
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* HairDesignPage —— 发型设计(试戴 + 换发色),Phase A 直连线上服务。
|
* HairDesignPage —— 发型设计(只试戴;换发色已拆到独立页 HairColorPage),Phase A 直连线上服务。
|
||||||
*
|
*
|
||||||
* 流程(产品方案 §2.3):上传面部特写 → 点选发型即试戴(5~10s)→ 可换发色 → 保存相册。
|
* 交互(2026-09 按用户反馈重排):
|
||||||
|
* 图片区单区域展示,顶部「原图/效果图」页签切换(无结果时效果图页签置灰不可点);
|
||||||
|
* 点选发型只选中不生成,吸底「换发型」按钮触发试戴(5~10s)→ 成功自动切到效果图页签;
|
||||||
|
* 保存走系统相册对话框;不再保留「上次结果」回看卡(相册留存即可)。
|
||||||
* 异常口径:400 用服务端 message 原样提示;失败保留当前图不覆盖。
|
* 异常口径:400 用服务端 message 原样提示;失败保留当前图不覆盖。
|
||||||
* 发型库三态(#8/#18):加载中 / 空态 / 失败+重新加载。
|
* 发型库三态(#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 { router } from '@kit.ArkUI'
|
||||||
import { image } from '@kit.ImageKit'
|
import { image } from '@kit.ImageKit'
|
||||||
import { util } from '@kit.ArkTS'
|
|
||||||
import { photoAccessHelper } from '@kit.MediaLibraryKit'
|
import { photoAccessHelper } from '@kit.MediaLibraryKit'
|
||||||
import { fileUri } from '@kit.CoreFileKit'
|
import { fileUri } from '@kit.CoreFileKit'
|
||||||
import fs from '@ohos.file.fs'
|
|
||||||
import { TryonApi, Hairstyle } from '../core/TryonApi'
|
import { TryonApi, Hairstyle } from '../core/TryonApi'
|
||||||
import { ImageCodec } from '../core/ImageCodec'
|
import { ImageCodec } from '../core/ImageCodec'
|
||||||
import { ApiClient } from '../core/ApiClient'
|
import { ApiClient } from '../core/ApiClient'
|
||||||
@@ -38,7 +32,6 @@ import {
|
|||||||
COLOR_ERROR
|
COLOR_ERROR
|
||||||
} from '../core/Theme'
|
} from '../core/Theme'
|
||||||
import { Segment } from '../components/f2/Segment'
|
import { Segment } from '../components/f2/Segment'
|
||||||
import { ColorDot } from '../components/f2/ColorDot'
|
|
||||||
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
|
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
|
||||||
import { TaskBonusPanel } from '../components/f1/TaskBonusPanel'
|
import { TaskBonusPanel } from '../components/f1/TaskBonusPanel'
|
||||||
|
|
||||||
@@ -52,22 +45,6 @@ interface HairQuotaWire {
|
|||||||
reset_at: string | null
|
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
|
@Entry
|
||||||
@Component
|
@Component
|
||||||
struct HairDesignPage {
|
struct HairDesignPage {
|
||||||
@@ -79,36 +56,20 @@ struct HairDesignPage {
|
|||||||
@State generating: boolean = false
|
@State generating: boolean = false
|
||||||
@State statusText: string = ''
|
@State statusText: string = ''
|
||||||
@State resultPm: image.PixelMap | undefined = undefined
|
@State resultPm: image.PixelMap | undefined = undefined
|
||||||
@State resultBase64: string = '' // 换发色/保存都用它,避免二次编码
|
@State resultBase64: string = '' // 保存用它,避免二次编码
|
||||||
@State resultLocalPath: string = '' // 本机留存副本(#18):保存优先复用
|
@State imageTab: number = 0 // 图片区页签:0=原图 1=效果图(无结果时置灰)
|
||||||
@State lastLocalPath: string = '' // 「上次结果」卡数据源(aboutToAppear 回看,#18)
|
@State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款多于男款)
|
||||||
@State showOriginal: boolean = false
|
|
||||||
@State colorIdx: number = 1
|
|
||||||
@State ratio: number = 0.9
|
|
||||||
@State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款 34 / 男款 26)
|
|
||||||
@State showFaceConsent: boolean = false
|
@State showFaceConsent: boolean = false
|
||||||
// —— 每日试戴额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2;试戴与换发色合并计数)——
|
// —— 每日试戴额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2;与换发色共用 hairstyle key)——
|
||||||
@State hairRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余
|
@State hairRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余
|
||||||
@State showQuotaBlocked: boolean = false // 额度耗尽引导 overlay
|
@State showQuotaBlocked: boolean = false // 额度耗尽引导 overlay
|
||||||
@State showTaskPanel: boolean = false // 「做任务换额度」半屏面板
|
@State showTaskPanel: boolean = false // 「做任务换额度」半屏面板
|
||||||
private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口
|
private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口
|
||||||
|
private imageTabs: string[] = ['原图', '效果图']
|
||||||
/** 本机结果留存路径(#18):filesDir/results(cacheDir 有系统配额会被清)。 */
|
|
||||||
private resultStorePath(): string {
|
|
||||||
return `${ContextHolder.get().filesDir}/results/hair_last.jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
aboutToAppear(): void {
|
aboutToAppear(): void {
|
||||||
this.loadStyles()
|
this.loadStyles()
|
||||||
this.loadHairRemaining()
|
this.loadHairRemaining()
|
||||||
// 上次结果回看(#18):沙盒留存最近一次发型结果,杀 App/重启不丢
|
|
||||||
try {
|
|
||||||
if (fs.accessSync(this.resultStorePath())) {
|
|
||||||
this.lastLocalPath = this.resultStorePath()
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
// 目录不存在等:视为无上次结果
|
|
||||||
}
|
|
||||||
// 预留:Agent 联动(V2)预选发型,见产品方案 §7
|
// 预留:Agent 联动(V2)预选发型,见产品方案 §7
|
||||||
const params = router.getParams() as Record<string, Object> | undefined
|
const params = router.getParams() as Record<string, Object> | undefined
|
||||||
if (params !== undefined && typeof params['hairstyle_id'] === 'string') {
|
if (params !== undefined && typeof params['hairstyle_id'] === 'string') {
|
||||||
@@ -158,15 +119,14 @@ struct HairDesignPage {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RGB 分量转两位 hex(发色圆点展示用)。 */
|
/** 当前选中的发型名(吸底按钮文案用);未选中为空串。 */
|
||||||
private hex2(v: number): string {
|
private selectedStyleName(): string {
|
||||||
const s: string = v.toString(16)
|
for (const s of this.styles) {
|
||||||
return s.length < 2 ? `0${s}` : s
|
if (s.id === this.selectedStyleId) {
|
||||||
}
|
return s.name
|
||||||
|
}
|
||||||
/** PRESET_COLORS 的 RGB 转 #RRGGBB:仅圆点展示,发色接口 rgb 传参格式不变。 */
|
}
|
||||||
private colorHex(rgb: number[]): string {
|
return ''
|
||||||
return `#${this.hex2(rgb[0])}${this.hex2(rgb[1])}${this.hex2(rgb[2])}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传面部特写前须单独同意(CR-01:人脸属敏感个人信息)。 */
|
/** 上传面部特写前须单独同意(CR-01:人脸属敏感个人信息)。 */
|
||||||
@@ -188,8 +148,8 @@ struct HairDesignPage {
|
|||||||
this.photoUri = picked.uri
|
this.photoUri = picked.uri
|
||||||
this.resultPm = undefined
|
this.resultPm = undefined
|
||||||
this.resultBase64 = ''
|
this.resultBase64 = ''
|
||||||
this.resultLocalPath = ''
|
|
||||||
this.selectedStyleId = ''
|
this.selectedStyleId = ''
|
||||||
|
this.imageTab = 0
|
||||||
this.statusText = ''
|
this.statusText = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.statusText = e instanceof ApiError ? e.message : '读取照片失败'
|
this.statusText = e instanceof ApiError ? e.message : '读取照片失败'
|
||||||
@@ -207,8 +167,19 @@ struct HairDesignPage {
|
|||||||
this.statusText = '未同意照片授权,你可以先浏览下方发型库'
|
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
|
return
|
||||||
}
|
}
|
||||||
// 每日额度耗尽(P0-5,与换发色合并计数):不发请求,给「做任务换额度」出路
|
// 每日额度耗尽(P0-5,与换发色合并计数):不发请求,给「做任务换额度」出路
|
||||||
@@ -219,21 +190,19 @@ struct HairDesignPage {
|
|||||||
}
|
}
|
||||||
const startMs: number = Date.now()
|
const startMs: number = Date.now()
|
||||||
this.generating = true
|
this.generating = true
|
||||||
this.selectedStyleId = style.id
|
|
||||||
this.showOriginal = false
|
|
||||||
this.statusText = `正在试戴「${style.name}」,约 5~15 秒…`
|
this.statusText = `正在试戴「${style.name}」,约 5~15 秒…`
|
||||||
try {
|
try {
|
||||||
const buf = await ImageCodec.toJpegBuffer(this.photoUri)
|
const buf = await ImageCodec.toJpegBuffer(this.photoUri)
|
||||||
this.resultBase64 = await TryonApi.changeHair(buf, style.id)
|
this.resultBase64 = await TryonApi.changeHair(buf, style.id)
|
||||||
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
||||||
this.statusText = '试戴完成,点「对比」看原图'
|
this.imageTab = 1 // 成功自动切到效果图页签
|
||||||
|
this.statusText = '试戴完成,点「原图」页签可对比'
|
||||||
// 成功才计数(失败不占额度):端侧记账 + 埋点上报
|
// 成功才计数(失败不占额度):端侧记账 + 埋点上报
|
||||||
DailyLimiter.tryConsume('hairstyle')
|
DailyLimiter.tryConsume('hairstyle')
|
||||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||||
Tracker.track('hair.generate', {
|
Tracker.track('hair.generate', {
|
||||||
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||||
})
|
})
|
||||||
this.persistResult()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Tracker.track('hair.generate', {
|
Tracker.track('hair.generate', {
|
||||||
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||||
@@ -244,68 +213,13 @@ struct HairDesignPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async changeColor(): Promise<void> {
|
/** 保存到相册:结果 Base64 现场写缓存文件 → 系统保存对话框(取消/失败三态文案)。 */
|
||||||
if (this.resultBase64.length === 0 || this.generating) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 每日额度耗尽(P0-5,与试戴合并计数同一 hairstyle key):不发请求,给出路
|
|
||||||
if (this.hairRemaining === 0) {
|
|
||||||
Tracker.track('hair.generate', { 'ok': false, 'duration_s': 0, 'blocked': true })
|
|
||||||
this.showQuotaBlocked = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const startMs: number = Date.now()
|
|
||||||
this.generating = true
|
|
||||||
const c: PresetColor = PRESET_COLORS[this.colorIdx]
|
|
||||||
this.statusText = `正在换发色(${c.name})…`
|
|
||||||
try {
|
|
||||||
const helper = new util.Base64Helper()
|
|
||||||
const pure: string = this.resultBase64.includes(',')
|
|
||||||
? this.resultBase64.substring(this.resultBase64.indexOf(',') + 1)
|
|
||||||
: this.resultBase64
|
|
||||||
// slice() 拷贝出精确长度的副本:decodeSync 返回视图,直接取 .buffer 会带尾部脏字节
|
|
||||||
const buf = helper.decodeSync(pure).slice().buffer as ArrayBuffer
|
|
||||||
const rgb: string = `[${c.rgb[0]}, ${c.rgb[1]}, ${c.rgb[2]}]`
|
|
||||||
this.resultBase64 = await TryonApi.changeHairColor(buf, rgb, this.ratio)
|
|
||||||
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
|
||||||
this.statusText = '换发色完成'
|
|
||||||
// 成功才计数(与试戴同一 hairstyle key,合并计算每日额度)
|
|
||||||
DailyLimiter.tryConsume('hairstyle')
|
|
||||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
|
||||||
Tracker.track('hair.generate', {
|
|
||||||
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
|
||||||
})
|
|
||||||
this.persistResult() // 落盘覆盖为最新发色版本(#18 验收④)
|
|
||||||
} catch (e) {
|
|
||||||
Tracker.track('hair.generate', {
|
|
||||||
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
|
||||||
})
|
|
||||||
this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试'
|
|
||||||
} finally {
|
|
||||||
this.generating = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 结果留存(#18):当前结果 Base64 落 filesDir/results/hair_last.jpg;失败不阻断展示。 */
|
|
||||||
private persistResult(): void {
|
|
||||||
try {
|
|
||||||
const path: string = ImageCodec.base64ToPersistFile(this.resultBase64, 'hair_last.jpg')
|
|
||||||
this.resultLocalPath = path
|
|
||||||
this.lastLocalPath = path
|
|
||||||
} catch (_e) {
|
|
||||||
this.resultLocalPath = '' // 落盘失败:保存时走 base64 现场写缓存兜底
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存到相册(#18):优先复用本机留存文件(一次写、双用途);留存失败过的现场写缓存兜底。 */
|
|
||||||
private async saveResult(): Promise<void> {
|
private async saveResult(): Promise<void> {
|
||||||
if (this.resultBase64.length === 0) {
|
if (this.resultBase64.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const path: string = this.resultLocalPath.length > 0
|
const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`)
|
||||||
? this.resultLocalPath
|
|
||||||
: ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`)
|
|
||||||
this.statusText = await this.saveFileToGallery(path)
|
this.statusText = await this.saveFileToGallery(path)
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
this.statusText = '保存失败,请重试'
|
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):不发请求,给「做任务换额度」出路。 */
|
/** 今日试戴额度耗尽的引导 overlay(P0-5):不发请求,给「做任务换额度」出路。 */
|
||||||
@Builder
|
@Builder
|
||||||
QuotaBlockedDialog() {
|
QuotaBlockedDialog() {
|
||||||
@@ -408,92 +314,94 @@ struct HairDesignPage {
|
|||||||
|
|
||||||
Scroll() {
|
Scroll() {
|
||||||
Column({ space: 12 }) {
|
Column({ space: 12 }) {
|
||||||
// 顶部双图区(v2):原图 | 效果图 等宽,左下角标当前显示态,点右图切换对比
|
// 图片区页签:原图 | 效果图(无结果时效果图置灰不可点)
|
||||||
Row({ space: 12 }) {
|
Row() {
|
||||||
// 左图:原图(无照片时为上传入口占位)
|
ForEach(this.imageTabs, (label: string, idx: number) => {
|
||||||
Stack({ alignContent: Alignment.BottomStart }) {
|
Text(label)
|
||||||
if (this.photoUri.length > 0) {
|
.fontSize(13)
|
||||||
Image(this.photoUri)
|
.fontColor(idx === 1 && this.resultPm === undefined
|
||||||
.width('100%').height(240).objectFit(ImageFit.Cover).borderRadius(12)
|
? COLOR_TEXT_DISABLED
|
||||||
Text('原图')
|
: (this.imageTab === idx ? Color.White : COLOR_TEXT_PRIMARY))
|
||||||
.fontSize(11).fontColor(Color.White)
|
.backgroundColor(this.imageTab === idx && !(idx === 1 && this.resultPm === undefined)
|
||||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
? COLOR_PRIMARY
|
||||||
.borderRadius(12)
|
: Color.Transparent)
|
||||||
.backgroundColor(COLOR_BADGE_BG)
|
.padding({ left: 20, right: 20, top: 7, bottom: 7 })
|
||||||
.margin(8)
|
.borderRadius(16)
|
||||||
} else {
|
.onClick(() => {
|
||||||
Column({ space: 6 }) {
|
if (idx === 1 && this.resultPm === undefined) {
|
||||||
Text('+').fontSize(28).fontColor(COLOR_TEXT_SECONDARY)
|
return // 无结果:效果图页签不响应
|
||||||
Text('上传面部特写').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
}
|
||||||
}
|
this.imageTab = idx
|
||||||
.width('100%').height(240).justifyContent(FlexAlign.Center)
|
})
|
||||||
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
}, (label: string) => label)
|
||||||
.onClick(() => { this.pickPhoto(false) })
|
}
|
||||||
}
|
.padding(2)
|
||||||
}
|
.borderRadius(18)
|
||||||
.layoutWeight(1)
|
.backgroundColor(COLOR_IMAGE_BG)
|
||||||
|
.alignSelf(ItemAlign.Start)
|
||||||
|
|
||||||
// 右图:效果图(点按在 效果图/原图 间切换对比,沿用现交互)
|
// 图片区(单区域):无照片=上传入口;生成中=loading;按页签显示原图/效果图
|
||||||
Stack({ alignContent: Alignment.BottomStart }) {
|
Stack({ alignContent: Alignment.BottomStart }) {
|
||||||
if (this.generating) {
|
if (this.photoUri.length === 0) {
|
||||||
Column({ space: 8 }) {
|
Column({ space: 6 }) {
|
||||||
LoadingProgress().width(36).height(36).color(COLOR_PRIMARY)
|
Text('+').fontSize(28).fontColor(COLOR_TEXT_SECONDARY)
|
||||||
Text('生成中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
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)
|
|
||||||
}
|
}
|
||||||
|
.width('100%').height(320).justifyContent(FlexAlign.Center)
|
||||||
// 右下「今日还可试戴 N 次」胶囊(P0-5,试戴+换发色合并计数):仅展示不参与命中
|
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||||
if (this.hairRemaining >= 0) {
|
.onClick(() => { this.pickPhoto(false) })
|
||||||
Row() {
|
} else if (this.generating) {
|
||||||
Text(`今日还可试戴 ${this.hairRemaining} 次`)
|
Column({ space: 8 }) {
|
||||||
.fontSize(11).fontColor(Color.White)
|
LoadingProgress().width(36).height(36).color(COLOR_PRIMARY)
|
||||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
Text('生成中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
||||||
.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)
|
||||||
|
} 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(() => {
|
// 右下「今日还可试戴 N 次」胶囊(P0-5,与换发色共用额度):仅展示不参与命中
|
||||||
if (this.resultPm !== undefined && !this.generating) {
|
if (this.hairRemaining >= 0 && this.photoUri.length > 0) {
|
||||||
this.showOriginal = !this.showOriginal
|
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%')
|
.width('100%')
|
||||||
|
|
||||||
// 入口文字链(沿用):相册/拍照 + 保存(保存沿用现交互与置灰口径)
|
// 入口文字链:相册/拍照 + 保存(有结果才可点)
|
||||||
Row({ space: 16 }) {
|
Row({ space: 16 }) {
|
||||||
Text('相册').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(false) })
|
Text('相册').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(false) })
|
||||||
Text('拍照').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(true) })
|
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%')
|
Text(this.statusText).fontSize(12).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上次发型结果回看(#18):无本次结果时展示留存副本(保存/移除)
|
// 发型网格:点选只选中(描边),吸底「换发型」按钮触发试戴
|
||||||
if (this.lastLocalPath.length > 0 && this.resultPm === undefined) {
|
Text('选择发型').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
|
||||||
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() {
|
Row() {
|
||||||
Segment({
|
Segment({
|
||||||
items: ['女生', '男生'],
|
items: ['女生', '男生'],
|
||||||
@@ -561,7 +446,9 @@ struct HairDesignPage {
|
|||||||
Text(style.name).fontSize(11).fontColor(COLOR_TEXT_PRIMARY).maxLines(1)
|
Text(style.name).fontSize(11).fontColor(COLOR_TEXT_PRIMARY).maxLines(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onClick(() => { this.tryOn(style) })
|
.onClick(() => {
|
||||||
|
this.selectedStyleId = style.id // 只选中不生成,底部按钮触发
|
||||||
|
})
|
||||||
}, (style: Hairstyle) => style.id)
|
}, (style: Hairstyle) => style.id)
|
||||||
}
|
}
|
||||||
.columnsTemplate('1fr 1fr 1fr')
|
.columnsTemplate('1fr 1fr 1fr')
|
||||||
@@ -605,41 +492,8 @@ struct HairDesignPage {
|
|||||||
.padding({ top: 8, bottom: 8 })
|
.padding({ top: 8, bottom: 8 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 换发色卡(v2):8 色圆点+名称 + 强度滑杆;无结果整体隐藏(沿用现逻辑)
|
// 合并计数脚注(P0-5):试戴与换发色页共用 hairstyle 每日额度
|
||||||
if (this.resultPm !== undefined) {
|
Text('试戴与换发色共用每日额度')
|
||||||
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%')
|
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
@@ -648,26 +502,25 @@ struct HairDesignPage {
|
|||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.scrollBar(BarState.Off)
|
.scrollBar(BarState.Off)
|
||||||
|
|
||||||
// 吸底「应用发色」(v2):白底条+顶分隔线,高48圆角24主色;
|
// 吸底「换发型」:白底条+顶分隔线,高48圆角24主色;未选照片/发型或生成中置灰
|
||||||
// 与发色卡同条件渲染(无结果不出现),生成中置灰并显示「生成中…」
|
Column() {
|
||||||
if (this.resultPm !== undefined) {
|
Divider().strokeWidth(0.5).color(COLOR_DIVIDER)
|
||||||
Column() {
|
Button(this.generating
|
||||||
Divider().strokeWidth(0.5).color(COLOR_DIVIDER)
|
? '生成中…'
|
||||||
Button(this.generating ? '生成中…' : '应用发色')
|
: (this.selectedStyleName().length > 0 ? `试戴「${this.selectedStyleName()}」` : '换发型'))
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height(48)
|
.height(48)
|
||||||
.borderRadius(24)
|
.borderRadius(24)
|
||||||
.fontSize(16)
|
.fontSize(16)
|
||||||
.fontWeight(FontWeight.Medium)
|
.fontWeight(FontWeight.Medium)
|
||||||
.fontColor(this.generating || this.resultBase64.length === 0 ? COLOR_TEXT_DISABLED : Color.White)
|
.fontColor(this.canGenerate() ? Color.White : COLOR_TEXT_DISABLED)
|
||||||
.backgroundColor(this.generating || this.resultBase64.length === 0 ? COLOR_DIVIDER : COLOR_PRIMARY)
|
.backgroundColor(this.canGenerate() ? COLOR_PRIMARY : COLOR_DIVIDER)
|
||||||
.enabled(!this.generating && this.resultBase64.length > 0)
|
.enabled(this.canGenerate())
|
||||||
.onClick(() => { this.changeColor() })
|
.onClick(() => { this.tryOn() })
|
||||||
}
|
|
||||||
.width('100%')
|
|
||||||
.backgroundColor(Color.White)
|
|
||||||
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
|
|
||||||
}
|
}
|
||||||
|
.width('100%')
|
||||||
|
.backgroundColor(Color.White)
|
||||||
|
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
@@ -694,4 +547,9 @@ struct HairDesignPage {
|
|||||||
showClose: true
|
showClose: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 吸底按钮可用条件:已上传照片 + 已选发型 + 不在生成中。 */
|
||||||
|
private canGenerate(): boolean {
|
||||||
|
return !this.generating && this.photoUri.length > 0 && this.selectedStyleId.length > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ struct HomePage {
|
|||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tab1 首页:问候区 + 两张功能入口卡 + 今日额度小卡。 */
|
/** Tab1 首页:问候区 + 功能入口卡 + 今日额度小卡。 */
|
||||||
@Builder
|
@Builder
|
||||||
HomeTab() {
|
HomeTab() {
|
||||||
Scroll() {
|
Scroll() {
|
||||||
@@ -188,7 +188,8 @@ struct HomePage {
|
|||||||
|
|
||||||
this.EntryCard('美学诊断', '个人形象风格分析报告', '▣', ACCENT, 'pages/DiagnosisLandingPage', 'diagnosis')
|
this.EntryCard('美学诊断', '个人形象风格分析报告', '▣', ACCENT, 'pages/DiagnosisLandingPage', 'diagnosis')
|
||||||
this.EntryCard('美搭配', '我的衣橱,换装试穿', '◈', '#E8912D', 'pages/FittingRoomPage', 'fitting')
|
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')
|
this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage', 'chat')
|
||||||
if (this.quotaNetError) {
|
if (this.quotaNetError) {
|
||||||
// P0-6:额度拉取遇网络错误——横幅换占配额卡位置,重试可恢复
|
// P0-6:额度拉取遇网络错误——横幅换占配额卡位置,重试可恢复
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"pages/LlmTestPage",
|
"pages/LlmTestPage",
|
||||||
"pages/TryonPage",
|
"pages/TryonPage",
|
||||||
"pages/HairDesignPage",
|
"pages/HairDesignPage",
|
||||||
|
"pages/HairColorPage",
|
||||||
"pages/ReportPdfPage",
|
"pages/ReportPdfPage",
|
||||||
"pages/FittingRoomPage",
|
"pages/FittingRoomPage",
|
||||||
"pages/DiagnosisLandingPage",
|
"pages/DiagnosisLandingPage",
|
||||||
|
|||||||
Reference in New Issue
Block a user