画廊添加,详细描述,优化功能
This commit is contained in:
BIN
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
</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">
|
||||
<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
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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