chore: bump version to 3.2.0-beta.9

Fix TypeScript error in ab-search-bar.vue and include pending changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Estrella Pan
2026-01-25 19:29:12 +01:00
parent f1fb4d7926
commit c044b65fef
26 changed files with 2522 additions and 596 deletions

View File

@@ -1,6 +1,6 @@
[project]
name = "auto-bangumi"
version = "3.2.0-beta.8"
version = "3.2.0-beta.9"
description = "AutoBangumi - Automated anime download manager"
requires-python = ">=3.10"
dependencies = [

View File

@@ -59,6 +59,12 @@ class Checker:
@staticmethod
async def check_downloader() -> bool:
from module.downloader import DownloadClient
# Mock downloader always succeeds
if settings.downloader.type == "mock":
logger.info("[Checker] Using MockDownloader - skipping connection check")
return True
try:
url = (
f"http://{settings.downloader.host}"

View File

@@ -1,12 +1,16 @@
import json
import logging
from typing import TypeAlias
from module.models import Bangumi, RSSItem, Torrent
from module.network import RequestContent
from module.parser.analyser.tmdb_parser import tmdb_parser
from module.rss import RSSAnalyser
from .provider import search_url
logger = logging.getLogger(__name__)
SEARCH_KEY = [
"group_name",
"title_raw",
@@ -18,13 +22,32 @@ SEARCH_KEY = [
BangumiJSON: TypeAlias = str
# Cache for TMDB poster lookups by official_title
_poster_cache: dict[str, str | None] = {}
class SearchTorrent(RequestContent, RSSAnalyser):
async def search_torrents(self, rss_item: RSSItem) -> list[Torrent]:
return await self.get_torrents(rss_item.url)
async def _fetch_tmdb_poster(self, title: str) -> str | None:
"""Fetch poster from TMDB if not in cache."""
if title in _poster_cache:
return _poster_cache[title]
try:
tmdb_info = await tmdb_parser(title, "zh", test=True)
if tmdb_info and tmdb_info.poster_link:
_poster_cache[title] = tmdb_info.poster_link
return tmdb_info.poster_link
except Exception as e:
logger.debug(f"[Searcher] Failed to fetch TMDB poster for {title}: {e}")
_poster_cache[title] = None
return None
async def analyse_keyword(
self, keywords: list[str], site: str = "mikan", limit: int = 5
self, keywords: list[str], site: str = "mikan", limit: int = 100
):
rss_item = search_url(site, keywords)
torrents = await self.search_torrents(rss_item)
@@ -39,6 +62,11 @@ class SearchTorrent(RequestContent, RSSAnalyser):
if special_link not in exist_list:
bangumi.rss_link = special_link
exist_list.append(special_link)
# Fetch poster from TMDB if missing
if not bangumi.poster_link and bangumi.official_title:
tmdb_poster = await self._fetch_tmdb_poster(bangumi.official_title)
if tmdb_poster:
bangumi.poster_link = tmdb_poster
yield json.dumps(bangumi.dict(), separators=(",", ":"))
@staticmethod

2
backend/uv.lock generated
View File

@@ -54,7 +54,7 @@ wheels = [
[[package]]
name = "auto-bangumi"
version = "3.2.0b4"
version = "3.2.0b7"
source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },

View File

@@ -25,7 +25,7 @@ export const apiDownloader = {
return data!;
},
async deleteTorrents(hashes: string[], deleteFiles: boolean = false) {
async deleteTorrents(hashes: string[], deleteFiles = false) {
const { data } = await axios.post<ApiSuccess>(
'api/v1/downloader/torrents/delete',
{ hashes, delete_files: deleteFiles }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { CheckOne, Close, Copy, Down, ErrorPicture, Right } from '@icon-park/vue-next';
import { NDynamicTags, NSpin, useMessage } from 'naive-ui';
import type { BangumiRule } from '#/bangumi';
const emit = defineEmits<{
@@ -20,6 +22,23 @@ const rule = defineModel<BangumiRule>('rule', {
required: true,
});
const message = useMessage();
// Local deep copy for editing (prevents mutation of original)
const localRule = ref<BangumiRule>(JSON.parse(JSON.stringify(rule.value)));
// Sync when rule changes (e.g., opening different item)
watch(rule, (newVal) => {
localRule.value = JSON.parse(JSON.stringify(newVal));
}, { deep: true });
const posterSrc = computed(() => resolvePosterUrl(localRule.value.poster_link));
const showAdvanced = ref(true);
const copied = ref(false);
const offsetLoading = ref(false);
const offsetReason = ref('');
// Delete file dialog state
const deleteFileDialog = reactive<{
show: boolean;
type: 'disable' | 'delete';
@@ -27,24 +46,77 @@ const deleteFileDialog = reactive<{
show: false,
type: 'disable',
});
watch(show, (val) => {
if (!val) {
deleteFileDialog.show = false;
showAdvanced.value = false;
offsetReason.value = '';
}
});
function showDeleteFileDialog(type: string) {
deleteFileDialog.show = true;
if (type === 'disable' || type === '禁用') {
deleteFileDialog.type = 'disable';
} else {
deleteFileDialog.type = 'delete';
// Info tags for display
const infoTags = computed(() => {
const tags: { value: string; type: string }[] = [];
const { season, season_raw, dpi, subtitle, group_name } = localRule.value;
if (season || season_raw) {
const seasonDisplay = season_raw || (season ? `S${season}` : '');
tags.push({ value: seasonDisplay, type: 'season' });
}
if (dpi) {
tags.push({ value: dpi, type: 'resolution' });
}
if (subtitle) {
tags.push({ value: subtitle, type: 'subtitle' });
}
if (group_name) {
tags.push({ value: group_name, type: 'group' });
}
return tags;
});
// Copy RSS link
async function copyRssLink() {
const rssLink = localRule.value.rss_link?.[0] || '';
if (rssLink) {
await navigator.clipboard.writeText(rssLink);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
}
}
// Auto detect offset
async function autoDetectOffset() {
if (!localRule.value.id) return;
offsetLoading.value = true;
offsetReason.value = '';
try {
const result = await apiBangumi.suggestOffset(localRule.value.id);
localRule.value.offset = result.suggested_offset;
offsetReason.value = result.reason;
} catch (e) {
console.error('Failed to detect offset:', e);
message.error('Failed to detect offset');
} finally {
offsetLoading.value = false;
}
}
const close = () => (show.value = false);
function emitdeleteFile(deleteFile: boolean) {
function showDeleteFileDialog() {
deleteFileDialog.show = true;
deleteFileDialog.type = 'delete';
}
function emitDeleteFile(deleteFile: boolean) {
emit('deleteFile', deleteFileDialog.type, {
id: rule.value.id,
deleteFile,
@@ -52,6 +124,8 @@ function emitdeleteFile(deleteFile: boolean) {
}
function emitApply() {
// Copy local changes back to rule before emitting
Object.assign(rule.value, localRule.value);
emit('apply', rule.value);
}
@@ -66,109 +140,771 @@ function emitArchive() {
function emitUnarchive() {
emit('unarchive', rule.value.id);
}
const popupTitle = computed(() => {
if (rule.value.deleted) {
return t('homepage.rule.enable_rule');
} else {
return t('homepage.rule.edit_rule');
}
});
const boxSize = computed(() => {
if (rule.value.deleted) {
return 'w-300';
} else {
return 'w-460';
}
});
</script>
<template>
<!-- Enable deleted rule dialog -->
<ab-popup
v-if="rule.deleted"
v-model:show="show"
:title="popupTitle"
:css="`${boxSize} max-w-[90vw]`"
:title="$t('homepage.rule.enable_rule')"
css="w-300 max-w-[90vw]"
>
<div v-if="rule.deleted">
<div>{{ $t('homepage.rule.enable_hit') }}</div>
<div line my-8></div>
<div f-cer gap-x-10>
<ab-button size="small" type="warn" @click="() => emitEnable()">
{{ $t('homepage.rule.yes_btn') }}
</ab-button>
<ab-button size="small" @click="() => close()">
{{ $t('homepage.rule.no_btn') }}
</ab-button>
</div>
<div>{{ $t('homepage.rule.enable_hit') }}</div>
<div line my-8></div>
<div f-cer gap-x-10>
<ab-button size="small" type="warn" @click="emitEnable">
{{ $t('homepage.rule.yes_btn') }}
</ab-button>
<ab-button size="small" @click="close">
{{ $t('homepage.rule.no_btn') }}
</ab-button>
</div>
<div v-else class="edit-rule-content">
<ab-rule v-model:rule="rule"></ab-rule>
<div class="edit-rule-actions">
<ab-button
v-if="rule.archived"
size="small"
@click="emitUnarchive"
>
{{ $t('homepage.rule.unarchive') }}
</ab-button>
<ab-button
v-else
size="small"
@click="emitArchive"
>
{{ $t('homepage.rule.archive') }}
</ab-button>
<ab-button-multi
size="small"
type="warn"
:selections="[t('homepage.rule.delete'), t('homepage.rule.disable')]"
@click="showDeleteFileDialog"
/>
<ab-button size="small" @click="emitApply">
{{ $t('homepage.rule.apply') }}
</ab-button>
</div>
</div>
<ab-popup
v-model:show="deleteFileDialog.show"
:title="$t('homepage.rule.delete')"
>
<div>{{ $t('homepage.rule.delete_hit') }}</div>
<div line my-8></div>
<div f-cer gap-x-10>
<ab-button size="small" type="warn" @click="() => emitdeleteFile(true)">
{{ $t('homepage.rule.yes_btn') }}
</ab-button>
<ab-button size="small" @click="() => emitdeleteFile(false)">
{{ $t('homepage.rule.no_btn') }}
</ab-button>
</div>
</ab-popup>
</ab-popup>
<!-- Main edit modal -->
<Teleport v-else to="body">
<Transition name="modal">
<div v-if="show" class="edit-backdrop" @click.self="close">
<div class="edit-modal" role="dialog" aria-modal="true">
<!-- Header -->
<header class="edit-header">
<h2 class="edit-title">{{ $t('homepage.rule.edit_rule') }}</h2>
<button class="close-btn" aria-label="Close" @click="close">
<Close theme="outline" size="18" />
</button>
</header>
<!-- Content -->
<div class="edit-content">
<!-- Bangumi Info -->
<div class="bangumi-info">
<div class="bangumi-poster">
<template v-if="localRule.poster_link">
<img :src="posterSrc" :alt="localRule.official_title" />
</template>
<template v-else>
<div class="poster-placeholder">
<ErrorPicture theme="outline" size="32" />
</div>
</template>
</div>
<div class="bangumi-meta">
<input
v-model="localRule.official_title"
type="text"
class="title-input"
:placeholder="$t('homepage.rule.official_title')"
/>
<p v-if="localRule.title_raw" class="bangumi-subtitle">{{ localRule.title_raw }}</p>
<div class="meta-row">
<input
:value="localRule.year ?? ''"
type="text"
class="year-input"
:class="{ 'year-input--empty': !localRule.year }"
:placeholder="$t('homepage.rule.year')"
@input="(e) => localRule.year = (e.target as HTMLInputElement).value || null"
/>
<span class="meta-separator">·</span>
<label class="season-label">S</label>
<input
v-model.number="localRule.season"
type="number"
class="season-input"
min="1"
/>
</div>
</div>
</div>
<!-- Info Tags -->
<div v-if="infoTags.length > 0" class="info-tags">
<div
v-for="tag in infoTags"
:key="tag.type"
class="info-tag"
:class="`info-tag--${tag.type}`"
>
{{ tag.value }}
</div>
</div>
<!-- RSS Link -->
<div v-if="localRule.rss_link?.[0]" class="rss-section">
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.rss') }}:</span>
<span class="info-value info-value--link">
{{ localRule.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>
<!-- 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">
<!-- Filter rules row -->
<div class="advanced-row advanced-row--tags">
<label class="advanced-label">{{ $t('homepage.rule.filter') }}</label>
<div class="advanced-control filter-tags">
<NDynamicTags v-model:value="localRule.filter" size="small" />
</div>
</div>
<!-- Offset row -->
<div class="advanced-row">
<label class="advanced-label">{{ $t('homepage.rule.offset') }}</label>
<div class="advanced-control offset-controls">
<input
v-model.number="localRule.offset"
type="number"
ab-input
class="offset-input"
/>
<button
class="detect-btn"
:disabled="offsetLoading"
@click="autoDetectOffset"
>
<NSpin v-if="offsetLoading" :size="14" />
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
</button>
</div>
</div>
<div v-if="offsetReason" class="offset-reason">{{ offsetReason }}</div>
</div>
</Transition>
</div>
</div>
<!-- Footer -->
<footer class="edit-footer">
<div class="footer-left">
<ab-button
v-if="localRule.archived"
size="small"
@click="emitUnarchive"
>
{{ $t('homepage.rule.unarchive') }}
</ab-button>
<ab-button
v-else
size="small"
@click="emitArchive"
>
{{ $t('homepage.rule.archive') }}
</ab-button>
<ab-button
size="small"
type="warn"
@click="showDeleteFileDialog"
>
{{ $t('homepage.rule.delete') }}
</ab-button>
</div>
<div class="footer-right">
<ab-button size="small" @click="emitApply">
{{ $t('homepage.rule.apply') }}
</ab-button>
</div>
</footer>
</div>
<!-- Delete confirmation dialog -->
<Transition name="modal">
<div v-if="deleteFileDialog.show" class="delete-dialog-backdrop" @click.self="deleteFileDialog.show = false">
<div class="delete-dialog">
<h3 class="delete-title">{{ $t('homepage.rule.delete') }}</h3>
<p class="delete-message">{{ $t('homepage.rule.delete_hit') }}</p>
<div class="delete-actions">
<ab-button size="small" type="secondary" @click="emitDeleteFile(false)">
{{ $t('homepage.rule.no_btn') }}
</ab-button>
<ab-button size="small" type="warn" @click="emitDeleteFile(true)">
{{ $t('homepage.rule.yes_btn') }}
</ab-button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss" scoped>
.edit-rule-content {
.edit-backdrop {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
justify-content: center;
background: var(--color-overlay);
z-index: var(--z-modal);
padding: 16px;
}
.edit-rule-actions {
.edit-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;
}
.edit-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
}
.edit-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);
}
}
.edit-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;
display: flex;
flex-direction: column;
gap: 6px;
}
.title-input {
width: 100%;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
background: transparent;
border: none;
border-bottom: 1px solid transparent;
padding: 4px 0;
outline: none;
transition: border-color var(--transition-fast);
&:hover,
&:focus {
border-bottom-color: var(--color-border);
}
&:focus {
border-bottom-color: var(--color-primary);
}
&::placeholder {
color: var(--color-text-muted);
font-weight: 400;
}
}
.bangumi-subtitle {
font-size: 13px;
color: var(--color-text-muted);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.year-input {
width: 60px;
font-size: 13px;
color: var(--color-text-secondary);
background: transparent;
border: none;
border-bottom: 1px solid transparent;
padding: 2px 0;
outline: none;
transition: border-color var(--transition-fast), background-color var(--transition-fast);
&:hover,
&:focus {
border-bottom-color: var(--color-border);
}
&:focus {
border-bottom-color: var(--color-primary);
}
&::placeholder {
color: var(--color-text-muted);
}
&--empty {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
border-bottom-color: var(--color-warning);
border-radius: var(--radius-xs) var(--radius-xs) 0 0;
padding: 2px 4px;
&::placeholder {
color: var(--color-warning);
}
}
}
.meta-separator {
color: var(--color-text-muted);
}
.season-label {
font-size: 13px;
color: var(--color-text-secondary);
font-weight: 500;
}
.season-input {
width: 40px;
font-size: 13px;
color: var(--color-text-secondary);
background: transparent;
border: none;
border-bottom: 1px solid transparent;
padding: 2px 0;
outline: none;
text-align: center;
transition: border-color var(--transition-fast);
&:hover,
&:focus {
border-bottom-color: var(--color-border);
}
&:focus {
border-bottom-color: var(--color-primary);
}
// Hide spinner buttons
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
-moz-appearance: textfield;
}
// Info Tags
.info-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.info-tag {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: var(--radius-full);
font-size: 13px;
font-weight: 600;
&--season {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
&--resolution {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
}
&--subtitle {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
}
&--group {
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
color: var(--color-warning);
}
}
// RSS section
.rss-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;
}
}
.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;
}
.advanced-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 32px;
&--tags {
align-items: flex-start;
}
}
.advanced-label {
flex-shrink: 0;
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
line-height: 32px;
}
.advanced-control {
display: flex;
justify-content: flex-end;
:deep(.n-dynamic-tags) {
justify-content: flex-end;
min-height: 32px;
.n-tag {
height: 28px;
margin: 2px 0 2px 6px !important;
}
.n-button {
height: 28px;
}
}
}
.offset-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
gap: 8px;
height: 32px;
}
.offset-input {
width: 70px;
height: 32px;
text-align: center;
}
.detect-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 80px;
height: 32px;
padding: 0 14px;
font-size: 13px;
font-family: inherit;
font-weight: 500;
color: #fff;
background: var(--color-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
transition: background-color var(--transition-fast);
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
&:disabled {
cursor: wait;
}
}
.offset-reason {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: -4px;
}
// 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
.edit-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
padding-top: 16px;
flex-wrap: wrap;
}
.footer-left {
display: flex;
align-items: center;
gap: 8px;
}
.footer-right {
display: flex;
align-items: center;
gap: 12px;
}
// Delete confirmation dialog
.delete-dialog-backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-overlay);
z-index: calc(var(--z-modal) + 10);
}
.delete-dialog {
width: 100%;
max-width: 320px;
padding: 24px;
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
text-align: center;
}
.delete-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin: 0 0 12px;
}
.delete-message {
font-size: 14px;
color: var(--color-text-secondary);
margin: 0 0 20px;
}
.delete-actions {
display: flex;
justify-content: center;
gap: 12px;
}
// Modal transition
.modal-enter-active,
.modal-leave-active {
transition: opacity 200ms ease;
.edit-modal,
.delete-dialog {
transition: transform 200ms ease, opacity 200ms ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.edit-modal,
.delete-dialog {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
}
// Responsive adjustments
@media (max-width: 480px) {
.edit-footer {
flex-direction: column;
gap: 12px;
}
.footer-left,
.footer-right {
width: 100%;
justify-content: center;
}
.footer-right {
order: -1;
}
}
</style>

View File

@@ -1,46 +1,22 @@
<script lang="ts" setup>
import AbSearchModal from './search/ab-search-modal.vue';
import type { BangumiRule } from '#/bangumi';
defineEmits<{
(e: 'add-bangumi', bangumiRule: BangumiRule): void;
}>();
const { showModal, provider, loading, inputValue } = storeToRefs(useSearchStore());
const { toggleModal, onSearch, getProviders } = useSearchStore();
const { showModal, provider, loading } = storeToRefs(useSearchStore());
const { toggleModal, getProviders } = useSearchStore();
onMounted(() => {
getProviders();
});
// 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 -->
<!-- Search trigger button -->
<ab-search
v-model:input-value="inputValue"
:provider="provider"
:loading="loading"
@search="handleSearch"
@select="handleSearchClick"
@click="handleSearchClick"
@click="toggleModal"
/>
<!-- Search Modal -->
<AbSearchModal
@close="toggleModal"
@add-bangumi="(bangumi) => $emit('add-bangumi', bangumi)"
/>
<AbSearchModal @close="toggleModal" />
</template>

View File

@@ -25,42 +25,49 @@ const showSelections = ref<boolean>(false);
</script>
<template>
<div class="btn-multi" :class="[`btn-multi--${size}`, `btn-multi--${type}`]">
<Component
:is="link !== null ? 'a' : 'button'"
:href="link"
class="btn-multi-main"
@click="$emit('click', selected)"
>
<NSpin :show="loading" :size="size === 'big' ? 'large' : 'small'">
<div class="btn-multi-label">{{ selected }}</div>
</NSpin>
</Component>
<div
class="btn-multi-arrow"
@click="() => (showSelections = !showSelections)"
>
<Down fill="white" />
<div class="btn-multi-container">
<div class="btn-multi" :class="[`btn-multi--${size}`, `btn-multi--${type}`]">
<Component
:is="link !== null ? 'a' : 'button'"
:href="link"
class="btn-multi-main"
@click="$emit('click', selected)"
>
<NSpin :show="loading" :size="size === 'big' ? 'large' : 'small'">
<div class="btn-multi-label">{{ selected }}</div>
</NSpin>
</Component>
<div
class="btn-multi-arrow"
@click="() => (showSelections = !showSelections)"
>
<Down fill="white" />
</div>
</div>
</div>
<div
v-if="showSelections"
class="btn-multi-dropdown"
:class="[`btn-multi--${size}`, `btn-multi--${type}`]"
>
<div
v-for="selection in selections"
:key="selection"
class="btn-multi-option"
@click="() => { selected = selection; showSelections = false; }"
v-if="showSelections"
class="btn-multi-dropdown"
:class="[`btn-multi--${size}`, `btn-multi--${type}`]"
>
{{ selection }}
<div
v-for="selection in selections"
:key="selection"
class="btn-multi-option"
@click="() => { selected = selection; showSelections = false; }"
>
{{ selection }}
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.btn-multi-container {
position: relative;
display: inline-flex;
}
.btn-multi {
display: flex;
align-items: center;
@@ -90,26 +97,22 @@ const showSelections = ref<boolean>(false);
&--primary {
.btn-multi-main,
.btn-multi-arrow,
.btn-multi-option {
.btn-multi-arrow {
background: var(--color-primary);
}
.btn-multi-main:hover,
.btn-multi-arrow:hover,
.btn-multi-option:hover {
.btn-multi-arrow:hover {
background: var(--color-primary-hover);
}
}
&--warn {
.btn-multi-main,
.btn-multi-arrow,
.btn-multi-option {
.btn-multi-arrow {
background: var(--color-danger);
}
.btn-multi-main:hover,
.btn-multi-arrow:hover,
.btn-multi-option:hover {
.btn-multi-arrow:hover {
filter: brightness(0.9);
}
}
@@ -146,11 +149,32 @@ const showSelections = ref<boolean>(false);
.btn-multi-dropdown {
position: absolute;
z-index: 70;
left: 0;
bottom: 100%;
margin-bottom: 4px;
z-index: 100;
overflow: hidden;
transform: translateY(80%) translateX(-111%);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
min-width: 100%;
&.btn-multi--primary {
.btn-multi-option {
background: var(--color-primary);
&:hover {
background: var(--color-primary-hover);
}
}
}
&.btn-multi--warn {
.btn-multi-option {
background: var(--color-danger);
&:hover {
filter: brightness(0.9);
}
}
}
}
.btn-multi-option {
@@ -158,11 +182,12 @@ const showSelections = ref<boolean>(false);
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 0;
padding: 8px 12px;
cursor: pointer;
user-select: none;
color: #fff;
font-size: inherit;
transition: background-color var(--transition-fast);
white-space: nowrap;
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { computed, ref } from 'vue';
export interface DataListColumn {
key: string;
@@ -8,7 +8,7 @@ export interface DataListColumn {
hidden?: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DataItem = Record<string, any>;
const props = withDefaults(

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { computed, ref } from 'vue';
const props = withDefaults(
defineProps<{

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Down, Search } from '@icon-park/vue-next';
import { Search } from '@icon-park/vue-next';
import { NSpin } from 'naive-ui';
withDefaults(
@@ -13,123 +13,94 @@ withDefaults(
}
);
defineEmits<{ select: []; search: []; click: [] }>();
const inputValue = defineModel<string>('inputValue');
defineEmits<{ click: [] }>();
</script>
<template>
<div class="search-input" role="search">
<button
v-if="!loading"
class="search-icon-btn"
aria-label="Search"
@click="$emit('search')"
>
<Search theme="outline" size="20" class="search-icon" />
</button>
<NSpin v-else :size="18" />
<button
class="search-trigger"
role="search"
aria-label="Open search"
@click="$emit('click')"
>
<NSpin v-if="loading" :size="16" class="search-spinner" />
<Search v-else theme="outline" size="18" class="search-icon" />
<input
v-model="inputValue"
type="text"
:placeholder="$t('topbar.search.placeholder')"
class="search-field"
aria-label="Search anime"
@click="$emit('click')"
@keyup.enter="$emit('search')"
/>
<span class="search-placeholder">{{ $t('topbar.search.placeholder') }}</span>
<button
class="search-provider"
aria-label="Select search provider"
@click="$emit('select')"
>
<div class="search-provider-label">{{ provider }}</div>
<Down :size="14" />
</button>
</div>
<span class="search-provider">{{ provider }}</span>
</button>
</template>
<style lang="scss" scoped>
.search-input {
.search-trigger {
display: flex;
align-items: center;
height: 36px;
padding-left: 12px;
padding: 0 6px 0 12px;
gap: 10px;
width: 360px;
min-width: 240px;
border-radius: var(--radius-md);
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
overflow: hidden;
cursor: pointer;
font-family: inherit;
transition: border-color var(--transition-fast),
background-color var(--transition-normal);
background-color var(--transition-normal),
box-shadow var(--transition-fast);
&:focus-within {
&:hover {
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;
flex-shrink: 0;
&:focus-visible {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
@include forDesktop {
min-width: 320px;
}
}
.search-icon {
flex-shrink: 0;
color: var(--color-text-muted);
transition: color var(--transition-fast);
.search-icon-btn:hover & {
.search-trigger:hover & {
color: var(--color-primary);
}
}
.search-field {
.search-spinner {
flex-shrink: 0;
}
.search-placeholder {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
text-align: left;
font-size: 14px;
color: var(--color-text);
&::placeholder {
color: var(--color-text-muted);
}
}
.search-provider {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
height: 100%;
padding: 0 12px;
min-width: 80px;
background: var(--color-primary);
color: #fff;
border: none;
cursor: pointer;
user-select: none;
font-size: 13px;
font-family: inherit;
transition: background-color var(--transition-fast);
&:hover {
background: var(--color-primary-hover);
}
}
.search-provider-label {
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-provider {
flex-shrink: 0;
padding: 4px 10px;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: #fff;
font-size: 12px;
font-weight: 500;
transition: background-color var(--transition-fast);
.search-trigger:hover & {
background: var(--color-primary-hover);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue';
import { nextTick, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
@@ -91,8 +91,8 @@ defineExpose({ goTo });
:key="i"
class="ab-swipe-container__dot"
:class="{ 'ab-swipe-container__dot--active': currentIndex === i - 1 }"
@click="goTo(i - 1)"
:aria-label="`Go to slide ${i}`"
@click="goTo(i - 1)"
/>
</div>
</div>

View File

@@ -8,14 +8,13 @@ import {
Refresh,
} from '@icon-park/vue-next';
import { ruleTemplate } from '#/bangumi';
import type { BangumiRule } from '#/bangumi';
const { t, changeLocale } = useMyI18n();
const { running, onUpdate, offUpdate } = useAppInfo();
const showAccount = ref(false);
const showAddRSS = ref(false);
const searchRule = ref<BangumiRule>();
const rssRule = ref(ruleTemplate);
const { start, pause, shutdown, restart, resetRule } = useProgramStore();
const { refreshPoster } = useBangumiStore();
@@ -70,17 +69,9 @@ const items = [
const { isDark } = useDarkMode();
const onSearchFocus = ref(false);
function addSearchResult(bangumi: BangumiRule) {
showAddRSS.value = true;
searchRule.value = bangumi;
}
watch(showAddRSS, (val) => {
if (!val) {
searchRule.value = ruleTemplate;
setTimeout(() => {
onSearchFocus.value = false;
}, 300);
rssRule.value = ruleTemplate;
}
});
@@ -111,7 +102,7 @@ onUnmounted(() => {
</div>
<div class="topbar-search">
<ab-search-bar @add-bangumi="addSearchResult" />
<ab-search-bar />
</div>
</div>
@@ -127,7 +118,7 @@ onUnmounted(() => {
<ab-change-account v-model:show="showAccount"></ab-change-account>
<ab-add-rss
v-model:show="showAddRSS"
v-model:rule="searchRule"
v-model:rule="rssRule"
></ab-add-rss>
</div>
</template>

View File

@@ -1,24 +1,19 @@
<script lang="ts" setup>
import { ErrorPicture, Plus } from '@icon-park/vue-next';
import type { BangumiRule } from '#/bangumi';
import { ErrorPicture } from '@icon-park/vue-next';
import type { GroupedBangumi } from '@/store/search';
const props = defineProps<{
bangumi: BangumiRule;
group: GroupedBangumi;
}>();
const emit = defineEmits<{
(e: 'select', bangumi: BangumiRule): void;
(e: 'select', group: GroupedBangumi): void;
}>();
const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
const posterSrc = computed(() => resolvePosterUrl(props.group.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}` : '';
});
// Count of variants
const variantCount = computed(() => props.group.variants.length);
</script>
<template>
@@ -26,48 +21,29 @@ const seasonDisplay = computed(() => {
class="search-card"
role="button"
tabindex="0"
:aria-label="`Add ${bangumi.official_title}`"
@click="emit('select', bangumi)"
@keydown.enter="emit('select', bangumi)"
:aria-label="`View ${group.official_title}`"
@click="emit('select', group)"
@keydown.enter="emit('select', group)"
>
<!-- Poster -->
<div class="card-poster">
<template v-if="bangumi.poster_link">
<img :src="posterSrc" :alt="bangumi.official_title" loading="lazy" />
<template v-if="group.poster_link">
<img :src="posterSrc" :alt="group.official_title" loading="lazy" />
</template>
<template v-else>
<div class="card-placeholder">
<ErrorPicture theme="outline" size="24" />
<ErrorPicture theme="outline" size="32" />
</div>
</template>
<!-- Variant count badge -->
<div v-if="variantCount > 1" class="variant-badge">
{{ variantCount }}
</div>
</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>
<h3 class="card-title">{{ group.official_title }}</h3>
</div>
</div>
</template>
@@ -75,24 +51,27 @@ const seasonDisplay = computed(() => {
<style lang="scss" scoped>
.search-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
user-select: none;
overflow: hidden;
transition: all var(--transition-fast);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface-hover);
box-shadow: var(--shadow-md);
.add-btn {
.select-btn {
background: var(--color-primary);
color: #fff;
}
.card-poster img {
transform: scale(1.03);
}
}
&:focus-visible {
@@ -106,10 +85,10 @@ const seasonDisplay = computed(() => {
}
.card-poster {
width: 60px;
height: 84px;
position: relative;
width: 100%;
aspect-ratio: 5 / 7;
flex-shrink: 0;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-hover);
@@ -117,6 +96,7 @@ const seasonDisplay = computed(() => {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--transition-normal);
}
}
@@ -127,92 +107,41 @@ const seasonDisplay = computed(() => {
align-items: center;
justify-content: center;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.variant-badge {
position: absolute;
top: 8px;
right: 8px;
min-width: 24px;
height: 24px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius-full);
box-shadow: var(--shadow-md);
}
.card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
}
.card-title {
font-size: 14px;
font-size: 13px;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.4;
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

@@ -3,8 +3,6 @@ import { CheckOne, Close, Copy, Down, ErrorPicture, Right } from '@icon-park/vue
import { NDynamicTags, NSpin, useMessage } from 'naive-ui';
import type { BangumiRule } from '#/bangumi';
const message = useMessage();
const props = defineProps<{
bangumi: BangumiRule;
}>();
@@ -14,6 +12,8 @@ const emit = defineEmits<{
(e: 'cancel'): void;
}>();
const message = useMessage();
// Local deep copy of bangumi for editing (prevents mutation of original prop)
const localBangumi = ref<BangumiRule>(JSON.parse(JSON.stringify(props.bangumi)));

View File

@@ -2,10 +2,9 @@
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';
import type { GroupedBangumi } from '@/store/search';
const emit = defineEmits<{
(e: 'close'): void;
@@ -19,10 +18,7 @@ const {
provider,
loading,
inputValue,
bangumiList,
filteredResults,
filters,
filterOptions,
groupedResults,
showModal,
selectedResult,
} = storeToRefs(useSearchStore());
@@ -31,8 +27,6 @@ const {
getProviders,
onSearch,
clearSearch,
toggleFilter,
clearFilters,
selectResult,
clearSelectedResult,
} = useSearchStore();
@@ -42,6 +36,19 @@ const subscribing = ref(false);
const showProvider = ref(false);
const searchInputRef = ref<HTMLInputElement | null>(null);
// Tag filter state
const activeFilters = ref<{
group: string | null;
resolution: string | null;
subtitle: string | null;
season: string | null;
}>({
group: null,
resolution: null,
subtitle: null,
season: null,
});
// Close on Escape
onKeyStroke('Escape', () => {
if (selectedResult.value) {
@@ -59,12 +66,17 @@ onMounted(() => {
});
});
// Clear filters when search changes
watch(inputValue, () => {
activeFilters.value = { group: null, resolution: null, subtitle: null, season: null };
});
function onSelectProvider(site: string) {
provider.value = site;
showProvider.value = false;
}
function handleCardClick(bangumi: BangumiRule) {
function handleVariantSelect(bangumi: BangumiRule) {
selectResult(bangumi);
}
@@ -98,8 +110,72 @@ async function handleConfirm(bangumi: BangumiRule) {
function handleClose() {
clearSearch();
activeFilters.value = { group: null, resolution: null, subtitle: null, season: null };
emit('close');
}
// Get resolution display for variant
function getResolution(bangumi: BangumiRule): string {
return bangumi.dpi || '';
}
// Get subtitle display for variant
function getSubtitle(bangumi: BangumiRule): string {
return bangumi.subtitle || '';
}
// Get season display for variant
function getSeason(bangumi: BangumiRule): string {
if (bangumi.season_raw) return bangumi.season_raw;
if (bangumi.season) return `S${bangumi.season}`;
return '';
}
// Resolve poster URL for template
function getPosterUrl(link: string | null | undefined): string {
return resolvePosterUrl(link);
}
// Toggle filter
function toggleFilter(type: 'group' | 'resolution' | 'subtitle' | 'season', value: string) {
if (activeFilters.value[type] === value) {
activeFilters.value[type] = null;
} else {
activeFilters.value[type] = value;
}
}
// Check if variant matches active filters
function variantMatchesFilters(variant: BangumiRule): boolean {
const { group, resolution, subtitle, season } = activeFilters.value;
if (group && variant.group_name !== group) return false;
if (resolution && getResolution(variant) !== resolution) return false;
if (subtitle && getSubtitle(variant) !== subtitle) return false;
if (season && getSeason(variant) !== season) return false;
return true;
}
// Get filtered variants for a group
function getFilteredVariants(group: GroupedBangumi): BangumiRule[] {
const hasActiveFilter = Object.values(activeFilters.value).some(v => v !== null);
if (!hasActiveFilter) return group.variants;
return group.variants.filter(variantMatchesFilters);
}
// Check if tag is active
function isTagActive(type: 'group' | 'resolution' | 'subtitle' | 'season', value: string): boolean {
return activeFilters.value[type] === value;
}
// Check if any filter is active
const hasActiveFilters = computed(() => Object.values(activeFilters.value).some(v => v !== null));
// Clear all filters
function clearFilters() {
activeFilters.value = { group: null, resolution: null, subtitle: null, season: null };
}
</script>
<template>
@@ -167,43 +243,92 @@ function handleClose() {
</button>
</header>
<!-- Filters -->
<AbSearchFilters
:filters="filters"
:filter-options="filterOptions"
:filtered-count="filteredResults.length"
:total-count="bangumiList.length"
@toggle-filter="toggleFilter"
@clear-filters="clearFilters"
/>
<!-- Active Filters Bar -->
<div v-if="hasActiveFilters" class="active-filters">
<span class="filter-label">{{ $t('search.filter.active') }}:</span>
<span v-if="activeFilters.group" class="filter-tag filter-tag-group" @click="toggleFilter('group', activeFilters.group)">
{{ activeFilters.group }} ×
</span>
<span v-if="activeFilters.resolution" class="filter-tag filter-tag-res" @click="toggleFilter('resolution', activeFilters.resolution)">
{{ activeFilters.resolution }} ×
</span>
<span v-if="activeFilters.subtitle" class="filter-tag filter-tag-sub" @click="toggleFilter('subtitle', activeFilters.subtitle)">
{{ activeFilters.subtitle }} ×
</span>
<span v-if="activeFilters.season" class="filter-tag filter-tag-season" @click="toggleFilter('season', activeFilters.season)">
{{ activeFilters.season }} ×
</span>
<button class="clear-filters-btn" @click="clearFilters">{{ $t('search.filter.clear') }}</button>
</div>
<!-- Results Grid -->
<!-- Results List -->
<div class="results-container">
<!-- Empty state -->
<div v-if="!loading && filteredResults.length === 0 && inputValue" class="empty-state">
<div v-if="!loading && groupedResults.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">
<div v-else-if="!inputValue && groupedResults.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 }"
@select="handleCardClick"
/>
</transition-group>
<!-- Bangumi list -->
<div v-else class="bangumi-list">
<template v-for="group in groupedResults" :key="group.key">
<div
v-if="getFilteredVariants(group).length > 0"
class="bangumi-row"
>
<!-- Left: Poster -->
<div class="bangumi-poster">
<img
v-if="group.poster_link"
:src="getPosterUrl(group.poster_link)"
:alt="group.official_title"
/>
<div v-else class="bangumi-poster-placeholder">
<span class="placeholder-title">{{ group.official_title }}</span>
</div>
</div>
<!-- Right: Variant Grid -->
<div class="bangumi-variants">
<div
v-for="variant in getFilteredVariants(group)"
:key="variant.rss_link?.[0] || variant.title_raw"
class="variant-chip"
@click.stop="handleVariantSelect(variant)"
>
<span
v-if="variant.group_name"
class="chip-tag chip-tag-group"
:class="{ active: isTagActive('group', variant.group_name) }"
@click.stop="toggleFilter('group', variant.group_name)"
>{{ variant.group_name }}</span>
<span
v-if="getResolution(variant)"
class="chip-tag chip-tag-res"
:class="{ active: isTagActive('resolution', getResolution(variant)) }"
@click.stop="toggleFilter('resolution', getResolution(variant))"
>{{ getResolution(variant) }}</span>
<span
v-if="getSubtitle(variant)"
class="chip-tag chip-tag-sub"
:class="{ active: isTagActive('subtitle', getSubtitle(variant)) }"
@click.stop="toggleFilter('subtitle', getSubtitle(variant))"
>{{ getSubtitle(variant) }}</span>
<span
v-if="getSeason(variant)"
class="chip-tag chip-tag-season"
:class="{ active: isTagActive('season', getSeason(variant)) }"
@click.stop="toggleFilter('season', getSeason(variant))"
>{{ getSeason(variant) }}</span>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
@@ -414,24 +539,6 @@ function handleClose() {
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;
@@ -460,22 +567,201 @@ function handleClose() {
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);
// Bangumi list
.bangumi-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-leave-active {
transition: opacity 150ms ease-in;
.bangumi-row {
display: flex;
gap: 16px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.card-enter-from {
opacity: 0;
transform: translateY(8px);
.bangumi-poster {
width: 100px;
flex-shrink: 0;
@include forDesktop {
width: 120px;
}
img {
width: 100%;
aspect-ratio: 5 / 7;
object-fit: cover;
border-radius: var(--radius-md);
background: var(--color-surface-hover);
}
}
.card-leave-to {
opacity: 0;
.bangumi-poster-placeholder {
width: 100%;
aspect-ratio: 5 / 7;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: var(--radius-md);
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
}
.placeholder-title {
font-size: 11px;
font-weight: 500;
color: var(--color-text-muted);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
line-height: 1.3;
}
.bangumi-variants {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
}
.variant-chip {
display: flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 10px;
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
border-color: var(--color-primary);
background: var(--color-primary);
.chip-tag {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
}
}
.chip-tag {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
font-size: 11px;
font-weight: 500;
border-radius: var(--radius-sm);
white-space: nowrap;
transition: all var(--transition-fast);
}
.chip-tag-group {
background: var(--color-primary-light);
color: var(--color-primary);
}
.chip-tag-res {
background: rgba(34, 197, 94, 0.15);
color: var(--color-success);
}
.chip-tag-sub {
background: rgba(249, 115, 22, 0.15);
color: var(--color-accent);
}
.chip-tag-season {
background: rgba(139, 92, 246, 0.15);
color: rgb(139, 92, 246);
}
// Active filters bar
.active-filters {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--color-surface-hover);
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.filter-label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
}
.filter-tag {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: opacity var(--transition-fast);
&:hover {
opacity: 0.8;
}
}
.filter-tag-group {
background: var(--color-primary);
color: #fff;
}
.filter-tag-res {
background: var(--color-success);
color: #fff;
}
.filter-tag-sub {
background: var(--color-accent);
color: #fff;
}
.filter-tag-season {
background: rgb(139, 92, 246);
color: #fff;
}
.clear-filters-btn {
margin-left: auto;
padding: 4px 10px;
font-size: 12px;
font-family: inherit;
color: var(--color-text-muted);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
border-color: var(--color-danger);
color: var(--color-danger);
}
}
// Active tag highlight
.chip-tag.active {
box-shadow: 0 0 0 2px var(--color-primary);
}
</style>

View File

@@ -81,10 +81,10 @@ const azureItems: SettingItem<ExperimentalOpenAI>[] = [
</div>
<ab-setting
v-model:data="openAI.enable"
config-key="enable"
:label="() => t('config.experimental_openai_set.enable')"
type="switch"
v-model:data="openAI.enable"
/>
<transition name="slide-fade">

View File

@@ -44,7 +44,7 @@ export const useMyI18n = createSharedComposable(() => {
function returnUserLangText(texts: {
[k in Languages]: string;
}) {
return texts[lang.value] ?? texts['en'];
return texts[lang.value] ?? texts.en;
}
function returnUserLangMsg(res: ApiSuccess) {

View File

@@ -333,9 +333,11 @@
},
"common": {
"cancel": "Cancel",
"confirm": "Confirm"
"confirm": "Confirm",
"select": "Select"
},
"search": {
"subscribe": "Subscribe",
"no_results": "No results found. Try different keywords.",
"start_typing": "Enter keywords to start searching",
"filter": {
@@ -344,7 +346,8 @@
"subtitle_type": "Subtitle",
"season": "Season",
"clear": "Clear",
"results": "results"
"results": "results",
"active": "Filtering"
},
"confirm": {
"title": "Add Subscription",
@@ -352,8 +355,11 @@
"group": "Subtitle Group",
"resolution": "Resolution",
"subtitle": "Subtitle Type",
"season": "Season",
"filter": "Filter Rules",
"filter_hint": "Regex patterns to exclude unwanted torrents",
"save_path": "Save Path",
"save_path_placeholder": "Leave empty for default path",
"advanced": "Advanced Settings",
"subscribe": "Subscribe"
}

View File

@@ -333,9 +333,11 @@
},
"common": {
"cancel": "取消",
"confirm": "确认"
"confirm": "确认",
"select": "选择"
},
"search": {
"subscribe": "订阅",
"no_results": "未找到相关结果,试试其他关键词",
"start_typing": "输入关键词开始搜索",
"filter": {
@@ -344,7 +346,8 @@
"subtitle_type": "字幕语言",
"season": "季度",
"clear": "清除筛选",
"results": "个结果"
"results": "个结果",
"active": "筛选中"
},
"confirm": {
"title": "添加订阅",
@@ -352,8 +355,11 @@
"group": "字幕组",
"resolution": "分辨率",
"subtitle": "字幕类型",
"season": "季度",
"filter": "过滤规则",
"filter_hint": "用于排除不需要的种子,支持正则表达式",
"save_path": "保存路径",
"save_path_placeholder": "留空使用默认路径",
"advanced": "高级设置",
"subscribe": "确认订阅"
}

View File

@@ -374,7 +374,7 @@ function onRuleSelect(rule: BangumiRule) {
.archived-badge {
position: absolute;
bottom: 4px;
bottom: 36px; // Above the card title area
left: 4px;
padding: 2px 6px;
border-radius: 4px;

View File

@@ -1,5 +1,5 @@
<script lang="tsx" setup>
import { NDataTable, NTooltip, type DataTableColumns } from 'naive-ui';
import { type DataTableColumns, NDataTable, NTooltip } from 'naive-ui';
import type { RSS } from '#/rss';
definePage({

View File

@@ -15,6 +15,15 @@ export interface FilterOptions {
season: string[];
}
// Grouped bangumi result
export interface GroupedBangumi {
key: string;
official_title: string;
poster_link: string;
year?: string | null;
variants: BangumiRule[];
}
// Helper to parse metadata from title/bangumi
function parseResolution(bangumi: BangumiRule): string {
// Check dpi field first
@@ -81,9 +90,10 @@ export const useSearchStore = defineStore('search', () => {
// Modal state
const showModal = ref(false);
const selectedResult = ref<BangumiRule | null>(null);
const selectedGroup = ref<GroupedBangumi | null>(null);
// Filter state
const filters = ref<SearchFilters>({
// Filter state for selected group variants
const variantFilters = ref<SearchFilters>({
group: [],
resolution: [],
subtitleType: [],
@@ -97,14 +107,46 @@ export const useSearchStore = defineStore('search', () => {
searchData.value.map((item, index) => ({ order: index, value: item }))
);
// Extract filter options from results
const filterOptions = computed<FilterOptions>(() => {
// Group results by official_title
const groupedResults = computed<GroupedBangumi[]>(() => {
const map = new Map<string, BangumiRule[]>();
for (const item of searchData.value) {
const key = item.official_title || item.title_raw || '';
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)!.push(item);
}
const groups: GroupedBangumi[] = [];
for (const [key, variants] of map) {
// Use the first variant's poster and info
const first = variants[0];
groups.push({
key,
official_title: first.official_title || first.title_raw || '',
poster_link: first.poster_link || '',
year: first.year,
variants,
});
}
return groups;
});
// Extract filter options from selected group's variants
const variantFilterOptions = computed<FilterOptions>(() => {
if (!selectedGroup.value) {
return { group: [], resolution: [], subtitleType: [], season: [] };
}
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) {
for (const item of selectedGroup.value.variants) {
if (item.group_name) groups.add(item.group_name);
const res = parseResolution(item);
@@ -120,7 +162,6 @@ export const useSearchStore = defineStore('search', () => {
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());
}),
@@ -129,43 +170,43 @@ export const useSearchStore = defineStore('search', () => {
};
});
// Filtered results
const filteredResults = computed<SearchResult[]>(() => {
const hasFilters = Object.values(filters.value).some((arr) => arr.length > 0);
// Filtered variants based on selected filters
const filteredVariants = computed<BangumiRule[]>(() => {
if (!selectedGroup.value) return [];
const hasFilters = Object.values(variantFilters.value).some((arr) => arr.length > 0);
if (!hasFilters) {
return bangumiList.value;
return selectedGroup.value.variants;
}
return bangumiList.value.filter((item) => {
const bangumi = item.value;
return selectedGroup.value.variants.filter((bangumi) => {
// Group filter
if (filters.value.group.length > 0) {
if (!bangumi.group_name || !filters.value.group.includes(bangumi.group_name)) {
if (variantFilters.value.group.length > 0) {
if (!bangumi.group_name || !variantFilters.value.group.includes(bangumi.group_name)) {
return false;
}
}
// Resolution filter
if (filters.value.resolution.length > 0) {
if (variantFilters.value.resolution.length > 0) {
const res = parseResolution(bangumi);
if (!res || !filters.value.resolution.includes(res)) {
if (!res || !variantFilters.value.resolution.includes(res)) {
return false;
}
}
// Subtitle type filter
if (filters.value.subtitleType.length > 0) {
if (variantFilters.value.subtitleType.length > 0) {
const subType = parseSubtitleType(bangumi);
if (!subType || !filters.value.subtitleType.includes(subType)) {
if (!subType || !variantFilters.value.subtitleType.includes(subType)) {
return false;
}
}
// Season filter
if (filters.value.season.length > 0) {
if (variantFilters.value.season.length > 0) {
const season = parseSeason(bangumi);
if (!season || !filters.value.season.includes(season)) {
if (!season || !variantFilters.value.season.includes(season)) {
return false;
}
}
@@ -199,12 +240,13 @@ export const useSearchStore = defineStore('search', () => {
function clearSearch() {
keyword.value = '';
searchData.value = [];
filters.value = { group: [], resolution: [], subtitleType: [], season: [] };
variantFilters.value = { group: [], resolution: [], subtitleType: [], season: [] };
selectedGroup.value = null;
closeSearch();
}
function toggleFilter(category: keyof SearchFilters, value: string) {
const arr = filters.value[category];
function toggleVariantFilter(category: keyof SearchFilters, value: string) {
const arr = variantFilters.value[category];
const index = arr.indexOf(value);
if (index === -1) {
arr.push(value);
@@ -213,8 +255,18 @@ export const useSearchStore = defineStore('search', () => {
}
}
function clearFilters() {
filters.value = { group: [], resolution: [], subtitleType: [], season: [] };
function clearVariantFilters() {
variantFilters.value = { group: [], resolution: [], subtitleType: [], season: [] };
}
function selectGroup(group: GroupedBangumi) {
selectedGroup.value = group;
variantFilters.value = { group: [], resolution: [], subtitleType: [], season: [] };
}
function clearSelectedGroup() {
selectedGroup.value = null;
variantFilters.value = { group: [], resolution: [], subtitleType: [], season: [] };
}
function selectResult(bangumi: BangumiRule) {
@@ -232,9 +284,11 @@ export const useSearchStore = defineStore('search', () => {
provider,
providers,
bangumiList,
filteredResults,
filters,
filterOptions,
groupedResults,
selectedGroup,
variantFilters,
variantFilterOptions,
filteredVariants,
showModal,
selectedResult,
@@ -246,8 +300,10 @@ export const useSearchStore = defineStore('search', () => {
openModal,
closeModal,
toggleModal,
toggleFilter,
clearFilters,
toggleVariantFilter,
clearVariantFilters,
selectGroup,
clearSelectedGroup,
selectResult,
clearSelectedResult,
};

View File

@@ -31,6 +31,10 @@ declare module '@vue/runtime-core' {
AbRule: typeof import('./../../src/components/ab-rule.vue')['default']
AbSearch: typeof import('./../../src/components/basic/ab-search.vue')['default']
AbSearchBar: typeof import('./../../src/components/ab-search-bar.vue')['default']
AbSearchCard: typeof import('./../../src/components/search/ab-search-card.vue')['default']
AbSearchConfirm: typeof import('./../../src/components/search/ab-search-confirm.vue')['default']
AbSearchFilters: typeof import('./../../src/components/search/ab-search-filters.vue')['default']
AbSearchModal: typeof import('./../../src/components/search/ab-search-modal.vue')['default']
AbSelect: typeof import('./../../src/components/basic/ab-select.vue')['default']
AbSetting: typeof import('./../../src/components/ab-setting.vue')['default']
AbSidebar: typeof import('./../../src/components/layout/ab-sidebar.vue')['default']

View File

@@ -15,7 +15,7 @@ export const rssTemplate: RSS = {
name: '',
url: '',
aggregate: false,
parser: '',
parser: 'tmdb',
enabled: false,
connection_status: null,
last_checked_at: null,