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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user