afcd18c54f
Backend: - Add NotFoundException + BusinessException, return correct HTTP status (404/400) - Add @Index on reports.project_id and reports.upload_time - Add fileSize column to reports, populate on upload, return in DTO - Cascade delete: deleting project now removes all reports (DB + files + PDFs) - Delete report: also clean up pre-rendered PDF - File upload MIME validation (extension + Content-Type) - Remove duplicate @ExceptionHandler from ReportController - Switch from System.err to SLF4J logger - Handle MethodArgumentNotValid, MissingServletRequestPart, etc. Frontend: - Remove all Docker files (project uses 宝塔 panel deployment) - Upgrade axios 1.6.8 -> 1.7.7 (CVE-2024-39338) - Remove unused @vue-office/pptx + vue-demi (see CHANGELOG for rationale) - Fix vite proxy port 37821 -> 30081 - Remove mock data fallback in production - Add upload report UI (button + modal in ProjectDetail) - Add create project UI (button + modal in ProjectList) - Add filename search box in ProjectDetail - New useApi methods: createProject, uploadReport, deleteProject, deleteReport - FilePreview/ReportCard: show fileSize (was undefined before) Docs: - Add README.md (overview, quick start, structure) - Add CHANGELOG.md (full change log + pptx removal rationale) - Include EVALUATION_REPORT.md and blog-vibe-coding.md Tests: - All 73 backend tests pass - All 43 frontend tests pass - Updated test fixtures for new API contract
150 lines
5.2 KiB
Vue
150 lines
5.2 KiB
Vue
<template>
|
|
<div
|
|
@click="$emit('select', report)"
|
|
:class="[
|
|
'group relative rounded-xl cursor-pointer transition-all duration-300 overflow-hidden',
|
|
isSelected
|
|
? 'bg-orange-600 shadow-xl shadow-orange-600/40 border-2 border-orange-500'
|
|
: 'glass border border-orange-200/50 hover:border-orange-400 hover:shadow-lg hover:-translate-y-0.5'
|
|
]"
|
|
>
|
|
<div :class="['p-4', isSelected ? 'text-white' : 'text-slate-700']">
|
|
<!-- File icon and info -->
|
|
<div class="flex items-start space-x-3">
|
|
<div :class="[
|
|
'flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center shadow-lg transition-colors',
|
|
isSelected ? 'bg-white/30' : iconClass
|
|
]">
|
|
<svg v-if="isSelected" class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
<component v-else :is="fileIconComponent" class="w-6 h-6 text-white" />
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h3
|
|
:class="[
|
|
'text-base font-semibold truncate select-none',
|
|
isSelected ? 'text-white' : 'text-slate-800 group-hover:text-orange-600'
|
|
]"
|
|
:title="report.fileName"
|
|
>
|
|
{{ report.fileName }}
|
|
</h3>
|
|
<div class="mt-2 flex items-center space-x-3">
|
|
<span :class="[
|
|
'px-3 py-1 rounded-full text-xs font-semibold',
|
|
isSelected ? 'bg-white/20 text-white' : typeBadgeClass
|
|
]">
|
|
{{ fileTypeLabel }}
|
|
</span>
|
|
<span :class="[
|
|
'text-sm',
|
|
isSelected ? 'text-white/80' : 'text-slate-500'
|
|
]">
|
|
{{ formatFileSize(report.fileSize) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Date and arrow -->
|
|
<div :class="[
|
|
'mt-4 pt-3 flex items-center justify-between',
|
|
isSelected ? 'border-t border-white/20' : 'border-t border-orange-100'
|
|
]">
|
|
<div :class="[
|
|
'flex items-center text-sm',
|
|
isSelected ? 'text-white/70' : 'text-slate-500'
|
|
]">
|
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
{{ formatDate(report.uploadTime) }}
|
|
</div>
|
|
|
|
<!-- Arrow indicator -->
|
|
<div :class="[
|
|
'w-8 h-8 rounded-full flex items-center justify-center transition-all',
|
|
isSelected ? 'bg-white/20' : 'bg-orange-100 group-hover:bg-orange-500 group-hover:text-white'
|
|
]">
|
|
<svg :class="['w-4 h-4', isSelected ? 'text-white' : 'text-orange-500']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, h } from 'vue'
|
|
|
|
const props = defineProps({
|
|
report: {
|
|
type: Object,
|
|
required: true
|
|
},
|
|
isSelected: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
})
|
|
|
|
defineEmits(['select'])
|
|
|
|
const fileIconMap = {
|
|
html: { color: 'bg-gradient-to-br from-orange-500 to-orange-600' },
|
|
md: { color: 'bg-gradient-to-br from-orange-400 to-orange-500' },
|
|
pptx: { color: 'bg-gradient-to-br from-orange-500 to-orange-600' }
|
|
}
|
|
|
|
const fileTypeLabelMap = {
|
|
html: 'HTML',
|
|
md: 'Markdown',
|
|
pptx: 'PowerPoint'
|
|
}
|
|
|
|
// File icon SVG component
|
|
const FileIcon = {
|
|
render() {
|
|
return h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' })
|
|
])
|
|
}
|
|
}
|
|
|
|
// Format uploadTime to display date
|
|
const formatDate = (isoString) => {
|
|
if (!isoString) return '未知时间'
|
|
const d = new Date(isoString)
|
|
const pad = n => String(n).padStart(2, '0')
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
}
|
|
|
|
const formatFileSize = (bytes) => {
|
|
if (bytes == null || isNaN(bytes)) return ''
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
const fileIconComponent = computed(() => FileIcon)
|
|
|
|
const iconClass = computed(() => {
|
|
const config = fileIconMap[props.report.fileType] || fileIconMap.html
|
|
return config.color
|
|
})
|
|
|
|
const fileTypeLabel = computed(() => {
|
|
return fileTypeLabelMap[props.report.fileType] || props.report.fileType.toUpperCase()
|
|
})
|
|
|
|
const typeBadgeClass = computed(() => {
|
|
const colors = {
|
|
html: 'bg-orange-100 text-orange-600',
|
|
md: 'bg-orange-100 text-orange-600',
|
|
pptx: 'bg-orange-100 text-orange-600'
|
|
}
|
|
return colors[props.report.fileType] || 'bg-orange-100 text-orange-600'
|
|
})
|
|
</script> |