This commit is contained in:
truewhile
2026-09-04 11:56:29 +08:00
parent cc40169616
commit 203abd106a
72 changed files with 1979 additions and 1155 deletions
@@ -202,7 +202,7 @@ function ManualScrapeCandidateRow({
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" referrerPolicy="no-referrer" />
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
) : (
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
)}
+23 -26
View File
@@ -1,12 +1,13 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Film, Play, Layers, Star } from 'lucide-react'
import { imageURL } from '../api/client'
import type { Media } from '../types'
export const MediaCard = ({
media, progress, count, rating, linkTo, onClick, actions, compact,
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
export const MediaCard = memo(function MediaCard({
media, progress, count, rating, linkTo, onClick, actions, renderActions, compact,
}: {
media: Media
progress?: number
@@ -15,25 +16,26 @@ export const MediaCard = ({
linkTo?: string
onClick?: () => void
actions?: ReactNode
renderActions?: (media: Media) => ReactNode
compact?: boolean
}) => {
}) {
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
const posterSrc = imageURL(media.poster_url, media.updated_at)
const displayRating = rating ?? media.rating
const versionCount = media.versions?.length ?? 0
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
const actionContent = actions ?? renderActions?.(media)
useEffect(() => {
setPosterFit('cover')
}, [media.poster_url, media.updated_at])
const card = (
<motion.div
<div
ref={ref}
whileHover={{ scale: 1.04, y: -6 }}
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
>
{/* Poster Wrapper */}
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
@@ -95,16 +97,11 @@ export const MediaCard = ({
</span>
)}
{/* Premium Hover Overlay */}
{/* Premium Hover Overlay(CSS 过渡替代 framer-motion 逐卡动画实例) */}
<div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${
compact ? 'p-3' : 'p-4'
}`}>
<motion.div
initial={{ y: 15, opacity: 0 }}
whileHover={{ y: 0, opacity: 1 }}
transition={{ duration: 0.2 }}
className="space-y-2"
>
<div className="space-y-2 translate-y-4 transition-transform duration-200 group-hover:translate-y-0">
<span className={`inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 font-bold text-white shadow-md shadow-brand-500/20 ${
compact ? 'py-1.5 text-[11px]' : 'text-xs py-2'
}`}>
@@ -116,15 +113,15 @@ export const MediaCard = ({
{media.overview || "暂无简介内容"}
</p>
)}
</motion.div>
</div>
</div>
{/* Progress Bar overlay */}
{progress !== undefined && progress > 0 && progress < 1 && (
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-[var(--app-hover)]">
<div
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
style={{ width: `${Math.round(progress * 100)}%` }}
<div
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
)}
@@ -148,7 +145,7 @@ export const MediaCard = ({
)}
</div>
</div>
</motion.div>
</div>
)
if (onClick) {
@@ -157,23 +154,23 @@ export const MediaCard = ({
<button type="button" onClick={onClick} className="block w-full text-left">
{card}
</button>
{actions && (
{actionContent && (
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{actions}
{actionContent}
</div>
)}
</div>
)
}
if (actions) {
if (actionContent) {
return (
<div className="group relative block">
<Link to={href} className="block">
{card}
</Link>
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{actions}
{actionContent}
</div>
</div>
)
@@ -184,4 +181,4 @@ export const MediaCard = ({
{card}
</Link>
)
}
})
-52
View File
@@ -1,52 +0,0 @@
import { type ReactNode } from 'react'
import { usePermissionStore } from '../stores/permissions'
import { useAuthStore } from '../stores/auth'
interface PermissionGuardProps {
permission: string
children: ReactNode
fallback?: ReactNode
requireSuperUser?: boolean
}
/**
* PermissionGuard 组件用于根据用户权限控制内容显示。
*
* @param permission - 需要的权限键
* @param children - 有权限时显示的内容
* @param fallback - 无权限时显示的内容(可选,默认不显示)
* @param requireSuperUser - 是否要求超级用户(admin/plus)绕过权限检查
*/
export function PermissionGuard({
permission,
children,
fallback = null,
requireSuperUser = false,
}: PermissionGuardProps) {
const { hasPermission, isSuper, permissions, isLoading } = usePermissionStore()
const tier = useAuthStore((state) => state.tier)
const role = useAuthStore((state) => state.user?.role)
// 超级用户(admin 或 plus)默认有所有权限
if (isSuper || tier === 'plus' || role === 'admin') {
return <>{children}</>
}
// 如果 requireSuperUser 为 true 且用户不是超级用户,则不显示
if (requireSuperUser && !isSuper) {
return <>{fallback}</>
}
// 加载中时显示 fallback
if (isLoading && Object.keys(permissions).length === 0) {
return <>{fallback}</>
}
// 检查具体权限
if (hasPermission(permission)) {
return <>{children}</>
}
return <>{fallback}</>
}
+17 -6
View File
@@ -28,18 +28,25 @@ export function usePermission(
options: { autoFetch?: boolean } = {}
): boolean {
const { autoFetch = true } = options
const { hasPermission, isSuper, permissions, isLoading, fetchPermissions } = usePermissionStore()
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
const hasPermission = usePermissionStore((s) => s.hasPermission)
const isSuper = usePermissionStore((s) => s.isSuper)
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
const tier = useAuthStore((state) => state.tier)
const role = useAuthStore((state) => state.user?.role)
const isAuthenticated = useAuthStore((state) => state.token !== null)
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
// 权限未加载时自动获取
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
useEffect(() => {
if (!hasSuperAccess && isAuthenticated && autoFetch && Object.keys(permissions).length === 0 && !isLoading) {
fetchPermissions()
if (!hasSuperAccess && isAuthenticated && autoFetch) {
const { permissions, isLoading } = usePermissionStore.getState()
if (Object.keys(permissions).length === 0 && !isLoading) {
fetchPermissions()
}
}
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated, isLoading, permissions])
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated])
// 超级用户有所有权限
if (hasSuperAccess) {
@@ -78,7 +85,11 @@ export function usePermission(
* ```
*/
export function usePermissions() {
const { permissions, isSuper, isLoading, fetchPermissions } = usePermissionStore()
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
const permissions = usePermissionStore((s) => s.permissions)
const isSuper = usePermissionStore((s) => s.isSuper)
const isLoading = usePermissionStore((s) => s.isLoading)
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
const tier = useAuthStore((state) => state.tier)
const role = useAuthStore((state) => state.user?.role)
const isAuthenticated = useAuthStore((state) => state.token !== null)
+68
View File
@@ -2,6 +2,74 @@
@tailwind components;
@tailwind utilities;
/* ── 自托管 Web 字体(OFL 许可,latin 子集,public/fonts/)──
取代原先 fonts.googleapis.com 的渲染阻塞外链:自托管服务常部署在
无法访问 Google 的网络,外链会拖住首屏渲染直到超时。 */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fonts/geist-300.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/geist-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/geist-500.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/geist-600.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/geist-700.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/jetbrainsmono-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/jetbrainsmono-500.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/jetbrainsmono-600.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
html, body, #root { height: 100%; }
:root {
+1 -1
View File
@@ -169,7 +169,7 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
</td>
<td className="py-2 pr-3 font-medium text-ink-600">
<div className="flex items-center gap-2">
{library.cover_url && <img src={library.cover_url} alt="" className="h-10 w-8 rounded object-cover" />}
{library.cover_url && <img src={library.cover_url} alt="" loading="lazy" decoding="async" className="h-10 w-8 rounded object-cover" />}
<span>{library.name}</span>
</div>
</td>
+4 -1
View File
@@ -31,7 +31,10 @@ export function AdminUsersPanel() {
}
useEffect(() => {
refresh().catch(() => undefined)
const timer = window.setInterval(() => refresh().catch(() => undefined), 10000)
const timer = window.setInterval(() => {
if (document.hidden) return
refresh().catch(() => undefined)
}, 10000)
return () => window.clearInterval(timer)
}, [])
+29 -7
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import { memo, type ReactNode } from 'react'
import { Film } from 'lucide-react'
import { MediaCard } from '../components/MediaCard'
@@ -33,7 +33,9 @@ export function LibraryMediaSections({
{!isSeries && items.length > 0 && (
<MediaGrid count={items.length} renderItem={(index) => {
const media = items[index]
return <MediaCard key={media.id} media={media} actions={cardActions(media)} />
// renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮,
// 配合 memo,父级无关状态变化不再级联重渲染全部卡片。
return <MediaCard key={media.id} media={media} renderActions={cardActions} />
}} />
)}
@@ -45,12 +47,11 @@ export function LibraryMediaSections({
<MediaGrid count={seriesCards.length} renderItem={(index) => {
const series = seriesCards[index]
return (
<MediaCard
<SeriesCardItem
key={series.key}
media={series.rep}
count={series.count}
actions={cardActions(series.rep)}
onClick={() => onSeriesClick(series)}
series={series}
cardActions={cardActions}
onSeriesClick={onSeriesClick}
/>
)
}} />
@@ -63,6 +64,27 @@ export function LibraryMediaSections({
)
}
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
// 父级重渲染不会穿透到每张剧集卡片。
const SeriesCardItem = memo(function SeriesCardItem({
series,
cardActions,
onSeriesClick,
}: {
series: SeriesCard
cardActions: (media: Media) => ReactNode
onSeriesClick: (series: SeriesCard) => void
}) {
return (
<MediaCard
media={series.rep}
count={series.count}
renderActions={cardActions}
onClick={() => onSeriesClick(series)}
/>
)
})
function MediaGrid({ count, renderItem }: { count: number; renderItem: (index: number) => ReactNode }) {
if (count <= VIRTUALIZE_THRESHOLD) {
return <div className={MEDIA_GRID_CLASS}>{Array.from({ length: count }, (_, index) => renderItem(index))}</div>
+7 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion'
@@ -175,7 +175,7 @@ export function LibraryPage() {
setManualMovie,
})
const handleToggleFavourite = async (mediaID: string) => {
const handleToggleFavourite = useCallback(async (mediaID: string) => {
if (!canFavorite || favouriteBusyID) return
setFavouriteBusyID(mediaID)
try {
@@ -183,9 +183,11 @@ export function LibraryPage() {
} finally {
setFavouriteBusyID('')
}
}
}, [canFavorite, favouriteBusyID, toggleFavourite])
const cardActions = (media: Media): ReactNode => {
// useCallback 稳定引用:配合 MediaCard 的 memo,仅在收藏状态/操作集变化时
// 才让卡片重渲染。
const cardActions = useCallback((media: Media): ReactNode => {
const actions: ReactNode[] = []
if (canFavorite) {
actions.push(
@@ -206,7 +208,7 @@ export function LibraryPage() {
}
if (actions.length === 0) return undefined
return <>{actions}</>
}
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
if (loading) {
return (
@@ -70,6 +70,8 @@ export function LibrarySeriesDetailHeader({
<img
src={imageURL(series.rep.poster_url, series.rep.updated_at)}
alt={series.rep.title}
loading="lazy"
decoding="async"
className="aspect-[2/3] w-full object-cover"
referrerPolicy="no-referrer"
/>
+8 -254
View File
@@ -1,13 +1,10 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
AlertCircle,
Ban,
CheckCircle2,
Clock,
Copy,
ExternalLink,
Eye,
Film,
Image as ImageIcon,
@@ -26,6 +23,8 @@ import { imageURL } from '../api/client'
import { scraperAPI } from '../api/scraper'
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
import { PROVIDER_LABELS, ScrapeDetailModal } from './scraper-queue/ScrapeDetailModal'
import { copyToClipboard, useTaskSelection } from './queue-shared'
const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
{ key: 'all', label: '全部', icon: Sparkles, color: 'text-ink-600' },
@@ -36,14 +35,6 @@ const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Cloc
{ key: 'canceled', label: '已取消', icon: Ban, color: 'text-amber-500' },
]
const PROVIDER_LABELS: Record<string, string> = {
tmdb: 'TheMovieDB',
douban: '豆瓣 Douban',
bangumi: 'Bangumi 番组计划',
thetvdb: 'TheTVDB',
metatube: 'MetaTube',
}
const TYPE_ICONS: Record<string, ReactNode> = {
movie: <Film size={14} className="text-blue-500" />,
tv: <Tv size={14} className="text-purple-500" />,
@@ -51,13 +42,6 @@ const TYPE_ICONS: Record<string, ReactNode> = {
adult: <Film size={14} className="text-rose-500" />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
anime: '动漫',
adult: 'Adult',
}
const PAGE_SIZE = 50
export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
@@ -70,7 +54,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
const [isRefreshing, setIsRefreshing] = useState(false)
const [autoRefresh, setAutoRefresh] = useState(true)
const [batchBusy, setBatchBusy] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const { selectedIds, setSelectedIds, reset: clearSelection, toggleRow: toggleSelectRow, toggleAll: toggleAllIds } = useTaskSelection()
const [detailTask, setDetailTask] = useState<ScrapeTask | null>(null)
const refresh = useCallback(
@@ -103,19 +87,17 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
useEffect(() => {
if (!autoRefresh) return
const timer = setInterval(() => {
if (document.hidden) return
refresh().catch(() => undefined)
}, 3000)
return () => clearInterval(timer)
}, [autoRefresh, refresh])
useEffect(() => {
setSelectedIds(new Set())
}, [filter, page])
clearSelection()
}, [filter, page, clearSelection])
const copyText = (text: string, label: string) => {
navigator.clipboard.writeText(text)
toast.success(`已复制${label}`)
}
const copyText = copyToClipboard
// Row actions
const cancelTask = async (task: ScrapeTask) => {
@@ -237,22 +219,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
const allCurrentChecked =
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
const toggleSelectAll = () => {
if (allCurrentChecked) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredTasks.map((t) => t.id)))
}
}
const toggleSelectRow = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => toggleAllIds(filteredTasks.map((t) => t.id))
return (
<div className="space-y-6">
@@ -950,216 +917,3 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
</div>
)
}
function ScrapeDetailModal({
task,
onClose,
onRetry,
onCancel,
onDelete,
onCopy,
}: {
task: ScrapeTask
onClose: () => void
onRetry: (t: ScrapeTask) => void
onCancel: (t: ScrapeTask) => void
onDelete: (t: ScrapeTask) => void
onCopy: (text: string, label: string) => void
}) {
const status = taskStatusMeta(task.status)
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-brand-500" />
<h3 className="font-display text-base font-bold text-ink-600">刮削任务详情</h3>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
>
<X size={18} />
</button>
</div>
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
{/* Matched Poster / Info Banner */}
{task.matched_title ? (
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
{task.poster_url && (
<img
src={imageURL(task.poster_url)}
alt=""
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
/>
)}
<div className="space-y-1.5 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white uppercase">
已匹配
</span>
{task.provider && (
<span className="rounded border border-gray-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-ink-600">
{PROVIDER_LABELS[task.provider] ?? task.provider}
</span>
)}
</div>
<h4 className="font-display text-base font-extrabold text-ink-600 truncate">
{task.matched_title}
</h4>
<div className="flex items-center gap-3 text-sand-500 text-[11px]">
{task.matched_year > 0 && <span>年份:{task.matched_year}</span>}
<span>类型:{TYPE_LABELS[task.media_type] ?? task.media_type}</span>
</div>
{task.media_id && (
<Link
to={`/media/${task.media_id}`}
target="_blank"
className="inline-flex items-center gap-1 text-brand-500 font-semibold hover:underline pt-1"
>
<span>在媒体详情中查看</span>
<ExternalLink size={11} />
</Link>
)}
</div>
</div>
) : null}
{/* Media Info Box */}
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">原始媒体标题</span>
<span className="font-bold text-ink-600 select-all">{task.media_title}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">所属媒体库</span>
<span className="font-medium text-ink-100">{task.library_name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">媒体库类型</span>
<span className="font-medium text-ink-100">
{TYPE_LABELS[task.media_type] ?? task.media_type}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">当前状态</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
>
{task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">剧照/海报刮削</span>
<span className="font-medium text-ink-100">
{task.episode_images ? '开启' : '关闭'}
</span>
</div>
</div>
{/* File path */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sand-500 font-medium">
<span>磁盘文件路径</span>
<button
type="button"
onClick={() => onCopy(task.media_path, '文件路径')}
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
>
<Copy size={11} /> 复制
</button>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
{task.media_path}
</div>
</div>
{/* Error Message Box */}
{task.error && (
<div className="space-y-2">
<div className="flex items-center justify-between text-rose-500 font-medium">
<span className="flex items-center gap-1">
<AlertCircle size={13} /> 刮削未匹配 / 异常详情
</span>
<button
type="button"
onClick={() => onCopy(task.error, '错误日志')}
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
>
<Copy size={11} /> 复制日志
</button>
</div>
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
{task.error}
</div>
</div>
)}
{/* Timeline */}
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
<div>入队时间:{formatTime(task.created_at)}</div>
{task.started_at && <div>开始刮削:{formatTime(task.started_at)}</div>}
{task.finished_at && <div>完成时间:{formatTime(task.finished_at)}</div>}
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
<div>
{(task.status === 'done' ||
task.status === 'failed' ||
task.status === 'canceled') && (
<button
type="button"
onClick={() => onDelete(task)}
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
>
<Trash2 size={13} />
删除记录
</button>
)}
</div>
<div className="flex items-center gap-2">
{(task.status === 'pending' || task.status === 'running') && (
<button
type="button"
onClick={() => onCancel(task)}
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
>
<Ban size={13} />
取消任务
</button>
)}
<button
type="button"
onClick={() => onRetry(task)}
className="neon-button !py-2 !px-4 text-xs font-semibold"
>
<RefreshCw size={13} />
重新刮削
</button>
<button
type="button"
onClick={onClose}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
>
关闭
</button>
</div>
</div>
</div>
</div>
)
}
+3 -310
View File
@@ -1,31 +1,27 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { FormEvent, useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
ArrowDown,
ArrowUp,
ChevronRight,
Cloud,
ExternalLink,
FolderPlus,
HardDrive,
Loader2,
Plus,
QrCode,
RefreshCw,
Search,
Trash2,
Tv,
X,
} from 'lucide-react'
import QRCode from 'qrcode'
import { strmAPI, type Strm115Source, type StrmRemoteEntry } from '../api/strm'
import { strmAPI, type StrmRemoteEntry } from '../api/strm'
import type { SettingDef } from './SettingsRow'
import { SettingRow } from './SettingsRow'
import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from '../types/strm'
import { STRM_PROVIDER_LABELS } from '../types/strm'
import { apiErrorMessage } from './StrmManagePage'
import { Strm115AuthPanel } from './strm-dialogs/Strm115AuthPanel'
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
import {
defaultEmbyRemoteLines,
@@ -496,309 +492,6 @@ export function StrmAccountDialog({
// ─── 115 登录二维码(canvas 渲染;115 返回的是扫码页面链接而非图片) ───────────
// QR 尺寸:物理像素 = CSS 像素(避免 canvas 被 CSS 缩放导致裁切/模糊)
const QR_SIZE = 176
function QrCanvas({ content }: { content: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [error, setError] = useState('')
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !content) return
QRCode.toCanvas(canvas, content, {
width: QR_SIZE,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#1f2937', light: '#ffffff' },
})
.then(() => setError(''))
.catch((err: unknown) => setError(err instanceof Error ? err.message : '二维码生成失败'))
}, [content])
return (
<div className="relative h-44 w-44 shrink-0 rounded-xl border border-gray-200 bg-white">
<canvas
ref={canvasRef}
width={QR_SIZE}
height={QR_SIZE}
role="img"
aria-label="115 授权二维码"
style={{ width: QR_SIZE, height: QR_SIZE }}
/>
{error && (
<div className="absolute inset-0 grid place-items-center rounded-xl bg-gray-50 p-3 text-center text-xs text-rose-500">
{error}
</div>
)}
</div>
)
}
// ─── 115 开放平台授权面板 ─────────────────────────────────────────────────────
type AuthSourceKey = 'built_in_appid' | 'custom_appid' | 'built_in_relay' | 'third_party_service'
const AUTH_SOURCE_OPTIONS: { key: AuthSourceKey; label: string; desc: string; needsRelayKey?: boolean }[] = [
{ key: 'built_in_appid', label: '官方应用目录', desc: '设备码扫码授权,无需回跳' },
{ key: 'custom_appid', label: '自定义 APP ID', desc: '使用自己申请的开放平台应用' },
{ key: 'built_in_relay', label: '中继授权', desc: 'QMediaSync / MQFamily 中继', needsRelayKey: true },
{ key: 'third_party_service', label: '第三方服务', desc: 'MoviePilot / CloudDrive' },
]
function Strm115AuthPanel({
existing,
accountName,
onAuthed,
}: {
existing: StrmAccount | null
accountName: string
onAuthed: (account: StrmAccount) => void
}) {
const [sources, setSources] = useState<{ built_in: Strm115Source[]; relay: Strm115Source[]; third_party: Strm115Source[] } | null>(null)
const [relayKeyConfigured, setRelayKeyConfigured] = useState(false)
const [authSource, setAuthSource] = useState<AuthSourceKey>('built_in_appid')
const [thirdParty, setThirdParty] = useState<'moviepilot' | 'clouddrive'>('moviepilot')
const [relayProvider, setRelayProvider] = useState<'qmediasync' | 'mqfamily'>('qmediasync')
const [appID, setAppID] = useState('100195125')
const [appKeyword, setAppKeyword] = useState('')
const [starting, setStarting] = useState(false)
const [authUI, setAuthUI] = useState<{ sessionId: string; accountId: string; mode: 'qrcode' | 'url'; authUrl?: string; qrcodeUrl?: string } | null>(null)
const [authStatus, setAuthStatus] = useState('')
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
useEffect(() => stopPolling, [])
useEffect(() => {
;(async () => {
try {
const data = await strmAPI.list115Sources()
setSources(data)
const settings = await strmAPI.getSettings().catch(() => null)
const key = settings?.['strm.115_relay_key']
setRelayKeyConfigured(Boolean(key && key.trim()))
} catch (err) {
toast.error(apiErrorMessage(err))
}
})()
}, [])
const filteredApps = useMemo(
() =>
(sources?.built_in ?? []).filter(
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
),
[appKeyword, sources],
)
useEffect(() => {
if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) {
setAppID(filteredApps[0].app_id)
}
}, [appID, filteredApps])
const buildStartPayload = () => {
switch (authSource) {
case 'built_in_appid':
return { auth_source: 'built_in_appid', app_id: appID }
case 'custom_appid':
return { auth_source: 'custom_appid', app_id: appID }
case 'built_in_relay':
return { auth_source: 'built_in_relay', provider: relayProvider }
case 'third_party_service':
return { auth_source: 'third_party_service', provider: thirdParty }
}
}
const startAuth = async () => {
if (authSource === 'built_in_appid' && filteredApps.length === 0) {
toast.error('未找到匹配的官方应用')
return
}
setStarting(true)
setAuthStatus('')
try {
// 115 授权需要账号 ID:没有则先创建空凭据账号
let account = existing
if (!account) {
account = await strmAPI.createAccount({ name: accountName || '115 网盘', provider: 'cloud115', config: {} })
}
const result = await strmAPI.start115OAuth(account.id, buildStartPayload())
setAuthUI(
result.mode === 'qrcode'
? { sessionId: result.session_id, accountId: account.id, mode: 'qrcode', qrcodeUrl: result.qrcode?.qrcode }
: { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url },
)
stopPolling()
pollRef.current = setInterval(async () => {
try {
const status = await strmAPI.poll115OAuth(account.id, result.session_id)
setAuthStatus(status.tip)
if (status.status === 'confirmed') {
stopPolling()
const updated = await strmAPI.testAccount(account.id)
onAuthed(updated)
}
if (status.status === 'expired') {
stopPolling()
setAuthUI(null)
toast.error('授权已过期,请重新发起')
}
} catch {
/* 轮询失败等下一次 */
}
}, 3000)
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setStarting(false)
}
}
const openAuthWindow = () => {
if (authUI?.mode === 'url' && authUI.authUrl) {
window.open(authUI.authUrl, '_blank', 'noopener')
}
}
const resetAuth = () => {
stopPolling()
setAuthUI(null)
setAuthStatus('')
}
return (
<div className="space-y-4 rounded-2xl bg-gray-50 p-4">
{existing?.has_credential && (
<p className="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-600">
✓ 该账号已授权;重新授权会替换现有令牌
</p>
)}
{!authUI && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
{AUTH_SOURCE_OPTIONS.map((option) => {
const active = authSource === option.key
const disabled = Boolean(option.needsRelayKey && !relayKeyConfigured)
return (
<button
key={option.key}
type="button"
disabled={disabled}
onClick={() => setAuthSource(option.key)}
className={
'rounded-xl border-2 p-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-40 ' +
(active ? 'border-brand-400 bg-brand-50' : 'border-gray-100 bg-white hover:border-gray-200')
}
>
<p className="text-sm font-bold text-ink-600">{option.label}</p>
<p className="text-[11px] text-sand-500">{option.desc}</p>
{disabled && <p className="text-[10px] text-rose-400">需在 STRM 设置配置共享密钥</p>}
</button>
)
})}
</div>
{authSource === 'built_in_appid' && (
<div className="space-y-2">
<Field label="选择官方应用" hint="应用目录来自 QMediaSync 内置;媒体播放器 / 飞牛 / 恒星等">
<div className="flex items-center gap-2">
<Search size={14} className="shrink-0 text-sand-400" />
<input className={inputCls} placeholder="搜索应用名称或 ID…" value={appKeyword} onChange={(e) => setAppKeyword(e.target.value)} />
</div>
<select className={inputCls} value={appID} onChange={(e) => setAppID(e.target.value)} size={5}>
{filteredApps.map((source) => (
<option key={source.app_id} value={source.app_id}>
{source.display_name}({source.app_id})
</option>
))}
</select>
{filteredApps.length === 0 && <p className="text-xs text-sand-500">未找到匹配的官方应用</p>}
</Field>
</div>
)}
{authSource === 'custom_appid' && (
<Field label="自定义 APP ID" hint="使用自己在 115 开放平台申请的应用 ID">
<input className={inputCls} value={appID} placeholder="100195125" onChange={(e) => setAppID(e.target.value)} />
</Field>
)}
{authSource === 'built_in_relay' && (
<Field label="中继服务">
<select className={inputCls} value={relayProvider} onChange={(e) => setRelayProvider(e.target.value as typeof relayProvider)}>
<option value="qmediasync">QMediaSync(oauth.qmediasync.cn)</option>
<option value="mqfamily">MQFamily(api.mqfamily.top)</option>
</select>
</Field>
)}
{authSource === 'third_party_service' && (
<Field label="第三方授权服务">
<select className={inputCls} value={thirdParty} onChange={(e) => setThirdParty(e.target.value as typeof thirdParty)}>
<option value="moviepilot">MoviePilot(https://movie-pilot.org)</option>
<option value="clouddrive">CloudDrive(redirect115.zhenyunpan.com)</option>
</select>
</Field>
)}
<button
type="button"
onClick={startAuth}
disabled={starting || (authSource === 'built_in_appid' && filteredApps.length === 0)}
className="neon-button disabled:opacity-50"
>
{starting ? <Loader2 size={16} className="animate-spin" /> : <QrCode size={16} />}
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
</button>
</div>
)}
{authUI && (
<div className="space-y-3">
{authUI.mode === 'qrcode' && authUI.qrcodeUrl ? (
<div className="flex items-center gap-4">
<QrCanvas content={authUI.qrcodeUrl} />
<div className="space-y-1.5 text-sm">
<p className="font-medium text-ink-600">{authStatus || '等待扫码…'}</p>
<p className="text-xs text-sand-500">请使用 115 手机客户端扫码并确认授权,5 分钟内有效</p>
</div>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-ink-100">请点击下方按钮在新窗口完成授权:</p>
<button type="button" onClick={openAuthWindow} className="neon-button">
<ExternalLink size={16} />
打开授权页面
</button>
<p className="text-xs text-sand-500">完成后回到此页面等待自动确认(授权成功后可关闭弹窗)</p>
</div>
)}
<div className="flex items-center gap-2 text-sm">
<Loader2 size={14} className="animate-spin text-brand-500" />
<span className="text-ink-50">{authStatus}</span>
</div>
<button
type="button"
onClick={resetAuth}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50"
>
<RefreshCw size={14} />
重新发起授权
</button>
</div>
)}
</div>
)
}
// ─── STRM 设置 ────────────────────────────────────────────────────────────────
const SETTING_DEFS: SettingDef[] = [
{ key: 'strm.base_url', label: 'STRM 链接基础地址', type: 'text', hint: '生成的 strm 文件指向的播放地址;留空依次自动使用 app.server_url,最后回退到本机地址(http://127.0.0.1:端口)。Emby 在其他设备时请配置局域网/公网地址' },
{ key: 'strm.video_ext', label: '视频扩展名', type: 'text', hint: '逗号分隔,命中即生成 .strm' },
+4 -1
View File
@@ -95,7 +95,10 @@ export function StrmManagePage() {
// 有进行中的同步时轮询刷新状态
useEffect(() => {
if (!paths.some((p) => p.last_sync_status === 'running')) return
const timer = setInterval(() => refresh().catch(() => undefined), 4000)
const timer = setInterval(() => {
if (document.hidden) return
refresh().catch(() => undefined)
}, 4000)
return () => clearInterval(timer)
}, [paths, refresh])
+9 -241
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import {
AlertCircle,
@@ -8,12 +8,7 @@ import {
Copy,
Download,
Eye,
File,
FileText,
Film,
Image as ImageIcon,
Loader2,
MessageSquare,
PlayCircle,
RefreshCw,
Search,
@@ -26,6 +21,8 @@ import { strmAPI } from '../api/strm'
import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm'
import { STRM_PROVIDER_LABELS } from '../types/strm'
import { apiErrorMessage, formatBytes, formatTime, taskStatusMeta } from './StrmManagePage'
import { TaskDetailModal, getFileIcon } from './strm-queue/TaskDetailModal'
import { copyToClipboard, useTaskSelection } from './queue-shared'
const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
{ key: 'all', label: '全部', icon: Clock, color: 'text-ink-600' },
@@ -38,23 +35,6 @@ const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock;
const PAGE_SIZE = 50
function getFileIcon(filename: string): ReactNode {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
return <FileText size={15} className="text-amber-500 shrink-0" />
}
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
}
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
}
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
return <Film size={15} className="text-emerald-500 shrink-0" />
}
return <File size={15} className="text-gray-400 shrink-0" />
}
export function StrmQueuePanel({
kind,
embedded = false,
@@ -71,7 +51,7 @@ export function StrmQueuePanel({
const [isRefreshing, setIsRefreshing] = useState(false)
const [autoRefresh, setAutoRefresh] = useState(true)
const [batchBusy, setBatchBusy] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const { selectedIds, setSelectedIds, reset: clearSelection, toggleRow: toggleSelectRow, toggleAll: toggleAllIds } = useTaskSelection()
const [detailTask, setDetailTask] = useState<StrmTask | null>(null)
const isDownload = kind === 'download'
@@ -109,6 +89,7 @@ export function StrmQueuePanel({
useEffect(() => {
if (!autoRefresh) return
const timer = setInterval(() => {
if (document.hidden) return
refresh().catch(() => undefined)
}, 3000)
return () => clearInterval(timer)
@@ -116,13 +97,10 @@ export function StrmQueuePanel({
// Clear selections when changing filter or page
useEffect(() => {
setSelectedIds(new Set())
}, [filter, page])
clearSelection()
}, [filter, page, clearSelection])
const copyText = (text: string, label: string) => {
navigator.clipboard.writeText(text)
toast.success(`已复制${label}`)
}
const copyText = copyToClipboard
// Task actions
const cancelTask = async (task: StrmTask) => {
@@ -234,22 +212,7 @@ export function StrmQueuePanel({
const allCurrentChecked =
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
const toggleSelectAll = () => {
if (allCurrentChecked) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredTasks.map((t) => t.id)))
}
}
const toggleSelectRow = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => toggleAllIds(filteredTasks.map((t) => t.id))
return (
<div className="space-y-6">
@@ -996,201 +959,6 @@ export function StrmQueuePanel({
)
}
function TaskDetailModal({
task,
isDownload,
onClose,
onRetry,
onCancel,
onDelete,
onCopy,
}: {
task: StrmTask
isDownload: boolean
onClose: () => void
onRetry: (t: StrmTask) => void
onCancel: (t: StrmTask) => void
onDelete: (t: StrmTask) => void
onCopy: (text: string, label: string) => void
}) {
const status = taskStatusMeta(task.status)
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
<div className="flex items-center gap-2">
{getFileIcon(task.file_name)}
<h3 className="font-display text-base font-bold text-ink-600">任务详情</h3>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
>
<X size={18} />
</button>
</div>
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
{/* Main Info Box */}
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">任务 ID</span>
<span className="font-mono text-ink-100 select-all">{task.id}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">文件名称</span>
<span className="font-bold text-ink-600 select-all">{task.file_name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">文件大小</span>
<span className="font-mono text-ink-100">{formatBytes(task.size)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">当前状态</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
>
{status.label}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">云盘提供方</span>
<span className="font-medium text-ink-100">
{STRM_PROVIDER_LABELS[task.provider] ?? task.provider}
</span>
</div>
{task.retry_count > 0 && (
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">已重试次数</span>
<span className="font-bold text-amber-600">{task.retry_count} 次</span>
</div>
)}
</div>
{/* Paths */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sand-500 font-medium">
<span>{isDownload ? '本地输出目标路径' : '本地来源路径'}</span>
<button
type="button"
onClick={() => onCopy(task.local_path, '本地路径')}
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
>
<Copy size={11} /> 复制
</button>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
{task.local_path}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sand-500 font-medium">
<span>远端网盘路径</span>
<button
type="button"
onClick={() => onCopy(task.remote_path, '远端路径')}
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
>
<Copy size={11} /> 复制
</button>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
{task.remote_path}
</div>
</div>
{/* Error Message Box */}
{task.error && (
<div className="space-y-2">
<div className="flex items-center justify-between text-rose-500 font-medium">
<span className="flex items-center gap-1">
<AlertCircle size={13} /> 错误详情
</span>
<button
type="button"
onClick={() => onCopy(task.error!, '错误信息')}
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
>
<Copy size={11} /> 复制错误
</button>
</div>
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
{task.error}
</div>
</div>
)}
{/* Timeline */}
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
<div>创建时间:{formatTime(task.created_at)}</div>
{task.started_at && <div>开始时间:{formatTime(task.started_at)}</div>}
{task.finished_at && <div>结束时间:{formatTime(task.finished_at)}</div>}
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
<div>
{(task.status === 'done' ||
task.status === 'failed' ||
task.status === 'canceled') && (
<button
type="button"
onClick={() => onDelete(task)}
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
>
<Trash2 size={13} />
删除记录
</button>
)}
</div>
<div className="flex items-center gap-2">
{(task.status === 'pending' || task.status === 'running') && (
<button
type="button"
onClick={() => onCancel(task)}
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
>
<Ban size={13} />
取消任务
</button>
)}
{(task.status === 'failed' || task.status === 'canceled') && (
<button
type="button"
onClick={() => onRetry(task)}
className="neon-button !py-2 !px-4 text-xs font-semibold"
>
<RefreshCw size={13} />
重新入队
</button>
)}
<button
type="button"
onClick={onClose}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
>
关闭
</button>
</div>
</div>
</div>
</div>
)
}
export function StrmDownloadQueuePage() {
return <StrmQueuePanel kind="download" />
}
+38
View File
@@ -0,0 +1,38 @@
import { useCallback, useState } from 'react'
import toast from 'react-hot-toast'
/** 复制文本到剪贴板并提示(Strm/Scraper 队列页共用)。 */
export function copyToClipboard(text: string, label: string) {
navigator.clipboard.writeText(text)
toast.success(`已复制${label}`)
}
/**
* 任务列表多选状态:StrmQueuePage 与 ScraperQueuePage 共用。
* allIds 变化(翻页/筛选)时调用 reset() 清空选中。
*/
export function useTaskSelection() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const reset = useCallback(() => setSelectedIds(new Set()), [])
const toggleRow = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const toggleAll = useCallback((visibleIds: string[]) => {
setSelectedIds((prev) => {
if (visibleIds.length > 0 && visibleIds.every((id) => prev.has(id))) {
return new Set()
}
return new Set(visibleIds)
})
}, [])
return { selectedIds, setSelectedIds, reset, toggleRow, toggleAll }
}
@@ -0,0 +1,234 @@
import { Link } from 'react-router-dom'
import { AlertCircle, Ban, Copy, ExternalLink, RefreshCw, Sparkles, Trash2, X } from 'lucide-react'
import { imageURL } from '../../api/client'
import type { ScrapeTask } from '../../types/scraper'
import { formatTime, taskStatusMeta } from '../StrmManagePage'
export const PROVIDER_LABELS: Record<string, string> = {
tmdb: 'TheMovieDB',
douban: '豆瓣 Douban',
bangumi: 'Bangumi 番组计划',
thetvdb: 'TheTVDB',
metatube: 'MetaTube',
}
export const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
anime: '动漫',
adult: 'Adult',
}
export function ScrapeDetailModal({
task,
onClose,
onRetry,
onCancel,
onDelete,
onCopy,
}: {
task: ScrapeTask
onClose: () => void
onRetry: (t: ScrapeTask) => void
onCancel: (t: ScrapeTask) => void
onDelete: (t: ScrapeTask) => void
onCopy: (text: string, label: string) => void
}) {
const status = taskStatusMeta(task.status)
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-brand-500" />
<h3 className="font-display text-base font-bold text-ink-600">刮削任务详情</h3>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
>
<X size={18} />
</button>
</div>
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
{/* Matched Poster / Info Banner */}
{task.matched_title ? (
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
{task.poster_url && (
<img
src={imageURL(task.poster_url)}
alt=""
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
/>
)}
<div className="space-y-1.5 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white uppercase">
已匹配
</span>
{task.provider && (
<span className="rounded border border-gray-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-ink-600">
{PROVIDER_LABELS[task.provider] ?? task.provider}
</span>
)}
</div>
<h4 className="font-display text-base font-extrabold text-ink-600 truncate">
{task.matched_title}
</h4>
<div className="flex items-center gap-3 text-sand-500 text-[11px]">
{task.matched_year > 0 && <span>年份:{task.matched_year}</span>}
<span>类型:{TYPE_LABELS[task.media_type] ?? task.media_type}</span>
</div>
{task.media_id && (
<Link
to={`/media/${task.media_id}`}
target="_blank"
className="inline-flex items-center gap-1 text-brand-500 font-semibold hover:underline pt-1"
>
<span>在媒体详情中查看</span>
<ExternalLink size={11} />
</Link>
)}
</div>
</div>
) : null}
{/* Media Info Box */}
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">原始媒体标题</span>
<span className="font-bold text-ink-600 select-all">{task.media_title}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">所属媒体库</span>
<span className="font-medium text-ink-100">{task.library_name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">媒体库类型</span>
<span className="font-medium text-ink-100">
{TYPE_LABELS[task.media_type] ?? task.media_type}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">当前状态</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
>
{task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">剧照/海报刮削</span>
<span className="font-medium text-ink-100">
{task.episode_images ? '开启' : '关闭'}
</span>
</div>
</div>
{/* File path */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sand-500 font-medium">
<span>磁盘文件路径</span>
<button
type="button"
onClick={() => onCopy(task.media_path, '文件路径')}
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
>
<Copy size={11} /> 复制
</button>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
{task.media_path}
</div>
</div>
{/* Error Message Box */}
{task.error && (
<div className="space-y-2">
<div className="flex items-center justify-between text-rose-500 font-medium">
<span className="flex items-center gap-1">
<AlertCircle size={13} /> 刮削未匹配 / 异常详情
</span>
<button
type="button"
onClick={() => onCopy(task.error, '错误日志')}
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
>
<Copy size={11} /> 复制日志
</button>
</div>
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
{task.error}
</div>
</div>
)}
{/* Timeline */}
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
<div>入队时间:{formatTime(task.created_at)}</div>
{task.started_at && <div>开始刮削:{formatTime(task.started_at)}</div>}
{task.finished_at && <div>完成时间:{formatTime(task.finished_at)}</div>}
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
<div>
{(task.status === 'done' ||
task.status === 'failed' ||
task.status === 'canceled') && (
<button
type="button"
onClick={() => onDelete(task)}
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
>
<Trash2 size={13} />
删除记录
</button>
)}
</div>
<div className="flex items-center gap-2">
{(task.status === 'pending' || task.status === 'running') && (
<button
type="button"
onClick={() => onCancel(task)}
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
>
<Ban size={13} />
取消任务
</button>
)}
<button
type="button"
onClick={() => onRetry(task)}
className="neon-button !py-2 !px-4 text-xs font-semibold"
>
<RefreshCw size={13} />
重新刮削
</button>
<button
type="button"
onClick={onClose}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
>
关闭
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,319 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { ExternalLink, Loader2, QrCode, RefreshCw, Search } from 'lucide-react'
import QRCode from 'qrcode'
import { strmAPI, type Strm115Source } from '../../api/strm'
import type { StrmAccount } from '../../types/strm'
import { apiErrorMessage } from '../StrmManagePage'
const QR_SIZE = 176
function QrCanvas({ content }: { content: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [error, setError] = useState('')
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !content) return
QRCode.toCanvas(canvas, content, {
width: QR_SIZE,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#1f2937', light: '#ffffff' },
})
.then(() => setError(''))
.catch((err: unknown) => setError(err instanceof Error ? err.message : '二维码生成失败'))
}, [content])
return (
<div className="relative h-44 w-44 shrink-0 rounded-xl border border-gray-200 bg-white">
<canvas
ref={canvasRef}
width={QR_SIZE}
height={QR_SIZE}
role="img"
aria-label="115 授权二维码"
style={{ width: QR_SIZE, height: QR_SIZE }}
/>
{error && (
<div className="absolute inset-0 grid place-items-center rounded-xl bg-gray-50 p-3 text-center text-xs text-rose-500">
{error}
</div>
)}
</div>
)
}
type AuthSourceKey = 'built_in_appid' | 'custom_appid' | 'built_in_relay' | 'third_party_service'
const AUTH_SOURCE_OPTIONS: { key: AuthSourceKey; label: string; desc: string; needsRelayKey?: boolean }[] = [
{ key: 'built_in_appid', label: '官方应用目录', desc: '设备码扫码授权,无需回跳' },
{ key: 'custom_appid', label: '自定义 APP ID', desc: '使用自己申请的开放平台应用' },
{ key: 'built_in_relay', label: '中继授权', desc: 'QMediaSync / MQFamily 中继', needsRelayKey: true },
{ key: 'third_party_service', label: '第三方服务', desc: 'MoviePilot / CloudDrive' },
]
const inputCls = 'input-base w-full'
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<label className="block space-y-1">
<span className="text-sm font-medium text-ink-100">{label}</span>
{children}
{hint && <span className="block text-xs text-sand-500">{hint}</span>}
</label>
)
}
export function Strm115AuthPanel({
existing,
accountName,
onAuthed,
}: {
existing: StrmAccount | null
accountName: string
onAuthed: (account: StrmAccount) => void
}) {
const [sources, setSources] = useState<{ built_in: Strm115Source[]; relay: Strm115Source[]; third_party: Strm115Source[] } | null>(null)
const [relayKeyConfigured, setRelayKeyConfigured] = useState(false)
const [authSource, setAuthSource] = useState<AuthSourceKey>('built_in_appid')
const [thirdParty, setThirdParty] = useState<'moviepilot' | 'clouddrive'>('moviepilot')
const [relayProvider, setRelayProvider] = useState<'qmediasync' | 'mqfamily'>('qmediasync')
const [appID, setAppID] = useState('100195125')
const [appKeyword, setAppKeyword] = useState('')
const [starting, setStarting] = useState(false)
const [authUI, setAuthUI] = useState<{ sessionId: string; accountId: string; mode: 'qrcode' | 'url'; authUrl?: string; qrcodeUrl?: string } | null>(null)
const [authStatus, setAuthStatus] = useState('')
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
useEffect(() => stopPolling, [])
useEffect(() => {
;(async () => {
try {
const data = await strmAPI.list115Sources()
setSources(data)
const settings = await strmAPI.getSettings().catch(() => null)
const key = settings?.['strm.115_relay_key']
setRelayKeyConfigured(Boolean(key && key.trim()))
} catch (err) {
toast.error(apiErrorMessage(err))
}
})()
}, [])
const filteredApps = useMemo(
() =>
(sources?.built_in ?? []).filter(
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
),
[appKeyword, sources],
)
useEffect(() => {
if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) {
setAppID(filteredApps[0].app_id)
}
}, [appID, filteredApps])
const buildStartPayload = () => {
switch (authSource) {
case 'built_in_appid':
return { auth_source: 'built_in_appid', app_id: appID }
case 'custom_appid':
return { auth_source: 'custom_appid', app_id: appID }
case 'built_in_relay':
return { auth_source: 'built_in_relay', provider: relayProvider }
case 'third_party_service':
return { auth_source: 'third_party_service', provider: thirdParty }
}
}
const startAuth = async () => {
if (authSource === 'built_in_appid' && filteredApps.length === 0) {
toast.error('未找到匹配的官方应用')
return
}
setStarting(true)
setAuthStatus('')
try {
// 115 授权需要账号 ID:没有则先创建空凭据账号
let account = existing
if (!account) {
account = await strmAPI.createAccount({ name: accountName || '115 网盘', provider: 'cloud115', config: {} })
}
const result = await strmAPI.start115OAuth(account.id, buildStartPayload())
setAuthUI(
result.mode === 'qrcode'
? { sessionId: result.session_id, accountId: account.id, mode: 'qrcode', qrcodeUrl: result.qrcode?.qrcode }
: { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url },
)
stopPolling()
pollRef.current = setInterval(async () => {
try {
const status = await strmAPI.poll115OAuth(account.id, result.session_id)
setAuthStatus(status.tip)
if (status.status === 'confirmed') {
stopPolling()
const updated = await strmAPI.testAccount(account.id)
onAuthed(updated)
}
if (status.status === 'expired') {
stopPolling()
setAuthUI(null)
toast.error('授权已过期,请重新发起')
}
} catch {
/* 轮询失败等下一次 */
}
}, 3000)
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setStarting(false)
}
}
const openAuthWindow = () => {
if (authUI?.mode === 'url' && authUI.authUrl) {
window.open(authUI.authUrl, '_blank', 'noopener')
}
}
const resetAuth = () => {
stopPolling()
setAuthUI(null)
setAuthStatus('')
}
return (
<div className="space-y-4 rounded-2xl bg-gray-50 p-4">
{existing?.has_credential && (
<p className="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-600">
✓ 该账号已授权;重新授权会替换现有令牌
</p>
)}
{!authUI && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
{AUTH_SOURCE_OPTIONS.map((option) => {
const active = authSource === option.key
const disabled = Boolean(option.needsRelayKey && !relayKeyConfigured)
return (
<button
key={option.key}
type="button"
disabled={disabled}
onClick={() => setAuthSource(option.key)}
className={
'rounded-xl border-2 p-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-40 ' +
(active ? 'border-brand-400 bg-brand-50' : 'border-gray-100 bg-white hover:border-gray-200')
}
>
<p className="text-sm font-bold text-ink-600">{option.label}</p>
<p className="text-[11px] text-sand-500">{option.desc}</p>
{disabled && <p className="text-[10px] text-rose-400">需在 STRM 设置配置共享密钥</p>}
</button>
)
})}
</div>
{authSource === 'built_in_appid' && (
<div className="space-y-2">
<Field label="选择官方应用" hint="应用目录来自 QMediaSync 内置;媒体播放器 / 飞牛 / 恒星等">
<div className="flex items-center gap-2">
<Search size={14} className="shrink-0 text-sand-400" />
<input className={inputCls} placeholder="搜索应用名称或 ID…" value={appKeyword} onChange={(e) => setAppKeyword(e.target.value)} />
</div>
<select className={inputCls} value={appID} onChange={(e) => setAppID(e.target.value)} size={5}>
{filteredApps.map((source) => (
<option key={source.app_id} value={source.app_id}>
{source.display_name}({source.app_id})
</option>
))}
</select>
{filteredApps.length === 0 && <p className="text-xs text-sand-500">未找到匹配的官方应用</p>}
</Field>
</div>
)}
{authSource === 'custom_appid' && (
<Field label="自定义 APP ID" hint="使用自己在 115 开放平台申请的应用 ID">
<input className={inputCls} value={appID} placeholder="100195125" onChange={(e) => setAppID(e.target.value)} />
</Field>
)}
{authSource === 'built_in_relay' && (
<Field label="中继服务">
<select className={inputCls} value={relayProvider} onChange={(e) => setRelayProvider(e.target.value as typeof relayProvider)}>
<option value="qmediasync">QMediaSync(oauth.qmediasync.cn)</option>
<option value="mqfamily">MQFamily(api.mqfamily.top)</option>
</select>
</Field>
)}
{authSource === 'third_party_service' && (
<Field label="第三方授权服务">
<select className={inputCls} value={thirdParty} onChange={(e) => setThirdParty(e.target.value as typeof thirdParty)}>
<option value="moviepilot">MoviePilot(https://movie-pilot.org)</option>
<option value="clouddrive">CloudDrive(redirect115.zhenyunpan.com)</option>
</select>
</Field>
)}
<button
type="button"
onClick={startAuth}
disabled={starting || (authSource === 'built_in_appid' && filteredApps.length === 0)}
className="neon-button disabled:opacity-50"
>
{starting ? <Loader2 size={16} className="animate-spin" /> : <QrCode size={16} />}
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
</button>
</div>
)}
{authUI && (
<div className="space-y-3">
{authUI.mode === 'qrcode' && authUI.qrcodeUrl ? (
<div className="flex items-center gap-4">
<QrCanvas content={authUI.qrcodeUrl} />
<div className="space-y-1.5 text-sm">
<p className="font-medium text-ink-600">{authStatus || '等待扫码…'}</p>
<p className="text-xs text-sand-500">请使用 115 手机客户端扫码并确认授权,5 分钟内有效</p>
</div>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-ink-100">请点击下方按钮在新窗口完成授权:</p>
<button type="button" onClick={openAuthWindow} className="neon-button">
<ExternalLink size={16} />
打开授权页面
</button>
<p className="text-xs text-sand-500">完成后回到此页面等待自动确认(授权成功后可关闭弹窗)</p>
</div>
)}
<div className="flex items-center gap-2 text-sm">
<Loader2 size={14} className="animate-spin text-brand-500" />
<span className="text-ink-50">{authStatus}</span>
</div>
<button
type="button"
onClick={resetAuth}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50"
>
<RefreshCw size={14} />
重新发起授权
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,218 @@
import type { ReactNode } from 'react'
import { AlertCircle, Ban, Copy, FileText, Film, Image as ImageIcon, File, MessageSquare, RefreshCw, Trash2, X } from 'lucide-react'
import type { StrmTask } from '../../types/strm'
import { STRM_PROVIDER_LABELS } from '../../types/strm'
import { formatBytes, formatTime, taskStatusMeta } from '../StrmManagePage'
export function getFileIcon(filename: string): ReactNode {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
return <FileText size={15} className="text-amber-500 shrink-0" />
}
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
}
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
}
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
return <Film size={15} className="text-emerald-500 shrink-0" />
}
return <File size={15} className="text-gray-400 shrink-0" />
}
export function TaskDetailModal({
task,
isDownload,
onClose,
onRetry,
onCancel,
onDelete,
onCopy,
}: {
task: StrmTask
isDownload: boolean
onClose: () => void
onRetry: (t: StrmTask) => void
onCancel: (t: StrmTask) => void
onDelete: (t: StrmTask) => void
onCopy: (text: string, label: string) => void
}) {
const status = taskStatusMeta(task.status)
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
<div className="flex items-center gap-2">
{getFileIcon(task.file_name)}
<h3 className="font-display text-base font-bold text-ink-600">任务详情</h3>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
>
<X size={18} />
</button>
</div>
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
{/* Main Info Box */}
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">任务 ID</span>
<span className="font-mono text-ink-100 select-all">{task.id}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">文件名称</span>
<span className="font-bold text-ink-600 select-all">{task.file_name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">文件大小</span>
<span className="font-mono text-ink-100">{formatBytes(task.size)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">当前状态</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
>
{status.label}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">云盘提供方</span>
<span className="font-medium text-ink-100">
{STRM_PROVIDER_LABELS[task.provider] ?? task.provider}
</span>
</div>
{task.retry_count > 0 && (
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">已重试次数</span>
<span className="font-bold text-amber-600">{task.retry_count} 次</span>
</div>
)}
</div>
{/* Paths */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sand-500 font-medium">
<span>{isDownload ? '本地输出目标路径' : '本地来源路径'}</span>
<button
type="button"
onClick={() => onCopy(task.local_path, '本地路径')}
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
>
<Copy size={11} /> 复制
</button>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
{task.local_path}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sand-500 font-medium">
<span>远端网盘路径</span>
<button
type="button"
onClick={() => onCopy(task.remote_path, '远端路径')}
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
>
<Copy size={11} /> 复制
</button>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
{task.remote_path}
</div>
</div>
{/* Error Message Box */}
{task.error && (
<div className="space-y-2">
<div className="flex items-center justify-between text-rose-500 font-medium">
<span className="flex items-center gap-1">
<AlertCircle size={13} /> 错误详情
</span>
<button
type="button"
onClick={() => onCopy(task.error!, '错误信息')}
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
>
<Copy size={11} /> 复制错误
</button>
</div>
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
{task.error}
</div>
</div>
)}
{/* Timeline */}
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
<div>创建时间:{formatTime(task.created_at)}</div>
{task.started_at && <div>开始时间:{formatTime(task.started_at)}</div>}
{task.finished_at && <div>结束时间:{formatTime(task.finished_at)}</div>}
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
<div>
{(task.status === 'done' ||
task.status === 'failed' ||
task.status === 'canceled') && (
<button
type="button"
onClick={() => onDelete(task)}
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
>
<Trash2 size={13} />
删除记录
</button>
)}
</div>
<div className="flex items-center gap-2">
{(task.status === 'pending' || task.status === 'running') && (
<button
type="button"
onClick={() => onCancel(task)}
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
>
<Ban size={13} />
取消任务
</button>
)}
{(task.status === 'failed' || task.status === 'canceled') && (
<button
type="button"
onClick={() => onRetry(task)}
className="neon-button !py-2 !px-4 text-xs font-semibold"
>
<RefreshCw size={13} />
重新入队
</button>
)}
<button
type="button"
onClick={onClose}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
>
关闭
</button>
</div>
</div>
</div>
</div>
)
}
+25 -15
View File
@@ -14,6 +14,10 @@ interface PermissionState {
clearPermissions: () => void
}
// 进行中的请求共享同一个 Promise:同一次 commit 内挂载的多个消费方
// 同时触发 fetchPermissions 时只会发出一次 /permissions 请求。
let inflight: Promise<void> | null = null
export const usePermissionStore = create<PermissionState>((set, get) => ({
permissions: {},
role: '',
@@ -23,22 +27,28 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
error: null,
fetchPermissions: async () => {
if (inflight) return inflight
set({ isLoading: true, error: null })
try {
const result = await getMyPermissions()
set({
permissions: result.permissions ?? {},
role: result.role ?? '',
tier: result.tier ?? 'free',
isSuper: result.is_super ?? false,
isLoading: false,
})
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
})
}
inflight = (async () => {
try {
const result = await getMyPermissions()
set({
permissions: result.permissions ?? {},
role: result.role ?? '',
tier: result.tier ?? 'free',
isSuper: result.is_super ?? false,
isLoading: false,
})
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
})
} finally {
inflight = null
}
})()
return inflight
},
hasPermission: (key: string) => {