diff --git a/app/entry/src/main/ets/components/f2/CapsuleButton.ets b/app/entry/src/main/ets/components/f2/CapsuleButton.ets new file mode 100644 index 0000000..e898df7 --- /dev/null +++ b/app/entry/src/main/ets/components/f2/CapsuleButton.ets @@ -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() + } + }) + } +} diff --git a/app/entry/src/main/ets/components/f2/CategoryChip.ets b/app/entry/src/main/ets/components/f2/CategoryChip.ets new file mode 100644 index 0000000..13aa619 --- /dev/null +++ b/app/entry/src/main/ets/components/f2/CategoryChip.ets @@ -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() + }) + } +} diff --git a/app/entry/src/main/ets/components/f2/ClothCard.ets b/app/entry/src/main/ets/components/f2/ClothCard.ets new file mode 100644 index 0000000..ecdf40a --- /dev/null +++ b/app/entry/src/main/ets/components/f2/ClothCard.ets @@ -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%') + } +} diff --git a/app/entry/src/main/ets/components/f2/ColorDot.ets b/app/entry/src/main/ets/components/f2/ColorDot.ets new file mode 100644 index 0000000..2a210a0 --- /dev/null +++ b/app/entry/src/main/ets/components/f2/ColorDot.ets @@ -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() + }) + } +} diff --git a/app/entry/src/main/ets/components/f2/Segment.ets b/app/entry/src/main/ets/components/f2/Segment.ets new file mode 100644 index 0000000..2b82f5e --- /dev/null +++ b/app/entry/src/main/ets/components/f2/Segment.ets @@ -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) + } +} diff --git a/app/entry/src/main/ets/components/f3/FormRow.ets b/app/entry/src/main/ets/components/f3/FormRow.ets new file mode 100644 index 0000000..00f5f16 --- /dev/null +++ b/app/entry/src/main/ets/components/f3/FormRow.ets @@ -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%') + } +} diff --git a/app/entry/src/main/ets/components/f3/StepBar.ets b/app/entry/src/main/ets/components/f3/StepBar.ets new file mode 100644 index 0000000..13b0893 --- /dev/null +++ b/app/entry/src/main/ets/components/f3/StepBar.ets @@ -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) + } +} diff --git a/app/entry/src/main/ets/core/BuiltInCloth.ets b/app/entry/src/main/ets/core/BuiltInCloth.ets new file mode 100644 index 0000000..4b652d5 --- /dev/null +++ b/app/entry/src/main/ets/core/BuiltInCloth.ets @@ -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_',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 { + 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 { + const rm = ContextHolder.get().resourceManager + const data = await rm.getRawFileContent(`cloth/${file}`) + const helper = new util.Base64Helper() + return helper.encodeToStringSync(data) + } +} diff --git a/app/entry/src/main/ets/core/Theme.ets b/app/entry/src/main/ets/core/Theme.ets index daa7315..d0e59ba 100644 --- a/app/entry/src/main/ets/core/Theme.ets +++ b/app/entry/src/main/ets/core/Theme.ets @@ -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' diff --git a/app/entry/src/main/ets/core/TryonApi.ets b/app/entry/src/main/ets/core/TryonApi.ets index ea2dc66..4aeaa5b 100644 --- a/app/entry/src/main/ets/core/TryonApi.ets +++ b/app/entry/src/main/ets/core/TryonApi.ets @@ -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 = new Set([ + '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 = new Set([ + '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 { - 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 { - 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 { + private static async postHairForm(url: string, parts: HairFormPart[]): Promise { 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) { diff --git a/app/entry/src/main/ets/core/WardrobeStore.ets b/app/entry/src/main/ets/core/WardrobeStore.ets new file mode 100644 index 0000000..ffad2a3 --- /dev/null +++ b/app/entry/src/main/ets/core/WardrobeStore.ets @@ -0,0 +1,144 @@ +/** + * WardrobeStore —— 「我的衣橱」本机存取(需求 v2 §3.3)。 + * + * - 元数据(品类标签、名称、添加时间)写 preferences(meida_wardrobe), + * 图片文件压缩转存 filesDir/wardrobe/.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/.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 { + 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 { + 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 { + 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 { + 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) { + // 内存态已生效,持久化失败下次启动重读旧值 + } + } +} diff --git a/app/entry/src/main/ets/pages/DiagnosisFormPage.ets b/app/entry/src/main/ets/pages/DiagnosisFormPage.ets new file mode 100644 index 0000000..6d31b5b --- /dev/null +++ b/app/entry/src/main/ets/pages/DiagnosisFormPage.ets @@ -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(OCCUPATION_STORAGE_KEY, this.occupation) + // 四个值全部为 string,接收端 ReportCreatePage 按 string 读取 + const params: Record = { + '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) + } +} diff --git a/app/entry/src/main/ets/pages/DiagnosisLandingPage.ets b/app/entry/src/main/ets/pages/DiagnosisLandingPage.ets new file mode 100644 index 0000000..ea356df --- /dev/null +++ b/app/entry/src/main/ets/pages/DiagnosisLandingPage.ets @@ -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) + } +} diff --git a/app/entry/src/main/ets/pages/FittingRoomPage.ets b/app/entry/src/main/ets/pages/FittingRoomPage.ets new file mode 100644 index 0000000..9917390 --- /dev/null +++ b/app/entry/src/main/ets/pages/FittingRoomPage.ets @@ -0,0 +1,1173 @@ +/** + * FittingRoomPage —— 试衣间 v2「美搭配」(需求 v2 §3.1/3.2/3.3/3.4/4.1,UI 规范 §3)。 + * + * 形态:顶部本人照大图预览(45% 屏,Cover 圆角16,左上「模特」角标)+ 右缘悬浮 + * 黑胶囊「试穿」(未选上衣置灰)+ 下方「我的衣橱」Tab + 品类过滤 chips + + * 双列衣橱网格(末位「+ 添加」卡)。生成链路沿用 TryonPage v1: + * 320s 超时/秒数计时/屏幕常亮/返回挽留/取消生成/genSeq 失效/结果落盘 + * filesDir/results/tryon_last.jpg/「上次结果」卡。 + * + * 槽位模型(需求 §3.1):双槽位——上衣槽(上衣/连衣裙/外套/套装 → cloth_img) + * 与下装槽(下装 → kuzi_img,须伴随上衣)各单选、可替换、可取消; + * 「试穿」可用条件 = 本人照 + 上衣槽非空。仅选下装时按钮禁用并提示。 + * 品类降级映射:连衣裙/外套/套装按上衣工作流处理,页面加脚注声明。 + * + * 模特位(需求 §3.2):仅本人照片,无模特库/滑动切换;空态占位 +「上传/拍摄 + * 你的正面全身照」按钮,点击走 FacePhotoConsent(人脸单独同意,CR-01); + * 服装图不涉及人脸,不弹同意。 + */ + +import { router, window } from '@kit.ArkUI' +import { photoAccessHelper } from '@kit.MediaLibraryKit' +import { fileUri } from '@kit.CoreFileKit' +import request from '@ohos.request' +import fs from '@ohos.file.fs' +import { TryonApi } from '../core/TryonApi' +import { ImageCodec } from '../core/ImageCodec' +import { WardrobeStore, WardrobeItem, CLOTH_CATEGORIES } from '../core/WardrobeStore' +import { BuiltInCloth } from '../core/BuiltInCloth' +import { ApiError } from '../core/ApiTypes' +import { ContextHolder } from '../core/ContextHolder' +import { + COLOR_PRIMARY, + COLOR_PAGE_BG, + COLOR_TEXT_PRIMARY, + COLOR_TEXT_SECONDARY, + COLOR_DIVIDER, + COLOR_IMAGE_BG, + COLOR_BADGE_BG, + COLOR_ERROR, + COLOR_TEXT_DISABLED +} from '../core/Theme' +import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent' +import { CategoryChip } from '../components/f2/CategoryChip' +import { ClothCard } from '../components/f2/ClothCard' +import { CapsuleButton } from '../components/f2/CapsuleButton' + +/** 品类过滤 chips:全部 + 五品类(UI 规范 §8 文案)。 */ +const FILTER_CHIPS: string[] = ['全部'].concat(CLOTH_CATEGORIES) + +@Entry +@Component +struct FittingRoomPage { + // —— 衣橱 —— + @State items: WardrobeItem[] = [] + @State builtinItems: WardrobeItem[] = [] // 精选衣橱(内置 rawfile 素材) + @State clothTab: number = 0 // 0=我的衣橱 1=精选衣橱 + @State activeFilter: string = '全部' // 品类单选过滤,默认「全部」 + @State showAddSheet: boolean = false // 添加衣服的品类弹层 + @State addCategory: string = '' // 弹层内五选一,默认无选中 + @State showDeleteConfirm: boolean = false + // —— 模特位 / 槽位 —— + @State humanUri: string = '' + @State topSlotId: string = '' // 上衣槽(上衣/连衣裙/外套/套装) + @State bottomSlotId: string = '' // 下装槽 + @State showFaceConsent: boolean = false + // —— 生成链路(沿用 TryonPage v1)—— + @State generating: boolean = false + @State elapsedSec: number = 0 + @State statusText: string = '' + @State resultUrl: string = '' + @State resultLocalPath: string = '' // 本机留存副本:展示与保存优先走它 + @State lastLocalPath: string = '' // 「上次结果」卡数据源 + @State showOriginal: boolean = false + @State showLeaveConfirm: boolean = false // 生成中离开的挽留确认 + // —— 高级项 —— + @State desc: string = '' + @State advancedOpen: boolean = false + + private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口(本人照) + private pendingAddUri: string = '' // 已选待入橱的服装图(品类确认后入库) + private deleteTargetId: string = '' // 待确认删除的衣橱条目 + private timerId: number = -1 + private genSeq: number = 0 // 生成代号:取消/放弃后使在途请求的收尾与迟到结果失效 + + /** 本机结果留存目录:filesDir/results(沿用 TryonPage #17)。 */ + private resultStoreDir(): string { + return `${ContextHolder.get().filesDir}/results` + } + + private resultStorePath(): string { + return `${this.resultStoreDir()}/tryon_last.jpg` + } + + aboutToAppear(): void { + // 预留:Agent 联动(V2)自动填入提示词(与 TryonPage 同参) + const params = router.getParams() as Record | undefined + if (params !== undefined && typeof params['change_desc'] === 'string') { + this.desc = params['change_desc'] as string + } + this.hydrateWardrobe() + this.hydrateBuiltin() + // 上次结果回看:沙盒留存最近一次换装结果,杀 App/重启不丢 + try { + if (fs.accessSync(this.resultStorePath())) { + this.lastLocalPath = this.resultStorePath() + } + } catch (_e) { + // 目录不存在等:视为无上次结果 + } + } + + aboutToDisappear(): void { + this.abandonGeneration() + } + + /** 衣橱数据加载(preferences → 内存 → 页面态)。 */ + private async hydrateWardrobe(): Promise { + await WardrobeStore.init() + this.items = WardrobeStore.list() + } + + /** 精选衣橱加载(rawfile manifest,离线打包;失败为空数组不阻断)。 */ + private async hydrateBuiltin(): Promise { + this.builtinItems = await BuiltInCloth.load() + } + + // ==================== 品类过滤与槽位 ==================== + + /** 当前品类过滤下的衣橱条目(列表本身已按添加时间倒序)。 */ + private filteredItems(): WardrobeItem[] { + if (this.activeFilter === '全部') { + return this.items + } + return this.items.filter((it: WardrobeItem): boolean => it.category === this.activeFilter) + } + + private findItem(id: string): WardrobeItem | undefined { + const own = this.items.find((it: WardrobeItem): boolean => it.id === id) + if (own !== undefined) { + return own + } + return this.builtinItems.find((it: WardrobeItem): boolean => it.id === id) + } + + /** 条目所在槽位角标(上衣/下装);未选中返回空。 */ + private slotLabelFor(item: WardrobeItem): string { + if (this.topSlotId === item.id) { + return '上衣' + } + if (this.bottomSlotId === item.id) { + return '下装' + } + return '' + } + + /** ForEach key 带上选中态:槽位变化时重建对应卡片,确保描边/角标刷新。 */ + private cardKey(item: WardrobeItem): string { + return this.slotLabelFor(item) === '' ? item.id : `${item.id}_slot` + } + + /** 点选卡:设入对应槽位(下装类进下装槽,其余归上衣槽);再点取消。 */ + private onCardTap(item: WardrobeItem): void { + if (WardrobeStore.isBottom(item.category)) { + this.bottomSlotId = this.bottomSlotId === item.id ? '' : item.id + } else { + this.topSlotId = this.topSlotId === item.id ? '' : item.id + } + } + + /** 「试穿」可用条件:本人照 + 上衣槽非空 + 非生成中(需求 §3.1/§3.2)。 */ + private canTryOn(): boolean { + return this.humanUri.length > 0 && this.topSlotId.length > 0 && !this.generating + } + + /** 槽位图 → Base64:内置条目直读 rawfile 字节;我的衣橱走压缩编码。 */ + private async slotImageBase64(item: WardrobeItem): Promise { + if (BuiltInCloth.isBuiltin(item.id)) { + return BuiltInCloth.fileBase64(item.id.substring(2)) + } + return ImageCodec.toJpegBase64(item.uri) + } + + /** 仅选下装未选上衣的提示(需求 §5 验收 3)。 */ + private needTopHint(): boolean { + return this.bottomSlotId.length > 0 && this.topSlotId.length === 0 + } + + // ==================== 本人照(模特位) ==================== + + /** 本人照含人脸,上传前须单独同意(CR-01);服饰图不涉及。 */ + private pickHuman(fromCamera: boolean): void { + if (!FaceConsentStore.isGranted()) { + this.consentPickCamera = fromCamera + this.showFaceConsent = true + return + } + this.doPickHuman(fromCamera) + } + + private async doPickHuman(fromCamera: boolean): Promise { + try { + const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery() + if (picked === undefined) { + return + } + this.humanUri = picked.uri + this.resetResult() + } catch (e) { + this.statusText = e instanceof ApiError ? e.message : '读取照片失败' + } + } + + private onConsentAgree(): void { + FaceConsentStore.grant() + this.showFaceConsent = false + this.doPickHuman(this.consentPickCamera) + } + + private onConsentRefuse(): void { + this.showFaceConsent = false + this.statusText = '未同意照片授权,本次不上传照片' + } + + // ==================== 衣橱上传 / 删除 ==================== + + /** 「+ 添加」卡:先选/拍一张,再弹必选品类弹层(需求 §3.3)。 */ + private async pickAddImage(fromCamera: boolean): Promise { + try { + const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery() + if (picked === undefined) { + return + } + this.pendingAddUri = picked.uri + this.addCategory = '' // 默认无选中,未选不能确认 + this.showAddSheet = true + } catch (e) { + this.statusText = e instanceof ApiError ? e.message : '读取照片失败' + } + } + + /** 品类确认 → 压缩落盘 filesDir/wardrobe + 元数据写 preferences。 */ + private async confirmAdd(): Promise { + if (this.addCategory === '' || this.pendingAddUri === '') { + return + } + const uri: string = this.pendingAddUri + const category: string = this.addCategory + this.pendingAddUri = '' + this.showAddSheet = false + try { + await WardrobeStore.add(uri, category) + this.items = WardrobeStore.list() + } catch (e) { + this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '保存失败,请重试' + } + } + + private requestRemove(id: string): void { + this.deleteTargetId = id + this.showDeleteConfirm = true + } + + private async confirmRemove(): Promise { + const id: string = this.deleteTargetId + this.showDeleteConfirm = false + this.deleteTargetId = '' + if (id === '') { + return + } + try { + await WardrobeStore.remove(id) + } catch (_e) { + // 删除失败不阻断:本地列表仍刷新(文件残留由卸载回收) + } + if (this.topSlotId === id) { + this.topSlotId = '' + } + if (this.bottomSlotId === id) { + this.bottomSlotId = '' + } + this.items = WardrobeStore.list() + } + + // ==================== 生成链路(沿用 TryonPage v1) ==================== + + /** 生成期间保持屏幕常亮:1~5 分钟等待不允许被系统自动锁屏打断。 */ + private async setKeepScreenOn(keep: boolean): Promise { + try { + const win = await window.getLastWindow(ContextHolder.get()) + await win.setWindowKeepScreenOn(keep) + } catch (e) { + console.error(`MEIDA_TRYON keepScreenOn(${keep}) failed: ${String(e)}`) + } + } + + private stopTimer(): void { + if (this.timerId >= 0) { + clearInterval(this.timerId) + this.timerId = -1 + } + } + + /** 终止在途生成并复位等待态(取消/离开/页面销毁共用);不动已选槽位。 */ + private abandonGeneration(): void { + this.genSeq += 1 // 使在途 generate 的收尾与迟到结果全部失效 + TryonApi.cancelChangeCloth() + this.stopTimer() + if (this.generating) { + this.generating = false + this.setKeepScreenOn(false) + } + } + + /** + * 试穿生成:两图各自压缩 → Base64(ImageCodec.toJpegBase64)→ + * changeCloth(human, cloth=上衣槽必传, kuzi=下装槽可选, desc)。 + */ + private async generate(): Promise { + if (!this.canTryOn()) { + return + } + const top = this.findItem(this.topSlotId) + if (top === undefined) { + return + } + const bottom = this.findItem(this.bottomSlotId) + const gen: number = ++this.genSeq + this.generating = true + this.elapsedSec = 0 + this.resultUrl = '' + this.resultLocalPath = '' + this.showOriginal = false + this.statusText = '正在压缩上传…' + this.setKeepScreenOn(true) + this.timerId = setInterval(() => { + this.elapsedSec += 1 + }, 1000) + try { + const human = await ImageCodec.toJpegBase64(this.humanUri) + const cloth = await this.slotImageBase64(top) + const kuzi = bottom !== undefined ? await this.slotImageBase64(bottom) : '' + if (gen !== this.genSeq) { + return // 压缩期已取消:不再发起真实换装请求 + } + this.statusText = '已提交,AI 生成中(一般 1~3 分钟,请留在本页)…' + const url = await TryonApi.changeCloth(human, cloth, kuzi, this.desc.trim()) + if (gen !== this.genSeq) { + return // 已被取消/离开,丢弃迟到结果 + } + this.resultUrl = url + this.statusText = '生成完成,正在留存到本机…' + const saved: boolean = await this.persistResult(url) + if (gen !== this.genSeq) { + return + } + this.statusText = saved ? '生成完成,点预览图可对比原图' : '结果已生成,本地暂存失败,请尽快保存到相册' + } catch (e) { + if (gen === this.genSeq) { + this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试' + } + } finally { + if (gen === this.genSeq) { + this.stopTimer() + this.generating = false + this.setKeepScreenOn(false) + } + } + } + + /** 取消生成:直连模式无服务端任务、不涉及后端配额,取消即放弃等待本次结果。 */ + private cancelGenerate(): void { + if (!this.generating) { + return + } + this.abandonGeneration() + this.statusText = '已取消本次生成(内测直连服务,不涉及额度扣减)' + } + + /** 生成中点返回:先挽留,确认离开才真正退出。 */ + private tryBack(): void { + if (this.generating) { + this.showLeaveConfirm = true + return + } + router.back() + } + + onBackPress(): boolean { + if (this.generating) { + this.showLeaveConfirm = true + return true // 拦截系统返回,交由挽留确认处理 + } + return false + } + + /** 回到选人台:清掉上一轮生成/保存的残留。 */ + private resetResult(): void { + this.resultUrl = '' + this.resultLocalPath = '' + this.showOriginal = false + this.statusText = '' + } + + /** + * 生成成功即转存本机:下载结果到 filesDir/results/tryon_last.jpg。 + * 下载前先清旧文件——request.downloadFile 不能覆盖已存在文件(TryonPage #21 同款坑)。 + * 失败返回 false,调用方降级用 OSS URL。 + */ + private async persistResult(url: string): Promise { + try { + const path: string = this.resultStorePath() + if (!fs.accessSync(this.resultStoreDir())) { + fs.mkdirSync(this.resultStoreDir(), true) + } + try { + if (fs.accessSync(path)) { + fs.unlinkSync(path) + } + } catch (_e) { + // 清理失败不阻断:下载失败走统一降级 + } + const task = await request.downloadFile(ContextHolder.get(), { url: url, filePath: path }) + await new Promise((resolve: () => void, reject: (r: Error) => void) => { + task.on('complete', () => resolve()) + task.on('fail', (err: number) => reject(new Error(`download fail: ${err}`))) + }) + this.resultLocalPath = path + this.lastLocalPath = path + return true + } catch (_e) { + this.resultLocalPath = '' + return false + } + } + + /** 保存到相册:本机副本在手时跳过下载直接存;转存失败过的现场下载兜底。 */ + private async saveResult(): Promise { + if (this.resultUrl.length === 0) { + return + } + if (this.resultLocalPath.length > 0) { + this.statusText = await this.saveFileToGallery(this.resultLocalPath) + return + } + try { + const path: string = `${ContextHolder.get().cacheDir}/tryon_${Date.now()}.jpg` + const task = await request.downloadFile(ContextHolder.get(), { url: this.resultUrl, filePath: path }) + await new Promise((resolve: () => void, reject: (r: Error) => void) => { + task.on('complete', () => resolve()) + task.on('fail', (err: number) => reject(new Error(`download fail: ${err}`))) + }) + this.statusText = await this.saveFileToGallery(path) + } catch (_e) { + this.statusText = '保存失败,请重试' + } + } + + /** + * 系统保存对话框 → 三态文案:返回 URI 列表非空=已保存;空数组=用户取消; + * 抛异常=保存失败(不再谎报成取消)。 + */ + private async saveFileToGallery(path: string): Promise { + try { + const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get()) + const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE } + const uris: Array = await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg]) + return uris.length > 0 ? '已保存到相册' : '已取消保存' + } catch (_e) { + return '保存失败,请重试' + } + } + + /** 「上次结果」卡的保存:直接走本机副本,不依赖网络。 */ + private async saveLastResult(): Promise { + if (this.lastLocalPath.length === 0) { + return + } + this.statusText = await this.saveFileToGallery(this.lastLocalPath) + } + + /** 结果区图源:本机副本优先,转存失败降级 OSS URL。 */ + private resultImageSrc(): string { + return this.resultLocalPath.length > 0 ? fileUri.getUriFromPath(this.resultLocalPath) : this.resultUrl + } + + /** 预览图源:有结果且未切原图 → 结果图;否则本人照。 */ + private previewSrc(): string { + if (this.resultUrl.length > 0 && !this.showOriginal) { + return this.resultImageSrc() + } + return this.humanUri + } + + /** 预览左上角标:本人照显示「模特」,结果图显示「效果图」。 */ + private previewBadge(): string { + if (this.resultUrl.length > 0 && !this.showOriginal) { + return '效果图' + } + return '模特' + } + + // ==================== UI ==================== + + @Builder + TopBar() { + Row() { + Text('‹ 返回') + .fontSize(15) + .fontColor(COLOR_TEXT_SECONDARY) + .onClick(() => { + this.tryBack() + }) + Text('试衣间') + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(COLOR_TEXT_PRIMARY) + Text('').width(48) + } + .width('100%') + .padding({ left: 16, right: 16, top: 12, bottom: 12 }) + .justifyContent(FlexAlign.SpaceBetween) + .backgroundColor(Color.White) + } + + /** 顶部预览区(45% 屏):本人照 Cover 圆角16 + 「模特」角标 + 悬浮「试穿」黑胶囊。 */ + @Builder + PreviewBlock() { + Stack() { + if (this.humanUri.length === 0) { + // 空态:浅灰底 + 人形占位 + 上传按钮(点击→人脸同意→拍/选) + Column({ space: 12 }) { + Column({ space: 4 }) { + Circle({ width: 30, height: 30 }).fill(COLOR_TEXT_DISABLED) + Row() + .width(56) + .height(26) + .borderRadius(13) + .backgroundColor(COLOR_TEXT_DISABLED) + } + Button('上传/拍摄你的正面全身照') + .height(40) + .borderRadius(20) + .fontSize(13) + .backgroundColor(COLOR_PRIMARY) + .onClick(() => { + this.pickHuman(false) + }) + Text('拍照') + .fontSize(12) + .fontColor(COLOR_PRIMARY) + .onClick(() => { + this.pickHuman(true) + }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .backgroundColor(COLOR_IMAGE_BG) + } else { + // 本人照/结果大图:有结果时点图切换 原图/效果 对比(沿用 v1) + Image(this.previewSrc()) + .width('100%') + .height('100%') + .objectFit(ImageFit.Cover) + .onClick(() => { + if (this.resultUrl.length > 0 && !this.generating) { + this.showOriginal = !this.showOriginal + } + }) + } + + // 左上「模特/效果图」角标(黑 60% 胶囊,白字 12,距边 12) + if (this.humanUri.length > 0) { + Text(this.previewBadge()) + .fontSize(12) + .fontColor(Color.White) + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + .borderRadius(14) + .backgroundColor(COLOR_BADGE_BG) + .position({ x: 12, y: 12 }) + } + + // 右缘悬浮「试穿」黑胶囊:覆盖层自身不参与命中(None),点击落到图上 + Row() { + CapsuleButton({ + label: '试\n穿', + isActive: this.canTryOn(), + onAction: (): void => { + this.generate() + } + }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.End) + .alignItems(VerticalAlign.Center) + .padding({ right: 16 }) + .hitTestBehavior(HitTestMode.None) + + // 生成中覆盖层:阶段文案 + 已等待秒数 + 「取消生成」 + if (this.generating) { + Column({ space: 10 }) { + LoadingProgress() + .width(36) + .height(36) + .color(COLOR_PRIMARY) + Text(this.statusText) + .fontSize(13) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + Text(`${this.elapsedSec}s`) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(Color.White) + Button('取消生成') + .height(32) + .fontSize(13) + .fontColor(Color.White) + .backgroundColor(Color.Transparent) + .border({ width: 1, color: Color.White }) + .onClick(() => { + this.cancelGenerate() + }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .padding({ left: 24, right: 24 }) + .backgroundColor(COLOR_BADGE_BG) + } + } + .width('100%') + .height('45%') + .borderRadius(16) + .clip(true) + .margin({ left: 16, right: 16, top: 8, bottom: 8 }) + } + + /** Tab 行:「我的衣橱|精选衣橱」(主色 3vp 下划线;用 border-bottom 避免内层 Row 撑爆布局)。 */ + @Builder + WardrobeTab() { + Row({ space: 28 }) { + Column() { + Text('我的衣橱') + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(this.clothTab === 0 ? COLOR_PRIMARY : COLOR_TEXT_SECONDARY) + } + .padding({ bottom: 5, top: 4 }) + .border({ width: { bottom: 3 }, color: { bottom: this.clothTab === 0 ? COLOR_PRIMARY : Color.Transparent } }) + .onClick(() => { + this.clothTab = 0 + }) + Column() { + Text('精选衣橱') + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(this.clothTab === 1 ? COLOR_PRIMARY : COLOR_TEXT_SECONDARY) + } + .padding({ bottom: 5, top: 4 }) + .border({ width: { bottom: 3 }, color: { bottom: this.clothTab === 1 ? COLOR_PRIMARY : Color.Transparent } }) + .onClick(() => { + this.clothTab = 1 + }) + } + .width('100%') + .padding({ left: 16, right: 16, top: 4 }) + } + + /** 品类条:「衣服库」标题 + 横向滚动 chips(选中黑底白字)。 */ + @Builder + CategoryBar() { + Column({ space: 10 }) { + Text('衣服库') + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(COLOR_TEXT_PRIMARY) + .width('100%') + Scroll() { + Row({ space: 8 }) { + ForEach(FILTER_CHIPS, (c: string) => { + CategoryChip({ + label: c, + selected: this.activeFilter === c, + onPick: (): void => { + this.activeFilter = c + } + }) + }, (c: string) => (this.activeFilter === c ? 'on_' : 'off_') + c) + } + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + .width('100%') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + /** 双列衣橱网格 + 末位「+ 添加」卡;空衣橱走空态。精选页走 BuiltinGrid。 */ + @Builder + WardrobeGrid() { + if (this.clothTab === 1) { + this.BuiltinGrid() + } else if (this.items.length === 0) { + // 空衣橱态(UI 规范 §8 文案) + Column({ space: 8 }) { + Row() { + Text('衣') + .fontSize(18) + .fontColor(COLOR_TEXT_DISABLED) + } + .width(44) + .height(44) + .borderRadius(22) + .backgroundColor(Color.White) + .border({ width: 1, color: COLOR_DIVIDER }) + .justifyContent(FlexAlign.Center) + Text('衣橱还是空的') + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(COLOR_TEXT_PRIMARY) + .margin({ top: 4 }) + Text('从相册挑一件,马上试穿效果') + .fontSize(12) + .fontColor(COLOR_TEXT_SECONDARY) + Button('从相册选一件衣服') + .width('80%') + .height(48) + .borderRadius(24) + .fontSize(16) + .backgroundColor(COLOR_PRIMARY) + .margin({ top: 12 }) + .onClick(() => { + this.pickAddImage(false) + }) + } + .width('100%') + .padding({ top: 24, bottom: 24 }) + .alignItems(HorizontalAlign.Center) + } else { + Grid() { + ForEach(this.filteredItems(), (it: WardrobeItem) => { + GridItem() { + ClothCard({ + item: it, + selected: this.slotLabelFor(it) !== '', + slotLabel: this.slotLabelFor(it), + onSelect: (): void => { + this.onCardTap(it) + }, + onDelete: (): void => { + this.requestRemove(it.id) + } + }) + } + }, (it: WardrobeItem) => this.cardKey(it)) + + // 末位「+ 添加」卡(常驻):点卡片选图,底部「拍照」入口拍图 + GridItem() { + Stack({ alignContent: Alignment.Bottom }) { + Column({ space: 4 }) { + Text('+') + .fontSize(28) + .fontColor(COLOR_TEXT_SECONDARY) + Text('添加') + .fontSize(13) + .fontColor(COLOR_TEXT_SECONDARY) + } + .width('100%') + .aspectRatio(3 / 4) + .justifyContent(FlexAlign.Center) + .backgroundColor(COLOR_IMAGE_BG) + .borderRadius(12) + .border({ width: 1, color: COLOR_DIVIDER }) + .onClick(() => { + this.pickAddImage(false) + }) + Text('拍照') + .fontSize(11) + .fontColor(COLOR_PRIMARY) + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .onClick(() => { + this.pickAddImage(true) + }) + } + .width('100%') + } + } + .columnsTemplate('1fr 1fr') + .columnsGap(12) + .rowsGap(12) + .width('100%') + } + } + + /** 精选衣橱网格(内置 rawfile 素材):点选同样设入槽位,无删除/添加。 */ + @Builder + BuiltinGrid() { + if (this.filteredBuiltin().length === 0) { + Column({ space: 8 }) { + Text('精选衣服加载失败') + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(COLOR_TEXT_PRIMARY) + Text('请退出页面后重试') + .fontSize(12) + .fontColor(COLOR_TEXT_SECONDARY) + } + .width('100%') + .padding({ top: 24, bottom: 24 }) + .alignItems(HorizontalAlign.Center) + } else { + Grid() { + ForEach(this.filteredBuiltin(), (it: WardrobeItem) => { + GridItem() { + ClothCard({ + item: it, + selected: this.slotLabelFor(it) !== '', + slotLabel: this.slotLabelFor(it), + rawfilePath: `cloth/${it.id.substring(2)}`, + onSelect: (): void => { + this.onCardTap(it) + }, + onDelete: (): void => {} + }) + } + }, (it: WardrobeItem) => this.cardKey(it) + '_b') + } + .columnsTemplate('1fr 1fr') + .columnsGap(12) + .rowsGap(12) + .width('100%') + } + } + + /** 当前品类过滤下的精选条目。 */ + private filteredBuiltin(): WardrobeItem[] { + if (this.activeFilter === '全部') { + return this.builtinItems + } + return this.builtinItems.filter((it: WardrobeItem): boolean => it.category === this.activeFilter) + } + + /** 上次结果卡(沿用 TryonPage #17):无本次结果时展示留存副本。 */ + @Builder + LastResultCard() { + if (this.lastLocalPath.length > 0 && this.resultUrl.length === 0) { + Column({ space: 8 }) { + Text('上次试衣结果') + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(COLOR_TEXT_PRIMARY) + .width('100%') + Image(fileUri.getUriFromPath(this.lastLocalPath)) + .width('100%') + .height(260) + .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) + } + } + + /** 生成中离开的挽留确认(沿用 TryonPage #16)。 */ + @Builder + LeaveConfirmDialog() { + Column() { + Column({ space: 14 }) { + Text('生成仍在进行') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor(COLOR_TEXT_PRIMARY) + Text('换装一般需要 1~3 分钟,离开将无法收到本次结果') + .fontSize(13) + .fontColor(COLOR_TEXT_SECONDARY) + .textAlign(TextAlign.Center) + Row({ space: 12 }) { + Button('继续等待') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor(COLOR_IMAGE_BG) + .fontColor(COLOR_TEXT_PRIMARY) + .onClick(() => { + this.showLeaveConfirm = false + }) + Button('仍要离开') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor(COLOR_PRIMARY) + .onClick(() => { + this.showLeaveConfirm = false + this.abandonGeneration() + router.back() + }) + } + .width('100%') + .margin({ top: 4 }) + } + .width('82%') + .padding(20) + .borderRadius(16) + .backgroundColor(Color.White) + } + .width('100%') + .height('100%') + .backgroundColor(COLOR_BADGE_BG) + .justifyContent(FlexAlign.Center) + } + + /** 删除确认弹窗(UI 规范 §8:移除这件衣服?/取消/移除)。 */ + @Builder + DeleteConfirmDialog() { + Column() { + Column({ space: 14 }) { + Text('移除这件衣服?') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor(COLOR_TEXT_PRIMARY) + Row({ space: 12 }) { + Button('取消') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor(COLOR_IMAGE_BG) + .fontColor(COLOR_TEXT_PRIMARY) + .onClick(() => { + this.showDeleteConfirm = false + this.deleteTargetId = '' + }) + Button('移除') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor(COLOR_PRIMARY) + .onClick(() => { + this.confirmRemove() + }) + } + .width('100%') + .margin({ top: 4 }) + } + .width('82%') + .padding(20) + .borderRadius(16) + .backgroundColor(Color.White) + } + .width('100%') + .height('100%') + .backgroundColor(COLOR_BADGE_BG) + .justifyContent(FlexAlign.Center) + } + + /** 添加衣服的必选品类弹层:五选一,默认无选中,未选不能确认(需求 §3.3)。 */ + @Builder + AddCategorySheet() { + Column() { + Column({ space: 14 }) { + Text('选择品类') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor(COLOR_TEXT_PRIMARY) + Text('这件衣服是哪个品类?') + .fontSize(13) + .fontColor(COLOR_TEXT_SECONDARY) + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(CLOTH_CATEGORIES, (c: string) => { + CategoryChip({ + label: c, + selected: this.addCategory === c, + onPick: (): void => { + this.addCategory = c + } + }) + .margin({ right: 8, bottom: 8 }) + }, (c: string) => (this.addCategory === c ? 'on_' : 'off_') + c) + } + .width('100%') + Row({ space: 12 }) { + Button('取消') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor(COLOR_IMAGE_BG) + .fontColor(COLOR_TEXT_PRIMARY) + .onClick(() => { + this.showAddSheet = false + this.pendingAddUri = '' + }) + Button('确认') + .layoutWeight(1) + .height(40) + .fontSize(14) + .fontColor(this.addCategory === '' ? COLOR_TEXT_DISABLED : Color.White) + .backgroundColor(this.addCategory === '' ? COLOR_DIVIDER : COLOR_PRIMARY) + .enabled(this.addCategory !== '') + .onClick(() => { + this.confirmAdd() + }) + } + .width('100%') + .margin({ top: 4 }) + } + .width('82%') + .padding(20) + .borderRadius(16) + .backgroundColor(Color.White) + } + .width('100%') + .height('100%') + .backgroundColor(COLOR_BADGE_BG) + .justifyContent(FlexAlign.Center) + } + + build() { + Stack() { + Column() { + this.TopBar() + this.PreviewBlock() + + // 仅选下装未选上衣的提示(需求 §5 验收 3) + if (!this.generating && this.needTopHint()) { + Text('请先选择一件上衣,再搭配下装') + .fontSize(12) + .fontColor(COLOR_ERROR) + .width('100%') + .padding({ left: 16, right: 16 }) + } + // 等待期之外的轻提示(选图失败/已保存等) + if (!this.generating && this.statusText.length > 0) { + Text(this.statusText) + .fontSize(12) + .fontColor(COLOR_TEXT_SECONDARY) + .width('100%') + .padding({ left: 16, right: 16 }) + } + // 结果操作行:保存到相册 / 再来一次(点预览图本身做原图/效果切换) + if (this.resultUrl.length > 0 && !this.generating) { + Row({ space: 24 }) { + Text('保存到相册') + .fontSize(13) + .fontColor(COLOR_PRIMARY) + .onClick(() => { + this.saveResult() + }) + Text('再来一次') + .fontSize(13) + .fontColor(COLOR_PRIMARY) + .onClick(() => { + this.resetResult() + }) + } + .width('100%') + .justifyContent(FlexAlign.Center) + } + // 本人照合规提示(CR-07:页面明示仅限本人照片) + if (this.humanUri.length > 0 && this.resultUrl.length === 0 && !this.generating) { + Row({ space: 16 }) { + Text('相册') + .fontSize(12) + .fontColor(COLOR_PRIMARY) + .onClick(() => { + this.pickHuman(false) + }) + Text('拍照') + .fontSize(12) + .fontColor(COLOR_PRIMARY) + .onClick(() => { + this.pickHuman(true) + }) + Text('请使用本人照片,上传即确认不侵犯他人肖像权') + .fontSize(11) + .fontColor(COLOR_TEXT_SECONDARY) + .layoutWeight(1) + } + .width('100%') + .padding({ left: 16, right: 16 }) + } + + Scroll() { + Column({ space: 12 }) { + this.WardrobeTab() + this.CategoryBar() + this.WardrobeGrid() + // 品类降级声明脚注(需求 §3.1:连衣裙/外套/套装按上衣工作流) + if (this.items.length > 0) { + Text('连衣裙、外套、套装按上衣流程试穿,试穿效果以实际生成为准') + .fontSize(11) + .fontColor(COLOR_TEXT_SECONDARY) + .width('100%') + } + // 高级项:提示词输入,默认折叠(沿用 v1) + Column({ space: 8 }) { + Row({ space: 8 }) { + Text('高级提示词') + .fontSize(13) + .fontColor(COLOR_TEXT_SECONDARY) + Text(this.advancedOpen ? '收起' : '展开') + .fontSize(13) + .fontColor(COLOR_PRIMARY) + .onClick(() => { + this.advancedOpen = !this.advancedOpen + }) + } + .width('100%') + if (this.advancedOpen) { + TextInput({ text: this.desc, placeholder: '提示词(可选):如「换成户外街拍背景」' }) + .width('100%') + .onChange((v: string) => { + this.desc = v + }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + this.LastResultCard() + } + .width('100%') + .padding({ left: 16, right: 16, bottom: 24 }) + } + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + + if (this.showFaceConsent) { + FacePhotoConsent({ + onAgree: (): void => { + this.onConsentAgree() + }, + onRefuse: (): void => { + this.onConsentRefuse() + } + }) + } + if (this.showLeaveConfirm) { + this.LeaveConfirmDialog() + } + if (this.showDeleteConfirm) { + this.DeleteConfirmDialog() + } + if (this.showAddSheet) { + this.AddCategorySheet() + } + } + .width('100%') + .height('100%') + .backgroundColor(COLOR_PAGE_BG) + } +} diff --git a/app/entry/src/main/ets/pages/HairDesignPage.ets b/app/entry/src/main/ets/pages/HairDesignPage.ets index b6e1a81..f7c0410 100644 --- a/app/entry/src/main/ets/pages/HairDesignPage.ets +++ b/app/entry/src/main/ets/pages/HairDesignPage.ets @@ -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) } } diff --git a/app/entry/src/main/ets/pages/HomePage.ets b/app/entry/src/main/ets/pages/HomePage.ets index 4e2f510..4aa4709 100644 --- a/app/entry/src/main/ets/pages/HomePage.ets +++ b/app/entry/src/main/ets/pages/HomePage.ets @@ -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() diff --git a/app/entry/src/main/ets/pages/ReportCreatePage.ets b/app/entry/src/main/ets/pages/ReportCreatePage.ets index d2f50f6..9ae8c42 100644 --- a/app/entry/src/main/ets/pages/ReportCreatePage.ets +++ b/app/entry/src/main/ets/pages/ReportCreatePage.ets @@ -105,6 +105,26 @@ struct ReportCreatePage { if (cached !== undefined && cached !== '') { this.lastReportId = cached } + // 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6):只填四字段, + // 核心诉求/心理风格仍由用户在本页补齐;无参数时行为与旧直达完全一致。 + const params = router.getParams() as Record | 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 { diff --git a/app/entry/src/main/resources/base/profile/main_pages.json b/app/entry/src/main/resources/base/profile/main_pages.json index 70caad4..3e40c1c 100644 --- a/app/entry/src/main/resources/base/profile/main_pages.json +++ b/app/entry/src/main/resources/base/profile/main_pages.json @@ -10,6 +10,9 @@ "pages/LlmTestPage", "pages/TryonPage", "pages/HairDesignPage", - "pages/ReportPdfPage" + "pages/ReportPdfPage", + "pages/FittingRoomPage", + "pages/DiagnosisLandingPage", + "pages/DiagnosisFormPage" ] } diff --git a/app/entry/src/main/resources/rawfile/cloth/manifest.json b/app/entry/src/main/resources/rawfile/cloth/manifest.json new file mode 100644 index 0000000..77b32ff --- /dev/null +++ b/app/entry/src/main/resources/rawfile/cloth/manifest.json @@ -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": "深色双排扣西装" + } + ] +} \ No newline at end of file diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_04.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_04.jpg new file mode 100644 index 0000000..2942722 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_04.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_05.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_05.jpg new file mode 100644 index 0000000..8014d5b Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_05.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_06.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_06.jpg new file mode 100644 index 0000000..77b9d3e Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_06.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_08.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_08.jpg new file mode 100644 index 0000000..0b00fcf Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_08.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_13.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_13.jpg new file mode 100644 index 0000000..83e81c1 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_13.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_15.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_15.jpg new file mode 100644 index 0000000..ed18042 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_15.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_22.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_22.jpg new file mode 100644 index 0000000..546bbd1 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_22.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_23.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_23.jpg new file mode 100644 index 0000000..54d9cb5 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_23.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_24.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_24.jpg new file mode 100644 index 0000000..937f24d Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_24.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/上衣_25.jpg b/app/entry/src/main/resources/rawfile/cloth/上衣_25.jpg new file mode 100644 index 0000000..1ddb5a9 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/上衣_25.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/下装_03.jpg b/app/entry/src/main/resources/rawfile/cloth/下装_03.jpg new file mode 100644 index 0000000..8bcbf65 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/下装_03.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/下装_07.jpg b/app/entry/src/main/resources/rawfile/cloth/下装_07.jpg new file mode 100644 index 0000000..60ca871 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/下装_07.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/下装_09.jpg b/app/entry/src/main/resources/rawfile/cloth/下装_09.jpg new file mode 100644 index 0000000..1c03d0d Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/下装_09.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/下装_11.jpg b/app/entry/src/main/resources/rawfile/cloth/下装_11.jpg new file mode 100644 index 0000000..1ca6507 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/下装_11.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/下装_12.jpg b/app/entry/src/main/resources/rawfile/cloth/下装_12.jpg new file mode 100644 index 0000000..69048fc Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/下装_12.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/外套_16.jpg b/app/entry/src/main/resources/rawfile/cloth/外套_16.jpg new file mode 100644 index 0000000..469056c Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/外套_16.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/外套_17.jpg b/app/entry/src/main/resources/rawfile/cloth/外套_17.jpg new file mode 100644 index 0000000..6317b48 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/外套_17.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/外套_18.jpg b/app/entry/src/main/resources/rawfile/cloth/外套_18.jpg new file mode 100644 index 0000000..03d6aa5 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/外套_18.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/外套_19.jpg b/app/entry/src/main/resources/rawfile/cloth/外套_19.jpg new file mode 100644 index 0000000..5ba3bdf Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/外套_19.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/外套_20.jpg b/app/entry/src/main/resources/rawfile/cloth/外套_20.jpg new file mode 100644 index 0000000..75c5c76 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/外套_20.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/外套_21.jpg b/app/entry/src/main/resources/rawfile/cloth/外套_21.jpg new file mode 100644 index 0000000..ab5c5b0 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/外套_21.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/外套_29.jpg b/app/entry/src/main/resources/rawfile/cloth/外套_29.jpg new file mode 100644 index 0000000..ec383e2 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/外套_29.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/套装_28.jpg b/app/entry/src/main/resources/rawfile/cloth/套装_28.jpg new file mode 100644 index 0000000..fabb7e7 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/套装_28.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/连衣裙_00.jpg b/app/entry/src/main/resources/rawfile/cloth/连衣裙_00.jpg new file mode 100644 index 0000000..d452cff Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/连衣裙_00.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/连衣裙_01.jpg b/app/entry/src/main/resources/rawfile/cloth/连衣裙_01.jpg new file mode 100644 index 0000000..c61c2f6 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/连衣裙_01.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/连衣裙_02.jpg b/app/entry/src/main/resources/rawfile/cloth/连衣裙_02.jpg new file mode 100644 index 0000000..db76948 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/连衣裙_02.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/连衣裙_10.jpg b/app/entry/src/main/resources/rawfile/cloth/连衣裙_10.jpg new file mode 100644 index 0000000..5fa8f43 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/连衣裙_10.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/连衣裙_14.jpg b/app/entry/src/main/resources/rawfile/cloth/连衣裙_14.jpg new file mode 100644 index 0000000..2d07ecd Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/连衣裙_14.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/连衣裙_26.jpg b/app/entry/src/main/resources/rawfile/cloth/连衣裙_26.jpg new file mode 100644 index 0000000..86b0a92 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/连衣裙_26.jpg differ diff --git a/app/entry/src/main/resources/rawfile/cloth/连衣裙_27.jpg b/app/entry/src/main/resources/rawfile/cloth/连衣裙_27.jpg new file mode 100644 index 0000000..4514822 Binary files /dev/null and b/app/entry/src/main/resources/rawfile/cloth/连衣裙_27.jpg differ