feat: 视频显示到列表

This commit is contained in:
2026-05-07 10:21:08 +08:00
parent c8a65ee2cb
commit 90a59a3dc5
6 changed files with 150 additions and 30 deletions

View File

@@ -1744,7 +1744,7 @@ export default {
cover: "封面", cover: "封面",
productImageDesc: '从产品图中选取', productImageDesc: '从产品图中选取',
cropDesc: '从主产品图或线稿图中裁剪', cropDesc: '从主产品图或线稿图中裁剪',
productImageMainTitle: '产品图 ', productImageMainTitle: '产品图/视频',
productImageSubTitle: ' (来自设计集)', productImageSubTitle: ' (来自设计集)',
apparelSketchTitle: '服装线稿图 ', apparelSketchTitle: '服装线稿图 ',
apparelSketchSubTitle: ' (来自设计集)', apparelSketchSubTitle: ' (来自设计集)',

View File

@@ -1795,7 +1795,7 @@ export default {
cover:'Cover', cover:'Cover',
productImageDesc:'Choose from product image', productImageDesc:'Choose from product image',
cropDesc:'Crop from main product image or sketch', cropDesc:'Crop from main product image or sketch',
productImageMainTitle:'Product Image ', productImageMainTitle:'Product Media ',
productImageSubTitle:'(from design collection)', productImageSubTitle:'(from design collection)',
apparelSketchTitle:'Apparel Sketch ', apparelSketchTitle:'Apparel Sketch ',
apparelSketchSubTitle:'(from design collection)', apparelSketchSubTitle:'(from design collection)',

View File

@@ -1,20 +1,14 @@
import { Https } from "@/tool/https" import { Https } from "@/tool/https"
import type { SketchDetailResponse } from "./types"
// 编辑时根据ID获取信息 // 编辑时根据ID获取信息
export const fetchListingDetailById = (id) => { export const fetchListingDetailById = (id) => {
return Https.axiosGet("/seller/listing/detail", { params: { id } }) return Https.axiosGet("/seller/listing/detail", { params: { id } })
} }
interface SketchIDs { type SketchIDs = Array<number | string>
designItemIds: Array
}
interface DetailReturns {
clothes: string[]
designItemId: number
toProductImageUrls: string[]
}
// 获取designItemId对应的产品图 // 获取designItemId对应的产品图
export const fetchSketchDetail = (data: SketchIDs): Array<DetailReturns> => { export const fetchSketchDetail = (data: SketchIDs): Promise<SketchDetailResponse[]> => {
let params = "?" let params = "?"
data.forEach((id, index) => { data.forEach((id, index) => {
if (index === data.length - 1) { if (index === data.length - 1) {

View File

@@ -12,8 +12,10 @@
v-for="(item, index) in imageList" v-for="(item, index) in imageList"
:key="index" :key="index"
class="product-image-item flex flex-center" class="product-image-item flex flex-center"
:class="{ selected: item.selected }" :class="{ selected: item.selected, video: item.isVideo }"
@click="emit('select', index)" @click="emit('select', index)"
@mouseenter="handleMouseEnter(index, item)"
@mouseleave="handleMouseLeave(index, item)"
> >
<img <img
v-if="item.selected" v-if="item.selected"
@@ -21,8 +23,11 @@
class="checked" class="checked"
alt="" alt=""
/> />
<img class="img-src" :src="item.url" alt="" /> <img class="img-src" :src="getDisplayUrl(item, index)" alt="" />
<div v-if="item.selected && index === firstSelectedIndex" class="main-pic"> <div v-if="item.isVideo && durationMap[index]" class="video-duration">
{{ durationMap[index] }}
</div>
<div v-if="item.selected && index === firstSelectedIndex && !item.isVideo" class="main-pic">
main main
</div> </div>
</div> </div>
@@ -30,8 +35,8 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { computed, ref, watch } from "vue"
import type { ListingItem } from "../types" import type { ListingItem, ProductMediaItem } from "../types"
const props = defineProps<{ const props = defineProps<{
imageList: ListingItem["prodImageList"] imageList: ListingItem["prodImageList"]
@@ -43,6 +48,70 @@
}>() }>()
const selectedCount = computed(() => props.imageList.filter((item) => item.selected).length) const selectedCount = computed(() => props.imageList.filter((item) => item.selected).length)
const hoveredVideoIndex = ref<number | null>(null)
const durationMap = ref<Record<number, string>>({})
const videoSourceKey = computed(() =>
props.imageList
.map((item) => `${item.videoUrl || ""}::${item.url || ""}`)
.join("|")
)
const getDisplayUrl = (item: ProductMediaItem, index: number) => {
if (item.isVideo && hoveredVideoIndex.value === index && item.gifUrl) {
return item.gifUrl
}
return item.url
}
const handleMouseEnter = (index: number, item: ProductMediaItem) => {
if (!item.isVideo || !item.gifUrl) return
hoveredVideoIndex.value = index
}
const handleMouseLeave = (index: number, item: ProductMediaItem) => {
if (!item.isVideo) return
if (hoveredVideoIndex.value === index) hoveredVideoIndex.value = null
}
const formatVideoDuration = (duration: number) => {
if (!Number.isFinite(duration) || duration <= 0) return ""
const totalSeconds = Math.round(duration)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
}
const loadVideoDurations = () => {
durationMap.value = {}
props.imageList.forEach((item, index) => {
if (!item.isVideo || !item.videoUrl) return
const video = document.createElement("video")
video.preload = "metadata"
video.src = item.videoUrl
video.onloadedmetadata = () => {
durationMap.value = {
...durationMap.value,
[index]: formatVideoDuration(video.duration)
}
}
video.onerror = () => {
video.src = ""
}
})
}
watch(
videoSourceKey,
() => {
hoveredVideoIndex.value = null
loadVideoDurations()
},
{ immediate: true }
)
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@@ -135,6 +204,20 @@
object-fit: contain; object-fit: contain;
} }
.video-duration {
position: absolute;
right: 0.8rem;
bottom: 0.8rem;
z-index: 1;
padding: 0 0.8rem;
height: 2.4rem;
line-height: 2.4rem;
border-radius: 1.2rem;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 1.2rem;
}
.main-pic { .main-pic {
position: absolute; position: absolute;
height: 2.4rem; height: 2.4rem;

View File

@@ -91,7 +91,10 @@
ListingDetailImage, ListingDetailImage,
ListingDetailResponse, ListingDetailResponse,
ListingItem, ListingItem,
ProductMediaItem,
RadioOption, RadioOption,
SketchDetailResponse,
SketchDetailVideo,
StatusType StatusType
} from "./types" } from "./types"
@@ -127,12 +130,7 @@
}) })
const genderOptions = computed(() => { const genderOptions = computed(() => {
return ( return STORE.state.UserHabit?.sex.value
STORE.state.UserHabit?.sex.value.map((el) => ({
...el,
// name: el.key.toLowerCase()
})) || []
)
}) })
const fallbackCategoryOptions: Record<string, RadioOption[]> = { const fallbackCategoryOptions: Record<string, RadioOption[]> = {
@@ -249,6 +247,23 @@
return listing return listing
} }
const createProductVideoItem = (video: SketchDetailVideo): ProductMediaItem | null => {
const firstFrameUrl = video?.firstFrameUrl || ""
const gifUrl = video?.gifUrl || ""
const videoUrl = video?.videoUrl || ""
if (!firstFrameUrl || !videoUrl) return null
return {
url: firstFrameUrl,
firstFrameUrl,
gifUrl,
videoUrl,
isVideo: true,
selected: false
}
}
const handleSelectProdImg = (index: number) => { const handleSelectProdImg = (index: number) => {
const listing = currentListing.value const listing = currentListing.value
const target = prodImgList.value[index] const target = prodImgList.value[index]
@@ -257,6 +272,10 @@
target.selected = willSelect target.selected = willSelect
if (willSelect && listing.firstSelectedIndex === null) { if (willSelect && listing.firstSelectedIndex === null) {
if (target.isVideo) {
message.warning("The first selected item is the main product image. Videos cannot be used.")
return
}
listing.mainProductImage = target.url listing.mainProductImage = target.url
listing.firstSelectedIndex = index listing.firstSelectedIndex = index
return return
@@ -438,13 +457,18 @@
} }
const handleFetchItemDetial = (list) => { const handleFetchItemDetial = (list) => {
fetchSketchDetail(list).then((res) => { fetchSketchDetail(list).then((res: SketchDetailResponse[]) => {
res.forEach((item, index) => { res.forEach((item, index) => {
if (!selectList.value[index]) return if (!selectList.value[index]) return
selectList.value[index].sketchList = item.clothes.map((el) => ({ url: el })) selectList.value[index].sketchList = (item.clothes || []).map((el) => ({ url: el }))
selectList.value[index].prodImageList = item.toProductImageUrls.map((el) => ({ const imageItems = (item.toProductImageUrls || []).map((el) => ({
url: el url: el,
selected: false
})) }))
const videoItems = (item.videos || [])
.map((video) => createProductVideoItem(video))
.filter((video): video is ProductMediaItem => Boolean(video))
selectList.value[index].prodImageList = [...imageItems, ...videoItems]
}) })
}) })
} }

View File

@@ -8,6 +8,15 @@ export type RadioOption = {
export type TopImageType = "sketch" | "mainProductImage" | "cover" export type TopImageType = "sketch" | "mainProductImage" | "cover"
export type CropType = TopImageType | "apparel" export type CropType = TopImageType | "apparel"
export type ProductMediaItem = {
url: string
selected?: boolean
isVideo?: boolean
videoUrl?: string
gifUrl?: string
firstFrameUrl?: string
}
export type ListingItem = { export type ListingItem = {
designItemId: number | string | null designItemId: number | string | null
sketch: string | null sketch: string | null
@@ -21,10 +30,7 @@ export type ListingItem = {
gender: string gender: string
category: string[] | null category: string[] | null
firstSelectedIndex: number | null firstSelectedIndex: number | null
prodImageList: Array<{ prodImageList: ProductMediaItem[]
url: string
selected?: boolean
}>
sketchList: Array<{ url: string | null }> sketchList: Array<{ url: string | null }>
} }
@@ -45,4 +51,17 @@ export type ListingDetailResponse = {
images?: ListingDetailImage[] | null images?: ListingDetailImage[] | null
} }
export type SketchDetailVideo = {
firstFrameUrl?: string | null
gifUrl?: string | null
videoUrl?: string | null
}
export type SketchDetailResponse = {
clothes?: string[] | null
designItemId?: number | string | null
toProductImageUrls?: string[] | null
videos?: SketchDetailVideo[] | null
}
export type StatusType = "draft" | "publish" export type StatusType = "draft" | "publish"