This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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='文件链接'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -207,20 +206,6 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
下载/查看
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
if (!allowedExtensions.includes(fileExt)) {
|
{Object.keys(uploadingFiles).length > 0 && (
|
||||||
message.error('不支持的文件格式!请上传 PPT、PDF、视频或图片文件');
|
<div style={{ marginBottom: 16 }}>
|
||||||
return Upload.LIST_IGNORE;
|
<Space orientation="vertical" style={{ width: '100%' }} size="small">
|
||||||
}
|
{Object.entries(uploadingFiles).map(([uid, info]) => (
|
||||||
|
<Progress
|
||||||
|
key={uid}
|
||||||
|
percent={info.percent}
|
||||||
|
status={info.status === 'error' ? 'exception' : 'active'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
const isLt50M = file.size / 1024 / 1024 < 50;
|
<Upload
|
||||||
if (!isLt50M) {
|
showUploadList={false}
|
||||||
message.error('文件大小不能超过 50MB');
|
maxCount={5}
|
||||||
return Upload.LIST_IGNORE;
|
accept=".ppt,.pptx,.pdf,.mp4,.mov,.avi,.webm,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx"
|
||||||
}
|
beforeUpload={(file) => {
|
||||||
return false;
|
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();
|
||||||
<Button icon={<CloudUploadOutlined />}>上传文件 (最大50MB)</Button>
|
|
||||||
</Upload>
|
if (!allowedExtensions.includes(fileExt)) {
|
||||||
</Form.Item>
|
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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user