<template>
<div class="container">
<!-- 搜索区域 -->
<el-card class="search-box">
<el-form :inline="true" :model="searchData" style="display: flex; align-items: center; gap: 10px">
<el-form-item label="名称" style="margin-bottom: 0">
<el-input
v-model="searchData.name"
@blur="searchData.name = searchData.name.trim()"
placeholder="请输入名称"
clearable
class="wide-input"
/>
</el-form-item>
<el-form-item label="区域" style="margin-bottom: 0">
<el-input
v-model="searchData.region"
@blur="searchData.region = searchData.region.trim()"
placeholder="请输入区域"
clearable
class="wide-input"
/>
</el-form-item>
<el-form-item label="工厂" style="margin-bottom: 0">
<el-select
v-model="searchData.factoryCode"
placeholder="请选择工厂"
clearable
class="wide-select"
>
<el-option
v-for="factoryCode in factoryCodes"
:key="factoryCode.value"
:label="factoryCode.label"
:value="factoryCode.value"
/>
</el-select>
</el-form-item>
<el-form-item label='格式' style="margin-bottom: 0">
<el-select
v-model="searchData.fileFormat"
placeholder="请选择格式"
clearable
class="wide-select"
>
<el-option
v-for="fileFormat in fileFormats"
:key="fileFormat.value"
:label="fileFormat.label"
:value="fileFormat.value"
/>
</el-select>
</el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button type="primary" @click="handleAdd">新增</el-button>
<el-button type="danger" @click="handleBatchDelete" :disabled="selectedRows.length === 0">
批量删除
</el-button>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card >
<el-table :data="tableData" v-loading="loading" border style="width: 100%" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="uuid" label="序号" min-width="120" align="center" />
<el-table-column prop="modelPreviewUrl" label="缩略图" width="150" align="center">
<template #default="{row}">
<el-image v-if="row.modelPreviewUrl"
:src="row.modelPreviewUrl"
:preview-src-list="[row.modelPreviewUrl]"
:preview-teleported="true"
fit="cover"
/>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="100" align="center" />
<el-table-column prop="region" label="区域" min-width="100" align="center" />
<el-table-column prop="factoryCode" label="工厂" min-width="100" align="center" />
<el-table-column prop="fileFormat" label="格式" min-width="100" align="center" />
<el-table-column prop="creatTime" label="创建时间" min-width="100" align="center">
<template #default="{row}">
{{ dayjs(row.creatTime).format('YYYY-MM-DD HH:mm:ss') }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="150" align="center">
<template #default="{ row }">
<el-button
v-if="row.modelUrl"
type="primary"
link
size="small"
@click="openModelPreview(row.modelUrl)"
>
预览
</el-button>
<el-button size="small" link @click="handleEdit(row)">编辑</el-button>
<el-button size="small" link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="currentPage"
:page-sizes="[10, 20, 50]"
v-model:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="fetchData"
@current-change="fetchData"
style="margin-top: 20px"
/>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="formDialogVisible" width="600px">
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
<el-form-item label="文件" prop="modelData">
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="handleModelChange"
accept=".glb"
drag
style="width: 45%;"
>
<div v-if="uploadedFileInfo.name" class="upload-success-info">
<!-- 左侧:状态图标 + 文件信息 -->
<div class="upload-content-left">
<el-icon color="#67c23a">
<CircleCheckFilled />
</el-icon>
<div class="upload-text-group">
<p class="upload-status">上传成功</p>
<div class="file-details">
<el-icon class="file-icon">
<Document />
</el-icon>
<span class="file-name">{{ uploadedFileInfo.name }}</span>
<small class="file-size">({{ uploadedFileInfo.size }} MB)</small>
</div>
</div>
</div>
<!-- 右侧:删除按钮 -->
<el-button size="small" type="danger" @click="removeFile" circle plain class="delete-btn">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div v-else>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">拖拽或 点击<em>上传</em> .glb 模型</div>
</div>
</el-upload>
</el-form-item>
<el-form-item label="缩略图" prop="modelPreviewData">
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="handleThumbnailUpload"
accept="image/*"
drag
style="width: 45%;"
>
<div v-if="formData.modelPreviewData" class="thumbnail-preview-box">
<img :src="formData.modelPreviewData" alt="缩略图" class="thumbnail-image" />
</div>
<div v-else>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">拖拽或 点击<em>上传</em> 缩略图</div>
</div>
</el-upload>
<el-button
v-if="formData.modelData"
type="primary"
size="small"
style="margin-top: 10px; width: 300px;"
@click="openModelPreview(currentModelUrl)"
>
通过预览截图
</el-button>
</el-form-item>
<el-form-item label="名称" prop="name" class="custom-input">
<el-input v-model="formData.name" />
</el-form-item>
<el-form-item label="区域" prop="region" class="custom-input">
<el-input v-model="formData.region" />
</el-form-item>
<el-form-item label="工厂" prop="factoryCode">
<el-select v-model="formData.factoryCode" class="wide-select">
<el-option
v-for="item in factoryCodes"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="格式" prop="fileFormat">
<el-select v-model="formData.fileFormat" class="wide-select">
<el-option
v-for="item in fileFormats"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确认</el-button>
</template>
</el-dialog>
<!-- 模型预览对话框(含截图功能) -->
<el-dialog
v-model="previewDialogVisible"
title="模型预览"
width="50%"
:close-on-click-modal="false"
append-to-body
destroy-on-close="false"
>
<ModelViewer v-show="previewDialogVisible" ref="modelViewerRef" :model-url="currentModelUrl" />
<template #footer>
<div class="dialog-footer">
<el-button @click="previewDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="downloadModel">下载模型</el-button>
<el-button type="danger" @click="screenshotModel" >截 图</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang = "ts" setup name = "modelConfig">
import { ref, reactive, onMounted, nextTick,computed } from 'vue';
import { ElMessage, ElMessageBox, ElForm } from 'element-plus';
import { UploadFilled } from '@element-plus/icons-vue'
import dayjs from 'dayjs';
import ModelViewer from '@/components/ModelViewer.vue'
import useDataModel from "../hooks/useDataModel";
import { useModelStore } from "../stores/modelData";
import type { ReturnDataModelItem,ReturnDataModel } from '../common/modelData.dto'
const formRef = ref<InstanceType<typeof ElForm>>();
const { getModelList,
addModel,
updateModel,
deleteModel,
deleteAllModel } = useDataModel();
const modelStore = useModelStore();
const searchData = reactive({
name: '',
region: '',
factoryCode: '',
fileFormat: ''
})
const tableData = ref<ReturnDataModelItem[]>([])
const selectedRows = ref<ReturnDataModelItem[]>([])
const loading = ref(false);
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const formDialogVisible = ref(false) // 控制新增/编辑弹窗
const previewDialogVisible = ref(false) // 控制 3D 预览弹窗
const currentModelUrl = ref('')
const isCapturingFromPreview = ref(false) // 防止截图时干扰上传 UI
const modelViewerRef = ref(null)
const uploadedFileInfo = ref({
name:'',
size:''
}) // 存储文件名和大小
const dialogTitle = ref('')
const formData = reactive({
modelData: '',
modelPreviewData:'',
name: '',
region: '',
factoryCode: '',
fileFormat: ''
});
const factoryCodes = [
{ label: 'CUXL', value: 'CUXL' },
{ label: 'CXL', value: 'CXL' },
{ label: 'CCMC', value: 'CCMC' },
{ label: 'Perkins', value: 'Perkins' },
{ label: 'CQL', value: 'CQL' },
{ label: 'CWL', value: 'CWL' },
{ label: 'CRDC', value: 'CRDC' }
]
const fileFormats = [
{ label: 'GLB', value: '.glb' },
]
const formRules = reactive({
modelData: computed(() => [
...(dialogTitle.value === '新增'
? [{ required: true, message: '请上传模型文件', trigger: 'change' }]
: [])
]),
modelPreviewData: computed(() => [
...(dialogTitle.value === '新增'
? [{ required: true, message: '请上传缩略图', trigger: 'change' }]
: [])
]),
name: [
{ required: true, message: '名称不能为空', trigger: ['blur', 'change'] }
],
region: [
{ required: true, message: '区域不能为空', trigger: ['blur', 'change'] }
],
factoryCode: [
{ required: false, message: '工厂不能为空', trigger: ['blur', 'change'] }
],
fileFormat: [
{ required: false, message: '格式不能为空', trigger: ['blur', 'change'] }
],
})
const currentEditRow = ref({
uuid:'',
modelUrl: '',
modelPreviewUrl: '',
name: '',
region: '',
factoryCode: '',
fileFormat: '',
});
async function handleSelectionChange(selection:any){
selectedRows.value = selection
}
async function fetchData(){
loading.value = true;
try {
await getModelList({
page: currentPage.value,
limit: pageSize.value,
name: searchData.name,
region: searchData.region,
factoryCode: searchData.factoryCode,
fileFormat: searchData.fileFormat
});
if(modelStore.modelData.status){
tableData.value = modelStore.modelData.rows;
total.value = modelStore.modelData.total;
loading.value = false;
}
else{
loading.value = false;
}
} catch {
}
}
async function handleSearch(){
await fetchData();
}
async function handleAdd(){
dialogTitle.value = '新增'
Object.keys(formData).forEach(key => {
const k = key as keyof typeof formData;
formData[k] = '';
})
uploadedFileInfo.value = {
name:'',
size:''
}
currentModelUrl.value = '';
formDialogVisible.value = true;
}
async function handleEdit(row: any) {
dialogTitle.value = '编辑';
formData.modelData = '';
formData.modelPreviewData = row.modelPreviewUrl || '';
currentEditRow.value = { ...row };
formData.name = row.name;
formData.region = row.region;
formData.factoryCode = row.factoryCode;
formData.fileFormat = row.fileFormat;
currentModelUrl.value = row.modelUrl;
uploadedFileInfo.value = {
name: '-',
size: '-'
};
formDialogVisible.value = true;
}
async function handleModelChange(uploadFile: any) {
const rawFile = uploadFile.raw as File; // 获取原生 File 对象
if (!rawFile) {
ElMessage.error('未获取到文件');
return;
}
// 校验扩展名
if (!rawFile.name.toLowerCase().endsWith('.glb')) {
ElMessage.warning('请上传 .glb 格式的模型文件');
return;
}
// 使用 FileReader 转为 Base64
const reader = new FileReader();
return new Promise<void>((resolve, reject) => {
reader.onload = (e) => {
const result = e.target?.result;
if (typeof result === 'string') {
formData.modelData = result; // 如: data:model/gltf-binary;base64,...
const sizeInMB = (rawFile.size / (1024 * 1024)).toFixed(2)
uploadedFileInfo.value = {
name: rawFile.name,
size: sizeInMB
}
currentModelUrl.value = URL.createObjectURL(rawFile)
ElMessage.success('模型加载成功');
resolve();
} else {
ElMessage.error('模型读取失败');
formData.modelData = '';
reject(new Error('Failed to read as Data URL'));
}
};
reader.onerror = () => {
ElMessage.error('文件读取失败');
reject(new Error('FileReader error'));
};
reader.readAsDataURL(rawFile); // 开始读取为 data URL (Base64)
});
}
async function removeFile(){
uploadedFileInfo.value = { name: '', size: '' };
};
async function handleThumbnailUpload(uploadFile: any) {
const file = uploadFile.raw
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
const target = e.target
if (!target || !target.result) {
console.error('文件读取失败:result 为空')
return
}
formData.modelPreviewData = target.result as string
isCapturingFromPreview.value = false
ElMessage.success('图片上传成功');
}
reader.onerror = (e) => {
console.error('文件读取错误:', e)
}
reader.readAsDataURL(file)
}
async function openModelPreview(url: string) {
currentModelUrl.value = url
previewDialogVisible.value = true
// 等待 dialog 和 ModelViewer 的 DOM 完全挂载
await nextTick()
await nextTick() // 双保险,确保 layout 完成
// 触发一次 resize 模拟,强制 Three.js 重新适配
window.dispatchEvent(new Event('resize'))
}
async function downloadModel(){
const a = document.createElement('a')
a.href = currentModelUrl.value
a.download = currentModelUrl.value.split('/').pop() || 'model.glb'
a.click()
}
async function screenshotModel() {
if (isCapturingFromPreview.value) return;
isCapturingFromPreview.value = true;
await nextTick();
await new Promise(r => requestAnimationFrame(r));
const viewer = modelViewerRef.value as any;
if (!viewer?.renderer?.domElement) {
ElMessage.error('渲染器未就绪,请再等等');
isCapturingFromPreview.value = false;
return;
}
viewer.renderer.render(viewer.scene, viewer.camera);
formData.modelPreviewData = viewer.renderer.domElement.toDataURL('image/png');
ElMessage.success('截图成功');
previewDialogVisible.value = false;
isCapturingFromPreview.value = false;
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确认删除该记录?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
var resultbool = await deleteModel(row.uuid)
if(resultbool){
ElMessage.success('删除成功')
await fetchData()
}
else{
ElMessage.error('删除失败')
await fetchData()
}
} catch (err) {
if (err !== 'cancel') {
console.error('删除操作异常:', err)
ElMessage.error('删除失败')
}
if (err === 'cancel') {
ElMessage.info('已取消')
}
}
}
async function handleBatchDelete(){
try {
if (selectedRows.value.length === 0) return
await ElMessageBox.confirm(
`确定删除选中的 ${selectedRows.value.length} 条记录?`,'提示',
{
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}
)
var idsToDelete = selectedRows.value.map(row => row.uuid ) as string[]
var resultbool = await deleteAllModel(idsToDelete)
if(resultbool.status){
ElMessage.success('删除成功')
await fetchData()
}
else{
ElMessage.error('删除失败')
await fetchData()
}
} catch (err) {
if (err !== 'cancel') {
console.error('删除操作异常:', err)
ElMessage.error('删除失败')
}
if (err === 'cancel') {
ElMessage.info('已取消')
}
}
}
function shouldOmitField(key: Exclude<keyof typeof currentEditRow.value, 'uuid'|'modelUrl'|'modelPreviewUrl'>){
return currentEditRow.value[key] === formData[key]
}
async function submitForm(){
try {
const valid = await formRef.value?.validate();
if (!valid) {
ElMessage.error('请完善表单信息');
}
var resultBool = false;
if(dialogTitle.value === '新增'){
resultBool = await addModel(
{
modelData: formData.modelData,
modelPreviewData: formData.modelPreviewData,
name:formData.name,
region: formData.region,
factoryCode: formData.factoryCode,
fileFormat: formData.fileFormat
})
uploadedFileInfo.value = {
name:'',
size:''
}
currentModelUrl.value = '';
}
if(dialogTitle.value === '编辑'){
console.log(1111,formData.modelData)
resultBool = await updateModel(
{
uuid:currentEditRow.value.uuid,
...(formData.modelData.includes('base64') && { modelData: formData.modelData }),
...(formData.modelPreviewData.includes('base64') && { modelPreviewData: formData.modelPreviewData }),
...(!shouldOmitField('name') && { name: formData.name }),
...(!shouldOmitField('region') && { region: formData.region }),
...(!shouldOmitField('factoryCode') && { factoryCode: formData.factoryCode }),
...(!shouldOmitField('fileFormat') && { fileFormat: formData.fileFormat })
})
currentEditRow.value = {
uuid:'',
modelUrl: '',
modelPreviewUrl: '',
name: '',
region: '',
factoryCode: '',
fileFormat: '',
}
}
if(resultBool){
ElMessage.success(`${dialogTitle.value}成功`);
await fetchData();
}
else{
ElMessage.error(`${dialogTitle.value}失败`)
}
formDialogVisible.value = false
}catch (error) {
ElMessage.error('表单验证异常');
}
}
async function sleep(ms:number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
onMounted(async () => {
await fetchData()
})
</script>
<style scoped>
.container {
padding: 0px;
}
.search-box {
margin-top: 15px;
margin-bottom: 8px;
}
.table-header {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
}
.custom-input {
width: 400px !important;
}
.wide-select {
width: 120px !important;
}
.wide-input {
width: 150px !important;
}
/* 附件样式:类似邮箱中的附件 */
.upload-success-info {
display: flex;
justify-content: space-between; /* 核心:左右分开 */
align-items: center; /* 垂直居中 */
padding: 8px 0;
font-size: 14px;
color: #333;
width: 100%; /* 确保占满父容器 */
}
.upload-content-left {
display: flex;
align-items: center;
gap: 8px; /* 图标与文字组之间的间距 */
flex: 1; /* 自动填充可用空间 */
overflow: hidden;
}
.upload-text-group {
display: flex;
flex-direction: column;
white-space: nowrap;
overflow: hidden;
}
.upload-status {
margin: 0 0 2px 0;
font-size: 13px;
color: #67c23a;
font-weight: bold;
}
.file-details {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
overflow: hidden;
}
.file-icon {
color: #409eff;
font-size: 16px;
}
.file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
color: #999;
font-size: 12px;
}
.delete-btn {
flex-shrink: 0; /* 防止按钮被压缩 */
}
.icon-button-center {
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
border-radius: 50%;
}
.icon-button-center .el-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 16px; /* 可调大小 */
}
/* 缩略图预览容器 */
.thumbnail-upload {
width: 100px; /* 明确设置较小宽度 */
height: auto;
padding: 8px; /* 减小内边距 */
border: 1px dashed #c0ccda;
border-radius: 6px;
}
.thumbnail-preview-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4px; /* 缩小内边距 */
margin: 0;
text-align: center;
height: auto;
min-height: unset; /* 防止 el-upload 默认最小高度 */
}
.thumbnail-image {
max-width: 100%;
max-height: 120px; /* 进一步缩小最大高度 */
border-radius: 0px;
object-fit: contain;
margin-bottom: 0px; /* 缩小底部间距 */
}
/* 全局统一 drag upload 样式 */
.el-upload--drag {
height: 100px !important;
}
.el-upload--drag .el-upload-dragger {
height: 100%;
padding: 10px;
}
.el-upload--drag .el-icon--upload {
font-size: 28px !important;
margin-bottom: 6px;
}
.el-upload--drag .el-upload__text {
font-size: 13px !important;
line-height: 1.4;
}
</style>
编辑后,更新图片,提交后缩略图不能立马更新,需要页面刷新后才能更新图片
最新发布