初始化

This commit is contained in:
2025-08-21 10:43:04 +08:00
commit 29a79b1c6b
68 changed files with 13314 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const isLoggedIn = computed(() => authStore.isLoggedIn)
const username = computed(() => authStore.username)
onMounted(async () => {
await authStore.fetchLoginStatus()
})
</script>
<template>
<div id="app">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-container">
<div class="nav-brand">
<RouterLink to="/" class="brand-link">
<svg viewBox="0 0 24 24" fill="currentColor" class="brand-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 class="brand-text">Pixiv Manager</span>
</RouterLink>
</div>
<div class="nav-menu">
<RouterLink to="/" class="nav-link">首页</RouterLink>
<RouterLink to="/search" class="nav-link" v-if="isLoggedIn">搜索</RouterLink>
<RouterLink to="/downloads" class="nav-link" v-if="isLoggedIn">下载管理</RouterLink>
<RouterLink to="/artists" class="nav-link" v-if="isLoggedIn">作者管理</RouterLink>
</div>
<div class="nav-auth">
<div v-if="isLoggedIn" class="user-info">
<span class="username">{{ username }}</span>
<button @click="authStore.logout" class="btn btn-text">登出</button>
</div>
<RouterLink v-else to="/login" class="btn btn-primary">登录</RouterLink>
</div>
</div>
</nav>
<!-- 主内容区域 -->
<main class="main-content">
<RouterView />
</main>
<!-- 页脚 -->
<footer class="footer">
<div class="footer-container">
<p>&copy; 2024 Pixiv Manager. 仅供学习和个人使用</p>
</div>
</footer>
</div>
</template>
<style scoped>
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background: white;
border-bottom: 1px solid #e5e7eb;
position: sticky;
top: 0;
z-index: 100;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 4rem;
}
.nav-brand {
display: flex;
align-items: center;
}
.brand-link {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: #1f2937;
font-weight: 700;
font-size: 1.25rem;
}
.brand-icon {
width: 2rem;
height: 2rem;
color: #3b82f6;
}
.brand-text {
color: #1f2937;
}
.nav-menu {
display: flex;
gap: 2rem;
}
.nav-link {
text-decoration: none;
color: #6b7280;
font-weight: 500;
transition: color 0.2s;
padding: 0.5rem 0;
border-bottom: 2px solid transparent;
}
.nav-link:hover {
color: #3b82f6;
}
.nav-link.router-link-active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.nav-auth {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.username {
color: #374151;
font-weight: 500;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-text {
background: none;
color: #6b7280;
padding: 0.25rem 0.5rem;
}
.btn-text:hover {
color: #374151;
background: #f3f4f6;
}
.main-content {
flex: 1;
}
.footer {
background: #f8fafc;
border-top: 1px solid #e5e7eb;
padding: 2rem 0;
margin-top: auto;
}
.footer-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
text-align: center;
}
.footer p {
color: #6b7280;
margin: 0;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.nav-container {
padding: 0 1rem;
}
.nav-menu {
gap: 1rem;
}
.brand-text {
display: none;
}
.username {
display: none;
}
}
</style>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

+281
View File
@@ -0,0 +1,281 @@
/* 全局样式重置 */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
color: #1f2937;
}
/* 全局按钮样式 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
transform: translateY(-1px);
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
}
.btn-text {
background: none;
color: #6b7280;
padding: 0.5rem 1rem;
}
.btn-text:hover {
color: #374151;
background: #f3f4f6;
}
/* 全局容器样式 */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 0 1rem;
}
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 工具类 */
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.mt-5 { margin-top: 1.25rem; }
.mt-6 { margin-top: 1.5rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-5 { margin-bottom: 1.25rem; }
.mb-6 { margin-bottom: 1.5rem; }
.ml-1 { margin-left: 0.25rem; }
.ml-2 { margin-left: 0.5rem; }
.ml-3 { margin-left: 0.75rem; }
.ml-4 { margin-left: 1rem; }
.mr-1 { margin-right: 0.25rem; }
.mr-2 { margin-right: 0.5rem; }
.mr-3 { margin-right: 0.75rem; }
.mr-4 { margin-right: 1rem; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-5 { padding: 1.25rem; }
.p-6 { padding: 1.5rem; }
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-5 { gap: 1.25rem; }
.gap-6 { gap: 1.5rem; }
.w-full {
width: 100%;
}
.h-full {
height: 100%;
}
.rounded {
border-radius: 0.25rem;
}
.rounded-lg {
border-radius: 0.5rem;
}
.rounded-xl {
border-radius: 0.75rem;
}
.shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.shadow-lg {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.bg-white {
background-color: white;
}
.bg-gray-50 {
background-color: #f9fafb;
}
.bg-gray-100 {
background-color: #f3f4f6;
}
.text-gray-500 {
color: #6b7280;
}
.text-gray-600 {
color: #4b5563;
}
.text-gray-700 {
color: #374151;
}
.text-gray-900 {
color: #111827;
}
.text-blue-600 {
color: #2563eb;
}
.text-red-600 {
color: #dc2626;
}
.text-green-600 {
color: #059669;
}
.text-sm {
font-size: 0.875rem;
}
.text-base {
font-size: 1rem;
}
.text-lg {
font-size: 1.125rem;
}
.text-xl {
font-size: 1.25rem;
}
.text-2xl {
font-size: 1.5rem;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.font-bold {
font-weight: 700;
}
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<template>
<div class="artwork-card" @click="handleClick">
<div class="artwork-image">
<img
:src="artwork.image_urls.medium"
:alt="artwork.title"
@load="imageLoaded = true"
@error="imageError = true"
:class="{ loaded: imageLoaded, error: imageError }"
/>
<div v-if="!imageLoaded && !imageError" class="image-placeholder">
<LoadingSpinner text="加载中..." />
</div>
<div v-if="imageError" class="image-error">
<span>图片加载失败</span>
</div>
</div>
<div class="artwork-info">
<h3 class="artwork-title" :title="artwork.title">
{{ artwork.title }}
</h3>
<div class="artwork-meta">
<div class="artist-info">
<img
:src="artwork.user.profile_image_urls.medium"
:alt="artwork.user.name"
class="artist-avatar"
/>
<span class="artist-name">{{ artwork.user.name }}</span>
</div>
<div class="artwork-stats">
<span class="stat">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
{{ artwork.total_bookmarks }}
</span>
<span class="stat">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
{{ artwork.total_view }}
</span>
</div>
</div>
<div class="artwork-tags">
<span
v-for="tag in artwork.tags.slice(0, 3)"
:key="tag.name"
class="tag"
>
{{ tag.name }}
</span>
<span v-if="artwork.tags.length > 3" class="tag-more">
+{{ artwork.tags.length - 3 }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import type { Artwork } from '@/types';
interface Props {
artwork: Artwork;
}
interface Emits {
(e: 'click', artwork: Artwork): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const imageLoaded = ref(false);
const imageError = ref(false);
const handleClick = () => {
emit('click', props.artwork);
};
</script>
<style scoped>
.artwork-card {
background: white;
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.artwork-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.artwork-image {
position: relative;
aspect-ratio: 1;
background: #f3f4f6;
overflow: hidden;
}
.artwork-image img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s;
}
.artwork-image img.loaded {
opacity: 1;
}
.artwork-image img.error {
opacity: 0.5;
}
.image-placeholder,
.image-error {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
}
.image-error {
color: #6b7280;
font-size: 0.875rem;
}
.artwork-info {
padding: 1rem;
}
.artwork-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artwork-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.artist-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.artist-avatar {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
object-fit: cover;
}
.artist-name {
font-size: 0.875rem;
color: #6b7280;
font-weight: 500;
}
.artwork-stats {
display: flex;
gap: 0.75rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.stat svg {
width: 0.875rem;
height: 0.875rem;
}
.artwork-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.tag {
background: #f3f4f6;
color: #374151;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
line-height: 1;
}
.tag-more {
background: #e5e7eb;
color: #6b7280;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
line-height: 1;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div v-if="error" class="error-message" :class="type">
<div class="error-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<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>
</div>
<div class="error-content">
<div class="error-title">{{ title }}</div>
<div class="error-text">{{ error }}</div>
</div>
<button v-if="dismissible" @click="$emit('dismiss')" class="error-close">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
</template>
<script setup lang="ts">
interface Props {
error: string | null;
title?: string;
type?: 'error' | 'warning' | 'info';
dismissible?: boolean;
}
interface Emits {
(e: 'dismiss'): void;
}
const props = withDefaults(defineProps<Props>(), {
title: '错误',
type: 'error',
dismissible: false
});
defineEmits<Emits>();
</script>
<style scoped>
.error-message {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
position: relative;
}
.error-message.error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.error-message.warning {
background-color: #fffbeb;
border: 1px solid #fed7aa;
color: #d97706;
}
.error-message.info {
background-color: #eff6ff;
border: 1px solid #bfdbfe;
color: #2563eb;
}
.error-icon {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
}
.error-content {
flex: 1;
min-width: 0;
}
.error-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.error-text {
font-size: 0.875rem;
line-height: 1.4;
}
.error-close {
flex-shrink: 0;
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
}
.error-close:hover {
background-color: rgba(0, 0, 0, 0.1);
}
.error-close svg {
width: 1rem;
height: 1rem;
}
</style>
@@ -0,0 +1,63 @@
<template>
<div class="loading-spinner" :class="{ overlay: overlay }">
<div class="spinner">
<div class="spinner-ring"></div>
<div class="spinner-text" v-if="text">{{ text }}</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
text?: string;
overlay?: boolean;
}
defineProps<Props>();
</script>
<style scoped>
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.loading-spinner.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
padding: 0;
}
.spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.spinner-ring {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner-text {
color: #666;
font-size: 0.875rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
+7
View File
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
+19
View File
@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
+14
View File
@@ -0,0 +1,14 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+63
View File
@@ -0,0 +1,63 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/search',
name: 'search',
component: () => import('../views/SearchView.vue'),
meta: { requiresAuth: true }
},
{
path: '/artwork/:id',
name: 'artwork',
component: () => import('../views/ArtworkView.vue'),
meta: { requiresAuth: true }
},
{
path: '/artist/:id',
name: 'artist',
component: () => import('../views/ArtistView.vue'),
meta: { requiresAuth: true }
},
{
path: '/downloads',
name: 'downloads',
component: () => import('../views/DownloadsView.vue'),
meta: { requiresAuth: true }
},
{
path: '/artists',
name: 'artists',
component: () => import('../views/ArtistsView.vue'),
meta: { requiresAuth: true }
}
]
})
// 路由守卫
router.beforeEach(async (to, from, next) => {
// 检查是否需要认证
if (to.meta.requiresAuth) {
// 这里可以添加认证检查逻辑
// 暂时直接放行,后续可以集成认证状态检查
next()
} else {
next()
}
})
export default router
+91
View File
@@ -0,0 +1,91 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios';
import type { ApiResponse } from '@/types';
// API配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
class ApiService {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
this.client.interceptors.request.use(
(config) => {
// 可以在这里添加认证token等
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
this.client.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
return response;
},
(error) => {
// 统一错误处理
if (error.response) {
const { status, data } = error.response;
console.error(`API Error ${status}:`, data);
} else if (error.request) {
console.error('Network Error:', error.request);
} else {
console.error('Request Error:', error.message);
}
return Promise.reject(error);
}
);
}
/**
* GET请求
*/
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const response = await this.client.get<ApiResponse<T>>(url, config);
return response.data;
}
/**
* POST请求
*/
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const response = await this.client.post<ApiResponse<T>>(url, data, config);
return response.data;
}
/**
* PUT请求
*/
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const response = await this.client.put<ApiResponse<T>>(url, data, config);
return response.data;
}
/**
* DELETE请求
*/
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const response = await this.client.delete<ApiResponse<T>>(url, config);
return response.data;
}
/**
* 健康检查
*/
async healthCheck(): Promise<ApiResponse> {
return this.get('/health');
}
}
export const apiService = new ApiService();
export default apiService;
+81
View File
@@ -0,0 +1,81 @@
import apiService from './api';
import type { ApiResponse, Artist, User } from '@/types';
export interface ArtistArtworksOptions {
type?: 'art' | 'manga' | 'novel';
filter?: 'for_ios' | 'for_android';
offset?: number;
limit?: number;
}
export interface ArtistFollowingOptions {
restrict?: 'public' | 'private';
offset?: number;
limit?: number;
}
export interface ArtistFollowersOptions {
offset?: number;
limit?: number;
}
class ArtistService {
/**
* 获取作者信息
*/
async getArtistInfo(id: number): Promise<ApiResponse<Artist>> {
return apiService.get<Artist>(`/api/artist/${id}`);
}
/**
* 获取作者作品列表
*/
async getArtistArtworks(id: number, options: ArtistArtworksOptions = {}): Promise<ApiResponse<{ artworks: any[]; next_url?: string; total: number }>> {
const params = new URLSearchParams();
if (options.type) params.append('type', options.type);
if (options.filter) params.append('filter', options.filter);
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 = query ? `/api/artist/${id}/artworks?${query}` : `/api/artist/${id}/artworks`;
return apiService.get<{ artworks: any[]; next_url?: string; total: number }>(url);
}
/**
* 获取作者关注列表
*/
async getArtistFollowing(id: number, options: ArtistFollowingOptions = {}): Promise<ApiResponse<{ users: User[]; next_url?: string; total: number }>> {
const params = new URLSearchParams();
if (options.restrict) params.append('restrict', options.restrict);
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 = query ? `/api/artist/${id}/following?${query}` : `/api/artist/${id}/following`;
return apiService.get<{ users: User[]; next_url?: string; total: number }>(url);
}
/**
* 获取作者粉丝列表
*/
async getArtistFollowers(id: number, options: ArtistFollowersOptions = {}): Promise<ApiResponse<{ users: User[]; next_url?: string; total: number }>> {
const params = new URLSearchParams();
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 = query ? `/api/artist/${id}/followers?${query}` : `/api/artist/${id}/followers`;
return apiService.get<{ users: User[]; next_url?: string; total: number }>(url);
}
/**
* 关注/取消关注作者
*/
async followArtist(id: number, action: 'follow' | 'unfollow'): Promise<ApiResponse> {
return apiService.post(`/api/artist/${id}/follow`, { action });
}
}
export const artistService = new ArtistService();
export default artistService;
+71
View File
@@ -0,0 +1,71 @@
import apiService from './api';
import type { ApiResponse, Artwork, SearchParams, PaginatedResponse } from '@/types';
export interface ArtworkDetailOptions {
include_user?: boolean;
include_series?: boolean;
}
export interface ArtworkImagesResponse {
artwork_id: number;
total_pages: number;
images: Array<{
page: number;
original: string;
large: string;
medium: string;
square_medium: string;
}>;
selected_size: string;
}
class ArtworkService {
/**
* 获取作品详情
*/
async getArtworkDetail(id: number, options: ArtworkDetailOptions = {}): Promise<ApiResponse<Artwork>> {
const params = new URLSearchParams();
if (options.include_user !== undefined) {
params.append('include_user', options.include_user.toString());
}
if (options.include_series !== undefined) {
params.append('include_series', options.include_series.toString());
}
const query = params.toString();
const url = query ? `/api/artwork/${id}?${query}` : `/api/artwork/${id}`;
return apiService.get<Artwork>(url);
}
/**
* 获取作品预览信息
*/
async getArtworkPreview(id: number): Promise<ApiResponse<Artwork>> {
return apiService.get<Artwork>(`/api/artwork/${id}/preview`);
}
/**
* 获取作品图片URL
*/
async getArtworkImages(id: number, size: string = 'medium'): Promise<ApiResponse<ArtworkImagesResponse>> {
return apiService.get<ArtworkImagesResponse>(`/api/artwork/${id}/images?size=${size}`);
}
/**
* 搜索作品
*/
async searchArtworks(params: SearchParams): Promise<ApiResponse<{ artworks: Artwork[]; next_url?: string; total: number }>> {
const queryParams = new URLSearchParams();
queryParams.append('keyword', params.keyword);
if (params.type) queryParams.append('type', params.type);
if (params.sort) queryParams.append('sort', params.sort);
if (params.duration) queryParams.append('duration', params.duration);
if (params.offset !== undefined) queryParams.append('offset', params.offset.toString());
if (params.limit !== undefined) queryParams.append('limit', params.limit.toString());
return apiService.get<{ artworks: Artwork[]; next_url?: string; total: number }>(`/api/artwork/search?${queryParams.toString()}`);
}
}
export const artworkService = new ArtworkService();
export default artworkService;
+59
View File
@@ -0,0 +1,59 @@
import apiService from './api';
import type { ApiResponse, LoginStatus } from '@/types';
export interface LoginUrlResponse {
login_url: string;
code_verifier: string;
}
export interface LoginCallbackRequest {
code: string;
}
export interface LoginCallbackResponse {
user: {
id: number;
name: string;
account: string;
};
}
class AuthService {
/**
* 获取登录状态
*/
async getLoginStatus(): Promise<ApiResponse<LoginStatus>> {
return apiService.get<LoginStatus>('/api/auth/status');
}
/**
* 获取登录URL
*/
async getLoginUrl(): Promise<ApiResponse<LoginUrlResponse>> {
return apiService.get<LoginUrlResponse>('/api/auth/login-url');
}
/**
* 处理登录回调
*/
async handleLoginCallback(code: string): Promise<ApiResponse<LoginCallbackResponse>> {
return apiService.post<LoginCallbackResponse>('/api/auth/callback', { code });
}
/**
* 重新登录
*/
async relogin(): Promise<ApiResponse> {
return apiService.post('/api/auth/relogin');
}
/**
* 登出
*/
async logout(): Promise<ApiResponse> {
return apiService.post('/api/auth/logout');
}
}
export const authService = new AuthService();
export default authService;
+66
View File
@@ -0,0 +1,66 @@
import apiService from './api';
import type { ApiResponse, DownloadTask, DownloadParams } from '@/types';
export interface DownloadArtworkRequest extends DownloadParams {
size?: 'original' | 'large' | 'medium' | 'square_medium';
quality?: 'high' | 'medium' | 'low';
format?: 'auto' | 'jpg' | 'png';
}
export interface DownloadMultipleRequest extends DownloadParams {
artworkIds: number[];
concurrent?: number;
}
export interface DownloadArtistRequest extends DownloadParams {
type?: 'art' | 'manga' | 'novel';
filter?: 'for_ios' | 'for_android';
limit?: number;
}
class DownloadService {
/**
* 下载单个作品
*/
async downloadArtwork(id: number, params: DownloadArtworkRequest = {}): Promise<ApiResponse<any>> {
return apiService.post(`/api/download/artwork/${id}`, params);
}
/**
* 批量下载作品
*/
async downloadMultipleArtworks(params: DownloadMultipleRequest): Promise<ApiResponse<any>> {
return apiService.post('/api/download/artworks', params);
}
/**
* 下载作者作品
*/
async downloadArtistArtworks(id: number, params: DownloadArtistRequest = {}): Promise<ApiResponse<any>> {
return apiService.post(`/api/download/artist/${id}`, params);
}
/**
* 获取下载进度
*/
async getDownloadProgress(taskId: string): Promise<ApiResponse<DownloadTask>> {
return apiService.get<DownloadTask>(`/api/download/progress/${taskId}`);
}
/**
* 取消下载任务
*/
async cancelDownload(taskId: string): Promise<ApiResponse> {
return apiService.delete(`/api/download/cancel/${taskId}`);
}
/**
* 获取下载历史
*/
async getDownloadHistory(offset: number = 0, limit: number = 20): Promise<ApiResponse<{ tasks: DownloadTask[]; total: number; offset: number; limit: number }>> {
return apiService.get<{ tasks: DownloadTask[]; total: number; offset: number; limit: number }>(`/api/download/history?offset=${offset}&limit=${limit}`);
}
}
export const downloadService = new DownloadService();
export default downloadService;
+139
View File
@@ -0,0 +1,139 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import authService from '@/services/auth';
import type { LoginStatus } from '@/types';
export const useAuthStore = defineStore('auth', () => {
// 状态
const loginStatus = ref<LoginStatus>({
isLoggedIn: false
});
const loading = ref(false);
const error = ref<string | null>(null);
// 计算属性
const isLoggedIn = computed(() => loginStatus.value.isLoggedIn);
const username = computed(() => loginStatus.value.username);
const userId = computed(() => loginStatus.value.user_id);
// 获取登录状态
const fetchLoginStatus = async () => {
try {
loading.value = true;
error.value = null;
const response = await authService.getLoginStatus();
if (response.success && response.data) {
loginStatus.value = response.data;
}
} catch (err) {
error.value = err instanceof Error ? err.message : '获取登录状态失败';
console.error('获取登录状态失败:', err);
} finally {
loading.value = false;
}
};
// 获取登录URL
const getLoginUrl = async () => {
try {
loading.value = true;
error.value = null;
const response = await authService.getLoginUrl();
if (response.success && response.data) {
return response.data.login_url;
}
throw new Error(response.error || '获取登录URL失败');
} catch (err) {
error.value = err instanceof Error ? err.message : '获取登录URL失败';
console.error('获取登录URL失败:', err);
throw err;
} finally {
loading.value = false;
}
};
// 处理登录回调
const handleLoginCallback = async (code: string) => {
try {
loading.value = true;
error.value = null;
const response = await authService.handleLoginCallback(code);
if (response.success) {
await fetchLoginStatus(); // 重新获取登录状态
return response.data;
}
throw new Error(response.error || '登录失败');
} catch (err) {
error.value = err instanceof Error ? err.message : '登录失败';
console.error('登录失败:', err);
throw err;
} finally {
loading.value = false;
}
};
// 重新登录
const relogin = async () => {
try {
loading.value = true;
error.value = null;
const response = await authService.relogin();
if (response.success) {
await fetchLoginStatus(); // 重新获取登录状态
return true;
}
throw new Error(response.error || '重新登录失败');
} catch (err) {
error.value = err instanceof Error ? err.message : '重新登录失败';
console.error('重新登录失败:', err);
throw err;
} finally {
loading.value = false;
}
};
// 登出
const logout = async () => {
try {
loading.value = true;
error.value = null;
const response = await authService.logout();
if (response.success) {
loginStatus.value = { isLoggedIn: false };
return true;
}
throw new Error(response.error || '登出失败');
} catch (err) {
error.value = err instanceof Error ? err.message : '登出失败';
console.error('登出失败:', err);
throw err;
} finally {
loading.value = false;
}
};
// 清除错误
const clearError = () => {
error.value = null;
};
return {
// 状态
loginStatus,
loading,
error,
// 计算属性
isLoggedIn,
username,
userId,
// 方法
fetchLoginStatus,
getLoginUrl,
handleLoginCallback,
relogin,
logout,
clearError
};
});
+12
View File
@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
+132
View File
@@ -0,0 +1,132 @@
// 基础响应类型
export interface ApiResponse<T = any> {
success: boolean;
message?: string;
data?: T;
error?: string;
code?: string;
timestamp?: string;
}
// 分页响应类型
export interface PaginatedResponse<T = any> extends ApiResponse<T[]> {
pagination?: {
page: number;
limit: number;
total: number;
pages: number;
};
}
// 用户信息
export interface User {
id: number;
name: string;
account: string;
profile_image_urls: {
medium: string;
};
is_followed: boolean;
}
// 作品信息
export interface Artwork {
id: number;
title: string;
description: string;
user: User;
image_urls: {
square_medium: string;
medium: string;
large: string;
original: string;
};
tags: Array<{
name: string;
translated_name?: string;
}>;
create_date: string;
update_date: string;
type: string;
width: number;
height: number;
page_count: number;
is_bookmarked: boolean;
total_bookmarks: number;
total_view: number;
is_muted: boolean;
meta_single_page?: {
original_image_url?: string;
large_image_url?: string;
};
meta_pages?: Array<{
image_urls: {
square_medium: string;
medium: string;
large: string;
original: string;
};
}>;
}
// 作者信息
export interface Artist {
id: number;
name: string;
account: string;
profile_image_urls: {
medium: string;
};
comment: string;
is_followed: boolean;
total_illusts: number;
total_manga: number;
total_novels: number;
total_bookmarked_illust: number;
total_following: number;
total_followers: number;
}
// 登录状态
export interface LoginStatus {
isLoggedIn: boolean;
username?: string;
user_id?: number;
}
// 下载任务
export interface DownloadTask {
id: string;
type: 'artwork' | 'batch' | 'artist';
status: 'downloading' | 'completed' | 'failed' | 'partial' | 'cancelled';
progress: number;
total: number;
completed: number;
failed: number;
start_time: string;
end_time?: string;
files?: Array<{
path: string;
url: string;
size: string;
}>;
results?: any[];
}
// 搜索参数
export interface SearchParams {
keyword: string;
type?: 'all' | 'art' | 'manga' | 'novel';
sort?: 'date_desc' | 'date_asc' | 'popular_desc';
duration?: 'all' | 'within_last_day' | 'within_last_week' | 'within_last_month';
offset?: number;
limit?: number;
}
// 下载参数
export interface DownloadParams {
size?: 'original' | 'large' | 'medium' | 'square_medium';
quality?: 'high' | 'medium' | 'low';
format?: 'auto' | 'jpg' | 'png';
concurrent?: number;
}
+15
View File
@@ -0,0 +1,15 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
+490
View File
@@ -0,0 +1,490 @@
<template>
<div class="artist-page">
<div class="container">
<div v-if="loading" class="loading-section">
<LoadingSpinner text="加载中..." />
</div>
<div v-else-if="error" class="error-section">
<ErrorMessage :error="error" @dismiss="clearError" />
</div>
<div v-else-if="artist" class="artist-content">
<!-- 作者信息卡片 -->
<div class="artist-header">
<div class="artist-profile">
<img
:src="artist.profile_image_urls.medium"
:alt="artist.name"
class="artist-avatar"
/>
<div class="artist-info">
<h1 class="artist-name">{{ artist.name }}</h1>
<p class="artist-account">@{{ artist.account }}</p>
<p v-if="artist.comment" class="artist-comment">{{ artist.comment }}</p>
</div>
</div>
<div class="artist-actions">
<button @click="handleFollow" class="btn btn-primary">
{{ artist.is_followed ? '取消关注' : '关注' }}
</button>
<button @click="handleDownloadAll" class="btn btn-secondary" :disabled="downloading">
{{ downloading ? '下载中...' : '下载所有作品' }}
</button>
</div>
</div>
<!-- 作者统计 -->
<div class="artist-stats">
<div class="stat-card">
<div class="stat-number">{{ artist.total_illusts }}</div>
<div class="stat-label">插画</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ artist.total_manga }}</div>
<div class="stat-label">漫画</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ artist.total_novels }}</div>
<div class="stat-label">小说</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ artist.total_followers }}</div>
<div class="stat-label">粉丝</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ artist.total_following }}</div>
<div class="stat-label">关注</div>
</div>
</div>
<!-- 作品列表 -->
<div class="artworks-section">
<div class="section-header">
<h2>作品列表</h2>
<div class="artwork-filters">
<select v-model="artworkType" @change="fetchArtworks" class="filter-select">
<option value="art">插画</option>
<option value="manga">漫画</option>
<option value="novel">小说</option>
</select>
</div>
</div>
<div v-if="artworksLoading" class="loading-section">
<LoadingSpinner text="加载作品中..." />
</div>
<div v-else-if="artworks.length > 0" class="artworks-grid">
<ArtworkCard
v-for="artwork in artworks"
:key="artwork.id"
:artwork="artwork"
@click="handleArtworkClick"
/>
</div>
<div v-else class="empty-section">
<p>暂无作品</p>
</div>
<div v-if="hasMore" class="load-more">
<button @click="loadMore" class="btn btn-secondary" :disabled="loadingMore">
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import artistService from '@/services/artist';
import downloadService from '@/services/download';
import type { Artist, Artwork } from '@/types';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import ErrorMessage from '@/components/common/ErrorMessage.vue';
import ArtworkCard from '@/components/artwork/ArtworkCard.vue';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
// 状态
const artist = ref<Artist | null>(null);
const artworks = ref<Artwork[]>([]);
const loading = ref(false);
const artworksLoading = ref(false);
const loadingMore = ref(false);
const error = ref<string | null>(null);
const downloading = ref(false);
// 筛选状态
const artworkType = ref<'art' | 'manga' | 'novel'>('art');
const offset = ref(0);
const hasMore = ref(true);
// 获取作者信息
const fetchArtistInfo = async () => {
const artistId = parseInt(route.params.id as string);
if (isNaN(artistId)) {
error.value = '无效的作者ID';
return;
}
try {
loading.value = true;
error.value = null;
const response = await artistService.getArtistInfo(artistId);
if (response.success && response.data) {
artist.value = response.data;
} else {
throw new Error(response.error || '获取作者信息失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '获取作者信息失败';
console.error('获取作者信息失败:', err);
} finally {
loading.value = false;
}
};
// 获取作者作品
const fetchArtworks = async (reset = true) => {
if (!artist.value) return;
try {
artworksLoading.value = true;
if (reset) {
offset.value = 0;
artworks.value = [];
}
const response = await artistService.getArtistArtworks(artist.value.id, {
type: artworkType.value,
offset: offset.value,
limit: 30
});
if (response.success && response.data) {
if (reset) {
artworks.value = response.data.artworks;
} else {
artworks.value.push(...response.data.artworks);
}
hasMore.value = response.data.artworks.length === 30;
offset.value += response.data.artworks.length;
} else {
throw new Error(response.error || '获取作品列表失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '获取作品列表失败';
console.error('获取作品列表失败:', err);
} finally {
artworksLoading.value = false;
}
};
// 加载更多
const loadMore = async () => {
if (loadingMore.value || !hasMore.value) return;
loadingMore.value = true;
await fetchArtworks(false);
loadingMore.value = false;
};
// 关注/取消关注
const handleFollow = async () => {
if (!artist.value) return;
try {
const action = artist.value.is_followed ? 'unfollow' : 'follow';
const response = await artistService.followArtist(artist.value.id, action);
if (response.success) {
artist.value.is_followed = !artist.value.is_followed;
} else {
throw new Error(response.error || '操作失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '操作失败';
console.error('关注操作失败:', err);
}
};
// 下载所有作品
const handleDownloadAll = async () => {
if (!artist.value) return;
try {
downloading.value = true;
const response = await downloadService.downloadArtistArtworks(artist.value.id, {
type: artworkType.value,
limit: 50
});
if (response.success) {
console.log('下载任务已创建:', response.data);
} else {
throw new Error(response.error || '下载失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '下载失败';
console.error('下载失败:', err);
} finally {
downloading.value = false;
}
};
// 点击作品
const handleArtworkClick = (artwork: Artwork) => {
router.push(`/artwork/${artwork.id}`);
};
// 清除错误
const clearError = () => {
error.value = null;
};
onMounted(async () => {
await fetchArtistInfo();
await fetchArtworks();
});
</script>
<style scoped>
.artist-page {
min-height: 100vh;
background: #f8fafc;
padding: 2rem 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.loading-section,
.error-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.artist-header {
background: white;
border-radius: 1rem;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
}
.artist-profile {
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.artist-avatar {
width: 5rem;
height: 5rem;
border-radius: 50%;
object-fit: cover;
}
.artist-info {
flex: 1;
}
.artist-name {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 0.5rem 0;
}
.artist-account {
color: #6b7280;
font-size: 1.125rem;
margin: 0 0 1rem 0;
}
.artist-comment {
color: #374151;
line-height: 1.6;
margin: 0;
}
.artist-actions {
display: flex;
flex-direction: column;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 1rem;
min-width: 120px;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.artist-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: #3b82f6;
margin-bottom: 0.5rem;
}
.stat-label {
color: #6b7280;
font-size: 0.875rem;
font-weight: 500;
}
.artworks-section {
background: white;
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.section-header h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.artwork-filters {
display: flex;
gap: 1rem;
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
font-size: 0.875rem;
color: #374151;
}
.artworks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.empty-section {
text-align: center;
padding: 4rem 0;
color: #6b7280;
}
.load-more {
text-align: center;
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
}
.artist-header {
flex-direction: column;
align-items: stretch;
}
.artist-profile {
flex-direction: column;
text-align: center;
}
.artist-actions {
flex-direction: row;
}
.btn {
flex: 1;
}
.section-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.artworks-grid {
grid-template-columns: 1fr;
}
}
</style>
+588
View File
@@ -0,0 +1,588 @@
<template>
<div class="artists-page">
<div class="container">
<div class="page-header">
<h1 class="page-title">作者管理</h1>
<div class="header-actions">
<div class="search-box">
<input
v-model="searchKeyword"
type="text"
placeholder="搜索作者..."
class="search-input"
@keyup.enter="handleSearch"
/>
<button @click="handleSearch" class="search-btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
</button>
</div>
</div>
</div>
<div v-if="error" class="error-section">
<ErrorMessage :error="error" @dismiss="clearError" />
</div>
<div v-if="loading" class="loading-section">
<LoadingSpinner text="加载中..." />
</div>
<div v-else class="artists-content">
<!-- 关注列表 -->
<div class="section">
<h2 class="section-title">关注的作者</h2>
<div v-if="followingArtists.length > 0" class="artists-grid">
<div
v-for="artist in followingArtists"
:key="artist.id"
class="artist-card"
>
<div class="artist-header">
<img
:src="artist.profile_image_urls.medium"
:alt="artist.name"
class="artist-avatar"
/>
<div class="artist-info">
<h3 class="artist-name">{{ artist.name }}</h3>
<p class="artist-account">@{{ artist.account }}</p>
</div>
<div class="artist-actions">
<button @click="handleUnfollow(artist.id)" class="btn btn-danger btn-small">
取消关注
</button>
</div>
</div>
<div class="artist-stats">
<div class="stat">
<span class="stat-number">{{ artist.total_illusts }}</span>
<span class="stat-label">插画</span>
</div>
<div class="stat">
<span class="stat-number">{{ artist.total_manga }}</span>
<span class="stat-label">漫画</span>
</div>
<div class="stat">
<span class="stat-number">{{ artist.total_followers }}</span>
<span class="stat-label">粉丝</span>
</div>
</div>
<div class="artist-actions-bottom">
<router-link :to="`/artist/${artist.id}`" class="btn btn-primary btn-small">
查看作品
</router-link>
<button @click="handleDownloadArtist(artist.id)" class="btn btn-secondary btn-small">
下载作品
</button>
</div>
</div>
</div>
<div v-else class="empty-section">
<div class="empty-content">
<svg viewBox="0 0 24 24" fill="currentColor" class="empty-icon">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
<h3>暂无关注的作者</h3>
<p>关注喜欢的作者在这里管理他们</p>
</div>
</div>
</div>
<!-- 搜索建议 -->
<div v-if="searchResults.length > 0" class="section">
<h2 class="section-title">搜索结果</h2>
<div class="artists-grid">
<div
v-for="artist in searchResults"
:key="artist.id"
class="artist-card"
>
<div class="artist-header">
<img
:src="artist.profile_image_urls.medium"
:alt="artist.name"
class="artist-avatar"
/>
<div class="artist-info">
<h3 class="artist-name">{{ artist.name }}</h3>
<p class="artist-account">@{{ artist.account }}</p>
</div>
<div class="artist-actions">
<button
@click="handleFollow(artist.id)"
class="btn btn-primary btn-small"
:disabled="artist.is_followed"
>
{{ artist.is_followed ? '已关注' : '关注' }}
</button>
</div>
</div>
<div class="artist-stats">
<div class="stat">
<span class="stat-number">{{ artist.total_illusts }}</span>
<span class="stat-label">插画</span>
</div>
<div class="stat">
<span class="stat-number">{{ artist.total_manga }}</span>
<span class="stat-label">漫画</span>
</div>
<div class="stat">
<span class="stat-number">{{ artist.total_followers }}</span>
<span class="stat-label">粉丝</span>
</div>
</div>
<div class="artist-actions-bottom">
<router-link :to="`/artist/${artist.id}`" class="btn btn-primary btn-small">
查看作品
</router-link>
<button @click="handleDownloadArtist(artist.id)" class="btn btn-secondary btn-small">
下载作品
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import artistService from '@/services/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 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;
// 这里需要根据实际API调整
// const response = await artistService.getFollowingArtists();
// if (response.success && response.data) {
// followingArtists.value = response.data.artists;
// }
// 暂时使用模拟数据
followingArtists.value = [];
} 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 = [];
return;
}
try {
// 这里需要根据实际API调整
// const response = await artistService.searchArtists({ keyword: searchKeyword.value });
// if (response.success && response.data) {
// searchResults.value = response.data.artists;
// }
// 暂时使用模拟数据
searchResults.value = [];
} catch (err) {
error.value = err instanceof Error ? err.message : '搜索失败';
console.error('搜索失败:', err);
}
};
// 关注作者
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 || '关注失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '关注失败';
console.error('关注失败:', err);
}
};
// 取消关注
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 || '取消关注失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '取消关注失败';
console.error('取消关注失败:', err);
}
};
// 下载作者作品
const handleDownloadArtist = async (artistId: number) => {
try {
const response = await downloadService.downloadArtistArtworks(artistId, {
limit: 50
});
if (response.success) {
console.log('下载任务已创建:', response.data);
router.push('/downloads');
} else {
throw new Error(response.error || '下载失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '下载失败';
console.error('下载失败:', err);
}
};
// 清除错误
const clearError = () => {
error.value = null;
};
onMounted(() => {
fetchFollowingArtists();
});
</script>
<style scoped>
.artists-page {
min-height: 100vh;
background: #f8fafc;
padding: 2rem 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.header-actions {
display: flex;
gap: 1rem;
}
.search-box {
display: flex;
align-items: center;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
overflow: hidden;
}
.search-input {
padding: 0.75rem 1rem;
border: none;
outline: none;
font-size: 1rem;
min-width: 300px;
}
.search-btn {
padding: 0.75rem;
background: #3b82f6;
color: white;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.search-btn:hover {
background: #2563eb;
}
.search-btn svg {
width: 1.25rem;
height: 1.25rem;
}
.error-section,
.loading-section {
margin-bottom: 2rem;
}
.loading-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.section {
margin-bottom: 3rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.artists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.artist-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.artist-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.artist-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.artist-avatar {
width: 3rem;
height: 3rem;
border-radius: 50%;
object-fit: cover;
}
.artist-info {
flex: 1;
}
.artist-name {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.25rem 0;
}
.artist-account {
color: #6b7280;
font-size: 0.875rem;
margin: 0;
}
.artist-actions {
flex-shrink: 0;
}
.artist-stats {
display: flex;
justify-content: space-around;
margin-bottom: 1rem;
padding: 1rem 0;
border-top: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.stat-number {
font-size: 1.25rem;
font-weight: 700;
color: #3b82f6;
}
.stat-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
}
.artist-actions-bottom {
display: flex;
gap: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 0.875rem;
flex: 1;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-small {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.empty-section {
text-align: center;
padding: 4rem 0;
}
.empty-content {
max-width: 400px;
margin: 0 auto;
}
.empty-icon {
width: 4rem;
height: 4rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.empty-content h3 {
font-size: 1.5rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.empty-content p {
color: #6b7280;
line-height: 1.6;
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
}
.page-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.search-input {
min-width: auto;
flex: 1;
}
.artists-grid {
grid-template-columns: 1fr;
}
.artist-header {
flex-direction: column;
text-align: center;
}
.artist-actions-bottom {
flex-direction: column;
}
}
</style>
+554
View File
@@ -0,0 +1,554 @@
<template>
<div class="artwork-page">
<div class="container">
<div v-if="loading" class="loading-section">
<LoadingSpinner text="加载中..." />
</div>
<div v-else-if="error" class="error-section">
<ErrorMessage :error="error" @dismiss="clearError" />
</div>
<div v-else-if="artwork" class="artwork-content">
<!-- 作品图片 -->
<div class="artwork-gallery">
<div class="main-image">
<img
:src="currentImageUrl"
:alt="artwork.title"
@load="imageLoaded = true"
@error="imageError = true"
:class="{ loaded: imageLoaded, error: imageError }"
/>
<div v-if="!imageLoaded && !imageError" class="image-placeholder">
<LoadingSpinner text="加载中..." />
</div>
<div v-if="imageError" class="image-error">
<span>图片加载失败</span>
</div>
</div>
<!-- 多页作品缩略图 -->
<div v-if="artwork.page_count > 1" class="thumbnails">
<button
v-for="(page, index) in artwork.meta_pages"
:key="index"
@click="currentPage = index"
class="thumbnail"
:class="{ active: currentPage === index }"
>
<img :src="page.image_urls.square_medium" :alt="`第 ${index + 1} 页`" />
</button>
</div>
</div>
<!-- 作品信息 -->
<div class="artwork-info">
<div class="artwork-header">
<h1 class="artwork-title">{{ artwork.title }}</h1>
<div class="artwork-actions">
<button @click="handleDownload" class="btn btn-primary" :disabled="downloading">
{{ downloading ? '下载中...' : '下载' }}
</button>
<button @click="handleBookmark" class="btn btn-secondary">
{{ artwork.is_bookmarked ? '取消收藏' : '收藏' }}
</button>
</div>
</div>
<!-- 作者信息 -->
<div class="artist-info">
<img
:src="artwork.user.profile_image_urls.medium"
:alt="artwork.user.name"
class="artist-avatar"
/>
<div class="artist-details">
<h3 class="artist-name">{{ artwork.user.name }}</h3>
<p class="artist-account">@{{ artwork.user.account }}</p>
</div>
<router-link :to="`/artist/${artwork.user.id}`" class="btn btn-text">
查看作者
</router-link>
</div>
<!-- 作品统计 -->
<div class="artwork-stats">
<div class="stat">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<span>{{ artwork.total_bookmarks }}</span>
</div>
<div class="stat">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
<span>{{ artwork.total_view }}</span>
</div>
<div class="stat">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/>
</svg>
<span>{{ artwork.width }} × {{ artwork.height }}</span>
</div>
</div>
<!-- 标签 -->
<div class="artwork-tags">
<h3>标签</h3>
<div class="tags-list">
<span
v-for="tag in artwork.tags"
:key="tag.name"
class="tag"
>
{{ tag.name }}
</span>
</div>
</div>
<!-- 描述 -->
<div v-if="artwork.description" class="artwork-description">
<h3>描述</h3>
<div class="description-content" v-html="artwork.description"></div>
</div>
<!-- 创建时间 -->
<div class="artwork-meta">
<p>创建时间: {{ formatDate(artwork.create_date) }}</p>
<p v-if="artwork.update_date !== artwork.create_date">
更新时间: {{ formatDate(artwork.update_date) }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import artworkService from '@/services/artwork';
import downloadService from '@/services/download';
import type { Artwork } from '@/types';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import ErrorMessage from '@/components/common/ErrorMessage.vue';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
// 状态
const artwork = ref<Artwork | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const currentPage = ref(0);
const imageLoaded = ref(false);
const imageError = ref(false);
const downloading = ref(false);
// 计算属性
const currentImageUrl = computed(() => {
if (!artwork.value) return '';
if (artwork.value.page_count === 1) {
return artwork.value.image_urls.large;
} else if (artwork.value.meta_pages && artwork.value.meta_pages[currentPage.value]) {
return artwork.value.meta_pages[currentPage.value].image_urls.large;
}
return artwork.value.image_urls.large;
});
// 获取作品详情
const fetchArtworkDetail = async () => {
const artworkId = parseInt(route.params.id as string);
if (isNaN(artworkId)) {
error.value = '无效的作品ID';
return;
}
try {
loading.value = true;
error.value = null;
const response = await artworkService.getArtworkDetail(artworkId);
if (response.success && response.data) {
artwork.value = response.data;
} else {
throw new Error(response.error || '获取作品详情失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '获取作品详情失败';
console.error('获取作品详情失败:', err);
} finally {
loading.value = false;
}
};
// 下载作品
const handleDownload = async () => {
if (!artwork.value) return;
try {
downloading.value = true;
const response = await downloadService.downloadArtwork(artwork.value.id);
if (response.success) {
// 可以显示下载成功提示
console.log('下载任务已创建:', response.data);
} else {
throw new Error(response.error || '下载失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '下载失败';
console.error('下载失败:', err);
} finally {
downloading.value = false;
}
};
// 收藏/取消收藏
const handleBookmark = () => {
// 这里可以添加收藏功能
console.log('收藏功能待实现');
};
// 格式化日期
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN');
};
// 清除错误
const clearError = () => {
error.value = null;
};
onMounted(() => {
fetchArtworkDetail();
});
</script>
<style scoped>
.artwork-page {
min-height: 100vh;
background: #f8fafc;
padding: 2rem 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.loading-section,
.error-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.artwork-content {
display: grid;
grid-template-columns: 1fr 400px;
gap: 3rem;
align-items: start;
}
.artwork-gallery {
background: white;
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.main-image {
position: relative;
aspect-ratio: 1;
background: #f3f4f6;
overflow: hidden;
}
.main-image img {
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
transition: opacity 0.3s;
}
.main-image img.loaded {
opacity: 1;
}
.main-image img.error {
opacity: 0.5;
}
.image-placeholder,
.image-error {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
}
.image-error {
color: #6b7280;
font-size: 0.875rem;
}
.thumbnails {
display: flex;
gap: 0.5rem;
padding: 1rem;
overflow-x: auto;
}
.thumbnail {
flex-shrink: 0;
width: 60px;
height: 60px;
border: 2px solid transparent;
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s;
background: none;
padding: 0;
}
.thumbnail.active {
border-color: #3b82f6;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artwork-info {
background: white;
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.artwork-header {
margin-bottom: 2rem;
}
.artwork-title {
font-size: 1.75rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 1rem 0;
line-height: 1.3;
}
.artwork-actions {
display: flex;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover {
background: #e5e7eb;
}
.btn-text {
background: none;
color: #3b82f6;
padding: 0.5rem 1rem;
}
.btn-text:hover {
background: #f3f4f6;
}
.artist-info {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: #f8fafc;
border-radius: 0.75rem;
margin-bottom: 2rem;
}
.artist-avatar {
width: 3rem;
height: 3rem;
border-radius: 50%;
object-fit: cover;
}
.artist-details {
flex: 1;
}
.artist-name {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.25rem 0;
}
.artist-account {
color: #6b7280;
margin: 0;
font-size: 0.875rem;
}
.artwork-stats {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
}
.stat svg {
width: 1rem;
height: 1rem;
}
.artwork-tags {
margin-bottom: 2rem;
}
.artwork-tags h3 {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1rem 0;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
background: #f3f4f6;
color: #374151;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
line-height: 1;
}
.artwork-description {
margin-bottom: 2rem;
}
.artwork-description h3 {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1rem 0;
}
.description-content {
color: #374151;
line-height: 1.6;
}
.artwork-meta {
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.artwork-meta p {
color: #6b7280;
font-size: 0.875rem;
margin: 0.25rem 0;
}
@media (max-width: 1024px) {
.artwork-content {
grid-template-columns: 1fr;
gap: 2rem;
}
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
}
.artwork-actions {
flex-direction: column;
}
.btn {
width: 100%;
}
.artwork-stats {
flex-direction: column;
gap: 1rem;
}
.thumbnails {
padding: 0.5rem;
}
.thumbnail {
width: 50px;
height: 50px;
}
}
</style>
+607
View File
@@ -0,0 +1,607 @@
<template>
<div class="downloads-page">
<div class="container">
<div class="page-header">
<h1 class="page-title">下载管理</h1>
<div class="header-actions">
<button @click="refreshHistory" class="btn btn-secondary" :disabled="loading">
{{ loading ? '刷新中...' : '刷新' }}
</button>
</div>
</div>
<div v-if="error" class="error-section">
<ErrorMessage :error="error" @dismiss="clearError" />
</div>
<div v-if="loading" class="loading-section">
<LoadingSpinner text="加载中..." />
</div>
<div v-else class="downloads-content">
<!-- 活跃任务 -->
<div v-if="activeTasks.length > 0" class="section">
<h2 class="section-title">活跃任务</h2>
<div class="tasks-grid">
<div
v-for="task in activeTasks"
:key="task.id"
class="task-card active"
>
<div class="task-header">
<div class="task-info">
<h3 class="task-title">{{ getTaskTitle(task) }}</h3>
<p class="task-type">{{ getTaskTypeLabel(task.type) }}</p>
</div>
<div class="task-status">
<span class="status-badge downloading">下载中</span>
</div>
</div>
<div class="task-progress">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${task.progress}%` }"
></div>
</div>
<div class="progress-text">
{{ task.completed }}/{{ task.total }} ({{ task.progress }}%)
</div>
</div>
<div class="task-actions">
<button @click="cancelTask(task.id)" class="btn btn-danger">
取消
</button>
</div>
</div>
</div>
</div>
<!-- 历史记录 -->
<div class="section">
<h2 class="section-title">下载历史</h2>
<div v-if="completedTasks.length > 0" class="tasks-grid">
<div
v-for="task in completedTasks"
:key="task.id"
class="task-card completed"
>
<div class="task-header">
<div class="task-info">
<h3 class="task-title">{{ getTaskTitle(task) }}</h3>
<p class="task-type">{{ getTaskTypeLabel(task.type) }}</p>
</div>
<div class="task-status">
<span class="status-badge" :class="task.status">
{{ getStatusLabel(task.status) }}
</span>
</div>
</div>
<div class="task-details">
<div class="detail-item">
<span class="detail-label">完成时间:</span>
<span class="detail-value">{{ formatDate(task.end_time) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">文件数量:</span>
<span class="detail-value">{{ task.completed }}/{{ task.total }}</span>
</div>
<div v-if="task.failed > 0" class="detail-item">
<span class="detail-label">失败数量:</span>
<span class="detail-value error">{{ task.failed }}</span>
</div>
</div>
<div v-if="task.files && task.files.length > 0" class="task-files">
<h4>下载的文件:</h4>
<div class="files-list">
<div
v-for="file in task.files.slice(0, 3)"
:key="file.path"
class="file-item"
>
<span class="file-name">{{ getFileName(file.path) }}</span>
<span class="file-size">{{ file.size }}</span>
</div>
<div v-if="task.files.length > 3" class="file-more">
还有 {{ task.files.length - 3 }} 个文件...
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-section">
<div class="empty-content">
<svg viewBox="0 0 24 24" fill="currentColor" class="empty-icon">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
<h3>暂无下载记录</h3>
<p>开始下载作品后这里会显示下载历史</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useAuthStore } from '@/stores/auth';
import downloadService from '@/services/download';
import type { DownloadTask } from '@/types';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import ErrorMessage from '@/components/common/ErrorMessage.vue';
const authStore = useAuthStore();
// 状态
const tasks = ref<DownloadTask[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
// 计算属性
const activeTasks = computed(() =>
tasks.value.filter(task => task.status === 'downloading')
);
const completedTasks = computed(() =>
tasks.value.filter(task =>
task.status === 'completed' ||
task.status === 'partial' ||
task.status === 'failed' ||
task.status === 'cancelled'
).sort((a, b) => new Date(b.end_time || '').getTime() - new Date(a.end_time || '').getTime())
);
// 获取下载历史
const fetchDownloadHistory = async () => {
try {
loading.value = true;
error.value = null;
const response = await downloadService.getDownloadHistory();
if (response.success && response.data) {
tasks.value = response.data.tasks;
} else {
throw new Error(response.error || '获取下载历史失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '获取下载历史失败';
console.error('获取下载历史失败:', err);
} finally {
loading.value = false;
}
};
// 取消任务
const cancelTask = async (taskId: string) => {
try {
const response = await downloadService.cancelDownload(taskId);
if (response.success) {
// 更新任务状态
const task = tasks.value.find(t => t.id === taskId);
if (task) {
task.status = 'cancelled';
task.end_time = new Date().toISOString();
}
} else {
throw new Error(response.error || '取消任务失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '取消任务失败';
console.error('取消任务失败:', err);
}
};
// 刷新历史
const refreshHistory = async () => {
await fetchDownloadHistory();
};
// 获取任务标题
const getTaskTitle = (task: DownloadTask) => {
switch (task.type) {
case 'artwork':
return `作品 #${task.artwork_id}`;
case 'artist':
return `作者作品`;
case 'batch':
return `批量下载 (${task.total} 个作品)`;
default:
return '下载任务';
}
};
// 获取任务类型标签
const getTaskTypeLabel = (type: string) => {
switch (type) {
case 'artwork':
return '单个作品';
case 'artist':
return '作者作品';
case 'batch':
return '批量下载';
default:
return type;
}
};
// 获取状态标签
const getStatusLabel = (status: string) => {
switch (status) {
case 'completed':
return '已完成';
case 'partial':
return '部分完成';
case 'failed':
return '失败';
case 'cancelled':
return '已取消';
default:
return status;
}
};
// 格式化日期
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN');
};
// 获取文件名
const getFileName = (filePath: string) => {
return filePath.split('/').pop() || filePath;
};
// 清除错误
const clearError = () => {
error.value = null;
};
onMounted(() => {
fetchDownloadHistory();
});
</script>
<style scoped>
.downloads-page {
min-height: 100vh;
background: #f8fafc;
padding: 2rem 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.header-actions {
display: flex;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.error-section,
.loading-section {
margin-bottom: 2rem;
}
.loading-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.section {
margin-bottom: 3rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.tasks-grid {
display: grid;
gap: 1.5rem;
}
.task-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border-left: 4px solid #e5e7eb;
}
.task-card.active {
border-left-color: #3b82f6;
}
.task-card.completed {
border-left-color: #10b981;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.task-info {
flex: 1;
}
.task-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.25rem 0;
}
.task-type {
color: #6b7280;
font-size: 0.875rem;
margin: 0;
}
.task-status {
flex-shrink: 0;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-badge.downloading {
background: #dbeafe;
color: #1d4ed8;
}
.status-badge.completed {
background: #d1fae5;
color: #065f46;
}
.status-badge.partial {
background: #fef3c7;
color: #92400e;
}
.status-badge.failed {
background: #fee2e2;
color: #dc2626;
}
.status-badge.cancelled {
background: #f3f4f6;
color: #6b7280;
}
.task-progress {
margin-bottom: 1rem;
}
.progress-bar {
width: 100%;
height: 0.5rem;
background: #e5e7eb;
border-radius: 0.25rem;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.875rem;
color: #6b7280;
text-align: center;
}
.task-actions {
display: flex;
justify-content: flex-end;
}
.task-details {
margin-bottom: 1rem;
}
.detail-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.detail-label {
color: #6b7280;
font-size: 0.875rem;
}
.detail-value {
color: #374151;
font-size: 0.875rem;
font-weight: 500;
}
.detail-value.error {
color: #dc2626;
}
.task-files {
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
}
.task-files h4 {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.75rem 0;
}
.files-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9fafb;
border-radius: 0.375rem;
}
.file-name {
font-size: 0.875rem;
color: #374151;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 0.75rem;
color: #6b7280;
flex-shrink: 0;
margin-left: 1rem;
}
.file-more {
font-size: 0.875rem;
color: #6b7280;
text-align: center;
font-style: italic;
}
.empty-section {
text-align: center;
padding: 4rem 0;
}
.empty-content {
max-width: 400px;
margin: 0 auto;
}
.empty-icon {
width: 4rem;
height: 4rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.empty-content h3 {
font-size: 1.5rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.empty-content p {
color: #6b7280;
line-height: 1.6;
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
}
.page-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.task-header {
flex-direction: column;
gap: 0.5rem;
}
.task-actions {
justify-content: stretch;
}
.btn {
width: 100%;
}
}
</style>
+365
View File
@@ -0,0 +1,365 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
const isLoggedIn = computed(() => authStore.isLoggedIn);
onMounted(async () => {
await authStore.fetchLoginStatus();
});
</script>
<template>
<div class="home">
<div class="hero-section">
<div class="hero-content">
<h1 class="hero-title">Pixiv 作品管理器</h1>
<p class="hero-subtitle">
发现收藏下载你喜欢的 Pixiv 作品
</p>
<div class="hero-actions">
<router-link
v-if="!isLoggedIn"
to="/login"
class="btn btn-primary"
>
立即登录
</router-link>
<router-link
v-else
to="/search"
class="btn btn-primary"
>
开始搜索
</router-link>
<router-link
to="/downloads"
class="btn btn-secondary"
>
下载管理
</router-link>
</div>
</div>
</div>
<div class="features-section">
<div class="container">
<h2 class="section-title">主要功能</h2>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
</div>
<h3 class="feature-title">作品搜索</h3>
<p class="feature-description">
通过关键词标签作者等多种方式搜索作品
</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<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>
</div>
<h3 class="feature-title">一键下载</h3>
<p class="feature-description">
支持单个作品批量作品作者作品下载
</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</div>
<h3 class="feature-title">作者管理</h3>
<p class="feature-description">
关注喜欢的作者查看作品列表和统计信息
</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/>
<path d="M7 12h2v5H7zm4-3h2v8h-2zm4-3h2v11h-2z"/>
</svg>
</div>
<h3 class="feature-title">下载管理</h3>
<p class="feature-description">
实时查看下载进度管理下载历史和任务
</p>
</div>
</div>
</div>
</div>
<div v-if="isLoggedIn" class="quick-actions">
<div class="container">
<h2 class="section-title">快速操作</h2>
<div class="quick-actions-grid">
<router-link to="/search" class="quick-action-card">
<div class="quick-action-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
</div>
<span>搜索作品</span>
</router-link>
<router-link to="/downloads" class="quick-action-card">
<div class="quick-action-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
</div>
<span>下载管理</span>
</router-link>
<router-link to="/artists" class="quick-action-card">
<div class="quick-action-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</div>
<span>作者管理</span>
</router-link>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.home {
min-height: 100vh;
}
.hero-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 4rem 0;
text-align: center;
}
.hero-content {
max-width: 800px;
margin: 0 auto;
padding: 0 2rem;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 1rem;
line-height: 1.2;
}
.hero-subtitle {
font-size: 1.25rem;
margin-bottom: 2rem;
opacity: 0.9;
line-height: 1.6;
}
.hero-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
transform: translateY(-1px);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.features-section {
padding: 4rem 0;
background: #f8fafc;
}
.section-title {
text-align: center;
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 3rem;
color: #1f2937;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
}
.feature-card {
background: white;
padding: 2rem;
border-radius: 1rem;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.feature-card:hover {
transform: translateY(-4px);
}
.feature-icon {
width: 4rem;
height: 4rem;
margin: 0 auto 1.5rem;
background: #3b82f6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.feature-icon svg {
width: 2rem;
height: 2rem;
}
.feature-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
color: #1f2937;
}
.feature-description {
color: #6b7280;
line-height: 1.6;
}
.quick-actions {
padding: 4rem 0;
}
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
max-width: 600px;
margin: 0 auto;
}
.quick-action-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
background: white;
border-radius: 1rem;
text-decoration: none;
color: #374151;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.quick-action-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
color: #3b82f6;
}
.quick-action-icon {
width: 3rem;
height: 3rem;
background: #f3f4f6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
transition: all 0.2s;
}
.quick-action-card:hover .quick-action-icon {
background: #3b82f6;
color: white;
}
.quick-action-icon svg {
width: 1.5rem;
height: 1.5rem;
}
@media (max-width: 768px) {
.hero-title {
font-size: 2rem;
}
.hero-subtitle {
font-size: 1rem;
}
.hero-actions {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 300px;
}
.features-grid {
grid-template-columns: 1fr;
}
.quick-actions-grid {
grid-template-columns: 1fr;
}
}
</style>
+337
View File
@@ -0,0 +1,337 @@
<template>
<div class="login-page">
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1 class="login-title">登录 Pixiv</h1>
<p class="login-subtitle">
通过 Pixiv 账号登录以使用所有功能
</p>
</div>
<div v-if="error" class="error-section">
<ErrorMessage
:error="error"
title="登录失败"
dismissible
@dismiss="clearError"
/>
</div>
<div class="login-status" v-if="isLoggedIn">
<div class="status-success">
<svg viewBox="0 0 24 24" fill="currentColor">
<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>
<div class="status-content">
<h3>已登录</h3>
<p>欢迎回来{{ username }}</p>
</div>
</div>
<div class="login-actions">
<router-link to="/" class="btn btn-primary">
返回首页
</router-link>
<button @click="handleLogout" class="btn btn-secondary" :disabled="loading">
{{ loading ? '登出中...' : '登出' }}
</button>
</div>
</div>
<div v-else class="login-form">
<div class="login-info">
<p>点击下方按钮将跳转到 Pixiv 官方登录页面</p>
<ul class="login-features">
<li>安全可靠的官方登录</li>
<li>支持所有 Pixiv 功能</li>
<li>自动保存登录状态</li>
</ul>
</div>
<div class="login-actions">
<button
@click="handleLogin"
class="btn btn-primary btn-large"
:disabled="loading"
>
<LoadingSpinner v-if="loading" text="获取登录链接..." />
<span v-else>开始登录</span>
</button>
<router-link to="/" class="btn btn-text">
返回首页
</router-link>
</div>
</div>
<div class="login-footer">
<p class="footer-text">
登录即表示您同意我们的
<a href="#" class="footer-link">服务条款</a>
<a href="#" class="footer-link">隐私政策</a>
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import ErrorMessage from '@/components/common/ErrorMessage.vue';
const router = useRouter();
const authStore = useAuthStore();
const isLoggedIn = computed(() => authStore.isLoggedIn);
const username = computed(() => authStore.username);
const loading = computed(() => authStore.loading);
const error = computed(() => authStore.error);
onMounted(async () => {
await authStore.fetchLoginStatus();
});
const handleLogin = async () => {
try {
const loginUrl = await authStore.getLoginUrl();
window.open(loginUrl, '_blank');
} catch (err) {
console.error('获取登录URL失败:', err);
}
};
const handleLogout = async () => {
try {
await authStore.logout();
router.push('/');
} catch (err) {
console.error('登出失败:', err);
}
};
const clearError = () => {
authStore.clearError();
};
</script>
<style scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.login-container {
width: 100%;
max-width: 480px;
}
.login-card {
background: white;
border-radius: 1rem;
padding: 2.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-title {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.5rem;
}
.login-subtitle {
color: #6b7280;
line-height: 1.6;
}
.error-section {
margin-bottom: 2rem;
}
.login-status {
text-align: center;
}
.status-success {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
padding: 1.5rem;
border-radius: 0.75rem;
margin-bottom: 2rem;
}
.status-success svg {
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
.status-content h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
}
.status-content p {
margin: 0;
opacity: 0.8;
}
.login-form {
margin-bottom: 2rem;
}
.login-info {
margin-bottom: 2rem;
}
.login-info p {
color: #6b7280;
margin-bottom: 1rem;
line-height: 1.6;
}
.login-features {
list-style: none;
padding: 0;
margin: 0;
}
.login-features li {
display: flex;
align-items: center;
gap: 0.5rem;
color: #374151;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.login-features li::before {
content: "✓";
color: #10b981;
font-weight: bold;
}
.login-actions {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 1rem;
min-width: 120px;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
transform: translateY(-1px);
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.btn-text {
background: none;
color: #6b7280;
padding: 0.5rem 1rem;
}
.btn-text:hover {
color: #374151;
background: #f3f4f6;
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.125rem;
min-width: 200px;
}
.login-footer {
text-align: center;
padding-top: 2rem;
border-top: 1px solid #e5e7eb;
}
.footer-text {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.footer-link {
color: #3b82f6;
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.login-page {
padding: 1rem;
}
.login-card {
padding: 2rem;
}
.login-title {
font-size: 1.75rem;
}
.btn-large {
min-width: 100%;
}
}
</style>
+408
View File
@@ -0,0 +1,408 @@
<template>
<div class="search-page">
<div class="search-header">
<div class="container">
<h1 class="page-title">搜索作品</h1>
<div class="search-form">
<div class="search-input-group">
<input
v-model="searchKeyword"
type="text"
placeholder="输入关键词搜索作品..."
class="search-input"
@keyup.enter="handleSearch"
/>
<button @click="handleSearch" class="search-btn" :disabled="loading">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
</button>
</div>
<div class="search-filters">
<select v-model="searchType" class="filter-select">
<option value="all">全部类型</option>
<option value="art">插画</option>
<option value="manga">漫画</option>
<option value="novel">小说</option>
</select>
<select v-model="searchSort" class="filter-select">
<option value="date_desc">最新</option>
<option value="date_asc">最旧</option>
<option value="popular_desc">最受欢迎</option>
</select>
<select v-model="searchDuration" class="filter-select">
<option value="all">全部时间</option>
<option value="within_last_day">最近一天</option>
<option value="within_last_week">最近一周</option>
<option value="within_last_month">最近一月</option>
</select>
</div>
</div>
</div>
</div>
<div class="search-content">
<div class="container">
<div v-if="error" class="error-section">
<ErrorMessage :error="error" @dismiss="clearError" />
</div>
<div v-if="loading" class="loading-section">
<LoadingSpinner text="搜索中..." />
</div>
<div v-else-if="searchResults.length > 0" class="results-section">
<div class="results-header">
<h2>搜索结果 ({{ totalResults }})</h2>
<div class="results-actions">
<button @click="loadMore" class="btn btn-secondary" :disabled="loadingMore">
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
<div class="artworks-grid">
<ArtworkCard
v-for="artwork in searchResults"
:key="artwork.id"
:artwork="artwork"
@click="handleArtworkClick"
/>
</div>
</div>
<div v-else-if="hasSearched" class="empty-section">
<div class="empty-content">
<svg viewBox="0 0 24 24" fill="currentColor" class="empty-icon">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
<h3>未找到相关作品</h3>
<p>尝试使用不同的关键词或调整搜索条件</p>
</div>
</div>
<div v-else class="welcome-section">
<div class="welcome-content">
<h2>开始搜索</h2>
<p>输入关键词来搜索你喜欢的作品</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import artworkService from '@/services/artwork';
import type { Artwork, SearchParams } from '@/types';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import ErrorMessage from '@/components/common/ErrorMessage.vue';
import ArtworkCard from '@/components/artwork/ArtworkCard.vue';
const router = useRouter();
const authStore = useAuthStore();
// 搜索状态
const searchKeyword = ref('');
const searchType = ref<'all' | 'art' | 'manga' | 'novel'>('all');
const searchSort = ref<'date_desc' | 'date_asc' | 'popular_desc'>('date_desc');
const searchDuration = ref<'all' | 'within_last_day' | 'within_last_week' | 'within_last_month'>('all');
// 结果状态
const searchResults = ref<Artwork[]>([]);
const totalResults = ref(0);
const loading = ref(false);
const loadingMore = ref(false);
const error = ref<string | null>(null);
const hasSearched = ref(false);
const offset = ref(0);
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
return;
}
try {
loading.value = true;
error.value = null;
offset.value = 0;
hasSearched.value = true;
const params: SearchParams = {
keyword: searchKeyword.value.trim(),
type: searchType.value,
sort: searchSort.value,
duration: searchDuration.value,
offset: 0,
limit: 30
};
const response = await artworkService.searchArtworks(params);
if (response.success && response.data) {
searchResults.value = response.data.artworks;
totalResults.value = response.data.total;
} else {
throw new Error(response.error || '搜索失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '搜索失败';
console.error('搜索失败:', err);
} finally {
loading.value = false;
}
};
const loadMore = async () => {
if (!searchKeyword.value.trim() || loadingMore.value) {
return;
}
try {
loadingMore.value = true;
offset.value += 30;
const params: SearchParams = {
keyword: searchKeyword.value.trim(),
type: searchType.value,
sort: searchSort.value,
duration: searchDuration.value,
offset: offset.value,
limit: 30
};
const response = await artworkService.searchArtworks(params);
if (response.success && response.data) {
searchResults.value.push(...response.data.artworks);
} else {
throw new Error(response.error || '加载更多失败');
}
} catch (err) {
error.value = err instanceof Error ? err.message : '加载更多失败';
console.error('加载更多失败:', err);
} finally {
loadingMore.value = false;
}
};
const handleArtworkClick = (artwork: Artwork) => {
router.push(`/artwork/${artwork.id}`);
};
const clearError = () => {
error.value = null;
};
</script>
<style scoped>
.search-page {
min-height: 100vh;
background: #f8fafc;
}
.search-header {
background: white;
border-bottom: 1px solid #e5e7eb;
padding: 2rem 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 2rem;
}
.search-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-input-group {
display: flex;
gap: 0.5rem;
}
.search-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
}
.search-btn {
padding: 0.75rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
.search-btn:hover:not(:disabled) {
background: #2563eb;
}
.search-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.search-btn svg {
width: 1.25rem;
height: 1.25rem;
}
.search-filters {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
font-size: 0.875rem;
color: #374151;
}
.search-content {
padding: 2rem 0;
}
.error-section,
.loading-section {
margin-bottom: 2rem;
}
.results-section {
margin-bottom: 2rem;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.results-header h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.artworks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
}
.empty-section,
.welcome-section {
text-align: center;
padding: 4rem 0;
}
.empty-content,
.welcome-content {
max-width: 400px;
margin: 0 auto;
}
.empty-icon {
width: 4rem;
height: 4rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.empty-content h3,
.welcome-content h2 {
font-size: 1.5rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.empty-content p,
.welcome-content p {
color: #6b7280;
line-height: 1.6;
}
@media (max-width: 768px) {
.search-filters {
flex-direction: column;
}
.filter-select {
width: 100%;
}
.results-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.artworks-grid {
grid-template-columns: 1fr;
}
}
</style>