feat(fitting): 试衣间布局 v2.1——紧凑预览 300vp+三列纯图卡+全屏查看器(设计师 Agent 出稿)

- 预览区 3:4 大图(437vp/占屏57%) → 固定 300vp + Contain 不裁切(用户要求预览变小且不裁切)
- 新增 ImageViewer 全屏查看器:点预览/生成完成自动弹出;原图|效果图页签与预览角标共用 showOriginal;保存走三态口径
- 衣橱网格双列带文字行 → 三列纯图卡(104×139,品类图内左下角标),首屏可见 6 卡 vs 原 3 卡
- 试穿胶囊 52×120→44×96、品类 chips 高 32→28、角标 12→11
- 设计稿:docs/美搭配与美学诊断-UI规范.md §10(含 7 条冲突修订表与 8 条验收)
This commit is contained in:
xsl
2026-09-03 22:14:39 +08:00
parent d2c603566b
commit 1b534f5133
6 changed files with 309 additions and 69 deletions
@@ -1,7 +1,7 @@
/**
* CapsuleButton —— 「试穿」黑色竖向胶囊(《美搭配与美学诊断-UI规范》§3/§7,需求 v2 §3.2)。
*
* 形态:52×120 竖条(高约 120vp,对齐参考站),黑底白字 14(COLOR_BLACK_CAPSULE),
* 形态:44×96 竖条(v2.1 §10.4 紧凑化),黑底白字 14(COLOR_BLACK_CAPSULE),
* 右缘悬浮垂直居中;未选上衣/未传本人照/生成中置灰(§2 主按钮禁用态:
* 底 COLOR_DIVIDER、字 COLOR_TEXT_DISABLED)。label 支持换行竖排(如 '试\n穿')。
*/
@@ -22,10 +22,10 @@ export struct CapsuleButton {
.fontColor(this.isActive ? Color.White : COLOR_TEXT_DISABLED)
.textAlign(TextAlign.Center)
}
.width(52)
.height(120)
.width(44)
.height(96)
.justifyContent(FlexAlign.Center)
.borderRadius(26)
.borderRadius(22)
.backgroundColor(this.isActive ? COLOR_BLACK_CAPSULE : COLOR_DIVIDER)
.onClick(() => {
if (this.isActive) {
@@ -1,7 +1,7 @@
/**
* CategoryChip —— 品类 chip(《美搭配与美学诊断-UI规范》§3/§7)。
*
* 形态:高约 32 胶囊;选中黑底白字(COLOR_BLACK_CAPSULE,试衣间专属黑),
* 形态:高约 28 胶囊(§10.4 紧凑化);选中黑底白字(COLOR_BLACK_CAPSULE,试衣间专属黑),
* 未选白底 + COLOR_DIVIDER 描边。用于品类过滤条与「添加衣服」品类弹层。
* selected 随父层状态变化,必须 @Prop(普通成员只在创建时赋值一次,不随父刷新)。
*/
@@ -16,11 +16,11 @@ export struct CategoryChip {
build() {
Text(this.label)
.fontSize(13)
.fontSize(12)
.fontColor(this.selected ? Color.White : COLOR_TEXT_PRIMARY)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.selected ? COLOR_BLACK_CAPSULE : Color.White)
.borderRadius(16)
.borderRadius(14)
.border({
width: 1,
color: this.selected ? Color.Transparent : COLOR_DIVIDER
@@ -1,14 +1,11 @@
/**
* ClothCard —— 衣橱服装卡(《美搭配与美学诊断-UI规范》§3/§7)。
* ClothCard —— 衣橱服装卡(《美搭配与美学诊断-UI规范》§10.2 v2.1 三列纯图卡)。
*
* 形态:白底圆角 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。
* 形态:图 3:4 Cover(约 104×139),无名称/品类文字行(三列窄卡放不下,
* 品类改图内左下小角标:黑 60% 底白字 10);选中 2vp 主色描边 + 右上槽位角标;
* 我的衣橱卡保留 20×20 删除角标,内置精选(rawfilePath 非空)无删除、无失效态。
* 结构说明:角标行与卡片主体是 Stack 兄弟节点(非父子),点击角标不会误触选择;
* selected/slotLabel 随父层选择状态变化,必须 @Prop。
*/
import { fileUri } from '@kit.CoreFileKit'
@@ -17,7 +14,6 @@ import {
COLOR_BADGE_BG,
COLOR_IMAGE_BG,
COLOR_PRIMARY,
COLOR_TEXT_PRIMARY,
COLOR_TEXT_SECONDARY
} from '../../core/Theme'
@@ -47,37 +43,36 @@ export struct ClothCard {
.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
})
Stack({ alignContent: Alignment.BottomStart }) {
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
})
}
// 左下品类角标(黑 60% 底白字 10)
Text(this.item.category)
.fontSize(10)
.fontColor(Color.White)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor(COLOR_BADGE_BG)
.margin(6)
}
.width('100%')
}
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)
.backgroundColor(COLOR_IMAGE_BG)
.borderRadius(12)
.clip(true)
.border({
@@ -92,19 +87,20 @@ export struct ClothCard {
}
})
// 右上角标行(槽位角标 + 删除角标,右上 6;内置精选无删除)
// 右上角标行(槽位角标 + 删除角标;内置精选无删除)
Row({ space: 4 }) {
if (this.slotLabel !== '') {
Text(this.slotLabel)
.fontSize(11)
.fontSize(10)
.fontColor(Color.White)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor(COLOR_PRIMARY)
}
if (this.rawfilePath === '') {
Text('×')
.fontSize(14)
.fontSize(13)
.fontColor(Color.White)
.width(20)
.height(20)
@@ -116,7 +112,7 @@ export struct ClothCard {
})
}
}
.margin(6)
.margin(4)
}
.width('100%')
}
@@ -0,0 +1,120 @@
/**
* ImageViewer —— 试衣间全屏图片查看器(《美搭配与美学诊断-UI规范》§10.3)。
*
* 形态:全屏覆盖层,底 COLOR_BLACK_CAPSULE(#1A1A1A);顶部 ×关闭 + 「原图/效果图」
* 页签(仅 hasResult 时显示页签,模特态只有 ×);图 Contain 宽 100%-32 居中,
* 点图切换原图/效果——与页面预览共用父层 showOriginal(@Link 双向);底部
* 「保存到相册」主按钮(走父层既有三态口径)+「再来一次」文字链(仅结果态)。
* 关闭:点 × / 系统返回由父层 onBackPress 处理(viewerOpen 状态门闩)。
*/
import {
COLOR_BLACK_CAPSULE,
COLOR_PRIMARY
} from '../../core/Theme'
@Component
export struct ImageViewer {
@Link showOriginal: boolean // 与页面预览共用:false=效果图优先
hasResult: boolean = false
originalSrc: string = ''
resultSrc: string = ''
onSave: () => void = () => {}
onRetry: () => void = () => {}
onClose: () => void = () => {}
private currentSrc(): string {
if (this.hasResult && !this.showOriginal) {
return this.resultSrc
}
return this.originalSrc
}
build() {
Stack() {
Column() {
// 顶栏 56:× 距左 16;页签仅结果态
Stack({ alignContent: Alignment.Start }) {
Text('×')
.fontSize(24)
.fontColor(Color.White)
.width(40)
.height(40)
.textAlign(TextAlign.Center)
.onClick(() => {
this.onClose()
})
if (this.hasResult) {
Row({ space: 24 }) {
this.TabItem('原图', !this.showOriginal)
this.TabItem('效果图', this.showOriginal)
}
.justifyContent(FlexAlign.Center)
.width('100%')
}
}
.width('100%')
.height(56)
// 图:Contain 宽 100%-32 居中,点图切换
Image(this.currentSrc())
.width('100%')
.layoutWeight(1)
.objectFit(ImageFit.Contain)
.margin({ left: 16, right: 16, top: 4, bottom: 8 })
.onClick(() => {
if (this.hasResult) {
this.showOriginal = !this.showOriginal
}
})
// 底区:保存主按钮 + 再来一次(仅结果态显示两者;模特态留白)
Column({ space: 10 }) {
if (this.hasResult) {
Button('保存到相册')
.width('100%')
.height(48)
.borderRadius(24)
.fontSize(16)
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.onSave()
})
Text('再来一次')
.fontSize(13)
.fontColor(Color.White)
.padding({ top: 4, bottom: 8 })
.onClick(() => {
this.onRetry()
})
}
}
.width('100%')
.padding({ left: 32, right: 32, bottom: 20 })
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
.backgroundColor(COLOR_BLACK_CAPSULE)
}
@Builder
TabItem(label: string, active: boolean) {
Column({ space: 4 }) {
Text(label)
.fontSize(13)
.fontColor(active ? Color.White : '99FFFFFF')
Row()
.width('100%')
.height(3)
.borderRadius(2)
.backgroundColor(active ? Color.White : Color.Transparent)
}
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.showOriginal = label === '原图'
})
}
}
@@ -43,6 +43,7 @@ import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoCo
import { CategoryChip } from '../components/f2/CategoryChip'
import { ClothCard } from '../components/f2/ClothCard'
import { CapsuleButton } from '../components/f2/CapsuleButton'
import { ImageViewer } from '../components/f2/ImageViewer'
/** 品类过滤 chips:全部 + 五品类(UI 规范 §8 文案)。 */
const FILTER_CHIPS: string[] = ['全部'].concat(CLOTH_CATEGORIES)
@@ -73,6 +74,7 @@ struct FittingRoomPage {
@State lastLocalPath: string = '' // 「上次结果」卡数据源
@State showOriginal: boolean = false
@State showLeaveConfirm: boolean = false // 生成中离开的挽留确认
@State viewerOpen: boolean = false // 全屏图片查看器(§10.3)
// —— 高级项 ——
@State desc: string = ''
@State advancedOpen: boolean = false
@@ -359,7 +361,8 @@ struct FittingRoomPage {
if (gen !== this.genSeq) {
return
}
this.statusText = saved ? '生成完成,点预览图可对比原图' : '结果已生成,本地暂存失败,请尽快保存到相册'
this.statusText = saved ? '生成完成,点图可查看大图' : '结果已生成,本地暂存失败,请尽快保存到相册'
this.viewerOpen = true // 生成完成自动弹全屏查看器恰一次(§10.1)
} catch (e) {
if (gen === this.genSeq) {
this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试'
@@ -392,6 +395,10 @@ struct FittingRoomPage {
}
onBackPress(): boolean {
if (this.viewerOpen) {
this.viewerOpen = false
return true
}
if (this.generating) {
this.showLeaveConfirm = true
return true // 拦截系统返回,交由挽留确认处理
@@ -564,27 +571,27 @@ struct FittingRoomPage {
.justifyContent(FlexAlign.Center)
.backgroundColor(COLOR_IMAGE_BG)
} else {
// 本人照/结果大图:有结果时点图切换 原图/效果 对比(沿用 v1)
Image(this.previewSrc())
// 本人照/结果大图(Contain 不裁切,§10.1):点图开全屏查看器
Image(this.showOriginal || this.resultSrc === '' ? this.humanUri : this.resultSrc)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.objectFit(ImageFit.Contain)
.onClick(() => {
if (this.resultUrl.length > 0 && !this.generating) {
this.showOriginal = !this.showOriginal
if (!this.generating && this.humanUri.length > 0) {
this.viewerOpen = true
}
})
}
// 左上「模特/效果图」角标(黑 60% 胶囊,白字 12,距边 12)
// 左上「模特/效果图」角标(黑 60% 胶囊,白字 11,距边 10,§10.4)
if (this.humanUri.length > 0) {
Text(this.showOriginal || this.resultSrc === '' ? '模特' : '效果图')
.fontSize(12)
.fontSize(11)
.fontColor(Color.White)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.borderRadius(14)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor(COLOR_BADGE_BG)
.position({ x: 12, y: 12 })
.position({ x: 10, y: 10 })
}
// 右缘悬浮「试穿」黑胶囊:覆盖层自身不参与命中(None),点击落到图上
@@ -637,7 +644,8 @@ struct FittingRoomPage {
}
}
.width('100%')
.aspectRatio(3 / 4) // 3:4 竖版:与生成结果(768x1024)/人像照同比例,Cover 不再裁成正方形
.height(300) // 紧凑预览(§10.1):固定 300vp,占屏约 38%
.backgroundColor(COLOR_IMAGE_BG) // Contain 灰边底
.borderRadius(16)
.clip(true)
.margin({ left: 16, right: 16, top: 8, bottom: 8 })
@@ -794,9 +802,9 @@ struct FittingRoomPage {
.width('100%')
}
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.width('100%')
}
}
@@ -834,9 +842,9 @@ struct FittingRoomPage {
}
}, (it: WardrobeItem) => this.cardKey(it) + '_b')
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.width('100%')
}
}
@@ -1172,6 +1180,25 @@ struct FittingRoomPage {
if (this.showAddSheet) {
this.AddCategorySheet()
}
// 全屏图片查看器(§10.3):与预览共用 showOriginal/resultSrc
if (this.viewerOpen) {
ImageViewer({
showOriginal: $showOriginal,
hasResult: this.resultUrl.length > 0,
originalSrc: this.humanUri,
resultSrc: this.resultSrc,
onSave: (): void => {
this.saveResult()
},
onRetry: (): void => {
this.viewerOpen = false
this.resetResult()
},
onClose: (): void => {
this.viewerOpen = false
}
})
}
}
.width('100%')
.height('100%')