mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
fix organizer diagnostics and site integrations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user