feat(app): 美搭配 v2(衣橱+精选30件)/发型 v2(男女分栏+换发色修复)/美学诊断两页——多 Agent 一轮交付
- fix(tryon): 鸿蒙 http.multiFormDataList 真机发空包(400 请上传图片)→ TryonApi 手工构造 multipart,真机验证通过;换发型/换发色/换装全链路 MEIDA_TRYON 打点 - feat(hair): 60 款发型视觉分类男女(26/34)Segment 分栏;双图对比、8 色圆点、吸底应用发色 - feat(fitting): FittingRoomPage 预览+试穿黑胶囊+我的衣橱(preferences+filesDir 持久化)+精选衣橱(rawfile 30 件+manifest)+品类过滤+双槽位生成 - feat(diagnosis): 紫渐变落地页+五字段填写页(@Prop 修选择器回填)+预填跳 ReportCreatePage - chore(theme): 9 个色板 token 收口;首页入口改版
@@ -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 |