feat(tryon): 试衣间 + 发型设计(Phase A 直连线上 API)

- TryonApi.ets:直连 meida.xiangsilian.com——换装 /change_cloth_base64(JSON+Base64,超时 320s)、发型 /hair/api/*(multipart,实测 5~10s)
- ImageCodec.ets:相册选图→解码→缩放→Base64 的公共链路
- TryonPage(模特/服装双图换装)+ HairDesignPage(60 款发型试戴/换发色),首页两张入口卡
- docs/试衣间与发型设计-产品方案.md:Phase A 直连内测 / Phase B 服务端代理 + 合规纳管的分工结论
- 待补:真机实测耗时(T-P0-13/14 基线);Phase B 迁服务端时代码改动集中在 TryonApi 一处
This commit is contained in:
xsl
2026-08-30 13:18:15 +08:00
parent b31c7490f2
commit d9868fb4f7
6 changed files with 941 additions and 0 deletions
@@ -0,0 +1,304 @@
/**
* HairDesignPage —— 发型设计(试戴 + 换发色),Phase A 直连线上服务。
*
* 流程(产品方案 §2.3):上传面部特写 → 点选发型即试戴(5~10s)→ 可换发色 → 保存相册。
* 异常口径:400 用服务端 message 原样提示;失败保留当前图不覆盖。
*/
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 { TryonApi, Hairstyle } from '../core/TryonApi'
import { ImageCodec } from '../core/ImageCodec'
import { ApiError } from '../core/ApiTypes'
import { ContextHolder } from '../core/ContextHolder'
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] }
]
const PAGE_BG: string = '#F5F6F8'
const ACCENT: string = '#FF5A79'
const TEXT_PRIMARY: string = '#222222'
const TEXT_SECONDARY: string = '#8A9099'
@Entry
@Component
struct HairDesignPage {
@State photoUri: string = ''
@State styles: Hairstyle[] = []
@State listError: string = ''
@State selectedStyleId: string = ''
@State generating: boolean = false
@State statusText: string = ''
@State resultPm: image.PixelMap | undefined = undefined
@State resultBase64: string = '' // 换发色/保存都用它,避免二次编码
@State showOriginal: boolean = false
@State colorIdx: number = 1
@State ratio: number = 0.9
aboutToAppear(): void {
this.loadStyles()
// 预留:Agent 联动(V2)预选发型,见产品方案 §7
const params = router.getParams() as Record<string, Object> | undefined
if (params !== undefined && typeof params['hairstyle_id'] === 'string') {
this.selectedStyleId = params['hairstyle_id'] as string
}
}
private async loadStyles(): Promise<void> {
try {
this.styles = await TryonApi.listHairstyles()
} catch (e) {
this.listError = e instanceof ApiError ? e.message : '发型列表加载失败'
}
}
private async pickPhoto(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.selectedStyleId = ''
this.statusText = ''
} catch (e) {
this.statusText = e instanceof ApiError ? e.message : '读取照片失败'
}
}
private async tryOn(style: Hairstyle): Promise<void> {
if (this.photoUri.length === 0 || this.generating) {
return
}
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 = '试戴完成,点「对比」看原图'
} catch (e) {
this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试'
} finally {
this.generating = false
}
}
private async changeColor(): Promise<void> {
if (this.resultBase64.length === 0 || this.generating) {
return
}
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
const buf = helper.decodeSync(pure).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 = '换发色完成'
} catch (e) {
this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试'
} finally {
this.generating = false
}
}
private async saveResult(): Promise<void> {
if (this.resultBase64.length === 0) {
return
}
try {
const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`)
const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get())
const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE }
await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg])
this.statusText = '已保存到相册'
} catch {
this.statusText = '已取消保存'
}
}
build() {
Column() {
// 顶部标题栏
Row() {
Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back())
Text('发型设计').fontSize(17).fontWeight(FontWeight.Medium).fontColor(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({ 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('对比')
.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%')
}
// 发型网格
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)
}
}
.onClick(() => { this.tryOn(style) })
}, (style: Hairstyle) => style.id)
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.width('100%')
} else {
Text(this.listError.length > 0 ? this.listError : '发型加载中…')
.fontSize(12).fontColor(TEXT_SECONDARY)
}
// 换发色(有结果才可用)
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 })
})
}
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)
}
Button(this.generating ? '生成中…' : '应用发色')
.width('100%')
.backgroundColor(ACCENT)
.enabled(!this.generating && this.resultBase64.length > 0)
.onClick(() => { this.changeColor() })
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(Color.White)
}
}
.width('100%')
.padding(16)
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
.backgroundColor(PAGE_BG)
}
}