页面优化,增加作者搜索功能,若干bug修复

This commit is contained in:
2025-08-24 09:22:44 +08:00
parent 2e368e9771
commit 4b507ab161
15 changed files with 1199 additions and 976 deletions
-94
View File
@@ -1,94 +0,0 @@
<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
@@ -1,87 +0,0 @@
<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>
+241
View File
@@ -0,0 +1,241 @@
<template>
<div class="artist-card">
<div class="artist-header">
<img
:src="getImageUrl(artist.profile_image_urls.medium)"
:alt="artist.name"
class="artist-avatar"
crossorigin="anonymous"
/>
<div class="artist-info">
<h3 class="artist-name">{{ artist.name }}</h3>
<p class="artist-account">@{{ artist.account }}</p>
</div>
<div class="artist-actions">
<button
v-if="showFollowButton"
@click="handleFollowClick"
class="btn btn-primary btn-small"
:disabled="artist.is_followed"
>
{{ artist.is_followed ? '已关注' : '关注' }}
</button>
<button
v-if="showUnfollowButton"
@click="handleUnfollowClick"
class="btn btn-danger btn-small"
>
取消关注
</button>
</div>
</div>
<div class="artist-actions-bottom">
<router-link :to="`/artist/${artist.id}`" class="btn btn-primary btn-small">
查看作品
</router-link>
<button @click="handleDownloadClick" class="btn btn-secondary btn-small">
下载作品
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface Artist {
id: number
name: string
account: string
profile_image_urls: {
medium: string
}
is_followed?: boolean
}
interface Props {
artist: Artist
showFollowButton?: boolean
showUnfollowButton?: boolean
}
interface Emits {
(e: 'follow', artistId: number): void
(e: 'unfollow', artistId: number): void
(e: 'download', artist: Artist): void
}
const props = withDefaults(defineProps<Props>(), {
showFollowButton: false,
showUnfollowButton: true
})
const emit = defineEmits<Emits>()
// 处理关注点击
const handleFollowClick = () => {
emit('follow', props.artist.id)
}
// 处理取消关注点击
const handleUnfollowClick = () => {
emit('unfollow', props.artist.id)
}
// 处理下载点击
const handleDownloadClick = () => {
emit('download', props.artist)
}
// 处理图片URL,通过后端代理
const getImageUrl = (originalUrl: string) => {
if (!originalUrl) return ''
// 如果是Pixiv的图片URL,通过后端代理
if (originalUrl.includes('i.pximg.net')) {
const encodedUrl = encodeURIComponent(originalUrl)
return `http://localhost:3000/api/proxy/image?url=${encodedUrl}`
}
return originalUrl
}
</script>
<style scoped>
.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;
height: 100%;
display: flex;
flex-direction: column;
}
.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: 1.5rem;
flex: 1;
}
.artist-avatar {
width: 4rem;
height: 4rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.artist-info {
flex: 1;
min-width: 0; /* 防止文本溢出 */
}
.artist-name {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.5rem 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-account {
color: #6b7280;
font-size: 0.875rem;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-actions {
flex-shrink: 0;
}
.artist-actions-bottom {
display: flex;
gap: 0.75rem;
margin-top: auto;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
font-size: 0.875rem;
flex: 1;
min-height: 2.5rem;
}
.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.5rem 0.75rem;
font-size: 0.75rem;
min-height: 2rem;
}
@media (max-width: 768px) {
.artist-header {
flex-direction: column;
text-align: center;
gap: 1rem;
}
.artist-actions-bottom {
flex-direction: column;
}
.artist-avatar {
width: 5rem;
height: 5rem;
}
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<template>
<div class="artist-search">
<!-- 搜索表单 -->
<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-options">
<label class="checkbox-label">
<input
v-model="hideFollowedArtists"
type="checkbox"
class="form-checkbox"
/>
<span>隐藏已关注的作者</span>
</label>
</div>
</div>
<!-- 搜索结果 -->
<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="searchResults.length > 0" class="results-section">
<div class="results-header">
<h3>搜索结果 ({{ filteredResults.length }})</h3>
<div v-if="hideFollowedArtists && searchResults.length !== filteredResults.length" class="filter-info">
已隐藏 {{ searchResults.length - filteredResults.length }} 个已关注的作者
</div>
</div>
<div class="artists-grid">
<ArtistCard
v-for="artist in filteredResults"
:key="artist.id"
:artist="artist"
:show-follow-button="true"
:show-unfollow-button="false"
@follow="handleFollow"
@download="handleDownload"
/>
</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="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 v-else class="welcome-section">
<div class="welcome-content">
<h3>搜索作者</h3>
<p>输入作者名称或账号来搜索</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useArtistStore } from '@/stores/artist'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ErrorMessage from '@/components/common/ErrorMessage.vue'
import ArtistCard from '@/components/artist/ArtistCard.vue'
const router = useRouter()
const artistStore = useArtistStore()
// 搜索状态
const searchKeyword = ref('')
const hideFollowedArtists = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
const hasSearched = ref(false)
const searchResults = ref<any[]>([])
// 计算属性:过滤搜索结果
const filteredResults = computed(() => {
if (!hideFollowedArtists.value) {
return searchResults.value
}
// 获取已关注作者的ID列表
const followedIds = new Set(artistStore.followingArtists.map(artist => artist.id))
// 过滤掉已关注的作者
return searchResults.value.filter(artist => !followedIds.has(artist.id))
})
// 执行搜索
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
return
}
try {
loading.value = true
error.value = null
hasSearched.value = true
await artistStore.searchArtists(searchKeyword.value)
searchResults.value = artistStore.searchResults
} catch (err) {
error.value = err instanceof Error ? err.message : '搜索失败'
console.error('搜索失败:', err)
} finally {
loading.value = false
}
}
// 关注作者
const handleFollow = async (artistId: number) => {
try {
await artistStore.followArtist(artistId)
// 更新搜索结果中的关注状态
const artist = searchResults.value.find(a => a.id === artistId)
if (artist) {
artist.is_followed = true
}
} catch (err) {
error.value = err instanceof Error ? err.message : '关注失败'
console.error('关注失败:', err)
}
}
// 下载作品
const handleDownload = (artist: any) => {
// 触发父组件的下载事件
emit('download', artist)
}
// 清除错误
const clearError = () => {
error.value = null
}
// 定义事件
interface Emits {
(e: 'download', artist: any): void
}
const emit = defineEmits<Emits>()
// 暴露方法给父组件
defineExpose({
searchKeyword,
clearSearch: () => {
searchKeyword.value = ''
searchResults.value = []
hasSearched.value = false
error.value = null
}
})
</script>
<style scoped>
.artist-search {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.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;
display: flex;
align-items: center;
justify-content: center;
}
.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-options {
display: flex;
align-items: center;
gap: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: #374151;
}
.form-checkbox {
width: 1rem;
height: 1rem;
accent-color: #3b82f6;
}
.loading-section,
.error-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.results-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.results-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.results-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.filter-info {
font-size: 0.875rem;
color: #6b7280;
}
.artists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.empty-section,
.welcome-section {
text-align: center;
padding: 3rem 0;
}
.empty-content,
.welcome-content {
max-width: 300px;
margin: 0 auto;
}
.empty-icon {
width: 3rem;
height: 3rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.empty-content h3,
.welcome-content h3 {
font-size: 1.25rem;
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-input-group {
flex-direction: column;
}
.artists-grid {
grid-template-columns: 1fr;
}
.search-options {
justify-content: flex-start;
}
}
</style>