修改图生3D,支持多图上传
This commit is contained in:
parent
a8b9f658a0
commit
7f8e28a4af
@ -62,13 +62,44 @@ export class AI3DService {
|
||||
|
||||
// 3. 提交到 AI 服务
|
||||
try {
|
||||
// 构建生成选项
|
||||
const options: any = {
|
||||
generateType: dto.generateType,
|
||||
faceCount: dto.faceCount,
|
||||
};
|
||||
|
||||
// 处理多视图图片(图生3D支持)
|
||||
if (dto.inputType === 'image' && dto.multiViewImages) {
|
||||
const viewKeyMap: Record<string, string> = {
|
||||
left: 'left',
|
||||
right: 'right',
|
||||
back: 'back',
|
||||
top: 'top',
|
||||
bottom: 'bottom',
|
||||
left45: 'left_front',
|
||||
right45: 'right_front',
|
||||
};
|
||||
|
||||
const multiViewImages: { viewType: string; imageUrl: string }[] = [];
|
||||
for (const [key, url] of Object.entries(dto.multiViewImages)) {
|
||||
if (url && key !== 'front' && viewKeyMap[key]) {
|
||||
multiViewImages.push({
|
||||
viewType: viewKeyMap[key],
|
||||
imageUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (multiViewImages.length > 0) {
|
||||
options.multiViewImages = multiViewImages;
|
||||
this.logger.log(`多视图模式: ${multiViewImages.length} 张额外视图`);
|
||||
}
|
||||
}
|
||||
|
||||
const externalTaskId = await this.ai3dProvider.submitTask(
|
||||
dto.inputType,
|
||||
dto.inputContent,
|
||||
{
|
||||
generateType: dto.generateType,
|
||||
faceCount: dto.faceCount,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
// 4. 更新状态为处理中
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
IsObject,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
@ -18,6 +19,21 @@ import {
|
||||
*/
|
||||
export type GenerateType = 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch';
|
||||
|
||||
/**
|
||||
* 多视图图片类型
|
||||
* 支持的视角:left, right, back, top, bottom, left_front (左前45°), right_front (右前45°)
|
||||
*/
|
||||
export type MultiViewImages = {
|
||||
front?: string; // 正图(作为主图)
|
||||
left?: string; // 左视图
|
||||
right?: string; // 右视图
|
||||
back?: string; // 后视图
|
||||
top?: string; // 顶视图
|
||||
bottom?: string; // 底视图
|
||||
left45?: string; // 左前45°视图 -> left_front
|
||||
right45?: string; // 右前45°视图 -> right_front
|
||||
};
|
||||
|
||||
export class CreateTaskDto {
|
||||
@IsString()
|
||||
@IsIn(['text', 'image'], { message: '输入类型必须是 text 或 image' })
|
||||
@ -40,4 +56,8 @@ export class CreateTaskDto {
|
||||
@Min(10000, { message: '模型面数最小为10000' })
|
||||
@Max(1500000, { message: '模型面数最大为1500000' })
|
||||
faceCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: '多视图图片必须是对象' })
|
||||
multiViewImages?: MultiViewImages;
|
||||
}
|
||||
|
||||
@ -11,6 +11,14 @@ export interface AI3DGenerateResult {
|
||||
errorMessage?: string; // 错误信息
|
||||
}
|
||||
|
||||
/**
|
||||
* 多视图图片
|
||||
*/
|
||||
export interface MultiViewImage {
|
||||
viewType: string; // left, right, back, top, bottom, left_front, right_front
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型生成配置选项
|
||||
*/
|
||||
@ -19,6 +27,8 @@ export interface AI3DGenerateOptions {
|
||||
generateType?: 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch';
|
||||
/** 模型面数:10000-1500000,默认500000 */
|
||||
faceCount?: number;
|
||||
/** 多视图图片(图生3D支持) */
|
||||
multiViewImages?: MultiViewImage[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -85,11 +85,23 @@ export class HunyuanAI3DProvider implements AI3DProvider {
|
||||
payload.GenerateType = options.generateType;
|
||||
}
|
||||
|
||||
// 多视图图片支持
|
||||
if (options?.multiViewImages && options.multiViewImages.length > 0) {
|
||||
payload.MultiViewImages = options.multiViewImages.map((img) => ({
|
||||
ViewType: img.viewType,
|
||||
ViewImageUrl: img.imageUrl,
|
||||
}));
|
||||
this.logger.log(
|
||||
`提交图生3D任务(多视图): ${options.multiViewImages.length} 张图片 ` +
|
||||
`[类型: ${options?.generateType || 'Normal'}]`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`提交图生3D任务: ${inputContent.substring(0, 50)}... ` +
|
||||
`[类型: ${options?.generateType || 'Normal'}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成签名和请求头
|
||||
const headers = TencentCloudSigner.sign({
|
||||
|
||||
@ -111,13 +111,71 @@
|
||||
|
||||
<!-- 图生3D上传 -->
|
||||
<div v-else class="image-input-section">
|
||||
<div class="input-label">
|
||||
<PictureOutlined class="label-icon" />
|
||||
<span>参考图片</span>
|
||||
<!-- 单张/多张图片切换 -->
|
||||
<div class="image-mode-switch">
|
||||
<span
|
||||
class="mode-link"
|
||||
:class="{ active: imageMode === 'single' }"
|
||||
@click="imageMode = 'single'"
|
||||
>单张图片</span
|
||||
>
|
||||
<span
|
||||
class="mode-link"
|
||||
:class="{ active: imageMode === 'multiple' }"
|
||||
@click="imageMode = 'multiple'"
|
||||
>多张图片</span
|
||||
>
|
||||
|
||||
<!-- 图片上传建议 Popover -->
|
||||
<a-popover
|
||||
placement="rightTop"
|
||||
trigger="click"
|
||||
:overlayStyle="{ width: '320px' }"
|
||||
overlayClassName="image-tips-popover"
|
||||
>
|
||||
<template #content>
|
||||
<div class="image-tips-content">
|
||||
<div class="tips-desc">
|
||||
支持png/jpg/jpeg/webp格式,图片大小最大不超过10M,分辨率最低要求128x128
|
||||
</div>
|
||||
<div class="input-hint">
|
||||
上传参考图片,AI 将智能识别并生成 3D 模型
|
||||
<div class="tips-example">
|
||||
<img
|
||||
src="https://competition-ms-1325825530.cos.ap-guangzhou.myqcloud.com/img/3d-example.png"
|
||||
alt="示例图片"
|
||||
/>
|
||||
<div class="tips-list">
|
||||
<div class="tip-item">
|
||||
<CheckCircleFilled class="tip-icon" />
|
||||
<span>背景简单(尽量纯色)</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<CheckCircleFilled class="tip-icon" />
|
||||
<span>不包含文字</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<CheckCircleFilled class="tip-icon" />
|
||||
<span>单主体</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<CheckCircleFilled class="tip-icon" />
|
||||
<span>主体不要过小</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="tips-title">图片上传建议</span>
|
||||
</template>
|
||||
<span class="image-tips-link">
|
||||
<QuestionCircleOutlined />
|
||||
<span>图片上传建议</span>
|
||||
</span>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<!-- 单张图片模式 -->
|
||||
<template v-if="imageMode === 'single'">
|
||||
<a-upload-dragger
|
||||
:file-list="[]"
|
||||
:before-upload="handleBeforeUpload"
|
||||
@ -138,6 +196,39 @@
|
||||
<p class="upload-hint">支持 JPG、PNG 格式,最大 10MB</p>
|
||||
</template>
|
||||
</a-upload-dragger>
|
||||
</template>
|
||||
|
||||
<!-- 多张图片模式 -->
|
||||
<template v-else>
|
||||
<div class="multi-image-container">
|
||||
<!-- 缩略图展示条 -->
|
||||
<div class="thumbnail-bar" @click="openMultiViewModal">
|
||||
<div v-if="hasMultiImages" class="thumbnail-list">
|
||||
<div
|
||||
v-for="(img, key) in multiImages"
|
||||
:key="key"
|
||||
class="thumbnail-item"
|
||||
@click.stop
|
||||
>
|
||||
<img :src="img" :alt="getViewName(key)" />
|
||||
<div
|
||||
class="thumbnail-delete"
|
||||
@click="removeMultiImage(key)"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thumbnail-add">
|
||||
<PlusOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="upload-tip">
|
||||
<InfoCircleOutlined />
|
||||
<span>图片上传建议:上传多角度图片可提升生成质量</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 模型类型选择 -->
|
||||
<div class="model-settings">
|
||||
@ -154,6 +245,196 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 多视图上传弹框 -->
|
||||
<a-modal
|
||||
v-model:open="multiViewModalVisible"
|
||||
title="添加多视图"
|
||||
:width="600"
|
||||
:footer="null"
|
||||
class="multi-view-modal"
|
||||
@cancel="multiViewModalVisible = false"
|
||||
>
|
||||
<div class="multi-view-grid">
|
||||
<!-- 顶图 -->
|
||||
<div class="view-row top-row">
|
||||
<div class="view-item" @click="triggerUpload('top')">
|
||||
<template v-if="multiImages.top">
|
||||
<img :src="multiImages.top" alt="顶图" />
|
||||
<div
|
||||
class="view-delete"
|
||||
@click.stop="removeMultiImage('top')"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="view-placeholder">
|
||||
<PictureOutlined />
|
||||
<span>上传顶图</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左45° 正图 右45° -->
|
||||
<div class="view-row middle-row">
|
||||
<div class="view-item" @click="triggerUpload('left45')">
|
||||
<template v-if="multiImages.left45">
|
||||
<img :src="multiImages.left45" alt="左45°图" />
|
||||
<div
|
||||
class="view-delete"
|
||||
@click.stop="removeMultiImage('left45')"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="view-placeholder">
|
||||
<PictureOutlined />
|
||||
<span>上传左45°图</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="view-item required" @click="triggerUpload('front')">
|
||||
<template v-if="multiImages.front">
|
||||
<img :src="multiImages.front" alt="正图" />
|
||||
<div
|
||||
class="view-delete"
|
||||
@click.stop="removeMultiImage('front')"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="view-placeholder">
|
||||
<PictureOutlined />
|
||||
<span>上传正图 <span class="required-mark">*</span></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="view-item" @click="triggerUpload('right45')">
|
||||
<template v-if="multiImages.right45">
|
||||
<img :src="multiImages.right45" alt="右45°图" />
|
||||
<div
|
||||
class="view-delete"
|
||||
@click.stop="removeMultiImage('right45')"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="view-placeholder">
|
||||
<PictureOutlined />
|
||||
<span>上传右45°图</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左图 中心预览 右图 -->
|
||||
<div class="view-row center-row">
|
||||
<div class="view-item" @click="triggerUpload('left')">
|
||||
<template v-if="multiImages.left">
|
||||
<img :src="multiImages.left" alt="左图" />
|
||||
<div
|
||||
class="view-delete"
|
||||
@click.stop="removeMultiImage('left')"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="view-placeholder">
|
||||
<PictureOutlined />
|
||||
<span>上传左图</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="view-center">
|
||||
<div class="center-preview">
|
||||
<img
|
||||
v-if="multiImages.front"
|
||||
:src="multiImages.front"
|
||||
alt="预览"
|
||||
/>
|
||||
<div v-else class="center-placeholder">
|
||||
<AppstoreOutlined />
|
||||
<span>3D 预览</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="view-item" @click="triggerUpload('right')">
|
||||
<template v-if="multiImages.right">
|
||||
<img :src="multiImages.right" alt="右图" />
|
||||
<div
|
||||
class="view-delete"
|
||||
@click.stop="removeMultiImage('right')"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="view-placeholder">
|
||||
<PictureOutlined />
|
||||
<span>上传右图</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背图 -->
|
||||
<div class="view-row bottom-row">
|
||||
<div class="view-item" @click="triggerUpload('back')">
|
||||
<template v-if="multiImages.back">
|
||||
<img :src="multiImages.back" alt="背图" />
|
||||
<div
|
||||
class="view-delete"
|
||||
@click.stop="removeMultiImage('back')"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="view-placeholder">
|
||||
<PictureOutlined />
|
||||
<span>上传背图</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底图 -->
|
||||
<div class="view-row bottom-row">
|
||||
<div class="view-item" @click="triggerUpload('bottom')">
|
||||
<template v-if="multiImages.bottom">
|
||||
<img :src="multiImages.bottom" alt="底图" />
|
||||
<div
|
||||
class="view-delete"
|
||||
@click.stop="removeMultiImage('bottom')"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="view-placeholder">
|
||||
<PictureOutlined />
|
||||
<span>上传底图</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件输入 -->
|
||||
<input
|
||||
ref="multiImageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleMultiImageUpload"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
@ -375,6 +656,11 @@ import {
|
||||
FolderOutlined,
|
||||
SyncOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
InfoCircleOutlined,
|
||||
AppstoreOutlined,
|
||||
QuestionCircleOutlined,
|
||||
CheckCircleFilled,
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
createAI3DTask,
|
||||
@ -428,6 +714,36 @@ const imageUrl = ref("")
|
||||
const generating = ref(false)
|
||||
const currentSampleIndex = ref(0)
|
||||
|
||||
// 图片模式:单张/多张
|
||||
const imageMode = ref<"single" | "multiple">("single")
|
||||
|
||||
// 多图上传相关
|
||||
type MultiImageKey =
|
||||
| "top"
|
||||
| "left45"
|
||||
| "front"
|
||||
| "right45"
|
||||
| "left"
|
||||
| "right"
|
||||
| "back"
|
||||
| "bottom"
|
||||
const multiImages = ref<Partial<Record<MultiImageKey, string>>>({})
|
||||
const multiViewModalVisible = ref(false)
|
||||
const multiImageInput = ref<HTMLInputElement | null>(null)
|
||||
const currentUploadKey = ref<MultiImageKey | null>(null)
|
||||
|
||||
// 视图名称映射
|
||||
const viewNameMap: Record<MultiImageKey, string> = {
|
||||
top: "顶图",
|
||||
left45: "左45°图",
|
||||
front: "正图",
|
||||
right45: "右45°图",
|
||||
left: "左图",
|
||||
right: "右图",
|
||||
back: "背图",
|
||||
bottom: "底图",
|
||||
}
|
||||
|
||||
// 模型设置
|
||||
const generateType = ref<"Normal" | "LowPoly" | "Geometry" | "Sketch">("Normal")
|
||||
const faceCount = ref(500000)
|
||||
@ -452,6 +768,11 @@ let resizeObserver: ResizeObserver | null = null
|
||||
// 计算属性
|
||||
const currentSamples = computed(() => samplePrompts[currentSampleIndex.value])
|
||||
|
||||
// 是否有多图上传
|
||||
const hasMultiImages = computed(() => {
|
||||
return Object.keys(multiImages.value).length > 0
|
||||
})
|
||||
|
||||
const canGenerate = computed(() => {
|
||||
// 首先检查用户是否有创建权限
|
||||
if (!authStore.hasPermission("ai-3d:create")) {
|
||||
@ -462,7 +783,13 @@ const canGenerate = computed(() => {
|
||||
if (inputType.value === "text") {
|
||||
return textContent.value.trim().length > 0
|
||||
} else {
|
||||
// 图生3D模式
|
||||
if (imageMode.value === "single") {
|
||||
return imageUrl.value.length > 0
|
||||
} else {
|
||||
// 多图模式:至少需要正图
|
||||
return !!multiImages.value.front
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -537,14 +864,76 @@ const handleBeforeUpload = async (file: File) => {
|
||||
return false // 阻止默认上传行为
|
||||
}
|
||||
|
||||
// 多图上传相关方法
|
||||
const openMultiViewModal = () => {
|
||||
multiViewModalVisible.value = true
|
||||
}
|
||||
|
||||
const getViewName = (key: string) => {
|
||||
return viewNameMap[key as MultiImageKey] || key
|
||||
}
|
||||
|
||||
const triggerUpload = (key: MultiImageKey) => {
|
||||
currentUploadKey.value = key
|
||||
multiImageInput.value?.click()
|
||||
}
|
||||
|
||||
const handleMultiImageUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file || !currentUploadKey.value) return
|
||||
|
||||
const isImage = file.type.startsWith("image/")
|
||||
if (!isImage) {
|
||||
message.error("只能上传图片文件")
|
||||
input.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
if (!isLt10M) {
|
||||
message.error("图片大小不能超过 10MB")
|
||||
input.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result: any = await uploadFile(file)
|
||||
const url = result.data?.url || result.url
|
||||
if (url) {
|
||||
multiImages.value[currentUploadKey.value] = url
|
||||
message.success(`${getViewName(currentUploadKey.value)}上传成功`)
|
||||
} else {
|
||||
message.error("上传失败:无法获取图片地址")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("上传失败:", error)
|
||||
message.error("图片上传失败")
|
||||
}
|
||||
|
||||
input.value = ""
|
||||
currentUploadKey.value = null
|
||||
}
|
||||
|
||||
const removeMultiImage = (key: string) => {
|
||||
delete multiImages.value[key as MultiImageKey]
|
||||
}
|
||||
|
||||
// 生成3D模型
|
||||
const handleGenerate = async () => {
|
||||
if (!canGenerate.value) return
|
||||
|
||||
generating.value = true
|
||||
try {
|
||||
const content =
|
||||
inputType.value === "text" ? textContent.value.trim() : imageUrl.value
|
||||
let content: string
|
||||
if (inputType.value === "text") {
|
||||
content = textContent.value.trim()
|
||||
} else if (imageMode.value === "single") {
|
||||
content = imageUrl.value
|
||||
} else {
|
||||
// 多张图片模式:使用正图作为主要输入
|
||||
content = multiImages.value.front || ""
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
const params: any = {
|
||||
@ -558,13 +947,28 @@ const handleGenerate = async () => {
|
||||
params.faceCount = faceCount.value
|
||||
}
|
||||
|
||||
// 多张图片模式:添加额外的视图图片
|
||||
if (inputType.value === "image" && imageMode.value === "multiple") {
|
||||
const multiViewImages: Record<string, string> = {}
|
||||
Object.entries(multiImages.value).forEach(([key, url]) => {
|
||||
if (url) {
|
||||
multiViewImages[key] = url
|
||||
}
|
||||
})
|
||||
if (Object.keys(multiViewImages).length > 0) {
|
||||
params.multiViewImages = multiViewImages
|
||||
}
|
||||
}
|
||||
|
||||
const task = await createAI3DTask(params)
|
||||
|
||||
// 清空输入
|
||||
if (inputType.value === "text") {
|
||||
textContent.value = ""
|
||||
} else {
|
||||
} else if (imageMode.value === "single") {
|
||||
imageUrl.value = ""
|
||||
} else {
|
||||
multiImages.value = {}
|
||||
}
|
||||
|
||||
// 跳转到生成页面
|
||||
@ -604,7 +1008,7 @@ const startPolling = () => {
|
||||
|
||||
pollingTimer = window.setInterval(async () => {
|
||||
const processingTasks = historyList.value.filter(
|
||||
(t) => t.status === "pending" || t.status === "processing"
|
||||
(t) => t.status === "pending" || t.status === "processing",
|
||||
)
|
||||
|
||||
if (processingTasks.length === 0) {
|
||||
@ -774,7 +1178,7 @@ onMounted(async () => {
|
||||
|
||||
// 检查是否有处理中的任务,有则开启轮询
|
||||
const hasProcessing = historyList.value.some(
|
||||
(t) => t.status === "pending" || t.status === "processing"
|
||||
(t) => t.status === "pending" || t.status === "processing",
|
||||
)
|
||||
if (hasProcessing) {
|
||||
startPolling()
|
||||
@ -1249,6 +1653,392 @@ $gradient-secondary: linear-gradient(135deg, $primary-light 0%, $primary 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 单张/多张图片切换
|
||||
.image-mode-switch {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
|
||||
.mode-link {
|
||||
font-size: 14px;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: $primary;
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-tips-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图片上传建议弹框样式
|
||||
.image-tips-content {
|
||||
.tips-desc {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tips-example {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tips-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: $text;
|
||||
|
||||
.tip-icon {
|
||||
color: $success;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tips-title {
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
// 多图容器
|
||||
.multi-image-container {
|
||||
.thumbnail-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: rgba($surface-light, 0.6);
|
||||
border: 2px dashed rgba($primary, 0.3);
|
||||
border-radius: 12px;
|
||||
min-height: 72px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
overflow-x: auto;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-light;
|
||||
background: rgba($surface-light, 0.8);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba($primary, 0.05);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba($primary, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thumbnail-item {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail-delete {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&:hover .thumbnail-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail-add {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 2px dashed rgba($primary, 0.4);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $primary;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
background: rgba($primary, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
|
||||
.anticon {
|
||||
color: $primary-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 多视图弹框
|
||||
.multi-view-modal {
|
||||
:deep(.ant-modal-content) {
|
||||
background: $surface;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-header) {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
padding: 16px 24px;
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-close) {
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.multi-view-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.view-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
&.middle-row,
|
||||
&.center-row {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.view-item {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 2px dashed rgba($primary, 0.3);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba($surface-light, 0.5);
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
background: rgba($primary, 0.05);
|
||||
}
|
||||
|
||||
&.required {
|
||||
border-color: rgba($primary, 0.5);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.view-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: $text-muted;
|
||||
|
||||
.anticon {
|
||||
font-size: 24px;
|
||||
color: $primary-light;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.view-delete {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 77, 79, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .view-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.view-center {
|
||||
width: 120px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.center-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid rgba($primary, 0.2);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba($primary, 0.05) 0%,
|
||||
rgba($primary, 0.1) 100%
|
||||
);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.center-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: $text-muted;
|
||||
|
||||
.anticon {
|
||||
font-size: 28px;
|
||||
color: $primary-light;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-upload {
|
||||
:deep(.ant-upload-drag) {
|
||||
background: rgba($surface-light, 0.6) !important;
|
||||
@ -1470,7 +2260,11 @@ $gradient-secondary: linear-gradient(135deg, $primary-light 0%, $primary 100%);
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
}
|
||||
&.gradient-2 {
|
||||
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 10%) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
$primary-dark 0%,
|
||||
darken($primary-dark, 10%) 100%
|
||||
);
|
||||
}
|
||||
&.gradient-3 {
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
@ -1960,3 +2754,67 @@ $gradient-secondary: linear-gradient(135deg, $primary-light 0%, $primary 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 全局样式 - 用于 Popover 等渲染到 body 的组件 -->
|
||||
<style lang="scss">
|
||||
// 图片上传建议 Popover 全局样式
|
||||
.image-tips-popover {
|
||||
.ant-popover-inner {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.ant-popover-title {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.image-tips-content {
|
||||
.tips-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tips-example {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tips-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
.tip-icon {
|
||||
color: #52c41a;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user