feat(ui): redesign search panel with modal and filter system

- Replace dropdown search results with full-screen modal
- Add 4-tier filter chips: subtitle group, resolution, subtitle type, season
- Auto-extract filter options from search results
- Add confirmation modal before subscribing
- Support toggle close (click search input, Escape, or X button)
- Responsive grid layout: 1-4 columns based on viewport
- SSE streaming with fade-in card animations
- Add i18n translations for search filters and confirmation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Estrella Pan
2026-01-25 11:48:32 +01:00
parent 8e7e8933e4
commit 362c7b81ca
10 changed files with 1868 additions and 92 deletions

View File

@@ -0,0 +1,259 @@
# Search Panel Redesign
## Overview
Redesign the search bar component from a dropdown list to a full modal-based search experience with advanced filtering capabilities.
## Problems with Current Implementation
1. **Click-outside clears everything** - `v-on-click-outside="clearSearch"` causes accidental loss of results
2. **Limited result display** - Absolute positioned list with no scroll container
3. **No explicit close control** - User has no intentional way to dismiss except clicking outside
4. **No filtering** - Results often contain same anime with different subtitle groups/seasons, hard to find the right one
## Design Goals
- Prevent accidental dismissal of search results
- Support displaying many results with proper scrolling
- Enable filtering by subtitle group, resolution, subtitle type, and season
- Provide confirmation step before subscribing
---
## Search Panel Structure
### Trigger & Toggle Behavior
- Clicking the search input opens the search modal (if closed) or closes it (if open)
- Pressing `Escape` closes the modal
- A visible `×` close button in the modal header provides explicit dismissal
- Clicking the backdrop does NOT close (prevents accidental loss of results)
### Modal Layout
```
┌─────────────────────────────────────────────────────────┐
│ 🔍 [Search input] [provider ▼] [×] │
├─────────────────────────────────────────────────────────┤
│ 字幕组: [喵萌奶茶屋] [ANi] [桜都] [LoliHouse] [+3] │
│ 分辨率: [1080p] [720p] [4K] │
│ 字幕语言: [简中 CHS] [繁中 CHT] [双语] [内嵌] [外挂] │
│ 季度: [S1] [S2] [剧场版] │
│ [清除筛选] 8/24 结果 │
├─────────────────────────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Result │ │ Result │ │ Result │ │
│ │ Card │ │ Card │ │ Card │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Result │ │ Result │ │ Result │ │
│ │ Card │ │ Card │ │ Card │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ (scrollable grid) │
└─────────────────────────────────────────────────────────┘
```
- Modal centered on screen with backdrop overlay
- Fixed header with search input, provider selector, close button
- Filter chips section below header (sticky when scrolling)
- Scrollable grid of result cards (3 columns on desktop, responsive)
---
## Filter Chip System
### Four Filter Dimensions
1. **字幕组 (Subtitle Group)** - Fansub team name
2. **分辨率 (Resolution)** - 720p, 1080p, 4K/2160p
3. **字幕语言 (Subtitle Type)** - CHS (简中), CHT (繁中), 双语 (Dual), 内嵌 (Hardcoded), 外挂 (External ASS/SRT)
4. **季度 (Season)** - S1, S2, Movie/剧场版, OVA
### Auto-generated Filters
- As results stream in, extract unique values for each dimension
- Chips appear dynamically as new filter values are discovered
- Parsing logic extracts metadata from torrent titles
### Filter Behavior
- Chips are toggleable - click to activate (highlighted), click again to deactivate
- Multiple filters can be active simultaneously
- Logic: AND within same category, OR across categories
- Active filters show with filled background, inactive with outline style
- "清除筛选" (Clear all) button appears when any filter is active
- Result count updates dynamically: "显示 8 / 24 个结果"
### Overflow Handling
- If a category has >5 options, show first 4 + `[+N more]` chip that expands
- Each category row can collapse/expand independently
- Collapsed state shows badge count for active filters
---
## Result Cards
### Card Design (Compact Grid Item)
```
┌──────────────────────────┐
│ ┌──────┐ 葬送的芙莉莲 │
│ │poster│ Frieren │
│ │ │ ───────────── │
│ └──────┘ 喵萌奶茶屋 │
│ 1080p · 简中 │
│ S1 · 全28集 │
└──────────────────────────┘
```
### Card Content
- Thumbnail/poster (if available from API, placeholder if not)
- Title (Chinese + Romaji/English)
- Subtitle group badge
- Resolution + Subtitle type tags
- Season + Episode count
### Streaming Animation
- Cards fade in with slight upward slide (`opacity: 0 → 1`, `translateY: 8px → 0`)
- Staggered delay: each card delays 50ms after previous
- Grid re-flows smoothly as new cards appear
- Filter changes use same fade animation for showing/hiding
### Empty & Loading States
| State | Display |
|-------|---------|
| Initial | "输入关键词开始搜索" |
| Searching | Spinner in search input, cards stream in |
| No results | "未找到相关结果,试试其他关键词" |
| Filtered to zero | "没有符合筛选条件的结果" + "清除筛选" button |
---
## Confirmation Modal
When user clicks a result card, a nested modal appears for review before subscribing.
### Layout
```
┌─────────────────────────────────────────────────────┐
│ 添加订阅 [×] │
├─────────────────────────────────────────────────────┤
│ ┌────────┐ │
│ │ │ 葬送的芙莉莲 │
│ │ poster │ Sousou no Frieren │
│ │ │ ★ 9.2 · 2023年秋 · 全28集 │
│ └────────┘ │
├─────────────────────────────────────────────────────┤
│ RSS 源: [当前选择的RSS链接] [复制] │
│ 字幕组: 喵萌奶茶屋 │
│ 分辨率: 1080p │
│ 字幕类型: 简体中文 (内嵌) │
├─────────────────────────────────────────────────────┤
│ 高级设置 ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 过滤规则: [自动生成的filter] │ │
│ │ 保存路径: /media/anime/葬送的芙莉莲/ │ │
│ │ 重命名: ☑ 启用自动重命名 │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────┤
│ [取消] [确认订阅 ✓] │
└─────────────────────────────────────────────────────┘
```
### Behavior
- Shows parsed metadata for user review
- Advanced settings collapsed by default
- "确认订阅" adds to subscriptions and closes both modals
- "取消" returns to search results (results preserved)
- Escape key returns to search results (not full close)
---
## Keyboard Navigation
| Key | Action |
|-----|--------|
| `Enter` (in search input) | Trigger search |
| `Escape` | Close confirmation modal (if open) → close search modal |
| `Tab` | Navigate through filter chips, then result cards |
| `Enter` (on focused card) | Open confirmation modal |
| Arrow keys | Navigate grid (optional enhancement) |
---
## Responsive Design
### Breakpoints
| Viewport | Grid | Modal Width | Behavior |
|----------|------|-------------|----------|
| Desktop (>1024px) | 3 columns | 800px centered | Full experience |
| Tablet (768-1024px) | 2 columns | 90% width | Filters collapse to single row |
| Mobile (<768px) | 1 column | Full screen | Bottom sheet style |
### Mobile Adaptations
- Modal becomes full-screen bottom sheet
- Filter chips horizontally scrollable (single row)
- Cards stack vertically (single column)
- Swipe down to close (in addition to × button)
---
## Component Structure
```
ab-search-modal.vue (new - main modal container)
├── ab-search-header.vue (search input + provider + close)
├── ab-search-filters.vue (new - filter chips)
├── ab-search-results.vue (new - scrollable grid)
│ └── ab-search-card.vue (new - individual result card)
└── ab-search-confirm.vue (new - confirmation modal)
```
### State Management
Extend `useSearchStore` with:
- `filters` - active filter state per dimension
- `filteredResults` - computed results after filter application
- `showModal` - modal open/close state
- `selectedResult` - currently selected result for confirmation
---
## Implementation Notes
### Filter Parsing
Need to extract metadata from torrent titles. Common patterns:
- Subtitle group: `[喵萌奶茶屋]`, `【ANi】`
- Resolution: `1080p`, `720p`, `2160p`, `4K`
- Subtitle type: `简体`, `繁體`, `CHS`, `CHT`, `简日双语`, `内嵌`, `外挂`
- Season: `S01`, `Season 1`, `第一季`, `剧场版`, `Movie`, `OVA`
### SSE Streaming
Keep existing SSE implementation but:
- Parse metadata from each result as it arrives
- Update filter options incrementally
- Apply active filters to new results immediately
### Animation
Use Vue's `<TransitionGroup>` with staggered delays:
```css
.card-enter-active {
transition: all 0.3s ease;
transition-delay: calc(var(--index) * 50ms);
}
.card-enter-from {
opacity: 0;
transform: translateY(8px);
}
```

View File

@@ -1,112 +1,46 @@
<script lang="ts" setup>
import { vOnClickOutside } from '@vueuse/components';
import AbSearchModal from './search/ab-search-modal.vue';
import type { BangumiRule } from '#/bangumi';
defineEmits<{
(e: 'add-bangumi', bangumiRule: BangumiRule): void;
}>();
const showProvider = ref(false);
const { providers, provider, loading, inputValue, bangumiList } = storeToRefs(
useSearchStore()
);
const { getProviders, onSearch, clearSearch } = useSearchStore();
const { showModal, provider, loading, inputValue } = storeToRefs(useSearchStore());
const { toggleModal, onSearch, getProviders } = useSearchStore();
onMounted(() => {
getProviders();
});
function onSelect(site: string) {
provider.value = site;
showProvider.value = false;
// Handle click on search input - toggle modal
function handleSearchClick() {
toggleModal();
}
// Handle search trigger from input
function handleSearch() {
if (!showModal.value) {
toggleModal();
}
onSearch();
}
</script>
<template>
<!-- Compact search trigger -->
<ab-search
v-model:inputValue="inputValue"
v-model:input-value="inputValue"
:provider="provider"
:loading="loading"
@search="onSearch"
@select="() => (showProvider = !showProvider)"
@search="handleSearch"
@select="handleSearchClick"
@click="handleSearchClick"
/>
<transition name="dropdown">
<div
v-show="showProvider"
v-on-click-outside="() => (showProvider = false)"
class="provider-dropdown"
>
<div
v-for="site in providers"
:key="site"
class="provider-item"
@click="() => onSelect(site)"
>
{{ site }}
</div>
</div>
</transition>
<div v-on-click-outside="clearSearch" class="search-results">
<transition-group name="fade-list" tag="ul" class="search-results-list">
<li v-for="bangumi in bangumiList" :key="bangumi.order">
<ab-bangumi-card
:bangumi="bangumi.value"
type="search"
@click="() => $emit('add-bangumi', bangumi.value)"
/>
</li>
</transition-group>
</div>
<!-- Search Modal -->
<AbSearchModal
@close="toggleModal"
@add-bangumi="(bangumi) => $emit('add-bangumi', bangumi)"
/>
</template>
<style lang="scss" scoped>
.provider-dropdown {
position: absolute;
top: 84px;
left: 540px;
width: 120px;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
z-index: 50;
overflow: hidden;
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
.provider-item {
padding: 10px 12px;
font-size: 14px;
color: var(--color-primary);
cursor: pointer;
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background-color var(--transition-fast), color var(--transition-fast);
&:hover {
background: var(--color-primary);
color: #fff;
}
}
.search-results {
position: absolute;
top: 84px;
left: 192px;
z-index: 30;
}
.search-results-list {
display: flex;
flex-direction: column;
gap: 12px;
list-style: none;
padding: 0;
margin: 0;
}
</style>

View File

@@ -13,7 +13,7 @@ withDefaults(
}
);
defineEmits<{ select: []; search: [] }>();
defineEmits<{ select: []; search: []; click: [] }>();
const inputValue = defineModel<string>('inputValue');
</script>
@@ -36,6 +36,7 @@ const inputValue = defineModel<string>('inputValue');
:placeholder="$t('topbar.search.placeholder')"
class="search-field"
aria-label="Search anime"
@click="$emit('click')"
@keyup.enter="$emit('search')"
/>

View File

@@ -0,0 +1,216 @@
<script lang="ts" setup>
import { ErrorPicture, Plus } from '@icon-park/vue-next';
import type { BangumiRule } from '#/bangumi';
const props = defineProps<{
bangumi: BangumiRule;
}>();
defineEmits(['click']);
const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
// Format season display
const seasonDisplay = computed(() => {
if (props.bangumi.season_raw) {
return props.bangumi.season_raw;
}
return props.bangumi.season ? `S${props.bangumi.season}` : '';
});
</script>
<template>
<div
class="search-card"
role="button"
tabindex="0"
:aria-label="`Add ${bangumi.official_title}`"
@click="$emit('click')"
@keydown.enter="$emit('click')"
>
<!-- Poster -->
<div class="card-poster">
<template v-if="bangumi.poster_link">
<img :src="posterSrc" :alt="bangumi.official_title" loading="lazy" />
</template>
<template v-else>
<div class="card-placeholder">
<ErrorPicture theme="outline" size="24" />
</div>
</template>
</div>
<!-- Info -->
<div class="card-info">
<h3 class="card-title">{{ bangumi.official_title }}</h3>
<p v-if="bangumi.title_raw" class="card-subtitle">{{ bangumi.title_raw }}</p>
<div class="card-tags">
<span v-if="bangumi.group_name" class="tag tag-group">
{{ bangumi.group_name }}
</span>
<span v-if="bangumi.dpi" class="tag tag-resolution">
{{ bangumi.dpi }}
</span>
<span v-if="bangumi.subtitle" class="tag tag-subtitle">
{{ bangumi.subtitle }}
</span>
<span v-if="seasonDisplay" class="tag tag-season">
{{ seasonDisplay }}
</span>
</div>
</div>
<!-- Add button -->
<div class="card-action">
<div class="add-btn">
<Plus theme="outline" size="18" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.search-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
user-select: none;
transition: all var(--transition-fast);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface-hover);
.add-btn {
background: var(--color-primary);
color: #fff;
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&:active {
transform: scale(0.98);
}
}
.card-poster {
width: 60px;
height: 84px;
flex-shrink: 0;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-hover);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.card-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
transition: color var(--transition-normal);
}
.card-subtitle {
font-size: 12px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
line-height: 1.4;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.tag {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
font-size: 11px;
font-weight: 500;
border-radius: var(--radius-full);
white-space: nowrap;
}
.tag-group {
background: var(--color-primary-light);
color: var(--color-primary);
}
.tag-resolution {
background: rgba(34, 197, 94, 0.15);
color: var(--color-success);
}
.tag-subtitle {
background: rgba(249, 115, 22, 0.15);
color: var(--color-accent);
}
.tag-season {
background: var(--color-surface-hover);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.card-action {
flex-shrink: 0;
}
.add-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-surface-hover);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
transition: all var(--transition-fast);
}
</style>

View File

@@ -0,0 +1,458 @@
<script lang="ts" setup>
import { CheckOne, Close, Copy, Down, ErrorPicture, Right } from '@icon-park/vue-next';
import type { BangumiRule } from '#/bangumi';
const props = defineProps<{
bangumi: BangumiRule;
}>();
const emit = defineEmits<{
(e: 'confirm', bangumi: BangumiRule): void;
(e: 'cancel'): void;
}>();
const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
const showAdvanced = ref(false);
const copied = ref(false);
// Format season display
const seasonDisplay = computed(() => {
if (props.bangumi.season_raw) {
return props.bangumi.season_raw;
}
return props.bangumi.season ? `Season ${props.bangumi.season}` : '';
});
// Copy RSS link
async function copyRssLink() {
const rssLink = props.bangumi.rss_link?.[0] || '';
if (rssLink) {
await navigator.clipboard.writeText(rssLink);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
}
}
function handleConfirm() {
emit('confirm', props.bangumi);
}
</script>
<template>
<div class="confirm-backdrop" @click.self="emit('cancel')">
<div class="confirm-modal" role="dialog" aria-modal="true">
<!-- Header -->
<header class="confirm-header">
<h2 class="confirm-title">{{ $t('search.confirm.title') }}</h2>
<button class="close-btn" aria-label="Close" @click="emit('cancel')">
<Close theme="outline" size="18" />
</button>
</header>
<!-- Content -->
<div class="confirm-content">
<!-- Bangumi Info -->
<div class="bangumi-info">
<div class="bangumi-poster">
<template v-if="bangumi.poster_link">
<img :src="posterSrc" :alt="bangumi.official_title" />
</template>
<template v-else>
<div class="poster-placeholder">
<ErrorPicture theme="outline" size="32" />
</div>
</template>
</div>
<div class="bangumi-meta">
<h3 class="bangumi-title">{{ bangumi.official_title }}</h3>
<p v-if="bangumi.title_raw" class="bangumi-subtitle">{{ bangumi.title_raw }}</p>
<div class="bangumi-details">
<span v-if="bangumi.year">{{ bangumi.year }}</span>
<span v-if="seasonDisplay">{{ seasonDisplay }}</span>
</div>
</div>
</div>
<!-- Info rows -->
<div class="info-section">
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.rss') }}:</span>
<span class="info-value info-value--link">
{{ bangumi.rss_link?.[0] || '-' }}
</span>
<button class="copy-btn" :class="{ copied }" @click="copyRssLink">
<CheckOne v-if="copied" theme="outline" size="14" />
<Copy v-else theme="outline" size="14" />
</button>
</div>
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.group') }}:</span>
<span class="info-value">{{ bangumi.group_name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.resolution') }}:</span>
<span class="info-value">{{ bangumi.dpi || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.subtitle') }}:</span>
<span class="info-value">{{ bangumi.subtitle || '-' }}</span>
</div>
</div>
<!-- Advanced settings -->
<div class="advanced-section">
<button class="advanced-toggle" @click="showAdvanced = !showAdvanced">
<component :is="showAdvanced ? Down : Right" theme="outline" size="14" />
{{ $t('search.confirm.advanced') }}
</button>
<transition name="expand">
<div v-show="showAdvanced" class="advanced-content">
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.filter') }}:</span>
<span class="info-value info-value--code">
{{ bangumi.filter?.join(', ') || '-' }}
</span>
</div>
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.save_path') }}:</span>
<span class="info-value info-value--code">
{{ bangumi.save_path || '-' }}
</span>
</div>
</div>
</transition>
</div>
</div>
<!-- Footer -->
<footer class="confirm-footer">
<button class="btn btn-secondary" @click="emit('cancel')">
{{ $t('common.cancel') }}
</button>
<button class="btn btn-primary" @click="handleConfirm">
{{ $t('search.confirm.subscribe') }}
</button>
</footer>
</div>
</div>
</template>
<style lang="scss" scoped>
.confirm-backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-overlay);
z-index: calc(var(--z-modal) + 10);
padding: 16px;
}
.confirm-modal {
width: 100%;
max-width: 480px;
max-height: 90vh;
display: flex;
flex-direction: column;
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
animation: modal-in 200ms ease-out;
}
@keyframes modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.confirm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
}
.confirm-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-muted);
transition: all var(--transition-fast);
&:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
}
.confirm-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
// Bangumi info section
.bangumi-info {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.bangumi-poster {
width: 80px;
height: 112px;
flex-shrink: 0;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-surface-hover);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.poster-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.bangumi-meta {
flex: 1;
min-width: 0;
}
.bangumi-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin: 0 0 4px;
line-height: 1.4;
}
.bangumi-subtitle {
font-size: 13px;
color: var(--color-text-muted);
margin: 0 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bangumi-details {
display: flex;
gap: 8px;
font-size: 13px;
color: var(--color-text-secondary);
span {
&:not(:last-child)::after {
content: '·';
margin-left: 8px;
color: var(--color-text-muted);
}
}
}
// Info section
.info-section {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--color-surface-hover);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.info-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.info-label {
flex-shrink: 0;
width: 70px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
}
.info-value {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--color-text);
word-break: break-all;
&--link {
color: var(--color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--code {
font-family: 'Fira Code', monospace;
font-size: 12px;
background: var(--color-surface);
padding: 4px 8px;
border-radius: var(--radius-sm);
}
}
.copy-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-muted);
transition: all var(--transition-fast);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
&.copied {
background: var(--color-success);
border-color: var(--color-success);
color: #fff;
}
}
// Advanced section
.advanced-section {
margin-bottom: 8px;
}
.advanced-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 0;
font-size: 13px;
font-family: inherit;
font-weight: 500;
color: var(--color-text-secondary);
background: transparent;
border: none;
cursor: pointer;
transition: color var(--transition-fast);
&:hover {
color: var(--color-text);
}
}
.advanced-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--color-surface-hover);
border-radius: var(--radius-md);
margin-top: 8px;
}
// Expand transition
.expand-enter-active,
.expand-leave-active {
transition: all var(--transition-normal);
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
max-height: 0;
margin-top: 0;
padding-top: 0;
padding-bottom: 0;
}
// Footer
.confirm-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
}
.btn {
height: 40px;
padding: 0 20px;
font-size: 14px;
font-family: inherit;
font-weight: 500;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&-secondary {
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
color: var(--color-text);
&:hover {
background: var(--color-surface);
border-color: var(--color-text-muted);
}
}
&-primary {
background: var(--color-primary);
border: 1px solid var(--color-primary);
color: #fff;
&:hover {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
}
}
</style>

View File

@@ -0,0 +1,189 @@
<script lang="ts" setup>
import { CloseSmall } from '@icon-park/vue-next';
import type { FilterOptions, SearchFilters } from '@/store/search';
const props = defineProps<{
filters: SearchFilters;
filterOptions: FilterOptions;
filteredCount: number;
totalCount: number;
}>();
const emit = defineEmits<{
(e: 'toggle-filter', category: keyof SearchFilters, value: string): void;
(e: 'clear-filters'): void;
}>();
const { t } = useMyI18n();
const categories = [
{ key: 'group' as const, label: () => t('search.filter.group') },
{ key: 'resolution' as const, label: () => t('search.filter.resolution') },
{ key: 'subtitleType' as const, label: () => t('search.filter.subtitle_type') },
{ key: 'season' as const, label: () => t('search.filter.season') },
];
const hasActiveFilters = computed(() => {
return Object.values(props.filters).some((arr) => arr.length > 0);
});
function isActive(category: keyof SearchFilters, value: string): boolean {
return props.filters[category].includes(value);
}
// Limit displayed chips, show "+N more" for overflow
const MAX_VISIBLE_CHIPS = 8;
function getVisibleOptions(options: string[]) {
return options.slice(0, MAX_VISIBLE_CHIPS);
}
function getOverflowCount(options: string[]) {
return Math.max(0, options.length - MAX_VISIBLE_CHIPS);
}
</script>
<template>
<div v-if="Object.values(filterOptions).some(arr => arr.length > 0)" class="filters-section">
<!-- Filter rows -->
<div v-for="cat in categories" :key="cat.key" class="filter-row">
<template v-if="filterOptions[cat.key].length > 0">
<span class="filter-label">{{ cat.label() }}:</span>
<div class="filter-chips">
<button
v-for="option in getVisibleOptions(filterOptions[cat.key])"
:key="option"
class="filter-chip"
:class="{ active: isActive(cat.key, option) }"
@click="emit('toggle-filter', cat.key, option)"
>
{{ option }}
</button>
<span
v-if="getOverflowCount(filterOptions[cat.key]) > 0"
class="filter-overflow"
>
+{{ getOverflowCount(filterOptions[cat.key]) }}
</span>
</div>
</template>
</div>
<!-- Footer: Clear + Count -->
<div class="filters-footer">
<button
v-if="hasActiveFilters"
class="clear-btn"
@click="emit('clear-filters')"
>
<CloseSmall :size="14" />
{{ $t('search.filter.clear') }}
</button>
<span class="results-count">
<template v-if="hasActiveFilters">
{{ filteredCount }} / {{ totalCount }} {{ $t('search.filter.results') }}
</template>
<template v-else>
{{ totalCount }} {{ $t('search.filter.results') }}
</template>
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.filters-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
transition: background-color var(--transition-normal), border-color var(--transition-normal);
}
.filter-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.filter-label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
min-width: 60px;
flex-shrink: 0;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.filter-chip {
height: 28px;
padding: 0 12px;
font-size: 12px;
font-family: inherit;
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
background: var(--color-surface-hover);
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
transition: all var(--transition-fast);
&:hover:not(.active) {
border-color: var(--color-primary);
color: var(--color-primary);
}
&.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
}
.filter-overflow {
font-size: 12px;
color: var(--color-text-muted);
padding: 0 8px;
}
.filters-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
}
.clear-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
font-family: inherit;
color: var(--color-danger);
background: transparent;
border: 1px solid var(--color-danger);
border-radius: var(--radius-full);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
background: var(--color-danger);
color: #fff;
}
}
.results-count {
font-size: 12px;
color: var(--color-text-muted);
}
</style>

View File

@@ -0,0 +1,455 @@
<script lang="ts" setup>
import { Close, Down, Search } from '@icon-park/vue-next';
import { NSpin } from 'naive-ui';
import { onKeyStroke } from '@vueuse/core';
import AbSearchFilters from './ab-search-filters.vue';
import AbSearchCard from './ab-search-card.vue';
import AbSearchConfirm from './ab-search-confirm.vue';
import type { BangumiRule } from '#/bangumi';
const emit = defineEmits<{
(e: 'close'): void;
(e: 'add-bangumi', bangumi: BangumiRule): void;
}>();
const {
providers,
provider,
loading,
inputValue,
bangumiList,
filteredResults,
filters,
filterOptions,
showModal,
selectedResult,
} = storeToRefs(useSearchStore());
const {
getProviders,
onSearch,
clearSearch,
toggleFilter,
clearFilters,
selectResult,
clearSelectedResult,
} = useSearchStore();
const showProvider = ref(false);
const searchInputRef = ref<HTMLInputElement | null>(null);
// Close on Escape
onKeyStroke('Escape', () => {
if (selectedResult.value) {
clearSelectedResult();
} else {
emit('close');
}
});
// Focus input on mount
onMounted(() => {
getProviders();
nextTick(() => {
searchInputRef.value?.focus();
});
});
function onSelectProvider(site: string) {
provider.value = site;
showProvider.value = false;
}
function handleCardClick(bangumi: BangumiRule) {
selectResult(bangumi);
}
function handleConfirm(bangumi: BangumiRule) {
emit('add-bangumi', bangumi);
clearSelectedResult();
emit('close');
}
function handleClose() {
clearSearch();
emit('close');
}
</script>
<template>
<Teleport to="body">
<!-- Backdrop -->
<transition name="overlay">
<div v-if="showModal" class="modal-backdrop" />
</transition>
<!-- Modal -->
<transition name="modal">
<div v-if="showModal" class="modal-container" role="dialog" aria-modal="true">
<div class="modal-content">
<!-- Header -->
<header class="modal-header">
<div class="search-input-wrapper">
<button
v-if="!loading"
class="search-icon-btn"
aria-label="Search"
@click="onSearch"
>
<Search theme="outline" size="20" />
</button>
<NSpin v-else :size="18" />
<input
ref="searchInputRef"
v-model="inputValue"
type="text"
:placeholder="$t('topbar.search.placeholder')"
class="search-input"
aria-label="Search anime"
@keyup.enter="onSearch"
/>
<div class="provider-select">
<button
class="provider-btn"
aria-label="Select search provider"
@click="showProvider = !showProvider"
>
<span class="provider-label">{{ provider }}</span>
<Down :size="14" />
</button>
<transition name="dropdown">
<div v-show="showProvider" class="provider-dropdown">
<button
v-for="site in providers"
:key="site"
class="provider-item"
:class="{ active: site === provider }"
@click="onSelectProvider(site)"
>
{{ site }}
</button>
</div>
</transition>
</div>
</div>
<button class="close-btn" aria-label="Close search" @click="handleClose">
<Close theme="outline" size="20" />
</button>
</header>
<!-- Filters -->
<AbSearchFilters
:filters="filters"
:filter-options="filterOptions"
:filtered-count="filteredResults.length"
:total-count="bangumiList.length"
@toggle-filter="toggleFilter"
@clear-filters="clearFilters"
/>
<!-- Results Grid -->
<div class="results-container">
<!-- Empty state -->
<div v-if="!loading && filteredResults.length === 0 && inputValue" class="empty-state">
<p>{{ $t('search.no_results') }}</p>
</div>
<!-- Initial state -->
<div v-else-if="!inputValue && filteredResults.length === 0" class="empty-state">
<p>{{ $t('search.start_typing') }}</p>
</div>
<!-- Results grid -->
<transition-group
v-else
name="card"
tag="div"
class="results-grid"
>
<AbSearchCard
v-for="(result, index) in filteredResults"
:key="result.order"
:bangumi="result.value"
:style="{ '--stagger-index': index }"
@click="handleCardClick(result.value)"
/>
</transition-group>
</div>
</div>
<!-- Confirmation Modal -->
<AbSearchConfirm
v-if="selectedResult"
:bangumi="selectedResult"
@confirm="handleConfirm"
@cancel="clearSelectedResult"
/>
</div>
</transition>
</Teleport>
</template>
<style lang="scss" scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: var(--color-overlay);
z-index: var(--z-modal-backdrop);
}
.modal-container {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 60px 16px 16px;
z-index: var(--z-modal);
overflow-y: auto;
@include forDesktop {
padding: 80px 24px 24px;
}
}
.modal-content {
width: 100%;
max-width: 1100px;
max-height: calc(100vh - 100px);
display: flex;
flex-direction: column;
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
transition: background-color var(--transition-normal);
@include forDesktop {
max-height: calc(100vh - 120px);
}
}
// Header
.modal-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
transition: border-color var(--transition-normal);
}
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
height: 44px;
padding-left: 14px;
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast), background-color var(--transition-normal);
&:focus-within {
border-color: var(--color-primary);
background: var(--color-surface);
}
}
.search-icon-btn {
display: flex;
align-items: center;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-text-muted);
transition: color var(--transition-fast);
&:hover {
color: var(--color-primary);
}
}
.search-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
font-size: 15px;
color: var(--color-text);
&::placeholder {
color: var(--color-text-muted);
}
}
.provider-select {
position: relative;
height: 100%;
}
.provider-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 100%;
padding: 0 14px;
min-width: 90px;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
cursor: pointer;
font-size: 13px;
font-family: inherit;
transition: background-color var(--transition-fast);
&:hover {
background: var(--color-primary-hover);
}
}
.provider-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 120px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
overflow: hidden;
z-index: 10;
}
.provider-item {
display: block;
width: 100%;
padding: 10px 14px;
font-size: 14px;
color: var(--color-text);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background-color var(--transition-fast), color var(--transition-fast);
&:hover {
background: var(--color-primary);
color: #fff;
}
&.active {
background: var(--color-primary-light);
color: var(--color-primary);
}
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text-muted);
flex-shrink: 0;
transition: all var(--transition-fast);
&:hover {
background: var(--color-danger);
border-color: var(--color-danger);
color: #fff;
}
}
// Results
.results-container {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.results-grid {
display: grid;
gap: 12px;
grid-template-columns: 1fr;
@include forTablet {
grid-template-columns: repeat(2, 1fr);
}
@include forDesktop {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (min-width: 1200px) {
grid-template-columns: repeat(4, 1fr);
}
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--color-text-muted);
font-size: 15px;
}
// Modal transition
.modal-enter-active {
transition: opacity var(--transition-normal), transform var(--transition-normal);
}
.modal-leave-active {
transition: opacity 150ms ease-in, transform 150ms ease-in;
}
.modal-enter-from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
.modal-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
// Card stagger animation
.card-enter-active {
transition: opacity var(--transition-slow), transform var(--transition-slow);
transition-delay: calc(var(--stagger-index, 0) * 40ms);
}
.card-leave-active {
transition: opacity 150ms ease-in;
}
.card-enter-from {
opacity: 0;
transform: translateY(8px);
}
.card-leave-to {
opacity: 0;
}
</style>

View File

@@ -331,6 +331,33 @@
"rss": "RSS Manager",
"title": "Menu"
},
"common": {
"cancel": "Cancel",
"confirm": "Confirm"
},
"search": {
"no_results": "No results found. Try different keywords.",
"start_typing": "Enter keywords to start searching",
"filter": {
"group": "Group",
"resolution": "Resolution",
"subtitle_type": "Subtitle",
"season": "Season",
"clear": "Clear",
"results": "results"
},
"confirm": {
"title": "Add Subscription",
"rss": "RSS Feed",
"group": "Subtitle Group",
"resolution": "Resolution",
"subtitle": "Subtitle Type",
"filter": "Filter Rules",
"save_path": "Save Path",
"advanced": "Advanced Settings",
"subscribe": "Subscribe"
}
},
"topbar": {
"add": {
"aggregate": "Aggregate RSS",

View File

@@ -331,6 +331,33 @@
"rss": "RSS 管理",
"title": "菜单"
},
"common": {
"cancel": "取消",
"confirm": "确认"
},
"search": {
"no_results": "未找到相关结果,试试其他关键词",
"start_typing": "输入关键词开始搜索",
"filter": {
"group": "字幕组",
"resolution": "分辨率",
"subtitle_type": "字幕语言",
"season": "季度",
"clear": "清除筛选",
"results": "个结果"
},
"confirm": {
"title": "添加订阅",
"rss": "RSS 源",
"group": "字幕组",
"resolution": "分辨率",
"subtitle": "字幕类型",
"filter": "过滤规则",
"save_path": "保存路径",
"advanced": "高级设置",
"subscribe": "确认订阅"
}
},
"topbar": {
"add": {
"aggregate": "聚合 RSS",

View File

@@ -1,3 +1,69 @@
import type { BangumiRule, SearchResult } from '#/bangumi';
// Filter types
export interface SearchFilters {
group: string[];
resolution: string[];
subtitleType: string[];
season: string[];
}
export interface FilterOptions {
group: string[];
resolution: string[];
subtitleType: string[];
season: string[];
}
// Helper to parse metadata from title/bangumi
function parseResolution(bangumi: BangumiRule): string {
// Check dpi field first
if (bangumi.dpi) {
return bangumi.dpi;
}
// Parse from title_raw
const title = bangumi.title_raw || '';
const resMatch = title.match(/\b(4K|2160p|1080p|720p|480p)\b/i);
return resMatch ? resMatch[1].toLowerCase() : '';
}
function parseSubtitleType(bangumi: BangumiRule): string {
// Check subtitle field first
if (bangumi.subtitle) {
const sub = bangumi.subtitle.toLowerCase();
if (sub.includes('简') || sub.includes('chs') || sub.includes('sc')) return '简中';
if (sub.includes('繁') || sub.includes('cht') || sub.includes('tc')) return '繁中';
if (sub.includes('双语') || sub.includes('dual')) return '双语';
}
// Parse from title_raw
const title = bangumi.title_raw || '';
if (/简体|简中|CHS|SC/i.test(title)) return '简中';
if (/繁體|繁中|CHT|TC/i.test(title)) return '繁中';
if (/双语|Dual/i.test(title)) return '双语';
if (/内嵌|内封/i.test(title)) return '内嵌';
if (/外挂|ASS|SRT/i.test(title)) return '外挂';
return '';
}
function parseSeason(bangumi: BangumiRule): string {
if (bangumi.season_raw) {
return bangumi.season_raw;
}
if (bangumi.season) {
return `S${bangumi.season}`;
}
// Parse from title
const title = bangumi.title_raw || '';
if (/剧场版|Movie|劇場版/i.test(title)) return '剧场版';
if (/OVA/i.test(title)) return 'OVA';
const seasonMatch = title.match(/S(\d+)|Season\s*(\d+)|第(\d+)季/i);
if (seasonMatch) {
const num = seasonMatch[1] || seasonMatch[2] || seasonMatch[3];
return `S${num}`;
}
return '';
}
export const useSearchStore = defineStore('search', () => {
const providers = ref<string[]>(['mikan', 'dmhy', 'nyaa']);
@@ -12,33 +78,177 @@ export const useSearchStore = defineStore('search', () => {
provider.value = providers.value[0];
// Modal state
const showModal = ref(false);
const selectedResult = ref<BangumiRule | null>(null);
// Filter state
const filters = ref<SearchFilters>({
group: [],
resolution: [],
subtitleType: [],
season: [],
});
const loading = computed(() => status.value !== 'CLOSED');
const bangumiList = computed(() =>
// Raw bangumi list with order
const bangumiList = computed<SearchResult[]>(() =>
searchData.value.map((item, index) => ({ order: index, value: item }))
);
// Extract filter options from results
const filterOptions = computed<FilterOptions>(() => {
const groups = new Set<string>();
const resolutions = new Set<string>();
const subtitleTypes = new Set<string>();
const seasons = new Set<string>();
for (const item of searchData.value) {
if (item.group_name) groups.add(item.group_name);
const res = parseResolution(item);
if (res) resolutions.add(res);
const subType = parseSubtitleType(item);
if (subType) subtitleTypes.add(subType);
const season = parseSeason(item);
if (season) seasons.add(season);
}
return {
group: Array.from(groups).sort(),
resolution: Array.from(resolutions).sort((a, b) => {
// Sort by resolution quality descending
const order = ['4k', '2160p', '1080p', '720p', '480p'];
return order.indexOf(a.toLowerCase()) - order.indexOf(b.toLowerCase());
}),
subtitleType: Array.from(subtitleTypes),
season: Array.from(seasons).sort(),
};
});
// Filtered results
const filteredResults = computed<SearchResult[]>(() => {
const hasFilters = Object.values(filters.value).some((arr) => arr.length > 0);
if (!hasFilters) {
return bangumiList.value;
}
return bangumiList.value.filter((item) => {
const bangumi = item.value;
// Group filter
if (filters.value.group.length > 0) {
if (!bangumi.group_name || !filters.value.group.includes(bangumi.group_name)) {
return false;
}
}
// Resolution filter
if (filters.value.resolution.length > 0) {
const res = parseResolution(bangumi);
if (!res || !filters.value.resolution.includes(res)) {
return false;
}
}
// Subtitle type filter
if (filters.value.subtitleType.length > 0) {
const subType = parseSubtitleType(bangumi);
if (!subType || !filters.value.subtitleType.includes(subType)) {
return false;
}
}
// Season filter
if (filters.value.season.length > 0) {
const season = parseSeason(bangumi);
if (!season || !filters.value.season.includes(season)) {
return false;
}
}
return true;
});
});
async function getProviders() {
providers.value = await apiSearch.getProvider();
provider.value = providers.value[0];
}
function openModal() {
showModal.value = true;
}
function closeModal() {
showModal.value = false;
selectedResult.value = null;
}
function toggleModal() {
if (showModal.value) {
closeModal();
} else {
openModal();
}
}
function clearSearch() {
keyword.value = '';
searchData.value = [];
filters.value = { group: [], resolution: [], subtitleType: [], season: [] };
closeSearch();
}
function toggleFilter(category: keyof SearchFilters, value: string) {
const arr = filters.value[category];
const index = arr.indexOf(value);
if (index === -1) {
arr.push(value);
} else {
arr.splice(index, 1);
}
}
function clearFilters() {
filters.value = { group: [], resolution: [], subtitleType: [], season: [] };
}
function selectResult(bangumi: BangumiRule) {
selectedResult.value = bangumi;
}
function clearSelectedResult() {
selectedResult.value = null;
}
return {
// State
inputValue: keyword,
loading,
provider,
providers,
bangumiList,
filteredResults,
filters,
filterOptions,
showModal,
selectedResult,
// Actions
clearSearch,
getProviders,
onSearch: openSearch,
closeSearch,
openModal,
closeModal,
toggleModal,
toggleFilter,
clearFilters,
selectResult,
clearSelectedResult,
};
});