fix: FilePreview fileType case + Tailwind v4 gradient transparent bug

- FilePreview.vue: add normalizedFileType computed to handle backend
  returning uppercase HTML/MD/PPTX (fixes preview/download buttons)
- FilePreview.vue: bg-gradient-to-r from-orange-500 -> bg-orange-500
  (Tailwind v4 gradient + CSS variable = transparent)
- ReportCard.vue: bg-gradient-to-r -> bg-orange-600 for selected state
- Add .opencode/, node_modules/, dist/ to .gitignore
- Initial git setup for publish project
This commit is contained in:
2026-05-24 20:09:42 +08:00
commit b9137204a0
78 changed files with 12950 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
<template>
<div class="h-full flex flex-col">
<!-- Preview header -->
<div v-if="report" class="glass border-b border-orange-200/50 px-6 py-4 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<!-- File type icon -->
<div :class="iconBgClass" class="w-12 h-12 rounded-xl flex items-center justify-center shadow-lg">
<svg 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="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" />
</svg>
</div>
<div>
<h3 class="font-semibold text-slate-800 text-lg">{{ report.fileName }}</h3>
<div class="mt-1 flex items-center space-x-3 text-sm">
<span class="px-2.5 py-1 bg-orange-100 text-orange-600 rounded-full font-medium">{{ fileTypeLabel }}</span>
<span class="text-slate-500">{{ report.reportDate }}</span>
<span class="text-slate-400">·</span>
<span class="text-slate-500">{{ report.size }}</span>
</div>
</div>
</div>
<!-- Download button -->
<button
@click="downloadReport"
class="group px-5 py-2.5 bg-orange-500 text-white rounded-xl hover:bg-orange-600 transition-all shadow-lg shadow-orange-500/30 flex items-center space-x-2 hover:shadow-xl"
>
<svg class="w-5 h-5 group-hover:translate-y-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span class="font-medium">下载</span>
</button>
</div>
</div>
<!-- Preview content -->
<div class="flex-1 overflow-auto p-6 bg-slate-50/50">
<!-- HTML Preview -->
<div v-if="normalizedFileType === 'html'" class="bg-white rounded-2xl shadow-xl overflow-hidden border border-orange-200/30">
<iframe
ref="iframeRef"
:srcdoc="content"
class="w-full h-full min-h-[500px]"
sandbox="allow-same-origin"
></iframe>
</div>
<!-- Markdown Preview -->
<div v-else-if="normalizedFileType === 'md'" class="bg-white rounded-2xl shadow-xl p-8 border border-orange-200/30 prose max-w-none prose-orange">
<div v-html="renderedMarkdown"></div>
</div>
<!-- PPTX Preview (PDF iframe) -->
<div v-else-if="normalizedFileType === 'pptx'" class="bg-white rounded-2xl shadow-xl overflow-hidden h-full flex flex-col border border-orange-200/30">
<iframe
v-if="pdfUrl"
:src="pdfUrl"
class="w-full flex-1 min-h-[500px]"
type="application/pdf"
></iframe>
<div v-else class="flex items-center justify-center h-full">
<div class="text-center">
<div class="w-16 h-16 mx-auto bg-gradient-to-br from-orange-400 to-orange-600 rounded-2xl flex items-center justify-center mb-4 shadow-lg">
<svg class="w-8 h-8 text-white animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<p class="text-slate-500 font-medium">正在加载预览...</p>
</div>
</div>
</div>
<!-- Empty state -->
<div v-else class="flex items-center justify-center h-full">
<div class="text-center">
<div class="w-24 h-24 glass rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg class="w-12 h-12 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
</div>
<p class="text-slate-500 text-lg">选择一份报告以预览</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { marked } from 'marked'
import { useApi } from '../composables/useApi'
const props = defineProps({
report: {
type: Object,
default: null
},
content: {
type: String,
default: ''
}
})
const iframeRef = ref(null)
const pdfUrl = ref(null)
const { fetchReportBytes, fetchReportPdf } = useApi()
const fileTypeLabelMap = {
html: 'HTML',
md: 'Markdown',
pptx: 'PowerPoint'
}
const iconBgClassMap = {
html: 'bg-orange-500',
md: 'bg-orange-400',
pptx: 'bg-orange-500'
}
const fileTypeLabel = computed(() => {
return fileTypeLabelMap[normalizedFileType.value] || props.report?.fileType?.toUpperCase() || ''
})
const iconBgClass = computed(() => {
return iconBgClassMap[normalizedFileType.value] || iconBgClassMap.html
})
const normalizedFileType = computed(() => {
return (props.report?.fileType || '').toLowerCase()
})
const renderedMarkdown = computed(() => {
if (!props.content) return ''
return marked(props.content)
})
// Watch for report changes and load PDF preview for PPTX
watch(() => props.report, async (newReport) => {
pdfUrl.value = null
if (normalizedFileType.value === 'pptx') {
try {
const pdfBlob = await fetchReportPdf(newReport.id)
if (pdfBlob) {
pdfUrl.value = URL.createObjectURL(pdfBlob)
}
} catch (e) {
console.error('Failed to load PDF preview:', e)
}
}
}, { immediate: true })
onUnmounted(() => {
if (pdfUrl.value) {
URL.revokeObjectURL(pdfUrl.value)
}
})
const downloadReport = async () => {
if (!props.report) return
const ft = normalizedFileType.value
if (ft === 'pptx' || ft === 'html' || ft === 'md') {
try {
const bytes = await fetchReportBytes(props.report.id)
if (bytes) {
const blob = new Blob([bytes], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.report.fileName
a.click()
URL.revokeObjectURL(url)
} else {
alert('文件不存在或无法读取')
}
} catch (e) {
console.error('Download failed:', e)
alert('下载失败')
}
return
}
}
</script>
+28
View File
@@ -0,0 +1,28 @@
<template>
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="flex items-center justify-between px-6 py-4">
<div>
<h2 class="text-xl font-semibold text-gray-800">
{{ title }}
</h2>
<p v-if="subtitle" class="text-sm text-gray-500 mt-1">{{ subtitle }}</p>
</div>
<div class="flex items-center space-x-4">
<slot name="actions"></slot>
</div>
</div>
</header>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
}
})
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<div
@click="$emit('click')"
class="group relative h-[420px] rounded-3xl overflow-hidden cursor-pointer select-none transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<!-- Background Image - Full cover -->
<div
v-if="imageUrl"
class="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-105"
:style="{ backgroundImage: `url(${imageUrl})` }"
></div>
<!-- Fallback gradient background when no image -->
<div
v-else
class="absolute inset-0 bg-gradient-to-br from-orange-400 via-orange-500 to-orange-600"
></div>
<!-- Overlay gradient - bottom heavy for text readability -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
<!-- Shimmer effect on hover -->
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000"></div>
<!-- Content -->
<div class="absolute inset-0 flex flex-col justify-end p-8">
<!-- Top right icon -->
<div class="absolute top-6 right-6">
<div class="w-12 h-12 bg-white/20 backdrop-blur-md rounded-xl flex items-center justify-center">
<svg 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>
</div>
</div>
<!-- Title -->
<h3 class="text-2xl font-bold text-white mb-2 group-hover:text-orange-200 transition-colors">
{{ title }}
</h3>
<!-- Description -->
<p class="text-white/80 text-sm mb-4 line-clamp-2">
{{ description || '暂无描述' }}
</p>
<!-- Footer info -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="px-3 py-1.5 bg-white/20 backdrop-blur-sm text-white text-sm font-medium rounded-full">
{{ reportCount }} 份报告
</span>
<span class="text-white/70 text-sm">
{{ formatDate(createdAt) }}
</span>
</div>
<!-- Arrow animation on hover -->
<div class="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center group-hover:bg-orange-500 transition-all duration-300">
<svg class="w-5 h-5 text-white transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</div>
</div>
</div>
<!-- Border glow effect on hover -->
<div class="absolute inset-0 rounded-3xl border-2 border-transparent group-hover:border-orange-400/50 transition-colors duration-300 pointer-events-none"></div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
description: {
type: String,
default: ''
},
imageUrl: {
type: String,
default: ''
},
reportCount: {
type: Number,
default: 0
},
createdAt: {
type: String,
default: ''
}
})
defineEmits(['click'])
const formatDate = (dateStr) => {
if (!dateStr) return '未知时间'
try {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return dateStr
}
}
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<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'
]">
{{ report.size }}
</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>
{{ report.reportDate || '未知时间' }}
</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' })
])
}
}
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>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div>
<!-- Mobile menu button -->
<button
@click="toggleSidebar"
class="lg:hidden fixed top-4 left-4 z-50 p-2 bg-orange-500 rounded-lg shadow-md"
>
<svg 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="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Overlay -->
<div
v-if="isOpen"
@click="toggleSidebar"
class="lg:hidden fixed inset-0 bg-black/30 z-40"
></div>
<!-- Sidebar -->
<aside
:class="[
'fixed lg:static inset-y-0 left-0 z-40 w-64 bg-white border-r border-orange-200 transform transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
]"
>
<div class="flex flex-col h-full">
<!-- Logo -->
<div class="p-6 border-b border-orange-200">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-orange-400 to-orange-600 rounded-xl flex items-center justify-center shadow-lg">
<svg class="w-5 h-5 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>
</div>
<div>
<h1 class="text-lg font-bold text-orange-600">日报分发</h1>
<p class="text-xs text-orange-400">管理系统</p>
</div>
</div>
</div>
<!-- Project List -->
<div class="flex-1 overflow-y-auto p-4">
<h2 class="text-xs font-semibold text-orange-500 uppercase tracking-wider mb-3">项目目录</h2>
<ul class="space-y-1">
<li v-for="project in projects" :key="project.id">
<router-link
:to="`/project/${project.id}`"
@click="closeSidebarOnMobile"
class="flex items-center justify-between px-3 py-2.5 rounded-lg transition-all duration-200 select-none cursor-pointer hover:bg-orange-50"
:class="isActiveProject(project.id) ? 'bg-orange-100 text-orange-700 font-medium' : 'text-orange-800'"
>
<span class="truncate font-medium">{{ project.name }}</span>
<span class="text-xs px-2 py-0.5 bg-orange-200 text-orange-700 rounded-full">{{ project.reportCount }}</span>
</router-link>
</li>
</ul>
</div>
<!-- Footer -->
<div class="p-4 border-t border-orange-200 text-center">
<p class="text-xs text-orange-400">v1.0.0</p>
</div>
</div>
</aside>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
projects: {
type: Array,
default: () => []
}
})
const route = useRoute()
const isOpen = ref(false)
const isActiveProject = (projectId) => {
return route.params.id == projectId
}
const toggleSidebar = () => {
isOpen.value = !isOpen.value
}
const closeSidebarOnMobile = () => {
if (window.innerWidth < 1024) {
isOpen.value = false
}
}
</script>
<style scoped>
.select-none {
user-select: none;
-webkit-user-select: none;
}
</style>