fix organizer diagnostics and site integrations

This commit is contained in:
ShukeBta
2026-06-21 09:44:12 +08:00
parent 6e61c8b545
commit 7b881d031c
37 changed files with 2012 additions and 164 deletions
+1
View File
@@ -14,6 +14,7 @@ export interface ExternalMediaResult {
source: string
media_type?: string
title: string
original_name?: string
overview?: string
poster_url?: string
backdrop_url?: string
+1
View File
@@ -8,6 +8,7 @@ export interface SiteSearchResult {
site_name: string
site_id: string
title: string
subtitle?: string
torrent_url: string
download_url: string
category?: string
+17
View File
@@ -1,6 +1,23 @@
import { api } from './client'
import type { Subscription } from '../types'
export function buildSiteSearchFeedURL(keyword: string, source?: string, aliases: string[] = []) {
const params = new URLSearchParams()
params.set('keyword', keyword)
if (source) params.set('source', source)
const seen = new Set([keyword.trim().toLowerCase()])
aliases
.map((alias) => alias.trim())
.filter(Boolean)
.forEach((alias) => {
const key = alias.toLowerCase()
if (seen.has(key)) return
seen.add(key)
params.append('alias', alias)
})
return `site-search://search?${params.toString()}`
}
export const subscriptionsAPI = {
list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
+2 -2
View File
@@ -5,7 +5,7 @@ import toast from 'react-hot-toast'
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
import { imageURL } from '../api/client'
import { subscriptionsAPI } from '../api/subscriptions'
import { buildSiteSearchFeedURL, subscriptionsAPI } from '../api/subscriptions'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
@@ -238,7 +238,7 @@ export function AIAssistantPage() {
onClick={async () => {
setSubscribing(key)
try {
const feed = `site-search://search?keyword=${encodeURIComponent(keyword)}&source=${encodeURIComponent(item.source)}`
const feed = buildSiteSearchFeedURL(keyword, item.source, [item.title, item.original_name || ''])
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
+2 -2
View File
@@ -4,7 +4,7 @@ import { AlertTriangle, Download, Info, Rss, Sparkles, X } from 'lucide-react'
import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover'
import { imageURL } from '../api/client'
import { subscriptionsAPI } from '../api/subscriptions'
import { buildSiteSearchFeedURL, subscriptionsAPI } from '../api/subscriptions'
const defaultSections = [
'tmdb_trending_day',
@@ -258,7 +258,7 @@ function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: (
const submit = async () => {
const finalKeyword = form.keyword.trim() || keyword
const feed = `site-search://search?keyword=${encodeURIComponent(finalKeyword)}&source=${encodeURIComponent(source)}`
const feed = buildSiteSearchFeedURL(finalKeyword, source, [item.title, item.original_name || ''])
setBusy(true)
try {
const sub = await subscriptionsAPI.create({
+17 -2
View File
@@ -200,6 +200,8 @@ export function FileManagerPage() {
() => libraries.filter((library) => !isCloudLibraryPath(library.path)),
[libraries],
)
const autoMoveKeepsSeeding = autoConfig.transferMode === 'move' && settingOn(autoConfig.keepSeeding)
const manualMoveKeepsSeeding = organizeTransferMode === 'move' && settingOn(autoConfig.keepSeeding)
const refresh = useCallback(() => {
setLoading(true)
@@ -568,7 +570,7 @@ export function FileManagerPage() {
onChange={(event) => changeAutoConfig('transferMode', event.target.value)}
>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="move">移动(关闭保种才会移动)</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
@@ -585,6 +587,13 @@ export function FileManagerPage() {
</label>
</div>
{autoMoveKeepsSeeding && (
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
当前同时选择了“移动”和“保种”。为避免 qB 做种源文件被删除,后端会实际使用硬链接;Docker / NAS
多挂载或不同子卷下可能报 invalid cross-device link。需要真正移动时请关闭“保种”,需要保种但硬链接失败时请选择“复制”。
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
@@ -780,13 +789,19 @@ export function FileManagerPage() {
<span className="text-xs text-ink-50">整理方式</span>
<select className="input-base w-full" value={organizeTransferMode} onChange={(event) => setOrganizeTransferMode(event.target.value)}>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="move">移动(关闭保种才会移动)</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
</div>
{manualMoveKeepsSeeding && (
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
“保种”已开启,选择“移动”时后端会改用硬链接以保留下载源。要执行真正移动,请先在上方自动整理设置里关闭“保种”并保存。
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input type="checkbox" checked={scanAfter} onChange={(event) => setScanAfter(event.target.checked)} />
+2 -2
View File
@@ -6,7 +6,7 @@ import { CheckCircle2, Info, Rss, Sparkles } from 'lucide-react'
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import { subscriptionsAPI } from '../api/subscriptions'
import { buildSiteSearchFeedURL, subscriptionsAPI } from '../api/subscriptions'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
@@ -219,7 +219,7 @@ export function SearchPage() {
const key = `${item.source}:${keyword}`
setSubscribing(key)
try {
const feed = `site-search://search?keyword=${encodeURIComponent(keyword)}&source=${encodeURIComponent(item.source)}`
const feed = buildSiteSearchFeedURL(keyword, item.source, [item.title, item.original_name || ''])
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
+24
View File
@@ -95,6 +95,7 @@ export function SiteFormModal({
<option value="gazelle">Gazelle(HDBits等)</option>
<option value="unit3d">UNIT3D(BeyondHD等)</option>
<option value="mteam">馒头 M-Team(专用API)</option>
<option value="yemapt">YemaPT(API Auth)</option>
<option value="discuz">Discuz 论坛型</option>
<option value="custom_rss">自定义 RSS</option>
</select>
@@ -156,6 +157,27 @@ export function SiteFormModal({
</div>
)}
{form.type === "yemapt" && (
<div className="p-3 rounded-xl border border-cyan-500/30 bg-cyan-500/5">
<div className="text-sm font-medium text-cyan-400 mb-1">
YemaPT 配置指南
</div>
<div className="text-xs text-ink-50 space-y-1">
<div>
<b>站点地址:</b>
<code className="text-cyan-300">https://www.yemapt.org</code>
</div>
<div>
<b>认证方式:</b>使用个人详情页创建的第三方对接专用 auth
</div>
<div className="pl-3 text-sand-500">
填入下方 API Key;后端会按 Wiki 要求通过 Authorization
请求头原样发送。
</div>
</div>
</div>
)}
<div>
<label className="block text-sm text-ink-50 mb-2">认证方式</label>
<div className="flex gap-2 mb-3">
@@ -229,6 +251,8 @@ export function SiteFormModal({
<p className="text-xs text-sand-500 mt-1">
{form.type === "mteam"
? "馒头:控制台 → 实验室 → 存取令牌;第三方工具通过 x-api-key 请求头访问"
: form.type === "yemapt"
? "YemaPT:个人详情页 → 第三方对接专用 auth;通过 Authorization 请求头原样访问"
: "站点的访问 API Key"}
</p>
</div>
+21 -16
View File
@@ -15,6 +15,12 @@ import {
import { SiteCard } from "./SiteCard";
import { SiteFormModal } from "./SiteFormModal";
function apiErrorMessage(err: unknown, fallback: string): string {
const data = (err as { response?: { data?: { message?: string; error?: string } } })
?.response?.data;
return data?.message || data?.error || (err instanceof Error ? err.message : fallback);
}
export function SitesPage() {
const [sites, setSites] = useState<Site[]>([]);
const [loading, setLoading] = useState(true);
@@ -65,8 +71,10 @@ export function SitesPage() {
setEditingId(null);
};
const silentSave = async (): Promise<boolean> => {
if (!form.name.trim() || !form.url.trim()) return false;
const silentSave = async (): Promise<{ ok: boolean; message?: string }> => {
if (!form.name.trim() || !form.url.trim()) {
return { ok: false, message: "站点名称和地址不能为空" };
}
const payload = siteFormToPayload(form, !editingId);
try {
if (editingId) {
@@ -75,22 +83,22 @@ export function SitesPage() {
const response = await sitesAPI.create(payload);
setEditingId((response.data as Site)?.id ?? null);
}
return true;
} catch {
return false;
return { ok: true };
} catch (err: unknown) {
return { ok: false, message: apiErrorMessage(err, "保存失败") };
}
};
const handleSave = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
const ok = await silentSave();
if (ok) {
const result = await silentSave();
if (result.ok) {
toast.success(editingId ? "站点已更新" : "站点已添加");
closeModal();
await loadSites();
} else {
toast.error("保存失败");
toast.error(result.message || "保存失败");
}
setSaving(false);
};
@@ -98,10 +106,10 @@ export function SitesPage() {
const handleTest = async (id: string) => {
if (editingId === id) {
setSaving(true);
const ok = await silentSave();
const result = await silentSave();
setSaving(false);
if (!ok) {
toast.error("保存失败,无法测试");
if (!result.ok) {
toast.error(result.message || "保存失败,无法测试");
return;
}
}
@@ -111,10 +119,7 @@ export function SitesPage() {
const response = await sitesAPI.test(id);
toast.success(response?.message || "连接测试成功");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "连接测试失败";
toast.error(message);
toast.error(apiErrorMessage(err, "连接测试失败"));
} finally {
setTestingId(null);
}
@@ -141,7 +146,7 @@ export function SitesPage() {
setForm((current) => ({
...current,
type,
auth_type: type === "mteam" ? "api_key" : current.auth_type,
auth_type: type === "mteam" || type === "yemapt" ? "api_key" : current.auth_type,
}));
};
+50 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { Activity } from 'lucide-react'
import toast from 'react-hot-toast'
import { Activity, Copy } from 'lucide-react'
import { tasksAPI, type BackgroundTask, type TasksSnapshot } from '../api/tasks'
@@ -55,16 +56,50 @@ function formatMetrics(metrics?: Record<string, number>): string {
.join(' · ')
}
function hasTaskIssues(task: BackgroundTask): boolean {
return Boolean(task.metrics?.errors || task.metrics?.scan_errors || task.metrics?.scrape_errors)
}
function statusBadge(task: BackgroundTask) {
if (task.status === 'failed') {
return <span className="rounded-lg border border-red-400/40 px-1.5 py-0.5 text-xs text-red-500">failed</span>
}
if (hasTaskIssues(task)) {
return <span className="rounded-lg border border-orange-400/40 px-1.5 py-0.5 text-xs text-orange-500">issues</span>
}
if (task.status === 'completed') {
return <span className="rounded-lg border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-500">done</span>
}
return <span className="rounded-lg border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-500">running</span>
}
function taskCopyText(task: BackgroundTask): string {
const lines = [
`任务: ${task.name}`,
`状态: ${task.status}${hasTaskIssues(task) ? ' (issues)' : ''}`,
`阶段: ${task.stage || '-'}`,
`来源: ${task.source_path || '-'}`,
`目标: ${task.dest_path || '-'}`,
`消息: ${task.error || task.message || '-'}`,
]
const metrics = formatMetrics(task.metrics)
if (metrics) lines.push(`指标: ${metrics}`)
if (task.details?.length) {
lines.push('详情:')
lines.push(...task.details)
}
return lines.join('\n')
}
async function copyTask(task: BackgroundTask) {
try {
await navigator.clipboard.writeText(taskCopyText(task))
toast.success('任务详情已复制')
} catch {
toast.error('复制失败,请手动选中详情文本')
}
}
function BackgroundTaskTable({ tasks, empty }: { tasks: BackgroundTask[]; empty: string }) {
if (tasks.length === 0) return <p className="text-sand-500">{empty}</p>
return (
@@ -90,14 +125,24 @@ function BackgroundTaskTable({ tasks, empty }: { tasks: BackgroundTask[]; empty:
<td className="text-ink-100">{task.stage || '-'}</td>
<td>{statusBadge(task)}</td>
<td className="max-w-md text-ink-100">
<div>{task.error || task.message || '-'}</div>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 select-text break-words">{task.error || task.message || '-'}</div>
<button
type="button"
className="rounded-lg border border-gray-200 bg-white p-1 text-sand-500 hover:border-primary-400/40 hover:text-brand-500"
title="复制任务详情"
onClick={() => void copyTask(task)}
>
<Copy size={14} />
</button>
</div>
{formatMetrics(task.metrics) && (
<div className="mt-1 text-xs text-sand-500">{formatMetrics(task.metrics)}</div>
<div className="mt-1 select-text text-xs text-sand-500">{formatMetrics(task.metrics)}</div>
)}
{task.details && task.details.length > 0 && (
<div className="mt-2 max-h-28 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-2 font-mono text-[11px] text-sand-600">
<div className="mt-2 max-h-64 select-text overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-2 font-mono text-[11px] leading-relaxed text-sand-600">
{task.details.map((line, index) => (
<div key={`${task.id}-detail-${index}`} className="break-all">
<div key={`${task.id}-detail-${index}`} className="whitespace-pre-wrap break-words">
{line}
</div>
))}
+3
View File
@@ -6,6 +6,7 @@ export const SITE_TYPE_LABELS: Record<string, string> = {
gazelle: "Gazelle",
unit3d: "UNIT3D",
mteam: "M-Team",
yemapt: "YemaPT",
discuz: "Discuz",
custom_rss: "自定义 RSS",
};
@@ -15,6 +16,7 @@ export const SITE_TYPE_ABBR: Record<string, string> = {
gazelle: "GZ",
unit3d: "U3",
mteam: "MT",
yemapt: "YM",
discuz: "DZ",
custom_rss: "RS",
};
@@ -24,6 +26,7 @@ export const SITE_TYPE_COLORS: Record<string, string> = {
gazelle: "bg-purple-500/15 text-purple-400",
unit3d: "bg-orange-500/15 text-orange-400",
mteam: "bg-green-500/15 text-green-400",
yemapt: "bg-cyan-500/15 text-cyan-400",
discuz: "bg-yellow-500/15 text-yellow-400",
custom_rss: "bg-sand-500/15 text-ink-50",
};
+1 -1
View File
@@ -376,7 +376,7 @@ export interface SchedulerStatus {
export interface Site {
id: string
name: string
type: string // nexusphp / gazelle / unit3d / mteam / discuz / custom_rss
type: string // nexusphp / gazelle / unit3d / mteam / yemapt / discuz / custom_rss
url: string
auth_type: string // cookie / api_key / auth_header
cookie?: string // decrypted only in detail view