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.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)

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 = 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)

View File

@@ -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):

View File

@@ -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 {

View File

@@ -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()