mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-06-15 06:27:53 +08:00
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:
36
CHANGELOG.md
36
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
389
webui/src/components/setting/config-search-provider.vue
Normal file
389
webui/src/components/setting/config-search-provider.vue
Normal 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>
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user