mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-27 09:00:41 +08:00
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:
259
docs/plans/2026-01-25-search-panel-redesign.md
Normal file
259
docs/plans/2026-01-25-search-panel-redesign.md
Normal 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);
|
||||
}
|
||||
```
|
||||
@@ -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>
|
||||
|
||||
@@ -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')"
|
||||
/>
|
||||
|
||||
|
||||
216
webui/src/components/search/ab-search-card.vue
Normal file
216
webui/src/components/search/ab-search-card.vue
Normal 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>
|
||||
458
webui/src/components/search/ab-search-confirm.vue
Normal file
458
webui/src/components/search/ab-search-confirm.vue
Normal 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>
|
||||
189
webui/src/components/search/ab-search-filters.vue
Normal file
189
webui/src/components/search/ab-search-filters.vue
Normal 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>
|
||||
455
webui/src/components/search/ab-search-modal.vue
Normal file
455
webui/src/components/search/ab-search-modal.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user