new
All checks were successful
Deploy to Server / deploy (push) Successful in 32s

This commit is contained in:
jeremygan2021
2026-03-22 22:04:13 +08:00
parent 2e05322909
commit 2104e7b7dc
8 changed files with 525 additions and 112 deletions

View File

@@ -1,4 +1,5 @@
from django.contrib import admin from django.contrib import admin
from django.utils.safestring import mark_safe
from unfold.admin import ModelAdmin from unfold.admin import ModelAdmin
from unfold.decorators import display from unfold.decorators import display
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, ScoreFormula from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, ScoreFormula
@@ -37,6 +38,31 @@ class ProjectFileInline(admin.TabularInline):
model = ProjectFile model = ProjectFile
extra = 0 extra = 0
tab = True tab = True
readonly_fields = ('file_url_display',)
def file_url_display(self, obj):
if obj.file_url:
return mark_safe(f'<a href="{obj.file_url}" target="_blank">{obj.file_url[:50]}...</a>')
elif obj.file:
return obj.file.url
return "-"
file_url_display.short_description = "文件链接"
@admin.register(ProjectFile)
class ProjectFileAdmin(ModelAdmin):
list_display = ['id', 'project', 'name', 'file_type', 'file_url_display', 'created_at']
list_filter = ['file_type', 'created_at']
search_fields = ['name', 'project__title']
readonly_fields = ('file_url_display',)
def file_url_display(self, obj):
if obj.file_url:
return mark_safe(f'<a href="{obj.file_url}" target="_blank">打开文件</a>')
elif obj.file:
return obj.file.url
return "-"
file_url_display.short_description = "文件链接"
@admin.register(Competition) @admin.register(Competition)

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-22 13:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0010_remove_competition_custom_score_formula'),
]
operations = [
migrations.AlterField(
model_name='projectfile',
name='file_url',
field=models.TextField(blank=True, help_text='OSS URL或外部链接', null=True, verbose_name='文件链接'),
),
]

View File

@@ -296,7 +296,7 @@ class ProjectFile(models.Model):
file_type = models.CharField(max_length=20, choices=FILE_TYPE_CHOICES, default='other', verbose_name="文件类型") file_type = models.CharField(max_length=20, choices=FILE_TYPE_CHOICES, default='other', verbose_name="文件类型")
file = models.FileField(upload_to='competitions/projects/files/', verbose_name="文件", null=True, blank=True) file = models.FileField(upload_to='competitions/projects/files/', verbose_name="文件", null=True, blank=True)
file_url = models.URLField(verbose_name="文件链接", null=True, blank=True, help_text="视频等大文件建议使用外部链接") file_url = models.TextField(verbose_name="文件链接", null=True, blank=True, help_text="OSS URL或外部链接")
name = models.CharField(max_length=100, verbose_name="文件名称", blank=True) name = models.CharField(max_length=100, verbose_name="文件名称", blank=True)

View File

@@ -1,6 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
from shop.serializers import WeChatUserSerializer from shop.serializers import WeChatUserSerializer
import uuid
class ScoreDimensionSerializer(serializers.ModelSerializer): class ScoreDimensionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@@ -53,7 +54,9 @@ class ProjectFileSerializer(serializers.ModelSerializer):
return value return value
def create(self, validated_data): def create(self, validated_data):
from django.conf import settings
file_obj = validated_data.get('file') file_obj = validated_data.get('file')
if file_obj: if file_obj:
ext = file_obj.name.split('.')[-1].lower() if '.' in file_obj.name else '' ext = file_obj.name.split('.')[-1].lower() if '.' in file_obj.name else ''
if ext in ['ppt', 'pptx']: if ext in ['ppt', 'pptx']:
@@ -69,6 +72,22 @@ class ProjectFileSerializer(serializers.ModelSerializer):
if not validated_data.get('name'): if not validated_data.get('name'):
validated_data['name'] = file_obj.name validated_data['name'] = file_obj.name
try:
from ai_services.services import AliyunTingwuService
service = AliyunTingwuService()
if service.bucket:
project = validated_data.get('project')
file_name = f"competitions/projects/{project.id}/{uuid.uuid4()}.{ext}"
oss_url = service.upload_to_oss(file_obj, file_name, day=30)
validated_data['file_url'] = oss_url
validated_data['file'] = None
print(f"OSS upload success: {oss_url}")
else:
print("OSS bucket is None, OSS not configured properly")
except Exception as e:
print(f"OSS upload failed in serializer: {e}")
return super().create(validated_data) return super().create(validated_data)
class ProjectSerializer(serializers.ModelSerializer): class ProjectSerializer(serializers.ModelSerializer):

View File

@@ -352,11 +352,253 @@
</div> </div>
</div> </div>
<!-- File Preview Modal -->
<div id="filePreviewModal" class="modal fixed inset-0 z-[70] flex items-center justify-center p-4" style="background-color: rgba(0,0,0,0.7);">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-5xl max-h-[95vh] flex flex-col relative animate-fade-in overflow-hidden">
<button class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 z-10 p-2 rounded-full hover:bg-gray-100 transition-colors" onclick="closeModal('filePreviewModal')">
<i class="fas fa-times text-xl"></i>
</button>
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50 flex justify-between items-center">
<h2 id="filePreviewTitle" class="text-lg font-bold text-gray-900 flex items-center truncate">
<i id="filePreviewIcon" class="fas fa-file-pdf text-red-500 mr-2"></i>
<span id="filePreviewName">文件预览</span>
</h2>
<div class="flex items-center gap-2 ml-4">
<a id="fileDownloadBtn" href="#" download class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
<i class="fas fa-download mr-1.5"></i>下载
</a>
<button onclick="openFileNewTab()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
<i class="fas fa-external-link-alt mr-1.5"></i>新窗口打开
</button>
</div>
</div>
<div class="flex-1 overflow-hidden bg-gray-100 relative">
<iframe id="filePreviewFrame" class="w-full h-full min-h-[60vh]" frameborder="0"></iframe>
<div id="imagePreviewContainer" class="hidden w-full h-full overflow-auto flex items-center justify-center p-4">
<img id="imagePreviewImg" class="max-w-full max-h-full object-contain rounded shadow-lg" src="" alt="图片预览">
</div>
<div id="filePreviewLoading" class="absolute inset-0 flex items-center justify-center bg-gray-100">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-4xl text-blue-500 mb-3"></i>
<p class="text-gray-500">正在加载文件...</p>
</div>
</div>
<div id="filePreviewError" class="hidden absolute inset-0 flex items-center justify-center bg-gray-100">
<div class="text-center">
<i class="fas fa-exclamation-circle text-4xl text-red-500 mb-3"></i>
<p class="text-gray-600 mb-3">文件加载失败</p>
<p class="text-sm text-gray-400">请尝试下载文件或在新窗口中打开</p>
</div>
</div>
</div>
<div class="px-4 py-3 bg-white border-t border-gray-200 text-xs text-gray-500 flex justify-between">
<span>如文件无法预览,请直接下载或在浏览器中打开</span>
<span id="filePreviewHint"></span>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="https://cdn.staticfile.net/marked/11.1.1/marked.min.js"></script> <script src="https://cdn.staticfile.net/marked/11.1.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script> <script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script>
let currentPreviewFile = { url: '', name: '', type: '' };
function previewFile(url, name, type) {
currentPreviewFile = { url: decodeURIComponent(url), name: decodeURIComponent(name), type: type };
const modal = document.getElementById('filePreviewModal');
const titleEl = document.getElementById('filePreviewTitle');
const iconEl = document.getElementById('filePreviewIcon');
const nameEl = document.getElementById('filePreviewName');
const downloadBtn = document.getElementById('fileDownloadBtn');
const frame = document.getElementById('filePreviewFrame');
const imageContainer = document.getElementById('imagePreviewContainer');
const imageImg = document.getElementById('imagePreviewImg');
const loading = document.getElementById('filePreviewLoading');
const error = document.getElementById('filePreviewError');
const hint = document.getElementById('filePreviewHint');
frame.style.display = 'none';
imageContainer.classList.add('hidden');
loading.classList.remove('hidden');
error.classList.add('hidden');
const fileUrl = decodeURIComponent(url);
const fileName = decodeURIComponent(name);
const fileType = type || '';
titleEl.className = 'text-lg font-bold text-gray-900 flex items-center truncate';
nameEl.textContent = fileName;
downloadBtn.href = fileUrl;
downloadBtn.download = fileName;
loading.classList.remove('hidden');
error.classList.add('hidden');
frame.style.display = 'block';
let iconClass = 'fas fa-file text-gray-500';
let previewUrl = fileUrl;
let hintText = '';
if (fileType === 'pdf') {
iconClass = 'fas fa-file-pdf text-red-500';
previewUrl = fileUrl;
hintText = '提示PDF文件使用本地PDF.js预览器加载';
} else if (fileType === 'ppt' || fileType === 'pptx') {
iconClass = 'fas fa-file-powerpoint text-orange-500';
previewUrl = '';
hintText = '提示PPT文件需要下载后查看或使用WPS/Office打开';
} else if (fileType === 'image') {
iconClass = 'fas fa-image text-green-500';
previewUrl = 'image';
hintText = '提示:点击图片可查看大图';
} else if (fileType === 'doc' || fileType === 'docx') {
iconClass = 'fas fa-file-word text-blue-500';
previewUrl = '';
hintText = '提示Word文档需要下载后查看或使用WPS/Word打开';
} else {
previewUrl = fileUrl;
hintText = '此文件类型不支持在线预览,请下载后查看';
}
iconEl.className = iconClass + ' mr-2';
hint.textContent = hintText;
if (!previewUrl) {
loading.classList.add('hidden');
error.classList.remove('hidden');
frame.style.display = 'none';
document.querySelector('#filePreviewError p:first-of-type').textContent = '此文件类型暂不支持在线预览';
document.querySelector('#filePreviewError p:last-of-type').textContent = '请点击上方"下载"按钮保存文件后查看';
modal.classList.add('active');
return;
}
if (fileType === 'pdf') {
loading.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin text-4xl text-blue-500 mb-3"></i><p class="text-gray-500">正在加载PDF文件...</p></div>';
renderPdfPreview(fileUrl, frame, loading, error);
} else if (fileType === 'image') {
imageImg.src = fileUrl;
imageImg.onload = () => {
loading.classList.add('hidden');
imageContainer.classList.remove('hidden');
};
imageImg.onerror = () => {
loading.classList.add('hidden');
error.classList.remove('hidden');
error.querySelector('p:first-of-type').textContent = '图片加载失败';
error.querySelector('p:last-of-type').textContent = '请点击"下载"按钮保存图片后查看';
};
} else {
frame.style.display = 'block';
frame.onload = function() {
loading.classList.add('hidden');
};
frame.onerror = function() {
loading.classList.add('hidden');
};
frame.src = previewUrl;
}
modal.classList.add('active');
}
async function renderPdfPreview(url, frameEl, loadingEl, errorEl) {
try {
const loadingText = loadingEl.querySelector('p');
let arrayBuffer;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('无法加载PDF文件');
arrayBuffer = await response.arrayBuffer();
} catch (fetchErr) {
console.warn('PDF fetch failed, trying iframe method:', fetchErr);
frameEl.style.display = 'block';
frameEl.src = url;
loadingEl.classList.add('hidden');
frameEl.onload = () => loadingEl.classList.add('hidden');
frameEl.onerror = () => {
loadingEl.classList.add('hidden');
showPdfError(errorEl, 'PDF文件加载失败请下载后查看');
};
return;
}
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const container = document.createElement('div');
container.style.cssText = 'width:100%;height:100%;overflow:auto;background:#525659;padding:20px;';
const scale = 1.2;
const pageContainer = document.createElement('div');
pageContainer.style.cssText = 'background:white;margin:0 auto;box-shadow:0 2px 10px rgba(0,0,0,0.3);width:' + (595 * scale) + 'px;';
const pageNum = Math.min(pdf.numPages, 20);
for (let i = 1; i <= pageNum; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: scale });
const canvas = document.createElement('canvas');
canvas.style.cssText = 'display:block;margin:0 auto;width:100%;';
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: context, viewport: viewport }).promise;
pageContainer.appendChild(canvas);
}
if (pdf.numPages > 20) {
const moreInfo = document.createElement('div');
moreInfo.style.cssText = 'text-align:center;padding:20px;color:#999;background:white;margin-top:10px;';
moreInfo.textContent = `... 还有 ${pdf.numPages - 20} 页未显示,请下载完整查看`;
pageContainer.appendChild(moreInfo);
}
container.appendChild(pageContainer);
frameEl.style.display = 'none';
loadingEl.classList.add('hidden');
const parent = frameEl.parentElement;
const oldContainer = parent.querySelector('#pdfRenderContainer');
if (oldContainer) oldContainer.remove();
parent.insertBefore(container, frameEl);
container.id = 'pdfRenderContainer';
} catch (err) {
console.error('PDF加载失败:', err);
loadingEl.classList.add('hidden');
frameEl.style.display = 'block';
frameEl.src = url;
frameEl.onload = () => loadingEl.classList.add('hidden');
frameEl.onerror = () => {
loadingEl.classList.add('hidden');
showPdfError(errorEl, 'PDF文件无法预览请下载后查看');
};
}
}
function showPdfError(errorEl, message) {
errorEl.classList.remove('hidden');
errorEl.querySelector('p:first-of-type').textContent = message;
errorEl.querySelector('p:last-of-type').textContent = '请点击"下载"按钮保存文件后查看';
}
function openFileNewTab() {
window.open(currentPreviewFile.url, '_blank');
}
/** /**
* 更新单个维度分数显示并计算总分 * 更新单个维度分数显示并计算总分
*/ */
@@ -563,25 +805,49 @@ async function viewProject(id) {
filesList.innerHTML = data.files.map(file => { filesList.innerHTML = data.files.map(file => {
let iconClass = 'fas fa-file'; let iconClass = 'fas fa-file';
let iconColor = 'text-gray-500'; let iconColor = 'text-gray-500';
if (file.file_type === 'ppt' || file.file_type === 'pptx') { let canPreview = false;
let fileType = file.file_type || '';
if (fileType === 'ppt' || fileType === 'pptx') {
iconClass = 'fas fa-file-powerpoint'; iconClass = 'fas fa-file-powerpoint';
iconColor = 'text-orange-500'; iconColor = 'text-orange-500';
} else if (file.file_type === 'pdf') { canPreview = true;
} else if (fileType === 'pdf') {
iconClass = 'fas fa-file-pdf'; iconClass = 'fas fa-file-pdf';
iconColor = 'text-red-500'; iconColor = 'text-red-500';
} else if (file.file_type === 'video') { canPreview = true;
} else if (fileType === 'video') {
iconClass = 'fas fa-video'; iconClass = 'fas fa-video';
iconColor = 'text-purple-500'; iconColor = 'text-purple-500';
} else if (file.file_type === 'image') { } else if (fileType === 'image') {
iconClass = 'fas fa-image'; iconClass = 'fas fa-image';
iconColor = 'text-green-500'; iconColor = 'text-green-500';
canPreview = true;
} else if (fileType === 'doc' || fileType === 'docx') {
iconClass = 'fas fa-file-word';
iconColor = 'text-blue-500';
canPreview = true;
} }
const fileName = file.name || '未命名文件';
const previewBtn = canPreview ? `
<button onclick="previewFile('${encodeURIComponent(file.file_url)}', '${encodeURIComponent(fileName)}', '${fileType}')"
class="ml-2 px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded hover:bg-blue-100 transition-colors">
<i class="fas fa-eye mr-1"></i>预览
</button>
` : '';
return ` return `
<a href="${file.file_url}" target="_blank" class="flex items-center p-3 bg-white rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-blue-300 transition-colors group"> <div class="flex items-center p-3 bg-white rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-blue-300 transition-colors group">
<i class="${iconClass} ${iconColor} text-xl mr-3 group-hover:scale-110 transition-transform"></i> <i class="${iconClass} ${iconColor} text-xl mr-3 group-hover:scale-110 transition-transform"></i>
<span class="flex-1 text-sm font-medium text-gray-700 truncate">${file.name || '未命名文件'}</span> <div class="flex-1 min-w-0">
<i class="fas fa-external-link-alt text-gray-400 group-hover:text-blue-500"></i> <span class="text-sm font-medium text-gray-700 block truncate">${fileName}</span>
</a> </div>
<a href="${file.file_url}" download="${fileName}" class="ml-2 p-2 text-gray-400 hover:text-blue-500 transition-colors" title="下载">
<i class="fas fa-download"></i>
</a>
${previewBtn}
</div>
`; `;
}).join(''); }).join('');
} else { } else {

View File

@@ -10,7 +10,6 @@ from .serializers import (
ProjectSerializer, ProjectFileSerializer, ProjectSerializer, ProjectFileSerializer,
ScoreSerializer, CommentSerializer, ScoreDimensionSerializer ScoreSerializer, CommentSerializer, ScoreDimensionSerializer
) )
import uuid
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
@@ -206,21 +205,7 @@ class ProjectFileViewSet(viewsets.ModelViewSet):
if not user or project.contestant.user != user: if not user or project.contestant.user != user:
raise serializers.ValidationError("无权上传文件") raise serializers.ValidationError("无权上传文件")
file_obj = serializer.validated_data.get('file')
if file_obj:
try:
from ai_services.services import AliyunTingwuService
service = AliyunTingwuService()
if service.bucket:
ext = file_obj.name.split('.')[-1] if '.' in file_obj.name else ''
file_name = f"competitions/projects/{project.id}/{uuid.uuid4()}.{ext}"
oss_url = service.upload_to_oss(file_obj, file_name, day=30)
serializer.save(file_url=oss_url, file=None)
return
except Exception as e:
print(f"OSS upload failed, using local storage: {e}")
serializer.save() serializer.save()

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api', baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api',
timeout: 8000, // 增加超时时间到 10秒 timeout: 120000, // 大文件上传需要更长超时时间 2分钟
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }

View File

@@ -1,19 +1,63 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Button, Form, Input, Upload, App, Modal, Select } from 'antd'; import { Button, Form, Input, Upload, App, Modal, Progress, Space } from 'antd';
import { UploadOutlined, CloudUploadOutlined, LinkOutlined } from '@ant-design/icons'; import { CloudUploadOutlined, LinkOutlined, FileTextOutlined, DownloadOutlined, FilePdfOutlined, FilePptOutlined, VideoCameraOutlined, PictureOutlined } from '@ant-design/icons';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createProject, updateProject, submitProject, uploadProjectFile } from '../../api'; import { createProject, updateProject, submitProject, uploadProjectFile, getProjects } from '../../api';
const { TextArea } = Input; const { TextArea } = Input;
const { Option } = Select;
const getFileIcon = (fileType) => {
switch (fileType) {
case 'pdf':
return <FilePdfOutlined style={{ color: '#ff4d4f' }} />;
case 'ppt':
case 'pptx':
return <FilePptOutlined style={{ color: '#fa8c16' }} />;
case 'video':
return <VideoCameraOutlined style={{ color: '#722ed1' }} />;
case 'image':
return <PictureOutlined style={{ color: '#52c41a' }} />;
default:
return <FileTextOutlined style={{ color: '#1890ff' }} />;
}
};
const getFileUrl = (file) => {
return file.file_url_display || file.file_url || (file.file ? URL.createObjectURL(file.file) : null);
};
const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => { const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => {
const { message } = App.useApp(); const { message, modal } = App.useApp();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [fileList, setFileList] = useState([]); const [uploadedFiles, setUploadedFiles] = useState([]);
const [uploadingFiles, setUploadingFiles] = useState({});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Reset form when initialValues changes (important for switching between create/edit) useEffect(() => {
if (initialValues?.id) {
getProjects({ competition: competitionId, contestant: initialValues.id })
.then(res => {
const project = res.data?.results?.[0];
if (project?.files && project.files.length > 0) {
const files = project.files.map(file => ({
uid: file.id,
id: file.id,
name: file.name || '未命名文件',
url: file.file_url_display || file.file_url,
fileType: file.file_type,
status: 'done'
}));
setUploadedFiles(files);
} else {
setUploadedFiles([]);
}
})
.catch(err => {
console.error('获取项目文件失败:', err);
});
}
}, [initialValues?.id, competitionId]);
React.useEffect(() => { React.useEffect(() => {
if (initialValues) { if (initialValues) {
form.setFieldsValue(initialValues); form.setFieldsValue(initialValues);
@@ -30,7 +74,7 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
onSuccess(); onSuccess();
}, },
onError: (error) => { onError: (error) => {
message.error(`创建失败: ${error.message}`); message.error(`创建失败: ${error.response?.data?.detail || error.message}`);
} }
}); });
@@ -42,26 +86,68 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
onSuccess(); onSuccess();
}, },
onError: (error) => { onError: (error) => {
message.error(`更新失败: ${error.message}`); message.error(`更新失败: ${error.response?.data?.detail || error.message}`);
} }
}); });
const uploadMutation = useMutation({ const handleUpload = ({ file, onSuccess, onError }) => {
mutationFn: uploadProjectFile, console.log('handleUpload called', file.name);
onSuccess: (data) => {
message.success('文件上传成功'); if (!initialValues?.id) {
setFileList([...fileList, data]); // Add file to list (assuming response format) message.warning('请先保存项目基本信息再上传文件');
}, onError(new Error('请先保存项目'));
onError: (error) => { return;
message.error(`上传失败: ${error.message}`);
} }
});
const fileUid = file.uid || Date.now().toString();
console.log('fileUid:', fileUid);
setUploadingFiles(prev => ({
...prev,
[fileUid]: { percent: 0, status: 'uploading' }
}));
const formData = new FormData();
formData.append('file', file);
formData.append('project', initialValues.id);
console.log('Sending upload request for project:', initialValues.id);
uploadProjectFile(formData)
.then(res => {
console.log('Upload success:', res);
setUploadingFiles(prev => ({
...prev,
[fileUid]: { percent: 100, status: 'done' }
}));
const newFile = {
uid: res.data.id,
id: res.data.id,
name: res.data.name || file.name,
url: res.data.file_url_display || res.data.file_url,
fileType: res.data.file_type,
status: 'done'
};
setUploadedFiles(prev => [...prev, newFile]);
message.success('文件上传成功');
onSuccess(res.data);
})
.catch(err => {
console.error('Upload error:', err);
setUploadingFiles(prev => ({
...prev,
[fileUid]: { percent: 0, status: 'error' }
}));
message.error(`上传失败: ${err.response?.data?.detail || err.message}`);
onError(err);
});
};
const onFinish = (values) => { const onFinish = (values) => {
const data = { const data = {
...values, ...values,
competition: competitionId, competition: competitionId,
// Handle file URLs/IDs if needed in create/update
}; };
if (initialValues?.id) { if (initialValues?.id) {
@@ -71,28 +157,6 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
} }
}; };
const handleUpload = ({ file, onSuccess, onError }) => {
if (!initialValues?.id) {
message.warning('请先保存项目基本信息再上传文件');
// Prevent default upload
onError(new Error('请先保存项目'));
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('project', initialValues?.id || ''); // Need project ID first usually
uploadMutation.mutate(formData, {
onSuccess: (data) => {
onSuccess(data);
},
onError: (error) => {
onError(error);
}
});
};
return ( return (
<Modal <Modal
title={initialValues?.id ? "修改已提交项目" : "提交新项目"} title={initialValues?.id ? "修改已提交项目" : "提交新项目"}
@@ -144,49 +208,84 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
<Input prefix={<LinkOutlined />} placeholder="https://example.com/image.jpg" /> <Input prefix={<LinkOutlined />} placeholder="https://example.com/image.jpg" />
</Form.Item> </Form.Item>
{/* File Upload Section - Only visible if project exists */}
{initialValues?.id && ( {initialValues?.id && (
<Form.Item label="项目附件 (PPT/PDF/视频/图片)"> <Form.Item label="项目附件 (PPT/PDF/视频/图片)">
<Upload <div style={{ marginBottom: 16 }}>
customRequest={handleUpload} {uploadedFiles.length === 0 ? (
listType="picture" <div style={{ color: '#999', fontStyle: 'italic' }}>暂无上传文件</div>
maxCount={5} ) : (
accept=".ppt,.pptx,.pdf,.mp4,.mov,.avi,.webm,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx" <Space orientation="vertical" style={{ width: '100%' }} size="middle">
beforeUpload={(file) => { {uploadedFiles.map((file) => (
const allowedTypes = [ <div key={file.uid} style={{
'application/vnd.ms-powerpoint', display: 'flex',
'application/vnd.openxmlformats-officedocument.presentationml.presentation', alignItems: 'center',
'application/pdf', padding: '12px 16px',
'video/mp4', background: '#f5f5f5',
'video/quicktime', borderRadius: 8,
'video/x-msvideo', border: '1px solid #e8e8e8'
'video/webm', }}>
'image/jpeg', <span style={{ fontSize: 20, marginRight: 12 }}>
'image/png', {getFileIcon(file.fileType)}
'image/gif', </span>
'image/webp', <span style={{ flex: 1, fontWeight: 500 }}>{file.name}</span>
'application/msword', {file.url && (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' <Button
]; type="link"
const allowedExtensions = ['ppt', 'pptx', 'pdf', 'mp4', 'mov', 'avi', 'webm', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx']; icon={<DownloadOutlined />}
const fileExt = file.name.split('.').pop()?.toLowerCase(); href={file.url}
target="_blank"
if (!allowedExtensions.includes(fileExt)) { >
message.error('不支持的文件格式!请上传 PPT、PDF、视频或图片文件'); 下载/查看
return Upload.LIST_IGNORE; </Button>
} )}
</div>
const isLt50M = file.size / 1024 / 1024 < 50; ))}
if (!isLt50M) { </Space>
message.error('文件大小不能超过 50MB'); )}
return Upload.LIST_IGNORE; </div>
}
return false; {Object.keys(uploadingFiles).length > 0 && (
}} <div style={{ marginBottom: 16 }}>
> <Space orientation="vertical" style={{ width: '100%' }} size="small">
<Button icon={<CloudUploadOutlined />}>上传文件 (最大50MB)</Button> {Object.entries(uploadingFiles).map(([uid, info]) => (
</Upload> <Progress
</Form.Item> key={uid}
percent={info.percent}
status={info.status === 'error' ? 'exception' : 'active'}
size="small"
/>
))}
</Space>
</div>
)}
<Upload
showUploadList={false}
maxCount={5}
accept=".ppt,.pptx,.pdf,.mp4,.mov,.avi,.webm,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx"
beforeUpload={(file) => {
console.log('beforeUpload triggered for:', file.name);
const allowedExtensions = ['ppt', 'pptx', 'pdf', 'mp4', 'mov', 'avi', 'webm', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx'];
const fileExt = file.name.split('.').pop()?.toLowerCase();
if (!allowedExtensions.includes(fileExt)) {
message.error('不支持的文件格式!请上传 PPT、PDF、视频或图片文件');
return Upload.LIST_IGNORE;
}
const isLt50M = file.size / 1024 / 1024 < 50;
if (!isLt50M) {
message.error('文件大小不能超过 50MB');
return Upload.LIST_IGNORE;
}
console.log('beforeUpload passed, manually calling handleUpload');
handleUpload({ file, onSuccess: () => {}, onError: () => {} });
return Upload.LIST_IGNORE;
}}
>
<Button icon={<CloudUploadOutlined />} onClick={() => console.log('Upload button clicked')}>继续上传文件 (最大50MB)</Button>
</Upload>
</Form.Item>
)} )}
<Form.Item> <Form.Item>
@@ -200,7 +299,7 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
type="primary" type="primary"
danger danger
onClick={() => { onClick={() => {
Modal.confirm({ modal.confirm({
title: '确认提交?', title: '确认提交?',
content: '提交后将无法修改,确认提交吗?', content: '提交后将无法修改,确认提交吗?',
onOk: () => submitProject(initialValues.id).then(() => { onOk: () => submitProject(initialValues.id).then(() => {
@@ -220,4 +319,4 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
); );
}; };
export default ProjectSubmission; export default ProjectSubmission;