feat(app): 美搭配 v2(衣橱+精选30件)/发型 v2(男女分栏+换发色修复)/美学诊断两页——多 Agent 一轮交付

- fix(tryon): 鸿蒙 http.multiFormDataList 真机发空包(400 请上传图片)→ TryonApi 手工构造 multipart,真机验证通过;换发型/换发色/换装全链路 MEIDA_TRYON 打点
- feat(hair): 60 款发型视觉分类男女(26/34)Segment 分栏;双图对比、8 色圆点、吸底应用发色
- feat(fitting): FittingRoomPage 预览+试穿黑胶囊+我的衣橱(preferences+filesDir 持久化)+精选衣橱(rawfile 30 件+manifest)+品类过滤+双槽位生成
- feat(diagnosis): 紫渐变落地页+五字段填写页(@Prop 修选择器回填)+预填跳 ReportCreatePage
- chore(theme): 9 个色板 token 收口;首页入口改版
This commit is contained in:
xsl
2026-09-02 23:40:16 +08:00
parent f17868233a
commit 8a901176c4
49 changed files with 3048 additions and 204 deletions
+272 -187
View File
@@ -6,6 +6,11 @@
* 发型库三态(#8/#18):加载中 / 空态 / 失败+重新加载。
* 结果归宿(#18,与 #17 统一):试戴/换发色成功即落 filesDir/results/hair_last.jpg
* (换发色后覆盖为最新版本),保存优先复用该文件,杀 App 后「上次结果」卡可回看。
*
* v2 视觉重排(《美搭配与美学诊断-UI规范》§4,交互全部保留):
* 顶部双图区(原图|效果图 等宽,左下角标 COLOR_BADGE_BG,点右图切换对比);
* 性别分栏改 Segment 组件(女生/男生);8 色发色改 ColorDot 圆点;
* 「应用发色」移至吸底主按钮(无结果时随发色卡整体隐藏,沿用现逻辑)。
*/
import { router } from '@kit.ArkUI'
@@ -18,7 +23,19 @@ import { TryonApi, Hairstyle } from '../core/TryonApi'
import { ImageCodec } from '../core/ImageCodec'
import { ApiError } from '../core/ApiTypes'
import { ContextHolder } from '../core/ContextHolder'
import { COLOR_PRIMARY } from '../core/Theme'
import {
COLOR_PRIMARY,
COLOR_PAGE_BG,
COLOR_TEXT_PRIMARY,
COLOR_TEXT_SECONDARY,
COLOR_TEXT_DISABLED,
COLOR_DIVIDER,
COLOR_IMAGE_BG,
COLOR_BADGE_BG,
COLOR_ERROR
} from '../core/Theme'
import { Segment } from '../components/f2/Segment'
import { ColorDot } from '../components/f2/ColorDot'
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
interface PresetColor {
@@ -37,11 +54,6 @@ const PRESET_COLORS: PresetColor[] = [
{ name: '蓝黑', rgb: [40, 50, 70] }
]
const PAGE_BG: string = '#F5F6F8'
const ACCENT: string = COLOR_PRIMARY
const TEXT_PRIMARY: string = '#222222'
const TEXT_SECONDARY: string = '#8A9099'
@Entry
@Component
struct HairDesignPage {
@@ -59,6 +71,7 @@ struct HairDesignPage {
@State showOriginal: boolean = false
@State colorIdx: number = 1
@State ratio: number = 0.9
@State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款 34 / 男款 26)
@State showFaceConsent: boolean = false
private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口
@@ -97,6 +110,29 @@ struct HairDesignPage {
}
}
/** 当前 Tab 的发型(unknown 款两 Tab 都展示,服务端新增款不丢)。 */
private visibleStyles(): Hairstyle[] {
const out: Hairstyle[] = []
for (const s of this.styles) {
const g: string = TryonApi.hairstyleGender(s.id)
if (g === this.genderTab || g === 'unknown') {
out.push(s)
}
}
return out
}
/** RGB 分量转两位 hex(发色圆点展示用)。 */
private hex2(v: number): string {
const s: string = v.toString(16)
return s.length < 2 ? `0${s}` : s
}
/** PRESET_COLORS 的 RGB 转 #RRGGBB:仅圆点展示,发色接口 rgb 传参格式不变。 */
private colorHex(rgb: number[]): string {
return `#${this.hex2(rgb[0])}${this.hex2(rgb[1])}${this.hex2(rgb[2])}`
}
/** 上传面部特写前须单独同意(CR-01:人脸属敏感个人信息)。 */
private pickPhoto(fromCamera: boolean): void {
if (!FaceConsentStore.isGranted()) {
@@ -168,7 +204,8 @@ struct HairDesignPage {
const pure: string = this.resultBase64.includes(',')
? this.resultBase64.substring(this.resultBase64.indexOf(',') + 1)
: this.resultBase64
const buf = helper.decodeSync(pure).buffer as ArrayBuffer
// 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)
@@ -235,8 +272,8 @@ struct HairDesignPage {
Column() {
// 顶部标题栏
Row() {
Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back())
Text('发型设计').fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY)
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%')
@@ -245,199 +282,247 @@ struct HairDesignPage {
.backgroundColor(Color.White)
Scroll() {
Column({ space: 12 }) {
// 照片区:左原图 / 右结果
Row({ space: 12 }) {
Column({ space: 8 }) {
if (this.photoUri.length > 0) {
Image(this.photoUri)
.width('100%').height(220).objectFit(ImageFit.Cover).borderRadius(12)
} else {
Column({ space: 6 }) {
Text('+').fontSize(28).fontColor(TEXT_SECONDARY)
Text('上传面部特写').fontSize(12).fontColor(TEXT_SECONDARY)
}
.width('100%').height(220).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor('#EDEFF3')
.onClick(() => { this.pickPhoto(false) })
}
Row({ space: 8 }) {
Text('相册').fontSize(12).fontColor(ACCENT).onClick(() => { this.pickPhoto(false) })
Text('拍照').fontSize(12).fontColor(ACCENT).onClick(() => { this.pickPhoto(true) })
}
}
.layoutWeight(1)
Column({ space: 8 }) {
if (this.generating) {
Column({ space: 8 }) {
LoadingProgress().width(36).height(36).color(ACCENT)
Text('生成中…').fontSize(12).fontColor(TEXT_SECONDARY)
}
.width('100%').height(220).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor('#EDEFF3')
} else if (this.resultPm !== undefined && !this.showOriginal) {
Image(this.resultPm)
.width('100%').height(220).objectFit(ImageFit.Cover).borderRadius(12)
} else if (this.showOriginal && this.photoUri.length > 0) {
Image(this.photoUri)
.width('100%').height(220).objectFit(ImageFit.Cover).borderRadius(12)
} else {
Column() {
Text('效果图').fontSize(12).fontColor(TEXT_SECONDARY)
}
.width('100%').height(220).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor('#EDEFF3')
}
Row({ space: 8 }) {
Text(this.showOriginal ? '看效果' : '对比')
.fontSize(12)
.fontColor(this.resultPm !== undefined ? ACCENT : TEXT_SECONDARY)
.onClick(() => {
if (this.resultPm !== undefined) {
this.showOriginal = !this.showOriginal
}
})
Text('保存')
.fontSize(12)
.fontColor(this.resultPm !== undefined ? ACCENT : TEXT_SECONDARY)
.onClick(() => { this.saveResult() })
}
}
.layoutWeight(1)
}
.width('100%')
Text('请使用本人照片,上传即确认不侵犯他人肖像权')
.fontSize(11).fontColor(TEXT_SECONDARY).width('100%')
if (this.statusText.length > 0) {
Text(this.statusText).fontSize(12).fontColor(TEXT_SECONDARY).width('100%')
}
// 上次发型结果回看(#18):无本次结果时展示留存副本(保存/移除)
if (this.lastLocalPath.length > 0 && this.resultPm === undefined) {
Column({ space: 8 }) {
Text('上次发型结果')
.fontSize(13).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY).width('100%')
Image(fileUri.getUriFromPath(this.lastLocalPath))
.width('100%').height(280).objectFit(ImageFit.Contain)
.borderRadius(12).backgroundColor('#EDEFF3')
Row({ space: 16 }) {
Text('保存到相册').fontSize(13).fontColor(ACCENT)
.onClick(() => { this.saveLastResult() })
Text('知道了').fontSize(13).fontColor(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(TEXT_PRIMARY)
if (this.styles.length > 0) {
Grid() {
ForEach(this.styles, (style: Hairstyle) => {
GridItem() {
Column({ space: 4 }) {
Image(TryonApi.hairstyleImageUrl(style))
.width('100%').aspectRatio(0.72).objectFit(ImageFit.Cover)
.borderRadius(8)
.border(this.selectedStyleId === style.id
? { width: 2, color: ACCENT }
: { width: 0 })
Text(style.name).fontSize(11).fontColor(TEXT_PRIMARY).maxLines(1)
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) })
}
.onClick(() => { this.tryOn(style) })
}, (style: Hairstyle) => style.id)
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.width('100%')
} else if (this.listError.length > 0) {
// 失败态(#8/#18):错误文案 + 重新加载出口
Column({ space: 8 }) {
Text(this.listError).fontSize(12).fontColor('#C0392B')
Text('重新加载')
.fontSize(13).fontColor(ACCENT)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.borderRadius(14).backgroundColor(Color.White)
.onClick(() => { this.loadStyles() })
}
.layoutWeight(1)
// 右图:效果图(点按在 效果图/原图 间切换对比,沿用现交互)
Stack({ alignContent: Alignment.BottomStart }) {
if (this.generating) {
Column({ space: 8 }) {
LoadingProgress().width(36).height(36).color(COLOR_PRIMARY)
Text('生成中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
.width('100%').height(240).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
} else if (this.resultPm !== undefined && !this.showOriginal) {
Image(this.resultPm)
.width('100%').height(240).objectFit(ImageFit.Cover).borderRadius(12)
Text('效果图')
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.margin(8)
} else if (this.showOriginal && this.photoUri.length > 0) {
Image(this.photoUri)
.width('100%').height(240).objectFit(ImageFit.Cover).borderRadius(12)
Text('原图')
.fontSize(11).fontColor(Color.White)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.margin(8)
} else {
Column() {
Text('效果图').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
.width('100%').height(240).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
}
}
.layoutWeight(1)
.onClick(() => {
if (this.resultPm !== undefined && !this.generating) {
this.showOriginal = !this.showOriginal
}
})
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
} else if (this.listLoaded) {
// 空态(#18):加载成功但无上架发型
Column({ space: 8 }) {
Text('发型暂未上架,稍后再试').fontSize(12).fontColor(TEXT_SECONDARY)
Text('重新加载')
.fontSize(13).fontColor(ACCENT)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.borderRadius(14).backgroundColor(Color.White)
.onClick(() => { this.loadStyles() })
// 入口文字链(沿用):相册/拍照 + 保存(保存沿用现交互与置灰口径)
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%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
} else {
// 加载中(#18):不再让空列表永远停在「加载中」
Row({ space: 8 }) {
LoadingProgress().width(18).height(18).color(ACCENT)
Text('发型加载中…').fontSize(12).fontColor(TEXT_SECONDARY)
Text('请使用本人照片,上传即确认不侵犯他人肖像权')
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
if (this.statusText.length > 0) {
Text(this.statusText).fontSize(12).fontColor(COLOR_TEXT_SECONDARY).width('100%')
}
// 上次发型结果回看(#18):无本次结果时展示留存副本(保存/移除)
if (this.lastLocalPath.length > 0 && this.resultPm === undefined) {
Column({ space: 8 }) {
Text('上次发型结果')
.fontSize(13).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY).width('100%')
Image(fileUri.getUriFromPath(this.lastLocalPath))
.width('100%').height(280).objectFit(ImageFit.Contain)
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
Row({ space: 16 }) {
Text('保存到相册').fontSize(13).fontColor(COLOR_PRIMARY)
.onClick(() => { this.saveLastResult() })
Text('知道了').fontSize(13).fontColor(COLOR_TEXT_SECONDARY)
.onClick(() => { this.lastLocalPath = '' })
}
.width('100%').justifyContent(FlexAlign.Center)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(Color.White)
}
// 发型网格(男女分栏,三态沿用不动)
Text('选择发型(点选即试戴)').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
// 性别分段(v2):女生/男生,映射 genderTab;unknown 款两段都展示
Row() {
Segment({
items: ['女生', '男生'],
selected: this.genderTab === 'female' ? 0 : 1,
onSelect: (idx: number): void => {
this.genderTab = idx === 0 ? 'female' : 'male'
}
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
}
// 换发色(有结果才可用)
if (this.resultPm !== undefined) {
Column({ space: 8 }) {
Text('换个发色').fontSize(14).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY)
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(PRESET_COLORS, (c: PresetColor, idx: number) => {
Text(c.name)
.fontSize(12)
.fontColor(this.colorIdx === idx ? Color.White : TEXT_PRIMARY)
.backgroundColor(this.colorIdx === idx ? ACCENT : '#EDEFF3')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ right: 8, bottom: 8 })
.onClick(() => { this.colorIdx = idx })
})
if (this.visibleStyles().length > 0) {
Grid() {
ForEach(this.visibleStyles(), (style: Hairstyle) => {
GridItem() {
Column({ space: 4 }) {
Image(TryonApi.hairstyleImageUrl(style))
.width('100%').aspectRatio(0.72).objectFit(ImageFit.Cover)
.borderRadius(8)
.border(this.selectedStyleId === style.id
? { width: 2, color: COLOR_PRIMARY }
: { width: 0 })
Text(style.name).fontSize(11).fontColor(COLOR_TEXT_PRIMARY).maxLines(1)
}
}
.onClick(() => { this.tryOn(style) })
}, (style: Hairstyle) => style.id)
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.width('100%')
} else if (this.listError.length > 0) {
// 失败态(#8/#18):错误文案 + 重新加载出口
Column({ space: 8 }) {
Text(this.listError).fontSize(12).fontColor(COLOR_ERROR)
Text('重新加载')
.fontSize(13).fontColor(COLOR_PRIMARY)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.borderRadius(14).backgroundColor(Color.White)
.onClick(() => { this.loadStyles() })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
} else if (this.listLoaded) {
// 空态(#18):加载成功但无上架发型
Column({ space: 8 }) {
Text('发型暂未上架,稍后再试').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
Text('重新加载')
.fontSize(13).fontColor(COLOR_PRIMARY)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.borderRadius(14).backgroundColor(Color.White)
.onClick(() => { this.loadStyles() })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
} else {
// 加载中(#18):不再让空列表永远停在「加载中」
Row({ space: 8 }) {
Text('强度').fontSize(12).fontColor(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(TEXT_SECONDARY).width(28)
LoadingProgress().width(18).height(18).color(COLOR_PRIMARY)
Text('发型加载中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
}
Button(this.generating ? '生成中…' : '应用发色')
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
}
// 换发色卡(v2):8 色圆点+名称 + 强度滑杆;无结果整体隐藏(沿用现逻辑)
if (this.resultPm !== undefined) {
Column({ space: 12 }) {
Text('换个发色').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(PRESET_COLORS, (c: PresetColor, idx: number) => {
Column() {
ColorDot({
colorHex: this.colorHex(c.rgb),
label: c.name,
selected: this.colorIdx === idx,
onSelect: (): void => { this.colorIdx = idx }
})
}
.margin({ right: 14, bottom: 12 })
}, (c: PresetColor) => c.name)
}
.width('100%')
.backgroundColor(ACCENT)
.enabled(!this.generating && this.resultBase64.length > 0)
.onClick(() => { this.changeColor() })
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)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(Color.White)
}
.width('100%')
.padding(16)
}
.layoutWeight(1)
.scrollBar(BarState.Off)
// 吸底「应用发色」(v2):白底条+顶分隔线,高48圆角24主色;
// 与发色卡同条件渲染(无结果不出现),生成中置灰并显示「生成中…」
if (this.resultPm !== undefined) {
Column() {
Divider().strokeWidth(0.5).color(COLOR_DIVIDER)
Button(this.generating ? '生成中…' : '应用发色')
.width('100%')
.height(48)
.borderRadius(24)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(this.generating || this.resultBase64.length === 0 ? COLOR_TEXT_DISABLED : Color.White)
.backgroundColor(this.generating || this.resultBase64.length === 0 ? COLOR_DIVIDER : COLOR_PRIMARY)
.enabled(!this.generating && this.resultBase64.length > 0)
.onClick(() => { this.changeColor() })
}
.width('100%')
.backgroundColor(Color.White)
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
.width('100%')
.padding(16)
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
@@ -455,6 +540,6 @@ struct HairDesignPage {
}
.width('100%')
.height('100%')
.backgroundColor(PAGE_BG)
.backgroundColor(COLOR_PAGE_BG)
}
}