feat(webui): add search provider settings panel

- Add config-search-provider.vue component for managing search sources
- Support CRUD operations for custom search providers
- Default providers (mikan, nyaa, dmhy) cannot be deleted
- URL template validation ensures %s placeholder is present
- Add backend API endpoints GET/PUT /search/provider/config
- Add i18n translations for zh-CN and en

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Estrella Pan
2026-01-25 22:27:33 +01:00
parent 5e0efc01b9
commit a0a21a71e5
7 changed files with 490 additions and 0 deletions

View File

@@ -1,3 +1,39 @@
# [3.2.0-beta.10] - 2026-01-25
## Backend
### Features
- 新增季度/集数偏移自动检测功能
- 通过分析 TMDB 剧集播出日期检测「虚拟季度」(如芙莉莲第一季分两部分播出)
- 当播出间隔超过6个月时自动识别为不同部分
- 自动计算集数偏移量(如 RSS 显示 S2E1 → TMDB S1E29
- 新增后台扫描线程,自动检测已有订阅的偏移问题
- 新增搜索源配置 API 端点:
- `GET /search/provider/config` - 获取搜索源配置
- `PUT /search/provider/config` - 更新搜索源配置
- 新增 API 端点:
- `POST /bangumi/detect-offset` - 检测季度/集数偏移
- `PATCH /bangumi/dismiss-review/{id}` - 忽略偏移检查提醒
- 数据库新增 `needs_review``needs_review_reason` 字段
## Frontend
### Features
- 新增搜索源设置面板
- 支持查看、添加、编辑、删除搜索源
- 默认搜索源mikan、nyaa、dmhy不可删除
- URL 模板验证,确保包含 `%s` 占位符
- 新增 iOS 风格通知角标系统
- 黄色角标 + 紫色边框显示需要检查的订阅
- 支持组合显示(如 `! | 2` 表示有警告且有多个规则)
- 卡片黄色发光动画提示需要注意
- 编辑弹窗新增警告横幅,支持一键自动检测和忽略
- 规则选择弹窗高亮显示有警告的规则
---
# [3.2.0-beta.8] - 2026-01-25
## Backend

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, Query
from sse_starlette.sse import EventSourceResponse
from module.conf.search_provider import get_provider, save_provider
from module.models import Bangumi
from module.searcher import SEARCH_CONFIG, SearchTorrent
from module.security.api import UNAUTHORIZED, get_current_user
@@ -32,3 +33,24 @@ async def search_torrents(site: str = "mikan", keywords: str = Query(None)):
)
async def search_provider():
return list(SEARCH_CONFIG.keys())
@router.get(
"/provider/config",
response_model=dict[str, str],
dependencies=[Depends(get_current_user)],
)
async def get_search_provider_config():
"""Get all search providers with their URL templates."""
return get_provider()
@router.put(
"/provider/config",
response_model=dict[str, str],
dependencies=[Depends(get_current_user)],
)
async def update_search_provider_config(providers: dict[str, str]):
"""Update search providers configuration."""
save_provider(providers)
return get_provider()

View File

@@ -19,4 +19,16 @@ def load_provider():
return DEFAULT_PROVIDER
def save_provider(providers: dict[str, str]):
"""Save search providers to config file and update SEARCH_CONFIG."""
global SEARCH_CONFIG
json_config.save(PROVIDER_PATH, providers)
SEARCH_CONFIG = providers
def get_provider():
"""Get current search providers config."""
return SEARCH_CONFIG
SEARCH_CONFIG = load_provider()

View File

@@ -0,0 +1,389 @@
<script lang="ts" setup>
import { Delete, EditTwo, Plus } from '@icon-park/vue-next';
interface SearchProvider {
name: string;
url: string;
}
const { t } = useMyI18n();
// State
const providers = ref<SearchProvider[]>([]);
const loading = ref(false);
const showAddDialog = ref(false);
const showEditDialog = ref(false);
const editingProvider = ref<SearchProvider | null>(null);
const editingIndex = ref<number>(-1);
// Form state
const formName = ref('');
const formUrl = ref('');
// Default providers that cannot be deleted
const defaultProviderNames = ['mikan', 'nyaa', 'dmhy'];
onMounted(() => {
loadProviders();
});
async function loadProviders() {
loading.value = true;
try {
const response = await fetch('/api/v1/search/provider/config');
if (response.ok) {
const data = await response.json();
providers.value = Object.entries(data).map(([name, url]) => ({
name,
url: url as string,
}));
}
} catch (error) {
console.error('Failed to load providers:', error);
} finally {
loading.value = false;
}
}
async function saveProviders() {
const providerObj: Record<string, string> = {};
providers.value.forEach((p) => {
providerObj[p.name] = p.url;
});
try {
const response = await fetch('/api/v1/search/provider/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(providerObj),
});
if (!response.ok) {
throw new Error('Failed to save providers');
}
} catch (error) {
console.error('Failed to save providers:', error);
}
}
function openAddDialog() {
formName.value = '';
formUrl.value = '';
showAddDialog.value = true;
}
function openEditDialog(provider: SearchProvider, index: number) {
editingProvider.value = provider;
editingIndex.value = index;
formName.value = provider.name;
formUrl.value = provider.url;
showEditDialog.value = true;
}
async function handleAdd() {
if (!formName.value.trim() || !formUrl.value.trim()) return;
// Check for duplicate name
if (providers.value.some((p) => p.name === formName.value.trim())) {
return;
}
providers.value.push({
name: formName.value.trim(),
url: formUrl.value.trim(),
});
await saveProviders();
showAddDialog.value = false;
formName.value = '';
formUrl.value = '';
}
async function handleEdit() {
if (!formName.value.trim() || !formUrl.value.trim()) return;
if (editingIndex.value < 0) return;
// Check for duplicate name (excluding current)
const duplicateIndex = providers.value.findIndex(
(p, i) => p.name === formName.value.trim() && i !== editingIndex.value
);
if (duplicateIndex !== -1) return;
providers.value[editingIndex.value] = {
name: formName.value.trim(),
url: formUrl.value.trim(),
};
await saveProviders();
showEditDialog.value = false;
editingProvider.value = null;
editingIndex.value = -1;
formName.value = '';
formUrl.value = '';
}
async function handleDelete(index: number) {
const provider = providers.value[index];
if (defaultProviderNames.includes(provider.name)) {
return;
}
if (!confirm(t('config.search_provider_set.delete_confirm'))) return;
providers.value.splice(index, 1);
await saveProviders();
}
function isDefaultProvider(name: string): boolean {
return defaultProviderNames.includes(name);
}
function validateUrl(url: string): boolean {
return url.includes('%s');
}
</script>
<template>
<ab-fold-panel :title="$t('config.search_provider_set.title')">
<div space-y-8>
<!-- Loading state -->
<div v-if="loading" text-gray-500 text-14>
{{ $t('passkey.loading') }}
</div>
<!-- Empty state -->
<div v-else-if="providers.length === 0" text-gray-500 text-14>
{{ $t('config.search_provider_set.no_providers') }}
</div>
<!-- Provider list -->
<div v-else space-y-8>
<div
v-for="(provider, index) in providers"
:key="provider.name"
class="provider-item"
>
<div class="provider-info">
<div class="provider-name">
{{ provider.name }}
<span v-if="isDefaultProvider(provider.name)" class="default-badge">
{{ $t('config.search_provider_set.default') }}
</span>
</div>
<div class="provider-url" :title="provider.url">
{{ provider.url }}
</div>
</div>
<div class="provider-actions">
<ab-button
size="small"
type="secondary"
@click="openEditDialog(provider, index)"
>
<EditTwo size="16" />
</ab-button>
<ab-button
v-if="!isDefaultProvider(provider.name)"
size="small"
type="warn"
@click="handleDelete(index)"
>
<Delete size="16" />
</ab-button>
</div>
</div>
</div>
<div line></div>
<!-- Hint text -->
<div class="hint-text">
{{ $t('config.search_provider_set.url_hint') }}
</div>
<!-- Add button -->
<div flex="~ justify-end">
<ab-button size="small" type="primary" @click="openAddDialog">
<Plus size="16" />
{{ $t('config.search_provider_set.add_new') }}
</ab-button>
</div>
</div>
<!-- Add dialog -->
<ab-popup
v-model:show="showAddDialog"
:title="$t('config.search_provider_set.add_title')"
css="w-400"
>
<div space-y-16>
<ab-label :label="$t('config.search_provider_set.name')">
<input
v-model="formName"
type="text"
:placeholder="$t('config.search_provider_set.name_placeholder')"
ab-input
maxlength="32"
/>
</ab-label>
<ab-label :label="$t('config.search_provider_set.url')">
<input
v-model="formUrl"
type="text"
:placeholder="$t('config.search_provider_set.url_placeholder')"
ab-input
@keyup.enter="handleAdd"
/>
</ab-label>
<div
v-if="formUrl && !validateUrl(formUrl)"
class="validation-warning"
>
{{ $t('config.search_provider_set.url_missing_placeholder') }}
</div>
<div line></div>
<div flex="~ justify-end gap-8">
<ab-button size="small" type="warn" @click="showAddDialog = false">
{{ $t('config.cancel') }}
</ab-button>
<ab-button
size="small"
type="primary"
:disabled="!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)"
@click="handleAdd"
>
{{ $t('config.apply') }}
</ab-button>
</div>
</div>
</ab-popup>
<!-- Edit dialog -->
<ab-popup
v-model:show="showEditDialog"
:title="$t('config.search_provider_set.edit_title')"
css="w-400"
>
<div space-y-16>
<ab-label :label="$t('config.search_provider_set.name')">
<input
v-model="formName"
type="text"
:placeholder="$t('config.search_provider_set.name_placeholder')"
ab-input
maxlength="32"
:disabled="editingProvider !== null && isDefaultProvider(editingProvider.name)"
/>
</ab-label>
<ab-label :label="$t('config.search_provider_set.url')">
<input
v-model="formUrl"
type="text"
:placeholder="$t('config.search_provider_set.url_placeholder')"
ab-input
@keyup.enter="handleEdit"
/>
</ab-label>
<div
v-if="formUrl && !validateUrl(formUrl)"
class="validation-warning"
>
{{ $t('config.search_provider_set.url_missing_placeholder') }}
</div>
<div line></div>
<div flex="~ justify-end gap-8">
<ab-button size="small" type="warn" @click="showEditDialog = false">
{{ $t('config.cancel') }}
</ab-button>
<ab-button
size="small"
type="primary"
:disabled="!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)"
@click="handleEdit"
>
{{ $t('config.apply') }}
</ab-button>
</div>
</div>
</ab-popup>
</ab-fold-panel>
</template>
<style lang="scss" scoped>
.provider-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: var(--color-surface-elevated, #f9fafb);
border-radius: 8px;
transition: background-color var(--transition-normal);
:root.dark & {
background: var(--color-surface-elevated, #1f2937);
}
}
.provider-info {
flex: 1;
min-width: 0;
}
.provider-name {
font-weight: 500;
font-size: 14px;
color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
}
.default-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: var(--color-primary);
color: white;
opacity: 0.8;
}
.provider-url {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
.provider-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.hint-text {
font-size: 12px;
color: var(--color-text-secondary);
line-height: 1.5;
}
.validation-warning {
font-size: 12px;
color: var(--color-danger, #ef4444);
padding: 8px 12px;
background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent);
border-radius: 6px;
}
</style>

View File

@@ -67,6 +67,21 @@
"title": "Proxy Setting",
"type": "Proxy Type",
"username": "Username"
},
"search_provider_set": {
"title": "Search Provider",
"add_new": "Add Provider",
"add_title": "Add Search Provider",
"edit_title": "Edit Search Provider",
"name": "Name",
"name_placeholder": "e.g., mikan",
"url": "URL Template",
"url_placeholder": "https://example.com/search?q=%s",
"url_hint": "Use %s as placeholder for search keywords in the URL",
"url_missing_placeholder": "URL must contain %s as search keyword placeholder",
"default": "Default",
"no_providers": "No search providers configured",
"delete_confirm": "Are you sure you want to delete this provider?"
}
},
"downloader": {

View File

@@ -67,6 +67,21 @@
"title": "代理设置",
"type": "类型",
"username": "用户名"
},
"search_provider_set": {
"title": "搜索源设置",
"add_new": "添加搜索源",
"add_title": "添加搜索源",
"edit_title": "编辑搜索源",
"name": "名称",
"name_placeholder": "例如mikan",
"url": "URL 模板",
"url_placeholder": "https://example.com/search?q=%s",
"url_hint": "URL 中使用 %s 作为搜索关键词的占位符",
"url_missing_placeholder": "URL 必须包含 %s 作为搜索关键词占位符",
"default": "默认",
"no_providers": "暂无搜索源",
"delete_confirm": "确定删除此搜索源?"
}
},
"downloader": {

View File

@@ -24,6 +24,7 @@ onActivated(() => {
<div class="config-col">
<config-notification></config-notification>
<config-proxy></config-proxy>
<config-search-provider></config-search-provider>
<config-player></config-player>
<config-openai></config-openai>
<config-passkey></config-passkey>