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
@@ -0,0 +1,36 @@
/**
* CapsuleButton —— 「试穿」黑色竖向胶囊(《美搭配与美学诊断-UI规范》§3/§7,需求 v2 §3.2)。
*
* 形态:52×120 竖条(高约 120vp,对齐参考站),黑底白字 14(COLOR_BLACK_CAPSULE),
* 右缘悬浮垂直居中;未选上衣/未传本人照/生成中置灰(§2 主按钮禁用态:
* 底 COLOR_DIVIDER、字 COLOR_TEXT_DISABLED)。label 支持换行竖排(如 '试\n穿')。
*/
import { COLOR_BLACK_CAPSULE, COLOR_DIVIDER, COLOR_TEXT_DISABLED } from '../../core/Theme'
@Component
export struct CapsuleButton {
label: string = ''
@Prop isActive: boolean = true
onAction: () => void = () => {}
build() {
Column() {
Text(this.label)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor(this.isActive ? Color.White : COLOR_TEXT_DISABLED)
.textAlign(TextAlign.Center)
}
.width(52)
.height(120)
.justifyContent(FlexAlign.Center)
.borderRadius(26)
.backgroundColor(this.isActive ? COLOR_BLACK_CAPSULE : COLOR_DIVIDER)
.onClick(() => {
if (this.isActive) {
this.onAction()
}
})
}
}
@@ -0,0 +1,32 @@
/**
* CategoryChip —— 品类 chip(《美搭配与美学诊断-UI规范》§3/§7)。
*
* 形态:高约 32 胶囊;选中黑底白字(COLOR_BLACK_CAPSULE,试衣间专属黑),
* 未选白底 + COLOR_DIVIDER 描边。用于品类过滤条与「添加衣服」品类弹层。
* selected 随父层状态变化,必须 @Prop(普通成员只在创建时赋值一次,不随父刷新)。
*/
import { COLOR_BLACK_CAPSULE, COLOR_DIVIDER, COLOR_TEXT_PRIMARY } from '../../core/Theme'
@Component
export struct CategoryChip {
label: string = ''
@Prop selected: boolean = false
onPick: () => void = () => {}
build() {
Text(this.label)
.fontSize(13)
.fontColor(this.selected ? Color.White : COLOR_TEXT_PRIMARY)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.selected ? COLOR_BLACK_CAPSULE : Color.White)
.borderRadius(16)
.border({
width: 1,
color: this.selected ? Color.Transparent : COLOR_DIVIDER
})
.onClick(() => {
this.onPick()
})
}
}
@@ -0,0 +1,123 @@
/**
* ClothCard —— 衣橱服装卡(《美搭配与美学诊断-UI规范》§3/§7)。
*
* 形态:白底圆角 12;服装图 aspectRatio 3:4 Cover;名称 13 Medium 单行截断;
* 品类 11 Secondary;右上角:槽位角标(选中时,主色底)+ 删除角标(20×20 圆,黑 60%)。
* 选中态:2vp COLOR_PRIMARY 描边(点选=设入槽位,再点取消)。
* 本机文件被清时 Image onError → 「已失效,点按删除」占位(需求 §4.1 ④)。
*
* 结构说明:角标行与卡片主体是 Stack 兄弟节点(非父子),点击角标不会
* 误触卡片的选择事件;item 内容按 id 恒定(普通成员),selected/slotLabel
* 随父层选择状态变化,必须 @Prop。
*/
import { fileUri } from '@kit.CoreFileKit'
import { WardrobeItem } from '../../core/WardrobeStore'
import {
COLOR_BADGE_BG,
COLOR_IMAGE_BG,
COLOR_PRIMARY,
COLOR_TEXT_PRIMARY,
COLOR_TEXT_SECONDARY
} from '../../core/Theme'
@Component
export struct ClothCard {
item: WardrobeItem = { id: '', uri: '', category: '', name: '', createdAt: 0 }
@Prop selected: boolean = false
@Prop slotLabel: string = '' // 选中时的槽位角标(上衣/下装);空=未选中
rawfilePath: string = '' // 非空=内置精选($rawfile 渲染,无删除角标、无失效态)
onSelect: () => void = () => {}
onDelete: () => void = () => {}
@State loadFailed: boolean = false
build() {
Stack({ alignContent: Alignment.TopEnd }) {
// 卡片主体(点击=选择/取消;失效时点击=删除)
Column() {
if (this.loadFailed) {
Column({ space: 4 }) {
Text('已失效,点按删除')
.fontSize(11)
.fontColor(COLOR_TEXT_SECONDARY)
.textAlign(TextAlign.Center)
}
.width('100%')
.aspectRatio(3 / 4)
.justifyContent(FlexAlign.Center)
.padding(8)
.backgroundColor(COLOR_IMAGE_BG)
} else if (this.rawfilePath !== '') {
Image($rawfile(this.rawfilePath))
.width('100%')
.aspectRatio(3 / 4)
.objectFit(ImageFit.Cover)
} else {
Image(fileUri.getUriFromPath(this.item.uri))
.width('100%')
.aspectRatio(3 / 4)
.objectFit(ImageFit.Cover)
.onError(() => {
this.loadFailed = true
})
}
Column({ space: 2 }) {
Text(this.item.name)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_PRIMARY)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.item.category)
.fontSize(11)
.fontColor(COLOR_TEXT_SECONDARY)
}
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 10 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(12)
.clip(true)
.border({
width: this.selected ? 2 : 0,
color: COLOR_PRIMARY
})
.onClick(() => {
if (this.loadFailed) {
this.onDelete()
} else {
this.onSelect()
}
})
// 右上角标行(槽位角标 + 删除角标,右上 6;内置精选无删除)
Row({ space: 4 }) {
if (this.slotLabel !== '') {
Text(this.slotLabel)
.fontSize(11)
.fontColor(Color.White)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor(COLOR_PRIMARY)
}
if (this.rawfilePath === '') {
Text('×')
.fontSize(14)
.fontColor(Color.White)
.width(20)
.height(20)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor(COLOR_BADGE_BG)
.onClick(() => {
this.onDelete()
})
}
}
.margin(6)
}
.width('100%')
}
}
@@ -0,0 +1,39 @@
/**
* ColorDot —— 发色圆点+名称(《美搭配与美学诊断-UI规范》§4/§7)。
*
* 形态:32vp 圆点,名称 11 在点下;选中态外环 2vp COLOR_PRIMARY。
* 圆点颜色由使用方传入(发型页 PRESET_COLORS 的 RGB 转 #RRGGBB),
* 组件不持有色板数据,发色接口的 rgb 传参格式不受影响。
*/
import { COLOR_PRIMARY, COLOR_TEXT_PRIMARY, COLOR_TEXT_SECONDARY } from '../../core/Theme'
@Component
export struct ColorDot {
colorHex: string = '#000000'
label: string = ''
selected: boolean = false
onSelect: () => void = () => {}
build() {
Column({ space: 4 }) {
Row()
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor(this.colorHex)
.border({
width: 2,
color: this.selected ? COLOR_PRIMARY : Color.Transparent
})
Text(this.label)
.fontSize(11)
.fontColor(this.selected ? COLOR_TEXT_PRIMARY : COLOR_TEXT_SECONDARY)
.maxLines(1)
}
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.onSelect()
})
}
}
@@ -0,0 +1,36 @@
/**
* Segment —— 两枚胶囊分段控件(《美搭配与美学诊断-UI规范》§4/§7)。
*
* 使用方:发型页(女生/男生)、美学诊断填写页(男/女)。
* 形态:容器 IMAGE_BG 圆角 18 内边距 2;选中段主色底白字圆角 16。
*/
import { COLOR_PRIMARY, COLOR_IMAGE_BG, COLOR_TEXT_PRIMARY } from '../../core/Theme'
@Component
export struct Segment {
items: string[] = []
selected: number = 0
onSelect: (idx: number) => void = (_idx: number) => {}
build() {
Row({ space: 0 }) {
ForEach(this.items, (label: string, idx: number) => {
Text(label)
.fontSize(13)
.fontColor(this.selected === idx ? Color.White : COLOR_TEXT_PRIMARY)
.backgroundColor(this.selected === idx ? COLOR_PRIMARY : Color.Transparent)
.padding({ left: 20, right: 20, top: 7, bottom: 7 })
.borderRadius(16)
.onClick(() => {
if (this.selected !== idx) {
this.onSelect(idx)
}
})
}, (label: string) => label)
}
.padding(2)
.borderRadius(18)
.backgroundColor(COLOR_IMAGE_BG)
}
}
@@ -0,0 +1,98 @@
/**
* FormRow —— 表单行(《美搭配与美学诊断-UI规范》§6/§7)。
*
* 使用方:美学诊断填写页(性别/出生日期/身高/体重/职业);
* ReportCreatePage 后续可迁移复用。
* 形态:行高 52(标签 14 左,值 14 右 + › 箭头 COLOR_TEXT_DISABLED),
* 行间分隔线 0.5vp COLOR_DIVIDER;值未填时显示占位文案(Secondary)。
* 行下可选错误提示(12 COLOR_ERROR)。
* 通过 rowContent 插槽可塞入自定义控件(分段/标签组),此时 useCustomContent 置 true。
*/
import {
COLOR_TEXT_PRIMARY,
COLOR_TEXT_SECONDARY,
COLOR_TEXT_DISABLED,
COLOR_DIVIDER,
COLOR_ERROR
} from '../../core/Theme'
@Component
export struct FormRow {
/** 行标签(左侧)。 */
label: string = ''
/** 行值(右侧文本);空串时显示 placeholder。@Prop:父层选择器回调改 @State 后本行必须随之刷新。 */
@Prop value: string = ''
/** 值未填时的占位文案。 */
placeholder: string = '请选择'
/** 行下错误提示(12 COLOR_ERROR);空串不显示。@Prop:随父层校验态刷新。 */
@Prop errorText: string = ''
/** 是否显示右侧 › 箭头。 */
showArrow: boolean = true
/** 是否显示行底分隔线(最后一行置 false)。 */
showDivider: boolean = true
/** 使用 rowContent 插槽替代「值文本+箭头」。 */
useCustomContent: boolean = false
/** 插槽内容右对齐(标签组换行等场景置 false 改左对齐)。 */
customAlignEnd: boolean = true
/** 自定义行内容插槽(在父组件作用域执行,可引用父组件 @State)。 */
@BuilderParam rowContent: () => void = this.doNothing
/** 整行点击(打开选择器等)。 */
onRowClick: () => void = () => {}
@Builder
doNothing() {
}
build() {
Column() {
Row({ space: 8 }) {
Text(this.label)
.fontSize(14)
.fontColor(COLOR_TEXT_PRIMARY)
if (this.useCustomContent) {
Column() {
this.rowContent()
}
.layoutWeight(1)
.alignItems(this.customAlignEnd ? HorizontalAlign.End : HorizontalAlign.Start)
} else {
Text(this.value === '' ? this.placeholder : this.value)
.fontSize(14)
.fontColor(this.value === '' ? COLOR_TEXT_SECONDARY : COLOR_TEXT_PRIMARY)
.textAlign(TextAlign.End)
.maxLines(1)
.layoutWeight(1)
if (this.showArrow) {
Text('›')
.fontSize(16)
.fontColor(COLOR_TEXT_DISABLED)
}
}
}
.width('100%')
.constraintSize({ minHeight: 52 })
.padding({ top: 6, bottom: 6 })
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.onRowClick()
})
if (this.errorText !== '') {
Text(this.errorText)
.fontSize(12)
.fontColor(COLOR_ERROR)
.width('100%')
.padding({ bottom: 6 })
}
if (this.showDivider) {
Divider()
.strokeWidth(0.5)
.color(COLOR_DIVIDER)
}
}
.width('100%')
}
}
@@ -0,0 +1,66 @@
/**
* StepBar —— 横向步骤条(《美搭配与美学诊断-UI规范》§5/§7)。
*
* 使用方:美学诊断落地页「如何生成你的报告」四步流程(测一测/分析/生成/报告)。
* 形态:圆点 28vp(当前主色底白字 / 未选 IMAGE_BG 底 Secondary 字),
* 连线 2vp COLOR_DIVIDER,标签 11 居中,四步等宽。
*/
import {
COLOR_PRIMARY,
COLOR_IMAGE_BG,
COLOR_TEXT_PRIMARY,
COLOR_TEXT_SECONDARY,
COLOR_DIVIDER
} from '../../core/Theme'
@Component
export struct StepBar {
/** 步骤标签(自左向右)。 */
steps: string[] = []
/** 当前进度下标:下标 ≤ current 的步骤圆点为主色底,其余为灰底。 */
current: number = 0
build() {
Row({ space: 0 }) {
ForEach(this.steps, (label: string, idx: number) => {
// 每步 + 右侧连线占一个等宽格,连线与圆点中心对齐(行高=圆点28)
Row({ space: 0 }) {
Column({ space: 6 }) {
Text(`${idx + 1}`)
.width(28)
.height(28)
.borderRadius(14)
.backgroundColor(idx <= this.current ? COLOR_PRIMARY : COLOR_IMAGE_BG)
.fontColor(idx <= this.current ? Color.White : COLOR_TEXT_SECONDARY)
.fontSize(13)
.textAlign(TextAlign.Center)
Text(label)
.fontSize(11)
.fontColor(idx <= this.current ? COLOR_TEXT_PRIMARY : COLOR_TEXT_SECONDARY)
.maxLines(1)
}
.width(64)
.alignItems(HorizontalAlign.Center)
if (idx < this.steps.length - 1) {
Row() {
Divider()
.strokeWidth(2)
.color(COLOR_DIVIDER)
.layoutWeight(1)
}
.layoutWeight(1)
.height(28)
.alignItems(VerticalAlign.Center)
.margin({ left: 4, right: 4 })
}
}
.layoutWeight(1)
.alignItems(VerticalAlign.Top)
}, (label: string) => label)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
}
@@ -0,0 +1,75 @@
/**
* BuiltInCloth —— 「精选衣橱」内置服装库(rawfile/cloth,需求 v2 §3.3 赴宴精选启用)。
*
* - 素材 30 件:本地 cloth_pic 15 + 网络精选 15,统一 900px JPEG + manifest.json
* (构建期离线打包,无网络请求;文件名→品类→名称见 rawfile/cloth/manifest.json);
* - 展示:虚拟 WardrobeItem(id='b_<file>',uri=''),ClothCard 走 $rawfile 渲染;
* - 生成:getRawFileContent 直读字节 → Base64(素材已标准化,无需二次压缩)。
*/
import { util } from '@kit.ArkTS'
import { ContextHolder } from './ContextHolder'
import { WardrobeItem } from './WardrobeStore'
interface BuiltInEntry {
file: string
category: string
name: string
}
interface BuiltInWire {
items: BuiltInEntry[]
}
const MANIFEST_PATH: string = 'cloth/manifest.json'
const ID_PREFIX: string = 'b_'
export class BuiltInCloth {
private static items: WardrobeItem[] = []
private static loaded: boolean = false
/** 是否内置条目 id('b_' 前缀)。 */
static isBuiltin(id: string): boolean {
return id.startsWith(ID_PREFIX)
}
/** 全部内置条目(虚拟 WardrobeItem;加载失败为空数组)。 */
static async load(): Promise<WardrobeItem[]> {
if (BuiltInCloth.loaded) {
return BuiltInCloth.items
}
try {
const rm = ContextHolder.get().resourceManager
const data = await rm.getRawFileContent(MANIFEST_PATH)
const text = util.TextDecoder.create('utf-8').decodeToString(data)
const wire = JSON.parse(text) as BuiltInWire
const out: WardrobeItem[] = []
for (const e of wire.items) {
if (typeof e.file !== 'string' || e.file.length === 0) {
continue
}
const it: WardrobeItem = {
id: ID_PREFIX + e.file,
uri: '',
category: e.category,
name: e.name,
createdAt: 0
}
out.push(it)
}
BuiltInCloth.items = out
} catch (_e) {
BuiltInCloth.items = [] // manifest 缺失/损坏:精选页展示空态,不阻断
}
BuiltInCloth.loaded = true
return BuiltInCloth.items
}
/** 内置图字节直读 → Base64(900px JPEG 已标准化,直接可用)。 */
static async fileBase64(file: string): Promise<string> {
const rm = ContextHolder.get().resourceManager
const data = await rm.getRawFileContent(`cloth/${file}`)
const helper = new util.Base64Helper()
return helper.encodeToStringSync(data)
}
}
+25
View File
@@ -17,3 +17,28 @@ export const COLOR_TEXT_PRIMARY: string = '#222222'
/** 次级文字(返回控件、辅助说明统一用此色) */
export const COLOR_TEXT_SECONDARY: string = '#8A9099'
/** 禁用文字/占位箭头(《美搭配与美学诊断-UI规范》§1 收口) */
export const COLOR_TEXT_DISABLED: string = '#C2C7CE'
/** 页面底色(原各页散落 #F5F6F8 收口) */
export const COLOR_PAGE_BG: string = '#F5F6F8'
/** 分隔线 / chip 描边 */
export const COLOR_DIVIDER: string = '#E5E7EB'
/** 图片占位底 / 未选中段底 */
export const COLOR_IMAGE_BG: string = '#EDEFF3'
/** 「试穿」黑胶囊与品类 chip 选中态(仅试衣间域) */
export const COLOR_BLACK_CAPSULE: string = '#1A1A1A'
/** 图上角标底(模特/删除/原图|效果图):黑 60% 透明 */
export const COLOR_BADGE_BG: string = '#99000000'
/** 美学诊断落地页 hero 渐变(仅落地页使用,其余页面禁紫) */
export const COLOR_DIAG_HERO_TOP: string = '#7C4DFF'
export const COLOR_DIAG_HERO_BOTTOM: string = '#5E35B1'
/** 错误文案(原 HairDesignPage 散落 #C0392B 收口) */
export const COLOR_ERROR: string = '#C0392B'
+120 -14
View File
@@ -10,8 +10,49 @@
*/
import http from '@ohos.net.http'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { util } from '@kit.ArkTS'
import { ApiError } from './ApiTypes'
/** 手工 multipart 的一个字段(image 字节或文本)。 */
interface HairFormPart {
name: string
filename?: string
contentType?: string
data: Uint8Array
}
/** 自定义边界(报文与请求头共用)。 */
const HAIR_BOUNDARY: string = '----MeidaHairForm7MA4YWxkTrZu0gW'
/**
* 发型性别映射:服务端 hairstyles 接口无性别字段,60 款由缩略图视觉分类
* (2026-09-02,男 26 / 女 34,抽检校准)。新增未知 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',
'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'
])
const TRYON_DOMAIN: number = 0x0200
const TRYON_TAG: string = 'MEIDA_TRYON'
export interface Hairstyle {
id: string
name: string
@@ -45,6 +86,7 @@ interface ChangeClothWire {
}
const BASE: string = 'https://meida.xiangsilian.com'
const HAIR_BASE: string = BASE
const HAIR_TIMEOUT_MS: number = 120000
const CLOTH_TIMEOUT_MS: number = 320000 // 服务端最长 ~300s,客户端必须更长(产品方案 §2.1.1)
@@ -78,26 +120,37 @@ export class TryonApi {
return style.image_url.startsWith('http') ? style.image_url : `${BASE}${style.image_url}`
}
/** 发型性别('male' | 'female' | 'unknown',unknown 在前端两个 Tab 都展示)。 */
static hairstyleGender(id: string): string {
if (MALE_HAIRSTYLE_IDS.has(id)) {
return 'male'
}
if (FEMALE_HAIRSTYLE_IDS.has(id)) {
return 'female'
}
return 'unknown'
}
/**
* 换发型。imageBuf 为已压缩 JPEG(ImageCodec.toJpegBuffer)。
* 返回结果图的 Base64 Data URL。失败抛 ApiError(message 用服务端原文)。
*/
static async changeHair(imageBuf: ArrayBuffer, hairstyleId: string): Promise<string> {
const form: http.MultiFormData[] = [
{ name: 'image', contentType: 'image/jpeg', remoteFileName: 'photo.jpg', data: imageBuf },
{ name: 'hairstyle_id', contentType: 'text/plain', data: hairstyleId }
const parts: HairFormPart[] = [
{ name: 'image', filename: 'photo.jpg', contentType: 'image/jpeg', data: new Uint8Array(imageBuf) },
{ name: 'hairstyle_id', contentType: 'text/plain', data: TryonApi.encodeText(hairstyleId) }
]
return TryonApi.postHairForm(`${BASE}/hair/api/change-hair`, form)
return TryonApi.postHairForm(`${HAIR_BASE}/hair/api/change-hair`, parts)
}
/** 换发色。rgb 为字符串化数组("[255, 0, 0]"——服务端要的怪格式,不是 hex)。 */
static async changeHairColor(imageBuf: ArrayBuffer, rgb: string, ratio: number): Promise<string> {
const form: http.MultiFormData[] = [
{ name: 'image', contentType: 'image/jpeg', remoteFileName: 'photo.jpg', data: imageBuf },
{ name: 'rgb', contentType: 'text/plain', data: rgb },
{ name: 'ratio', contentType: 'text/plain', data: `${ratio}` }
const parts: HairFormPart[] = [
{ name: 'image', filename: 'photo.jpg', contentType: 'image/jpeg', data: new Uint8Array(imageBuf) },
{ name: 'rgb', contentType: 'text/plain', data: TryonApi.encodeText(rgb) },
{ name: 'ratio', contentType: 'text/plain', data: TryonApi.encodeText(`${ratio}`) }
]
return TryonApi.postHairForm(`${BASE}/hair/api/change-hair-color`, form)
return TryonApi.postHairForm(`${HAIR_BASE}/hair/api/change-hair-color`, parts)
}
/**
@@ -116,6 +169,7 @@ export class TryonApi {
'kuzi_img': kuziBase64,
'change_desc': changeDesc
}
hilog.info(TRYON_DOMAIN, TRYON_TAG, 'cloth post %{public}s, json %{public}d bytes', BASE, JSON.stringify(body).length)
const resp = await client.request(`${BASE}/change_cloth_base64`, {
method: http.RequestMethod.POST,
header: { 'Content-Type': 'application/json' },
@@ -124,12 +178,16 @@ export class TryonApi {
connectTimeout: 15000,
readTimeout: CLOTH_TIMEOUT_MS
})
const wire = JSON.parse(resp.result as string) as ChangeClothWire
const respText: string = (resp.result ?? '') as string
hilog.info(TRYON_DOMAIN, TRYON_TAG, 'cloth resp code=%{public}d body=%{public}s',
resp.responseCode, respText.substring(0, 300))
const wire = JSON.parse(respText) as ChangeClothWire
if (wire.ret === 0 && wire.data !== undefined && wire.data.length > 0) {
return wire.data
}
throw new ApiError(50000, wire.msg || '换装生成失败', '请换张照片重试')
} catch (e) {
hilog.error(TRYON_DOMAIN, TRYON_TAG, 'cloth request failed: %{public}s', String(e))
throw TryonApi.wrap(e, '换装请求失败')
} finally {
if (TryonApi.clothClient === client) {
@@ -157,28 +215,76 @@ export class TryonApi {
}
}
private static async postHairForm(url: string, form: http.MultiFormData[]): Promise<string> {
private static async postHairForm(url: string, parts: HairFormPart[]): Promise<string> {
const client = http.createHttp()
try {
const body: ArrayBuffer = TryonApi.buildMultipart(parts)
hilog.info(TRYON_DOMAIN, TRYON_TAG, 'hair post %{public}s, multipart %{public}d bytes', url, body.byteLength)
const resp = await client.request(url, {
method: http.RequestMethod.POST,
multiFormDataList: form,
header: { 'Content-Type': `multipart/form-data; boundary=${HAIR_BOUNDARY}` },
extraData: body,
expectDataType: http.HttpDataType.STRING,
connectTimeout: 10000,
readTimeout: HAIR_TIMEOUT_MS
})
const wire = JSON.parse(resp.result as string) as HairResultWire
if (wire.success && wire.data !== undefined && wire.data.result_image.length > 0) {
const bodyText: string = (resp.result ?? '') as string
hilog.info(TRYON_DOMAIN, TRYON_TAG, 'hair resp code=%{public}d body=%{public}s',
resp.responseCode, bodyText.substring(0, 300))
// 线上返回键序不保证(实测 data 在前 success 在后),只认 data 判成功
const wire = JSON.parse(bodyText) as HairResultWire
if (wire.data !== undefined && wire.data.result_image.length > 0) {
return wire.data.result_image
}
throw new ApiError(50000, wire.message ?? '生成失败', '请换张照片重试')
} catch (e) {
hilog.error(TRYON_DOMAIN, TRYON_TAG, 'hair request failed: %{public}s', String(e))
throw TryonApi.wrap(e, '请求失败,请检查网络')
} finally {
client.destroy()
}
}
private static encodeText(s: string): Uint8Array {
return new util.TextEncoder().encodeInto(s)
}
/**
* 手工拼 multipart 报文。真机实测 http.multiFormDataList 发出的是
* 「无 Content-Type + 0 字节 body」(服务端回 400 请上传图片),
* 故按 RFC 7578 自行构造:extraData 发字节 + 显式 boundary 头。
*/
private static buildMultipart(parts: HairFormPart[]): ArrayBuffer {
const enc = new util.TextEncoder()
const dash: string = `--${HAIR_BOUNDARY}`
const chunks: Uint8Array[] = []
for (const p of parts) {
let head: string = `${dash}\r\nContent-Disposition: form-data; name="${p.name}"`
if (p.filename !== undefined) {
head += `; filename="${p.filename}"`
}
if (p.contentType !== undefined) {
head += `\r\nContent-Type: ${p.contentType}`
}
head += '\r\n\r\n'
chunks.push(enc.encodeInto(head))
chunks.push(p.data)
chunks.push(enc.encodeInto('\r\n'))
}
chunks.push(enc.encodeInto(`${dash}--\r\n`))
let total: number = 0
for (const c of chunks) {
total += c.byteLength
}
const out = new Uint8Array(total)
let off: number = 0
for (const c of chunks) {
out.set(c, off)
off += c.byteLength
}
return out.buffer
}
/** 与 ApiClient 同一纪律:未知异常包装成 ApiError。 */
private static wrap(e: Object, fallback: string): ApiError {
if (e instanceof ApiError) {
@@ -0,0 +1,144 @@
/**
* WardrobeStore —— 「我的衣橱」本机存取(需求 v2 §3.3)。
*
* - 元数据(品类标签、名称、添加时间)写 preferences(meida_wardrobe),
* 图片文件压缩转存 filesDir/wardrobe/<id>.jpg,App 卸载即清,不上传服务端;
* - 相册/拍照返回的临时 URI 重启后可能失效,入库时必须用 ImageCodec 压出
* 自有副本(顺带统一为长边 ≤1600 的 JPEG,预览与生成共用同一来源);
* - 条目 {id, uri, category, name, createdAt};category ∈ 五品类,
* 下装(kuzi 档)与上衣档(上衣/连衣裙/外套/套装)的降级映射见需求 §3.1。
* - 先例:AuthStore/FaceConsentStore 的 preferences + JSON 串口径。
*/
import preferences from '@ohos.data.preferences'
import fs from '@ohos.file.fs'
import { ContextHolder } from './ContextHolder'
import { ImageCodec } from './ImageCodec'
import { ApiError } from './ApiTypes'
/** 衣橱条目(uri 为本机沙盒绝对路径,展示/编码时经 fileUri 转 file:// URI)。 */
export interface WardrobeItem {
id: string
uri: string // 本机路径:filesDir/wardrobe/<id>.jpg
category: string // 上衣 / 下装 / 连衣裙 / 外套 / 套装
name: string
createdAt: number
}
/** 五个品类标签(UI 规范 §8 文案,勿改)。 */
export const CLOTH_CATEGORIES: string[] = ['上衣', '下装', '连衣裙', '外套', '套装']
const PREF_NAME: string = 'meida_wardrobe'
const KEY_ITEMS: string = 'items'
export class WardrobeStore {
private static items: WardrobeItem[] = []
private static prefs: preferences.Preferences | undefined
/** hydrate preferences → 内存;幂等。preferences 不可用时退化为空衣橱(不阻断页面)。 */
static async init(): Promise<void> {
if (WardrobeStore.prefs !== undefined) {
return
}
try {
WardrobeStore.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
const raw = await WardrobeStore.prefs.get(KEY_ITEMS, '') as string
if (raw !== '') {
const parsed = JSON.parse(raw) as WardrobeItem[]
WardrobeStore.items = Array.isArray(parsed)
? parsed.filter((it: WardrobeItem): boolean => typeof it.id === 'string' && it.id.length > 0
&& typeof it.uri === 'string' && it.uri.length > 0)
: []
}
} catch (_e) {
WardrobeStore.items = []
}
WardrobeStore.sortByNewest()
}
/** 全部条目(新添加在前)。 */
static list(): WardrobeItem[] {
return WardrobeStore.items
}
/**
* 上传入库:选/拍到的图先压缩为 JPEG 写入 filesDir/wardrobe,再写元数据。
* 任一步失败抛 ApiError(不残留半截条目)。
*/
static async add(pickedUri: string, category: string): Promise<WardrobeItem> {
const id: string = `w${Date.now()}${Math.floor(Math.random() * 1000)}`
const dir: string = WardrobeStore.dir()
const path: string = `${dir}/${id}.jpg`
try {
if (!fs.accessSync(dir)) {
fs.mkdirSync(dir, true)
}
const buf = await ImageCodec.toJpegBuffer(pickedUri)
const file = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC)
try {
fs.writeSync(file.fd, buf)
} finally {
fs.closeSync(file)
}
} catch (_e) {
throw new ApiError(50000, '衣服图片保存失败', '请重试')
}
const sameCat: number = WardrobeStore.items
.filter((it: WardrobeItem): boolean => it.category === category).length
const item: WardrobeItem = {
id: id,
uri: path,
category: category,
name: `${category} ${sameCat + 1}`,
createdAt: Date.now()
}
WardrobeStore.items.push(item)
WardrobeStore.sortByNewest()
await WardrobeStore.persist()
return item
}
/** 移除条目并尽力删文件;文件残留不阻断(列表仍刷新,元数据已同步)。 */
static async remove(id: string): Promise<void> {
const idx: number = WardrobeStore.items.findIndex((it: WardrobeItem): boolean => it.id === id)
if (idx < 0) {
return
}
const path: string = WardrobeStore.items[idx].uri
WardrobeStore.items.splice(idx, 1)
await WardrobeStore.persist()
try {
if (fs.accessSync(path)) {
fs.unlinkSync(path)
}
} catch (_e) {
// 文件可能已被系统清理,忽略
}
}
/** 是否下装档(kuzi_img):只有「下装」一类,须伴随上衣才能生成(需求 §3.1)。 */
static isBottom(category: string): boolean {
return category === '下装'
}
private static dir(): string {
return `${ContextHolder.get().filesDir}/wardrobe`
}
private static sortByNewest(): void {
WardrobeStore.items.sort((a: WardrobeItem, b: WardrobeItem): number => b.createdAt - a.createdAt)
}
/** 尽力写盘:失败不阻断 UI(最坏情况下次启动重读旧值)。 */
private static async persist(): Promise<void> {
try {
if (WardrobeStore.prefs === undefined) {
WardrobeStore.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
}
await WardrobeStore.prefs.put(KEY_ITEMS, JSON.stringify(WardrobeStore.items))
await WardrobeStore.prefs.flush()
} catch (_e) {
// 内存态已生效,持久化失败下次启动重读旧值
}
}
}
@@ -0,0 +1,427 @@
/**
* DiagnosisFormPage —— 美学诊断信息填写页(F3,需求 v2 §4.4)。
*
* 白底表单页:
* - 「基本信息」白卡分组表单(FormRow):
* 性别(Segment 男/女,必填)、出生日期(点击弹 DatePicker 1930~今天,
* 行内显示 YYYY-MM-DD(N岁))、身高(滚轮 100~250 默认 165)、
* 体重(滚轮 25~300 默认 55)、职业(常用 chips 单选,选填)。
* 滚轮/日期弹层写法沿用 ReportCreatePage 先例(getUIContext().showTextPickerDialog /
* DatePicker dialog),已有值从当前值滚起(clamp 写法一致)。
* - 校验:性别/出生日期/身高/体重必填,职业可空;缺失行下红字 12 COLOR_ERROR
* + 汇总条「请完成:…」(动态拼接)+ 滚动到首个缺失项(沿用 ReportCreatePage 口径)。
* - 「下一步」吸底主按钮:全部合法 → router.pushUrl pages/ReportCreatePage,
* params 键名精确为 gender/birthday/height_cm/weight_kg,值全部为 string
* (接收端 ReportCreatePage 按 string 读取);跳转失败页内红字兜底。
* - 职业仅本地留存(AppStorage),不进提交体(需求 §3.6/验收 4)。
*/
import { router, componentUtils } from '@kit.ArkUI'
import {
COLOR_PRIMARY,
COLOR_PRIMARY_SOFT,
COLOR_TEXT_PRIMARY,
COLOR_TEXT_SECONDARY,
COLOR_DIVIDER,
COLOR_ERROR,
COLOR_IMAGE_BG
} from '../core/Theme'
import { Segment } from '../components/f2/Segment'
import { FormRow } from '../components/f3/FormRow'
import { formatDate } from '../components/f3/ReportTypes'
// 性别分段(UI 规范 §8:男/女)
const GENDER_OPTIONS: string[] = ['男', '女']
// 常用职业 chips(需求 §4.4,单选、选填)
const OCCUPATION_OPTIONS: string[] = ['学生', '上班族', '教师', '医护', '公务员', '自由职业', '其他']
// 身高/体重滚轮口径与 ReportCreatePage 完全一致(100~250 / 25~300)
const HEIGHT_MIN: number = 100
const HEIGHT_MAX: number = 250
const HEIGHT_DEFAULT: number = 165
const WEIGHT_MIN: number = 25
const WEIGHT_MAX: number = 300
const WEIGHT_DEFAULT: number = 55
// 出生日期范围(需求 §4.4:1930~今天)
const BIRTHDAY_MIN: Date = new Date(1930, 0, 1)
// 未选出生日期时弹层的默认落点
const BIRTHDAY_FALLBACK_SELECTED: Date = new Date(2000, 0, 1)
/** 职业为本地留存字段(后端问卷 schema 无 occupation,Phase B 随 schema 扩展提交) */
const OCCUPATION_STORAGE_KEY: string = 'f3_diagnosis_occupation'
function buildRangeOptions(min: number, max: number): string[] {
const arr: string[] = []
for (let v = min; v <= max; v++) {
arr.push(`${v}`)
}
return arr
}
const HEIGHT_OPTIONS: string[] = buildRangeOptions(HEIGHT_MIN, HEIGHT_MAX)
const WEIGHT_OPTIONS: string[] = buildRangeOptions(WEIGHT_MIN, WEIGHT_MAX)
@Entry
@Component
struct DiagnosisFormPage {
@State gender: string = '' // '' | '男' | '女',默认不选
@State birthday: string = '' // YYYY-MM-DD;空串 = 未选
@State heightInput: string = ''
@State weightInput: string = ''
@State occupation: string = ''
@State genderErr: string = ''
@State birthdayErr: string = ''
@State heightErr: string = ''
@State weightErr: string = ''
@State formSummary: string = ''
@State navError: string = ''
private scroller: Scroller = new Scroller()
/** 由出生日期派生只读年龄(周岁)。 */
private ageOf(): number {
if (this.birthday === '') {
return 0
}
const d = new Date(this.birthday)
if (isNaN(d.getTime())) {
return 0
}
const now = new Date()
let age = now.getFullYear() - d.getFullYear()
const m = now.getMonth() - d.getMonth()
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) {
age--
}
return age
}
/** TextPickerResult.index 是联合类型(多列选择器为数组),单列取数字下标。 */
private pickIndex(v: TextPickerResult): number {
return typeof v.index === 'number' ? v.index : -1
}
private openBirthdayPicker(): void {
const parsed = this.birthday !== '' ? new Date(this.birthday) : BIRTHDAY_FALLBACK_SELECTED
const today = new Date()
// 已有值从当前值滚起;解析失败或越界时落在默认值/今天
const selected = isNaN(parsed.getTime()) || parsed.getTime() > today.getTime() ? today : parsed
this.getUIContext().showDatePickerDialog({
start: BIRTHDAY_MIN,
end: today,
selected: selected,
canLoop: false,
onDateAccept: (value: Date): void => {
this.birthday = formatDate(value)
this.birthdayErr = ''
}
})
}
/** 已有值从当前值滚起,未选过从常见默认值(165cm)滚起(沿用 ReportCreatePage)。 */
private openHeightPicker(): void {
const cur = this.heightInput !== '' ? Number(this.heightInput) : HEIGHT_DEFAULT
const clamped = Math.min(Math.max(cur - HEIGHT_MIN, 0), HEIGHT_OPTIONS.length - 1)
this.getUIContext().showTextPickerDialog({
range: HEIGHT_OPTIONS,
selected: clamped,
canLoop: false,
onAccept: (value: TextPickerResult) => {
const idx = this.pickIndex(value)
if (idx >= 0 && idx < HEIGHT_OPTIONS.length) {
this.heightInput = HEIGHT_OPTIONS[idx]
this.heightErr = ''
}
}
})
}
private openWeightPicker(): void {
const cur = this.weightInput !== '' ? Number(this.weightInput) : WEIGHT_DEFAULT
const clamped = Math.min(Math.max(cur - WEIGHT_MIN, 0), WEIGHT_OPTIONS.length - 1)
this.getUIContext().showTextPickerDialog({
range: WEIGHT_OPTIONS,
selected: clamped,
canLoop: false,
onAccept: (value: TextPickerResult) => {
const idx = this.pickIndex(value)
if (idx >= 0 && idx < WEIGHT_OPTIONS.length) {
this.weightInput = WEIGHT_OPTIONS[idx]
this.weightErr = ''
}
}
})
}
private validateForm(): boolean {
// 选择器产出必然在合法区间,未选才是唯一错误态
this.genderErr = this.gender === '' ? '请选择性别' : ''
this.birthdayErr = this.birthday === '' ? '请选择出生日期' : ''
this.heightErr = this.heightInput === '' ? '请选择身高' : ''
this.weightErr = this.weightInput === '' ? '请选择体重' : ''
return this.genderErr === '' && this.birthdayErr === '' && this.heightErr === '' && this.weightErr === ''
}
private missingLabels(): string[] {
const labels: string[] = []
if (this.genderErr !== '') {
labels.push('性别')
}
if (this.birthdayErr !== '') {
labels.push('出生日期')
}
if (this.heightErr !== '') {
labels.push('身高')
}
if (this.weightErr !== '') {
labels.push('体重')
}
return labels
}
private scrollToFirstError(): void {
let firstId = ''
if (this.genderErr !== '') {
firstId = 'field_gender'
} else if (this.birthdayErr !== '') {
firstId = 'field_birthday'
} else if (this.heightErr !== '') {
firstId = 'field_height'
} else if (this.weightErr !== '') {
firstId = 'field_weight'
}
if (firstId === '') {
return
}
try {
// windowOffset 随滚动同步移动,两处相减即为内容坐标差,
// 与当前滚动位置无关(沿用 ReportCreatePage 口径)
const root = componentUtils.getRectangleById('form_root')
const target = componentUtils.getRectangleById(firstId)
const dy = target.windowOffset.y - root.windowOffset.y - 8
this.scroller.scrollTo({ xOffset: 0, yOffset: Math.max(0, dy), animation: { duration: 250, curve: Curve.EaseOut } })
} catch (e) {
console.error(`scrollTo ${firstId} failed: ${String(e).substring(0, 80)}`)
}
}
private onNextTap(): void {
this.formSummary = ''
this.navError = ''
if (!this.validateForm()) {
// 字段级红字可能滚在视野外:汇总条点名缺哪几项,并自动滚到第一个缺失字段
const labels: string[] = this.missingLabels()
this.formSummary = labels.length > 0 ? `请完成:${labels.join('、')}` : '请检查标红的字段'
this.scrollToFirstError()
return
}
// 职业仅本地留存,不进提交体(需求 §3.6/验收 4)
AppStorage.setOrCreate<string>(OCCUPATION_STORAGE_KEY, this.occupation)
// 四个值全部为 string,接收端 ReportCreatePage 按 string 读取
const params: Record<string, Object> = {
'gender': this.gender,
'birthday': this.birthday,
'height_cm': this.heightInput,
'weight_kg': this.weightInput
}
router.pushUrl({ url: 'pages/ReportCreatePage', params: params })
.catch((e: Object): void => {
this.navError = `跳转失败,请重试:${String(e).substring(0, 80)}`
})
}
@Builder
TopBar() {
Row({ space: 8 }) {
Text('‹ 返回')
.fontSize(15)
.fontColor(COLOR_TEXT_SECONDARY)
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
.onClick(() => {
router.back()
})
Text('形象信息')
.fontSize(17)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_PRIMARY)
}
.width('100%')
.height(48)
.padding({ left: 12, right: 16 })
.backgroundColor(Color.White)
}
@Builder
GenderCell() {
Segment({
items: GENDER_OPTIONS,
selected: this.gender === GENDER_OPTIONS[0] ? 0 : (this.gender === GENDER_OPTIONS[1] ? 1 : -1),
onSelect: (idx: number): void => {
if (idx >= 0 && idx < GENDER_OPTIONS.length) {
this.gender = GENDER_OPTIONS[idx]
this.genderErr = ''
}
}
})
}
@Builder
OccupationCell() {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(OCCUPATION_OPTIONS, (o: string) => {
Text(o)
.fontSize(13)
.fontColor(this.occupation === o ? Color.White : COLOR_TEXT_PRIMARY)
.backgroundColor(this.occupation === o ? COLOR_PRIMARY : COLOR_IMAGE_BG)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(13)
.margin({ right: 8, bottom: 8 })
.onClick(() => {
this.occupation = this.occupation === o ? '' : o
})
}, (o: string) => o)
}
.width('100%')
}
build() {
Column() {
this.TopBar()
Scroll(this.scroller) {
Column({ space: 12 }) {
Text('基本信息')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_PRIMARY)
.width('100%')
.margin({ top: 4 })
// 分组卡:白底圆角 12,行间分隔线 0.5vp 由 FormRow 内置
Column({ space: 0 }) {
Column() {
FormRow({
label: '性别',
errorText: this.genderErr,
showArrow: false,
useCustomContent: true,
rowContent: (): void => {
this.GenderCell()
}
})
}
.width('100%')
.id('field_gender')
Column() {
FormRow({
label: '出生日期',
value: this.birthday === '' ? '' : `${this.birthday}(${this.ageOf()}岁)`,
errorText: this.birthdayErr,
onRowClick: (): void => {
this.openBirthdayPicker()
}
})
}
.width('100%')
.id('field_birthday')
Column() {
FormRow({
label: '身高',
value: this.heightInput === '' ? '' : `${this.heightInput} cm`,
errorText: this.heightErr,
onRowClick: (): void => {
this.openHeightPicker()
}
})
}
.width('100%')
.id('field_height')
Column() {
FormRow({
label: '体重',
value: this.weightInput === '' ? '' : `${this.weightInput} kg`,
errorText: this.weightErr,
onRowClick: (): void => {
this.openWeightPicker()
}
})
}
.width('100%')
.id('field_weight')
FormRow({
label: '职业',
showDivider: false,
useCustomContent: true,
customAlignEnd: false,
rowContent: (): void => {
this.OccupationCell()
}
})
}
.width('100%')
.padding(16)
.borderRadius(12)
.backgroundColor(Color.White)
Text('信息仅用于生成你的个人报告,不会对外展示或共享。')
.fontSize(11)
.fontColor(COLOR_TEXT_SECONDARY)
.width('100%')
.padding({ left: 4, right: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 16, right: 16, top: 16, bottom: 24 })
.id('form_root')
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
// 吸底区:汇总条 + 兜底错误 + 主按钮
Column({ space: 8 }) {
if (this.formSummary !== '') {
Text(this.formSummary)
.fontSize(13)
.fontColor(COLOR_ERROR)
.width('100%')
.padding(10)
.backgroundColor(COLOR_PRIMARY_SOFT)
.borderRadius(8)
.textAlign(TextAlign.Start)
.onClick(() => {
this.scrollToFirstError()
})
}
if (this.navError !== '') {
Text(this.navError)
.fontSize(12)
.fontColor(COLOR_ERROR)
.width('100%')
.textAlign(TextAlign.Start)
}
Button('下一步')
.width('100%')
.height(48)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(24)
.onClick(() => {
this.onNextTap()
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
.backgroundColor(Color.White)
.border({ width: { top: 0.5 }, color: COLOR_DIVIDER })
}
.width('100%')
.height('100%')
.backgroundColor(Color.White)
}
}
@@ -0,0 +1,202 @@
/**
* DiagnosisLandingPage —— 美学诊断落地页(F3,需求 v2 §4.3)。
*
* 纯静态营销页:零网络请求、零本地存储,弱网/失败态不适用。
* - 白底顶栏「美学诊断」(返回 + 标题);
* - 紫渐变 hero(COLOR_DIAG_HERO_TOP→COLOR_DIAG_HERO_BOTTOM,全 App 仅此页用紫):
* 大标「个人形象风格分析报告」白 26 Bold + 副标「5 分钟,看清你的穿衣风格」白85% 13;
* - 「你的穿衣困扰」白卡 5 行(主色圆点 8vp + 文字 14,文案照 UI 规范 §8 逐字);
* - 「如何生成你的报告」StepBar 四步(测一测/分析/生成/报告);
* - 吸底 CTA「马上体验」(主色玫红,非紫)→ pages/DiagnosisFormPage,
* CTA 上小字「我们会保障您的个人信息安全」。
*/
import { router, display } from '@kit.ArkUI'
import {
COLOR_PRIMARY,
COLOR_TEXT_PRIMARY,
COLOR_TEXT_SECONDARY,
COLOR_DIVIDER,
COLOR_PAGE_BG,
COLOR_DIAG_HERO_TOP,
COLOR_DIAG_HERO_BOTTOM
} from '../core/Theme'
import { StepBar } from '../components/f3/StepBar'
/** 困扰清单 5 条(UI 规范 §8 文案,逐字照抄)。 */
const PROBLEMS: string[] = [
'不知道自己适合什么风格',
'衣柜很多衣服却不会搭',
'穿显胖显黑,不敢尝试',
'想改变却没有方向',
'每次出门搭配浪费时间'
]
/** 四步流程(UI 规范 §8 文案,逐字照抄)。 */
const STEPS: string[] = ['测一测', '分析', '生成', '报告']
/** hero 紫渐变(仅落地页使用)。 */
const HERO_GRADIENT: Array<[ResourceColor, number]> = [
[COLOR_DIAG_HERO_TOP, 0.0],
[COLOR_DIAG_HERO_BOTTOM, 1.0]
]
/** 副标白 85% 透明(ARGB 前缀 D9)。 */
const HERO_SUB_TEXT: string = '#D9FFFFFF'
/** 取不到屏幕尺寸时 hero 的兜底高度。 */
const HERO_FALLBACK_HEIGHT: number = 300
@Entry
@Component
struct DiagnosisLandingPage {
@State heroHeight: number = HERO_FALLBACK_HEIGHT
aboutToAppear(): void {
// 规范 §5:hero 高 = 屏高 38%。滚动容器内百分比基准不稳定,按物理屏高换算 vp。
try {
const d = display.getDefaultDisplaySync()
this.heroHeight = Math.max(260, this.getUIContext().px2vp(d.height) * 0.38)
} catch (_e) {
this.heroHeight = HERO_FALLBACK_HEIGHT
}
}
private goForm(): void {
router.pushUrl({ url: 'pages/DiagnosisFormPage' })
.catch((e: Object): void => {
console.error(`nav to form failed: ${String(e).substring(0, 80)}`)
})
}
@Builder
TopBar() {
Row({ space: 8 }) {
Text('‹ 返回')
.fontSize(15)
.fontColor(COLOR_TEXT_SECONDARY)
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
.onClick(() => {
router.back()
})
Text('美学诊断')
.fontSize(17)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_PRIMARY)
}
.width('100%')
.height(48)
.padding({ left: 12, right: 16 })
.backgroundColor(Color.White)
}
@Builder
ProblemCard() {
Column({ space: 0 }) {
ForEach(PROBLEMS, (p: string) => {
Row({ space: 10 }) {
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(COLOR_PRIMARY)
Text(p)
.fontSize(14)
.fontColor(COLOR_TEXT_PRIMARY)
.layoutWeight(1)
}
.width('100%')
.constraintSize({ minHeight: 40 })
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
}, (p: string) => p)
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(12)
.backgroundColor(Color.White)
.alignItems(HorizontalAlign.Start)
}
@Builder
CtaBar() {
Column({ space: 8 }) {
Text('我们会保障您的个人信息安全')
.fontSize(11)
.fontColor(COLOR_TEXT_SECONDARY)
.width('100%')
.textAlign(TextAlign.Center)
Button('马上体验')
.width('100%')
.height(48)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(24)
.onClick(() => {
this.goForm()
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
.backgroundColor(Color.White)
.border({ width: { top: 0.5 }, color: COLOR_DIVIDER })
}
build() {
Column() {
this.TopBar()
Scroll() {
Column({ space: 14 }) {
// ① hero(全 App 唯一允许出现紫色的区块)
Column({ space: 10 }) {
Text('个人形象风格分析报告')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Text('5 分钟,看清你的穿衣风格')
.fontSize(13)
.fontColor(HERO_SUB_TEXT)
}
.width('100%')
.height(this.heroHeight)
.padding(24)
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
.linearGradient({ angle: 180, colors: HERO_GRADIENT })
// ② 穿衣困扰
Text('你的穿衣困扰')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_PRIMARY)
.width('100%')
this.ProblemCard()
// ③ 四步流程
Text('如何生成你的报告')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_PRIMARY)
.width('100%')
StepBar({ steps: STEPS, current: 0 })
.width('100%')
.padding({ top: 4, bottom: 8, left: 4, right: 4 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 16 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
// ④ 吸底 CTA(主色玫红,非紫)
this.CtaBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLOR_PAGE_BG)
}
}
File diff suppressed because it is too large Load Diff
+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)
}
}
+2 -2
View File
@@ -175,8 +175,8 @@ struct HomePage {
.alignItems(HorizontalAlign.Start)
.margin({ top: 8 })
this.EntryCard('生成我的形象报告', '体型 · 体态 · 穿搭建议,一次看全', '▣', ACCENT, 'pages/ReportCreatePage')
this.EntryCard('试衣间', '上传照片,换装试穿', '◈', '#E8912D', 'pages/TryonPage')
this.EntryCard('美学诊断', '个人形象风格分析报告', '▣', ACCENT, 'pages/DiagnosisLandingPage')
this.EntryCard('美搭配', '我的衣橱,换装试穿', '◈', '#E8912D', 'pages/FittingRoomPage')
this.EntryCard('发型设计', '60 款发型试戴,还能换发色', '✂', '#2DB38E', 'pages/HairDesignPage')
this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage')
this.QuotaCard()
@@ -105,6 +105,26 @@ struct ReportCreatePage {
if (cached !== undefined && cached !== '') {
this.lastReportId = cached
}
// 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6):只填四字段,
// 核心诉求/心理风格仍由用户在本页补齐;无参数时行为与旧直达完全一致。
const params = router.getParams() as Record<string, Object> | undefined
if (params !== undefined) {
if (typeof params['gender'] === 'string' && params['gender'].length > 0) {
this.gender = params['gender'] as string
}
if (typeof params['birthday'] === 'string' && params['birthday'].length >= 8) {
const d = new Date(params['birthday'] as string)
if (!isNaN(d.getTime())) {
this.birthday = d
}
}
if (typeof params['height_cm'] === 'string' && (params['height_cm'] as string).length > 0) {
this.heightInput = params['height_cm'] as string
}
if (typeof params['weight_kg'] === 'string' && (params['weight_kg'] as string).length > 0) {
this.weightInput = params['weight_kg'] as string
}
}
}
private validateForm(): boolean {
@@ -10,6 +10,9 @@
"pages/LlmTestPage",
"pages/TryonPage",
"pages/HairDesignPage",
"pages/ReportPdfPage"
"pages/ReportPdfPage",
"pages/FittingRoomPage",
"pages/DiagnosisLandingPage",
"pages/DiagnosisFormPage"
]
}
@@ -0,0 +1,154 @@
{
"items": [
{
"file": "连衣裙_00.jpg",
"category": "连衣裙",
"name": "蓝色碎花吊带裙"
},
{
"file": "连衣裙_01.jpg",
"category": "连衣裙",
"name": "黑色长袖连衣裙"
},
{
"file": "连衣裙_02.jpg",
"category": "连衣裙",
"name": "军绿衬衫裙"
},
{
"file": "下装_03.jpg",
"category": "下装",
"name": "拼色直筒牛仔裤"
},
{
"file": "上衣_04.jpg",
"category": "上衣",
"name": "红色荷叶边上衣"
},
{
"file": "上衣_05.jpg",
"category": "上衣",
"name": "白底刺绣T恤"
},
{
"file": "上衣_06.jpg",
"category": "上衣",
"name": "插肩印花T恤"
},
{
"file": "下装_07.jpg",
"category": "下装",
"name": "粉色缎面半身裙"
},
{
"file": "上衣_08.jpg",
"category": "上衣",
"name": "红色短袖T恤"
},
{
"file": "下装_09.jpg",
"category": "下装",
"name": "米白阔腿裤"
},
{
"file": "连衣裙_10.jpg",
"category": "连衣裙",
"name": "红色碎花连衣裙"
},
{
"file": "下装_11.jpg",
"category": "下装",
"name": "卡其色长半身裙"
},
{
"file": "下装_12.jpg",
"category": "下装",
"name": "宝蓝色阔腿裤"
},
{
"file": "上衣_13.jpg",
"category": "上衣",
"name": "深灰针织Polo衫"
},
{
"file": "连衣裙_14.jpg",
"category": "连衣裙",
"name": "白色碎花长裙"
},
{
"file": "上衣_15.jpg",
"category": "上衣",
"name": "米白半拉链针织衫"
},
{
"file": "外套_16.jpg",
"category": "外套",
"name": "白色羽绒服"
},
{
"file": "外套_17.jpg",
"category": "外套",
"name": "米色牛仔夹克"
},
{
"file": "外套_18.jpg",
"category": "外套",
"name": "白色牛仔外套"
},
{
"file": "外套_19.jpg",
"category": "外套",
"name": "白色羽绒马甲"
},
{
"file": "外套_20.jpg",
"category": "外套",
"name": "深蓝羽绒夹克"
},
{
"file": "外套_21.jpg",
"category": "外套",
"name": "白色针织开衫"
},
{
"file": "上衣_22.jpg",
"category": "上衣",
"name": "白色印花T恤"
},
{
"file": "上衣_23.jpg",
"category": "上衣",
"name": "米色灯芯绒衬衫"
},
{
"file": "上衣_24.jpg",
"category": "上衣",
"name": "墨绿圆领毛衣"
},
{
"file": "上衣_25.jpg",
"category": "上衣",
"name": "黑色格纹领T恤"
},
{
"file": "连衣裙_26.jpg",
"category": "连衣裙",
"name": "粉色碎花系带长裙"
},
{
"file": "连衣裙_27.jpg",
"category": "连衣裙",
"name": "白色针织吊带长裙"
},
{
"file": "套装_28.jpg",
"category": "套装",
"name": "背心阔腿裤套装"
},
{
"file": "外套_29.jpg",
"category": "外套",
"name": "深色双排扣西装"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB