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
+2 -6
View File
@@ -8,12 +8,8 @@
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cabinet+Grotesk:wght@400;500;600;700;800&family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<!-- 字体已自托管(见 src/index.css 的 @font-face),不再使用 Google Fonts 外链:
自托管环境常无法访问 Google,渲染阻塞外链会拖慢首屏。 -->
<title>MeBox</title>
</head>
<body class="font-body antialiased">
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

-1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 199 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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) => {
+20
View File
@@ -24,5 +24,25 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
// 只拆哈希长期稳定的核心库:业务代码发版后用户只需重新下载变小的
// 业务 chunk,核心 vendor 走浏览器长缓存。其余依赖保持 Vite 默认
// 分块,避免把 hls.js 等懒加载库拖进首屏。
manualChunks(id) {
if (!id.includes('node_modules')) return undefined
if (/[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom|scheduler)[\\/]/.test(id)) {
return 'vendor-react'
}
if (/[\\/]node_modules[\\/](axios|zustand)[\\/]/.test(id)) {
return 'vendor-data'
}
if (/[\\/]node_modules[\\/]framer-motion[\\/]/.test(id)) {
return 'vendor-motion'
}
return undefined
},
},
},
},
})