修复扫描速度慢,优化仓库显示
This commit is contained in:
@@ -62,6 +62,19 @@ class ConfigManager {
|
|||||||
// 检查配置文件是否存在
|
// 检查配置文件是否存在
|
||||||
await fs.access(this.configDir)
|
await fs.access(this.configDir)
|
||||||
logger.info('用户配置文件已存在')
|
logger.info('用户配置文件已存在')
|
||||||
|
|
||||||
|
// 验证配置文件是否有效
|
||||||
|
try {
|
||||||
|
const configData = await fs.readFile(this.configDir, 'utf8')
|
||||||
|
if (!configData || configData.trim() === '') {
|
||||||
|
throw new Error('配置文件为空')
|
||||||
|
}
|
||||||
|
JSON.parse(configData)
|
||||||
|
logger.info('配置文件验证通过')
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.warn('配置文件损坏,将重新创建:', parseError.message)
|
||||||
|
await this.createDefaultConfig()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 配置文件不存在,创建默认配置
|
// 配置文件不存在,创建默认配置
|
||||||
logger.info('创建默认用户配置文件...')
|
logger.info('创建默认用户配置文件...')
|
||||||
@@ -118,21 +131,46 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configData = await fs.readFile(this.configDir, 'utf8')
|
const configData = await fs.readFile(this.configDir, 'utf8')
|
||||||
|
|
||||||
|
// 检查文件内容是否为空或损坏
|
||||||
|
if (!configData || configData.trim() === '') {
|
||||||
|
logger.warn('配置文件为空,重新创建默认配置...')
|
||||||
|
await this.createDefaultConfig()
|
||||||
|
return this.defaultConfig
|
||||||
|
}
|
||||||
|
|
||||||
const config = JSON.parse(configData)
|
const config = JSON.parse(configData)
|
||||||
|
|
||||||
// 合并默认配置,确保所有必要的字段都存在
|
// 合并默认配置,确保所有必要的字段都存在
|
||||||
return { ...this.defaultConfig, ...config }
|
return { ...this.defaultConfig, ...config }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('读取配置文件失败:', error)
|
logger.error('读取配置文件失败:', error)
|
||||||
logger.info('使用默认配置...')
|
logger.info('配置文件可能损坏,尝试重新创建...')
|
||||||
// 如果读取失败,尝试创建默认配置
|
|
||||||
|
// 如果读取失败,尝试备份损坏的文件并创建默认配置
|
||||||
try {
|
try {
|
||||||
|
// 备份损坏的配置文件
|
||||||
|
const backupPath = this.configDir + '.backup.' + Date.now()
|
||||||
|
try {
|
||||||
|
await fs.copyFile(this.configDir, backupPath)
|
||||||
|
logger.info(`已备份损坏的配置文件到: ${backupPath}`)
|
||||||
|
} catch (backupError) {
|
||||||
|
logger.warn('备份损坏的配置文件失败:', backupError.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除损坏的配置文件
|
||||||
|
try {
|
||||||
|
await fs.unlink(this.configDir)
|
||||||
|
} catch (unlinkError) {
|
||||||
|
logger.warn('删除损坏的配置文件失败:', unlinkError.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的默认配置
|
||||||
await this.createDefaultConfig()
|
await this.createDefaultConfig()
|
||||||
return { ...this.defaultConfig }
|
return this.defaultConfig
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
logger.error('创建默认配置也失败:', createError)
|
logger.error('创建默认配置失败:', createError)
|
||||||
// 最后返回内存中的默认配置
|
return this.defaultConfig
|
||||||
return { ...this.defaultConfig }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,74 @@ router.get('/stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 快速扫描 - 仅获取基本信息
|
||||||
|
router.get('/quick-scan', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await repositoryService.quickScan()
|
||||||
|
res.json(ResponseUtil.success(result))
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json(ResponseUtil.error(error.message))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 完整扫描 - 支持并发和缓存
|
||||||
|
router.post('/scan', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
maxConcurrency = 5, // 减少默认并发数
|
||||||
|
useCache = true,
|
||||||
|
forceRefresh = false
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
const result = await repositoryService.scanRepository({
|
||||||
|
maxConcurrency: parseInt(maxConcurrency),
|
||||||
|
useCache: useCache === true,
|
||||||
|
forceRefresh: forceRefresh === true,
|
||||||
|
progressCallback: (progress) => {
|
||||||
|
// 可以通过 WebSocket 发送进度更新
|
||||||
|
console.log('扫描进度:', progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(ResponseUtil.success(result))
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json(ResponseUtil.error(error.message))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 增量扫描 - 只扫描变更的目录和文件
|
||||||
|
router.post('/incremental-scan', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
maxConcurrency = 5, // 减少默认并发数
|
||||||
|
useCache = true
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
const result = await repositoryService.incrementalScan({
|
||||||
|
maxConcurrency: parseInt(maxConcurrency),
|
||||||
|
useCache: useCache === true,
|
||||||
|
progressCallback: (progress) => {
|
||||||
|
// 可以通过 WebSocket 发送进度更新
|
||||||
|
console.log('增量扫描进度:', progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(ResponseUtil.success(result))
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json(ResponseUtil.error(error.message))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清除扫描缓存
|
||||||
|
router.post('/clear-scan-cache', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await repositoryService.clearScanCache()
|
||||||
|
res.json(ResponseUtil.success(result))
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json(ResponseUtil.error(error.message))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 清除磁盘使用情况缓存
|
// 清除磁盘使用情况缓存
|
||||||
router.post('/stats/clear-cache', async (req, res) => {
|
router.post('/stats/clear-cache', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置文件修复脚本
|
||||||
|
* 用于检查和修复损坏的配置文件
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs').promises
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
async function fixConfig() {
|
||||||
|
try {
|
||||||
|
console.log('🔧 开始检查配置文件...')
|
||||||
|
|
||||||
|
// 检测配置文件路径
|
||||||
|
const isPkg = process.pkg !== undefined
|
||||||
|
let configPath
|
||||||
|
|
||||||
|
if (isPkg) {
|
||||||
|
configPath = path.join(process.cwd(), 'data', 'user-config.json')
|
||||||
|
} else {
|
||||||
|
configPath = path.join(__dirname, '..', 'config', 'user-config.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📁 配置文件路径: ${configPath}`)
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
try {
|
||||||
|
await fs.access(configPath)
|
||||||
|
console.log('✅ 配置文件存在')
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ 配置文件不存在,将创建默认配置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件内容
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(configPath, 'utf8')
|
||||||
|
|
||||||
|
if (!content || content.trim() === '') {
|
||||||
|
console.log('⚠️ 配置文件为空')
|
||||||
|
throw new Error('配置文件为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试解析JSON
|
||||||
|
const config = JSON.parse(content)
|
||||||
|
console.log('✅ 配置文件格式正确')
|
||||||
|
console.log('📋 配置内容:', JSON.stringify(config, null, 2))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ 配置文件损坏:', error.message)
|
||||||
|
|
||||||
|
// 备份损坏的文件
|
||||||
|
const backupPath = configPath + '.backup.' + Date.now()
|
||||||
|
try {
|
||||||
|
await fs.copyFile(configPath, backupPath)
|
||||||
|
console.log(`💾 已备份损坏的配置文件到: ${backupPath}`)
|
||||||
|
} catch (backupError) {
|
||||||
|
console.log('⚠️ 备份失败:', backupError.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建默认配置
|
||||||
|
const defaultConfig = {
|
||||||
|
downloadDir: "./downloads",
|
||||||
|
fileStructure: "artist/artwork",
|
||||||
|
namingPattern: "{artist_name}/{artwork_id}_{title}",
|
||||||
|
maxFileSize: 0,
|
||||||
|
allowedExtensions: [".jpg", ".png", ".gif", ".webp"],
|
||||||
|
autoMigration: false,
|
||||||
|
migrationRules: [],
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
const configDir = path.dirname(configPath)
|
||||||
|
await fs.mkdir(configDir, { recursive: true })
|
||||||
|
|
||||||
|
// 写入默认配置
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8')
|
||||||
|
console.log('✅ 已创建默认配置文件')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎉 配置文件检查完成')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 修复配置文件失败:', error.message)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果直接运行此脚本
|
||||||
|
if (require.main === module) {
|
||||||
|
fixConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fixConfig }
|
||||||
+524
-55
@@ -18,6 +18,10 @@ class RepositoryService {
|
|||||||
this.configManager = new ConfigManager()
|
this.configManager = new ConfigManager()
|
||||||
this.config = null
|
this.config = null
|
||||||
|
|
||||||
|
// 配置加载状态
|
||||||
|
this.configLoaded = false
|
||||||
|
this.configLoading = false
|
||||||
|
|
||||||
// 磁盘使用情况缓存
|
// 磁盘使用情况缓存
|
||||||
this.diskUsageCache = {
|
this.diskUsageCache = {
|
||||||
data: null,
|
data: null,
|
||||||
@@ -25,8 +29,16 @@ class RepositoryService {
|
|||||||
cacheDuration: 5 * 60 * 1000 // 5分钟缓存
|
cacheDuration: 5 * 60 * 1000 // 5分钟缓存
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文件扫描缓存
|
||||||
|
this.scanCache = {
|
||||||
|
data: null,
|
||||||
|
timestamp: 0,
|
||||||
|
cacheDuration: 10 * 60 * 1000 // 10分钟缓存
|
||||||
|
}
|
||||||
|
|
||||||
// 缓存文件路径
|
// 缓存文件路径
|
||||||
this.cacheFilePath = null
|
this.cacheFilePath = null
|
||||||
|
this.scanCacheFilePath = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前工作目录(基于配置)
|
// 获取当前工作目录(基于配置)
|
||||||
@@ -56,9 +68,11 @@ class RepositoryService {
|
|||||||
|
|
||||||
// 初始化缓存文件路径
|
// 初始化缓存文件路径
|
||||||
this.cacheFilePath = path.join(path.dirname(this.configManager.getConfigPath()), 'disk-usage-cache.json')
|
this.cacheFilePath = path.join(path.dirname(this.configManager.getConfigPath()), 'disk-usage-cache.json')
|
||||||
|
this.scanCacheFilePath = path.join(path.dirname(this.configManager.getConfigPath()), 'scan-cache.json')
|
||||||
|
|
||||||
// 加载持久化缓存
|
// 加载持久化缓存
|
||||||
await this.loadPersistentCache()
|
await this.loadPersistentCache()
|
||||||
|
await this.loadScanCache()
|
||||||
|
|
||||||
return { success: true, message: '仓库初始化成功' }
|
return { success: true, message: '仓库初始化成功' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -66,12 +80,31 @@ class RepositoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载配置
|
// 加载配置 - 优化版本,支持缓存和防重复加载
|
||||||
async loadConfig() {
|
async loadConfig() {
|
||||||
|
// 如果配置已加载,直接返回
|
||||||
|
if (this.configLoaded && this.config) {
|
||||||
|
return this.config
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在加载,等待加载完成
|
||||||
|
if (this.configLoading) {
|
||||||
|
while (this.configLoading) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
}
|
||||||
|
return this.config
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始加载配置
|
||||||
|
this.configLoading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.config = await this.configManager.readConfig()
|
this.config = await this.configManager.readConfig()
|
||||||
|
this.configLoaded = true
|
||||||
|
logger.info('配置加载成功')
|
||||||
|
return this.config
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('加载配置失败:', error)
|
logger.error('加载配置失败,使用默认配置:', error)
|
||||||
// 如果加载失败,使用默认配置对象
|
// 如果加载失败,使用默认配置对象
|
||||||
this.config = {
|
this.config = {
|
||||||
downloadDir: "./downloads",
|
downloadDir: "./downloads",
|
||||||
@@ -83,6 +116,10 @@ class RepositoryService {
|
|||||||
migrationRules: [],
|
migrationRules: [],
|
||||||
lastUpdated: new Date().toISOString()
|
lastUpdated: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
this.configLoaded = true
|
||||||
|
return this.config
|
||||||
|
} finally {
|
||||||
|
this.configLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,108 +171,228 @@ class RepositoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扫描仓库
|
// 扫描仓库 - 优化版本,支持并发扫描
|
||||||
async scanRepository() {
|
async scanRepository(options = {}) {
|
||||||
|
const {
|
||||||
|
maxConcurrency = 5, // 减少默认并发数,避免文件句柄过多
|
||||||
|
useCache = true,
|
||||||
|
forceRefresh = false,
|
||||||
|
progressCallback = null
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
if (useCache && !forceRefresh) {
|
||||||
|
const cachedResult = await this.getCachedScanResult()
|
||||||
|
if (cachedResult) {
|
||||||
|
logger.info('使用缓存的扫描结果')
|
||||||
|
return cachedResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const artworks = []
|
const artworks = []
|
||||||
const artists = new Set()
|
const artists = new Set()
|
||||||
let totalSize = 0
|
let totalSize = 0
|
||||||
|
let processedArtists = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 确保配置已加载
|
// 确保配置已加载(使用缓存版本)
|
||||||
await this.loadConfig()
|
if (!this.configLoaded) {
|
||||||
|
await this.loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
// 使用当前配置的目录
|
// 使用当前配置的目录
|
||||||
const currentBaseDir = this.getCurrentBaseDir()
|
const currentBaseDir = this.getCurrentBaseDir()
|
||||||
|
|
||||||
// 扫描作者目录
|
// 扫描作者目录
|
||||||
const artistEntries = await fs.readdir(currentBaseDir, { withFileTypes: true })
|
const artistEntries = await fs.readdir(currentBaseDir, { withFileTypes: true })
|
||||||
|
const artistDirs = artistEntries
|
||||||
|
.filter(entry => entry.isDirectory() &&
|
||||||
|
!entry.name.startsWith('.') &&
|
||||||
|
entry.name !== '.repository-config.json')
|
||||||
|
.map(entry => ({
|
||||||
|
name: entry.name,
|
||||||
|
path: path.join(currentBaseDir, entry.name)
|
||||||
|
}))
|
||||||
|
|
||||||
for (const artistEntry of artistEntries) {
|
logger.info(`开始并发扫描 ${artistDirs.length} 个作者目录`)
|
||||||
if (!artistEntry.isDirectory()) continue
|
|
||||||
|
|
||||||
// 跳过配置文件和隐藏文件
|
// 并发处理作者目录
|
||||||
if (artistEntry.name.startsWith('.') || artistEntry.name === '.repository-config.json') {
|
const artistPromises = artistDirs.map(async (artistDir) => {
|
||||||
continue
|
try {
|
||||||
}
|
const artistName = artistDir.name
|
||||||
|
const artistPath = artistDir.path
|
||||||
|
|
||||||
const artistName = artistEntry.name
|
// 扫描作者下的作品目录
|
||||||
const artistPath = path.join(currentBaseDir, artistName)
|
const artworkEntries = await fs.readdir(artistPath, { withFileTypes: true })
|
||||||
|
const artworkDirs = artworkEntries
|
||||||
|
.filter(entry => entry.isDirectory())
|
||||||
|
.map(entry => ({
|
||||||
|
name: entry.name,
|
||||||
|
path: path.join(artistPath, entry.name)
|
||||||
|
}))
|
||||||
|
|
||||||
// 扫描作者下的作品目录
|
// 并发扫描作品文件
|
||||||
const artworkEntries = await fs.readdir(artistPath, { withFileTypes: true })
|
const artworkPromises = artworkDirs.map(async (artworkDir) => {
|
||||||
|
try {
|
||||||
|
const fullPath = artworkDir.path
|
||||||
|
|
||||||
for (const artworkEntry of artworkEntries) {
|
// 检查是否是作品目录(包含数字ID)
|
||||||
if (!artworkEntry.isDirectory()) continue
|
const artworkMatch = artworkDir.name.match(/^(\d+)_(.+)$/)
|
||||||
|
if (!artworkMatch) return null
|
||||||
|
|
||||||
const fullPath = path.join(artistPath, artworkEntry.name)
|
const artworkId = artworkMatch[1]
|
||||||
|
const title = artworkMatch[2]
|
||||||
|
|
||||||
// 检查是否是作品目录(包含数字ID)
|
// 扫描作品文件
|
||||||
const artworkMatch = artworkEntry.name.match(/^(\d+)_(.+)$/)
|
const files = await this.scanArtworkFiles(fullPath)
|
||||||
if (artworkMatch) {
|
|
||||||
const artworkId = artworkMatch[1]
|
|
||||||
const title = artworkMatch[2]
|
|
||||||
|
|
||||||
// 扫描作品文件
|
if (files.length > 0) {
|
||||||
const files = await this.scanArtworkFiles(fullPath)
|
const artworkSize = files.reduce((sum, file) => sum + file.size, 0)
|
||||||
|
return {
|
||||||
if (files.length > 0) {
|
id: artworkId,
|
||||||
artworks.push({
|
title: title,
|
||||||
id: artworkId,
|
artist: artistName,
|
||||||
title: title,
|
artistPath: artistPath,
|
||||||
artist: artistName,
|
path: fullPath,
|
||||||
artistPath: artistPath,
|
files: files,
|
||||||
path: fullPath,
|
size: artworkSize,
|
||||||
files: files,
|
createdAt: await this.getFileCreationTime(fullPath)
|
||||||
size: files.reduce((sum, file) => sum + file.size, 0),
|
}
|
||||||
createdAt: await this.getFileCreationTime(fullPath)
|
}
|
||||||
})
|
return null
|
||||||
artists.add(artistName)
|
} catch (error) {
|
||||||
totalSize += files.reduce((sum, file) => sum + file.size, 0)
|
logger.warn(`扫描作品目录失败 ${artworkDir.path}:`, error.message)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 等待所有作品扫描完成
|
||||||
|
const artworkResults = await Promise.all(artworkPromises)
|
||||||
|
const validArtworks = artworkResults.filter(artwork => artwork !== null)
|
||||||
|
|
||||||
|
processedArtists++
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback({
|
||||||
|
type: 'artist_completed',
|
||||||
|
artist: artistName,
|
||||||
|
artworkCount: validArtworks.length,
|
||||||
|
progress: Math.round((processedArtists / artistDirs.length) * 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return validArtworks
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`扫描作者目录失败 ${artistDir.path}:`, error.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 分批处理,避免过多并发
|
||||||
|
const batchSize = maxConcurrency
|
||||||
|
for (let i = 0; i < artistPromises.length; i += batchSize) {
|
||||||
|
const batch = artistPromises.slice(i, i + batchSize)
|
||||||
|
const batchResults = await Promise.all(batch)
|
||||||
|
|
||||||
|
// 处理批次结果
|
||||||
|
for (const artistArtworks of batchResults) {
|
||||||
|
for (const artwork of artistArtworks) {
|
||||||
|
artworks.push(artwork)
|
||||||
|
artists.add(artwork.artist)
|
||||||
|
totalSize += artwork.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新进度
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback({
|
||||||
|
type: 'batch_completed',
|
||||||
|
processed: Math.min(i + batchSize, artistDirs.length),
|
||||||
|
total: artistDirs.length,
|
||||||
|
progress: Math.round((Math.min(i + batchSize, artistDirs.length) / artistDirs.length) * 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
artworks,
|
artworks,
|
||||||
artists: Array.from(artists),
|
artists: Array.from(artists),
|
||||||
totalSize
|
totalSize,
|
||||||
|
scanTime: Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 缓存结果
|
||||||
|
if (useCache) {
|
||||||
|
await this.cacheScanResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`扫描完成: ${artworks.length} 个作品, ${artists.size} 个作者, 总大小: ${Math.round(totalSize / 1024 / 1024)}MB`)
|
||||||
|
|
||||||
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`扫描仓库失败: ${error.message}`)
|
throw new Error(`扫描仓库失败: ${error.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扫描作品文件
|
// 扫描作品文件 - 优化版本,支持并发扫描和批量统计
|
||||||
async scanArtworkFiles(artworkPath) {
|
async scanArtworkFiles(artworkPath) {
|
||||||
try {
|
try {
|
||||||
// 确保配置已加载
|
// 确保配置已加载(使用缓存版本)
|
||||||
await this.loadConfig()
|
if (!this.configLoaded) {
|
||||||
|
await this.loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
const files = []
|
|
||||||
const entries = await fs.readdir(artworkPath, { withFileTypes: true })
|
const entries = await fs.readdir(artworkPath, { withFileTypes: true })
|
||||||
|
const fileEntries = entries.filter(entry => entry.isFile())
|
||||||
|
|
||||||
for (const entry of entries) {
|
// 过滤允许的扩展名
|
||||||
if (entry.isFile()) {
|
const allowedFiles = fileEntries.filter(entry => {
|
||||||
const filePath = path.join(artworkPath, entry.name)
|
const ext = path.extname(entry.name).toLowerCase()
|
||||||
const ext = path.extname(entry.name).toLowerCase()
|
return this.config.allowedExtensions.includes(ext)
|
||||||
|
})
|
||||||
|
|
||||||
if (this.config.allowedExtensions.includes(ext)) {
|
if (allowedFiles.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大幅减少并发数量,避免 "too many open files" 错误
|
||||||
|
const batchSize = 3 // 进一步减少到3,更安全
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
for (let i = 0; i < allowedFiles.length; i += batchSize) {
|
||||||
|
const batch = allowedFiles.slice(i, i + batchSize)
|
||||||
|
|
||||||
|
// 处理当前批次
|
||||||
|
const batchPromises = batch.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(artworkPath, entry.name)
|
||||||
const stats = await fs.stat(filePath)
|
const stats = await fs.stat(filePath)
|
||||||
const currentBaseDir = this.getCurrentBaseDir()
|
const currentBaseDir = this.getCurrentBaseDir()
|
||||||
files.push({
|
|
||||||
|
return {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
path: path.relative(currentBaseDir, filePath),
|
path: path.relative(currentBaseDir, filePath),
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
extension: ext,
|
extension: path.extname(entry.name).toLowerCase(),
|
||||||
modifiedAt: stats.mtime
|
modifiedAt: stats.mtime
|
||||||
})
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`获取文件统计信息失败 ${entry.name}:`, error.message)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchResults = await Promise.all(batchPromises)
|
||||||
|
results.push(...batchResults)
|
||||||
|
|
||||||
|
// 添加小延迟,让系统有时间关闭文件句柄
|
||||||
|
if (i + batchSize < allowedFiles.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20)) // 增加延迟时间
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return files
|
return results.filter(file => file !== null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.warn(`扫描作品文件失败 ${artworkPath}:`, error.message)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1201,318 @@ class RepositoryService {
|
|||||||
const relativePath = path.relative(this.baseDir, filePath)
|
const relativePath = path.relative(this.baseDir, filePath)
|
||||||
return `/api/repository/preview?path=${encodeURIComponent(relativePath)}`
|
return `/api/repository/preview?path=${encodeURIComponent(relativePath)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取缓存的扫描结果
|
||||||
|
async getCachedScanResult() {
|
||||||
|
try {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// 检查内存缓存
|
||||||
|
if (this.scanCache.data &&
|
||||||
|
(now - this.scanCache.timestamp) < this.scanCache.cacheDuration) {
|
||||||
|
return this.scanCache.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查持久化缓存
|
||||||
|
if (this.scanCacheFilePath && await fs.access(this.scanCacheFilePath).then(() => true).catch(() => false)) {
|
||||||
|
const cacheData = await fs.readFile(this.scanCacheFilePath, 'utf8')
|
||||||
|
const cache = JSON.parse(cacheData)
|
||||||
|
|
||||||
|
// 检查缓存是否有效
|
||||||
|
const cacheAge = now - cache.timestamp
|
||||||
|
if (cacheAge < this.scanCache.cacheDuration) {
|
||||||
|
this.scanCache.data = cache.data
|
||||||
|
this.scanCache.timestamp = cache.timestamp
|
||||||
|
return cache.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('获取扫描缓存失败:', error.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存扫描结果
|
||||||
|
async cacheScanResult(result) {
|
||||||
|
try {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// 更新内存缓存
|
||||||
|
this.scanCache.data = result
|
||||||
|
this.scanCache.timestamp = now
|
||||||
|
|
||||||
|
// 保存到持久化缓存
|
||||||
|
if (this.scanCacheFilePath) {
|
||||||
|
const cacheData = {
|
||||||
|
data: result,
|
||||||
|
timestamp: now,
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(this.scanCacheFilePath, JSON.stringify(cacheData, null, 2), 'utf8')
|
||||||
|
logger.info('扫描结果已缓存')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('缓存扫描结果失败:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载扫描缓存
|
||||||
|
async loadScanCache() {
|
||||||
|
try {
|
||||||
|
if (!this.scanCacheFilePath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheData = await fs.readFile(this.scanCacheFilePath, 'utf8')
|
||||||
|
const cache = JSON.parse(cacheData)
|
||||||
|
|
||||||
|
// 检查缓存是否有效(10分钟内)
|
||||||
|
const now = Date.now()
|
||||||
|
const cacheAge = now - cache.timestamp
|
||||||
|
const maxCacheAge = this.scanCache.cacheDuration
|
||||||
|
|
||||||
|
if (cacheAge < maxCacheAge) {
|
||||||
|
this.scanCache.data = cache.data
|
||||||
|
this.scanCache.timestamp = cache.timestamp
|
||||||
|
logger.info('已加载扫描缓存,缓存年龄:', Math.round(cacheAge / 1000 / 60), '分钟')
|
||||||
|
} else {
|
||||||
|
logger.info('扫描缓存已过期,将重新扫描')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.info('加载扫描缓存失败,将重新扫描:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除扫描缓存
|
||||||
|
async clearScanCache() {
|
||||||
|
try {
|
||||||
|
// 清除内存缓存
|
||||||
|
this.scanCache.data = null
|
||||||
|
this.scanCache.timestamp = 0
|
||||||
|
|
||||||
|
// 删除持久化缓存文件
|
||||||
|
if (this.scanCacheFilePath) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(this.scanCacheFilePath)
|
||||||
|
logger.info('扫描缓存文件已删除')
|
||||||
|
} catch (error) {
|
||||||
|
logger.info('删除扫描缓存文件失败:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: '扫描缓存已清除' }
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`清除扫描缓存失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快速扫描 - 仅获取基本信息,不扫描文件详情
|
||||||
|
async quickScan() {
|
||||||
|
try {
|
||||||
|
// 确保配置已加载(使用缓存版本)
|
||||||
|
if (!this.configLoaded) {
|
||||||
|
await this.loadConfig()
|
||||||
|
}
|
||||||
|
const currentBaseDir = this.getCurrentBaseDir()
|
||||||
|
|
||||||
|
const artistEntries = await fs.readdir(currentBaseDir, { withFileTypes: true })
|
||||||
|
const artistDirs = artistEntries
|
||||||
|
.filter(entry => entry.isDirectory() &&
|
||||||
|
!entry.name.startsWith('.') &&
|
||||||
|
entry.name !== '.repository-config.json')
|
||||||
|
|
||||||
|
const artists = artistDirs.map(entry => entry.name)
|
||||||
|
let totalArtworks = 0
|
||||||
|
|
||||||
|
// 快速统计作品数量
|
||||||
|
for (const artistDir of artistDirs) {
|
||||||
|
try {
|
||||||
|
const artistPath = path.join(currentBaseDir, artistDir.name)
|
||||||
|
const artworkEntries = await fs.readdir(artistPath, { withFileTypes: true })
|
||||||
|
const artworkDirs = artworkEntries.filter(entry => entry.isDirectory())
|
||||||
|
totalArtworks += artworkDirs.length
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`快速扫描作者目录失败 ${artistDir.name}:`, error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalArtists: artists.length,
|
||||||
|
totalArtworks,
|
||||||
|
artists,
|
||||||
|
scanTime: Date.now()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`快速扫描失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增量扫描 - 只扫描变更的目录和文件
|
||||||
|
async incrementalScan(options = {}) {
|
||||||
|
const {
|
||||||
|
maxConcurrency = 10,
|
||||||
|
useCache = true,
|
||||||
|
progressCallback = null
|
||||||
|
} = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 确保配置已加载(使用缓存版本)
|
||||||
|
if (!this.configLoaded) {
|
||||||
|
await this.loadConfig()
|
||||||
|
}
|
||||||
|
const currentBaseDir = this.getCurrentBaseDir()
|
||||||
|
|
||||||
|
// 获取上次扫描的时间戳
|
||||||
|
const lastScanTime = this.scanCache.timestamp || 0
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// 如果缓存时间超过1小时,执行完整扫描
|
||||||
|
if (now - lastScanTime > 60 * 60 * 1000) {
|
||||||
|
logger.info('缓存过期,执行完整扫描')
|
||||||
|
return await this.scanRepository(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
const artworks = []
|
||||||
|
const artists = new Set()
|
||||||
|
let totalSize = 0
|
||||||
|
let changedCount = 0
|
||||||
|
|
||||||
|
// 扫描作者目录
|
||||||
|
const artistEntries = await fs.readdir(currentBaseDir, { withFileTypes: true })
|
||||||
|
const artistDirs = artistEntries
|
||||||
|
.filter(entry => entry.isDirectory() &&
|
||||||
|
!entry.name.startsWith('.') &&
|
||||||
|
entry.name !== '.repository-config.json')
|
||||||
|
|
||||||
|
logger.info(`开始增量扫描 ${artistDirs.length} 个作者目录`)
|
||||||
|
|
||||||
|
// 并发处理作者目录
|
||||||
|
const artistPromises = artistDirs.map(async (artistDir) => {
|
||||||
|
try {
|
||||||
|
const artistName = artistDir.name
|
||||||
|
const artistPath = path.join(currentBaseDir, artistName)
|
||||||
|
|
||||||
|
// 检查作者目录是否在缓存时间后有变更
|
||||||
|
const artistStats = await fs.stat(artistPath)
|
||||||
|
if (artistStats.mtime.getTime() <= lastScanTime) {
|
||||||
|
// 目录未变更,跳过
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
changedCount++
|
||||||
|
logger.debug(`检测到变更的作者目录: ${artistName}`)
|
||||||
|
|
||||||
|
// 扫描作者下的作品目录
|
||||||
|
const artworkEntries = await fs.readdir(artistPath, { withFileTypes: true })
|
||||||
|
const artworkDirs = artworkEntries
|
||||||
|
.filter(entry => entry.isDirectory())
|
||||||
|
.map(entry => ({
|
||||||
|
name: entry.name,
|
||||||
|
path: path.join(artistPath, entry.name)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 并发扫描作品文件
|
||||||
|
const artworkPromises = artworkDirs.map(async (artworkDir) => {
|
||||||
|
try {
|
||||||
|
const fullPath = artworkDir.path
|
||||||
|
|
||||||
|
// 检查作品目录是否在缓存时间后有变更
|
||||||
|
const artworkStats = await fs.stat(fullPath)
|
||||||
|
if (artworkStats.mtime.getTime() <= lastScanTime) {
|
||||||
|
// 作品目录未变更,跳过
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是作品目录(包含数字ID)
|
||||||
|
const artworkMatch = artworkDir.name.match(/^(\d+)_(.+)$/)
|
||||||
|
if (!artworkMatch) return null
|
||||||
|
|
||||||
|
const artworkId = artworkMatch[1]
|
||||||
|
const title = artworkMatch[2]
|
||||||
|
|
||||||
|
// 扫描作品文件
|
||||||
|
const files = await this.scanArtworkFiles(fullPath)
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
const artworkSize = files.reduce((sum, file) => sum + file.size, 0)
|
||||||
|
return {
|
||||||
|
id: artworkId,
|
||||||
|
title: title,
|
||||||
|
artist: artistName,
|
||||||
|
artistPath: artistPath,
|
||||||
|
path: fullPath,
|
||||||
|
files: files,
|
||||||
|
size: artworkSize,
|
||||||
|
createdAt: await this.getFileCreationTime(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`扫描作品目录失败 ${artworkDir.path}:`, error.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 等待所有作品扫描完成
|
||||||
|
const artworkResults = await Promise.all(artworkPromises)
|
||||||
|
return artworkResults.filter(artwork => artwork !== null)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`扫描作者目录失败 ${artistDir.path}:`, error.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 分批处理,避免过多并发
|
||||||
|
const batchSize = maxConcurrency
|
||||||
|
for (let i = 0; i < artistPromises.length; i += batchSize) {
|
||||||
|
const batch = artistPromises.slice(i, i + batchSize)
|
||||||
|
const batchResults = await Promise.all(batch)
|
||||||
|
|
||||||
|
// 处理批次结果
|
||||||
|
for (const artistArtworks of batchResults) {
|
||||||
|
for (const artwork of artistArtworks) {
|
||||||
|
artworks.push(artwork)
|
||||||
|
artists.add(artwork.artist)
|
||||||
|
totalSize += artwork.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新进度
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback({
|
||||||
|
type: 'incremental_progress',
|
||||||
|
processed: Math.min(i + batchSize, artistDirs.length),
|
||||||
|
total: artistDirs.length,
|
||||||
|
changed: changedCount,
|
||||||
|
progress: Math.round((Math.min(i + batchSize, artistDirs.length) / artistDirs.length) * 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
artworks,
|
||||||
|
artists: Array.from(artists),
|
||||||
|
totalSize,
|
||||||
|
scanTime: now,
|
||||||
|
isIncremental: true,
|
||||||
|
changedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
if (useCache) {
|
||||||
|
await this.cacheScanResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`增量扫描完成: ${artworks.length} 个作品, ${artists.size} 个作者, 变更: ${changedCount} 个目录`)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`增量扫描失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = RepositoryService
|
module.exports = RepositoryService
|
||||||
@@ -1,20 +1,76 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="artists-view">
|
<div class="artists-view">
|
||||||
<div class="artists-grid">
|
<!-- 空状态 -->
|
||||||
<div v-for="artist in artists" :key="artist.name" class="artist-card"
|
<div v-if="artists.length === 0" class="empty-state">
|
||||||
@click="$emit('select-artist', artist.name)">
|
<div class="empty-icon">👤</div>
|
||||||
<div class="artist-avatar">
|
<h3>暂无作者</h3>
|
||||||
<span class="avatar-text">{{ artist.name.charAt(0).toUpperCase() }}</span>
|
<p>这里还没有任何作者信息</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="artist-info">
|
|
||||||
<h4>{{ artist.name }}</h4>
|
<!-- 作者网格 -->
|
||||||
<p>{{ artist.artworkCount }} 个作品</p>
|
<div v-else class="artists-grid">
|
||||||
<p>{{ formatFileSize(artist.totalSize) }}</p>
|
<div v-for="artist in artists" :key="artist.name" class="artist-card">
|
||||||
</div>
|
<!-- 卡片内容 -->
|
||||||
<div class="artist-actions">
|
<div class="card-content" @click="$emit('view-artist-works', artist.name)">
|
||||||
<button @click.stop="$emit('view-artist-works', artist.name)" class="action-btn">
|
<!-- 头像和背景 -->
|
||||||
查看作品
|
<div class="artist-header">
|
||||||
</button>
|
<div class="artist-avatar">
|
||||||
|
<span class="avatar-text">{{ artist.name.charAt(0).toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="artist-badge">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 作者信息 -->
|
||||||
|
<div class="artist-details">
|
||||||
|
<h3 class="artist-name" :title="artist.name">{{ artist.name }}</h3>
|
||||||
|
|
||||||
|
<!-- 统计信息 -->
|
||||||
|
<div class="artist-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="stat-value">{{ artist.artworkCount }}</span>
|
||||||
|
<span class="stat-label">作品</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
<span class="stat-value">{{ formatFileSize(artist.totalSize) }}</span>
|
||||||
|
<span class="stat-label">大小</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="artist-actions">
|
||||||
|
<button @click.stop="$emit('view-artist-works', artist.name)" class="action-btn primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<span>查看作品</span>
|
||||||
|
</button>
|
||||||
|
<button @click.stop="$emit('select-artist', artist.name)" class="action-btn secondary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<path d="m21 21-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,86 +95,293 @@ defineEmits<Emits>()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.artists-view {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 作者网格 */
|
||||||
.artists-grid {
|
.artists-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 作者卡片 */
|
||||||
.artist-card {
|
.artist-card {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e5e7eb;
|
border-radius: 0.75rem;
|
||||||
border-radius: 0.5rem;
|
overflow: hidden;
|
||||||
padding: 1.5rem;
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
cursor: pointer;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-card:hover {
|
.artist-card:hover {
|
||||||
border-color: #3b82f6;
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 作者头部 */
|
||||||
|
.artist-header {
|
||||||
|
position: relative;
|
||||||
|
height: 120px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><defs><pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse"><path d="M 20 0 L 0 0 0 20" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-avatar {
|
.artist-avatar {
|
||||||
width: 60px;
|
position: relative;
|
||||||
height: 60px;
|
width: 80px;
|
||||||
background: #3b82f6;
|
height: 80px;
|
||||||
|
background: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
font-size: 1.5rem;
|
z-index: 1;
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-info {
|
.avatar-text {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-badge svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 作者详情 */
|
||||||
|
.artist-details {
|
||||||
|
padding: 1.25rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-info h4 {
|
.artist-name {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计信息 */
|
||||||
|
.artist-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
color: #6b7280;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-info p {
|
.stat-label {
|
||||||
margin: 0.25rem 0;
|
font-size: 0.75rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 2.5rem;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
.artist-actions {
|
.artist-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
padding: 0.5rem 1rem;
|
display: flex;
|
||||||
border: 1px solid #d1d5db;
|
align-items: center;
|
||||||
background: white;
|
justify-content: center;
|
||||||
border-radius: 0.375rem;
|
gap: 0.5rem;
|
||||||
cursor: pointer;
|
padding: 0.75rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.action-btn svg {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary {
|
||||||
|
flex: 1;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.secondary {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
border-color: #3b82f6;
|
color: #4b5563;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.secondary:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.artists-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.artists-grid {
|
.artists-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-card {
|
.artist-header {
|
||||||
flex-direction: column;
|
height: 100px;
|
||||||
text-align: center;
|
}
|
||||||
|
|
||||||
|
.artist-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-details {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.artists-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,31 +1,102 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="artworks-view">
|
<div class="artworks-view">
|
||||||
<div class="artworks-grid">
|
<!-- 无作品提示 -->
|
||||||
<div v-for="artwork in artworks" :key="artwork.id" class="artwork-card" @click="$emit('view-artwork', artwork)">
|
<div v-if="artworks.length === 0" class="empty-state">
|
||||||
<div class="artwork-preview" v-if="artwork.files.length > 0">
|
<div class="empty-icon">🎨</div>
|
||||||
<img :src="getPreviewUrl(artwork.files[0].path)" :alt="artwork.title" class="preview-image"
|
<h3>暂无作品</h3>
|
||||||
@click.stop="$emit('open-image-viewer', artwork, 0)" />
|
<p>这里还没有任何作品,快去下载一些吧!</p>
|
||||||
<div class="artwork-overlay">
|
</div>
|
||||||
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="view-btn-overlay">
|
|
||||||
👁️ 查看大图
|
<!-- 作品网格 -->
|
||||||
</button>
|
<div v-else class="artworks-grid">
|
||||||
|
<div v-for="artwork in artworks" :key="artwork.id" class="artwork-card">
|
||||||
|
<!-- 图片容器 - 点击预览大图 -->
|
||||||
|
<div class="artwork-image-wrapper" @click="$emit('open-image-viewer', artwork, 0)">
|
||||||
|
<div v-if="artwork.files.length > 0" class="artwork-image-container">
|
||||||
|
<img
|
||||||
|
:src="getPreviewUrl(artwork.files[0].path)"
|
||||||
|
:alt="artwork.title"
|
||||||
|
class="artwork-image"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<!-- 多图标识 -->
|
||||||
|
<div v-if="artwork.files.length > 1" class="multi-image-badge">
|
||||||
|
<svg class="badge-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ artwork.files.length }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 悬浮遮罩 -->
|
||||||
|
<div class="artwork-hover-overlay">
|
||||||
|
<button class="quick-view-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
<span>查看大图</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="artwork-no-image">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<span>暂无预览</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="artwork-info">
|
|
||||||
<h4>{{ artwork.title }}</h4>
|
<!-- 作品信息 -->
|
||||||
<p class="artist-name" @click.stop="$emit('select-artist', artwork.artist)">
|
<div class="artwork-content">
|
||||||
👤 {{ artwork.artist }}
|
<h3 class="artwork-title" :title="artwork.title">{{ artwork.title }}</h3>
|
||||||
</p>
|
|
||||||
<p>{{ formatFileSize(artwork.size) }}</p>
|
<!-- 作者信息 -->
|
||||||
<p class="file-count">{{ artwork.files.length }} 个文件</p>
|
<div class="artwork-artist" @click.stop="$emit('select-artist', artwork.artist)">
|
||||||
</div>
|
<div class="artist-avatar">
|
||||||
<div class="artwork-actions">
|
<span>{{ artwork.artist.charAt(0).toUpperCase() }}</span>
|
||||||
<button @click.stop="$emit('view-artwork', artwork)" class="action-btn">
|
</div>
|
||||||
详情
|
<span class="artist-name">{{ artwork.artist }}</span>
|
||||||
</button>
|
</div>
|
||||||
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="action-btn">
|
|
||||||
预览
|
<!-- 元数据 -->
|
||||||
</button>
|
<div class="artwork-meta">
|
||||||
|
<div class="meta-item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ formatFileSize(artwork.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
|
||||||
|
<polyline points="13 2 13 9 20 9"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ artwork.files.length }} 个文件</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="artwork-actions">
|
||||||
|
<button @click.stop="$emit('view-artwork', artwork)" class="action-btn action-btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</svg>
|
||||||
|
查看详情
|
||||||
|
</button>
|
||||||
|
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="action-btn action-btn-secondary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,128 +122,372 @@ defineEmits<Emits>()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.artworks-view {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 作品网格 - 响应式布局 */
|
||||||
.artworks-grid {
|
.artworks-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 作品卡片 */
|
||||||
.artwork-card {
|
.artwork-card {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e5e7eb;
|
border-radius: 0.75rem;
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
transition: all 0.2s;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-card:hover {
|
.artwork-card:hover {
|
||||||
border-color: #3b82f6;
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-preview {
|
/* 图片容器 */
|
||||||
|
.artwork-image-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 200px;
|
width: 100%;
|
||||||
overflow: hidden;
|
cursor: pointer;
|
||||||
|
background: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-image {
|
.artwork-image-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.3s ease;
|
||||||
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-card:hover .preview-image {
|
.artwork-card:hover .artwork-image {
|
||||||
transform: scale(1.05);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-overlay {
|
/* 多图标识 */
|
||||||
|
.multi-image-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬浮遮罩 */
|
||||||
|
.artwork-hover-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-card:hover .artwork-overlay {
|
.artwork-image-wrapper:hover .artwork-hover-overlay {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn-overlay {
|
.quick-view-btn {
|
||||||
background: white;
|
background: white;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-info {
|
.quick-view-btn:hover {
|
||||||
padding: 1rem;
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-info h4 {
|
.quick-view-btn svg {
|
||||||
margin: 0 0 0.5rem 0;
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 无图片状态 */
|
||||||
|
.artwork-no-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #9ca3af;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-no-image svg {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-no-image span {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 作品内容 */
|
||||||
|
.artwork-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-clamp: 2;
|
||||||
|
display: box;
|
||||||
|
box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 作者信息 */
|
||||||
|
.artwork-artist {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: -0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-artist:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-avatar {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-name {
|
.artist-name {
|
||||||
color: #3b82f6 !important;
|
color: #4b5563;
|
||||||
cursor: pointer;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-name:hover {
|
.artwork-artist:hover .artist-name {
|
||||||
text-decoration: underline;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-info p {
|
/* 元数据 */
|
||||||
margin: 0.25rem 0;
|
.artwork-meta {
|
||||||
font-size: 0.75rem;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-count {
|
.meta-item svg {
|
||||||
font-weight: 500;
|
width: 1rem;
|
||||||
color: #6b5563 !important;
|
height: 1rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
.artwork-actions {
|
.artwork-actions {
|
||||||
padding: 0 1rem 1rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.5rem;
|
padding: 0.625rem 1rem;
|
||||||
border: 1px solid #d1d5db;
|
border: none;
|
||||||
background: white;
|
border-radius: 0.5rem;
|
||||||
border-radius: 0.375rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.75rem;
|
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.action-btn svg {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-secondary {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
border-color: #3b82f6;
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-secondary:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.artworks-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.artworks-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn svg {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
.artworks-grid {
|
.artworks-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-image-badge {
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-icon {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,51 +1,121 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="gallery-view">
|
<div class="gallery-view">
|
||||||
<div class="gallery-controls">
|
<!-- 空状态 -->
|
||||||
<div class="zoom-controls">
|
<div v-if="artworks.length === 0" class="empty-state">
|
||||||
<button @click="zoomOut" class="zoom-btn" :disabled="zoomLevel <= 0.5">
|
<div class="empty-icon">🖼️</div>
|
||||||
🔍-
|
<h3>暂无作品</h3>
|
||||||
</button>
|
<p>这里还没有任何作品</p>
|
||||||
<span class="zoom-level">{{ Math.round(zoomLevel * 100) }}%</span>
|
|
||||||
<button @click="zoomIn" class="zoom-btn" :disabled="zoomLevel >= 3">
|
|
||||||
🔍+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="view-controls">
|
|
||||||
<button @click="setGridSize('small')" :class="['size-btn', { active: gridSize === 'small' }]">
|
|
||||||
小
|
|
||||||
</button>
|
|
||||||
<button @click="setGridSize('medium')" :class="['size-btn', { active: gridSize === 'medium' }]">
|
|
||||||
中
|
|
||||||
</button>
|
|
||||||
<button @click="setGridSize('large')" :class="['size-btn', { active: gridSize === 'large' }]">
|
|
||||||
大
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gallery-grid" :class="`grid-${gridSize}`">
|
<template v-else>
|
||||||
<div v-for="artwork in artworks" :key="artwork.id" class="gallery-item"
|
<!-- 画廊控制栏 -->
|
||||||
@click="$emit('open-image-viewer', artwork, 0)">
|
<div class="gallery-controls">
|
||||||
<div class="gallery-image-container">
|
<div class="controls-section">
|
||||||
<img :src="getPreviewUrl(artwork.files[0].path)" :alt="artwork.title" class="gallery-image"
|
<label class="control-label">网格大小</label>
|
||||||
@load="onImageLoad" @error="onImageError" />
|
<div class="control-buttons">
|
||||||
<div class="gallery-overlay">
|
<button @click="setGridSize('small')" :class="['control-btn', { active: gridSize === 'small' }]">
|
||||||
<div class="overlay-content">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<h4>{{ artwork.title }}</h4>
|
<rect x="3" y="3" width="7" height="7"/>
|
||||||
<p>{{ artwork.artist }}</p>
|
<rect x="14" y="3" width="7" height="7"/>
|
||||||
<div class="overlay-actions">
|
<rect x="14" y="14" width="7" height="7"/>
|
||||||
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="overlay-btn">
|
<rect x="3" y="14" width="7" height="7"/>
|
||||||
👁️ 查看大图
|
</svg>
|
||||||
</button>
|
<span>小</span>
|
||||||
<button @click.stop="$emit('view-artwork', artwork)" class="overlay-btn">
|
</button>
|
||||||
📋 详情
|
<button @click="setGridSize('medium')" :class="['control-btn', { active: gridSize === 'medium' }]">
|
||||||
</button>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="8" height="8"/>
|
||||||
|
<rect x="3" y="13" width="8" height="8"/>
|
||||||
|
<rect x="13" y="3" width="8" height="8"/>
|
||||||
|
<rect x="13" y="13" width="8" height="8"/>
|
||||||
|
</svg>
|
||||||
|
<span>中</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setGridSize('large')" :class="['control-btn', { active: gridSize === 'large' }]">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="18" height="7"/>
|
||||||
|
<rect x="3" y="14" width="18" height="7"/>
|
||||||
|
</svg>
|
||||||
|
<span>大</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls-section">
|
||||||
|
<label class="control-label">图片适应</label>
|
||||||
|
<div class="control-buttons">
|
||||||
|
<button @click="setFitMode('contain')" :class="['control-btn', { active: fitMode === 'contain' }]">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<rect x="7" y="7" width="10" height="10"/>
|
||||||
|
</svg>
|
||||||
|
<span>完整</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setFitMode('cover')" :class="['control-btn', { active: fitMode === 'cover' }]">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<rect x="2" y="2" width="20" height="20"/>
|
||||||
|
</svg>
|
||||||
|
<span>填充</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 画廊网格 -->
|
||||||
|
<div class="gallery-grid" :class="`grid-${gridSize}`">
|
||||||
|
<div v-for="artwork in artworks" :key="artwork.id" class="gallery-item">
|
||||||
|
<div class="gallery-card" @click="$emit('open-image-viewer', artwork, 0)">
|
||||||
|
<!-- 图片容器 -->
|
||||||
|
<div class="image-container" :class="`fit-${fitMode}`">
|
||||||
|
<img
|
||||||
|
:src="getPreviewUrl(artwork.files[0].path)"
|
||||||
|
:alt="artwork.title"
|
||||||
|
class="gallery-image"
|
||||||
|
loading="lazy"
|
||||||
|
@load="onImageLoad"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 多图徽章 -->
|
||||||
|
<div v-if="artwork.files.length > 1" class="image-count-badge">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ artwork.files.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 悬浮信息遮罩 -->
|
||||||
|
<div class="image-overlay">
|
||||||
|
<div class="overlay-top">
|
||||||
|
<h4 class="overlay-title">{{ artwork.title }}</h4>
|
||||||
|
<p class="overlay-artist">{{ artwork.artist }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="overlay-bottom">
|
||||||
|
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="overlay-action-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
查看大图
|
||||||
|
</button>
|
||||||
|
<button @click.stop="$emit('view-artwork', artwork)" class="overlay-action-btn secondary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</svg>
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -67,239 +137,432 @@ defineProps<Props>()
|
|||||||
defineEmits<Emits>()
|
defineEmits<Emits>()
|
||||||
|
|
||||||
// 画廊模式相关
|
// 画廊模式相关
|
||||||
const zoomLevel = ref(1)
|
const gridSize = ref<'small' | 'medium' | 'large'>('medium')
|
||||||
const gridSize = ref('medium')
|
const fitMode = ref<'contain' | 'cover'>('contain')
|
||||||
|
|
||||||
// 画廊模式图片缩放
|
// 设置网格大小
|
||||||
const zoomIn = () => {
|
|
||||||
zoomLevel.value = Math.min(zoomLevel.value + 0.1, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
|
||||||
zoomLevel.value = Math.max(zoomLevel.value - 0.1, 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 画廊模式网格大小
|
|
||||||
const setGridSize = (size: 'small' | 'medium' | 'large') => {
|
const setGridSize = (size: 'small' | 'medium' | 'large') => {
|
||||||
gridSize.value = size
|
gridSize.value = size
|
||||||
}
|
}
|
||||||
|
|
||||||
// 画廊模式图片加载和错误处理
|
// 设置图片适应模式
|
||||||
const onImageLoad = () => {
|
const setFitMode = (mode: 'contain' | 'cover') => {
|
||||||
// 图片加载成功后可以进行一些操作,例如调整布局
|
fitMode.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 图片加载成功
|
||||||
|
const onImageLoad = (event: Event) => {
|
||||||
|
const img = event.target as HTMLImageElement
|
||||||
|
img.classList.add('loaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片加载失败
|
||||||
const onImageError = (event: Event) => {
|
const onImageError = (event: Event) => {
|
||||||
console.error('图片加载失败:', (event.target as HTMLImageElement).src)
|
const img = event.target as HTMLImageElement
|
||||||
// 可以显示一个错误提示或替换图片
|
console.error('图片加载失败:', img.src)
|
||||||
|
img.classList.add('error')
|
||||||
|
|
||||||
|
// 显示占位图
|
||||||
|
img.style.display = 'none'
|
||||||
|
const container = img.parentElement
|
||||||
|
if (container) {
|
||||||
|
container.classList.add('has-error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.gallery-view {
|
.gallery-view {
|
||||||
position: relative;
|
width: 100%;
|
||||||
margin-top: 2rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 画廊控制栏 */
|
||||||
.gallery-controls {
|
.gallery-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
gap: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 1.25rem;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.75rem;
|
||||||
border: 1px solid #e5e7eb;
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-controls {
|
.controls-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-btn {
|
.control-label {
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-btn:hover:not(:disabled) {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-level {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6b7280;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-controls {
|
.control-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.size-btn {
|
.control-btn {
|
||||||
background: #3b82f6;
|
display: flex;
|
||||||
color: white;
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.5rem 0.75rem;
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.size-btn:hover:not(:disabled) {
|
.control-btn svg {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
color: #1f2937;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.active:hover {
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.size-btn:disabled {
|
/* 画廊网格 */
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-grid {
|
.gallery-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-small .gallery-grid {
|
.gallery-grid.grid-small {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-medium .gallery-grid {
|
.gallery-grid.grid-medium {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-large .gallery-grid {
|
.gallery-grid.grid-large {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 画廊项目 */
|
||||||
.gallery-item {
|
.gallery-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 250px;
|
}
|
||||||
border-radius: 0.5rem;
|
|
||||||
|
.gallery-card {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: white;
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
border: 1px solid #e5e7eb;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-item:hover {
|
.gallery-card:hover {
|
||||||
border-color: #3b82f6;
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-image-container {
|
/* 图片容器 */
|
||||||
|
.image-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
aspect-ratio: 4 / 3;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #f9fafb;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-image {
|
.image-container::before {
|
||||||
width: 100%;
|
content: '';
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
transform: scale(v-bind(zoomLevel));
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-item:hover .gallery-image {
|
|
||||||
transform: scale(v-bind(zoomLevel) * 1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-overlay {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: white;
|
||||||
display: flex;
|
z-index: 0;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-item:hover .gallery-overlay {
|
.gallery-image {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-image.loaded {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-content {
|
/* 图片适应模式 */
|
||||||
text-align: center;
|
.image-container.fit-contain .gallery-image {
|
||||||
color: white;
|
object-fit: contain;
|
||||||
padding: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-content h4 {
|
.image-container.fit-cover .gallery-image {
|
||||||
margin: 0 0 0.25rem 0;
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card:hover .gallery-image {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误状态 */
|
||||||
|
.image-container.has-error::after {
|
||||||
|
content: '图片加载失败';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #9ca3af;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-content p {
|
/* 多图徽章 */
|
||||||
|
.image-count-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-count-badge svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬浮遮罩 */
|
||||||
|
.image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.6) 0%,
|
||||||
|
rgba(0, 0, 0, 0) 30%,
|
||||||
|
rgba(0, 0, 0, 0) 70%,
|
||||||
|
rgba(0, 0, 0, 0.8) 100%
|
||||||
|
);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card:hover .image-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-top {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-title {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-clamp: 2;
|
||||||
|
display: box;
|
||||||
|
box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-artist {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.875rem;
|
||||||
opacity: 0.8;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-actions {
|
.overlay-bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-btn {
|
.overlay-action-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
background: white;
|
background: white;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.5rem 1rem;
|
border-radius: 0.5rem;
|
||||||
border-radius: 0.375rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.75rem;
|
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-btn:hover {
|
.overlay-action-btn svg {
|
||||||
background: #f3f4f6;
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-action-btn:hover {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-action-btn.secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-action-btn.secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.gallery-grid.grid-small {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid.grid-medium {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid.grid-large {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.gallery-controls {
|
.gallery-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-controls,
|
.controls-section {
|
||||||
.view-controls {
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-buttons {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
flex: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-grid {
|
.gallery-grid.grid-small {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid.grid-medium {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid.grid-large {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-action-btn span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-action-btn svg {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.image-count-badge {
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-count-badge svg {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-top {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-overlay {
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,25 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pagination" v-if="totalPages > 1">
|
<div class="pagination" v-if="totalPages > 1">
|
||||||
<button @click="$emit('change-page', 1)" :disabled="currentPage <= 1" class="page-btn">
|
<!-- 首页 -->
|
||||||
首页
|
<button
|
||||||
</button>
|
@click="$emit('change-page', 1)"
|
||||||
<button @click="$emit('change-page', currentPage - 1)" :disabled="currentPage <= 1" class="page-btn">
|
:disabled="currentPage <= 1"
|
||||||
上一页
|
class="page-btn nav-btn"
|
||||||
|
title="首页"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<polyline points="11 17 6 12 11 7"/>
|
||||||
|
<polyline points="18 17 13 12 18 7"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 上一页 -->
|
||||||
|
<button
|
||||||
|
@click="$emit('change-page', currentPage - 1)"
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
class="page-btn nav-btn"
|
||||||
|
title="上一页"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 页码 -->
|
||||||
<div class="page-numbers">
|
<div class="page-numbers">
|
||||||
<button v-for="page in visiblePages" :key="page" @click="$emit('change-page', page)"
|
<button
|
||||||
:class="['page-btn', { active: page === currentPage }]">
|
v-for="page in visiblePages"
|
||||||
|
:key="page"
|
||||||
|
@click="$emit('change-page', page)"
|
||||||
|
:class="['page-btn', 'number-btn', { active: page === currentPage }]"
|
||||||
|
:title="`第 ${page} 页`"
|
||||||
|
>
|
||||||
{{ page }}
|
{{ page }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="$emit('change-page', currentPage + 1)" :disabled="currentPage >= totalPages" class="page-btn">
|
<!-- 下一页 -->
|
||||||
下一页
|
<button
|
||||||
|
@click="$emit('change-page', currentPage + 1)"
|
||||||
|
:disabled="currentPage >= totalPages"
|
||||||
|
class="page-btn nav-btn"
|
||||||
|
title="下一页"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="$emit('change-page', totalPages)" :disabled="currentPage >= totalPages" class="page-btn">
|
|
||||||
末页
|
<!-- 末页 -->
|
||||||
|
<button
|
||||||
|
@click="$emit('change-page', totalPages)"
|
||||||
|
:disabled="currentPage >= totalPages"
|
||||||
|
class="page-btn nav-btn"
|
||||||
|
title="末页"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<polyline points="13 17 18 12 13 7"/>
|
||||||
|
<polyline points="6 17 11 12 6 7"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 页码信息 -->
|
||||||
|
<div class="page-info">
|
||||||
|
<span class="current-page">{{ currentPage }}</span>
|
||||||
|
<span class="divider">/</span>
|
||||||
|
<span class="total-pages">{{ totalPages }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -63,49 +112,148 @@ const visiblePages = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn {
|
.page-btn {
|
||||||
padding: 0.5rem 0.75rem;
|
display: flex;
|
||||||
border: 1px solid #d1d5db;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn:hover:not(:disabled) {
|
.page-btn svg {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover:not(:disabled):not(.active) {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
border-color: #3b82f6;
|
color: #1f2937;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn.active {
|
.page-btn:active:not(:disabled) {
|
||||||
background: #3b82f6;
|
transform: translateY(0);
|
||||||
color: white;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn:disabled {
|
.page-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航按钮 */
|
||||||
|
.nav-btn {
|
||||||
|
min-width: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页码按钮 */
|
||||||
|
.number-btn {
|
||||||
|
min-width: 2.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-btn.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-btn.active:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-numbers {
|
.page-numbers {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 页码信息 */
|
||||||
|
.page-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-page {
|
||||||
|
color: #3b82f6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
color: #d1d5db;
|
||||||
|
margin: 0 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-pages {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.pagination {
|
.pagination {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
min-width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-numbers {
|
.page-numbers {
|
||||||
order: 3;
|
order: -1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
order: -2;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.page-btn {
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,26 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="search-panel">
|
<div class="search-panel">
|
||||||
<div class="search-filters">
|
<div class="search-container">
|
||||||
|
<!-- 搜索框 -->
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input v-model="searchQuery" type="text" placeholder="搜索作品标题、作者名称..." class="search-input"
|
<div class="search-icon">
|
||||||
@input="debounceSearch" />
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<button @click="clearSearch" class="clear-btn" v-if="searchQuery">
|
<circle cx="11" cy="11" r="8"/>
|
||||||
✕
|
<path d="m21 21-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索作品标题、作者名称..."
|
||||||
|
class="search-input"
|
||||||
|
@input="debounceSearch"
|
||||||
|
@keyup.enter="emit('search', searchQuery)"
|
||||||
|
/>
|
||||||
|
<button v-if="searchQuery" @click="clearSearch" class="clear-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选控制 -->
|
||||||
<div class="filter-controls">
|
<div class="filter-controls">
|
||||||
<select v-model="sortBy" @change="handleSortChange" class="filter-select">
|
<div class="filter-group">
|
||||||
<option value="date">按日期排序</option>
|
<label class="filter-label">
|
||||||
<option value="name">按名称排序</option>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<option value="size">按大小排序</option>
|
<line x1="4" y1="21" x2="4" y2="14"/>
|
||||||
</select>
|
<line x1="4" y1="10" x2="4" y2="3"/>
|
||||||
|
<line x1="12" y1="21" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="3"/>
|
||||||
|
<line x1="20" y1="21" x2="20" y2="16"/>
|
||||||
|
<line x1="20" y1="12" x2="20" y2="3"/>
|
||||||
|
<line x1="1" y1="14" x2="7" y2="14"/>
|
||||||
|
<line x1="9" y1="8" x2="15" y2="8"/>
|
||||||
|
<line x1="17" y1="16" x2="23" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
<select v-model="sortBy" @change="handleSortChange" class="filter-select">
|
||||||
|
<option value="date">按日期</option>
|
||||||
|
<option value="name">按名称</option>
|
||||||
|
<option value="size">按大小</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<select v-model="filterBy" @change="handleFilterChange" class="filter-select">
|
<div class="filter-group">
|
||||||
<option value="all">全部</option>
|
<label class="filter-label">
|
||||||
<option value="images">仅图片</option>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<option value="videos">仅视频</option>
|
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||||
</select>
|
</svg>
|
||||||
|
</label>
|
||||||
|
<select v-model="filterBy" @change="handleFilterChange" class="filter-select">
|
||||||
|
<option value="all">全部类型</option>
|
||||||
|
<option value="images">仅图片</option>
|
||||||
|
<option value="videos">仅视频</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,75 +124,184 @@ watch(() => props.initialQuery, (newQuery) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-panel {
|
.search-panel {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-filters {
|
.search-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 搜索框 */
|
||||||
.search-box {
|
.search-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 400px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box:focus-within {
|
||||||
|
background: white;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
padding: 0.75rem 0;
|
||||||
border: 1px solid #d1d5db;
|
border: none;
|
||||||
border-radius: 0.375rem;
|
background: transparent;
|
||||||
font-size: 0.875rem;
|
font-size: 0.9375rem;
|
||||||
|
outline: none;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-btn {
|
.clear-btn {
|
||||||
position: absolute;
|
display: flex;
|
||||||
right: 0.5rem;
|
align-items: center;
|
||||||
top: 50%;
|
justify-content: center;
|
||||||
transform: translateY(-50%);
|
width: 2rem;
|
||||||
background: none;
|
height: 2rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem;
|
border-radius: 0.375rem;
|
||||||
border-radius: 0.25rem;
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn svg {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-btn:hover {
|
.clear-btn:hover {
|
||||||
background: #f3f4f6;
|
background: #e5e7eb;
|
||||||
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 筛选控制 */
|
||||||
.filter-controls {
|
.filter-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group:focus-within {
|
||||||
|
background: white;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label svg {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-select {
|
.filter-select {
|
||||||
padding: 0.5rem;
|
padding: 0.375rem 0.75rem;
|
||||||
border: 1px solid #d1d5db;
|
border: none;
|
||||||
border-radius: 0.375rem;
|
background: transparent;
|
||||||
background: white;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 5L6 8L9 5' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.25rem center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.filter-select:hover {
|
||||||
.search-filters {
|
color: #3b82f6;
|
||||||
flex-direction: column;
|
}
|
||||||
width: 100%;
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.search-container {
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box {
|
.search-box {
|
||||||
max-width: none;
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-controls {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-container {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-controls {
|
.filter-controls {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-select {
|
.filter-group {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: calc(50% - 0.375rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.filter-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,19 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="view-toggle">
|
<div class="view-toggle">
|
||||||
<button class="view-btn" :class="{ active: modelValue === 'artists' }"
|
<button
|
||||||
@click="$emit('update:modelValue', 'artists')">
|
class="view-btn"
|
||||||
<span class="btn-icon">👥</span>
|
:class="{ active: modelValue === 'artists' }"
|
||||||
按作者浏览
|
@click="$emit('update:modelValue', 'artists')"
|
||||||
|
:title="'按作者浏览'"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
<span class="btn-text">作者</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="view-btn" :class="{ active: modelValue === 'artworks' }"
|
|
||||||
@click="$emit('update:modelValue', 'artworks')">
|
<button
|
||||||
<span class="btn-icon">🖼️</span>
|
class="view-btn"
|
||||||
所有作品
|
:class="{ active: modelValue === 'artworks' }"
|
||||||
|
@click="$emit('update:modelValue', 'artworks')"
|
||||||
|
:title="'作品列表'"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="btn-text">列表</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="view-btn" :class="{ active: modelValue === 'gallery' }"
|
|
||||||
@click="$emit('update:modelValue', 'gallery')">
|
<button
|
||||||
<span class="btn-icon">🎨</span>
|
class="view-btn"
|
||||||
画廊模式
|
:class="{ active: modelValue === 'gallery' }"
|
||||||
|
@click="$emit('update:modelValue', 'gallery')"
|
||||||
|
:title="'画廊模式'"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<span class="btn-text">画廊</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -33,38 +61,72 @@ defineEmits<Emits>()
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-toggle {
|
.view-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: 0.5rem;
|
gap: 0;
|
||||||
margin-bottom: 2rem;
|
background: #f3f4f6;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn {
|
.view-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.625rem 1.125rem;
|
||||||
border: 1px solid #d1d5db;
|
border: none;
|
||||||
background: white;
|
background: transparent;
|
||||||
border-radius: 0.375rem;
|
color: #6b7280;
|
||||||
|
border-radius: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn svg {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn:hover:not(.active) {
|
||||||
|
color: #1f2937;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn.active {
|
.view-btn.active {
|
||||||
background: #3b82f6;
|
background: white;
|
||||||
color: white;
|
color: #3b82f6;
|
||||||
border-color: #3b82f6;
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-text {
|
||||||
font-size: 1rem;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.view-toggle {
|
.view-toggle {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.view-btn svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -209,6 +209,32 @@ export const useRepositoryStore = defineStore('repository', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速扫描 - 仅获取基本信息
|
||||||
|
const quickScan = async () => {
|
||||||
|
return await apiCall('/quick-scan')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完整扫描 - 支持并发和缓存
|
||||||
|
const scanRepository = async (options: {
|
||||||
|
maxConcurrency?: number
|
||||||
|
useCache?: boolean
|
||||||
|
forceRefresh?: boolean
|
||||||
|
} = {}) => {
|
||||||
|
return await apiCall('/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
maxConcurrency: options.maxConcurrency || 5, // 减少默认并发数
|
||||||
|
useCache: options.useCache !== false,
|
||||||
|
forceRefresh: options.forceRefresh === true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除扫描缓存
|
||||||
|
const clearScanCache = async () => {
|
||||||
|
return await apiCall('/clear-scan-cache', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
config,
|
config,
|
||||||
@@ -232,5 +258,8 @@ export const useRepositoryStore = defineStore('repository', () => {
|
|||||||
checkArtworkDownloaded,
|
checkArtworkDownloaded,
|
||||||
checkDirectoryExists,
|
checkDirectoryExists,
|
||||||
migrateFromOldToNew,
|
migrateFromOldToNew,
|
||||||
|
quickScan,
|
||||||
|
scanRepository,
|
||||||
|
clearScanCache,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -20,6 +20,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 扫描控制面板 -->
|
||||||
|
<div class="scan-controls">
|
||||||
|
<div class="scan-buttons">
|
||||||
|
<button @click="performFullScan" :disabled="isScanning" class="scan-button">
|
||||||
|
{{ isScanning ? '扫描中...' : '完整扫描' }}
|
||||||
|
</button>
|
||||||
|
<button @click="clearCache" class="clear-cache-button">
|
||||||
|
清除缓存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 扫描进度 -->
|
||||||
|
<div v-if="isScanning" class="scan-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: scanProgress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">{{ scanStatus }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 配置管理 -->
|
<!-- 配置管理 -->
|
||||||
<div v-if="activeTab === 'config'" class="tab-content">
|
<div v-if="activeTab === 'config'" class="tab-content">
|
||||||
<RepositoryConfigComponent :config="config" :migrating="migrating" :migration-progress="migrationProgress"
|
<RepositoryConfigComponent :config="config" :migrating="migrating" :migration-progress="migrationProgress"
|
||||||
@@ -65,6 +85,11 @@ const config = ref<RepositoryConfig>({
|
|||||||
migrationRules: []
|
migrationRules: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 扫描相关状态
|
||||||
|
const isScanning = ref(false)
|
||||||
|
const scanProgress = ref(0)
|
||||||
|
const scanStatus = ref('')
|
||||||
|
|
||||||
// 浏览相关
|
// 浏览相关
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const viewMode = ref('artworks') // 默认显示作品模式
|
const viewMode = ref('artworks') // 默认显示作品模式
|
||||||
@@ -96,6 +121,15 @@ onMounted(async () => {
|
|||||||
// 加载统计信息
|
// 加载统计信息
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
try {
|
try {
|
||||||
|
// 首先尝试快速扫描获取基本信息
|
||||||
|
try {
|
||||||
|
const quickResult = await repositoryStore.quickScan()
|
||||||
|
console.log('快速扫描结果:', quickResult)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('快速扫描失败,使用传统方法:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 然后获取完整统计信息
|
||||||
stats.value = await repositoryStore.getStats()
|
stats.value = await repositoryStore.getStats()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('加载统计信息失败:', error)
|
console.error('加载统计信息失败:', error)
|
||||||
@@ -408,6 +442,49 @@ const handleConfigSaved = async () => {
|
|||||||
console.error('配置保存后刷新数据失败:', error)
|
console.error('配置保存后刷新数据失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 执行完整扫描
|
||||||
|
const performFullScan = async () => {
|
||||||
|
try {
|
||||||
|
isScanning.value = true
|
||||||
|
scanProgress.value = 0
|
||||||
|
scanStatus.value = '开始扫描...'
|
||||||
|
|
||||||
|
const result = await repositoryStore.scanRepository({
|
||||||
|
maxConcurrency: 5, // 减少并发数,避免文件句柄过多
|
||||||
|
useCache: true,
|
||||||
|
forceRefresh: true
|
||||||
|
})
|
||||||
|
|
||||||
|
scanStatus.value = '扫描完成'
|
||||||
|
scanProgress.value = 100
|
||||||
|
|
||||||
|
// 重新加载数据
|
||||||
|
await loadStats()
|
||||||
|
await loadArtists()
|
||||||
|
await loadAllArtworks(1)
|
||||||
|
|
||||||
|
alert(`扫描完成!发现 ${result.artworks.length} 个作品,${result.artists.length} 个作者`)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('扫描失败:', error)
|
||||||
|
alert('扫描失败: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
isScanning.value = false
|
||||||
|
scanStatus.value = ''
|
||||||
|
scanProgress.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除扫描缓存
|
||||||
|
const clearCache = async () => {
|
||||||
|
try {
|
||||||
|
await repositoryStore.clearScanCache()
|
||||||
|
alert('扫描缓存已清除')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('清除缓存失败:', error)
|
||||||
|
alert('清除缓存失败: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -471,9 +548,87 @@ const handleConfigSaved = async () => {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scan-controls {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button:disabled {
|
||||||
|
background: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-cache-button {
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-cache-button:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-progress {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #3b82f6;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scan-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user