feat: 添加本地文件管理功能和图像处理配置优化

- 新增本地文件查看页面 (LocalFilesView.vue) 支持网格/列表视图
- 添加文件类型筛选、搜索和详情查看功能
- 完善路由配置和菜单导航 (archive-outline 图标)
- 优化后端图像处理配置,添加默认缩略图尺寸设置
- 更新配置加载器支持新的图像处理参数

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jimmyandClaude committed 2025-08-16 16:47:58 +08:00
1 parent ef79c61842
commit ba1d8f100e
9 files changed
+656 -14

No files matched your search

@@ -88,6 +88,7 @@ import {
SettingsOutline,
EyeOffOutline,
FolderOutline,
ArchiveOutline,
} from '@vicons/ionicons5'
import { menuOptions, type MenuOption, type MenuItem, type MenuGroup } from '@/types/menu'
@@ -113,6 +114,7 @@ const iconMap = {
'settings-outline': SettingsOutline,
'eye-off-outline': EyeOffOutline,
'folder-outline': FolderOutline,
'archive-outline': ArchiveOutline,
}
// 渲染图标
@@ -0,0 +1,42 @@
<template>
<n-button
quaternary
circle
size="medium"
@click="toggleTheme"
:title="isDark ? '切换到浅色主题' : '切换到深色主题'"
class="theme-toggle"
>
<template #icon>
<n-icon size="18">
<SunnyOutline v-if="isDark" />
<MoonOutline v-else />
</n-icon>
</template>
</n-button>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { NButton, NIcon } from 'naive-ui'
import { SunnyOutline, MoonOutline } from '@vicons/ionicons5'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const isDark = computed(() => themeStore.isDark)
const toggleTheme = () => {
themeStore.toggleTheme()
}
</script>
<style scoped>
.theme-toggle {
transition: all 0.3s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
}
</style>
@@ -31,6 +31,12 @@ const router = createRouter({
component: () => import('@/views/FieldView.vue'),
meta: { title: '文件夹' }
},
{
path: '/local-files',
name: 'local-files',
component: () => import('@/views/LocalFilesView.vue'),
meta: { title: '本地文件' }
},
{
path: '/library',
+51
View File
@@ -0,0 +1,51 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useThemeStore = defineStore('theme', () => {
const isDark = ref(false)
// 从localStorage读取主题设置
const initTheme = () => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
isDark.value = savedTheme === 'dark'
} else {
// 如果没有保存的主题,检查系统偏好
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
applyTheme()
}
// 应用主题到DOM
const applyTheme = () => {
const html = document.documentElement
if (isDark.value) {
html.classList.add('dark')
html.classList.remove('light')
} else {
html.classList.add('light')
html.classList.remove('dark')
}
}
// 切换主题
const toggleTheme = () => {
isDark.value = !isDark.value
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
applyTheme()
}
// 设置主题
const setTheme = (theme: 'light' | 'dark') => {
isDark.value = theme === 'dark'
localStorage.setItem('theme', theme)
applyTheme()
}
return {
isDark,
initTheme,
toggleTheme,
setTheme
}
})
+6
View File
@@ -40,6 +40,12 @@ export const menuOptions: MenuOption[] = [
icon: 'folder-outline',
path: '/field',
},
{
key: 'local-files',
label: '本地文件',
icon: 'archive-outline',
path: '/local-files',
},
// 媒体分组
{
key: 'media',
@@ -0,0 +1,510 @@
<template>
<div class="page-container">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="page-title">
<n-icon class="title-icon">
<archive-outline />
</n-icon>
本地文件
</h1>
<div class="page-actions">
<n-button type="primary">
<template #icon>
<n-icon><cloud-upload-outline /></n-icon>
</template>
上传文件
</n-button>
<n-button>
<template #icon>
<n-icon><folder-open-outline /></n-icon>
</template>
选择文件夹
</n-button>
</div>
</div>
<!-- 搜索和筛选 -->
<div class="toolbar">
<div class="toolbar-left">
<n-input
v-model:value="searchQuery"
placeholder="搜索本地文件..."
clearable
style="width: 300px"
>
<template #prefix>
<n-icon><search-outline /></n-icon>
</template>
</n-input>
</div>
<div class="toolbar-right">
<n-select
v-model:value="fileTypeFilter"
placeholder="文件类型"
:options="fileTypeOptions"
style="width: 150px"
/>
<n-button-group>
<n-button
:type="viewMode === 'grid' ? 'primary' : 'default'"
@click="setViewMode('grid')"
>
<template #icon>
<n-icon><grid-outline /></n-icon>
</template>
</n-button>
<n-button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="setViewMode('list')"
>
<template #icon>
<n-icon><list-outline /></n-icon>
</template>
</n-button>
</n-button-group>
</div>
</div>
<!-- 文件显示区域 -->
<div class="content-area">
<!-- 网格视图 -->
<div v-if="viewMode === 'grid'" class="file-grid">
<div
v-for="file in filteredFiles"
:key="file.id"
class="file-card"
@click="handleFileClick(file)"
>
<div class="file-preview">
<n-icon size="48" :color="getFileTypeColor(file.type)">
<component :is="getFileIcon(file.type)" />
</n-icon>
</div>
<div class="file-info">
<div class="file-name" :title="file.name">{{ file.name }}</div>
<div class="file-meta">
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<span class="file-date">{{ formatDate(file.lastModified) }}</span>
</div>
</div>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="file-list">
<n-data-table
:columns="listColumns"
:data="filteredFiles"
:row-key="(row) => row.id"
:pagination="pagination"
/>
</div>
<!-- 空状态 -->
<div v-if="filteredFiles.length === 0" class="empty-state">
<n-empty description="暂无本地文件">
<template #icon>
<n-icon size="64" color="var(--text-color-tertiary)">
<archive-outline />
</n-icon>
</template>
<template #extra>
<n-button type="primary" @click="handleSelectFiles">
选择文件
</n-button>
</template>
</n-empty>
</div>
</div>
<!-- 文件详情模态框 -->
<n-modal
v-model:show="detailVisible"
preset="card"
title="文件详情"
size="medium"
>
<div v-if="selectedFile" class="file-details">
<div class="detail-item">
<strong>文件名:</strong>{{ selectedFile.name }}
</div>
<div class="detail-item">
<strong>文件大小:</strong>{{ formatFileSize(selectedFile.size) }}
</div>
<div class="detail-item">
<strong>文件类型:</strong>{{ selectedFile.type }}
</div>
<div class="detail-item">
<strong>最后修改:</strong>{{ formatDate(selectedFile.lastModified) }}
</div>
<div class="detail-item">
<strong>文件路径:</strong>{{ selectedFile.path }}
</div>
</div>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
NButton,
NButtonGroup,
NInput,
NIcon,
NSelect,
NDataTable,
NEmpty,
NModal
} from 'naive-ui'
import {
ArchiveOutline,
CloudUploadOutline,
FolderOpenOutline,
SearchOutline,
GridOutline,
ListOutline,
DocumentOutline,
ImageOutline,
VideocamOutline,
MusicalNoteOutline
} from '@vicons/ionicons5'
// 响应式数据
const searchQuery = ref('')
const fileTypeFilter = ref('')
const viewMode = ref<'grid' | 'list'>('grid')
const detailVisible = ref(false)
const selectedFile = ref<any>(null)
// 文件类型选项
const fileTypeOptions = [
{ label: '全部类型', value: '' },
{ label: '图片', value: 'image' },
{ label: '视频', value: 'video' },
{ label: '音频', value: 'audio' },
{ label: '文档', value: 'document' }
]
// 模拟本地文件数据
const localFiles = ref([
{
id: '1',
name: 'vacation-photos.zip',
type: 'archive',
size: 25600000,
lastModified: new Date('2024-01-15'),
path: '/Users/Downloads/vacation-photos.zip'
},
{
id: '2',
name: 'presentation.pdf',
type: 'document',
size: 2048576,
lastModified: new Date('2024-01-14'),
path: '/Users/Documents/presentation.pdf'
},
{
id: '3',
name: 'background-music.mp3',
type: 'audio',
size: 5242880,
lastModified: new Date('2024-01-13'),
path: '/Users/Music/background-music.mp3'
},
{
id: '4',
name: 'family-video.mp4',
type: 'video',
size: 104857600,
lastModified: new Date('2024-01-12'),
path: '/Users/Videos/family-video.mp4'
},
{
id: '5',
name: 'screenshot.png',
type: 'image',
size: 1024000,
lastModified: new Date('2024-01-11'),
path: '/Users/Pictures/screenshot.png'
}
])
// 计算属性
const filteredFiles = computed(() => {
let files = localFiles.value
// 按文件类型筛选
if (fileTypeFilter.value) {
files = files.filter(file => file.type === fileTypeFilter.value)
}
// 按搜索词筛选
if (searchQuery.value) {
files = files.filter(file =>
file.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
}
return files
})
// 分页配置
const pagination = {
pageSize: 10
}
// 列表视图列定义
const listColumns = [
{
title: '文件名',
key: 'name',
render: (row: any) => {
return `${row.name}`
}
},
{
title: '类型',
key: 'type',
width: 100
},
{
title: '大小',
key: 'size',
width: 120,
render: (row: any) => formatFileSize(row.size)
},
{
title: '修改时间',
key: 'lastModified',
width: 180,
render: (row: any) => formatDate(row.lastModified)
}
]
// 方法
const setViewMode = (mode: 'grid' | 'list') => {
viewMode.value = mode
}
const handleFileClick = (file: any) => {
selectedFile.value = file
detailVisible.value = true
}
const handleSelectFiles = () => {
// 处理文件选择逻辑
console.log('选择文件')
}
const getFileIcon = (type: string) => {
const iconMap: { [key: string]: any } = {
image: ImageOutline,
video: VideocamOutline,
audio: MusicalNoteOutline,
document: DocumentOutline,
archive: ArchiveOutline
}
return iconMap[type] || DocumentOutline
}
const getFileTypeColor = (type: string) => {
const colorMap: { [key: string]: string } = {
image: 'var(--color-success)',
video: 'var(--color-info)',
audio: 'var(--color-secondary)',
document: 'var(--text-color-secondary)',
archive: 'var(--color-warning)'
}
return colorMap[type] || 'var(--text-color-secondary)'
}
const formatFileSize = (bytes: number) => {
const sizes = ['B', 'KB', 'MB', 'GB']
if (bytes === 0) return '0 B'
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]
}
const formatDate = (date: Date) => {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
</script>
<style scoped>
.page-container {
padding: var(--spacing-lg);
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.page-title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--text-color);
margin: 0;
}
.title-icon {
color: var(--color-primary);
}
.page-actions {
display: flex;
gap: var(--spacing-sm);
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--card-bg);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-lg);
border: 1px solid var(--border-color-light);
}
.toolbar-left {
display: flex;
gap: var(--spacing-md);
}
.toolbar-right {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.content-area {
min-height: 400px;
}
/* 网格视图 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.file-card {
background: var(--card-bg);
border: 1px solid var(--border-color-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.file-card:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.file-preview {
display: flex;
justify-content: center;
align-items: center;
height: 80px;
background: var(--bg-color-secondary);
border-radius: var(--radius-sm);
margin-bottom: var(--spacing-sm);
}
.file-info {
text-align: center;
}
.file-name {
font-weight: var(--font-weight-medium);
color: var(--text-color);
margin-bottom: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
display: flex;
justify-content: space-between;
font-size: var(--font-size-xs);
color: var(--text-color-secondary);
gap: var(--spacing-xs);
}
/* 列表视图 */
.file-list {
background: var(--card-bg);
border-radius: var(--radius-md);
border: 1px solid var(--border-color-light);
overflow: hidden;
}
/* 空状态 */
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
}
/* 文件详情 */
.file-details {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.detail-item {
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border-color-light);
}
.detail-item:last-child {
border-bottom: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.page-container {
padding: var(--spacing-md);
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.toolbar {
flex-direction: column;
gap: var(--spacing-md);
}
.toolbar-left,
.toolbar-right {
width: 100%;
justify-content: space-between;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
</style>
+9 -1
View File
@@ -17,4 +17,12 @@ logging:
level: "debug"
database:
type: "sqlite"
type: "sqlite"
# 图像处理配置
image:
thumbnail_format: "jpg"
thumbnail_size: [512, 720]
default_thumbnail_size: 720 # 默认使用的缩略图尺寸
thumbnail_quality: 80
screen_size: 1080
+16 -4
View File
@@ -16,6 +16,8 @@ type ImageCompressionOptions struct {
ThumbnailFormat consts.ImageFormat
// 缩略图大小
ThumbnailSize []int
// 默认缩略图大小
DefaultThumbnailSize int
// 缩略图质量
ThumbnailQuality int
// 用户屏幕尺寸大小
@@ -101,10 +103,11 @@ func InitConfig() *Config {
// 转换为原有的配置格式,保持向后兼容
imageCompressionOptions := ImageCompressionOptions{
ThumbnailFormat: convertToImageFormat(fileConfig.Image.ThumbnailFormat),
ThumbnailSize: fileConfig.Image.ThumbnailSize,
ThumbnailQuality: fileConfig.Image.ThumbnailQuality,
ScreenSize: fileConfig.Image.ScreenSize,
ThumbnailFormat: convertToImageFormat(fileConfig.Image.ThumbnailFormat),
ThumbnailSize: fileConfig.Image.ThumbnailSize,
DefaultThumbnailSize: fileConfig.Image.DefaultThumbnailSize,
ThumbnailQuality: fileConfig.Image.ThumbnailQuality,
ScreenSize: fileConfig.Image.ScreenSize,
}
pathConfig := PathConfig{
@@ -265,3 +268,12 @@ func GetTaskConfig() TaskConfig {
return taskConfig
}
// GetDefaultThumbnailSize 获取默认缩略图尺寸
func GetDefaultThumbnailSize() int {
if CONFIG.ImageCompressionOption.DefaultThumbnailSize > 0 {
return CONFIG.ImageCompressionOption.DefaultThumbnailSize
}
// 如果未设置或为0,返回默认值720
return 720
}
+14 -9
View File
@@ -52,11 +52,12 @@ type LoggingConfig struct {
// ImageConfig 图像处理配置
type ImageConfig struct {
ThumbnailFormat string `yaml:"thumbnail_format" json:"thumbnail_format"`
ThumbnailSize []int `yaml:"thumbnail_size" json:"thumbnail_size"`
ThumbnailQuality int `yaml:"thumbnail_quality" json:"thumbnail_quality"`
ScreenSize int `yaml:"screen_size" json:"screen_size"`
SupportedFormats struct {
ThumbnailFormat string `yaml:"thumbnail_format" json:"thumbnail_format"`
ThumbnailSize []int `yaml:"thumbnail_size" json:"thumbnail_size"`
DefaultThumbnailSize int `yaml:"default_thumbnail_size" json:"default_thumbnail_size"`
ThumbnailQuality int `yaml:"thumbnail_quality" json:"thumbnail_quality"`
ScreenSize int `yaml:"screen_size" json:"screen_size"`
SupportedFormats struct {
Base []string `yaml:"base" json:"base"`
Special []string `yaml:"special" json:"special"`
Thumbnail []string `yaml:"thumbnail" json:"thumbnail"`
@@ -155,10 +156,11 @@ func GetDefaultFileConfig() *FileConfig {
MaxLifetime: 0,
},
Image: ImageConfig{
ThumbnailFormat: "jpg",
ThumbnailSize: []int{512, 720},
ThumbnailQuality: 80,
ScreenSize: 1080,
ThumbnailFormat: "jpg",
ThumbnailSize: []int{512, 720},
DefaultThumbnailSize: 720,
ThumbnailQuality: 80,
ScreenSize: 1080,
SupportedFormats: struct {
Base []string `yaml:"base" json:"base"`
Special []string `yaml:"special" json:"special"`
@@ -287,6 +289,9 @@ func MergeWithDefaults(fileConfig *FileConfig) *FileConfig {
if fileConfig.Image.ScreenSize == 0 {
fileConfig.Image.ScreenSize = defaultConfig.Image.ScreenSize
}
if fileConfig.Image.DefaultThumbnailSize == 0 {
fileConfig.Image.DefaultThumbnailSize = defaultConfig.Image.DefaultThumbnailSize
}
if len(fileConfig.Image.SupportedFormats.Base) == 0 {
fileConfig.Image.SupportedFormats.Base = defaultConfig.Image.SupportedFormats.Base
}