画廊添加,详细描述,优化功能
This commit is contained in:
@@ -11,6 +11,9 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功
|
|||||||
- 📥 作品下载管理
|
- 📥 作品下载管理
|
||||||
- 👤 作者搜索
|
- 👤 作者搜索
|
||||||
- 🆔 作品ID搜索
|
- 🆔 作品ID搜索
|
||||||
|
- 📁 本地仓库管理
|
||||||
|
- 🖼️ 画廊模式浏览
|
||||||
|
- 🔍 作品检索和分类
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
@@ -72,11 +75,11 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功
|
|||||||
|
|
||||||
## 📱 功能展示
|
## 📱 功能展示
|
||||||
|
|
||||||
### 主界面
|
### 脚本启动
|
||||||

|

|
||||||
|
|
||||||
### 搜索功能
|
### 搜索功能
|
||||||
- **作品搜索**
|
- **作品搜索(下载过的会有提示)**
|
||||||

|

|
||||||
|
|
||||||
- **作者搜索**
|
- **作者搜索**
|
||||||
@@ -85,9 +88,22 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功
|
|||||||
- **作品ID搜索**
|
- **作品ID搜索**
|
||||||

|

|
||||||
|
|
||||||
|
### 列表轮换
|
||||||
|
- **通过作者搜索进入作品可以上下一个切换**
|
||||||
|
- 
|
||||||
|
|
||||||
### 下载管理
|
### 下载管理
|
||||||

|

|
||||||
|
|
||||||
|
### 仓库管理
|
||||||
|

|
||||||
|
|
||||||
|
### 作品检索
|
||||||
|

|
||||||
|
|
||||||
|
### 画廊模式
|
||||||
|

|
||||||
|
|
||||||
## 🛠️ 开发说明
|
## 🛠️ 开发说明
|
||||||
|
|
||||||
本项目刚刚建立,很多功能还不够完善,欢迎大家一起参与开发!
|
本项目刚刚建立,很多功能还不够完善,欢迎大家一起参与开发!
|
||||||
|
|||||||
@@ -2,6 +2,62 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ArtistService = require('../services/artist');
|
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
|
* 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
Binary file not shown.
@@ -19,6 +19,12 @@ export interface ArtistFollowersOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchArtistsOptions {
|
||||||
|
keyword: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
class ArtistService {
|
class ArtistService {
|
||||||
/**
|
/**
|
||||||
* 获取作者信息
|
* 获取作者信息
|
||||||
@@ -88,6 +94,20 @@ class ArtistService {
|
|||||||
const url = query ? `/api/artist/following?${query}` : '/api/artist/following';
|
const url = query ? `/api/artist/following?${query}` : '/api/artist/following';
|
||||||
return apiService.get<{ artists: Artist[]; total: number }>(url);
|
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();
|
export const artistService = new ArtistService();
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
+117
-77
@@ -18,25 +18,47 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-section">
|
<div v-if="artistStore.error" class="error-section">
|
||||||
<ErrorMessage :error="error" @dismiss="clearError" />
|
<ErrorMessage :error="artistStore.error" @dismiss="artistStore.clearError" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading-section">
|
<div v-if="artistStore.loading" class="loading-section">
|
||||||
<LoadingSpinner text="加载中..." />
|
<LoadingSpinner text="正在获取最新数据..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="artists-content">
|
<div v-else class="artists-content">
|
||||||
<!-- 关注列表 -->
|
<!-- 关注列表 -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2 class="section-title">关注的作者</h2>
|
<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
|
<div
|
||||||
v-for="artist in followingArtists"
|
v-for="artist in artistStore.followingArtists"
|
||||||
:key="artist.id"
|
:key="artist.id"
|
||||||
class="artist-card"
|
class="artist-card"
|
||||||
>
|
>
|
||||||
@@ -91,16 +113,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h3>暂无关注的作者</h3>
|
<h3>暂无关注的作者</h3>
|
||||||
<p>关注喜欢的作者,在这里管理他们</p>
|
<p>关注喜欢的作者,在这里管理他们</p>
|
||||||
|
<div v-if="!artistStore.loading && artistStore.hasFollowingArtists" class="cache-note">
|
||||||
|
<small>💡 提示:数据已缓存,点击刷新按钮获取最新数据</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h2 class="section-title">搜索结果</h2>
|
||||||
<div class="artists-grid">
|
<div class="artists-grid">
|
||||||
<div
|
<div
|
||||||
v-for="artist in searchResults"
|
v-for="artist in artistStore.searchResults"
|
||||||
:key="artist.id"
|
:key="artist.id"
|
||||||
class="artist-card"
|
class="artist-card"
|
||||||
>
|
>
|
||||||
@@ -158,63 +183,40 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import artistService from '@/services/artist';
|
import { useArtistStore } from '@/stores/artist';
|
||||||
import downloadService from '@/services/download';
|
import downloadService from '@/services/download';
|
||||||
import type { Artist } from '@/types';
|
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||||
import ErrorMessage from '@/components/common/ErrorMessage.vue';
|
import ErrorMessage from '@/components/common/ErrorMessage.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const artistStore = useArtistStore();
|
||||||
|
|
||||||
// 状态
|
// 本地状态
|
||||||
const followingArtists = ref<Artist[]>([]);
|
|
||||||
const searchResults = ref<Artist[]>([]);
|
|
||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const loading = ref(false);
|
|
||||||
const error = ref<string | null>(null);
|
|
||||||
|
|
||||||
// 获取关注的作者
|
// 获取关注的作者
|
||||||
const fetchFollowingArtists = async () => {
|
const fetchFollowingArtists = async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
await artistStore.fetchFollowingArtists();
|
||||||
error.value = null;
|
|
||||||
|
|
||||||
const response = await artistService.getFollowingArtists();
|
|
||||||
if (response.success && response.data) {
|
|
||||||
followingArtists.value = response.data.artists;
|
|
||||||
} else {
|
|
||||||
throw new Error(response.error || '获取关注列表失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '获取关注列表失败';
|
|
||||||
console.error('获取关注列表失败:', err);
|
console.error('获取关注列表失败:', err);
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 搜索作者
|
// 搜索作者
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (!searchKeyword.value.trim()) {
|
if (!searchKeyword.value.trim()) {
|
||||||
searchResults.value = [];
|
artistStore.clearSearchResults();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 这里需要根据实际API调整
|
await artistStore.searchArtists(searchKeyword.value);
|
||||||
// const response = await artistService.searchArtists({ keyword: searchKeyword.value });
|
|
||||||
// if (response.success && response.data) {
|
|
||||||
// searchResults.value = response.data.artists;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 暂时使用模拟数据
|
|
||||||
searchResults.value = [];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '搜索失败';
|
|
||||||
console.error('搜索失败:', err);
|
console.error('搜索失败:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -222,25 +224,8 @@ const handleSearch = async () => {
|
|||||||
// 关注作者
|
// 关注作者
|
||||||
const handleFollow = async (artistId: number) => {
|
const handleFollow = async (artistId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await artistService.followArtist(artistId, 'follow');
|
await artistStore.followArtist(artistId);
|
||||||
|
|
||||||
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 || '关注失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '关注失败';
|
|
||||||
console.error('关注失败:', err);
|
console.error('关注失败:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -248,22 +233,8 @@ const handleFollow = async (artistId: number) => {
|
|||||||
// 取消关注
|
// 取消关注
|
||||||
const handleUnfollow = async (artistId: number) => {
|
const handleUnfollow = async (artistId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await artistService.followArtist(artistId, 'unfollow');
|
await artistStore.unfollowArtist(artistId);
|
||||||
|
|
||||||
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 || '取消关注失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '取消关注失败';
|
|
||||||
console.error('取消关注失败:', err);
|
console.error('取消关注失败:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -282,14 +253,18 @@ const handleDownloadArtist = async (artistId: number) => {
|
|||||||
throw new Error(response.error || '下载失败');
|
throw new Error(response.error || '下载失败');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '下载失败';
|
artistStore.error = err instanceof Error ? err.message : '下载失败';
|
||||||
console.error('下载失败:', err);
|
console.error('下载失败:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 清除错误
|
// 刷新数据
|
||||||
const clearError = () => {
|
const handleRefresh = async () => {
|
||||||
error.value = null;
|
try {
|
||||||
|
await artistStore.refreshData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('刷新失败:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理图片URL,通过后端代理
|
// 处理图片URL,通过后端代理
|
||||||
@@ -305,6 +280,14 @@ const getImageUrl = (originalUrl: string) => {
|
|||||||
return originalUrl;
|
return originalUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 监听数据过期状态,自动刷新
|
||||||
|
watch(() => artistStore.isDataStale, (isStale) => {
|
||||||
|
if (isStale && artistStore.hasFollowingArtists) {
|
||||||
|
console.log('数据已过期,自动刷新...');
|
||||||
|
fetchFollowingArtists();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchFollowingArtists();
|
fetchFollowingArtists();
|
||||||
});
|
});
|
||||||
@@ -377,6 +360,12 @@ onMounted(() => {
|
|||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.refresh-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.error-section,
|
.error-section,
|
||||||
.loading-section {
|
.loading-section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
@@ -393,11 +382,49 @@ onMounted(() => {
|
|||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
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 {
|
.artists-grid {
|
||||||
@@ -569,6 +596,19 @@ onMounted(() => {
|
|||||||
line-height: 1.6;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ onMounted(async () => {
|
|||||||
实时查看下载进度,管理下载历史和任务
|
实时查看下载进度,管理下载历史和任务
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,6 +146,15 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<span>作者管理</span>
|
<span>作者管理</span>
|
||||||
</router-link>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+80
-932
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
Reference in New Issue
Block a user