feat: 前端显示元数据信息并添加智能分类状态提示

- Media 类型添加 languages/countries/genres 字段
- MediaDetailPage 显示元数据 badges(Languages/Countries/Genres)
- ToolsPage OrganizePanel 显示智能分类启用状态
- Badge 组件支持 className prop
- 添加 parseCSV 辅助函数处理逗号分隔字符串
This commit is contained in:
ShukeBta
2026-05-18 02:57:45 +08:00
parent a98429c02a
commit ff14251371
5 changed files with 181 additions and 45 deletions
+13 -3
View File
@@ -113,15 +113,25 @@ type Resolved struct {
// API key. Callers can use Resolved.APIKey != "" as the "configured" check. // API key. Callers can use Resolved.APIKey != "" as the "configured" check.
func (s *APIConfigService) Resolve(ctx context.Context, provider string) (Resolved, error) { func (s *APIConfigService) Resolve(ctx context.Context, provider string) (Resolved, error) {
row, err := s.findByProvider(ctx, provider) 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{}, 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), APIKey: s.crypto.Decrypt(row.APIKey),
BaseURL: row.BaseURL, BaseURL: row.BaseURL,
Extra: row.Extra, Extra: row.Extra,
Enabled: row.Enabled, 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 // Update upserts a single provider's config. An empty patch.APIKey leaves
+113 -38
View File
@@ -1,63 +1,114 @@
import { useEffect, useState } from 'react' 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 { discoverAPI, type DiscoverItem } from '../api/discover'
import { imageURL } from '../api/client' 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() { export function DiscoverPage() {
const [trending, setTrending] = useState<DiscoverItem[]>([]) const [trending, setTrending] = useState<DiscoverItem[]>([])
const [popular, setPopular] = useState<DiscoverItem[]>([]) const [popular, setPopular] = useState<DiscoverItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
setLoading(true)
setError(null)
Promise.all([ Promise.all([
discoverAPI.trending().catch(() => [] as DiscoverItem[]), discoverAPI.trending().catch((err) => {
discoverAPI.popular().catch(() => [] as DiscoverItem[]), 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]) => { .then(([t, p]) => {
setTrending(t) setTrending(t)
setPopular(p) setPopular(p)
}) })
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to load discover data')
})
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
// Check if TMDB API key is likely not configured
const isTMDBMissing = !loading && trending.length === 0 && popular.length === 0 && !error
return ( return (
<div className="space-y-10"> <div className="space-y-8 px-4 py-6 max-w-7xl mx-auto">
<header className="flex items-center gap-3"> {/* Header */}
<Sparkles className="h-6 w-6 text-primary-400" /> <header className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-2xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 border border-primary-500/20">
<Sparkles className="h-8 w-8 text-primary-400" />
</div>
<div> <div>
<h1 className="font-display text-3xl font-bold text-white">发现</h1> <h1 className="font-display text-4xl font-bold text-white tracking-tight">发现</h1>
<p className="text-sm text-slate-400"> <p className="mt-1 text-base text-slate-400">
来自 TMDb 的当日热门与流行榜单(需在外部 API 中配置 TMDb API Key)。 来自 TMDb 的当日热门与流行榜单
</p> </p>
</div> </div>
</header> </header>
{loading && <p className="text-slate-500">加载中…</p>} {/* Error Alert */}
{error && (
<div className="rounded-2xl bg-red-500/10 border border-red-500/20 p-4 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0" />
<p className="text-red-300">{error}</p>
</div>
)}
{trending.length > 0 && <Row title="今日趋势" items={trending} />} {/* Loading State */}
{popular.length > 0 && <Row title="热门电影" items={popular} />} {loading && <DiscoverSkeleton />}
{!loading && trending.length === 0 && popular.length === 0 && (
<div className="glass-panel"> {/* TMDB API Key Missing */}
<p className="text-slate-300"> {isTMDBMissing && (
还没有可用的发现数据。请前往 <span className="text-primary-400">管理后台 → 外部API</span> <div className="rounded-2xl bg-amber-500/10 border border-amber-500/20 p-6 text-center space-y-4">
,配置 TMDb API Key 后刷新页面。 <div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center">
<AlertTriangle className="h-8 w-8 text-amber-400" />
</div>
<h3 className="text-lg font-semibold text-white">TMDb API Key 未配置</h3>
<p className="text-sm text-slate-400 max-w-md mx-auto">
您需要配置 TMDb API Key 才能查看发现内容。请前往管理后台配置。
</p> </p>
<Link
to="/admin"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors font-medium"
>
前往管理后台
<ExternalLink className="h-4 w-4" />
</Link>
</div>
)}
{/* Content Rows */}
{!loading && !isTMDBMissing && (
<div className="space-y-10">
{trending.length > 0 && <ContentRow title="今日趋势" items={trending} />}
{popular.length > 0 && <ContentRow title="热门电影" items={popular} />}
{/* Empty State (TMDB configured but no data) */}
{trending.length === 0 && popular.length === 0 && (
<div className="text-center py-12">
<p className="text-slate-500">暂无发现内容</p>
</div>
)}
</div> </div>
)} )}
</div> </div>
) )
} }
function Row({ title, items }: { title: string; items: DiscoverItem[] }) { function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) {
return ( return (
<section className="space-y-3"> <section className="space-y-4">
<h2 className="font-display text-xl font-semibold text-white">{title}</h2> <h2 className="font-display text-2xl font-semibold text-white pl-1">{title}</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => ( {items.map((item) => (
<DiscoverCard key={m.tmdb_id} item={m} /> <DiscoverCard key={item.tmdb_id} item={item} />
))} ))}
</div> </div>
</section> </section>
@@ -66,33 +117,57 @@ function Row({ title, items }: { title: string; items: DiscoverItem[] }) {
function DiscoverCard({ item }: { item: DiscoverItem }) { function DiscoverCard({ item }: { item: DiscoverItem }) {
return ( return (
<div className="overflow-hidden rounded-xl border border-white/5 bg-surface-800/60"> <div className="group relative overflow-hidden rounded-2xl border border-white/5 bg-surface-800/60 hover:border-primary-500/30 transition-all duration-300 hover:shadow-2xl hover:shadow-primary-500/10 hover:-translate-y-1">
<div className="aspect-[2/3] w-full bg-surface-900"> {/* Poster */}
<div className="aspect-[2/3] w-full bg-surface-900 relative overflow-hidden">
{item.poster_url ? ( {item.poster_url ? (
<img <img
src={imageURL(item.poster_url)} src={imageURL(item.poster_url)}
alt={item.title} alt={item.title}
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
className="h-full w-full object-cover" className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-slate-600"> <div className="flex h-full w-full items-center justify-center text-slate-600">
无海报 无海报
</div> </div>
)} )}
{/* Rating Badge */}
{item.rating > 0 && (
<div className="absolute top-3 right-3 rounded-lg bg-black/70 backdrop-blur-sm px-2 py-1 text-sm font-semibold text-yellow-400 border border-yellow-400/30">
★ {item.rating.toFixed(1)}
</div>
)}
</div> </div>
<div className="px-3 py-2">
<p className="truncate text-sm font-medium text-white">{item.title}</p> {/* Info */}
<div className="flex items-center justify-between text-xs text-slate-400"> <div className="p-4 space-y-1">
{item.year > 0 && <span>{item.year}</span>} <p className="font-medium text-white truncate group-hover:text-primary-400 transition-colors">
{item.rating > 0 && ( {item.title}
<span className="rounded border border-yellow-400/40 px-1.5 py-0.5 text-yellow-400"> </p>
★ {item.rating.toFixed(1)} {item.year > 0 && (
</span> <p className="text-sm text-slate-500">{item.year}</p>
)} )}
</div>
</div> </div>
</div> </div>
) )
} }
function DiscoverSkeleton() {
return (
<div className="space-y-8">
{[1, 2].map((section) => (
<section key={section} className="space-y-4">
<div className="h-8 w-48 rounded-lg bg-surface-800 animate-pulse" />
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="aspect-[2/3] rounded-2xl bg-surface-800 animate-pulse" />
))}
</div>
</section>
))}
</div>
)
}
+17 -2
View File
@@ -30,6 +30,11 @@ function fmtSize(bytes: number): string {
return `${v.toFixed(2)} ${units[i]}` 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. // Detail screen for a single media item.
// //
// Buttons: // Buttons:
@@ -151,6 +156,15 @@ export function MediaDetailPage() {
<Badge>{fmtSize(media.size_bytes)}</Badge> <Badge>{fmtSize(media.size_bytes)}</Badge>
{media.container && <Badge>{media.container.toUpperCase()}</Badge>} {media.container && <Badge>{media.container.toUpperCase()}</Badge>}
<Badge>{media.scrape_status}</Badge> <Badge>{media.scrape_status}</Badge>
{parseCSV(media.languages).map(l => (
<Badge key={l} className="border-blue-400/30 text-blue-400 bg-blue-400/10">{l}</Badge>
))}
{parseCSV(media.countries).map(c => (
<Badge key={c} className="border-green-400/30 text-green-400 bg-green-400/10">{c}</Badge>
))}
{parseCSV(media.genres).map(g => (
<Badge key={g} className="border-purple-400/30 text-purple-400 bg-purple-400/10">{g}</Badge>
))}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -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 ( return (
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5"> <span className={`${base} ${className}`}>
{children} {children}
</span> </span>
) )
+35 -2
View File
@@ -1,10 +1,11 @@
import { FormEvent, useEffect, useState } from 'react' 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 toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { libraryAPI, mediaAPI } from '../api/library' import { libraryAPI, mediaAPI } from '../api/library'
import { toolsAPI } from '../api/tools' 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 // ToolsPage gathers admin-only one-off operations that don't belong on a
// dedicated screen of their own: // dedicated screen of their own:
@@ -44,10 +45,30 @@ function OrganizePanel() {
const [results, setResults] = useState<Media[]>([]) const [results, setResults] = useState<Media[]>([])
const [busyID, setBusyID] = useState<string | null>(null) const [busyID, setBusyID] = useState<string | null>(null)
const [smartClassify, setSmartClassify] = useState(false)
const [loadingSettings, setLoadingSettings] = useState(true)
useEffect(() => { useEffect(() => {
libraryAPI.list().then(setLibraries).catch(() => undefined) 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) => { const onOrganizeLibrary = async (e: FormEvent) => {
e.preventDefault() e.preventDefault()
if (!libraryID) return if (!libraryID) return
@@ -105,6 +126,18 @@ function OrganizePanel() {
的规范布局移动并重命名,确保已先完成刮削。 的规范布局移动并重命名,确保已先完成刮削。
</p> </p>
{!loadingSettings && (
<div className="flex items-center gap-2 text-xs">
<Info size={14} className={smartClassify ? 'text-green-400' : 'text-slate-500'} />
<span className={smartClassify ? 'text-green-400' : 'text-slate-500'}>
智能分类:{smartClassify ? '已启用' : '未启用'}
</span>
{smartClassify && (
<span className="text-slate-500">(将根据元数据自动分类到子目录)</span>
)}
</div>
)}
<form onSubmit={onOrganizeLibrary} className="flex flex-wrap gap-2"> <form onSubmit={onOrganizeLibrary} className="flex flex-wrap gap-2">
<select <select
required required
+3
View File
@@ -124,6 +124,9 @@ export interface Media {
scrape_status: string scrape_status: string
tmdb_id: number tmdb_id: number
bangumi_id: number bangumi_id: number
languages?: string
countries?: string
genres?: string
nsfw: boolean nsfw: boolean
created_at: string created_at: string
updated_at: string updated_at: string