This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from unfold.admin import ModelAdmin
|
||||
from unfold.decorators import display
|
||||
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, ScoreFormula
|
||||
@@ -37,6 +38,31 @@ class ProjectFileInline(admin.TabularInline):
|
||||
model = ProjectFile
|
||||
extra = 0
|
||||
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)
|
||||
|
||||
@@ -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 = 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)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
|
||||
from shop.serializers import WeChatUserSerializer
|
||||
import uuid
|
||||
|
||||
class ScoreDimensionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -53,7 +54,9 @@ class ProjectFileSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
from django.conf import settings
|
||||
file_obj = validated_data.get('file')
|
||||
|
||||
if file_obj:
|
||||
ext = file_obj.name.split('.')[-1].lower() if '.' in file_obj.name else ''
|
||||
if ext in ['ppt', 'pptx']:
|
||||
@@ -69,6 +72,22 @@ class ProjectFileSerializer(serializers.ModelSerializer):
|
||||
|
||||
if not validated_data.get('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)
|
||||
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -352,11 +352,253 @@
|
||||
</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
<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>
|
||||
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 => {
|
||||
let iconClass = 'fas fa-file';
|
||||
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';
|
||||
iconColor = 'text-orange-500';
|
||||
} else if (file.file_type === 'pdf') {
|
||||
canPreview = true;
|
||||
} else if (fileType === 'pdf') {
|
||||
iconClass = 'fas fa-file-pdf';
|
||||
iconColor = 'text-red-500';
|
||||
} else if (file.file_type === 'video') {
|
||||
canPreview = true;
|
||||
} else if (fileType === 'video') {
|
||||
iconClass = 'fas fa-video';
|
||||
iconColor = 'text-purple-500';
|
||||
} else if (file.file_type === 'image') {
|
||||
} else if (fileType === 'image') {
|
||||
iconClass = 'fas fa-image';
|
||||
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 `
|
||||
<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>
|
||||
<span class="flex-1 text-sm font-medium text-gray-700 truncate">${file.name || '未命名文件'}</span>
|
||||
<i class="fas fa-external-link-alt text-gray-400 group-hover:text-blue-500"></i>
|
||||
</a>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm font-medium text-gray-700 block truncate">${fileName}</span>
|
||||
</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('');
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,6 @@ from .serializers import (
|
||||
ProjectSerializer, ProjectFileSerializer,
|
||||
ScoreSerializer, CommentSerializer, ScoreDimensionSerializer
|
||||
)
|
||||
import uuid
|
||||
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
@@ -206,21 +205,7 @@ class ProjectFileViewSet(viewsets.ModelViewSet):
|
||||
|
||||
if not user or project.contestant.user != user:
|
||||
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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user