修改样式

This commit is contained in:
zhangxiaohua 2026-01-14 14:48:35 +08:00
parent d9abd6939c
commit 8210bf0ad3
7 changed files with 288 additions and 48 deletions

View File

@ -62,6 +62,10 @@ export class AI3DService {
const externalTaskId = await this.ai3dProvider.submitTask(
dto.inputType,
dto.inputContent,
{
generateType: dto.generateType,
faceCount: dto.faceCount,
},
);
// 4. 更新状态为处理中

View File

@ -1,4 +1,22 @@
import { IsString, IsIn, IsNotEmpty, MaxLength } from 'class-validator';
import {
IsString,
IsIn,
IsNotEmpty,
MaxLength,
IsOptional,
IsInt,
Min,
Max,
} from 'class-validator';
/**
*
* Normal: 带纹理
* LowPoly: 低多边形
* Geometry: 白模
* Sketch: 草图
*/
export type GenerateType = 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch';
export class CreateTaskDto {
@IsString()
@ -9,4 +27,17 @@ export class CreateTaskDto {
@IsNotEmpty({ message: '输入内容不能为空' })
@MaxLength(2000, { message: '输入内容最多2000个字符' })
inputContent: string;
@IsOptional()
@IsString()
@IsIn(['Normal', 'LowPoly', 'Geometry', 'Sketch'], {
message: '模型类型必须是 Normal、LowPoly、Geometry 或 Sketch',
})
generateType?: GenerateType;
@IsOptional()
@IsInt({ message: '模型面数必须是整数' })
@Min(10000, { message: '模型面数最小为10000' })
@Max(1500000, { message: '模型面数最大为1500000' })
faceCount?: number;
}

View File

@ -11,6 +11,16 @@ export interface AI3DGenerateResult {
errorMessage?: string; // 错误信息
}
/**
*
*/
export interface AI3DGenerateOptions {
/** 模型生成类型Normal-带纹理, LowPoly-低多边形, Geometry-白模, Sketch-草图 */
generateType?: 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch';
/** 模型面数10000-1500000默认500000 */
faceCount?: number;
}
/**
* AI 3D
* MockMeshy
@ -20,11 +30,13 @@ export interface AI3DProvider {
*
* @param inputType text | image
* @param inputContent URL
* @param options 3D支持
* @returns ID
*/
submitTask(
inputType: 'text' | 'image',
inputContent: string,
options?: AI3DGenerateOptions,
): Promise<string>;
/**

View File

@ -1,7 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { AI3DProvider, AI3DGenerateResult } from './ai-3d-provider.interface';
import {
AI3DProvider,
AI3DGenerateResult,
AI3DGenerateOptions,
} from './ai-3d-provider.interface';
import { TencentCloudSigner } from '../utils/tencent-cloud-sign';
import { ZipHandler } from '../utils/zip-handler';
@ -43,6 +47,7 @@ export class HunyuanAI3DProvider implements AI3DProvider {
async submitTask(
inputType: 'text' | 'image',
inputContent: string,
options?: AI3DGenerateOptions,
): Promise<string> {
try {
// 构造请求参数
@ -51,7 +56,19 @@ export class HunyuanAI3DProvider implements AI3DProvider {
if (inputType === 'text') {
// 文生3D使用 Prompt
payload.Prompt = inputContent;
this.logger.log(`提交文生3D任务: ${inputContent.substring(0, 50)}...`);
// 文生3D支持额外参数
if (options?.generateType) {
payload.GenerateType = options.generateType;
}
if (options?.faceCount) {
payload.FaceCount = options.faceCount;
}
this.logger.log(
`提交文生3D任务: ${inputContent.substring(0, 50)}... ` +
`[类型: ${options?.generateType || 'Normal'}, 面数: ${options?.faceCount || 500000}]`,
);
} else {
// 图生3D使用 ImageUrl 或 ImageBase64
if (
@ -59,14 +76,20 @@ export class HunyuanAI3DProvider implements AI3DProvider {
inputContent.startsWith('https://')
) {
payload.ImageUrl = inputContent;
this.logger.log(`提交图生3D任务 (URL): ${inputContent}`);
} else {
// 假设是 Base64 编码的图片
payload.ImageBase64 = inputContent;
this.logger.log(
`提交图生3D任务 (Base64): ${inputContent.substring(0, 30)}...`,
);
}
// 图生3D也支持模型类型
if (options?.generateType) {
payload.GenerateType = options.generateType;
}
this.logger.log(
`提交图生3D任务: ${inputContent.substring(0, 50)}... ` +
`[类型: ${options?.generateType || 'Normal'}]`,
);
}
// 生成签名和请求头

View File

@ -40,12 +40,21 @@ export interface AI3DTask {
completeTime?: string;
}
/**
*
*/
export type AI3DGenerateType = "Normal" | "LowPoly" | "Geometry" | "Sketch";
/**
*
*/
export interface CreateAI3DTaskParams {
inputType: AI3DInputType;
inputContent: string;
/** 模型生成类型Normal-带纹理, LowPoly-低多边形, Geometry-白模, Sketch-草图 */
generateType?: AI3DGenerateType;
/** 模型面数10000-1500000默认500000 */
faceCount?: number;
}
/**

View File

@ -567,10 +567,15 @@ $gradient-card: linear-gradient(
// ==========================================
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, 300px);
grid-template-columns: repeat(2, 300px);
gap: 24px;
margin-bottom: 40px;
justify-content: center;
// 1
&:has(.model-card:only-child) {
grid-template-columns: 300px;
}
}
// ==========================================

View File

@ -43,14 +43,17 @@
<div class="input-hint">
用文字描述你的想法AI 将为你生成精美的 3D 模型
</div>
<div class="textarea-wrapper">
<a-textarea
v-model:value="textContent"
placeholder="例如:一只卡通风格的橙色小猫,蓝色的大眼睛,尾巴卷曲..."
:rows="6"
:maxlength="150"
show-count
:rows="5"
:maxlength="1024"
class="text-input"
/>
<span class="char-count">{{ textContent.length }}/1024</span>
</div>
<div class="sample-section">
<div class="sample-header">
<span class="sample-title">灵感推荐</span>
@ -67,6 +70,31 @@
</span>
</div>
</div>
<!-- 模型设置 -->
<div class="model-settings">
<div class="setting-row">
<div class="setting-item">
<span class="setting-label">模型类型</span>
<a-select v-model:value="generateType" class="setting-select">
<a-select-option value="Normal">带纹理</a-select-option>
<a-select-option value="LowPoly">低多边形</a-select-option>
<a-select-option value="Geometry">白模</a-select-option>
<a-select-option value="Sketch">草图</a-select-option>
</a-select>
</div>
<div class="setting-item">
<span class="setting-label">模型面数</span>
<a-select v-model:value="faceCount" class="setting-select">
<a-select-option :value="50000">5万面</a-select-option>
<a-select-option :value="100000">10万面</a-select-option>
<a-select-option :value="300000">30万面</a-select-option>
<a-select-option :value="500000">50万面</a-select-option>
<a-select-option :value="1000000">100万面</a-select-option>
</a-select>
</div>
</div>
</div>
</div>
<!-- 图生3D上传 -->
@ -79,18 +107,40 @@
上传参考图片AI 将智能识别并生成 3D 模型
</div>
<a-upload-dragger
v-model:file-list="imageFileList"
:file-list="[]"
:before-upload="handleBeforeUpload"
:show-upload-list="false"
:max-count="1"
accept="image/*"
class="image-upload"
>
<template v-if="imageUrl">
<img :src="imageUrl" alt="预览" class="upload-preview-image" />
<div class="upload-change-hint">点击更换图片</div>
</template>
<template v-else>
<p class="upload-icon">
<PictureOutlined />
</p>
<p class="upload-text">点击或拖拽图片到此处</p>
<p class="upload-hint">支持 JPGPNG 格式最大 10MB</p>
</template>
</a-upload-dragger>
<!-- 模型类型选择 -->
<div class="model-settings">
<div class="setting-row">
<div class="setting-item full-width">
<span class="setting-label">模型类型</span>
<a-select v-model:value="generateType" class="setting-select">
<a-select-option value="Normal">带纹理</a-select-option>
<a-select-option value="LowPoly">低多边形</a-select-option>
<a-select-option value="Geometry">白模</a-select-option>
<a-select-option value="Sketch">草图</a-select-option>
</a-select>
</div>
</div>
</div>
</div>
</div>
@ -309,7 +359,6 @@ import {
ArrowLeftOutlined,
ClockCircleOutlined,
} from "@ant-design/icons-vue"
import type { UploadFile } from "ant-design-vue"
import {
createAI3DTask,
getAI3DTasks,
@ -358,11 +407,14 @@ const samplePrompts = [
//
const inputType = ref<"text" | "image">("text")
const textContent = ref("")
const imageFileList = ref<UploadFile[]>()
const imageUrl = ref("")
const generating = ref(false)
const currentSampleIndex = ref(0)
//
const generateType = ref<"Normal" | "LowPoly" | "Geometry" | "Sketch">("Normal")
const faceCount = ref(500000)
//
const historyList = ref<AI3DTask[]>([])
const historyLoading = ref(false)
@ -448,12 +500,18 @@ const handleBeforeUpload = async (file: File) => {
//
try {
const result = await uploadFile(file)
imageUrl.value = result.url
const result: any = await uploadFile(file)
//
const url = result.data?.url || result.url
if (url) {
imageUrl.value = url
message.success("图片上传成功")
} else {
message.error("上传失败:无法获取图片地址")
}
} catch (error) {
console.error("上传失败:", error)
message.error("图片上传失败")
return false
}
return false //
@ -468,16 +526,24 @@ const handleGenerate = async () => {
const content =
inputType.value === "text" ? textContent.value.trim() : imageUrl.value
const task = await createAI3DTask({
//
const params: any = {
inputType: inputType.value,
inputContent: content,
})
generateType: generateType.value,
}
// 3D
if (inputType.value === "text") {
params.faceCount = faceCount.value
}
const task = await createAI3DTask(params)
//
if (inputType.value === "text") {
textContent.value = ""
} else {
imageFileList.value = []
imageUrl.value = ""
}
@ -943,6 +1009,19 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
line-height: 1.6;
}
.textarea-wrapper {
position: relative;
.char-count {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 12px;
color: $text-muted;
pointer-events: none;
}
}
.text-input {
background: rgba($surface-light, 0.6) !important;
border: 2px solid rgba($primary, 0.15) !important;
@ -950,6 +1029,7 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
color: $text;
resize: none;
transition: all 0.3s;
padding-bottom: 28px !important;
&::placeholder {
color: rgba($text-muted, 0.5);
@ -964,14 +1044,67 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
box-shadow: 0 0 0 4px rgba($primary, 0.1) !important;
background: rgba($surface-light, 0.8) !important;
}
}
:deep(.ant-input-data-count) {
color: $text-muted;
//
.model-settings {
margin-top: 16px;
padding: 16px;
background: rgba($primary, 0.05);
border: 1px solid rgba($primary, 0.15);
border-radius: 12px;
.setting-row {
display: flex;
gap: 12px;
}
.setting-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
&.full-width {
flex: none;
width: 100%;
}
}
.setting-label {
font-size: 12px;
font-weight: 500;
color: $text-secondary;
}
.setting-select {
width: 100%;
:deep(.ant-select-selector) {
background: rgba($surface, 0.8) !important;
border: 1px solid rgba($primary, 0.2) !important;
border-radius: 8px !important;
height: 36px !important;
.ant-select-selection-item {
line-height: 34px !important;
font-size: 13px;
}
}
&:hover :deep(.ant-select-selector) {
border-color: rgba($primary, 0.4) !important;
}
&.ant-select-focused :deep(.ant-select-selector) {
border-color: $primary !important;
box-shadow: 0 0 0 2px rgba($primary, 0.1) !important;
}
}
}
.sample-section {
margin-top: 20px;
margin-top: 16px;
}
.sample-header {
@ -1028,28 +1161,51 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
border: 2px dashed rgba($primary, 0.3) !important;
border-radius: 12px;
transition: all 0.3s;
overflow: hidden;
&:hover {
border-color: $primary-light !important;
background: rgba($surface-light, 0.8) !important;
}
}
.upload-icon {
:deep(.ant-upload) {
padding: 24px !important;
}
:deep(.upload-icon) {
color: $primary-light;
font-size: 56px;
margin-bottom: 12px;
}
.upload-text {
:deep(.upload-text) {
color: $text;
font-size: 15px;
margin-bottom: 8px;
}
.upload-hint {
:deep(.upload-hint) {
color: $text-muted;
font-size: 12px;
}
:deep(.upload-preview-image) {
max-width: 100%;
max-height: 180px;
object-fit: contain;
border-radius: 8px;
}
:deep(.upload-change-hint) {
margin-top: 12px;
font-size: 13px;
color: $text-muted;
transition: color 0.3s;
}
:deep(.ant-upload-drag:hover .upload-change-hint) {
color: $primary-light;
}
}