feat: 视频保存

This commit is contained in:
2026-05-07 13:15:21 +08:00
parent 90a59a3dc5
commit 133433a260
6 changed files with 259 additions and 69 deletions

View File

@@ -1855,6 +1855,7 @@ export default {
SelectCollection: '选择商品', SelectCollection: '选择商品',
SelectSketch: '选择线稿图', SelectSketch: '选择线稿图',
EditListingDetails: '编辑商品详情', EditListingDetails: '编辑商品详情',
VideoWarning: '首次选中的图片素材会作为产品主图,视频不可作为产品主图'
}, },
ApplySeller: { ApplySeller: {
applySellerTitle: '申请成为卖家', applySellerTitle: '申请成为卖家',

View File

@@ -1909,6 +1909,7 @@ export default {
SelectCollection: 'Select Collection', SelectCollection: 'Select Collection',
SelectSketch: 'Select Sketch', SelectSketch: 'Select Sketch',
EditListingDetails: 'Edit Listing Details', EditListingDetails: 'Edit Listing Details',
VideoWarning:'The first selected item is the main product image. Videos cannot be used.'
}, },
ApplySeller: { ApplySeller: {
applySellerTitle: 'Apply to Become a Seller', applySellerTitle: 'Apply to Become a Seller',

View File

@@ -124,8 +124,11 @@
const currentOrigin = ref("sketch") const currentOrigin = ref("sketch")
const coverOrigin = ref([]) const coverOrigin = ref([])
const handleChangeOrigin = (type) => { const handleChangeOrigin = (type) => {
const targetOrigin = coverOrigin.value.find((el) => el.type === type)
if (!targetOrigin) return
currentOrigin.value = type currentOrigin.value = type
data.url = coverOrigin.value.filter((el) => el.type === type)[0].url data.url = targetOrigin.url
} }
const show = ref(false) const show = ref(false)
@@ -136,7 +139,7 @@
coverOrigin.value = [] coverOrigin.value = []
data.url = null data.url = null
currentOrigin.value = 'sketch' currentOrigin.value = "sketch"
data.url = url data.url = url
data.callback = callback data.callback = callback
@@ -148,9 +151,11 @@
if (options.hasOwnProperty("isPreview")) data.isPreview = options.isPreview if (options.hasOwnProperty("isPreview")) data.isPreview = options.isPreview
data.isProduct = options.isProduct data.isProduct = options.isProduct
} }
if (origin?.length && !data.url) { if (origin?.length) {
coverOrigin.value = origin coverOrigin.value = origin
data.url = origin[0].url const defaultOrigin = origin.find((el) => el.type === options?.coverFrom) || origin[0]
currentOrigin.value = defaultOrigin.type
data.url = defaultOrigin.url
} }
show.value = true show.value = true
} }
@@ -160,7 +165,7 @@
const imageClipRef = ref(null) const imageClipRef = ref(null)
const onSubmit = () => { const onSubmit = () => {
imageClipRef.value.getCropBlob().then((blob) => { imageClipRef.value.getCropBlob().then((blob) => {
if (data.callback) data.callback(blobToFile(blob, "image.png")) if (data.callback) data.callback(blobToFile(blob, "image.png"), currentOrigin.value)
onCancel() onCancel()
}) })
} }

View File

@@ -1,5 +1,5 @@
import { Https } from "@/tool/https" import { Https } from "@/tool/https"
import type { SketchDetailResponse } from "./types" import type { ListingImageCategory, SketchDetailResponse } from "./types"
// 编辑时根据ID获取信息 // 编辑时根据ID获取信息
export const fetchListingDetailById = (id) => { export const fetchListingDetailById = (id) => {
@@ -21,23 +21,26 @@ export const fetchSketchDetail = (data: SketchIDs): Promise<SketchDetailResponse
} }
interface ImageObj { interface ImageObj {
id: number // 图片id,有值会更新,没有会自动新增 id?: number // 图片id,有值会更新,没有会自动新增
category: "cover" | "main_product" | "product" | "sketch" | "apparel" // 图片类型 category: ListingImageCategory // 图片类型
imageUrl?: string | null
isSelected?: number
sortOrder?: number
} }
interface DetailData { interface DetailData {
id: number | string // 商品Id id: number | string // 商品Id
title: string // 商品名 title: string // 商品名
description: string // 商品描述 description: string // 商品描述
price: number // 价格 price: number | string // 价格
stock?: number // 库存 stock?: number // 库存
viewCount?: number // 浏览量 viewCount?: number // 浏览量
status: 0 | 1 | 2 // 0草稿 1发布 2删除 status: 0 | 1 | 2 // 0草稿 1发布 2删除
images: ImageObj[] images: ImageObj[]
designFor: "male" | "female" designFor: "male" | "female"
productCategory: "outwear" | "trousers" | "blouse" | "dress" | "skirt" | "accessories" productCategory: string[] | null
} }
// 保存/更新表单 // 保存/更新表单
export const fetchUpdateListing = (data: DetailData) => { export const fetchUpdateListing = (data: DetailData[]) => {
return Https.axiosPost("/seller/listing/batch", data) return Https.axiosPost("/seller/listing/batch", data)
} }

View File

@@ -88,8 +88,11 @@
fetchUpdateListing fetchUpdateListing
} from "./api" } from "./api"
import type { import type {
CoverSourceType,
CropType,
ListingDetailImage, ListingDetailImage,
ListingDetailResponse, ListingDetailResponse,
ListingImageCategory,
ListingItem, ListingItem,
ProductMediaItem, ProductMediaItem,
RadioOption, RadioOption,
@@ -124,6 +127,7 @@
desc: "", desc: "",
gender: "FEMALE", gender: "FEMALE",
category: null, category: null,
coverFrom: "sketch",
firstSelectedIndex: null, firstSelectedIndex: null,
prodImageList: [], prodImageList: [],
sketchList: [] sketchList: []
@@ -170,14 +174,48 @@
return [...images].sort((prev, next) => (prev.sortOrder ?? 0) - (next.sortOrder ?? 0)) return [...images].sort((prev, next) => (prev.sortOrder ?? 0) - (next.sortOrder ?? 0))
} }
const getImageSelected = (value: ListingDetailImage["isSelected"]) => const getImageSelected = (value: ListingDetailImage["isSelected"]) => {
value === true || value === 1 || value === "1" if (value === true || value === 1 || value === "1") return true
if (typeof value === "string") return value.toLowerCase() === "true"
return false
}
const getDetailImageSelected = (image: ListingDetailImage) =>
getImageSelected(image.isSelected) ||
getImageSelected(image.isSeleted) ||
getImageSelected(image.selected)
const normalizeCoverSource = (value: unknown): CoverSourceType =>
value === "mainProductImage" ? "mainProductImage" : "sketch"
const isCoverSource = (value: unknown): value is CoverSourceType =>
value === "sketch" || value === "mainProductImage"
const resolveCoverSourceFromImageUrl = (
imageUrl: string,
listing: ListingItem
): CoverSourceType => {
if (imageUrl === listing.mainProductImage) return "mainProductImage"
if (imageUrl === listing.sketch) return "sketch"
return normalizeCoverSource(imageUrl)
}
const videoImageCategories = ["firstFrame", "gif", "video"] as const
type VideoImageCategory = (typeof videoImageCategories)[number]
const isVideoImageCategory = (category: ListingDetailImage["category"]): category is VideoImageCategory =>
videoImageCategories.includes(category as VideoImageCategory)
const normalizeDetailGender = (value: ListingDetailResponse["designFor"]) => { const normalizeDetailGender = (value: ListingDetailResponse["designFor"]) => {
const gender = String(value || "").toUpperCase() const gender = String(value || "").toUpperCase()
return gender === "MALE" || gender === "FEMALE" ? gender : "FEMALE" return gender === "MALE" || gender === "FEMALE" ? gender : "FEMALE"
} }
const getListingDesignFor = (gender: ListingItem["gender"]): "male" | "female" =>
gender === "MALE" ? "male" : "female"
const normalizeDetailCategory = ( const normalizeDetailCategory = (
value: ListingDetailResponse["productCategory"] value: ListingDetailResponse["productCategory"]
): ListingItem["category"] => { ): ListingItem["category"] => {
@@ -191,6 +229,17 @@
const createListingItemFromDetail = (detail: ListingDetailResponse): ListingItem => { const createListingItemFromDetail = (detail: ListingDetailResponse): ListingItem => {
const listing = createListingItem() const listing = createListingItem()
let coverFromImageUrl = ""
const videoGroupMap = new Map<
number,
{
sortOrder: number
firstFrameUrl?: string
gifUrl?: string
videoUrl?: string
selected: boolean
}
>()
listing.productName = detail.title || "" listing.productName = detail.title || ""
listing.price = listing.price =
@@ -200,6 +249,11 @@
listing.category = normalizeDetailCategory(detail.productCategory) listing.category = normalizeDetailCategory(detail.productCategory)
getSortedDetailImages(detail.images || []).forEach((image) => { getSortedDetailImages(detail.images || []).forEach((image) => {
if (image.category === "cover_from") {
coverFromImageUrl = image.imageUrl || ""
return
}
const imageUrl = image.imageUrl || "" const imageUrl = image.imageUrl || ""
if (!imageUrl) return if (!imageUrl) return
@@ -213,7 +267,7 @@
return return
} }
if (image.category === "mainProductImage") { if (image.category === "mainProductImage" || image.category === "main_product") {
listing.mainProductImage = imageUrl listing.mainProductImage = imageUrl
return return
} }
@@ -221,22 +275,57 @@
if (image.category === "product") { if (image.category === "product") {
listing.prodImageList.push({ listing.prodImageList.push({
url: imageUrl, url: imageUrl,
selected: getImageSelected(image.isSelected) selected: getDetailImageSelected(image)
}) })
return return
} }
if (isVideoImageCategory(image.category)) {
if (image.sortOrder === null || typeof image.sortOrder === "undefined") return
const sortOrder = Number(image.sortOrder)
if (!Number.isFinite(sortOrder)) return
const group = videoGroupMap.get(sortOrder) || {
sortOrder,
selected: false
}
if (image.category === "firstFrame") group.firstFrameUrl = imageUrl
if (image.category === "gif") group.gifUrl = imageUrl
if (image.category === "video") group.videoUrl = imageUrl
group.selected = group.selected || getDetailImageSelected(image)
videoGroupMap.set(sortOrder, group)
return
}
if (image.category === "apparel") { if (image.category === "apparel") {
listing.sketchList.push({ url: imageUrl }) listing.sketchList.push({ url: imageUrl })
} }
}) })
Array.from(videoGroupMap.values())
.sort((prev, next) => prev.sortOrder - next.sortOrder)
.forEach((video) => {
const videoItem = createProductVideoItem(video, video.selected)
if (videoItem) listing.prodImageList.push(videoItem)
})
if (!listing.mainProductImage) { if (!listing.mainProductImage) {
listing.mainProductImage = listing.mainProductImage =
listing.prodImageList.find((item) => item.selected)?.url || "" listing.prodImageList.find((item) => item.selected && !item.isVideo)?.url || ""
}
if (coverFromImageUrl) {
listing.coverFrom = resolveCoverSourceFromImageUrl(coverFromImageUrl, listing)
} }
const selectedIndex = listing.prodImageList.findIndex((item) => item.selected) const mainProductIndex = listing.prodImageList.findIndex(
(item) => !item.isVideo && item.url === listing.mainProductImage
)
const selectedIndex =
mainProductIndex === -1
? listing.prodImageList.findIndex((item) => item.selected && !item.isVideo)
: mainProductIndex
listing.firstSelectedIndex = selectedIndex === -1 ? null : selectedIndex listing.firstSelectedIndex = selectedIndex === -1 ? null : selectedIndex
listing.productImage = listing.prodImageList.map((item) => item.url) listing.productImage = listing.prodImageList.map((item) => item.url)
@@ -247,7 +336,10 @@
return listing return listing
} }
const createProductVideoItem = (video: SketchDetailVideo): ProductMediaItem | null => { const createProductVideoItem = (
video: SketchDetailVideo,
selected = false
): ProductMediaItem | null => {
const firstFrameUrl = video?.firstFrameUrl || "" const firstFrameUrl = video?.firstFrameUrl || ""
const gifUrl = video?.gifUrl || "" const gifUrl = video?.gifUrl || ""
const videoUrl = video?.videoUrl || "" const videoUrl = video?.videoUrl || ""
@@ -260,7 +352,7 @@
gifUrl, gifUrl,
videoUrl, videoUrl,
isVideo: true, isVideo: true,
selected: false selected
} }
} }
@@ -287,25 +379,29 @@
} }
} }
const cropType = ref("") const getCoverOriginList = (item: ListingItem) => {
const handleClickCrop = (data: any, type: string, paramThree: any = []) => { const origin: Array<{ type: CoverSourceType; url: string }> = []
if (item.sketch) {
origin.push({ type: "sketch", url: item.sketch })
}
if (item.mainProductImage) {
origin.push({ type: "mainProductImage", url: item.mainProductImage })
}
return origin
}
const cropType = ref<CropType | "">("")
const handleClickCrop = (data: string | null, type: CropType, paramThree: number | unknown[] = []) => {
// 处理来自TopImageSection的调用: (data, type, list) // 处理来自TopImageSection的调用: (data, type, list)
// 处理来自ApparelSketchList的调用: (data, type, index) // 处理来自ApparelSketchList的调用: (data, type, index)
const index = typeof paramThree === "number" ? paramThree : undefined const index = typeof paramThree === "number" ? paramThree : undefined
const list = Array.isArray(paramThree) ? paramThree : []
// console.log(data, type) // console.log(data, type)
// console.log(selectList.value[currentIndex.value]) // console.log(selectList.value[currentIndex.value])
let origin = []
const currentItem = selectList.value[currentIndex.value] const currentItem = selectList.value[currentIndex.value]
if (currentItem.sketch) { const origin = type === "cover" ? getCoverOriginList(currentItem) : []
origin.push({ type: "sketch", url: currentItem.sketch }) const titleList: Record<CropType, string> = {
}
if (currentItem.mainProductImage) {
origin.push({ type: "mainProductImage", url: currentItem.mainProductImage })
}
if (type !== "cover") origin = []
const titleList = {
sketch: "Crop Sketch", sketch: "Crop Sketch",
mainProductImage: "Crop Main Product Image", mainProductImage: "Crop Main Product Image",
cover: "Crop Cover", cover: "Crop Cover",
@@ -315,17 +411,28 @@
cropType.value = type cropType.value = type
imageClipDialogRef.value.open( imageClipDialogRef.value.open(
data, data,
(file) => { (file: File, coverFrom?: CoverSourceType) => {
// console.log(file) // console.log(file)
uploadFile(file).then((res) => { uploadFile(file).then((res) => {
if (type === "apparel" && typeof index !== "undefined") { if (type === "apparel" && typeof index !== "undefined") {
selectList.value[currentIndex.value].sketchList[index].url = res selectList.value[currentIndex.value].sketchList[index].url = res
} else if (type === "cover") {
selectList.value[currentIndex.value].cover = res
if (isCoverSource(coverFrom)) {
selectList.value[currentIndex.value].coverFrom = coverFrom
}
} else { } else {
selectList.value[currentIndex.value][type] = res selectList.value[currentIndex.value][type] = res
} }
}) })
}, },
{ ratio, isPreview: true, title: titleList[type], isProduct: true }, {
ratio,
isPreview: true,
title: titleList[type],
isProduct: true,
coverFrom: currentItem.coverFrom
},
origin origin
) )
} }
@@ -377,49 +484,107 @@
return true return true
} }
type SaveListingImage = {
category: ListingImageCategory
imageUrl: string | null
isSelected: number
sortOrder: number
}
const isMainProductMedia = (item: ListingItem, media: ProductMediaItem, index: number) =>
!media.isVideo &&
(item.firstSelectedIndex === index ||
(Boolean(item.mainProductImage) && item.mainProductImage === media.url))
const getSortedProductMedia = (item: ListingItem) => {
return item.prodImageList
.map((media, index) => ({ media, index }))
.filter(({ media }) => !media.isVideo)
.sort((prev, next) => {
const prevRank = isMainProductMedia(item, prev.media, prev.index)
? 0
: prev.media.selected
? 1
: 2
const nextRank = isMainProductMedia(item, next.media, next.index)
? 0
: next.media.selected
? 1
: 2
return prevRank - nextRank || prev.index - next.index
})
}
const buildListingImages = (item: ListingItem) => {
const images: SaveListingImage[] = []
const sortOrderMap: Partial<Record<ListingImageCategory, number>> = {}
const getNextSortOrder = (category: ListingImageCategory) => {
const sortOrder = (sortOrderMap[category] || 0) + 1
sortOrderMap[category] = sortOrder
return sortOrder
}
const pushImage = (
category: ListingImageCategory,
imageUrl: string | null,
isSelected = 1,
sortOrder = getNextSortOrder(category)
) => {
images.push({
category,
imageUrl,
isSelected,
sortOrder
})
}
const getCoverFromImageUrl = () => {
if (item.coverFrom === "mainProductImage") return item.mainProductImage || null
return item.sketch
}
pushImage("sketch", item.sketch)
pushImage("cover", item.cover)
pushImage("cover_from", getCoverFromImageUrl())
if (item.mainProductImage) {
pushImage("main_product", item.mainProductImage)
}
getSortedProductMedia(item).forEach(({ media }) => {
pushImage("product", media.url, Number(!!media.selected))
})
item.sketchList.forEach((sketch) => {
pushImage("apparel", sketch.url)
})
let videoSortOrder = 0
item.prodImageList
.filter((media) => media.isVideo)
.forEach((media) => {
videoSortOrder += 1
const isSelected = Number(!!media.selected)
pushImage("firstFrame", media.firstFrameUrl || media.url, isSelected, videoSortOrder)
pushImage("gif", media.gifUrl || "", isSelected, videoSortOrder)
pushImage("video", media.videoUrl || "", isSelected, videoSortOrder)
})
return images
}
const handleSaveForm = async (type: StatusType) => { const handleSaveForm = async (type: StatusType) => {
const paramsList = [] const paramsList = selectList.value.map((item: ListingItem) => {
selectList.value.forEach((item: ListingItem) => { return {
const params = {
id: itemId.value, id: itemId.value,
title: item.productName, title: item.productName,
description: item.desc, description: item.desc,
price: item.price, price: item.price,
status: type === "draft" ? 0 : 1, status: type === "draft" ? 0 : 1,
images: [], images: buildListingImages(item),
designFor: (item.gender || "FEMALE").toLowerCase(), designFor: getListingDesignFor(item.gender),
productCategory: item.category productCategory: item.category
} }
;["sketch", "cover"].forEach((el) => {
params.images.push({
category: el,
imageUrl: item[el],
isSelected: 1
})
})
if (item.mainProductImage) {
params.images.push({
category: "main_product",
imageUrl: item.mainProductImage,
isSeleted: 1
})
}
item.prodImageList.forEach((item) => {
params.images.push({
category: "product",
imageUrl: item.url,
isSelected: Number(!!item.selected)
})
})
item.sketchList.forEach((item) => {
params.images.push({
category: "apparel",
imageUrl: item.url,
isSelected: 1
})
})
paramsList.push(params)
}) })
await fetchUpdateListing(paramsList) await fetchUpdateListing(paramsList)
} }

View File

@@ -7,6 +7,18 @@ 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 CoverSourceType = "sketch" | "mainProductImage"
export type ListingImageCategory =
| "cover"
| "cover_from"
| "main_product"
| "mainProductImage"
| "product"
| "sketch"
| "apparel"
| "firstFrame"
| "gif"
| "video"
export type ProductMediaItem = { export type ProductMediaItem = {
url: string url: string
@@ -29,15 +41,18 @@ export type ListingItem = {
desc: string desc: string
gender: string gender: string
category: string[] | null category: string[] | null
coverFrom: CoverSourceType
firstSelectedIndex: number | null firstSelectedIndex: number | null
prodImageList: ProductMediaItem[] prodImageList: ProductMediaItem[]
sketchList: Array<{ url: string | null }> sketchList: Array<{ url: string | null }>
} }
export type ListingDetailImage = { export type ListingDetailImage = {
category?: string | null category?: ListingImageCategory | string | null
imageUrl?: string | null imageUrl?: string | null
isSelected?: boolean | number | string | null isSelected?: boolean | number | string | null
isSeleted?: boolean | number | string | null
selected?: boolean | number | string | null
sortOrder?: number | null sortOrder?: number | null
} }