mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
优化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user