画廊添加,详细描述,优化功能

This commit is contained in:
2025-08-21 18:13:53 +08:00
parent c6783febc1
commit beeaf4055d
19 changed files with 2863 additions and 1011 deletions
+18 -2
View File
@@ -11,6 +11,9 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功
- 📥 作品下载管理
- 👤 作者搜索
- 🆔 作品ID搜索
- 📁 本地仓库管理
- 🖼️ 画廊模式浏览
- 🔍 作品检索和分类
## 🚀 快速开始
@@ -72,11 +75,11 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功
## 📱 功能展示
### 主界面
### 脚本启动
![脚本启动](./pic/脚本启动.png)
### 搜索功能
- **作品搜索**
- **作品搜索(下载过的会有提示)**
![搜索作品](./pic/搜索作品.png)
- **作者搜索**
@@ -85,9 +88,22 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功
- **作品ID搜索**
![作品id搜索](./pic/作品id搜索.png)
### 列表轮换
- **通过作者搜索进入作品可以上下一个切换**
- ![列表轮换](./pic/列表轮换.png)
### 下载管理
![下载管理](./pic/下载管理.png)
### 仓库管理
![仓库管理](./pic/仓库管理.png)
### 作品检索
![作品检索](./pic/作品检索.png)
### 画廊模式
![画廊模式](./pic/画廊模式.png)
## 🛠️ 开发说明
本项目刚刚建立,很多功能还不够完善,欢迎大家一起参与开发!
+56
View File
@@ -2,6 +2,62 @@ const express = require('express');
const router = express.Router();
const ArtistService = require('../services/artist');
/**
* 搜索作者
* GET /api/artist/search
*/
router.get('/search', async (req, res) => {
try {
const { keyword, offset = 0, limit = 30 } = req.query;
if (!keyword || keyword.trim() === '') {
return res.status(400).json({
success: false,
error: '搜索关键词不能为空'
});
}
const artistService = new ArtistService(req.backend.getAuth());
const result = await artistService.searchArtists({
keyword: keyword.trim(),
offset: parseInt(offset),
limit: parseInt(limit)
});
if (result.success) {
// 转换数据格式以匹配前端期望
const artists = (result.data.users || []).map(user => ({
id: user.user.id,
name: user.user.name,
account: user.user.account,
profile_image_urls: user.user.profile_image_urls,
total_illusts: 0,
total_manga: 0,
total_followers: 0,
is_followed: user.user.is_followed || false
}));
res.json({
success: true,
data: {
artists,
total: artists.length
}
});
} else {
res.status(404).json({
success: false,
error: result.error
});
}
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* 获取当前用户关注的作者列表
* GET /api/artist/following
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

BIN
View File
Binary file not shown.
+20
View File
@@ -19,6 +19,12 @@ export interface ArtistFollowersOptions {
limit?: number;
}
export interface SearchArtistsOptions {
keyword: string;
offset?: number;
limit?: number;
}
class ArtistService {
/**
* 获取作者信息
@@ -88,6 +94,20 @@ class ArtistService {
const url = query ? `/api/artist/following?${query}` : '/api/artist/following';
return apiService.get<{ artists: Artist[]; total: number }>(url);
}
/**
* 搜索作者
*/
async searchArtists(options: SearchArtistsOptions): Promise<ApiResponse<{ artists: Artist[]; total: number }>> {
const params = new URLSearchParams();
params.append('keyword', options.keyword);
if (options.offset !== undefined) params.append('offset', options.offset.toString());
if (options.limit !== undefined) params.append('limit', options.limit.toString());
const query = params.toString();
const url = `/api/artist/search?${query}`;
return apiService.get<{ artists: Artist[]; total: number }>(url);
}
}
export const artistService = new ArtistService();
+184
View File
@@ -0,0 +1,184 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import artistService from '@/services/artist';
import type { Artist } from '@/types';
export const useArtistStore = defineStore('artist', () => {
// 状态
const followingArtists = ref<Artist[]>([]);
const searchResults = ref<Artist[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const lastFetchTime = ref<number>(0);
const cacheExpiry = 5 * 60 * 1000; // 5分钟缓存
// 计算属性
const isDataStale = computed(() => {
return Date.now() - lastFetchTime.value > cacheExpiry;
});
const hasFollowingArtists = computed(() => {
return followingArtists.value.length > 0;
});
const hasSearchResults = computed(() => {
return searchResults.value.length > 0;
});
// 获取关注的作者
const fetchFollowingArtists = async (forceRefresh = false) => {
// 如果数据不是过期的且不是强制刷新,直接返回缓存的数据
if (!forceRefresh && !isDataStale.value && hasFollowingArtists.value) {
return {
success: true,
data: { artists: followingArtists.value }
};
}
try {
loading.value = true;
error.value = null;
const response = await artistService.getFollowingArtists();
if (response.success && response.data) {
followingArtists.value = response.data.artists;
lastFetchTime.value = Date.now();
} else {
throw new Error(response.error || '获取关注列表失败');
}
return response;
} catch (err) {
error.value = err instanceof Error ? err.message : '获取关注列表失败';
console.error('获取关注列表失败:', err);
throw err;
} finally {
loading.value = false;
}
};
// 搜索作者
const searchArtists = async (keyword: string) => {
if (!keyword.trim()) {
searchResults.value = [];
return { success: true, data: { artists: [] } };
}
try {
const response = await artistService.searchArtists({ keyword });
if (response.success && response.data) {
searchResults.value = response.data.artists;
return response;
} else {
throw new Error(response.error || '搜索失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '搜索失败';
console.error('搜索失败:', err);
throw err;
}
};
// 关注作者
const followArtist = async (artistId: number) => {
try {
const response = await artistService.followArtist(artistId, 'follow');
if (response.success) {
// 更新搜索结果的关注状态
const artist = searchResults.value.find(a => a.id === artistId);
if (artist) {
artist.is_followed = true;
}
// 添加到关注列表
const artistToAdd = searchResults.value.find(a => a.id === artistId);
if (artistToAdd && !followingArtists.value.find(a => a.id === artistId)) {
followingArtists.value.push(artistToAdd);
}
} else {
throw new Error(response.error || '关注失败');
}
return response;
} catch (err) {
error.value = err instanceof Error ? err.message : '关注失败';
console.error('关注失败:', err);
throw err;
}
};
// 取消关注
const unfollowArtist = async (artistId: number) => {
try {
const response = await artistService.followArtist(artistId, 'unfollow');
if (response.success) {
// 从关注列表中移除
followingArtists.value = followingArtists.value.filter(a => a.id !== artistId);
// 更新搜索结果的关注状态
const artist = searchResults.value.find(a => a.id === artistId);
if (artist) {
artist.is_followed = false;
}
} else {
throw new Error(response.error || '取消关注失败');
}
return response;
} catch (err) {
error.value = err instanceof Error ? err.message : '取消关注失败';
console.error('取消关注失败:', err);
throw err;
}
};
// 清除搜索结果
const clearSearchResults = () => {
searchResults.value = [];
};
// 清除错误
const clearError = () => {
error.value = null;
};
// 强制刷新数据
const refreshData = async () => {
return await fetchFollowingArtists(true);
};
// 重置状态
const reset = () => {
followingArtists.value = [];
searchResults.value = [];
loading.value = false;
error.value = null;
lastFetchTime.value = 0;
};
return {
// 状态
followingArtists,
searchResults,
loading,
error,
lastFetchTime,
// 计算属性
isDataStale,
hasFollowingArtists,
hasSearchResults,
// 方法
fetchFollowingArtists,
searchArtists,
followArtist,
unfollowArtist,
clearSearchResults,
clearError,
refreshData,
reset
};
});
+21
View File
@@ -0,0 +1,21 @@
/**
* 格式化文件大小
* @param bytes 字节数
* @returns 格式化后的文件大小字符串
*/
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 生成文件预览URL
* @param filePath 文件路径
* @returns 预览URL
*/
export const getPreviewUrl = (filePath: string): string => {
return `/api/repository/preview?path=${encodeURIComponent(filePath)}`
}
+116 -76
View File
@@ -18,25 +18,47 @@
</svg>
</button>
</div>
<button @click="handleRefresh" class="btn btn-secondary" :disabled="artistStore.loading">
<svg viewBox="0 0 24 24" fill="currentColor" class="refresh-icon">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
刷新
</button>
</div>
</div>
<div v-if="error" class="error-section">
<ErrorMessage :error="error" @dismiss="clearError" />
<div v-if="artistStore.error" class="error-section">
<ErrorMessage :error="artistStore.error" @dismiss="artistStore.clearError" />
</div>
<div v-if="loading" class="loading-section">
<LoadingSpinner text="加载中..." />
<div v-if="artistStore.loading" class="loading-section">
<LoadingSpinner text="正在获取最新数据..." />
</div>
<div v-else class="artists-content">
<!-- 关注列表 -->
<div class="section">
<div class="section-header">
<h2 class="section-title">关注的作者</h2>
<div v-if="artistStore.hasFollowingArtists" class="cache-indicator">
<span v-if="artistStore.isDataStale" class="cache-status stale">
<svg viewBox="0 0 24 24" fill="currentColor" class="cache-icon">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
数据已过期
</span>
<span v-else class="cache-status fresh">
<svg viewBox="0 0 24 24" fill="currentColor" class="cache-icon">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
数据已缓存
</span>
</div>
</div>
<div v-if="followingArtists.length > 0" class="artists-grid">
<div v-if="artistStore.followingArtists.length > 0" class="artists-grid">
<div
v-for="artist in followingArtists"
v-for="artist in artistStore.followingArtists"
:key="artist.id"
class="artist-card"
>
@@ -91,16 +113,19 @@
</svg>
<h3>暂无关注的作者</h3>
<p>关注喜欢的作者在这里管理他们</p>
<div v-if="!artistStore.loading && artistStore.hasFollowingArtists" class="cache-note">
<small>💡 提示数据已缓存点击刷新按钮获取最新数据</small>
</div>
</div>
</div>
</div>
<!-- 搜索建议 -->
<div v-if="searchResults.length > 0" class="section">
<div v-if="artistStore.searchResults.length > 0" class="section">
<h2 class="section-title">搜索结果</h2>
<div class="artists-grid">
<div
v-for="artist in searchResults"
v-for="artist in artistStore.searchResults"
:key="artist.id"
class="artist-card"
>
@@ -158,63 +183,40 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import artistService from '@/services/artist';
import { useArtistStore } from '@/stores/artist';
import downloadService from '@/services/download';
import type { Artist } from '@/types';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import ErrorMessage from '@/components/common/ErrorMessage.vue';
const router = useRouter();
const authStore = useAuthStore();
const artistStore = useArtistStore();
// 状态
const followingArtists = ref<Artist[]>([]);
const searchResults = ref<Artist[]>([]);
// 本地状态
const searchKeyword = ref('');
const loading = ref(false);
const error = ref<string | null>(null);
// 获取关注的作者
const fetchFollowingArtists = async () => {
try {
loading.value = true;
error.value = null;
const response = await artistService.getFollowingArtists();
if (response.success && response.data) {
followingArtists.value = response.data.artists;
} else {
throw new Error(response.error || '获取关注列表失败');
}
await artistStore.fetchFollowingArtists();
} catch (err) {
error.value = err instanceof Error ? err.message : '获取关注列表失败';
console.error('获取关注列表失败:', err);
} finally {
loading.value = false;
}
};
// 搜索作者
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
searchResults.value = [];
artistStore.clearSearchResults();
return;
}
try {
// 这里需要根据实际API调整
// const response = await artistService.searchArtists({ keyword: searchKeyword.value });
// if (response.success && response.data) {
// searchResults.value = response.data.artists;
// }
// 暂时使用模拟数据
searchResults.value = [];
await artistStore.searchArtists(searchKeyword.value);
} catch (err) {
error.value = err instanceof Error ? err.message : '搜索失败';
console.error('搜索失败:', err);
}
};
@@ -222,25 +224,8 @@ const handleSearch = async () => {
// 关注作者
const handleFollow = async (artistId: number) => {
try {
const response = await artistService.followArtist(artistId, 'follow');
if (response.success) {
// 更新搜索结果的关注状态
const artist = searchResults.value.find(a => a.id === artistId);
if (artist) {
artist.is_followed = true;
}
// 添加到关注列表
const artistToAdd = searchResults.value.find(a => a.id === artistId);
if (artistToAdd) {
followingArtists.value.push(artistToAdd);
}
} else {
throw new Error(response.error || '关注失败');
}
await artistStore.followArtist(artistId);
} catch (err) {
error.value = err instanceof Error ? err.message : '关注失败';
console.error('关注失败:', err);
}
};
@@ -248,22 +233,8 @@ const handleFollow = async (artistId: number) => {
// 取消关注
const handleUnfollow = async (artistId: number) => {
try {
const response = await artistService.followArtist(artistId, 'unfollow');
if (response.success) {
// 从关注列表中移除
followingArtists.value = followingArtists.value.filter(a => a.id !== artistId);
// 更新搜索结果的关注状态
const artist = searchResults.value.find(a => a.id === artistId);
if (artist) {
artist.is_followed = false;
}
} else {
throw new Error(response.error || '取消关注失败');
}
await artistStore.unfollowArtist(artistId);
} catch (err) {
error.value = err instanceof Error ? err.message : '取消关注失败';
console.error('取消关注失败:', err);
}
};
@@ -282,14 +253,18 @@ const handleDownloadArtist = async (artistId: number) => {
throw new Error(response.error || '下载失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '下载失败';
artistStore.error = err instanceof Error ? err.message : '下载失败';
console.error('下载失败:', err);
}
};
// 清除错误
const clearError = () => {
error.value = null;
// 刷新数据
const handleRefresh = async () => {
try {
await artistStore.refreshData();
} catch (err) {
console.error('刷新失败:', err);
}
};
// 处理图片URL,通过后端代理
@@ -305,6 +280,14 @@ const getImageUrl = (originalUrl: string) => {
return originalUrl;
};
// 监听数据过期状态,自动刷新
watch(() => artistStore.isDataStale, (isStale) => {
if (isStale && artistStore.hasFollowingArtists) {
console.log('数据已过期,自动刷新...');
fetchFollowingArtists();
}
});
onMounted(() => {
fetchFollowingArtists();
});
@@ -377,6 +360,12 @@ onMounted(() => {
height: 1.25rem;
}
.refresh-icon {
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
}
.error-section,
.loading-section {
margin-bottom: 2rem;
@@ -393,11 +382,49 @@ onMounted(() => {
margin-bottom: 3rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
margin: 0;
}
.cache-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cache-status {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
.cache-status.fresh {
background: #dcfce7;
color: #166534;
}
.cache-status.stale {
background: #fef3c7;
color: #92400e;
}
.cache-icon {
width: 0.875rem;
height: 0.875rem;
}
.artists-grid {
@@ -569,6 +596,19 @@ onMounted(() => {
line-height: 1.6;
}
.cache-note {
margin-top: 1rem;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 0.375rem;
text-align: center;
}
.cache-note small {
color: #6b7280;
font-size: 0.75rem;
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
+21
View File
@@ -99,6 +99,18 @@ onMounted(async () => {
实时查看下载进度管理下载历史和任务
</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"/>
</svg>
</div>
<h3 class="feature-title">仓库管理</h3>
<p class="feature-description">
管理本地作品仓库分类整理和快速检索
</p>
</div>
</div>
</div>
</div>
@@ -134,6 +146,15 @@ onMounted(async () => {
</div>
<span>作者管理</span>
</router-link>
<router-link to="/repository" class="quick-action-card">
<div class="quick-action-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"/>
</svg>
</div>
<span>仓库管理</span>
</router-link>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
<template>
<div v-if="artwork" class="modal-overlay" @click="closeModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>{{ artwork.title }}</h3>
<button @click="closeModal" class="modal-close">×</button>
</div>
<div class="modal-body">
<div class="artwork-details">
<p><strong>作者:</strong> {{ artwork.artist }}</p>
<p><strong>作品ID:</strong> {{ artwork.id }}</p>
<p><strong>文件大小:</strong> {{ formatFileSize(artwork.size) }}</p>
<p><strong>文件数量:</strong> {{ artwork.files.length }}</p>
</div>
<div class="artwork-files">
<h4>文件列表</h4>
<div class="files-grid">
<div
v-for="file in artwork.files"
:key="file.path"
class="file-item"
>
<img
:src="getPreviewUrl(file.path)"
:alt="file.name"
class="file-preview"
/>
<div class="file-info">
<p>{{ file.name }}</p>
<p>{{ formatFileSize(file.size) }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="deleteArtwork" class="btn btn-danger">
删除作品
</button>
<button @click="closeModal" class="btn btn-secondary">
关闭
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Artwork } from '@/stores/repository.ts'
interface Props {
artwork: Artwork | null
}
interface Emits {
(e: 'close'): void
(e: 'delete-artwork', artworkId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 关闭模态框
const closeModal = () => {
emit('close')
}
// 删除作品
const deleteArtwork = () => {
if (props.artwork) {
emit('delete-artwork', props.artwork.id)
}
}
import { formatFileSize, getPreviewUrl } from '@/utils/formatters'
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 0.5rem;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
width: 90%;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding: 1.5rem;
}
.artwork-details {
margin-bottom: 2rem;
}
.artwork-details p {
margin: 0.5rem 0;
}
.files-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.file-item {
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
overflow: hidden;
}
.file-preview {
width: 100%;
height: 100px;
object-fit: cover;
}
.file-info {
padding: 0.5rem;
}
.file-info p {
margin: 0.25rem 0;
font-size: 0.75rem;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,389 @@
<template>
<div class="config-section">
<h3>仓库配置</h3>
<div class="config-form">
<div class="form-group">
<label>下载目录</label>
<div class="path-input-group">
<input
v-model="config.downloadDir"
type="text"
placeholder="设置下载目录路径,例如: ./downloads 或 D:\downloads"
class="form-input"
/>
<button type="button" @click="selectDownloadDir" class="btn btn-secondary">
选择目录
</button>
<button type="button" @click="testDownloadDir" class="btn btn-outline">
测试
</button>
</div>
<small class="form-help">
<strong>路径示例</strong><br>
相对路径<code>./downloads</code>相对于项目根目录<br>
绝对路径<code>D:\downloads</code> <code>/home/user/downloads</code><br>
当前目录<code>.</code> <code>./</code>
</small>
</div>
<!-- 自动迁移选项 -->
<div class="form-group">
<label class="checkbox-label">
<input
v-model="config.autoMigration"
type="checkbox"
class="form-checkbox"
/>
<span>自动迁移旧下载文件</span>
</label>
<small class="form-help">
启用后保存配置时会自动将旧下载目录中的文件移动到新目录
</small>
</div>
<!-- 迁移进度显示 -->
<div v-if="migrating" class="migration-progress">
<div class="progress-header">
<h4>正在迁移文件...</h4>
<span class="progress-text">{{ migrationProgress }}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: migrationPercent + '%' }"></div>
</div>
</div>
<!-- 迁移结果 -->
<div v-if="migrationResult" class="migration-result">
<h4>迁移完成</h4>
<div class="result-stats">
<p> 成功迁移: {{ migrationResult.totalMigrated }} 个作品</p>
<p> 跳过: {{ migrationResult.log.filter((item: any) => item.status === 'skipped').length }} 个作品</p>
</div>
<div class="migration-log">
<h5>详细日志</h5>
<div
v-for="(item, index) in migrationResult.log.slice(0, 10)"
:key="index"
class="log-item"
:class="(item as any).status"
>
<span class="log-status">{{ (item as any).status === 'success' ? '✅' : '⏭️' }}</span>
<span class="log-text">{{ (item as any).title }} (ID: {{ (item as any).id }})</span>
<span v-if="(item as any).reason" class="log-reason">{{ (item as any).reason }}</span>
</div>
<div v-if="migrationResult.log.length > 10" class="log-more">
还有 {{ migrationResult.log.length - 10 }} 个文件...
</div>
</div>
</div>
<div class="form-group">
<label>文件结构</label>
<select v-model="config.fileStructure" class="form-select">
<option value="artist/artwork">作者/作品</option>
<option value="artwork">仅作品</option>
<option value="flat">扁平结构</option>
</select>
</div>
<div class="form-group">
<label>命名模式</label>
<input
v-model="config.namingPattern"
type="text"
placeholder="{artist_name}/{artwork_id}_{title}"
class="form-input"
/>
</div>
<div class="form-group">
<label>最大文件大小 (MB)</label>
<input
v-model.number="config.maxFileSize"
type="number"
placeholder="0表示无限制"
class="form-input"
/>
</div>
<div class="form-group">
<label>允许的文件扩展名</label>
<input
:value="config.allowedExtensions.join(',')"
@input="(e) => config.allowedExtensions = (e.target as HTMLInputElement).value.split(',').map(ext => ext.trim()).filter(ext => ext)"
type="text"
placeholder=".jpg,.png,.gif,.webp"
class="form-input"
/>
</div>
<div class="form-actions">
<button @click="saveConfig" class="btn btn-primary" :disabled="saving">
{{ saving ? '保存中...' : '保存配置' }}
</button>
<button @click="resetConfig" class="btn btn-outline" :disabled="saving">
重置为默认
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { RepositoryConfig } from '@/stores/repository.ts'
interface Props {
config: RepositoryConfig
migrating: boolean
migrationProgress: string
migrationPercent: number
migrationResult: any
}
interface Emits {
(e: 'update:config', config: RepositoryConfig): void
(e: 'save-config'): void
(e: 'reset-config'): void
(e: 'select-download-dir'): void
(e: 'test-download-dir'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const saving = ref(false)
// 选择下载目录
const selectDownloadDir = () => {
emit('select-download-dir')
}
// 测试下载目录
const testDownloadDir = () => {
emit('test-download-dir')
}
// 保存配置
const saveConfig = async () => {
saving.value = true
try {
emit('save-config')
} finally {
saving.value = false
}
}
// 重置配置
const resetConfig = () => {
emit('reset-config')
}
</script>
<style scoped>
.config-section h3 {
margin: 0 0 1.5rem 0;
color: #1f2937;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-input,
.form-select {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.path-input-group {
display: flex;
gap: 0.5rem;
}
.path-input-group .form-input {
flex: 1;
}
.path-input-group .btn {
white-space: nowrap;
min-width: 80px;
}
.form-help {
color: #6b7280;
font-size: 0.75rem;
margin-top: 0.25rem;
line-height: 1.4;
}
.form-help strong {
color: #374151;
font-weight: 600;
}
.form-help code {
background: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: 0.7rem;
color: #1f2937;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 500;
color: #374151;
}
.form-checkbox {
width: 1rem;
height: 1rem;
accent-color: #3b82f6;
}
.form-actions {
margin-top: 2rem;
display: flex;
gap: 1rem;
align-items: center;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.btn-outline {
background: white;
color: #3b82f6;
border: 1px solid #3b82f6;
}
.btn-outline:hover {
background: #3b82f6;
color: white;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.migration-progress {
margin-top: 1.5rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.progress-header h4 {
margin: 0;
color: #1f2937;
}
.progress-text {
font-size: 0.875rem;
color: #6b7280;
}
.progress-bar {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #3b82f6;
border-radius: 4px;
transition: width 0.3s ease-in-out;
}
.migration-result {
margin-top: 2rem;
padding: 1.5rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.result-stats {
margin-bottom: 1rem;
}
.migration-log {
max-height: 300px;
overflow-y: auto;
}
.log-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid #e5e7eb;
}
.log-status {
font-size: 1.2rem;
}
.log-reason {
color: #6b7280;
font-size: 0.875rem;
}
.log-more {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.5rem;
}
</style>
@@ -0,0 +1,244 @@
<template>
<div class="migrate-section">
<h3>数据迁移</h3>
<p class="migrate-description">
将旧项目中的作品文件迁移到当前仓库中系统会自动识别作品ID并避免重复迁移
</p>
<div class="migrate-form">
<div class="form-group">
<label>源目录路径</label>
<div class="path-input-group">
<input
v-model="migrateSourceDir"
type="text"
placeholder="选择要迁移的目录路径,例如: D:\old-downloads"
class="form-input"
/>
<button type="button" @click="selectMigrateDir" class="btn btn-secondary">
选择目录
</button>
<button type="button" @click="testMigrateDir" class="btn btn-outline">
测试
</button>
</div>
<small class="form-help">
<strong>迁移说明</strong><br>
选择要迁移的源目录系统会将整个目录结构移动到目标位置<br>
如果目标位置已存在同名目录将跳过迁移<br>
迁移完成后源文件会被移动到新位置移动操作
</small>
</div>
<div class="form-actions">
<button
@click="startMigration"
class="btn btn-primary"
:disabled="migrating"
>
{{ migrating ? '迁移中...' : '开始迁移' }}
</button>
</div>
</div>
<!-- 迁移结果 -->
<div v-if="migrationResult" class="migration-result">
<h4>迁移结果</h4>
<div class="result-stats">
<p>成功迁移: {{ migrationResult.totalMigrated }} 个作品</p>
<p>跳过: {{ migrationResult.log.filter((item: any) => item.status === 'skipped').length }} 个作品</p>
</div>
<div class="migration-log">
<h5>详细日志</h5>
<div
v-for="(item, index) in migrationResult.log"
:key="index"
class="log-item"
:class="(item as any).status"
>
<span class="log-status">{{ (item as any).status === 'success' ? '✅' : '⏭️' }}</span>
<span class="log-text">{{ (item as any).title }} (ID: {{ (item as any).id }})</span>
<span v-if="(item as any).reason" class="log-reason">{{ (item as any).reason }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
migrating: boolean
migrationResult: any
}
interface Emits {
(e: 'update:migrateSourceDir', dir: string): void
(e: 'select-migrate-dir'): void
(e: 'test-migrate-dir'): void
(e: 'start-migration'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const migrateSourceDir = ref('')
// 选择迁移目录
const selectMigrateDir = () => {
emit('select-migrate-dir')
}
// 测试迁移目录
const testMigrateDir = () => {
emit('test-migrate-dir')
}
// 开始迁移
const startMigration = () => {
emit('start-migration')
}
</script>
<style scoped>
.migrate-section h3 {
margin: 0 0 1.5rem 0;
color: #1f2937;
}
.migrate-description {
color: #6b7280;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.path-input-group {
display: flex;
gap: 0.5rem;
}
.path-input-group .form-input {
flex: 1;
}
.path-input-group .btn {
white-space: nowrap;
min-width: 80px;
}
.form-help {
color: #6b7280;
font-size: 0.75rem;
margin-top: 0.25rem;
line-height: 1.4;
}
.form-help strong {
color: #374151;
font-weight: 600;
}
.form-actions {
margin-top: 2rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.btn-outline {
background: white;
color: #3b82f6;
border: 1px solid #3b82f6;
}
.btn-outline:hover {
background: #3b82f6;
color: white;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.migration-result {
margin-top: 2rem;
padding: 1.5rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.result-stats {
margin-bottom: 1rem;
}
.migration-log {
max-height: 300px;
overflow-y: auto;
}
.log-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid #e5e7eb;
}
.log-status {
font-size: 1.2rem;
}
.log-reason {
color: #6b7280;
font-size: 0.875rem;
}
</style>
@@ -0,0 +1,78 @@
<template>
<div class="stats-grid" v-if="stats">
<div class="stat-card">
<div class="stat-icon">📁</div>
<div class="stat-content">
<div class="stat-value">{{ stats.totalArtworks }}</div>
<div class="stat-label">总作品数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">👤</div>
<div class="stat-content">
<div class="stat-value">{{ stats.totalArtists }}</div>
<div class="stat-label">总作者数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">💾</div>
<div class="stat-content">
<div class="stat-value">{{ formatFileSize(stats.totalSize) }}</div>
<div class="stat-label">总存储大小</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">💿</div>
<div class="stat-content">
<div class="stat-value">{{ stats.diskUsage.usagePercent }}%</div>
<div class="stat-label">磁盘使用率</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { RepositoryStats } from '@/stores/repository.ts'
interface Props {
stats: RepositoryStats | null
}
defineProps<Props>()
import { formatFileSize } from '@/utils/formatters'
</script>
<style scoped>
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
font-size: 2rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.stat-label {
color: #6b7280;
font-size: 0.875rem;
}
</style>