From ff1425137152e157075eca84d2d428009b4eec82 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Mon, 18 May 2026 02:57:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE=E4=BF=A1=E6=81=AF=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=99=BA=E8=83=BD=E5=88=86=E7=B1=BB=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Media 类型添加 languages/countries/genres 字段 - MediaDetailPage 显示元数据 badges(Languages/Countries/Genres) - ToolsPage OrganizePanel 显示智能分类启用状态 - Badge 组件支持 className prop - 添加 parseCSV 辅助函数处理逗号分隔字符串 --- internal/service/api_config.go | 16 +++- web/src/pages/DiscoverPage.tsx | 151 ++++++++++++++++++++++-------- web/src/pages/MediaDetailPage.tsx | 19 +++- web/src/pages/ToolsPage.tsx | 37 +++++++- web/src/types/index.ts | 3 + 5 files changed, 181 insertions(+), 45 deletions(-) diff --git a/internal/service/api_config.go b/internal/service/api_config.go index fa254f4..a8ce618 100644 --- a/internal/service/api_config.go +++ b/internal/service/api_config.go @@ -113,15 +113,25 @@ type Resolved struct { // API key. Callers can use Resolved.APIKey != "" as the "configured" check. func (s *APIConfigService) Resolve(ctx context.Context, provider string) (Resolved, error) { row, err := s.findByProvider(ctx, provider) - if err != nil || row == nil { + if err != nil { + s.log.Warn("api_config.resolve: query failed", zap.String("provider", provider), zap.Error(err)) return Resolved{}, err } - return Resolved{ + if row == nil { + s.log.Warn("api_config.resolve: provider not found", zap.String("provider", provider)) + return Resolved{}, nil + } + resolved := Resolved{ APIKey: s.crypto.Decrypt(row.APIKey), BaseURL: row.BaseURL, Extra: row.Extra, Enabled: row.Enabled, - }, nil + } + s.log.Debug("api_config.resolve: success", + zap.String("provider", provider), + zap.Bool("has_key", resolved.APIKey != ""), + zap.Bool("enabled", resolved.Enabled)) + return resolved, nil } // Update upserts a single provider's config. An empty patch.APIKey leaves diff --git a/web/src/pages/DiscoverPage.tsx b/web/src/pages/DiscoverPage.tsx index d82de2d..87ba641 100644 --- a/web/src/pages/DiscoverPage.tsx +++ b/web/src/pages/DiscoverPage.tsx @@ -1,63 +1,114 @@ import { useEffect, useState } from 'react' -import { Sparkles } from 'lucide-react' +import { Sparkles, AlertTriangle, ExternalLink } from 'lucide-react' +import { Link } from 'react-router-dom' import { discoverAPI, type DiscoverItem } from '../api/discover' import { imageURL } from '../api/client' -// DiscoverPage shows TMDb trending + popular rails. Rows are empty when -// no TMDb API key is configured (the backend simply returns []). export function DiscoverPage() { const [trending, setTrending] = useState([]) const [popular, setPopular] = useState([]) const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) useEffect(() => { + setLoading(true) + setError(null) + Promise.all([ - discoverAPI.trending().catch(() => [] as DiscoverItem[]), - discoverAPI.popular().catch(() => [] as DiscoverItem[]), + discoverAPI.trending().catch((err) => { + console.error('Failed to fetch trending:', err) + return [] as DiscoverItem[] + }), + discoverAPI.popular().catch((err) => { + console.error('Failed to fetch popular:', err) + return [] as DiscoverItem[] + }), ]) .then(([t, p]) => { setTrending(t) setPopular(p) }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to load discover data') + }) .finally(() => setLoading(false)) }, []) + // Check if TMDB API key is likely not configured + const isTMDBMissing = !loading && trending.length === 0 && popular.length === 0 && !error + return ( -
-
- +
+ {/* Header */} +
+
+ +
-

发现

-

- 来自 TMDb 的当日热门与流行榜单(需在外部 API 中配置 TMDb API Key)。 +

发现

+

+ 来自 TMDb 的当日热门与流行榜单

- {loading &&

加载中…

} + {/* Error Alert */} + {error && ( +
+ +

{error}

+
+ )} - {trending.length > 0 && } - {popular.length > 0 && } - {!loading && trending.length === 0 && popular.length === 0 && ( -
-

- 还没有可用的发现数据。请前往 管理后台 → 外部API - ,配置 TMDb API Key 后刷新页面。 + {/* Loading State */} + {loading && } + + {/* TMDB API Key Missing */} + {isTMDBMissing && ( +

+
+ +
+

TMDb API Key 未配置

+

+ 您需要配置 TMDb API Key 才能查看发现内容。请前往管理后台配置。

+ + 前往管理后台 + + +
+ )} + + {/* Content Rows */} + {!loading && !isTMDBMissing && ( +
+ {trending.length > 0 && } + {popular.length > 0 && } + + {/* Empty State (TMDB configured but no data) */} + {trending.length === 0 && popular.length === 0 && ( +
+

暂无发现内容

+
+ )}
)}
) } -function Row({ title, items }: { title: string; items: DiscoverItem[] }) { +function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) { return ( -
-

{title}

-
- {items.map((m) => ( - +
+

{title}

+
+ {items.map((item) => ( + ))}
@@ -66,33 +117,57 @@ function Row({ title, items }: { title: string; items: DiscoverItem[] }) { function DiscoverCard({ item }: { item: DiscoverItem }) { return ( -
-
+
+ {/* Poster */} +
{item.poster_url ? ( {item.title} ) : (
无海报
)} + + {/* Rating Badge */} + {item.rating > 0 && ( +
+ ★ {item.rating.toFixed(1)} +
+ )}
-
-

{item.title}

-
- {item.year > 0 && {item.year}} - {item.rating > 0 && ( - - ★ {item.rating.toFixed(1)} - - )} -
+ + {/* Info */} +
+

+ {item.title} +

+ {item.year > 0 && ( +

{item.year}

+ )}
) } + +function DiscoverSkeleton() { + return ( +
+ {[1, 2].map((section) => ( +
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+
+ ))} +
+ ) +} diff --git a/web/src/pages/MediaDetailPage.tsx b/web/src/pages/MediaDetailPage.tsx index ff5ca19..4dcf359 100644 --- a/web/src/pages/MediaDetailPage.tsx +++ b/web/src/pages/MediaDetailPage.tsx @@ -30,6 +30,11 @@ function fmtSize(bytes: number): string { return `${v.toFixed(2)} ${units[i]}` } +function parseCSV(s?: string): string[] { + if (!s) return [] + return s.split(',').map(x => x.trim()).filter(Boolean) +} + // Detail screen for a single media item. // // Buttons: @@ -151,6 +156,15 @@ export function MediaDetailPage() { {fmtSize(media.size_bytes)} {media.container && {media.container.toUpperCase()}} {media.scrape_status} + {parseCSV(media.languages).map(l => ( + {l} + ))} + {parseCSV(media.countries).map(c => ( + {c} + ))} + {parseCSV(media.genres).map(g => ( + {g} + ))}
@@ -201,9 +215,10 @@ export function MediaDetailPage() { ) } -function Badge({ children }: { children: React.ReactNode }) { +function Badge({ children, className = '' }: { children: React.ReactNode; className?: string }) { + const base = "rounded border border-white/10 bg-white/5 px-2 py-0.5" return ( - + {children} ) diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx index da5d6df..f719d24 100644 --- a/web/src/pages/ToolsPage.tsx +++ b/web/src/pages/ToolsPage.tsx @@ -1,10 +1,11 @@ import { FormEvent, useEffect, useState } from 'react' -import { Bell, FolderCog, Loader2, Search, Wrench } from 'lucide-react' +import { Bell, FolderCog, Info, Loader2, Search, Wrench } from 'lucide-react' import toast from 'react-hot-toast' +import { adminAPI } from '../api/admin' import { libraryAPI, mediaAPI } from '../api/library' import { toolsAPI } from '../api/tools' -import type { Library, Media } from '../types' +import type { Library, Media, Setting } from '../types' // ToolsPage gathers admin-only one-off operations that don't belong on a // dedicated screen of their own: @@ -44,10 +45,30 @@ function OrganizePanel() { const [results, setResults] = useState([]) const [busyID, setBusyID] = useState(null) + const [smartClassify, setSmartClassify] = useState(false) + const [loadingSettings, setLoadingSettings] = useState(true) + useEffect(() => { libraryAPI.list().then(setLibraries).catch(() => undefined) }, []) + useEffect(() => { + const loadSettings = async () => { + try { + const settings = await adminAPI.listSettings() + const setting = (settings as Setting[]).find(s => s.key === 'organizer.smart_classify') + if (setting) { + setSmartClassify(setting.value === 'true' || setting.value === '1' || setting.value === 'on') + } + } catch { + // ignore + } finally { + setLoadingSettings(false) + } + } + loadSettings() + }, []) + const onOrganizeLibrary = async (e: FormEvent) => { e.preventDefault() if (!libraryID) return @@ -105,6 +126,18 @@ function OrganizePanel() { 的规范布局移动并重命名,确保已先完成刮削。

+ {!loadingSettings && ( +
+ + + 智能分类:{smartClassify ? '已启用' : '未启用'} + + {smartClassify && ( + (将根据元数据自动分类到子目录) + )} +
+ )} +