Files
daily_publish/src/components/ReportCard.vue
T
panda afcd18c54f fix: evaluation report P0/P1/P2 fixes, remove Docker, add upload UI
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
2026-06-01 21:35:13 +08:00

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>