Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a901176c4 | ||
|
|
f17868233a |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "深色双排扣西装"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 101 KiB |
@@ -0,0 +1,211 @@
|
||||
# 美搭 —— 试衣间 v2 / 发型页 v2 / 美学诊断 UI 设计规范
|
||||
|
||||
> 版本 v1.0(2026-09-02)。范围:试衣间改版、发型页改版、美学诊断落地页与填写页(均为新增/重设计)。
|
||||
> 开发照本文执行,不再另行口头约定;与现有页面冲突处以本文为准。
|
||||
> 基线代码:core/Theme.ets、pages/HomePage.ets、pages/HairDesignPage.ets、pages/TryonPage.ets。
|
||||
|
||||
## 0. 现状勘误与落点
|
||||
|
||||
- `pages/FittingRoomPage.ets` **不存在**。试衣间现状是 `pages/TryonPage.ets`:三槽上传表单(全身照/上衣/裤子)+ 提示词 + 生成等待,非 v2 目标形态。
|
||||
- 试衣间 v2 建议新建 `pages/FittingRoomPage.ets` 承载衣橱网格形态;旧 TryonPage 在 v2 验收通过前保留,首页入口卡切换指向。
|
||||
- 发型页 v2 就地改造 `pages/HairDesignPage.ets`,保留:点按对比、人脸授权弹层(components/f2/FacePhotoConsent)、结果落盘(filesDir/results)。
|
||||
- 美学诊断两页新建:`pages/DiagnosisLandingPage.ets`、`pages/DiagnosisFormPage.ets`。
|
||||
- 滚轮选择器先例:`pages/ReportCreatePage.ets` 的 `getUIContext().showTextPickerDialog({ range, selected, canLoop:false })`,身高/体重直接复用此交互。
|
||||
|
||||
## 1. 色板 Token
|
||||
|
||||
一级来源 `core/Theme.ets`,页面内禁止再散落硬编码主色;下表「新增」色仅落地页 hero 与中性灰,收口于 Theme.ets 补常量。
|
||||
|
||||
| Token | 值 | 用途 | 使用页面 |
|
||||
|---|---|---|---|
|
||||
| COLOR_PRIMARY(现有) | #C2185B | 主按钮、选中态、强调文字、下划线 Tab、删除外的红字错误提示沿用 | 全部 |
|
||||
| COLOR_PRIMARY_SOFT(现有) | #F7E3EA | 次级按钮底、选中浅底 | 全部 |
|
||||
| COLOR_TEXT_PRIMARY(现有) | #222222 | 标题/正文 | 全部 |
|
||||
| COLOR_TEXT_SECONDARY(现有) | #8A9099 | 辅助说明、次级文字 | 全部 |
|
||||
| COLOR_TEXT_DISABLED(新增收口) | #C2C7CE | 禁用文字、占位箭头(现散落 HomePage,收进 Theme) | 全部 |
|
||||
| COLOR_PAGE_BG(新增收口) | #F5F6F8 | 页面底色(现各页各自定义,收进 Theme) | 全部 |
|
||||
| COLOR_DIVIDER(新增收口) | #E5E7EB | 分隔线、chip 描边 | 全部 |
|
||||
| COLOR_IMAGE_BG(新增收口) | #EDEFF3 | 图片占位底 / 未选中段底 | 试衣间、发型 |
|
||||
| COLOR_BLACK_CAPSULE(新增) | #1A1A1A | 「试穿」胶囊按钮、品类 chip 选中态(黑底白字) | 试衣间 |
|
||||
| COLOR_BADGE_BG(新增) | 0x99000000(黑 60% 透明) | 图上角标(模特/删除/原图|效果图)底 | 试衣间、发型 |
|
||||
| COLOR_DIAG_HERO_TOP(新增) | #7C4DFF | 美学诊断落地页 hero 渐变起点 | 仅落地页 |
|
||||
| COLOR_DIAG_HERO_BOTTOM(新增) | #5E35B1 | 落地页 hero 渐变终点 | 仅落地页 |
|
||||
| COLOR_ERROR(新增收口) | #C0392B | 错误文案(HairDesignPage 已硬编码,收进 Theme) | 全部 |
|
||||
|
||||
**裁决说明**:参考站紫调(#7C4DFF→#5E35B1)与 App 主色玫红 #C2185B 不同系。仅落地页 hero 使用独立紫渐变(与 AI 顾问紫 #6C5CE7 同为点缀系、不冲突);落地页其余元素、CTA 按钮、其余三页一律走玫红主色,不跟紫。
|
||||
|
||||
## 2. 基础尺度(四页统一)
|
||||
|
||||
- 页面左右边距 16;卡片内边距 14~16;列表/网格间距 12;同类元素组内间距 8。
|
||||
- 圆角:大卡片 16、图片卡/小卡 12、胶囊(按钮/chip)=高度一半、角标圆形。
|
||||
- 字号字重:页大标题 26 Bold;区块标题 16 Medium;正文/行标签 14 Regular;辅助/品类小字 12;角标/步骤标签 11。
|
||||
- 主按钮统一:宽 100%-32(或吸底减边距)、高 48、圆角 24、底 COLOR_PRIMARY、白字 16 Medium;禁用态底 #E5E7EB、字 #C2C7CE。
|
||||
- 图片一律 `ImageFit.Cover`;加载中占位底 COLOR_IMAGE_BG + LoadingProgress(色 COLOR_PRIMARY,36vp)。
|
||||
|
||||
## 3. 页面一:试衣间 v2(FittingRoomPage)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ‹ 返回 试衣间 │ 顶栏:白底,高48,标题17 Medium
|
||||
├──────────────────────────────┤
|
||||
│ ┌──────────────────────────┐ │ 预览区:高=屏45%(≥320vp)
|
||||
│ │[模特] ╲ │ │ 图片 Cover,圆角16,边距16
|
||||
│ │ ┌────┐ │ │ 左上角标「模特」:黑60%胶囊,
|
||||
│ │ │试穿 │ │ │ 白字12,内边距8×12,距边12
|
||||
│ │ └────┘ │ │ 「试穿」:黑胶囊#1A1A1A 白字14,
|
||||
│ │ ╱ │ │ 右缘悬浮垂直居中,距右16
|
||||
│ └──────────────────────────┘ │ 点图=生成后原图/效果切换(沿用)
|
||||
├──────────────────────────────┤
|
||||
│ 我的衣橱 │ Tab 行:仅1个Tab;选中16 Medium
|
||||
│ ───── │ 主色+3vp下划线(圆角2);占位留扩展
|
||||
├──────────────────────────────┤
|
||||
│ (全部)(上衣)(下装)(连衣裙)(外套)(套装)→ │ 品类条:横向Scroll,高32
|
||||
├──────────────────────────────┤
|
||||
│ ┌────────┐ ┌────────┐ │ 双列网格:列距12 行距12
|
||||
│ │图 Cover│ │图 Cover│ [×] │ 服装卡:白底圆角12
|
||||
│ │ │ │ │ │ 图 aspectRatio 3:4 Cover
|
||||
│ │名称 │ │名称 │ │ 名称13 Medium 主色文,单行截断
|
||||
│ │品类小字│ │品类小字│ │ 品类11 Secondary
|
||||
│ └────────┘ └────────┘ │ 删除角标:右上20×20圆 黑60%
|
||||
├──────────────────────────────┤
|
||||
│ 空态: (空衣橱插图区) │ 空态居中:图标44 Disabled色
|
||||
│ 衣橱还是空的 │ 标题14 Primary + 副文案12 Secondary
|
||||
│ [ 从相册选一件衣服 ] │ 大按钮:主色 高48 圆角24
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
状态:生成中→预览区 Loading+状态文案(沿用 TryonPage 计时/挽留口径);服装卡点击=选中试穿(描边 2vp COLOR_PRIMARY);删除角标点击需二次确认弹窗(「移除这件衣服?」取消/移除)。
|
||||
|
||||
## 4. 页面二:发型页 v2(HairDesignPage 改造)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ‹ 返回 发型设计 │ 顶栏同上
|
||||
├──────────────────────────────┤
|
||||
│ ┌────────────┐ ┌────────────┐│ 双图区:两图等宽,高=屏40%
|
||||
│ │ 原图 │ │ 效果图 ││ (≥240vp),间距12,圆角12
|
||||
│ │[原图|效果图]│ │ ││ 左下小角标标注当前显示态:
|
||||
│ └────────────┘ └────────────┘│ 黑60%胶囊白字11;点右图切换对比
|
||||
│ 相册 拍照 │ 入口文字链 12 Primary(沿用)
|
||||
├──────────────────────────────┤
|
||||
│ ╭──────╮──────╮ │ 性别分段控件:容器COLOR_IMAGE_BG
|
||||
│ │ 女生 │ 男生 │ │ 圆角18 内边距2;选中段主色底
|
||||
│ ╰──────╯──────╯ │ 白字圆角16,未选透明底深字13
|
||||
├──────────────────────────────┤
|
||||
│ 选择发型(点选即试戴) │ 三列网格沿用现状(不动)
|
||||
│ ▣ ▣ ▣ / ▣ ▣ ▣ / ▣ ▣ ▣ │ 选中描边2vp主色(沿用)
|
||||
├──────────────────────────────┤
|
||||
│ 发色 ● ● ● ● ● ● ● ● │ 8色圆点:32vp圆,名称11在点下
|
||||
│ 自然黑 深棕 栗色 蜜茶… │ 选中:外环2vp COLOR_PRIMARY
|
||||
├──────────────────────────────┤
|
||||
│ 强度 ────●──────── 0.9 │ 行:标签12 Secondary+Slider+值
|
||||
├──────────────────────────────┤
|
||||
│ [ 应用发色 ] │ 吸底主按钮(白底条+顶分隔线)
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
变化点:①发色由文字 chip 改为「圆点+名称」竖排小项(色值沿用 PRESET_COLORS 8 组 RGB);②「应用发色」由滚动区内改为吸底;③无结果时发色/强度区整体隐藏(沿用),按钮禁用态见 §2。上次结果卡、三态(加载/空/失败)、人脸授权弹层全部沿用不动。
|
||||
|
||||
## 5. 页面三:美学诊断落地页(DiagnosisLandingPage)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ██████ 紫渐变 hero █████████ │ hero:#7C4DFF→#5E35B1 线性渐变
|
||||
│ │ 高=屏38%,圆角16(四周留边16)
|
||||
│ 个人形象风格分析报告 │ 大标题 白 26 Bold,左上角内边距24
|
||||
│ 5 分钟,看清你的穿衣风格 │ 副标 白85% 13
|
||||
├──────────────────────────────┤
|
||||
│ 你的穿衣困扰 │ 区块标题16 Medium
|
||||
│ ┌──────────────────────────┐ │ 清单卡:白底圆角12 内边距16
|
||||
│ │ ● 不知道自己适合什么风格 │ │ 5 行,行高≥40,行间8
|
||||
│ │ ● 衣柜很多衣服却不会搭 │ │ 每行:主色圆点8vp+文字14
|
||||
│ │ ● 穿显胖/显黑,不敢尝试 │ │
|
||||
│ │ ● 想改变却没有方向 │ │
|
||||
│ │ ● 每次出门搭配浪费时间 │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ 如何生成你的报告 │ 区块标题16 Medium
|
||||
│ ①┄②┄③┄④ │ 横向步骤条:4步等宽
|
||||
│ 测一测 分析 生成 报告 │ 圆点28vp(当前主色底白字/未选
|
||||
│ │ IMAGE_BG底Secondary字),连线
|
||||
│ │ 2vp #E5E7EB,标签11居中
|
||||
├──────────────────────────────┤
|
||||
│ [ 马上体验 ] │ 吸底 CTA:主色玫红(非紫)高48
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
CTA 点击 → router 跳 DiagnosisFormPage。hero 为该页专属装饰,报告页/表单页不出现紫渐变。
|
||||
|
||||
## 6. 页面四:美学诊断填写页(DiagnosisFormPage)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ‹ 返回 形象信息 │ 顶栏同上
|
||||
├──────────────────────────────┤
|
||||
│ 基本信息 │ 区块标题16 Medium,上边距16
|
||||
│ ┌──────────────────────────┐ │ 分组卡:白底圆角12 内边距16
|
||||
│ │ 性别 [男 | 女] │ │ 行高52,行间分隔线0.5vp
|
||||
│ │ 出生日期 1995-06-01 ›│ 分段控件复用§4;未选占位
|
||||
│ │ 身高 165 cm ›│ 「请选择」Secondary色
|
||||
│ │ 体重 55 kg ›│ 行:标签14左,值14右+›Disabled
|
||||
│ │ 职业 请输入 ›│ 文本框右对齐无边框,占位同上
|
||||
│ └──────────────────────────┘ │
|
||||
│ 信息仅用于生成你的个人报告, │ 隐私说明:11 Secondary,左对齐
|
||||
│ 不会对外展示或共享。 │ 卡下边距8
|
||||
│ │ 中部留白(后续扩展题目区)
|
||||
├──────────────────────────────┤
|
||||
│ [ 下一步 ] │ 吸底主按钮:主色 高48
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
- 滚轮口径(全部复用 showTextPickerDialog,canLoop:false):出生日期 range 为「1950~2010 年」年份单列或 YYYY-MM 串(开发定,先例一致即可);身高 100~250 整数 cm,默认 165;体重 25~300 整数 kg,默认 55;已有值从当前值滚起(沿用 ReportCreatePage 的 clamp 写法)。
|
||||
- 校验:性别/出生日期/身高/体重必填,职业可空;缺失字段红字 12 COLOR_ERROR 行下提示 + 汇总条「请完成:…」并滚动到首个缺失项(沿用 ReportCreatePage 口径);「下一步」全部合法后才可跳转结果流程。
|
||||
|
||||
## 7. 组件清单(新建,一律独立文件、@Component 导出)
|
||||
|
||||
| 组件 | 建议文件 | 复用方 |
|
||||
|---|---|---|
|
||||
| CategoryChip 品类 chip | components/f2/CategoryChip.ets | 试衣间 v2 |
|
||||
| ClothCard 服装卡(图+名称+品类+删除角标) | components/f2/ClothCard.ets | 试衣间 v2 |
|
||||
| CapsuleButton 黑胶囊(试穿) | components/f2/CapsuleButton.ets | 试衣间 v2 |
|
||||
| Segment 分段控件(两枚胶囊,参数 items/selected) | components/f2/Segment.ets | 发型页、诊断表单 |
|
||||
| ColorDot 发色圆点+名称 | components/f2/ColorDot.ets | 发型页 |
|
||||
| StepBar 横向步骤条 | components/f3/StepBar.ets | 诊断落地页 |
|
||||
| FormRow 表单行(标签+值/控件+箭头+错误行) | components/f3/FormRow.ets | 诊断表单(ReportCreatePage 后续可迁) |
|
||||
|
||||
放置依据:f2=试衣间/发型域(FacePhotoConsent 已在此),f3=报告域(诊断归报告旅程)。通用基础件(Segment、FormRow)先放域内,出现第二域复用时再上收 core/。
|
||||
|
||||
## 8. 文案表(原文照抄,开发不得改写)
|
||||
|
||||
| 位置 | 文案 |
|
||||
|---|---|
|
||||
| 试衣间·页标题 | 试衣间 |
|
||||
| 试衣间·图上角标 | 模特 |
|
||||
| 试衣间·悬浮按钮 | 试穿 |
|
||||
| 试衣间·Tab | 我的衣橱 |
|
||||
| 试衣间·品类 chips | 全部 / 上衣 / 下装 / 连衣裙 / 外套 / 套装 |
|
||||
| 试衣间·空态标题/副文/按钮 | 衣橱还是空的 / 从相册挑一件,马上试穿效果 / 从相册选一件衣服 |
|
||||
| 试衣间·删除确认 | 移除这件衣服? / 取消 / 移除 |
|
||||
| 发型页·双图角标 | 原图 / 效果图 |
|
||||
| 发型页·分段控件 | 女生 / 男生 |
|
||||
| 发型页·区块标题 | 选择发型(点选即试戴) / 换个发色 / 强度 |
|
||||
| 发型页·主按钮 | 应用发色(生成中→生成中…) |
|
||||
| 发型页·图下提示 | 请使用本人照片,上传即确认不侵犯他人肖像权 |
|
||||
| 落地页·大标题/副标 | 个人形象风格分析报告 / 5 分钟,看清你的穿衣风格 |
|
||||
| 落地页·区块标题 | 你的穿衣困扰 / 如何生成你的报告 |
|
||||
| 落地页·困扰 5 条 | 不知道自己适合什么风格 / 衣柜很多衣服却不会搭 / 穿显胖显黑,不敢尝试 / 想改变却没有方向 / 每次出门搭配浪费时间 |
|
||||
| 落地页·步骤条 | 测一测 / 分析 / 生成 / 报告 |
|
||||
| 落地页·CTA | 马上体验 |
|
||||
| 表单页·页标题/区块 | 形象信息 / 基本信息 |
|
||||
| 表单页·行标签 | 性别 / 出生日期 / 身高 / 体重 / 职业 |
|
||||
| 表单页·分段 | 男 / 女;占位:请选择 / 请输入 |
|
||||
| 表单页·隐私说明 | 信息仅用于生成你的个人报告,不会对外展示或共享。 |
|
||||
| 表单页·主按钮 | 下一步 |
|
||||
| 表单页·缺失汇总 | 请完成:性别、出生日期…(动态拼接) |
|
||||
|
||||
## 9. 验收清单(逐条可勾)
|
||||
|
||||
1. 四页主色唯一来源 Theme.ets;落地页外无紫色,除 #1A1A1A 胶囊外无新增散落色值。
|
||||
2. 试衣间 v2:预览区约占屏 45%、双列网格、空态引导按钮、品类条横向可滚且选中黑底白字。
|
||||
3. 发型页 v2:双图+对比、分段控件、8 色圆点+名称、强度滑杆、吸底「应用发色」;旧三态与授权弹层行为不回归。
|
||||
4. 落地页:紫渐变 hero、5 条困扰卡、4 步步骤条、吸底「马上体验」跳填写页。
|
||||
5. 填写页:分组白卡表单、三个滚轮范围正确(100-250 / 25-300 / 年份)、必填校验+汇总条+滚动定位、隐私说明可见。
|
||||
@@ -0,0 +1,181 @@
|
||||
# 美搭配与美学诊断 · 需求文档 v2
|
||||
|
||||
> 2026-09-02 | 状态:待评审 | 关联:《试衣间与发型设计-产品方案》《鸿蒙美搭-智能穿搭美学助手-产品方案》§2.1、《美学报告-需求文档》v1.2
|
||||
> 目标形态:参考站 fitting-room.huimeimeta.com H5(试衣间页 / 发型品类形态 / 美学诊断落地页 + 信息填写页)。
|
||||
|
||||
## 1. 背景与范围
|
||||
|
||||
现有 Phase A 已交付:试衣间(本人照+上衣±裤子→换装,直连 meida.xiangsilian.com/change_cloth_base64)、发型设计(面部特写→60 款试戴+8 色换色)、美学报告(六字段问卷→32 维报告)。
|
||||
|
||||
本 v2 三件事:
|
||||
1. **试衣间改版**(对外名「美搭配」):视觉与交互对齐参考站(顶部预览+试穿按钮+衣橱 Tab+品类条+双列网格);
|
||||
2. **发型设计改版**:保留现有交互,视觉对齐参考站形态;
|
||||
3. **新增美学诊断**:营销落地页 + 信息填写页,复用现有报告创建链路。
|
||||
|
||||
Phase A 约束:无后端改造、无账号体系升级,全部改动在客户端。
|
||||
|
||||
## 2. 现状 vs 目标差距表
|
||||
|
||||
### 2.1 试衣间(美搭配)
|
||||
|
||||
| 维度 | 现状(TryonPage v1) | 目标(v2,对齐参考站) |
|
||||
|---|---|---|
|
||||
| 布局 | 三张照片槽(全身照/上衣/裤子)纵排表单 | 顶部大图模特预览 + 右侧悬浮黑色「试穿」按钮 + 下方衣橱区 |
|
||||
| 素材来源 | 逐张上传/拍摄 | 「我的衣橱」双列网格点选;模特位=本人照上传(拍/选) |
|
||||
| 品类 | 无品类概念(固定两槽) | 品类条:全部/上衣/下装/连衣裙/外套/套装(发型不在本页) |
|
||||
| Tab | 无 | 「我的衣橱|赴宴试衣间」双 Tab |
|
||||
| 模特切换 | 无 | 参考站支持左右滑切换模特;Phase A 仅本人照,**无模特库、无滑动切换**(见 §3.2) |
|
||||
| 生成链路 | 不变 | 不变(接口、超时 320s、秒数等待、常亮、挽留、本机留存均沿用) |
|
||||
|
||||
### 2.2 发型设计
|
||||
|
||||
| 维度 | 现状(HairDesignPage v1) | 目标(v2) |
|
||||
|---|---|---|
|
||||
| 布局 | 照片槽 + 男女分栏网格 + 换发色面板 | 同参考站形态:顶部预览大图 + 品类条(全部/男发/女发)+ 双列网格;换发色面板改为网格下方可展开卡片 |
|
||||
| 交互 | 点选即试戴、8 色发色、强度滑杆、男女分栏(女 34/男 26) | **全部保留**,仅视觉重排 |
|
||||
| 数据 | 在线拉 60 款 | 不变 |
|
||||
|
||||
### 2.3 美学诊断(新增)
|
||||
|
||||
| 维度 | 现状 | 目标(v2) |
|
||||
|---|---|---|
|
||||
| 入口 | 首页入口卡「生成我的形象报告」直达问卷页 | 入口卡改跳「美学诊断落地页」(营销态) |
|
||||
| 落地页 | 无 | 紫色调静态页:主张+PROBLEM+REASON+4 步流程+3 价值卡+CTA「马上体验」 |
|
||||
| 信息填写 | 无(问卷页直接六字段) | 白底「AI 美学检测」表单:性别/出生日期/身高/体重/职业 →「下一步」预填进现有问卷页 |
|
||||
|
||||
## 3. Phase A 技术约束下的产品裁决
|
||||
|
||||
### 3.1 品类 → 接口工作流的降级映射(重要)
|
||||
|
||||
换装接口仅支持「上衣(cloth_img,必传)+ 裤子(kuzi_img,可选且必须伴随上衣)」。参考站的 6 品类按下表**两档映射**,UI 不暴露工作流差异:
|
||||
|
||||
| 品类标签 | 工作流档位 | 说明 |
|
||||
|---|---|---|
|
||||
| 上衣 | cloth_img | 标准档 |
|
||||
| 下装 | kuzi_img | 仅可与上衣同时选中;未选上衣时点选提示「请先选择一件上衣,再搭配下装」 |
|
||||
| 连衣裙 / 外套 / 套装 | **归入 cloth_img(上衣档)** | 降级声明:按单件上衣工作流处理,效果可能不完整(如下摆/成套感缺失),品类条上对这三类加「试穿效果以实际生成为准」的页面脚注 |
|
||||
|
||||
选中模型:**双槽位**。上衣槽(含连衣裙/外套/套装)与下装槽各单选、可替换、可取消;「试穿」按钮可用条件=上衣槽非空。生成时按槽位组装 cloth_img/kuzi_img。
|
||||
|
||||
### 3.2 模特位:仅本人照片
|
||||
|
||||
- 「模特」=用户本人大图预览,**不做模特库、无左右滑动切换**(参考站该交互依赖内置模特素材与授权,Phase A 均无)。
|
||||
- 预览空态:占位图+「上传/拍摄你的正面全身照」按钮;点击走现有 FacePhotoConsent 流程。
|
||||
- 预览有图时:右侧悬浮「试穿」按钮(黑底白字、圆角、高约 120vp 竖条,对齐参考站);未选上衣时按钮置灰。
|
||||
|
||||
### 3.3 我的衣橱 / 赴宴试衣间
|
||||
|
||||
- **我的衣橱**(默认 Tab)=用户本机上传的服装:元数据(品类标签、添加时间)写 preferences,图片文件落 filesDir/wardrobe/,App 卸载即清,不上传服务端。
|
||||
- **上传交互**:衣橱网格末位常驻「+ 添加」卡;点击选/拍一张 → 必选品类标签弹层(上衣/下装/连衣裙/外套/套装,五选一,默认无选中,未选不能确认)→ 存入。
|
||||
- **赴宴试衣间**=精选示例服装(内置素材)。**Phase A 无内置素材,该 Tab 直接隐藏**(不显示灰色占位);素材管线(≥300 件 tryon_ready)就绪后再启用,启用时该 Tab 展示双列精选网格,点击行为与我的衣橱一致(点选→试穿)。
|
||||
|
||||
### 3.4 服装库
|
||||
|
||||
Phase A 无内置素材,网格唯一数据源=我的衣橱。空态:居中插画+「衣橱还是空的,拍一件你的衣服试试」+「添加第一件」按钮(点击=上传流程)。
|
||||
|
||||
### 3.5 发型页裁决
|
||||
|
||||
男女分栏(26 男/34 女)、8 色发色(自然黑/深棕/栗色/蜜茶/亚麻/酒红/奶奶灰/蓝黑)、强度滑杆(0~1,默认 0.9)、点选即试戴、换发色作用于当前结果图——交互全部保留;本次只做视觉重排对齐参考站(顶部预览+品类条+网格)。品类条「全部/男发/女发」映射现有 genderTab(全部=unknown 款两栏都显示,沿用)。
|
||||
|
||||
### 3.6 美学诊断:复用报告链路,不重复造接口
|
||||
|
||||
现有 `POST /api/v1/reports` 问卷体六字段:`gender`(女/男/不便告知)、`height_cm`(100~250)、`weight_kg`(25~300)、`birthday`(YYYY-MM-DD)、`core_goals`(六项多选,至少 1)、`psych_style`(九型单选);提交前有配额确认框(report_basic 终身 1 次),随后 3s 轮询任务→结果页。
|
||||
|
||||
**字段映射与预填策略**:
|
||||
|
||||
| 新表单字段 | 映射 | 说明 |
|
||||
|---|---|---|
|
||||
| 性别(男/女 二选切换) | `questionnaire.gender` | 直接映射「男/女」;「不便告知」不在新表单出现,仅原问卷页保留 |
|
||||
| 出生日期 | `questionnaire.birthday` | DatePicker(1930~当天);表单上按年月日展示并派生只读「年龄」 |
|
||||
| 身高 cm | `questionnaire.height_cm` | 沿用滚轮选择器 100~250,不开放键盘 |
|
||||
| 体重 KG | `questionnaire.weight_kg` | 滚轮 25~300 |
|
||||
| 职业 | **Phase A 仅本地留存,不进提交体** | 后端问卷 schema 无 occupation 字段(维度字典 #4 属全面报告),无后端改造前提下不提交;存 AppStorage 供 Phase B 随 schema 扩展提交 |
|
||||
|
||||
**流程**:填写页「下一步」→ `router.pushUrl('pages/ReportCreatePage')` 携带预填参数 `{gender, birthday, height_cm, weight_kg, occupation(本地)}`;ReportCreatePage 新增 aboutToAppear 读参预填对应 @State,用户在原页补齐核心诉求+心理风格后走原提交/配额确认/轮询链路。原问卷页字段与校验不删不改;从首页直达原问卷页的路径取消(入口卡改指落地页)。
|
||||
|
||||
### 3.7 合规红线(沿用,不放松)
|
||||
|
||||
- 仅限本人照片:试衣间模特位、发型页照片均限本人,页面明示「仅限本人照片」;
|
||||
- 人脸单独同意:上传含人脸照片前必须弹 FacePhotoConsent(服装图不涉及);
|
||||
- Phase A 不接入试衣/发型配额:无配额提示、无扣减文案,个人页配额卡保持只读;
|
||||
- 结果图不落服务端库、无分享出口;本机留存沿用 filesDir/results(仅最近一次)。
|
||||
|
||||
## 4. 页面结构定义
|
||||
|
||||
### 4.1 P1 试衣间(美搭配)v2
|
||||
|
||||
| 区块(上→下) | 内容与交互 | 状态 |
|
||||
|---|---|---|
|
||||
| ① 顶部预览区 | 高约 45% 屏;本人全身照大图(cover 裁切);右侧悬浮「试穿」竖按钮(黑底白字,选中 0 件上衣置灰) | 空态:浅灰底+人形占位+「上传/拍摄你的正面全身照」按钮(点击→人脸同意→拍/选);加载中不涉及(本地图即时显示) |
|
||||
| ② Tab 行 | 「我的衣橱|赴宴试衣间」;Phase A 仅渲染我的衣橱(见 §3.3) | — |
|
||||
| ③ 品类条 | 「衣服库」标题+横向滚动 chips:全部/上衣/下装/连衣裙/外套/套装;单选过滤,默认「全部」 | — |
|
||||
| ④ 双列网格 | 衣橱卡片:服装图(1:1 裁切)+ 品类角标;点选=设入对应槽位(选中态描边+右上角「上衣/下装」槽位角标);再点取消;末位「+ 添加」卡 | 空态见 §3.4;图片加载失败显示「已失效,点按删除」占位(本机文件被清时) |
|
||||
| ⑤ 结果区 | 生成中:预览区覆盖层显示已等待秒数+阶段文案+「取消生成」;完成后原图/结果点按切换+「保存到相册」「再来一次」;「上次结果」卡沿用 | 失败:400 提示重拍/换图;502/503/504「服务繁忙稍后重试」;>320s 主动中止提示;均保留已选不覆盖 |
|
||||
| ⑥ 高级项 | 提示词输入,默认折叠(沿用) | — |
|
||||
|
||||
### 4.2 P2 发型设计 v2
|
||||
|
||||
| 区块(上→下) | 内容与交互 | 状态 |
|
||||
|---|---|---|
|
||||
| ① 顶部预览区 | 面部特写大图+右侧悬浮「试戴」按钮(形态同 P1);预览下小字「仅限本人照片」 | 空态:占位+「上传/拍摄面部特写」按钮(→人脸同意);生成中覆盖层:进度文案+秒数,失败保留当前图 |
|
||||
| ② 品类条 | chips:全部/男发/女发(映射现有男女分栏) | — |
|
||||
| ③ 发型网格 | 双列:缩略图+名称;点选即试戴(选中描边);结果回填预览区,点按切换原图/结果 | 列表三态沿用:加载中骨架/空态「暂无发型」/失败+「重新加载」 |
|
||||
| ④ 换发色卡片 | 网格下方可展开卡片「换发色」:8 色圆点+强度滑杆+「应用」;作用于当前结果图 | 未试戴成功前卡片置灰+引导文案「先试戴一款发型」 |
|
||||
|
||||
### 4.3 P3 美学诊断落地页(新页面,纯静态)
|
||||
|
||||
| 区块(上→下) | 内容 |
|
||||
|---|---|
|
||||
| ① 主视觉 | 紫色渐变底;主标「个人形象风格分析报告」;副标「AI 解读你的身材、色彩与风格基因」;顶部返回+标题「美学诊断」 |
|
||||
| ② 穿衣困扰 PROBLEM | 标题「这些困扰你也有吗?」+ 清单卡 6 条(不知道自己适合什么风格 / 衣柜满满却没衣服穿 / 买回的衣服总穿不出效果 / 打扮了还是显老气或不够精致 / 换季搭配毫无头绪 / 不会配色显黑显胖) |
|
||||
| ③ REASON | 一段式说明「问题不在长相,在于没找到属于你的形象公式」 |
|
||||
| ④ 4 步流程 | 步骤条:① 身材形象分析 ② 运动形象分析 ③ 职业形象分析 ④ 专属报告编写(每步一句副文案) |
|
||||
| ⑤ 三张价值卡 | 专属形象基因 / 专属风格系统 / 专属魅力衣橱(各一句说明) |
|
||||
| ⑥ 底部 CTA | 通栏紫色按钮「马上体验」→ P4;CTA 上方小字「我们会保障您的个人信息安全」 |
|
||||
|
||||
无任何网络请求;弱网/失败态不适用。
|
||||
|
||||
### 4.4 P4 美学诊断信息填写页(新页面)
|
||||
|
||||
| 区块(上→下) | 内容与交互 | 状态 |
|
||||
|---|---|---|
|
||||
| ① 标题区 | 白底;「AI 美学检测」大标+「我们会保障您的个人信息安全」副标 | — |
|
||||
| ② 性别 | 「男/女」两粒切换胶囊(单选必填,默认不选) | 未选提交→红字「请选择性别」 |
|
||||
| ③ 出生日期 | 点击弹 DatePicker(1930~今天);行内展示「1998-06-15(26 岁)」 | 未选提交→红字「请选择出生日期」 |
|
||||
| ④ 身高 cm | 滚轮选择器 100~250 | 未选提交→红字「请选择身高」 |
|
||||
| ⑤ 体重 KG | 滚轮选择器 25~300 | 未选提交→红字「请选择体重」 |
|
||||
| ⑥ 职业 | 常用职业 chips 单选(学生/上班族/教师/医护/公务员/自由职业/其他)+选填 | 可不选 |
|
||||
| ⑦ 底部「下一步」 | 通栏按钮;校验失败时汇总条点名缺项并滚至首个缺失字段(沿用原问卷页交互);通过→预填跳 ReportCreatePage | 跳转失败:页内红字兜底提示 |
|
||||
|
||||
说明:进入 P4 即视为同意生成报告;配额确认仍发生在 ReportCreatePage 提交前(禁止静默扣减纪律不变)。已生成过报告的用户走完流程会在提交时命中「本期报告已生成」对话框(沿用 40920 处理),落地页不做二次拦截。
|
||||
|
||||
## 5. 验收标准(可自动化判定)
|
||||
|
||||
**P1 试衣间**
|
||||
1. 首次进入且未上传本人照:预览区显示占位文案与上传按钮,且「试穿」按钮不可点击;
|
||||
2. 衣橱上传一张图并选「连衣裙」标签:网格出现该卡,品类条切「连衣裙」可见、切「上衣」不可见;
|
||||
3. 仅选下装未选上衣:「试穿」不可用且出现「请先选择一件上衣,再搭配下装」提示;
|
||||
4. 选 1 件上衣+1 件下装点「试穿」:请求体 cloth_img 与 kuzi_img 均非空;仅选上衣时 kuzi_img 为空;
|
||||
5. 衣橱为空(清 preferences)时显示空态文案与「添加第一件」按钮。
|
||||
|
||||
**P2 发型**
|
||||
1. 列表加载完成:品类条「女发」项数=34、「男发」项数=26(±unknown 款两栏均含);
|
||||
2. 未试戴成功时「换发色」卡片置灰不可展开;
|
||||
3. 试戴成功后预览区可点按切换原图/结果;
|
||||
4. 照片上传前未通过人脸同意时不弹出相册/拍照选择器。
|
||||
|
||||
**P3/P4 美学诊断**
|
||||
1. 落地页渲染 6 条 PROBLEM、4 步流程、3 张价值卡,CTA 点击跳转 P4;
|
||||
2. P4 五项必填校验:任一未填点「下一步」,出现对应红字与汇总条且不发生路由跳转;
|
||||
3. 填写「女/1998-06-15/165/52」后「下一步」:ReportCreatePage 的性别=女、生日=1998-06-15、身高=165、体重=52 已预填,核心诉求与心理风格为空待用户填写;
|
||||
4. 职业任意选择不改变提交报文中 questionnaire 字段集合(无 occupation 键)。
|
||||
|
||||
## 6. 非目标(Phase B 及以后)
|
||||
|
||||
1. 模特库与左右滑动切换模特;
|
||||
2. 「赴宴试衣间」精选素材与该 Tab 启用;
|
||||
3. 电商链接/购买入口;
|
||||
4. AI 试衣配额接入与扣减、任务中心、AI 水印与内容编号、COS 转存(切 `/api/v1/tryon/*` 代理时一并做);
|
||||
5. 衣橱云端同步、多套搭配批量试穿、报告推荐搭配自动填充衣橱。
|
||||
|
||||
**文档版本**:v2.0 | 2026-09-02 | 作者:产品 Agent
|
||||