Files
imagefind/frontend/src/App.tsx
T

1659 lines
313 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Activity, AlertCircle, ArrowDown, ArrowLeft, ArrowUp, Bell, Bookmark, Boxes, CheckCircle2, ChevronLeft, ChevronRight,
Clock3, Cloud, Copy, Download, ExternalLink, Eye, EyeOff, Film, Folder, FolderPlus, HardDrive,
Grid2X2, GripVertical, Image as ImageIcon, KeyRound, Library, List, ListPlus, LockKeyhole, LogOut, Menu, MoreVertical,
Maximize, Monitor, Moon, Pause, Pencil, PictureInPicture, Play, Plus, RefreshCw, Search,
Settings, Share2, ShieldCheck, SkipBack, SkipForward, Sparkles, Sun, Tags, ThumbsUp, Trash2,
Upload, UserCircle2, Users, Volume2, VolumeX, Wifi, X
} from "lucide-react";
import {
type CSSProperties, type DragEvent as ReactDragEvent, FormEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type TouchEvent as ReactTouchEvent, useCallback, useEffect, useLayoutEffect,
useMemo, useRef, useState
} from "react";
import { createPortal } from "react-dom";
import type HlsInstance from "hls.js";
import { api, appBase, appMediaUrl, appUrl, downloadGeneratedBackup, restoreBackup, setCsrf, setGatewayMediaToken, setGatewaySession, uploadImage } from "./api";
type Page = "home" | "search" | "library" | "series" | "tags" | "actors" | "uploads" | "profile" | "settings" | "player";
type Source = {
id: string; kind: "local" | "webdav"; name: string; status: string; enabled: boolean;
last_scan_at?: string; last_error?: string; has_password?:boolean; config: Record<string, string | boolean | number>;
};
type Video = {
id: string; source_id: string; display_name: string; title: string; source_key: string;
source_name: string; source_kind: string; source_driver: string; source_mode?: string;
size_bytes: number; modified_at?: string; duration_ms?: number; width?: number; height?: number;
status: string; available: boolean; error?: string; catalog_code?: string; studio?: string;
series?: string; release_date?: string; description?: string; actors: string[]; tags: string[];
collection_id?:string;collection_name?:string;collection_position?:number;
collection_item_id?:string;collection_parent_id?:string|null;collection_path?:string[];
tag_items: TagItem[]; liked: boolean; favorited: boolean; progress_ms: number; completed: boolean;
last_played_at?: string; created_at?: string; updated_at?: string;
thumbnail_url?: string; playback_url: string; download_url: string;
};
type Result = {
frame_id: string; video_id: string; display_name: string; source_name: string; source_kind: string;
timestamp_ms: number; segment_start_ms: number; segment_end_ms: number; thumbnail_url: string;
playback_url: string; score: number; near_duplicate?: boolean; match_sources: string[];
match_details?: Array<{type:string;text:string;start_ms:number;end_ms:number}>;
catalog_code?: string;
};
type SearchCoverage = {available:boolean;model_version?:string;total:number;indexed:number;searchable:number;empty:number;queued:number;running:number;failed:number;low_quality?:number;pending:number;percent:number;current_model_percent:number};
type TranscriptItem = {id:string;start_ms:number;end_ms:number;raw_text:string;language?:string;quality_score?:number};
type TranscriptPage = {status:"ready"|"filtered"|"low_quality"|"empty"|"queued"|"running"|"failed"|"unavailable"|"pending";model_version?:string;indexed_version?:string;detected_language?:string;quality_score?:number;quality_state:"ready"|"filtered"|"low_quality"|"empty"|"legacy";rejected_segments:number;quality_flags:string[];aggregate_risk?:boolean;repeated_phrases?:{text:string;count:number}[];quality_score_semantics?:"rule_check";items:TranscriptItem[];page:number;page_size:number;total:number;pages:number;job?:Pick<Job,"id"|"status"|"progress"|"message"|"error">};
type SpeechConfig = {language_policy:"zh_priority"|"auto"|"zh";quality_profile:"speed"|"balanced"|"accuracy";model_variant:"small"|"medium";index_revision:number};
type RecognitionType = "visual"|"ocr"|"person"|"audio"|"subtitle"|"metadata";
type UploadTask = {
id: string; filename: string; title?: string; source_id: string; status: string; progress: number;
bytes_received: number; size_bytes: number; message?: string; error?: string;
relative_path: string; chunk_size: number; total_chunks: number; received_chunks: number[];
stage?:string;stage_label?:string;stages?:Array<{key:string;state:"pending"|"active"|"completed"|"failed"|"cancelled";progress:number;label:string}>;failure_stage?:"transfer"|"commit"|"catalog";
can_cancel?:boolean;can_retry?:boolean;
content_sha256?:string;deduplicated?:boolean;retry_count?:number;next_retry_at?:string;
transferred_bytes?:number;resume_mode?:"restart"|"offset"|"deduplicated";
phase?:"receiving"|"queued"|"encrypting"|"transferring"|"external_copying"|"external_verifying"|"indexing"|"completed"|"failed"|"cancelled";
storage_backend?:"legacy_webdav"|"openlist_native";receive_progress?:number;
external_progress?:number;external_status?:string;external_task_ref?:string;
collection_id?:string;collection_parent_id?:string;tag_ids?:string[];origin?:"browser"|"webdav";
};
type UploadPage = {items:UploadTask[];status_items:UploadTask[];active_count:number;failed_count:number;page:number;page_size:number;total:number;pages:number};
type DownloadTask = {
id:string;kind:"url"|"magnet"|"torrent";display_name:string;source_id:string;relative_path:string;
status:string;progress:number;total_bytes:number;completed_bytes:number;download_speed:number;
error?:string;files:string[];created_at:string;
};
type Person = { id: string; name: string; face_count: number; thumbnail_url?: string; is_named: boolean; hidden?: boolean; actor_id?: string };
type Actor = { id: string; name: string; video_count: number; thumbnail_url?: string; person_id?: string; aliases?: string[] };
type JobLane = "ai"|"transfer"|"download"|"scan";
type InferenceDiagnostics = {requested_device?:string;actual_device?:string;primary_backend?:string;language_candidates?:string[];selected_language?:string;fallback_scope?:"job"|"component"|null;fallback_reason?:string|null;gpu_quality_score?:number|null;cpu_verification_quality?:number|null};
type Job = { id:string;kind:string;lane?:JobLane;status:string;progress:number;message?:string;error?:string;attempts:number;created_at:string;started_at?:string;finished_at?:string;heartbeat_at?:string;pause_reason?:string;cancel_requested?:number;inference_diagnostics?:InferenceDiagnostics };
type JobPage = {items:Job[];page:number;page_size:number;total:number;pages:number;retryable_failed_count:number};
type FileItem = { key: string; name: string; is_dir: boolean; size_bytes: number; modified_at?: string };
type TrashItem = { id: string; source_id: string; display_name: string; original_key: string; size_bytes: number; deleted_at: string; expires_at: string };
type TagItem = { id: string; name: string; group_id: string; group_name: string; selection_mode: "single"|"multi" };
type Tag = TagItem & { ai_enabled: boolean; ai_method: "visual"|"text"; ai_description: string; ai_threshold: number; match_terms: string[]; video_count: number };
type TagGroup = { id: string; name: string; selection_mode: "single"|"multi"; sort_order: number; tag_count: number; video_count: number; tags: Tag[] };
type Suggestion = { id: string; video_id: string; video_title?: string; tag_id: string; tag_name: string; group_id: string; group_name: string; selection_mode: "single"|"multi"; confidence: number; status: string; evidence: Array<{kind:string;text?:string;frame_id?:string;thumbnail_url?:string;timestamp_ms?:number;start_ms?:number;score?:number}> };
type ThemePreference = "system"|"light"|"dark";
type CollectionViewMode = "index"|"detail"|"manage";
type CollectionRoute = {mode:CollectionViewMode;id?:string};
type Preferences = { autoplay: boolean; mask_covers: boolean; theme:ThemePreference; home_video_columns:1|2; upload_paths: Record<string,string>; notifications_seen_at?: string };
type ActivityItem = { id: string; type: string; kind: string; status: string; message?: string; error?: string; timestamp: string; progress?: number };
type BackupStatus = { empty:boolean;can_restore:boolean;blockers:Record<string,number>;business_counts:Record<string,number>;supported_format_versions:number[];max_upload_bytes:number;max_extract_bytes:number };
type BackupResult = { scope:"keys"|"full";created_at:string;source_app_version:string;counts:Record<string,number>;scan_jobs:Array<{source_id:string;job_id:string}>;warnings:string[] };
type BackupExport = {id:string;job_id?:string;scope:"keys"|"full";status:"queued"|"running"|"completed"|"failed";filename?:string;size_bytes?:number;error?:string;created_at?:string;finished_at?:string;expires_at?:string};
type Profile = {nickname:string;avatar:{text:string;color:string;accent?:string};role:string;created_at?:string;counts:{favorites:number;likes:number;history:number;continue_watching:number}};
type CollectionItem = {id:string;name:string;description:string;cover_video_id?:string;video_count:number;duration_ms:number;thumbnail_url?:string;created_at?:string;updated_at?:string};
type HomeVideoSection = {id:string;title:string;total:number;items:Video[];tag_id?:string;group_name?:string};
type HomeFeed = {video_count:number;updated_at?:string;recent:HomeVideoSection;collections:{total:number;items:CollectionItem[]};categories:HomeVideoSection[];people:Actor[];unorganized:HomeVideoSection|null};
type CollectionTreeItem = {id:string;collection_id:string;parent_id?:string|null;kind:"group"|"video";name?:string;video_id?:string;position:number;path:string[];duration_ms:number;video_count?:number;children?:CollectionTreeItem[];video?:Video};
type CollectionDetail = CollectionItem & {videos:Video[];items:CollectionTreeItem[]};
type VideoMarker = {id:string;video_id:string;position_ms:number;title?:string;created_at:string;updated_at:string};
type StorageEntry = {bytes:number|null;available:boolean;mode?:string;reason?:string;active_bytes?:number;reclaimable_bytes?:number;entries?:number;children?:Record<string,StorageEntry>;locations?:Array<{source_id:string;name:string;path:string;bytes:number;available:boolean;reason?:string}>;instances?:Array<{source_id:string;instance_id:string;state:string;active_refs:number;cache_bytes:number;runtime_bytes:number;last_access_at?:string|null}>};
type StorageUsage = {schema_version?:number;collected_at?:string;disk:{total_bytes:number;used_bytes:number;available_bytes:number};app_bytes:number;categories:Record<string,StorageEntry>;cached_seconds?:number};
type SourceCapacity = {source_id:string;scope:"local"|"remote";status:"available"|"unsupported"|"unreachable";total_bytes:number;used_bytes:number;available_bytes:number;provider:"local"|"rclone"|"webdav"|"manual";collected_at:string;cached_seconds:number;reason?:string};
type ResourceLaneStatus = {limit:number;running:number;queued:number;paused:number;pressure_reason?:string};
type DatabaseResourceStatus = {engine:"postgresql";journal_mode:"server";writer_queue_depth:number;writer_active:boolean;last_wait_ms:number;max_wait_ms:number;lock_retry_count:number;slow_transaction_count:number;pool_size?:number;pool_in_use?:number;pool_idle?:number;pool_waiters?:number};
type ResourceProfile = "quiet"|"balanced"|"turbo";
type ResourceIoStatus = {supported:boolean;state:"normal"|"paused"|"emergency";scope?:"data_volume"|"system";some_avg10:number;some_avg60:number;full_avg10:number;full_avg60:number;effective_limit_bytes:number;volume_supported?:boolean;volume_sampled?:boolean;volume_device?:string;volume_mount_point?:string;volume_in_flight?:number;volume_utilization_percent?:number};
type AiGpuMode = "low"|"balanced"|"throughput";
type ResourceStatus = {profile:ResourceProfile;ai_gpu_mode?:AiGpuMode;ai_gpu_profile?:{mode:AiGpuMode;selected_mode?:AiGpuMode;degraded?:boolean;hint:string;streams:number;batch_size:number};gpu?:{supported:boolean;utilization_percent?:number|null;engines:Record<string,number|null>;memory_supported:boolean;memory_total_bytes?:number|null;memory_used_bytes?:number|null;collector?:string|null;reason?:string|null;collected_at:string};manual_pause:boolean;pressure_reason?:string;running_jobs:number;paused_jobs:number;cpu_percent:number;memory_total_bytes:number;memory_available_bytes:number;disk_available_bytes:number;database?:DatabaseResourceStatus;lanes?:Record<JobLane,ResourceLaneStatus>;policy:{cpu_pause_percent:number;cpu_resume_percent:number;memory_reserve_mb:number;memory_reserve_percent:number;disk_reserve_gb:number};io?:ResourceIoStatus;remote_cache?:{limit_bytes:number;used_bytes:number;active_bytes:number;evictable_bytes:number;entries:number;downloading:number};rclone?:{managed:number;orphaned:number;draining:number;verifying:number;unresolved:number;reclaimed:number;last_checked_at?:string;next_check_seconds:number;reasons:Record<string,number>;cache_bytes:number;vfs_cache_bytes?:number;active_cache_bytes?:number;reclaimable_cache_bytes?:number;runtime_bytes?:number;encryption_staging_bytes?:number;instances?:StorageEntry["instances"]}};
type WebDavServerConfig = {enabled:boolean;source_id:string|null;relative_path:string;url:string;username:string;direct_access:boolean;direct_port:number;gateway_supported:false};
type PullRefreshPhase = "idle"|"pulling"|"ready"|"refreshing"|"settling";
type PlayerPointerState = {id:number;x:number;y:number;position:number;preview:number;axis:"pending"|"horizontal"|"vertical";held:boolean;priorRate:number;pointerType:string;controlsVisibleAtStart:boolean};
type AccessMode = "direct" | "gateway";
type ModelComponent = "visual"|"ocr"|"faces"|"audio";
type ModelInstallation = {job_id:string;status:"queued"|"running"|"failed"|"completed";progress:number;message?:string;error?:string};
type LoadStatus = {phase:"idle"|"loading"|"ready"|"error";refreshing:boolean;error?:string};
type AppResource = "home"|"sources"|"videos"|"uploads"|"downloads"|"people"|"actors"|"groups"|"preferences"|"activity"|"profile"|"collections";
type AppHistoryState =
| {kind:"page";page:Page;scrollY:number;videoId?:string;position?:number;homeSection?:string;collectionId?:string;collectionMode?:CollectionViewMode}
| {kind:"more";page:Page;scrollY:number};
type DialogRequest = {
kind:"confirm"|"input"|"choice";title:string;message?:string;defaultValue?:string;danger?:boolean;
confirmLabel?:string;cancelLabel?:string;
resolve:(value:boolean|string|null)=>void;
};
function normalizeCollectionDetail(value:CollectionDetail):CollectionDetail{
const videos=Array.isArray(value.videos)?value.videos:[];
if(Array.isArray(value.items))return {...value,videos};
return {...value,videos,items:videos.map((video,position)=>({id:video.collection_item_id||`legacy-${video.id}`,collection_id:value.id,parent_id:null,kind:"video" as const,video_id:video.id,position,path:[],duration_ms:video.duration_ms||0,video}))};
}
let showAppDialog:((request:DialogRequest)=>void)|null=null;
let showToast:((message:string,type?:"success"|"error")=>void)|null=null;
function appConfirm(title:string,message="",danger=false){return new Promise<boolean>(resolve=>showAppDialog?.({kind:"confirm",title,message,danger,resolve:value=>resolve(value===true)}))}
function appPrompt(title:string,defaultValue="",message=""){return new Promise<string|null>(resolve=>showAppDialog?.({kind:"input",title,message,defaultValue,resolve:value=>resolve(typeof value==="string"?value:null)}))}
function appChoice(title:string,message:string,confirmLabel:string,cancelLabel:string,danger=false){return new Promise<boolean|null>(resolve=>showAppDialog?.({kind:"choice",title,message,confirmLabel,cancelLabel,danger,resolve:value=>resolve(typeof value==="boolean"?value:null)}))}
function toast(message:string,type:"success"|"error"="success"){showToast?.(message,type)}
const idleLoad=():LoadStatus=>({phase:"idle",refreshing:false});
function mergeLoadStatus(...values:LoadStatus[]):LoadStatus{
const initial=values.find(value=>value.phase==="idle"||value.phase==="loading");
if(initial)return {phase:initial.phase,refreshing:false,error:initial.error};
const failed=values.find(value=>value.phase==="error");
if(failed)return failed;
return {phase:"ready",refreshing:values.some(value=>value.refreshing),error:values.find(value=>value.error)?.error};
}
function AsyncNotice({label="正在加载数据…",compact=false}:{label?:string;compact?:boolean}){
return <div className={`async-notice ${compact?"compact":""}`} role="status" aria-live="polite"><RefreshCw className="spin"/><span>{label}</span><i/><i/><i/></div>;
}
function AsyncBoundary({status,label,onRetry,children}:{status:LoadStatus;label:string;onRetry:()=>void;children:ReactNode}){
if(status.phase==="idle"||status.phase==="loading")return <div className="page async-page"><AsyncNotice label={`正在加载${label}…`}/></div>;
if(status.phase==="error")return <div className="page async-page"><div className="empty async-error"><AlertCircle/><h3>{label}加载失败</h3><p>{status.error||"暂时无法读取数据,请稍后重试。"}</p><button className="secondary" onClick={onRetry}><RefreshCw/>重新加载</button></div></div>;
return <>{(status.refreshing||status.error)&&<div className={`async-update ${status.error?"failed":""}`} role="status">{status.error?<AlertCircle/>:<RefreshCw className="spin"/>}<span>{status.error?`${label}更新失败:${status.error}`:`正在更新${label}…`}</span>{status.error&&<button onClick={onRetry}>重试</button>}</div>}{children}</>;
}
function mobileScrollContainer(){
if(!window.matchMedia("(max-width: 720px)").matches)return null;
return document.querySelector<HTMLElement>(".content");
}
function scrollPosition(){return mobileScrollContainer()?.scrollTop??window.scrollY}
function scrollToPosition(top=0,behavior:ScrollBehavior="auto"){
const container=mobileScrollContainer();
if(container){container.scrollTo({top,behavior});return}
window.scrollTo({top,behavior});
}
function scrollInstant(top=0){
const target=mobileScrollContainer();const root=target||document.documentElement;const prior=root.style.scrollBehavior;
root.style.scrollBehavior="auto";scrollToPosition(top,"auto");root.style.scrollBehavior=prior;
}
const THEME_STORAGE_KEY="imagefind:theme";
function storedTheme():ThemePreference{
const value=document.documentElement.dataset.themePreference||localStorage.getItem(THEME_STORAGE_KEY);
return value==="light"||value==="dark"?value:"system";
}
function applyTheme(theme:ThemePreference){
const dark=theme==="dark"||(theme==="system"&&window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme=dark?"dark":"light";
document.documentElement.dataset.themePreference=theme;
document.documentElement.style.colorScheme=dark?"dark":"light";
document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.setAttribute("content",dark?"#0f1115":"#f7f8fa");
localStorage.setItem(THEME_STORAGE_KEY,theme);
}
function AppDialogHost(){
const [request,setRequest]=useState<DialogRequest|null>(null);const [value,setValue]=useState("");const input=useRef<HTMLInputElement>(null);
const close=useCallback((result:boolean|string|null)=>setRequest(current=>{current?.resolve(result);return null}),[]);
useEffect(()=>{showAppDialog=next=>{setRequest(next);setValue(next.defaultValue||"")};return()=>{showAppDialog=null}},[]);
useEffect(()=>{if(!request)return;input.current?.focus();const key=(event:KeyboardEvent)=>{if(event.key==="Escape")close(null)};window.addEventListener("keydown",key);return()=>window.removeEventListener("keydown",key)},[close,request]);
if(!request)return null;
const dismiss=request.kind==="confirm"?false:null;
return <div className="modal-backdrop app-dialog-backdrop" onMouseDown={event=>{if(event.target===event.currentTarget)close(dismiss)}}><form className="dialog app-dialog" role="alertdialog" aria-modal="true" onSubmit={event=>{event.preventDefault();close(request.kind==="input"?value:true)}}><header><div><p className="eyebrow">ImageFind</p><h2>{request.title}</h2></div><button type="button" className="icon-button" aria-label="关闭" onClick={()=>close(dismiss)}><X/></button></header>{request.message&&<p>{request.message}</p>}{request.kind==="input"&&<label>请输入<input ref={input} value={value} onChange={event=>setValue(event.target.value)}/></label>}<footer><button type="button" className="secondary" onClick={()=>close(request.kind==="choice"?false:dismiss)}>{request.cancelLabel||"取消"}</button><button className={request.danger?"primary destructive":"primary"}>{request.confirmLabel||(request.kind==="input"?"确定":"继续")}</button></footer></form></div>
}
function ToastHost(){
const [items,setItems]=useState<Array<{id:number;message:string;type:string}>>([]);
useEffect(()=>{showToast=(message,type="success")=>{const id=Date.now()+Math.random();setItems(current=>[...current,{id,message,type}]);window.setTimeout(()=>setItems(current=>current.filter(item=>item.id!==id)),2600)};return()=>{showToast=null}},[]);
return <div className="toast-host" aria-live="polite">{items.map(item=><div className={item.type} key={item.id}>{item.type==="success"?<CheckCircle2/>:<AlertCircle/>}{item.message}</div>)}</div>
}
function formatTime(ms = 0) {
const total = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60;
return h ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
}
function formatSize(value = 0) {
if (!value) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / 1024 ** index).toFixed(index > 2 ? 2 : index ? 1 : 0)} ${units[index]}`;
}
const mediaExtensionPattern="(?:mp4|m4v|mov|mkv|webm|avi|wmv|flv|ts|m2ts|mts|mpg|mpeg|3gp)";
function cleanMediaTitle(value:string){
const raw=(value||"").trim();
const leaf=raw.split(/[\\/]/).pop()?.trim()||raw;
const hashOnly=leaf.match(/^([a-f\d]{32,64})(?:\.[a-z\d]{2,5})*$/i);
if(hashOnly)return `未命名影片 · ${hashOnly[1].slice(0,8)}`;
let title=leaf
.replace(new RegExp(`\\.[a-f\\d]{32,64}(?:\\.${mediaExtensionPattern})*$`,"i"),"")
.replace(new RegExp(`^(?:[a-f\\d]{32,64})[\\s._-]+`,"i"),"")
.replace(new RegExp(`(?:\\.${mediaExtensionPattern})+$`,"i"),"")
.replace(/_+/g," ")
.replace(/^[\s.\-_·—–]+|[\s.\-_·—–]+$/g,"")
.trim();
if(!title)title=leaf.replace(/\.[^.]+$/,"").trim();
if(/^[a-f\d]{32,64}$/i.test(title))return `未命名影片 · ${title.slice(0,8)}`;
return title||"未命名影片";
}
function mediaTitle(video:Pick<Video,"title"|"display_name">){
const title=(video.title||"").trim();const fallback=(video.display_name||"").trim();
return title&&title!==fallback?title:cleanMediaTitle(fallback||title);
}
function resourceIoSummary(io?:ResourceIoStatus){
if(!io?.supported)return "当前系统不提供 I/O 指标,继续使用 CPU、内存和剩余空间保护";
const limit=`后台上限 ${formatSize(io.effective_limit_bytes||0)}/s`;
if(io.scope==="data_volume"){
if(!io.volume_sampled)return `正在采样应用数据卷${io.volume_mount_point?` ${io.volume_mount_point}`:""} · ${limit}`;
return `应用数据卷利用率 ${Number(io.volume_utilization_percent||0).toFixed(1)}% · 全局等待 ${Number(io.full_avg10||0).toFixed(1)}%(仅供参考) · ${limit}`;
}
return `整机部分等待 ${Number(io.some_avg10||0).toFixed(1)}% · 全部等待 ${Number(io.full_avg10||0).toFixed(1)}% · ${limit}`;
}
const storageLabels:Record<string,string>={database:"数据库",ai_index:"AI 索引",models:"模型",thumbnails:"缩略图",preview_cache:"预览与搜索图片",remote_cache:"远程访问缓存",upload_staging:"上传与下载暂存",other:"其他应用数据",remote_media_cache:"AI 远程媒体",rclone_vfs_cache:"rclone VFS 缓存",rclone_runtime:"rclone 运行文件",upload_receive:"上传接收暂存",download_staging:"后台下载暂存",encryption_work:"加密工作目录",openlist_local_staging:"OpenList 本地中转",query_images:"搜索图片",hls_preview:"HLS 预览"};
function storageLabel(key:string){return storageLabels[key]||key}
function storageEntryNote(value:StorageEntry){
if(value.available===false)return value.reason||"当前无法统计";
const active=value.active_bytes||0, reclaimable=value.reclaimable_bytes||0;
if(active||reclaimable)return `活动 ${formatSize(active)} · 可清理 ${formatSize(reclaimable)}`;
return "";
}
function StorageBreakdown({children}:{children:Record<string,StorageEntry>}){
return <div className="storage-breakdown">{Object.entries(children).map(([key,value])=><div className="storage-breakdown-row" key={key}><div><span>{storageLabel(key)}</span>{storageEntryNote(value)&&<small>{storageEntryNote(value)}</small>}{value.locations?.length? <small>{value.locations.length} OpenList 中转路径</small>:null}</div><b>{value.available===false?"无法统计":formatSize(value.bytes||0)}</b></div>)}</div>;
}
function AvatarMark({profile,className="",loading=false}:{profile:Profile|null;className?:string;loading?:boolean}){
if(loading)return <span className={`generated-avatar avatar-loading ${className}`.trim()} role="status" aria-label="正在加载个人资料"><RefreshCw className="spin"/></span>;
const primary=profile?.avatar.color||"#3974dc";const accent=profile?.avatar.accent||"#6f9df1";
return <span className={`generated-avatar ${className}`.trim()} style={{backgroundImage:`linear-gradient(145deg,${primary},${accent})`}}>{profile?.avatar.text||"IF"}</span>;
}
function jobLabel(kind:string){
return ({index_video:"解析视频资料",transcribe_audio:"识别视频音频",scan_source:"扫描媒体库",refresh_path:"刷新上传文件",install_models:"安装 AI 模型",prepare_ai_runtime:"修复 AI 运行环境",suggest_tags:"生成标签建议",transfer_upload:"传输上传文件",aria2_download:"后台下载"} as Record<string,string>)[kind]||kind.replaceAll("_"," ");
}
const jobLaneLabels:Record<JobLane,string>={ai:"AI 识别",transfer:"上传转存",download:"后台下载",scan:"来源扫描"};
function jobLaneForKind(kind:string):JobLane{
if(["transfer_upload","refresh_path"].includes(kind))return "transfer";
if(kind==="aria2_download")return "download";
if(kind==="scan_source")return "scan";
return "ai";
}
function sourceLabel(source?: Source | Video) {
if (!source) return "私有媒体库";
const mode = "config" in source ? source.config.mode : source.source_mode;
const driver = "config" in source ? source.config.driver : source.source_driver;
const backend = "config" in source ? source.config.storage_backend : undefined;
if (backend === "openlist_native") return mode === "encrypted" ? "OpenList 后台加密库" : "OpenList 后台复制";
if (driver === "alist") return mode === "encrypted" ? "AList 加密库" : "AList 直连";
return ("config" in source ? source.kind : source.source_kind) === "local" ? "本地媒体库" : "WebDAV";
}
function normalizeUploadPage(value:UploadPage|UploadTask[],requestedPage:number,pageSize=10):UploadPage{
if(!Array.isArray(value))return value;
const active=value.filter(item=>["receiving","queued","transferring","indexing"].includes(item.status));
const failed=value.filter(item=>item.status==="failed");const pages=Math.max(1,Math.ceil(value.length/pageSize));const page=Math.min(Math.max(1,requestedPage),pages);
return {items:value.slice((page-1)*pageSize,page*pageSize),status_items:[...active,...failed],active_count:active.length,failed_count:failed.length,page,page_size:pageSize,total:value.length,pages};
}
function UploadStatusButton({uploads,uploadCounts,downloads=[],onUpload,onCenter,label="上传",alwaysCenter=false,className="",loading=false}:{uploads:UploadTask[];uploadCounts?:Pick<UploadPage,"active_count"|"failed_count">;downloads?:DownloadTask[];onUpload:()=>void;onCenter:()=>void;label?:string;alwaysCenter?:boolean;className?:string;loading?:boolean}){
const activeUploads=uploads.filter(item=>["receiving","queued","transferring","indexing"].includes(item.status));
const activeDownloads=downloads.filter(item=>["queued","downloading","transferring"].includes(item.status));
const failedUploads=uploads.filter(item=>item.status==="failed");const failedDownloads=downloads.filter(item=>item.status==="failed");
const activeCount=(uploadCounts?.active_count??activeUploads.length)+activeDownloads.length;const failedCount=(uploadCounts?.failed_count??failedUploads.length)+failedDownloads.length;
const previous=useRef(activeCount);const [success,setSuccess]=useState(false);
useEffect(()=>{if(previous.current>0&&!activeCount&&!failedCount){setSuccess(true);const timer=window.setTimeout(()=>setSuccess(false),1800);previous.current=activeCount;return()=>window.clearTimeout(timer)}previous.current=activeCount},[activeCount,failedCount]);
const count=failedCount||activeCount;const mode=loading?"loading":failedCount?"failed":activeCount?"active":success?"success":"idle";
const firstDownload=activeDownloads[0];
const stage=failedCount?`${failedCount} 个任务失败`:activeUploads[0]?.message||(firstDownload?`${firstDownload.display_name} · 后台下载 ${Math.round(firstDownload.progress*100)}%`:"传输任务进行中");
const description=mode==="loading"?"正在读取传输任务状态":mode==="idle"?"选择影片上传或新建后台下载":mode==="success"?"传输任务已完成":`${stage},共 ${count} 个任务`;
const icon=mode==="loading"?<RefreshCw className="spin"/>:mode==="failed"?<AlertCircle/>:mode==="success"?<CheckCircle2/>:activeDownloads.length&&!activeUploads.length?<Download/>:<Upload/>;
return <button className={`upload-status ${mode} ${className}`.trim()} aria-busy={loading} aria-label={`${label}${description}`} title={description} onClick={()=>loading||alwaysCenter||count?onCenter():onUpload()}>{alwaysCenter?<span className="mobile-more-icon">{icon}</span>:icon}<span>{label}</span>{count>0&&<b aria-hidden="true">{count>99?"99+":count}</b>}<small className="sr-only" aria-live="polite">{description}</small></button>
}
function PreferenceSwitch({title,description,checked,onChange,disabled=false}:{title:string;description:string;checked:boolean;onChange:(value:boolean)=>void;disabled?:boolean}){
return <div className={`preference-item ${disabled?"disabled":""}`}><span><strong>{title}</strong><small>{description}</small></span><button type="button" role="switch" aria-checked={checked} aria-label={title} disabled={disabled} className={`preference-switch ${checked?"on":""}`} onClick={()=>onChange(!checked)}><i/><b>{checked?"开":"关"}</b></button></div>;
}
function ThemeSelector({value,onChange}:{value:ThemePreference;onChange:(value:ThemePreference)=>void}){
const options:Array<[ThemePreference,string,ReactNode]>=[["system","跟随系统",<Monitor/>],["light","浅色",<Sun/>],["dark","深色",<Moon/>]];
return <div className="theme-preference"><span><strong>外观主题</strong><small>跟随系统时会随手机或电脑的外观自动切换</small></span><div role="radiogroup" aria-label="外观主题">{options.map(([key,label,icon])=><button type="button" role="radio" aria-checked={value===key} className={value===key?"active":""} key={key} onClick={()=>onChange(key)}>{icon}<span>{label}</span></button>)}</div></div>;
}
function AuthScreen({ configured, onReady }: { configured: boolean; onReady: () => void }) {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [rememberDevice, setRememberDevice] = useState(true);
async function submit(event: FormEvent) {
event.preventDefault(); setError("");
if (!configured && password !== confirm) return setError("两次输入的密码不一致");
setBusy(true);
try {
const value = await api<{ csrf_token: string }>(configured ? "/auth/login" : "/setup", {
method: "POST", body: JSON.stringify({ password, remember_device: rememberDevice })
});
setCsrf(value.csrf_token); onReady();
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
}
return <main className="auth-shell"><section className="auth-card">
<div className="auth-brand"><span className="brand-symbol" /><strong>Image<span>Find</span></strong></div>
<div className="auth-lock"><LockKeyhole /></div>
<p className="eyebrow">私有媒体空间</p>
<h1>{configured ? "进入你的私有空间" : "创建管理员"}</h1>
<p className="muted">{configured ? "媒体、搜索记录与 AI 索引仅保存在你的 NAS。" : "设置独立密码,保护本机私有媒体库。"}</p>
<form onSubmit={submit}>
<label>管理员密码<input autoFocus type="password" value={password} minLength={configured ? 1 : 10} onChange={e => setPassword(e.target.value)} /></label>
{!configured && <label>确认密码<input type="password" value={confirm} minLength={10} onChange={e => setConfirm(e.target.value)} /></label>}
<label className="remember-device"><input type="checkbox" checked={rememberDevice} onChange={event=>setRememberDevice(event.target.checked)}/><span><strong>信任此设备</strong><small>{rememberDevice?"保持登录 90 天":"关闭浏览器后需要重新登录"}</small></span></label>
{error && <div className="error"><AlertCircle />{error}</div>}
<button className="primary wide" disabled={busy}>{busy ? "请稍候…" : configured ? "登录" : "创建并进入"}</button>
</form>
<p className="privacy-copy"><ShieldCheck /> 单管理员 · 本地 AI · 不上传分析数据</p>
</section></main>;
}
function GatewayAccessScreen({message}:{message:string}){
return <main className="auth-shell"><section className="auth-card gateway-access-card">
<div className="auth-brand"><span className="brand-symbol"/><strong>Image<span>Find</span></strong></div>
<div className="auth-lock"><ShieldCheck/></div><p className="eyebrow">飞牛网关验证</p>
<h1>需要飞牛管理员身份</h1><p className="muted">{message}</p>
<button className="primary wide" onClick={()=>window.location.reload()}><RefreshCw/>重新验证</button>
</section></main>;
}
function Thumb({ src, title, loading="lazy" }: { src?: string; title: string; loading?: "eager"|"lazy" }) {
const url=appMediaUrl(src);
const [settled,setSettled]=useState<{url:string;state:"loaded"|"error"}|null>(null);
const state: "loading"|"loaded"|"error"|"missing"=!url?"missing":settled?.url===url?settled.state:"loading";
return <span className={`thumb-media ${state}`}>
{url&&<img key={url} src={url} alt={title} loading={loading} className="thumb-image" onLoad={()=>setSettled({url,state:"loaded"})} onError={()=>setSettled({url,state:"error"})}/>}
{state!=="loaded"&&<span className={`thumb-placeholder ${state}`} role="status" aria-live="polite">{state==="loading"?<RefreshCw className="spin"/>:state==="error"?<AlertCircle/>:<Film/>}<small>{state==="loading"?"封面加载中":state==="error"?"封面加载失败":"暂无封面"}</small></span>}
</span>;
}
function VideoCard({ video, onPlay, hidden, selecting=false, selected=false, onSelect }: { video:Video;onPlay:(video:Video)=>void;hidden:boolean;selecting?:boolean;selected?:boolean;onSelect?:(video:Video)=>void }) {
const displayTitle=mediaTitle(video);
const summary=[video.catalog_code,video.actors.slice(0,2).join(" / ")].filter(Boolean).join(" · ");
const categoryTags=[...new Map(video.tag_items.map(tag=>[tag.id,tag])).values()];
const visibleTags=categoryTags.slice(0,2);const hiddenTagCount=Math.max(0,categoryTags.length-visibleTags.length);
const categoryDescription=categoryTags.length?`,分类 ${categoryTags.map(tag=>tag.name).join("、")}`:"";
const activate=()=>selecting?onSelect?.(video):onPlay(video);
return <article className={`video-card ${hidden?"masked":""} ${selecting?"selecting":""} ${selected?"selected":""}`.trim()} role="button" tabIndex={0} aria-pressed={selecting?selected:undefined} aria-label={`${selecting?`${selected?"取消选择":"选择"} ${displayTitle}`:`播放 ${displayTitle}`}${categoryDescription}`} onClick={activate} onKeyDown={event=>{if(event.key==="Enter"||event.key===" "){event.preventDefault();activate()}}}>
<div className="video-thumb"><Thumb src={video.thumbnail_url} title={displayTitle} />
{!!visibleTags.length&&<span className="video-cover-tags" aria-hidden="true">{visibleTags.map(tag=><span title={`${tag.group_name}${tag.name}`} key={tag.id}>{tag.name}</span>)}{hiddenTagCount>0&&<b title={`另有 ${hiddenTagCount} 个分类`}>+{hiddenTagCount}</b>}</span>}
<span className="duration">{formatTime(video.duration_ms)}</span>
<span className="hover-play"><Play fill="currentColor" /></span>
{selecting&&<span className="video-select-check" aria-hidden="true">{selected?<CheckCircle2/>:<span/>}</span>}
</div>
<div className="video-info"><h3>{displayTitle}</h3>{summary&&<p>{summary}</p>}</div>
</article>;
}
function useRailState(itemCount:number,density:number){
const ref=useRef<HTMLDivElement>(null);const [edge,setEdge]=useState({start:true,end:false});
const update=useCallback(()=>{const node=ref.current;if(!node)return;setEdge({start:node.scrollLeft<=2,end:node.scrollLeft+node.clientWidth>=node.scrollWidth-2})},[]);
useLayoutEffect(()=>{const node=ref.current;if(!node)return;update();const resize=new ResizeObserver(update);resize.observe(node);node.addEventListener("scroll",update,{passive:true});return()=>{resize.disconnect();node.removeEventListener("scroll",update)}},[itemCount,density,update]);
const move=(direction:-1|1)=>{const node=ref.current;if(!node)return;node.scrollBy({left:direction*node.clientWidth*.86,behavior:window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth"})};
return {ref,edge,move};
}
function HomeVideoRail({section,columns,hidden,onPlay,onViewAll,showLayout,onColumns}:{section:HomeVideoSection;columns:1|2;hidden:boolean;onPlay:(video:Video)=>void;onViewAll:()=>void;showLayout?:boolean;onColumns?:(value:1|2)=>void}){
const {ref:trackRef,edge,move}=useRailState(section.items.length,columns);
void showLayout;void onColumns;
return <section className="home-content-rail" aria-labelledby={`home-section-${section.id}`}><header><div><h2 id={`home-section-${section.id}`}>{section.title}</h2><p>{section.group_name?`${section.group_name} · `:""}{section.total} 部影片</p></div><div className="home-rail-actions"><span className="home-rail-paging"><button aria-label={`向前浏览${section.title}`} disabled={edge.start} onClick={()=>move(-1)}><ChevronLeft/></button><button aria-label={`向后浏览${section.title}`} disabled={edge.end} onClick={()=>move(1)}><ChevronRight/></button></span><button className="text-button" onClick={onViewAll}>查看全部 <ChevronRight/></button></div></header><div ref={trackRef} className={`home-rail-track columns-${columns}`}>{section.items.map(video=><VideoCard key={video.id} video={video} onPlay={onPlay} hidden={hidden}/>)}</div></section>;
}
function HomeCollectionRail({collections,total,onOpen,onViewAll}:{collections:CollectionItem[];total:number;onOpen:(id:string)=>void;onViewAll:()=>void}){
const {ref:trackRef,edge,move}=useRailState(collections.length,2);
return <section className="home-content-rail home-collection-rail" aria-labelledby="home-section-collections"><header><div><h2 id="home-section-collections">合集</h2><p>{total} 个适合连续观看的合集</p></div><div className="home-rail-actions"><span className="home-rail-paging"><button aria-label="向前浏览合集" disabled={edge.start} onClick={()=>move(-1)}><ChevronLeft/></button><button aria-label="向后浏览合集" disabled={edge.end} onClick={()=>move(1)}><ChevronRight/></button></span><button className="text-button" onClick={onViewAll}>查看全部 <ChevronRight/></button></div></header><div ref={trackRef} className="home-rail-track home-collection-track">{collections.map(collection=><button className="home-collection-card" key={collection.id} onClick={()=>onOpen(collection.id)}><span><Thumb src={collection.thumbnail_url} title={collection.name}/><i>{collection.video_count} </i></span><strong>{collection.name}</strong><small>{collection.description||(collection.duration_ms?`总时长 ${formatTime(collection.duration_ms)}`:"时长待识别")}</small></button>)}</div></section>;
}
function ContinueCard({video,onPlay,hidden}:{video:Video;onPlay:(video:Video,position?:number)=>void;hidden:boolean}){
const progress=Math.min(100,video.duration_ms?video.progress_ms/video.duration_ms*100:0);
const displayTitle=mediaTitle(video);
return <article className={`continue-card ${hidden?"masked":""}`} role="button" tabIndex={0} aria-label={`继续播放 ${displayTitle}`} onClick={()=>onPlay(video,video.progress_ms)} onKeyDown={event=>{if(event.key==="Enter"||event.key===" "){event.preventDefault();onPlay(video,video.progress_ms)}}}><div className="continue-card-thumb"><Thumb src={video.thumbnail_url} title={displayTitle} loading="eager"/><span className="duration">{formatTime(video.duration_ms)}</span><i><b style={{transform:`scaleX(${progress/100})`}}/></i><span className="continue-play"><Play fill="currentColor"/></span></div><h3>{displayTitle}</h3><p>上次看到 {formatTime(video.progress_ms)} · 已观看 {Math.round(progress)}%</p></article>
}
function PlayerPage({ video, startMs, videos, collections, onBack, onPlay, onEdit, onDeleted, preferences, onPreferences, onState, onCollectionsChanged }: {
video: Video; startMs: number; videos: Video[]; onBack: () => void; onPlay: (video: Video) => void; onEdit: () => void;
onDeleted:()=>void|Promise<void>;collections:CollectionItem[];preferences: Preferences; onPreferences:(value:Partial<Preferences>)=>void; onState:(video:Video,state:Partial<Video>)=>void;onCollectionsChanged:()=>Promise<void>;
}) {
const element = useRef<HTMLVideoElement>(null);
const stage = useRef<HTMLDivElement>(null);
const hls = useRef<HlsInstance | null>(null);
const [fallback, setFallback] = useState(false);
const fallbackAttempted=useRef(false);
const [playerStatus,setPlayerStatus]=useState<"loading"|"ready"|"buffering"|"fallback"|"error">("loading");
const [metadataReady,setMetadataReady]=useState(false);const [loadSeconds,setLoadSeconds]=useState(0);
const [message, setMessage] = useState("");
const [liked, setLiked] = useState(video.liked);
const [saved, setSaved] = useState(video.favorited);
const [masked, setMasked] = useState(false);
const [collectionOpen,setCollectionOpen]=useState(false);
const [deleting,setDeleting]=useState(false);const [deleteError,setDeleteError]=useState("");
const [markers,setMarkers]=useState<VideoMarker[]>([]);const [markersLoading,setMarkersLoading]=useState(true);const [markerError,setMarkerError]=useState("");const [markersOpen,setMarkersOpen]=useState(false);const [directoryOpen,setDirectoryOpen]=useState(false);const [collectionDetail,setCollectionDetail]=useState<CollectionDetail|null>(null);
const [transcript,setTranscript]=useState<TranscriptPage|null>(null);const [transcriptOpen,setTranscriptOpen]=useState(false);const [transcriptLoading,setTranscriptLoading]=useState(true);const [transcriptBusy,setTranscriptBusy]=useState(false);const [transcriptError,setTranscriptError]=useState("");const [transcriptLanguageOpen,setTranscriptLanguageOpen]=useState(false);const [transcriptLanguage,setTranscriptLanguage]=useState<"auto"|"zh"|"en"|"ja"|"ko">("auto");
const [playing,setPlaying]=useState(false);const [currentTime,setCurrentTime]=useState(0);const [duration,setDuration]=useState(Math.max(0,(video.duration_ms||0)/1000));const [controlsVisible,setControlsVisible]=useState(true);const [muted,setMuted]=useState(false);const [volume,setVolume]=useState(1);const [rate,setRate]=useState(1);const [rateMenu,setRateMenu]=useState(false);const [gesture,setGesture]=useState("");
const [bufferedRanges,setBufferedRanges]=useState<Array<{start:number;end:number}>>([]);const [bufferedPercent,setBufferedPercent]=useState(0);
const displayTitle=mediaTitle(video);
const lastSaved=useRef(0);
const controlsTimer=useRef<number|undefined>(undefined);const controlsVisibleRef=useRef(true);const gestureTimer=useRef<number|undefined>(undefined);const pointer=useRef<PlayerPointerState>({id:-1,x:0,y:0,position:0,preview:0,axis:"pending",held:false,priorRate:1,pointerType:"touch",controlsVisibleAtStart:true});const longPressTimer=useRef<number|undefined>(undefined);const tapTimer=useRef<number|undefined>(undefined);const lastTap=useRef(0);
const hideControls=useCallback(()=>{window.clearTimeout(controlsTimer.current);controlsTimer.current=undefined;controlsVisibleRef.current=false;setControlsVisible(false)},[]);
const showControls=useCallback((persistent=false)=>{window.clearTimeout(controlsTimer.current);controlsTimer.current=undefined;controlsVisibleRef.current=true;setControlsVisible(true);if(playing&&!persistent&&!markersOpen&&!directoryOpen&&!rateMenu)controlsTimer.current=window.setTimeout(hideControls,4000)},[directoryOpen,hideControls,markersOpen,playing,rateMenu]);
const showGesture=useCallback((text:string)=>{window.clearTimeout(gestureTimer.current);setGesture(text);gestureTimer.current=window.setTimeout(()=>setGesture(""),900)},[]);
const seekTo=useCallback((value:number)=>{const target=element.current;if(!target)return;const next=Math.max(0,Math.min(Number.isFinite(target.duration)?target.duration:duration,value));target.currentTime=next;setCurrentTime(next)},[duration]);
const togglePlayback=useCallback(()=>{const target=element.current;if(!target)return;if(target.paused){void target.play().catch(reason=>{setPlayerStatus("error");setMessage(`无法开始播放:${(reason as Error).message||"浏览器拒绝了播放请求"}`)})}else target.pause();showControls()},[showControls]);
const toggleFullscreen=useCallback(async()=>{if(!stage.current)return;try{if(document.fullscreenElement)await document.exitFullscreen();else await stage.current.requestFullscreen()}catch(reason){setPlayerStatus("error");setMessage(`无法进入全屏:${(reason as Error).message}`)}},[]);
const togglePictureInPicture=useCallback(async()=>{const target=element.current;if(!target||!document.pictureInPictureEnabled)return;try{if(document.pictureInPictureElement)await document.exitPictureInPicture();else await target.requestPictureInPicture()}catch(reason){setPlayerStatus("error");setMessage(`无法打开画中画:${(reason as Error).message}`)}},[]);
const startPreview = useCallback(async (position: number) => {
if (fallbackAttempted.current) {
setPlayerStatus("error");setMessage("原始文件和兼容预览均无法播放,请重试或下载原文件");return;
}
fallbackAttempted.current=true;
setFallback(true);setPlayerStatus("fallback");setMessage("正在准备兼容播放,首批分片生成后会自动开始…");
try {
const value = await api<{ playlist_url: string }>(`/videos/${video.id}/preview`, {
method: "POST", body: JSON.stringify({ start_ms: Math.max(0, position) })
});
const target = element.current;
if (!target) return;
if (target.canPlayType("application/vnd.apple.mpegurl")) {
target.src = appMediaUrl(value.playlist_url);
target.load();void target.play().catch(()=>{setPlayerStatus("ready")});
} else {
const { default: Hls } = await import("hls.js");
if (!Hls.isSupported()) throw new Error("当前浏览器不支持 HLS");
hls.current?.destroy();
const player = new Hls({ maxBufferLength: 60, liveDurationInfinity: true });
hls.current = player; player.loadSource(appMediaUrl(value.playlist_url)); player.attachMedia(target);
player.on(Hls.Events.MANIFEST_PARSED, () => {setMessage("");setPlayerStatus("ready");void target.play().catch(()=>{})});
player.on(Hls.Events.ERROR, (_, data) => { if (data.fatal){setPlayerStatus("error");setMessage("兼容播放中断,请重试或下载原文件");player.destroy();hls.current=null;} });
}
} catch (reason) { setPlayerStatus("error");setMessage((reason as Error).message); }
}, [video.id]);
useEffect(() => {
setFallback(false);setMessage("");setPlayerStatus("loading");setMetadataReady(false);setLoadSeconds(0);setBufferedRanges([]);setBufferedPercent(0);setMarkersOpen(false);setDirectoryOpen(false);fallbackAttempted.current=false;hls.current?.destroy(); hls.current = null;
lastSaved.current=0;
const target = element.current;
if (target) { target.src = appMediaUrl(video.playback_url); target.load(); }
return () => { hls.current?.destroy(); hls.current = null; };
}, [video.id, video.playback_url]);
useEffect(()=>{if(metadataReady||playerStatus==="error"||playerStatus==="ready")return;const started=Date.now();const update=()=>setLoadSeconds(Math.max(0,Math.floor((Date.now()-started)/1000)));update();const timer=window.setInterval(update,1000);return()=>window.clearInterval(timer)},[metadataReady,playerStatus,video.id]);
useEffect(()=>{let active=true;setMarkersLoading(true);setMarkerError("");api<VideoMarker[]>(`/videos/${video.id}/markers`).then(value=>{if(active)setMarkers(value)}).catch(reason=>{if(active)setMarkerError((reason as Error).message)}).finally(()=>{if(active)setMarkersLoading(false)});return()=>{active=false}},[video.id]);
const loadTranscript=useCallback(async(page=1)=>{setTranscriptLoading(true);setTranscriptError("");try{const value=await api<TranscriptPage>(`/videos/${video.id}/transcript?page=${page}&page_size=30`);setTranscript(value)}catch(reason){setTranscriptError((reason as Error).message)}finally{setTranscriptLoading(false)}},[video.id]);
useEffect(()=>{setTranscript(null);setTranscriptOpen(false);void loadTranscript()},[loadTranscript]);
useEffect(()=>{if(!video.collection_id){setCollectionDetail(null);return}let active=true;api<CollectionDetail>(`/collections/${video.collection_id}`).then(value=>{if(active)setCollectionDetail(normalizeCollectionDetail(value))}).catch(()=>{if(active)setCollectionDetail(null)});return()=>{active=false}},[video.collection_id,video.id]);
useEffect(()=>{showControls(!playing||markersOpen||directoryOpen||rateMenu);return()=>window.clearTimeout(controlsTimer.current)},[directoryOpen,markersOpen,playing,rateMenu,showControls]);
useEffect(()=>()=>{window.clearTimeout(gestureTimer.current);window.clearTimeout(longPressTimer.current);window.clearTimeout(tapTimer.current)},[]);
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==="Escape"&&(markersOpen||directoryOpen)){event.preventDefault();setMarkersOpen(false);setDirectoryOpen(false);showControls();return}if((event.target as HTMLElement)?.matches("input,textarea,select"))return;const actions:Record<string,()=>void>={" ":togglePlayback,k:togglePlayback,K:togglePlayback,ArrowLeft:()=>{seekTo(currentTime-5);showGesture("快退 5 秒")},ArrowRight:()=>{seekTo(currentTime+5);showGesture("快进 5 秒")},m:()=>{if(element.current){element.current.muted=!element.current.muted;setMuted(element.current.muted)}},M:()=>{if(element.current){element.current.muted=!element.current.muted;setMuted(element.current.muted)}},f:()=>void toggleFullscreen(),F:()=>void toggleFullscreen()};const action=actions[event.key];if(action){event.preventDefault();action();showControls()}};window.addEventListener("keydown",key);return()=>window.removeEventListener("keydown",key)},[currentTime,directoryOpen,markersOpen,seekTo,showControls,showGesture,toggleFullscreen,togglePlayback]);
function syncBuffered(target:HTMLVideoElement){
const total=Number.isFinite(target.duration)&&target.duration>0?target.duration:duration;
const ranges:Array<{start:number;end:number}>=[];
for(let index=0;index<target.buffered.length;index+=1)ranges.push({start:target.buffered.start(index),end:target.buffered.end(index)});
setBufferedRanges(ranges);
const position=target.currentTime;
const active=ranges.find(range=>range.start<=position+.25&&range.end>=position);
const loadedEnd=active?.end??ranges.reduce((maximum,range)=>Math.max(maximum,range.end),0);
setBufferedPercent(total?Math.max(0,Math.min(100,Math.round(loadedEnd/total*100))):0);
}
function beginPointer(event:ReactPointerEvent<HTMLDivElement>){
if(event.pointerType==="mouse"&&event.button!==0)return;
try{event.currentTarget.setPointerCapture(event.pointerId)}catch{/* Synthetic and older WebView pointer streams may not support capture. */}
const position=element.current?.currentTime||0;
// A mouse move reveals hidden controls before the following pointerdown.
// Treating that freshly revealed state as an explicit "visible" tap made
// the 280 ms single-tap timer hide the controls again immediately. This
// is especially common in NAS mobile WebViews that synthesize mouse
// pointer streams from touch. Mouse clicks therefore keep controls open;
// genuine touch taps retain the usual show/hide toggle.
pointer.current={id:event.pointerId,x:event.clientX,y:event.clientY,position,preview:position,axis:"pending",held:false,priorRate:element.current?.playbackRate||1,pointerType:event.pointerType,controlsVisibleAtStart:event.pointerType==="mouse"?false:controlsVisibleRef.current||!playing};
window.clearTimeout(longPressTimer.current);
if(event.pointerType!=="mouse")longPressTimer.current=window.setTimeout(()=>{if(pointer.current.axis!=="pending"||!element.current)return;pointer.current.held=true;element.current.playbackRate=2;showGesture("2× 快速播放")},500);
}
function movePointer(event:ReactPointerEvent<HTMLDivElement>){
if(event.pointerId!==pointer.current.id)return;
const target=element.current;if(!target)return;
const delta=event.clientX-pointer.current.x;const vertical=event.clientY-pointer.current.y;
if(pointer.current.axis==="pending"){
if(Math.max(Math.abs(delta),Math.abs(vertical))<10)return;
pointer.current.axis=Math.abs(vertical)>Math.abs(delta)?"vertical":"horizontal";window.clearTimeout(longPressTimer.current);
}
if(pointer.current.axis==="vertical")return;
event.preventDefault();
const width=stage.current?.clientWidth||1;const span=Math.max(60,duration*.2);const preview=Math.max(0,Math.min(duration,pointer.current.position+delta/width*span));pointer.current.preview=preview;setCurrentTime(preview);showGesture(`${delta<0?"快退":"快进"} ${formatTime(preview*1000)}`);
}
function endPointer(event:ReactPointerEvent<HTMLDivElement>){
if(event.pointerId!==pointer.current.id)return;
window.clearTimeout(longPressTimer.current);const target=element.current;if(!target)return;
if(pointer.current.held){target.playbackRate=pointer.current.priorRate;setRate(pointer.current.priorRate);pointer.current.held=false;showControls();pointer.current.id=-1;return}
if(pointer.current.axis==="vertical"){pointer.current.id=-1;return}
if(pointer.current.axis==="horizontal"){event.preventDefault();seekTo(pointer.current.preview);showControls();pointer.current.id=-1;return}
const now=Date.now();const x=event.clientX-(stage.current?.getBoundingClientRect().left||0);const width=stage.current?.clientWidth||1;
if(now-lastTap.current<280){window.clearTimeout(tapTimer.current);const amount=x<width/2?-10:10;seekTo(target.currentTime+amount);showGesture(amount<0?"快退 10 秒":"快进 10 秒");showControls();lastTap.current=0}else{const visibleAtStart=pointer.current.controlsVisibleAtStart;lastTap.current=now;tapTimer.current=window.setTimeout(()=>{if(markersOpen||directoryOpen||rateMenu)showControls(true);else if(visibleAtStart)hideControls();else showControls();lastTap.current=0},280)}
pointer.current.id=-1;
}
function cancelPointer(){window.clearTimeout(longPressTimer.current);const target=element.current;if(target&&pointer.current.held)target.playbackRate=pointer.current.priorRate;pointer.current.id=-1;pointer.current.held=false;pointer.current.axis="pending"}
function retryPlayback(){const target=element.current;if(!target)return;fallbackAttempted.current=false;setFallback(false);setMessage("");setPlayerStatus("loading");target.src=appMediaUrl(video.playback_url);target.load();void target.play().catch(()=>setPlayerStatus("ready"))}
async function toggleState(field:"liked"|"favorited",value:boolean){
await api(`/videos/${video.id}/state`,{method:"PATCH",body:JSON.stringify({[field]:value})});
if(field==="liked")setLiked(value);else setSaved(value);onState(video,{[field]:value});
}
async function addMarker(){const position=Math.max(0,Math.round((element.current?.currentTime||currentTime)*1000));setMarkerError("");try{const marker=await api<VideoMarker>(`/videos/${video.id}/markers`,{method:"POST",body:JSON.stringify({position_ms:position})});setMarkers(current=>[...current,marker].sort((a,b)=>a.position_ms-b.position_ms));toast(`已添加时间点 ${formatTime(position)}`)}catch(reason){setMarkerError((reason as Error).message)}}
async function renameMarker(marker:VideoMarker){const title=await appPrompt("编辑时间点名称",marker.title||"",`位置 ${formatTime(marker.position_ms)}`);if(title===null)return;try{const updated=await api<VideoMarker>(`/videos/${video.id}/markers/${marker.id}`,{method:"PATCH",body:JSON.stringify({title:title.trim()||null})});setMarkers(current=>current.map(item=>item.id===updated.id?updated:item))}catch(reason){setMarkerError((reason as Error).message)}}
async function deleteMarker(marker:VideoMarker){if(!await appConfirm("删除时间点",`${marker.title||formatTime(marker.position_ms)} 将从当前视频中移除`,true))return;try{await api(`/videos/${video.id}/markers/${marker.id}`,{method:"DELETE"});setMarkers(current=>current.filter(item=>item.id!==marker.id))}catch(reason){setMarkerError((reason as Error).message)}}
function jumpMarker(marker:VideoMarker){seekTo(marker.position_ms/1000);void element.current?.play().catch(()=>{});showControls()}
async function deleteVideo(){
const deleteSource=await appChoice(
`删除“${displayTitle}`,
"你可以同时把源文件移入 ImageFind 回收站,也可以只从媒体库移除。仅移除时会清理任务、封面和 AI 索引,并阻止扫描再次自动加入。",
"移入回收站",
"仅移出媒体库",
true,
);
if(deleteSource===null)return;
const confirmed=await appConfirm(
deleteSource?"确认移入回收站":"确认仅移出媒体库",
deleteSource
?"源文件会移入 ImageFind 回收站,同时清理封面、预览和 AI 索引;到期前可恢复。"
:"源文件保持不变,但该路径会被标记为已移除,后续扫描不会让它重新出现。",
true,
);
if(!confirmed)return;
setDeleting(true);setDeleteError("");
const remove=async(withSource:boolean)=>api<{cancelled_jobs:number;source_deleted:boolean}>(`/videos/${video.id}?delete_source=${withSource}`,{method:"DELETE"});
try{
let result;
try{result=await remove(deleteSource)}catch(reason){
if(!deleteSource)throw reason;
const fallback=await appConfirm("源文件处理失败",`${(reason as Error).message}是否改为仅从媒体库移除源文件不会被改动`,true);
if(!fallback)throw reason;
result=await remove(false);
}
element.current?.pause();
toast(`${result.source_deleted?"视频已移入回收站":"视频已从媒体库移除"}索引与缓存已清理${result.cancelled_jobs?`,取消 ${result.cancelled_jobs} 个任务`:""}`);
await onDeleted();
}catch(reason){setDeleteError((reason as Error).message)}finally{setDeleting(false)}
}
async function reindexTranscript(){setTranscriptBusy(true);setTranscriptError("");try{await api(`/videos/${video.id}/transcript/reindex`,{method:"POST",body:JSON.stringify({language:transcriptLanguage})});setTranscriptLanguageOpen(false);toast(`音频重新识别任务已进入队列${transcriptLanguage==="auto"?",将自动检测语言":`,已固定为${({zh:"中文",en:"英语",ja:"日语",ko:"韩语"} as const)[transcriptLanguage]}`}`);await loadTranscript()}catch(reason){setTranscriptError((reason as Error).message)}finally{setTranscriptBusy(false)}}
function saveProgress(completed=false){
const progress=Math.round((element.current?.currentTime||0)*1000);
if(!progress&&!completed)return;
lastSaved.current=Date.now();
void api(`/videos/${video.id}/state`,{method:"PATCH",body:JSON.stringify({progress_ms:progress,completed})})
.then(()=>onState(video,{progress_ms:progress,completed,last_played_at:new Date().toISOString()})).catch(()=>{});
}
const related = videos.filter(item => item.id !== video.id).sort((left,right)=>{
const rank=(item:Video)=>(video.collection_id&&item.collection_id===video.collection_id?8:video.series&&item.series===video.series?6:0)+item.tags.filter(tag=>video.tags.includes(tag)).length*3+item.actors.filter(actor=>video.actors.includes(actor)).length*4+(item.source_id===video.source_id?1:0);
return rank(right)-rank(left);
}).slice(0, 7);
const collectionIndex=collectionDetail?.videos.findIndex(item=>item.id===video.id)??-1;
const collectionNext=collectionIndex>=0?collectionDetail?.videos[collectionIndex+1]:undefined;
function directoryNodes(nodes:CollectionTreeItem[]):ReactNode{return nodes.map(item=>item.kind==="group"?<div className="player-directory-group" key={item.id}><strong><Folder/>{item.name}</strong><div>{directoryNodes(item.children||[])}</div></div>:item.video?<button className={item.video_id===video.id?"active":""} key={item.id} onClick={()=>{onPlay(item.video!);setDirectoryOpen(false)}}><Play/><span><strong>{mediaTitle(item.video)}</strong><small>{item.path.length?item.path.join(" / "):"未分组"} · {formatTime(item.video.duration_ms)}</small></span></button>:null)}
return <div className="player-page">
<div className="player-layout"><div className="player-main">
<div ref={stage} className={`player-stage custom-player ${masked?"masked-player":""} ${controlsVisible||!playing?"controls-visible":"controls-hidden"}`} data-playback-source={fallback?"compatible":"original"} onPointerMove={event=>{if(event.pointerType==="mouse")showControls()}} onMouseLeave={()=>playing&&!markersOpen&&!directoryOpen&&!rateMenu&&hideControls()}>
<video ref={element} autoPlay playsInline poster={appMediaUrl(video.thumbnail_url)}
onLoadedMetadata={event => {setMetadataReady(true);setDuration(event.currentTarget.duration||duration);if(!fallback&&startMs)event.currentTarget.currentTime=startMs/1000;setCurrentTime(event.currentTarget.currentTime);syncBuffered(event.currentTarget)}}
onDurationChange={event=>{setDuration(event.currentTarget.duration||duration);syncBuffered(event.currentTarget)}} onProgress={event=>syncBuffered(event.currentTarget)}
onLoadStart={()=>setPlayerStatus(fallback?"fallback":"loading")} onCanPlay={event=>{syncBuffered(event.currentTarget);setPlayerStatus("ready")}} onPlay={()=>setPlaying(true)} onPlaying={()=>{setPlaying(true);setPlayerStatus("ready");setMessage("")}} onWaiting={event=>{syncBuffered(event.currentTarget);if(playerStatus!=="error")setPlayerStatus("buffering")}}
onTimeUpdate={event=>{setCurrentTime(event.currentTarget.currentTime);if(Date.now()-lastSaved.current>10000)saveProgress(false)}} onPause={()=>{setPlaying(false);saveProgress(false)}} onVolumeChange={event=>{setMuted(event.currentTarget.muted);setVolume(event.currentTarget.volume)}}
onEnded={()=>{setPlaying(false);saveProgress(true);if(preferences.autoplay&&(collectionNext||related[0]))onPlay(collectionNext||related[0])}}
onError={() => startPreview((element.current?.currentTime || startMs / 1000) * 1000)} />
<div className="player-gesture-surface" aria-hidden="true" onPointerDown={beginPointer} onPointerMove={movePointer} onPointerUp={endPointer} onPointerCancel={cancelPointer}/>
{gesture&&<span className="player-gesture" role="status">{gesture}</span>}
<div className="player-controls">
<header><button className="player-back" aria-label="返回" onClick={onBack}><ArrowLeft/></button><strong title={displayTitle}>{displayTitle}</strong>{collectionDetail&&<button className={`player-directory-toggle ${directoryOpen?"active":""}`} aria-label="合集目录" aria-expanded={directoryOpen} onClick={()=>{setDirectoryOpen(value=>!value);setMarkersOpen(false);showControls(true)}}><ListPlus/></button>}</header>
<div className="player-center-controls">{playerStatus==="error"?<button className="player-center-status error" onClick={retryPlayback}><AlertCircle/><span>{message||"视频播放失败"}</span><small>点击重试</small></button>:playerStatus!=="ready"?<div className={`player-center-status ${loadSeconds>=10&&!metadataReady?"timeout":""}`} role="status" aria-live="polite"><RefreshCw className="spin"/><span>{playerStatus==="fallback"?"正在准备兼容播放":playerStatus==="buffering"?`正在缓冲 ${bufferedPercent}%`:video.source_mode==="encrypted"&&loadSeconds>=2?"正在连接并解密云盘视频":"正在连接媒体源"}</span>{playerStatus==="buffering"?<small>已加载至视频的 {bufferedPercent}%</small>:loadSeconds>=10&&!metadataReady?<><small>已等待 {loadSeconds} 秒,可以重试或切换兼容预览。</small><span className="player-timeout-actions"><button type="button" onClick={retryPlayback}><RefreshCw/>重试</button><button type="button" onClick={()=>void startPreview((element.current?.currentTime||startMs/1000)*1000)}><Play/>兼容预览</button><a href={appMediaUrl(video.download_url)}><Download/>下载原文件</a></span></>:null}</div>:<><button aria-label="快退 10 秒" onClick={()=>{seekTo(currentTime-10);showGesture("快退 10 秒")}}><SkipBack/></button><button className="player-play" aria-label={playing?"暂停":"播放"} onClick={togglePlayback}>{playing?<Pause fill="currentColor"/>:<Play fill="currentColor"/>}</button><button aria-label="快进 10 秒" onClick={()=>{seekTo(currentTime+10);showGesture("快进 10 秒")}}><SkipForward/></button></>}</div>
<footer><div className="player-progress-wrap"><span className="player-progress-buffer" aria-hidden="true">{bufferedRanges.map((range,index)=><i key={`${range.start}-${range.end}-${index}`} style={{left:`${duration?range.start/duration*100:0}%`,width:`${duration?(range.end-range.start)/duration*100:0}%`}}/>)}</span><input className="player-progress" aria-label="播放进度" type="range" min={0} max={Math.max(duration,1)} step="0.1" value={Math.min(currentTime,Math.max(duration,1))} onChange={event=>seekTo(Number(event.target.value))} style={{"--player-progress":`${duration?currentTime/duration*100:0}%`} as CSSProperties}/>{markers.map(marker=><button type="button" className="player-marker-dot" aria-label={`跳转到 ${marker.title||formatTime(marker.position_ms)}`} title={marker.title||formatTime(marker.position_ms)} style={{left:`${duration?Math.min(100,marker.position_ms/(duration*1000)*100):0}%`}} key={marker.id} onClick={()=>jumpMarker(marker)}/>)}</div><div className="player-control-row"><button aria-label={playing?"暂停":"播放"} onClick={togglePlayback}>{playing?<Pause fill="currentColor"/>:<Play fill="currentColor"/>}</button><span>{formatTime(currentTime*1000)} / {formatTime(duration*1000)}</span><label className="player-volume"><button aria-label={muted?"取消静音":"静音"} onClick={()=>{if(element.current){element.current.muted=!muted;setMuted(!muted)}}}>{muted||volume===0?<VolumeX/>:<Volume2/>}</button><input aria-label="音量" type="range" min={0} max={1} step={.05} value={muted?0:volume} onChange={event=>{const value=Number(event.target.value);if(element.current){element.current.volume=value;element.current.muted=value===0}setVolume(value);setMuted(value===0)}}/></label><span className="player-spacer"/><button className={markersOpen?"active":""} aria-label="视频时间点" aria-expanded={markersOpen} onClick={()=>{setMarkersOpen(value=>!value);showControls(true)}}><Clock3/></button><div className="player-rate"><button aria-haspopup="menu" aria-expanded={rateMenu} onClick={()=>setRateMenu(!rateMenu)}>{rate}×</button>{rateMenu&&<div role="menu">{[.5,.75,1,1.25,1.5,2].map(value=><button role="menuitem" className={rate===value?"active":""} key={value} onClick={()=>{if(element.current)element.current.playbackRate=value;setRate(value);setRateMenu(false)}}>{value}×</button>)}</div>}</div>{document.pictureInPictureEnabled&&<button aria-label="画中画" onClick={()=>void togglePictureInPicture()}><PictureInPicture/></button>}<button aria-label="全屏" onClick={()=>void toggleFullscreen()}><Maximize/></button></div></footer>
</div>
{markersOpen&&<section className="player-marker-drawer" aria-label="视频时间点"><header><div><strong><Clock3/>视频时间点</strong><small>当前 {formatTime(currentTime*1000)}</small></div><button aria-label="关闭时间点" onClick={()=>{setMarkersOpen(false);showControls()}}><X/></button></header><button className="player-marker-add" onClick={addMarker}><Plus/>标记当前位置</button><div className="player-marker-list">{markersLoading?<div className="player-marker-loading"><RefreshCw className="spin"/>正在读取时间点…</div>:markers.length?markers.map(marker=><article key={marker.id}><button className="marker-jump" onClick={()=>jumpMarker(marker)}><Play/><span><strong>{marker.title||formatTime(marker.position_ms)}</strong><small>{formatTime(marker.position_ms)}</small></span></button><button aria-label="编辑时间点" onClick={()=>renameMarker(marker)}><Pencil/></button><button className="danger-text" aria-label="删除时间点" onClick={()=>deleteMarker(marker)}><Trash2/></button></article>):<p className="player-marker-empty">还没有时间点,播放到想保存的位置后点击“标记当前位置”。</p>}</div>{markerError&&<div className="player-marker-error"><AlertCircle/><span>{markerError}</span><button onClick={()=>setMarkerError("")}><X/></button></div>}</section>}
{directoryOpen&&collectionDetail&&<section className="player-directory-drawer" aria-label="合集目录"><header><div><strong><ListPlus/>{collectionDetail.name}</strong><small>{collectionDetail.videos.length} · 当前第 {collectionIndex+1} </small></div><button aria-label="关闭合集目录" onClick={()=>{setDirectoryOpen(false);showControls()}}><X/></button></header><div className="player-directory-tree">{directoryNodes(collectionDetail.items)}</div></section>}
{masked && <button className="unmask-player" onClick={() => setMasked(false)}><Eye />显示画面</button>}
</div>
<div className="player-title"><p className="eyebrow">{video.catalog_code || sourceLabel(video)}</p><h1>{displayTitle}</h1>{displayTitle!==video.display_name&&video.title===video.display_name&&<small className="raw-media-name">{video.display_name}</small>}</div>
<div className="player-actions">
<div className="owner"><span className="library-avatar">{video.source_mode === "encrypted" ? <LockKeyhole /> : video.source_name.slice(0, 1)}</span><div><strong>{video.source_name}</strong><span>{sourceLabel(video)} · {video.source_mode === "direct" ? "302 直连" : "认证播放"}</span></div></div>
<button className={liked ? "action active" : "action"} onClick={() => toggleState("liked",!liked)}><ThumbsUp /><span>喜欢</span></button>
<button className={saved ? "action active" : "action"} onClick={() => toggleState("favorited",!saved)}><Bookmark /><span>收藏</span></button>
<button className={video.collection_id?"action active":"action"} title={video.collection_name||"加入合集"} onClick={()=>setCollectionOpen(true)}><Boxes/><span>{video.collection_name||"加入合集"}</span></button>
<a className="action" href={appMediaUrl(video.download_url)}><Download /><span>下载</span></a>
<button className="action" onClick={() => setMasked(!masked)}>{masked ? <Eye /> : <EyeOff />}<span>遮罩</span></button>
<button className="action" onClick={onEdit}><Pencil /><span>编辑资料</span></button>
<button className="action danger-action" disabled={deleting} onClick={deleteVideo}>{deleting?<RefreshCw className="spin"/>:<Trash2 />}<span>{deleting?"正在删除…":"删除视频"}</span></button>
</div>
{deleteError&&<div className="error player-delete-error"><AlertCircle/>{deleteError}<button onClick={()=>setDeleteError("")}>关闭</button></div>}
<section className="description-card">
<div className="metadata-pills"><b>{video.width ? `${video.width >= 3800 ? "4K" : "1080P"}` : "视频"}</b><b>{formatSize(video.size_bytes)}</b>{video.studio && <b>{video.studio}</b>}{video.series && <b>{video.series}</b>}</div>
<p>{video.description || "这个视频尚未添加简介。可以编辑演员、番号、片商、合集和标签,AI 画面索引不受文件资料影响。"}</p>
{!!video.actors.length && <div className="inline-tags"><Users />{video.actors.map(name => <span key={name}>{name}</span>)}</div>}
{!!video.tags.length && <div className="inline-tags"><Tags />{video.tags.map(name => <span key={name}>{name}</span>)}</div>}
</section>
<section className="ai-panel"><div className="ai-head"><span><Sparkles /></span><div><h2>视频内智能检索</h2><p>搜索画面、字幕、OCR 或已命名人物,直接跳到相关时间点</p></div></div>
<form className="within-search" onSubmit={event => { event.preventDefault(); const input = event.currentTarget.elements.namedItem("within") as HTMLInputElement; if (input.value.trim()) window.dispatchEvent(new CustomEvent("imagefind-search", { detail: input.value.trim() })); }}><Search /><input name="within" placeholder="例如:红色衣服、字幕中的关键词、演员名字…"/><button>搜索</button></form>
</section>
<section className={`transcript-panel ${transcriptOpen?"open":""}`}>
<button className="transcript-toggle" aria-expanded={transcriptOpen} onClick={()=>setTranscriptOpen(value=>!value)}><span><Volume2/><span><strong>音频转写</strong><small>{transcriptLoading?"正在读取识别状态…":transcript?.status==="ready"?`${transcript.total} 个高可信时间片段`:transcript?.status==="filtered"?`${transcript.total} 个可搜索片段,部分内容已过滤`:transcript?.status==="low_quality"?"识别质量偏低,系统将进行一次增强修复":transcript?.status==="empty"?"已识别,未检测到语音":transcript?.status==="running"?`正在识别 ${Math.round((transcript.job?.progress||0)*100)}%`:transcript?.status==="queued"?"等待后台识别":transcript?.status==="failed"?"识别失败":transcript?.status==="unavailable"?"音频模型未安装":"尚未识别"}</small></span></span>{transcriptLoading?<RefreshCw className="spin"/>:transcriptOpen?<ArrowUp/>:<ArrowDown/>}</button>
{transcriptOpen&&<div className="transcript-body">{transcriptLoading?<AsyncNotice compact label="正在读取音频转写…"/>:<>{transcript&&<div className={`transcript-quality ${transcript.quality_state}`} role="status"><span><strong>{transcript.detected_language?`识别语言:${({zh:"中文",en:"英语",ja:"日语",ko:"韩语"} as Record<string,string>)[transcript.detected_language]||transcript.detected_language}`:"识别语言:自动"}</strong><small>{transcript.quality_state==="low_quality"?`检测到整片重复或低质量风险,结果仅供诊断且不进入搜索`:transcript.quality_state==="filtered"?`已过滤 ${transcript.rejected_segments} 个不可信片段`:transcript.quality_state==="empty"?"有界语音检测未发现可识别内容":transcript.quality_state==="legacy"?"旧版转写,建议重新识别":"规则检查未发现明显异常"}{typeof transcript.quality_score==="number"?` · 规则检查分 ${Math.round(transcript.quality_score*100)}%`:""}{transcript.repeated_phrases?.length?<>{" · 重复:"+transcript.repeated_phrases.slice(0,3).map(item=>item.text+"×"+item.count).join("、")}</>:null}</small></span>{["filtered","low_quality","legacy"].includes(transcript.quality_state)?<AlertCircle/>:<ShieldCheck/>}</div>}{transcript?.items.length?<><div className="transcript-list">{transcript.items.map(item=><button key={item.id} onClick={()=>{seekTo(item.start_ms/1000);void element.current?.play().catch(()=>{})}}><time>{formatTime(item.start_ms)}</time><span>{item.raw_text}</span><Play/></button>)}</div>{transcript.pages>1&&<div className="transcript-pages"><button disabled={transcript.page<=1} onClick={()=>void loadTranscript(transcript.page-1)}><ChevronLeft/>上一页</button><span>{transcript.page} / {transcript.pages}</span><button disabled={transcript.page>=transcript.pages} onClick={()=>void loadTranscript(transcript.page+1)}>下一页<ChevronRight/></button></div>}</>:<div className="transcript-empty"><VolumeX/><p>{transcript?.job?.error||transcript?.job?.message||"当前视频还没有可显示的音频转写。"}</p></div>}</>}{transcriptError&&<div className="error"><AlertCircle/>{transcriptError}</div>}<button className="secondary transcript-reindex" disabled={transcriptBusy||transcript?.status==="running"||transcript?.status==="queued"} onClick={()=>setTranscriptLanguageOpen(true)}>{transcriptBusy?<RefreshCw className="spin"/>:<RefreshCw/>}{transcript?.status==="failed"?"重试识别":"重新识别"}</button></div>}
</section>
</div>
<aside className={`related player-side-panel ${collectionDetail?"player-collection-directory":""}`}>
<header className="player-side-head"><div><h2>{collectionDetail?collectionDetail.name:"接下来播放"}</h2><p>{collectionDetail?`合集选集 · ${collectionDetail.videos.length} 部`:"根据当前影片为你推荐"}</p></div><button className={`switch ${preferences.autoplay?"on":""}`} onClick={()=>onPreferences({autoplay:!preferences.autoplay})}>自动播放 <i /></button></header>
{collectionDetail&&<section className="player-queue"><div className="player-directory-tree">{directoryNodes(collectionDetail.items)}</div></section>}
<section className="player-recommendations"><div className="player-recommendations-title"><h3>{collectionDetail?"接下来播放":"相关推荐"}</h3><span>{related.length} 部影片</span></div><div className="player-recommendations-list">{related.map(item => {const title=mediaTitle(item);return <article key={item.id} role="button" tabIndex={0} aria-label={`播放 ${title}`} onClick={() => onPlay(item)} onKeyDown={event=>{if(event.key==="Enter"||event.key===" "){event.preventDefault();onPlay(item)}}}><div className="related-thumb"><Thumb src={item.thumbnail_url} title={title}/><span className="duration">{formatTime(item.duration_ms)}</span></div><div><h3>{title}</h3><p>{item.catalog_code || item.source_name}</p><p>{sourceLabel(item)}</p></div></article>})}</div></section>
</aside></div>
{collectionOpen&&<CollectionPickerDialog video={video} collections={collections} onClose={()=>setCollectionOpen(false)} onAssigned={async collection=>{onState(video,{collection_id:collection?.id,collection_name:collection?.name,series:collection?.name});await onCollectionsChanged()}}/>}
{transcriptLanguageOpen&&<div className="modal-backdrop transcript-language-backdrop" onMouseDown={event=>{if(event.target===event.currentTarget)setTranscriptLanguageOpen(false)}}><form className="dialog transcript-language-dialog" role="dialog" aria-modal="true" aria-labelledby="transcript-language-title" onSubmit={event=>{event.preventDefault();void reindexTranscript()}}><header><div><h2 id="transcript-language-title">重新识别音频</h2><p>自动检测适合混合媒体;语言明确时固定语言可减少乱码和误识别。</p></div><button type="button" className="icon-button" aria-label="关闭" onClick={()=>setTranscriptLanguageOpen(false)}><X/></button></header><div className="transcript-language-options" role="radiogroup" aria-label="音频语言">{(["auto","zh","en","ja","ko"] as const).map(language=><button type="button" role="radio" aria-checked={transcriptLanguage===language} className={transcriptLanguage===language?"active":""} key={language} onClick={()=>setTranscriptLanguage(language)}><span><strong>{({auto:"自动检测",zh:"中文",en:"英语",ja:"日语",ko:"韩语"} as const)[language]}</strong><small>{language==="auto"?"优先读取音轨信息,并对有声片段智能检测":"固定解码语言,适合语言明确的视频"}</small></span>{transcriptLanguage===language?<CheckCircle2/>:<i/>}</button>)}</div><footer><button type="button" className="secondary" onClick={()=>setTranscriptLanguageOpen(false)}>取消</button><button className="primary" disabled={transcriptBusy}>{transcriptBusy?<RefreshCw className="spin"/>:<Volume2/>}开始识别</button></footer></form></div>}
</div>;
}
function CollectionPickerDialog({video,collections,onClose,onAssigned}:{video:Video;collections:CollectionItem[];onClose:()=>void;onAssigned:(collection:CollectionItem|null)=>void|Promise<void>}){
const [name,setName]=useState("");const [busy,setBusy]=useState(false);const [error,setError]=useState("");const [collectionId,setCollectionId]=useState(video.collection_id||collections[0]?.id||"");const [parentId,setParentId]=useState(video.collection_parent_id||"");const [detail,setDetail]=useState<CollectionDetail|null>(null);const [loading,setLoading]=useState(false);
useEffect(()=>{if(!collectionId){setDetail(null);return}let active=true;setLoading(true);api<CollectionDetail>(`/collections/${collectionId}`).then(value=>{if(active)setDetail(normalizeCollectionDetail(value))}).catch(reason=>{if(active)setError((reason as Error).message)}).finally(()=>{if(active)setLoading(false)});return()=>{active=false}},[collectionId]);
async function assign(){
const collection=collections.find(item=>item.id===collectionId);if(!collection)return;
setBusy(true);setError("");try{await api(`/collections/${collection.id}/videos`,{method:"POST",body:JSON.stringify({video_ids:[video.id],parent_id:parentId||null})});await onAssigned(collection);toast(`已加入“${collection.name}${parentId?"的指定分组":""}`);onClose()}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}
}
async function create(event:FormEvent){
event.preventDefault();if(!name.trim())return;setBusy(true);setError("");try{const collection=await api<CollectionItem>("/collections",{method:"POST",body:JSON.stringify({name:name.trim(),video_ids:[video.id]})});await onAssigned(collection);toast(`已创建并加入“${collection.name}”`);onClose()}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}
}
async function remove(){
if(!video.collection_id)return;setBusy(true);setError("");try{await api(`/collections/${video.collection_id}/videos/${video.id}`,{method:"DELETE"});await onAssigned(null);toast("已移出合集");onClose()}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}
}
return <div className="modal-backdrop collection-picker-backdrop" onMouseDown={event=>{if(event.target===event.currentTarget)onClose()}}><section className="dialog collection-picker" role="dialog" aria-modal="true" aria-label="加入合集"><header><div><p className="eyebrow">加入合集</p><h2>加入合集</h2></div><button className="icon-button" aria-label="关闭" onClick={onClose}><X/></button></header><p className="collection-picker-video">{video.title}</p>{collections.length?<div className="collection-picker-fields"><label>合集<select value={collectionId} disabled={busy} onChange={event=>{setCollectionId(event.target.value);setParentId("")}}>{collections.map(collection=><option value={collection.id} key={collection.id}>{collection.name} · {collection.video_count} </option>)}</select></label><label>章节或小节<select value={parentId} disabled={busy||loading} onChange={event=>setParentId(event.target.value)}><option value="">合集根级(未分组)</option>{collectionGroups(detail?.items||[]).map(({item,depth})=><option key={item.id} value={item.id}>{" ".repeat(depth)}{item.name}</option>)}</select></label>{loading&&<AsyncNotice compact label="正在读取合集目录…"/>}<button className="primary collection-picker-assign" disabled={busy||loading||!collectionId} onClick={assign}><Boxes/>加入所选位置</button></div>:<div className="empty compact"><Boxes/><p>还没有合集,可以在下方直接创建。</p></div>}<form className="collection-picker-create" onSubmit={create}><label>新建合集<input maxLength={200} value={name} onChange={event=>setName(event.target.value)} placeholder="输入合集名称"/></label><button className="primary" disabled={busy||!name.trim()}><Plus/>创建并加入</button></form>{video.collection_id&&<button className="danger-text collection-picker-remove" disabled={busy} onClick={remove}><X/>从“{video.collection_name||video.series}”移出</button>}{error&&<div className="error"><AlertCircle/>{error}</div>}</section></div>
}
function DirectoryPicker({sourceId,value,onChange}:{sourceId:string;value:string;onChange:(value:string)=>void}){
const [directories,setDirectories]=useState<FileItem[]>([]);const [loading,setLoading]=useState(false);const [manual,setManual]=useState(false);const [error,setError]=useState("");
const load=useCallback(async()=>{if(!sourceId)return;setLoading(true);setError("");try{const rows=await api<FileItem[]>(`/files?source_id=${encodeURIComponent(sourceId)}&path=${encodeURIComponent(value)}`);setDirectories(rows.filter(item=>item.is_dir));}catch(reason){setDirectories([]);setError((reason as Error).message)}finally{setLoading(false)}},[sourceId,value]);
useEffect(()=>{load()},[load]);
const crumbs=value.split("/").filter(Boolean);
async function mkdir(){const name=await appPrompt("新建目录","","目录名称");if(!name?.trim())return;await api("/files/directories",{method:"POST",body:JSON.stringify({source_id:sourceId,path:[value,name.trim()].filter(Boolean).join("/")})});await load();}
return <div className="directory-picker"><header><strong>保存到目录</strong><div><button type="button" onClick={()=>setManual(!manual)}>{manual?"浏览目录":"手动输入"}</button><button type="button" onClick={mkdir}><FolderPlus/>新建</button></div></header>
{manual?<input value={value} onChange={event=>onChange(event.target.value)} placeholder="例如:待整理/2026"/>:<><p className="picker-crumbs"><button type="button" onClick={()=>onChange("")}>根目录</button>{crumbs.map((part,index)=><span key={`${part}-${index}`}><ChevronRight/><button type="button" onClick={()=>onChange(crumbs.slice(0,index+1).join("/"))}>{part}</button></span>)}</p><div className="directory-list">{value&&<button type="button" onClick={()=>onChange(crumbs.slice(0,-1).join("/"))}><ArrowLeft/><span>上一级</span></button>}{directories.map(directory=><button type="button" key={directory.key} onClick={()=>onChange(directory.key)}><Folder/><span>{directory.name}</span><ChevronRight/></button>)}{loading&&<p><RefreshCw className="spin"/>读取目录…</p>}{!loading&&!directories.length&&<p>当前目录没有子目录</p>}</div></>}
{error&&<div className="error"><AlertCircle/>{error}</div>}<small>当前选择:/{value||""}</small>
</div>;
}
function UploadDialog({ open, sources, uploads, groups, collections, preferences, onPreferences, onClose, onChanged, onCollectionsChanged }: { open:boolean;sources: Source[]; uploads:UploadTask[];groups:TagGroup[];collections:CollectionItem[];preferences:Preferences;onPreferences:(value:Partial<Preferences>)=>void;onClose:()=>void;onChanged:()=>void|Promise<unknown>;onCollectionsChanged:()=>Promise<void> }) {
const writable = sources.filter(source => source.config.writable || source.config.driver === "alist" || source.config.storage_backend === "openlist_native");
const [sourceId, setSourceId] = useState(writable[0]?.id || "");
const [path, setPath] = useState(preferences.upload_paths[writable[0]?.id] || "");
const [conflict,setConflict]=useState<"rename"|"replace"|"skip">("rename");
const [files, setFiles] = useState<File[]>([]);
const [fileTitles,setFileTitles]=useState<Record<string,string>>({});
const [collectionId,setCollectionId]=useState("");
const [collectionParentId,setCollectionParentId]=useState("");const [collectionDetail,setCollectionDetail]=useState<CollectionDetail|null>(null);
const [tagIds,setTagIds]=useState<string[]>([]);
const [progress, setProgress] = useState<Record<string, number>>({});
const [fileErrors, setFileErrors] = useState<Record<string,string>>({});
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const fileKey=(file:File)=>`${file.name}:${file.size}:${file.lastModified}`;
useEffect(()=>{if(!sourceId&&writable[0]){setSourceId(writable[0].id);setPath(preferences.upload_paths[writable[0].id]||"")}},[preferences.upload_paths,sourceId,writable]);
useEffect(()=>{if(!collectionId){setCollectionDetail(null);setCollectionParentId("");return}let active=true;api<CollectionDetail>(`/collections/${collectionId}`).then(value=>{if(active)setCollectionDetail(normalizeCollectionDetail(value))}).catch(reason=>{if(active)setError((reason as Error).message)});return()=>{active=false}},[collectionId]);
function toggleTag(group:TagGroup,tagId:string){setTagIds(current=>{if(current.includes(tagId))return current.filter(id=>id!==tagId);const withoutSingle=group.selection_mode==="single"?current.filter(id=>!group.tags.some(tag=>tag.id===id)):current;return [...withoutSingle,tagId]})}
async function quickCollection(){const name=await appPrompt("新建合集","","输入合集名称,本批上传的视频会自动加入");if(!name?.trim())return;try{const value=await api<CollectionItem>("/collections",{method:"POST",body:JSON.stringify({name:name.trim()})});await onCollectionsChanged();setCollectionId(value.id);setCollectionParentId("")}catch(reason){setError((reason as Error).message)}}
function addFiles(selected:FileList|null){
if(!selected)return;
const additions=Array.from(selected);
setFileTitles(current=>{
const next={...current};
for(const file of additions){
const key=fileKey(file);if(key in next)continue;
const prior=uploads.find(task=>task.status==="receiving"&&task.source_id===sourceId&&task.relative_path===path&&task.filename===file.name&&task.size_bytes===file.size);
next[key]=prior?.title||"";
}
return next;
});
setFiles(current=>{
const known=new Set(current.map(fileKey));
return [...current,...additions.filter(file=>!known.has(fileKey(file)))];
});
}
async function start() {
if (!sourceId || !files.length) return;
setBusy(true); setError("");
let completed=0;let failed=0;
try {
for (const file of files) {
const key=fileKey(file);
setFileErrors(current=>{const next={...current};delete next[key];return next});
try{
setStatus(`正在上传 ${file.name}${completed+failed+1}/${files.length}`);
const prior=uploads.find(task=>task.status==="receiving"&&task.source_id===sourceId&&task.relative_path===path&&task.filename===file.name&&task.size_bytes===file.size);
const title=fileTitles[key]?.trim()||null;
const session = prior
? await api<UploadTask>(`/uploads/${prior.id}`,{method:"PATCH",body:JSON.stringify({title,collection_id:collectionId||null,collection_parent_id:collectionParentId||null,tag_ids:tagIds})})
: await api<UploadTask>("/uploads", {
method: "POST", body: JSON.stringify({ source_id: sourceId, relative_path: path, filename: file.name, title,collection_id:collectionId||null,collection_parent_id:collectionParentId||null,tag_ids:tagIds,size_bytes:file.size,conflict })
});
await onChanged();
const received=new Set(session.received_chunks||[]);
for (let index = 0; index < session.total_chunks; index++) {
if(received.has(index)){setProgress(current=>({...current,[key]:(index+1)/session.total_chunks}));continue;}
const blob = file.slice(index * session.chunk_size, Math.min(file.size, (index + 1) * session.chunk_size));
const data = await blob.arrayBuffer();
const headers: Record<string,string> = { "Content-Type": "application/octet-stream" };
const subtle = globalThis.crypto?.subtle;
if (subtle) {
headers["X-Chunk-SHA256"] = [...new Uint8Array(await subtle.digest("SHA-256", data))].map(value => value.toString(16).padStart(2, "0")).join("");
}
await api(`/uploads/${session.id}/chunks/${index}`, { method: "PUT", body: data, headers });
setProgress(current => ({ ...current, [key]: (index + 1) / session.total_chunks }));
}
await api(`/uploads/${session.id}/complete`, { method: "POST", body: "{}" });
completed+=1;
}catch(reason){
failed+=1;setFileErrors(current=>({...current,[key]:(reason as Error).message}));
}
}
await onPreferences({upload_paths:{...preferences.upload_paths,[sourceId]:path}});
setStatus(failed?`${completed} 个文件已排队,${failed} 个失败,可直接重试`:`${completed} 个文件已进入后台传输队列`);await onChanged();
if(!failed){const target=writable.find(source=>source.id===sourceId);toast(`已上传到 ImageFind,正在后台${target?.kind==="webdav"?`转存到 ${target.name}`:`写入 ${target?.name||"目标媒体库"}`},可在上传中心查看`);setTimeout(onClose,700)}
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
}
if(!open)return null;
return <div className="modal-backdrop"><section className="dialog upload-dialog"><header><div><p className="eyebrow">上传</p><h2>上传到私有媒体库</h2></div><button className="icon-button" aria-label={busy?"关闭弹窗并在后台继续":"关闭"} onClick={onClose}><X /></button></header>
{!writable.length ? <div className="empty compact"><AlertCircle /><h3>没有可写入的媒体库</h3><p>请先添加 AList,或在资料库中为本地目录启用写入。</p></div> : <>
<div className="form-row"><label>目标媒体库<select value={sourceId} onChange={e => {setSourceId(e.target.value);setPath(preferences.upload_paths[e.target.value]||"")}}>{writable.map(source => <option value={source.id} key={source.id}>{source.name} · {sourceLabel(source)}</option>)}</select></label><label>同名文件<select value={conflict} onChange={event=>setConflict(event.target.value as typeof conflict)}><option value="rename">自动重命名</option><option value="replace">替换原文件</option><option value="skip">跳过</option></select></label></div>
<DirectoryPicker sourceId={sourceId} value={path} onChange={setPath}/>
<section className="upload-batch-metadata"><header><div><strong>本批分类与合集</strong><small>可选;选择后会应用到本次上传的全部视频</small></div><button type="button" onClick={quickCollection}><Plus/>新建合集</button></header><div className="upload-collection-fields"><label>加入合集<select value={collectionId} disabled={busy} onChange={event=>{setCollectionId(event.target.value);setCollectionParentId("")}}><option value="">不加入合集</option>{collections.map(collection=><option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label>{collectionId&&<label>章节或小节<select value={collectionParentId} disabled={busy||!collectionDetail} onChange={event=>setCollectionParentId(event.target.value)}><option value="">合集根级(未分组)</option>{collectionGroups(collectionDetail?.items||[]).map(({item,depth})=><option key={item.id} value={item.id}>{" ".repeat(depth)}{item.name}</option>)}</select></label>}</div>{groups.some(group=>group.tags.length>0)&&<div className="upload-tag-groups">{groups.filter(group=>group.tags.length>0).map(group=><fieldset key={group.id}><legend>{group.name}<small>{group.selection_mode==="single"?"单选":"多选"}</small></legend><div>{group.tags.map(tag=><button type="button" disabled={busy} aria-pressed={tagIds.includes(tag.id)} className={tagIds.includes(tag.id)?"active":""} key={tag.id} onClick={()=>toggleTag(group,tag.id)}>{tagIds.includes(tag.id)&&<CheckCircle2/>}{tag.name}</button>)}</div></fieldset>)}</div>}</section>
<label className="drop-zone"><Upload /><strong>选择一个或多个视频</strong><span>可分批追加选择;支持大文件分块续传,单文件最大 100 GB</span><input type="file" multiple accept="video/*,.mkv,.avi,.wmv,.ts,.m2ts" onChange={event=>{addFiles(event.target.files);event.currentTarget.value=""}}/></label>
{!!files.length && <div className="selected-files">{files.map(file => {const key=fileKey(file);return <div className={fileErrors[key]?"failed":""} key={key}><Film /><span><strong>{file.name}</strong><label className="upload-title-field"><span>视频标题(可选)</span><input maxLength={500} disabled={busy} value={fileTitles[key]||""} onChange={event=>setFileTitles(current=>({...current,[key]:event.target.value}))} placeholder="留空则使用文件名"/></label><small>{fileErrors[key]||formatSize(file.size)}</small><i><b style={{width:`${(progress[key] || 0) * 100}%`}} /></i></span><button type="button" aria-label={`移除 ${file.name}`} disabled={busy} onClick={()=>{setFiles(current=>current.filter(item=>fileKey(item)!==key));setFileTitles(current=>{const next={...current};delete next[key];return next})}}><X/></button></div>})}</div>}
{status && <p className="status-line"><Activity />{status}</p>}{error && <div className="error"><AlertCircle />{error}</div>}
<footer><button className="secondary" onClick={onClose}>{busy?"后台继续":"取消"}</button><button className="primary" disabled={busy || !files.length} onClick={start}>{busy ? <RefreshCw className="spin" /> : <Upload />}{busy?"正在后台上传":"开始上传"}</button></footer>
</>}
</section></div>;
}
function MetadataDialog({ video, groups, seriesOptions, onClose, onSaved }: { video: Video; groups:TagGroup[];seriesOptions:string[]; onClose: () => void; onSaved: () => void }) {
const [form, setForm] = useState({ title: video.title, catalog_code: video.catalog_code || "", studio: video.studio || "", series: video.series || "", release_date: video.release_date || "", description: video.description || "", actors: video.actors.join(", ") });
const [tagIds,setTagIds]=useState(video.tag_items.map(tag=>tag.id));const [suggestions,setSuggestions]=useState<Suggestion[]>([]);const [analyzing,setAnalyzing]=useState(false);
const [error, setError] = useState(""); const [busy, setBusy] = useState(false);
const loadSuggestions=useCallback(()=>api<Suggestion[]>(`/videos/${video.id}/tag-suggestions`).then(setSuggestions).catch(()=>{}),[video.id]);
useEffect(()=>{loadSuggestions()},[loadSuggestions]);
async function save(event: FormEvent) {
event.preventDefault(); setBusy(true); setError("");
try {
await api(`/videos/${video.id}/metadata`, { method: "PATCH", body: JSON.stringify({ ...form, actors: form.actors.split(/[,]/).map(x => x.trim()).filter(Boolean), tag_ids:tagIds }) });
onSaved(); onClose();
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
}
function selectTag(group:TagGroup,tagId:string,checked:boolean){setTagIds(current=>group.selection_mode==="single"?[...current.filter(id=>!group.tags.some(tag=>tag.id===id)),...(checked?[tagId]:[])]:checked?[...new Set([...current,tagId])]:current.filter(id=>id!==tagId))}
async function decide(suggestion:Suggestion,action:"accept"|"reject",replace=false){try{await api("/tag-suggestions/decide",{method:"POST",body:JSON.stringify({suggestion_ids:[suggestion.id],action,replace_single:replace})});if(action==="accept")selectTag(groups.find(group=>group.id===suggestion.group_id)!,suggestion.tag_id,true);loadSuggestions();}catch(reason){if((reason as Error).message.includes("确认替换")&&await appConfirm("替换单选标签","该单选组已有标签,继续后会替换现有标签。"))return decide(suggestion,action,true);setError((reason as Error).message)}}
async function analyze(){setAnalyzing(true);setError("");try{await api("/tag-suggestions/analyze",{method:"POST",body:JSON.stringify({video_ids:[video.id],force:true})});setError("分析任务已创建,完成后重新打开或刷新候选。")}catch(reason){setError((reason as Error).message)}finally{setAnalyzing(false)}}
return <div className="modal-backdrop"><form className="dialog metadata-dialog" onSubmit={save}><header><div><p className="eyebrow">影片资料</p><h2>编辑影片资料</h2></div><button type="button" className="icon-button" onClick={onClose}><X /></button></header>
<label>显示标题<input value={form.title} onChange={e => setForm({...form,title:e.target.value})}/></label>
<div className="form-row"><label>番号<input value={form.catalog_code} onChange={e => setForm({...form,catalog_code:e.target.value})} placeholder="例如 ABC-123"/></label><label>发行日期<input type="date" value={form.release_date} onChange={e => setForm({...form,release_date:e.target.value})}/></label></div>
<div className="form-row"><label>片商<input value={form.studio} onChange={e => setForm({...form,studio:e.target.value})}/></label><label>合集<input list="series-suggestions" value={form.series} onChange={e => setForm({...form,series:e.target.value})}/><datalist id="series-suggestions">{seriesOptions.map(name=><option key={name} value={name}/>)}</datalist></label></div>
<label>演员(逗号分隔)<input value={form.actors} onChange={e => setForm({...form,actors:e.target.value})}/></label>
<div className="metadata-tags"><header><strong>分类与标签</strong><button type="button" onClick={analyze} disabled={analyzing}><Sparkles/>{analyzing?"创建任务…":"重新 AI 分析"}</button></header>{groups.map(group=><fieldset key={group.id}><legend>{group.name}<small>{group.selection_mode==="single"?"单选":"多选"}</small></legend><div>{group.selection_mode==="single"&&<label><input type="radio" name={`tag-${group.id}`} checked={!group.tags.some(tag=>tagIds.includes(tag.id))} onChange={()=>setTagIds(current=>current.filter(id=>!group.tags.some(tag=>tag.id===id)))}/>未选择</label>}{group.tags.map(tag=><label key={tag.id}><input type={group.selection_mode==="single"?"radio":"checkbox"} name={`tag-${group.id}`} checked={tagIds.includes(tag.id)} onChange={event=>selectTag(group,tag.id,event.target.checked)}/>{tag.name}</label>)}</div></fieldset>)}</div>
{!!suggestions.length&&<div className="suggestions"><header><Sparkles/><strong>AI 标签建议</strong></header>{suggestions.map(item=><article key={item.id}><div><b>{item.group_name} / {item.tag_name}</b><span>匹配分 {item.confidence.toFixed(3)}</span>{item.evidence[0]?.thumbnail_url&&<img src={appMediaUrl(item.evidence[0].thumbnail_url)} alt="AI 建议证据画面"/>}</div><button type="button" onClick={()=>decide(item,"reject")}>拒绝</button><button type="button" className="primary" onClick={()=>decide(item,"accept")}>接受</button></article>)}</div>}
<label>简介<textarea value={form.description} onChange={e => setForm({...form,description:e.target.value})}/></label>
<p className="muted tiny">编辑资料不会重命名或移动源文件。</p>{error && <div className="error"><AlertCircle />{error}</div>}
<footer><button type="button" className="secondary" onClick={onClose}>取消</button><button className="primary" disabled={busy}>{busy ? <RefreshCw className="spin"/> : <Pencil/>}保存</button></footer>
</form></div>;
}
function TagsPage({groups,videos,reload,onVideosChanged}:{groups:TagGroup[];videos:Video[];reload:()=>void;onVideosChanged:()=>void}){
const [editing,setEditing]=useState<{tag?:Tag;groupId:string}|null>(null);const [suggestions,setSuggestions]=useState<Suggestion[]>([]);const [selected,setSelected]=useState<string[]>([]);const [bulkTag,setBulkTag]=useState("");const [error,setError]=useState("");
const loadSuggestions=useCallback(()=>api<Suggestion[]>("/tag-suggestions?status=pending&limit=200").then(setSuggestions).catch(reason=>setError((reason as Error).message)),[]);
useEffect(()=>{loadSuggestions()},[loadSuggestions]);
async function addGroup(){const name=await appPrompt("新建分类组","","例如:场景、类型、服装");if(!name?.trim())return;await api("/tag-groups",{method:"POST",body:JSON.stringify({name:name.trim(),selection_mode:"multi"})});reload()}
async function editGroup(group:TagGroup){const name=await appPrompt("修改分类组",group.name);if(!name?.trim())return;await api(`/tag-groups/${group.id}`,{method:"PATCH",body:JSON.stringify({name:name.trim()})});reload()}
async function toggleMode(group:TagGroup){try{await api(`/tag-groups/${group.id}`,{method:"PATCH",body:JSON.stringify({selection_mode:group.selection_mode==="single"?"multi":"single"})});reload()}catch(reason){setError((reason as Error).message)}}
async function removeGroup(group:TagGroup){if(!await appConfirm(`删除分类组“${group.name}”`,"只允许删除不含标签的分类组。",true))return;try{await api(`/tag-groups/${group.id}`,{method:"DELETE"});reload()}catch(reason){setError((reason as Error).message)}}
async function removeTag(tag:Tag){if(!await appConfirm(`删除标签“${tag.name}”`,"影片与该标签的关联也会移除。",true))return;await api(`/tags/${tag.id}`,{method:"DELETE"});reload();onVideosChanged()}
async function mergeTag(tag:Tag,group:TagGroup){const targetName=await appPrompt("合并标签","",`请输入“${group.name}”中的目标标签名称`);if(!targetName)return;const target=group.tags.find(item=>item.name===targetName&&item.id!==tag.id);if(!target)return setError("未找到同组目标标签");await api(`/tags/${target.id}/merge`,{method:"POST",body:JSON.stringify({source_ids:[tag.id]})});reload();onVideosChanged()}
async function decide(ids:string[],action:"accept"|"reject",replace_single=false){try{await api("/tag-suggestions/decide",{method:"POST",body:JSON.stringify({suggestion_ids:ids,action,replace_single})});loadSuggestions();onVideosChanged()}catch(reason){if((reason as Error).message.includes("确认替换")&&await appConfirm("替换单选标签","部分单选组已有标签,继续后将替换。"))return decide(ids,action,true);setError((reason as Error).message)}}
async function bulk(action:"add"|"remove"|"replace"){if(!selected.length||!bulkTag)return;await api("/videos/tags/bulk",{method:"POST",body:JSON.stringify({video_ids:selected,tag_ids:[bulkTag],action})});onVideosChanged();setSelected([])}
async function analyzeAll(){if(!videos.length)return;await api("/tag-suggestions/analyze",{method:"POST",body:JSON.stringify({video_ids:videos.map(video=>video.id),force:false})});setError("已创建全库标签分析任务,可在通知中心查看进度")}
return <div className="page tags-page"><header className="page-heading"><button className="icon-button page-back" aria-label="返回" onClick={()=>window.history.back()}><ArrowLeft/></button><div><p className="eyebrow">分类与标签</p><h1>分类与标签</h1><p>按单选或多选分组管理标签;AI 只提供建议,确认后才写入影片资料。</p></div><div className="page-heading-actions"><button className="secondary" onClick={analyzeAll}><Sparkles/>分析全库</button><button className="primary" onClick={addGroup}><Plus/>新建分类组</button></div></header>{error&&<div className="error banner"><AlertCircle/>{error}</div>}
<div className="tag-groups">{groups.map(group=><section className="tag-group-card" key={group.id}><header><div><h2>{group.name}</h2><p>{group.selection_mode==="single"?"单选组":"多选组"} · {group.tag_count} 个标签 · {group.video_count} 部影片</p></div><div><button onClick={()=>toggleMode(group)}>{group.selection_mode==="single"?"改为多选":"改为单选"}</button><button aria-label={`编辑分类组 ${group.name}`} onClick={()=>editGroup(group)}><Pencil/></button><button aria-label={`删除分类组 ${group.name}`} className="danger-text" onClick={()=>removeGroup(group)}><Trash2/></button></div></header><div className="tag-list">{group.tags.map(tag=><article key={tag.id}><span className={tag.ai_enabled?"ai-dot on":"ai-dot"}><Sparkles/></span><div><strong>{tag.name}</strong><small>{tag.video_count} 部影片 · {tag.ai_enabled?(tag.ai_method==="visual"?`画面识别 ≥ ${tag.ai_threshold.toFixed(2)}`:`文本规则 ${tag.match_terms.join(" / ")||tag.name}`):"仅手动"}</small></div><button aria-label={`配置标签 ${tag.name}`} onClick={()=>setEditing({tag,groupId:group.id})}><Pencil/></button><button aria-label={`合并标签 ${tag.name}`} onClick={()=>mergeTag(tag,group)}><Share2/></button><button aria-label={`删除标签 ${tag.name}`} className="danger-text" onClick={()=>removeTag(tag)}><Trash2/></button></article>)}<button className="add-tag" onClick={()=>setEditing({groupId:group.id})}><Plus/>添加标签</button></div></section>)}</div>
<section className="panel batch-panel"><h2><Tags/>批量赋值</h2><div className="batch-controls"><select value={bulkTag} onChange={event=>setBulkTag(event.target.value)}><option value="">选择标签</option>{groups.map(group=><optgroup label={group.name} key={group.id}>{group.tags.map(tag=><option value={tag.id} key={tag.id}>{tag.name}</option>)}</optgroup>)}</select><button onClick={()=>bulk("add")}>添加</button><button onClick={()=>bulk("remove")}>移除</button><button className="danger-text" onClick={()=>bulk("replace")}>替换全部标签</button></div><div className="batch-videos">{videos.map(video=><label key={video.id}><input type="checkbox" checked={selected.includes(video.id)} onChange={event=>setSelected(current=>event.target.checked?[...current,video.id]:current.filter(id=>id!==video.id))}/><Thumb src={video.thumbnail_url} title={video.title}/><span>{video.title}</span></label>)}</div></section>
<section className="panel suggestion-review"><h2><Sparkles/>待确认 AI 建议 <small>{suggestions.length}</small></h2>{suggestions.length?<><div className="suggestion-actions"><button onClick={()=>decide(suggestions.map(item=>item.id),"reject")}>全部拒绝</button><button className="primary" onClick={()=>decide(suggestions.map(item=>item.id),"accept")}>全部接受</button></div>{suggestions.map(item=><article key={item.id}>{item.evidence[0]?.thumbnail_url?<img src={appMediaUrl(item.evidence[0].thumbnail_url)} alt="AI 建议证据画面"/>:<span><Sparkles/></span>}<div><strong>{item.video_title}</strong><b>{item.group_name} / {item.tag_name}</b><small>匹配分 {item.confidence.toFixed(3)}{item.evidence[0]?.text?` · ${item.evidence[0].text}`:""}</small></div><button onClick={()=>decide([item.id],"reject")}>拒绝</button><button className="primary" onClick={()=>decide([item.id],"accept")}>接受</button></article>)}</>:<div className="empty compact"><Sparkles/><h3>暂无待确认建议</h3><p>索引新视频后会自动分析已启用 AI 的标签。</p></div>}</section>
{editing&&<TagEditor value={editing.tag} groupId={editing.groupId} onClose={()=>setEditing(null)} onSaved={()=>{setEditing(null);reload()}}/>}
</div>;
}
function TagEditor({value,groupId,onClose,onSaved}:{value?:Tag;groupId:string;onClose:()=>void;onSaved:()=>void}){
const [form,setForm]=useState({name:value?.name||"",ai_enabled:value?.ai_enabled||false,ai_method:value?.ai_method||"visual",ai_description:value?.ai_description||"",ai_threshold:value?.ai_threshold??.28,match_terms:value?.match_terms.join("")||""});const [error,setError]=useState("");const [busy,setBusy]=useState(false);
async function save(event:FormEvent){event.preventDefault();setBusy(true);setError("");try{const body={group_id:groupId,name:form.name,ai_enabled:form.ai_enabled,ai_method:form.ai_method,ai_description:form.ai_description,ai_threshold:Number(form.ai_threshold),match_terms:form.match_terms.split(/[,]/).map(item=>item.trim()).filter(Boolean)};await api(value?`/tags/${value.id}`:"/tags",{method:value?"PATCH":"POST",body:JSON.stringify(body)});onSaved()}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
return <div className="modal-backdrop"><form className="dialog" onSubmit={save}><header><div><p className="eyebrow">标签设置</p><h2>{value?"配置标签":"添加标签"}</h2></div><button type="button" className="icon-button" onClick={onClose}><X/></button></header><label>标签名称<input required value={form.name} onChange={event=>setForm({...form,name:event.target.value})}/></label><label className="check"><input type="checkbox" checked={form.ai_enabled} onChange={event=>setForm({...form,ai_enabled:event.target.checked})}/>启用 AI 建议</label>{form.ai_enabled&&<><div className="form-row"><label>识别方式<select value={form.ai_method} onChange={event=>setForm({...form,ai_method:event.target.value as "visual"|"text"})}><option value="visual">画面语义</option><option value="text">文本规则</option></select></label><label>匹配阈值<input type="number" min="-1" max="1" step="0.01" value={form.ai_threshold} onChange={event=>setForm({...form,ai_threshold:Number(event.target.value)})}/></label></div>{form.ai_method==="visual"?<label>AI 画面描述<textarea value={form.ai_description} onChange={event=>setForm({...form,ai_description:event.target.value})} placeholder="描述这个标签在画面中的视觉特征"/></label>:<label>匹配关键词(逗号分隔)<textarea value={form.match_terms} onChange={event=>setForm({...form,match_terms:event.target.value})} placeholder="番号、片商、字幕或 OCR 关键词"/></label>}</>}{error&&<div className="error"><AlertCircle/>{error}</div>}<footer><button type="button" className="secondary" onClick={onClose}>取消</button><button className="primary" disabled={busy}>{busy?<RefreshCw className="spin"/>:<Pencil/>}保存</button></footer></form></div>;
}
function TrashDialog({sources,initialSourceId,onClose}:{sources:Source[];initialSourceId?:string;onClose:()=>void}){
const [sourceId,setSourceId]=useState(initialSourceId||"");
const [items,setItems]=useState<TrashItem[]>([]);
const [phase,setPhase]=useState<"loading"|"ready"|"error">("loading");
const [refreshing,setRefreshing]=useState(false);
const [pending,setPending]=useState("");
const [error,setError]=useState("");
const dialog=useRef<HTMLElement>(null);const loaded=useRef(false);
const load=useCallback(async()=>{
if(loaded.current)setRefreshing(true);else setPhase("loading");
setError("");
try{
setItems(await api<TrashItem[]>(`/trash${sourceId?`?source_id=${encodeURIComponent(sourceId)}`:""}`));
loaded.current=true;setPhase("ready");
}catch(reason){if(loaded.current)setError((reason as Error).message);else{setPhase("error");setError((reason as Error).message)}}
finally{setRefreshing(false)}
},[sourceId]);
useEffect(()=>{loaded.current=false;setItems([]);void load()},[load]);
useEffect(()=>{
const previous=document.activeElement as HTMLElement|null;const bodyOverflow=document.body.style.overflow;
const content=document.querySelector<HTMLElement>(".content");const contentOverflow=content?.style.overflow||"";
document.body.style.overflow="hidden";if(content)content.style.overflow="hidden";
window.setTimeout(()=>dialog.current?.querySelector<HTMLElement>("button,select")?.focus(),0);
const keydown=(event:KeyboardEvent)=>{
if(document.querySelector(".app-dialog-backdrop"))return;
if(event.key==="Escape"){event.preventDefault();onClose();return}
if(event.key!=="Tab")return;
const focusable=[...(dialog.current?.querySelectorAll<HTMLElement>("button,select,[href]")||[])].filter(item=>!item.hasAttribute("disabled"));
if(!focusable.length)return;const first=focusable[0],last=focusable[focusable.length-1];
if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus()}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus()}
};
window.addEventListener("keydown",keydown);
return()=>{window.removeEventListener("keydown",keydown);document.body.style.overflow=bodyOverflow;if(content)content.style.overflow=contentOverflow;previous?.focus()};
},[onClose]);
async function restore(item:TrashItem){
setPending(item.id);setError("");
try{const value=await api<{key:string}>(`/trash/${item.id}/restore`,{method:"POST",body:"{}"});toast(`已恢复到 ${value.key}`);await load()}
catch(reason){setError((reason as Error).message)}finally{setPending("")}
}
async function purge(item:TrashItem){
if(!await appConfirm(`永久删除“${item.display_name}”`,"源文件将被彻底删除且无法恢复。",true))return;
setPending(item.id);setError("");
try{await api(`/trash/${item.id}`,{method:"DELETE"});toast("已永久删除");await load()}
catch(reason){setError((reason as Error).message)}finally{setPending("")}
}
return createPortal(<div className="modal-backdrop trash-backdrop" onMouseDown={event=>{if(event.target===event.currentTarget)onClose()}}><section ref={dialog} className="dialog trash-dialog" role="dialog" aria-modal="true" aria-labelledby="trash-title" aria-busy={phase==="loading"||refreshing}><header><div><p className="eyebrow">回收站</p><h2 id="trash-title">回收站</h2></div><button className="icon-button" aria-label="关闭回收站" onClick={onClose}><X/></button></header><div className="trash-toolbar"><label>媒体库<select value={sourceId} onChange={event=>setSourceId(event.target.value)}><option value="">全部媒体库</option>{sources.map(source=><option key={source.id} value={source.id}>{source.name}</option>)}</select></label><button className="secondary" disabled={refreshing||phase==="loading"} onClick={()=>void load()}><RefreshCw className={refreshing?"spin":""}/>{refreshing?"正在刷新":"刷新"}</button></div>{error&&<div className="error trash-error"><AlertCircle/><span>{error}</span><button onClick={()=>void load()}>重试</button></div>}{phase==="loading"?<div className="loading trash-loading" role="status"><RefreshCw className="spin"/><p>正在读取回收站…</p></div>:phase==="error"?<div className="empty compact"><Trash2/><h3>暂时无法读取回收站</h3><p>请检查媒体库连接后重试。</p><button className="secondary" onClick={()=>void load()}><RefreshCw/>重新加载</button></div>:items.length?<div className="trash-list">{items.map(item=>{const source=sources.find(value=>value.id===item.source_id);const working=pending===item.id;return <article key={item.id}><span className="trash-item-icon"><Trash2/></span><div className="trash-item-main"><strong>{item.display_name}</strong><p>{item.original_key}</p><div><span>{source?.name||"未知媒体库"}</span><span>{item.size_bytes?formatSize(item.size_bytes):"大小未知"}</span><span>删除于 {new Date(item.deleted_at).toLocaleString()}</span><span>清理于 {new Date(item.expires_at).toLocaleString()}</span></div></div><div className="trash-actions"><button className="secondary" disabled={Boolean(pending)} onClick={()=>void restore(item)}>{working?<RefreshCw className="spin"/>:<RefreshCw/>}恢复</button><button className="danger-text" disabled={Boolean(pending)} onClick={()=>void purge(item)}><Trash2/>永久删除</button></div></article>})}</div>:<div className="empty trash-empty"><Trash2/><h3>回收站是空的</h3><p>{sourceId?"这个媒体库没有待清理的项目。":"删除的视频和文件会在保留期内显示在这里。"}</p></div>}</section></div>,document.body);
}
function LibraryPage({ sources, videos, uploads, uploadCounts, downloads, transfersLoading, reload, onUpload, onUploadCenter }: { sources: Source[]; videos:Video[];uploads:UploadTask[];uploadCounts:Pick<UploadPage,"active_count"|"failed_count">;downloads:DownloadTask[];transfersLoading:boolean;reload: () => void; onUpload: () => void;onUploadCenter:()=>void }) {
const [showAdd, setShowAdd] = useState(false);
const [mode, setMode] = useState<"local"|"webdav"|"alist-direct"|"alist-encrypted">("alist-encrypted");
const [form, setForm] = useState({ name:"", path:"", base_url:"", root_path:"", username:"", password:"", verify_tls:true });
const [recovery, setRecovery] = useState<Record<string, string> | null>(null);
const [activeSource, setActiveSource] = useState<Source | null>(null); const [currentPath, setCurrentPath] = useState("");
const [files, setFiles] = useState<FileItem[]>([]); const [showTrash,setShowTrash]=useState(false);
const [filesLoad,setFilesLoad]=useState<LoadStatus>(idleLoad());
const [selected,setSelected]=useState<string[]>([]);
const [error, setError] = useState(""); const [busy, setBusy] = useState(false);
const [nativeSource,setNativeSource]=useState<Source|null>(null);
const [nativeBusy,setNativeBusy]=useState<""|"test"|"save"|"disable">("");
const [nativeResult,setNativeResult]=useState("");
const [nativeForm,setNativeForm]=useState({endpoint:"",username:"",password:"",local_staging_path:"",source_path:"",target_path:"",verify_tls:true});
const loadFiles = useCallback(async (source: Source, path = "") => { setActiveSource(source);setCurrentPath(path);setSelected([]);setFiles([]);setFilesLoad({phase:"loading",refreshing:false});setError("");try{setFiles(await api(`/files?source_id=${encodeURIComponent(source.id)}&path=${encodeURIComponent(path)}`));setFilesLoad({phase:"ready",refreshing:false})}catch(reason){const message=(reason as Error).message;setError(message);setFilesLoad({phase:"error",refreshing:false,error:message})}}, []);
async function selectRecovery(file?: File) {
if (!file) return;
setError("");
try {
const value = JSON.parse(await file.text()) as Record<string, string>;
if (value.format !== "imagefind-rclone-crypt-v1" || !value.base_url || !value.username || !value.crypt_password || !value.crypt_salt) {
throw new Error("这不是有效的 ImageFind 加密库恢复文件");
}
setRecovery(value);
setForm(current => ({
...current,
name: current.name || "恢复的加密媒体库",
base_url: value.base_url,
root_path: value.root_path || "",
username: value.username,
}));
} catch (reason) { setRecovery(null); setError((reason as Error).message); }
}
async function addSource(event: FormEvent) {
event.preventDefault(); setBusy(true); setError("");
try {
let value: { recovery?: object };
if (mode === "local") value = await api("/sources/local", {method:"POST",body:JSON.stringify({name:form.name,path:form.path})});
else if (mode === "webdav") value = await api("/sources/webdav", {method:"POST",body:JSON.stringify({name:form.name,base_url:form.base_url,username:form.username,password:form.password,verify_tls:form.verify_tls})});
else if (mode === "alist-encrypted" && recovery) value = await api("/sources/alist/restore", {method:"POST",body:JSON.stringify({name:form.name,password:form.password,verify_tls:form.verify_tls,recovery})});
else value = await api("/sources/alist", {method:"POST",body:JSON.stringify({name:form.name,base_url:form.base_url,root_path:form.root_path,username:form.username,password:form.password,mode:mode === "alist-encrypted" ? "encrypted" : "direct",verify_tls:form.verify_tls})});
if (value.recovery) { const blob = new Blob([JSON.stringify(value.recovery,null,2)],{type:"application/json"}); const link=document.createElement("a");link.href=URL.createObjectURL(blob);link.download=`imagefind-vault-recovery-${Date.now()}.json`;link.click();URL.revokeObjectURL(link.href); }
setShowAdd(false); setRecovery(null); setForm({name:"",path:"",base_url:"",root_path:"",username:"",password:"",verify_tls:true}); reload();
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
}
async function toggleWritable(source: Source) { try { await api(`/sources/${source.id}/writable`,{method:"PATCH",body:JSON.stringify({writable:!source.config.writable})});reload(); } catch(reason){setError((reason as Error).message);} }
function openNative(source:Source){
const base=String(source.config.base_url||"");const marker=base.toLowerCase().indexOf("/dav");
setNativeSource(source);setNativeResult("");setError("");
setNativeForm({
endpoint:String(source.config.openlist_endpoint||(marker>=0?base.slice(0,marker):base)),
username:"",password:"",
local_staging_path:String(source.config.openlist_local_staging_path||""),
source_path:String(source.config.openlist_source_path||""),
target_path:String(source.config.openlist_target_path||source.config.root_path||(marker>=0?decodeURIComponent(base.slice(marker+4).replace(/^\/+|\/+$/g,"")):"")),
verify_tls:source.config.verify_tls!==false,
});
}
async function nativeAction(action:"test"|"save"|"disable"){
if(!nativeSource)return;setNativeBusy(action);setNativeResult("");setError("");
const body=action==="disable"?{enabled:false}:{enabled:true,...nativeForm};
try{
const result=await api<{message?:string}>(`/sources/${nativeSource.id}/openlist${action==="test"?"/test":""}`,{method:action==="test"?"POST":"PATCH",body:JSON.stringify(body)});
setNativeResult(result.message||(action==="disable"?"已恢复旧 WebDAV 上传方式":"OpenList 原生后台复制已启用"));
if(action!=="test"){reload();if(action==="disable")setNativeSource(null)}
}catch(reason){setError((reason as Error).message)}finally{setNativeBusy("")}
}
async function moveFile(file:FileItem){if(!activeSource)return;const destination=await appPrompt("移动或重命名",file.key,"填写相对媒体库路径");if(!destination||destination===file.key)return;try{await api("/files/move",{method:"POST",body:JSON.stringify({source_id:activeSource.id,source_key:file.key,destination_key:destination,overwrite:false})});loadFiles(activeSource,currentPath)}catch(reason){setError((reason as Error).message)}}
async function trashSelected(){if(!activeSource||!selected.length||!await appConfirm(`回收 ${selected.length} 项`,"项目将移入 ImageFind 回收站。",true))return;await api("/files/trash",{method:"POST",body:JSON.stringify({source_id:activeSource.id,keys:selected})});loadFiles(activeSource,currentPath)}
async function createDirectory(){if(!activeSource)return;const name=await appPrompt("新建目录");if(!name?.trim())return;await api("/files/directories",{method:"POST",body:JSON.stringify({source_id:activeSource.id,path:[currentPath,name.trim()].filter(Boolean).join("/")})});loadFiles(activeSource,currentPath)}
async function trashOne(file:FileItem){if(!activeSource||!await appConfirm(`回收“${file.name}”`,"项目可在回收站恢复。",true))return;await api("/files/trash",{method:"POST",body:JSON.stringify({source_id:activeSource.id,keys:[file.key]})});loadFiles(activeSource,currentPath)}
return <div className="page"><header className="page-heading"><button className="icon-button page-back" aria-label="返回" onClick={()=>window.history.back()}><ArrowLeft/></button><div><p className="eyebrow">媒体资料库</p><h1>媒体资料库</h1><p>本地、WebDAVAList 直连与加密保险库使用统一索引。</p></div><div className="page-heading-actions"><button className="secondary" onClick={()=>setShowTrash(true)}><Trash2/>回收站</button><UploadStatusButton uploads={uploads} uploadCounts={uploadCounts} downloads={downloads} loading={transfersLoading} onUpload={onUpload} onCenter={onUploadCenter}/><button className="primary" onClick={()=>setShowAdd(true)}><Plus/>添加资料库</button></div></header>
{error && <div className="error banner"><AlertCircle/>{error}</div>}
<div className="source-grid">{sources.map(source=><article className={`source-card ${activeSource?.id===source.id?"active":""}`} key={source.id} role="button" tabIndex={0} aria-label={`打开媒体来源 ${source.name}`} onClick={()=>loadFiles(source)} onKeyDown={event=>{if(event.target===event.currentTarget&&(event.key==="Enter"||event.key===" ")){event.preventDefault();loadFiles(source)}}}><span className={`source-icon ${source.config.mode||source.kind}`}>{source.config.mode==="encrypted"?<LockKeyhole/>:source.config.storage_backend==="openlist_native"?<Cloud/>:source.config.mode==="direct"?<Wifi/>:source.kind==="local"?<HardDrive/>:<Cloud/>}</span><div><h3>{source.name}</h3><p>{sourceLabel(source)}</p><small className={source.status}>{source.status === "idle" ? "可用" : source.status}</small></div><button className="icon-button mini" aria-label={`扫描 ${source.name}`} onClick={event=>{event.stopPropagation();api(`/sources/${source.id}/scan`,{method:"POST",body:"{}"});}}><RefreshCw/></button>{source.kind==="webdav"&&<button className={`icon-button mini native-config ${source.config.storage_backend==="openlist_native"?"active":""}`} aria-label={`配置 ${source.name} 的 OpenList 后台复制`} onClick={event=>{event.stopPropagation();openNative(source)}}><Settings/></button>}{source.kind==="local"&&<button className={source.config.writable?"write-toggle on":"write-toggle"} onClick={event=>{event.stopPropagation();toggleWritable(source)}}>{source.config.writable?"可写":"只读"}</button>}</article>)}</div>
{activeSource && <section className="file-panel"><header><div><h2>{activeSource.name}</h2><p className="breadcrumbs"><button onClick={()=>loadFiles(activeSource,"")}>根目录</button>{currentPath.split("/").filter(Boolean).map((part,index,parts)=><span key={`${part}-${index}`}><ChevronRight/><button onClick={()=>loadFiles(activeSource,parts.slice(0,index+1).join("/"))}>{part}</button></span>)}</p></div><div className="page-heading-actions">{!!selected.length&&<button className="danger-text" onClick={trashSelected}><Trash2/>回收 {selected.length} </button>}<button className="secondary" onClick={createDirectory}><FolderPlus/>新建目录</button></div></header>
{filesLoad.phase==="loading"?<AsyncNotice label="正在读取目录…"/>:filesLoad.phase==="error"?<div className="empty compact async-error"><AlertCircle/><h3>目录加载失败</h3><p>{filesLoad.error}</p><button className="secondary" onClick={()=>void loadFiles(activeSource,currentPath)}><RefreshCw/>重试</button></div>:<div className="file-table"><div className="file-row file-head"><span><input type="checkbox" checked={!!files.length&&selected.length===files.length} onChange={event=>setSelected(event.target.checked?files.map(file=>file.key):[])}/>名称</span><span>大小</span><span>修改时间</span><span/></div>{files.map(file=>{const video=videos.find(item=>item.source_id===activeSource.id&&item.source_key===file.key);return <div className="file-row" key={file.key} onDoubleClick={()=>file.is_dir&&loadFiles(activeSource,file.key)}><span><input type="checkbox" checked={selected.includes(file.key)} onChange={event=>setSelected(current=>event.target.checked?[...current,file.key]:current.filter(key=>key!==file.key))}/>{file.is_dir?<Folder/>:<Film/>}<strong>{file.name}</strong></span><span>{file.is_dir?"—":formatSize(file.size_bytes)}</span><span>{file.modified_at?new Date(file.modified_at).toLocaleDateString():"—"}</span><span className="file-actions">{video&&<a className="icon-button mini" href={appUrl(video.download_url)}><Download/></a>}<button className="icon-button mini" onClick={()=>moveFile(file)}><Pencil/></button><button className="icon-button mini danger" onClick={()=>trashOne(file)}><Trash2/></button></span></div>})}{!files.length&&<div className="empty compact file-empty"><Folder/><h3>当前目录为空</h3></div>}</div>}
</section>}
{showTrash&&<TrashDialog sources={sources} initialSourceId={activeSource?.id} onClose={()=>setShowTrash(false)}/>}
{nativeSource&&<div className="modal-backdrop"><form className="dialog source-dialog openlist-dialog" onSubmit={event=>{event.preventDefault();void nativeAction("save")}}><header><div><p className="eyebrow">OpenList 原生 API</p><h2>后台复制到“{nativeSource.name}</h2></div><button type="button" className="icon-button" aria-label="关闭" onClick={()=>setNativeSource(null)}><X/></button></header><div className="info-callout"><Cloud/><span><strong>网盘传输由 OpenList 执行</strong>ImageFind 只接收、加密和校验;这里显示的是 OpenList 后台任务真实进度。</span></div><label>OpenList API 地址<input required type="url" value={nativeForm.endpoint} onChange={event=>setNativeForm({...nativeForm,endpoint:event.target.value})} placeholder="http://127.0.0.1:5244"/><small>填写站点根地址,不要包含 /dav</small></label><div className="form-row"><label>用户名<input value={nativeForm.username} onChange={event=>setNativeForm({...nativeForm,username:event.target.value})} placeholder="留空沿用原账号"/></label><label>密码<input type="password" value={nativeForm.password} onChange={event=>setNativeForm({...nativeForm,password:event.target.value})} placeholder={nativeSource.has_password?"留空沿用已保存密码":"请输入密码"}/></label></div><label>ImageFind 本地中转目录<input required value={nativeForm.local_staging_path} onChange={event=>setNativeForm({...nativeForm,local_staging_path:event.target.value})} placeholder="/vol1/@appdata/imagefind/openlist-staging"/></label><label>OpenList 源挂载路径<input required value={nativeForm.source_path} onChange={event=>setNativeForm({...nativeForm,source_path:event.target.value})} placeholder="/ImageFind中转"/><small>OpenList 中的本地存储必须映射到上面的同一物理目录。</small></label><label>OpenList 目标根目录<input value={nativeForm.target_path} onChange={event=>setNativeForm({...nativeForm,target_path:event.target.value})} placeholder="/网盘/视频"/></label><label className="check"><input type="checkbox" checked={nativeForm.verify_tls} onChange={event=>setNativeForm({...nativeForm,verify_tls:event.target.checked})}/>验证 HTTPS 证书</label>{nativeResult&&<div className="success-callout"><CheckCircle2/>{nativeResult}</div>}{error&&<div className="error"><AlertCircle/>{error}</div>}<footer className="openlist-actions">{nativeSource.config.storage_backend==="openlist_native"&&<button type="button" className="danger-text" disabled={!!nativeBusy} onClick={()=>void nativeAction("disable")}>{nativeBusy==="disable"?<RefreshCw className="spin"/>:<X/>}停用原生模式</button>}<span/><button type="button" className="secondary" disabled={!!nativeBusy} onClick={()=>void nativeAction("test")}>{nativeBusy==="test"?<RefreshCw className="spin"/>:<Activity/>}测试完整链路</button><button className="primary" disabled={!!nativeBusy}>{nativeBusy==="save"?<RefreshCw className="spin"/>:<CheckCircle2/>}测试并启用</button></footer></form></div>}
{showAdd && <div className="modal-backdrop"><form className="dialog source-dialog" onSubmit={addSource}><header><div><p className="eyebrow">添加来源</p><h2>添加媒体资料库</h2></div><button type="button" className="icon-button" onClick={()=>setShowAdd(false)}><X/></button></header>
<div className="mode-tabs"><button type="button" className={mode==="alist-encrypted"?"active":""} onClick={()=>setMode("alist-encrypted")}><LockKeyhole/>AList 加密</button><button type="button" className={mode==="alist-direct"?"active":""} onClick={()=>setMode("alist-direct")}><Wifi/>AList 直连</button><button type="button" className={mode==="local"?"active":""} onClick={()=>setMode("local")}><HardDrive/>本地</button><button type="button" className={mode==="webdav"?"active":""} onClick={()=>setMode("webdav")}><Cloud/>WebDAV</button></div>
{mode==="alist-encrypted"&&<div className="info-callout"><ShieldCheck/><span><strong>{recovery?"正在恢复已有加密库":"推荐用于私有媒体"}</strong>{recovery?"地址、目录、账号和密钥已从恢复文件载入;请输入当前 AList 密码。":"文件内容和名称加密后存入网盘;AI 正常工作,播放经 NAS 解密。"}<label className="recovery-picker"><KeyRound/>{recovery?"更换恢复文件":"从恢复文件导入"}<input type="file" accept="application/json,.json" onChange={event=>selectRecovery(event.target.files?.[0])}/></label></span></div>}{mode==="alist-direct"&&<div className="warning-callout"><Wifi/><span><strong>节省 NAS 播放带宽</strong>浏览器通过 302 直接读取网盘,但远端保存的是明文。</span></div>}
<label>显示名称<input required value={form.name} onChange={e=>setForm({...form,name:e.target.value})} placeholder={mode.startsWith("alist")?"私有云媒体":"私有影片"}/></label>
{mode==="local"?<label>fnOS 目录路径<input required value={form.path} onChange={e=>setForm({...form,path:e.target.value})} placeholder="/vol1/1000/媒体/私有影片"/></label>:<><label>{mode.startsWith("alist")?"AList 地址":"WebDAV 地址"}<input required type="url" value={form.base_url} onChange={e=>setForm({...form,base_url:e.target.value})} placeholder="https://alist.example.com"/></label>{mode.startsWith("alist")&&<label>根目录<input value={form.root_path} onChange={e=>setForm({...form,root_path:e.target.value})} placeholder="例如:Private/Videos(可留空)"/></label>}<div className="form-row"><label>用户名<input required value={form.username} onChange={e=>setForm({...form,username:e.target.value})}/></label><label>密码<input required type="password" value={form.password} onChange={e=>setForm({...form,password:e.target.value})}/></label></div><label className="check"><input type="checkbox" checked={form.verify_tls} onChange={e=>setForm({...form,verify_tls:e.target.checked})}/>验证 HTTPS 证书</label></>}
{error&&<div className="error"><AlertCircle/>{error}</div>}<footer><button type="button" className="secondary" onClick={()=>setShowAdd(false)}>取消</button><button className="primary" disabled={busy}>{busy?<RefreshCw className="spin"/>:<Plus/>}连接并扫描</button></footer>
</form></div>}
</div>;
}
function UploadsPage({ uploads, uploadPage, downloads, onDownloads, sources, reload, onPage, onUpload }: { uploads: UploadTask[];uploadPage:UploadPage;downloads:DownloadTask[];onDownloads:(items:DownloadTask[])=>void;sources:Source[];reload: () => void;onPage:(page:number)=>Promise<unknown>;onUpload:()=>void }) {
uploads=uploads.map(task=>({...task,can_retry:task.can_retry??task.status==="failed",can_cancel:task.can_cancel??!["completed","cancelled","indexing"].includes(task.status)}));
const [tab,setTab]=useState<"uploads"|"downloads">("uploads");const [paging,setPaging]=useState(false);const [runtime,setRuntime]=useState<{available:boolean;running:boolean}|null>(null);const [runtimeLoad,setRuntimeLoad]=useState<LoadStatus>(idleLoad());const [showAdd,setShowAdd]=useState(false);const [url,setUrl]=useState("");const [sourceId,setSourceId]=useState("");const [relativePath,setRelativePath]=useState("");const [busy,setBusy]=useState(false);const [error,setError]=useState("");const torrentInput=useRef<HTMLInputElement>(null);
const writable=useMemo(()=>sources.filter(source=>source.config.writable||source.config.driver==="alist"||source.config.storage_backend==="openlist_native"),[sources]);
const loadDownloads=useCallback(async()=>{setRuntimeLoad(current=>({phase:current.phase==="ready"?"ready":"loading",refreshing:current.phase==="ready"}));setError("");try{const [items,state]=await Promise.all([api<DownloadTask[]>("/downloads"),api<{available:boolean;running:boolean}>("/downloads/runtime")]);onDownloads(items);setRuntime(state);setRuntimeLoad({phase:"ready",refreshing:false})}catch(reason){const message=(reason as Error).message;setError(message);setRuntimeLoad(current=>current.phase==="ready"?{phase:"ready",refreshing:false,error:message}:{phase:"error",refreshing:false,error:message})}},[onDownloads]);
useEffect(()=>{if(tab!=="downloads")return;void loadDownloads();const refresh=()=>{if(!document.hidden)void loadDownloads()};const timer=window.setInterval(refresh,2000);document.addEventListener("visibilitychange",refresh);return()=>{window.clearInterval(timer);document.removeEventListener("visibilitychange",refresh)}},[tab,loadDownloads]);
useEffect(()=>{if(!sourceId&&writable[0])setSourceId(writable[0].id)},[sourceId,writable]);
const labels: Record<string,string>={receiving:"接收中",queued:"等待上传通道",downloading:"下载中",paused:"已暂停",transferring:"写入媒体库",indexing:"正在加入媒体库",completed:"已完成",failed:"失败",cancelled:"已取消"};
async function createDownload(event:FormEvent){event.preventDefault();if(!url.trim()||!sourceId)return;setBusy(true);setError("");try{await api("/downloads",{method:"POST",body:JSON.stringify({url:url.trim(),source_id:sourceId,relative_path:relativePath})});setUrl("");setShowAdd(false);await loadDownloads()}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
async function createTorrent(file?:File){if(!file||!sourceId)return;setBusy(true);setError("");try{const form=new FormData();form.append("file",file);form.append("source_id",sourceId);form.append("relative_path",relativePath);await api("/downloads/torrent",{method:"POST",body:form});setShowAdd(false);await loadDownloads()}catch(reason){setError((reason as Error).message)}finally{setBusy(false);if(torrentInput.current)torrentInput.current.value=""}}
async function action(task:DownloadTask,name:"pause"|"resume"|"retry"){try{await api(`/downloads/${task.id}/${name}`,{method:"POST",body:"{}"});await loadDownloads()}catch(reason){setError((reason as Error).message)}}
async function removeDownload(task:DownloadTask){if(!await appConfirm(`移除“${task.display_name}”`,"未完成的暂存文件会一并删除;已写入媒体库的文件不会受影响。",true))return;await api(`/downloads/${task.id}`,{method:"DELETE"});await loadDownloads()}
async function changePage(next:number){if(paging||next<1||next>uploadPage.pages||next===uploadPage.page)return;setPaging(true);try{await onPage(next);document.querySelector(".transfer-center")?.scrollIntoView({block:"start",behavior:window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth"})}finally{setPaging(false)}}
const attentionCount=uploadPage.active_count+uploadPage.failed_count;
const historyCount=Math.max(0,uploadPage.total-attentionCount);
function renderUploadTask(task:UploadTask){
const target=sources.find(source=>source.id===task.source_id);const completed=task.status==="completed";const autoRetry=task.status==="queued"&&!!task.retry_count;const transferred=task.transferred_bytes?`${formatSize(task.transferred_bytes)} / ${formatSize(task.size_bytes)}`:"";const title=task.title?.trim()||cleanMediaTitle(task.filename);const stages=task.stages||[];
return <article className={completed?"completed-history":""} key={task.id}><span className={`transfer-icon ${task.status}`}>{completed?<CheckCircle2/>:task.status==="failed"?<AlertCircle/>:<Upload/>}</span><div className="transfer-main"><div><strong>{title}</strong><span>{task.filename} · {task.stage_label||({receiving:"正在接收",queued:"等待上传",transferring:"正在转存",indexing:"正在加入媒体库",completed:"已完成",failed:"失败",cancelled:"已取消"} as Record<string,string>)[task.status]||"上传处理中"} · {formatSize(task.size_bytes)}</span></div><p className="transfer-target">目标:{target?.name||"媒体库"} · {target?sourceLabel(target):"媒体来源"}</p>{completed?<p className="transfer-completed"><CheckCircle2/>已加入媒体库,AI 识别已排队</p>:<div className="transfer-stages">{stages.map(step=><span className={step.state==="completed"?"done":step.state} key={step.key}><i><b style={{transform:`scaleX(${Math.max(0,Math.min(1,step.progress||0))})`}}/></i><small>{step.label}</small><em>{step.state==="completed"?"完成":step.state==="active"?`${Math.round((step.progress||0)*100)}%`:step.state==="failed"?"失败":step.state==="cancelled"?"已取消":"等待"}</em></span>)}</div>}<div className="transfer-meta">{task.deduplicated&&<b>SHA-256 安全秒传</b>}{task.resume_mode==="offset"&&<b>断点续传</b>}{autoRetry&&<b>自动重试中</b>}{transferred&&<span>{transferred}</span>}</div>{!completed&&<p>{task.error||task.message||"等待更新"}</p>}</div><div className="transfer-actions">{task.can_retry&&<button onClick={()=>api(`/uploads/${task.id}/retry`,{method:"POST",body:"{}"}).then(reload)}><RefreshCw/>{task.failure_stage==="catalog"?"重新入库":"重试"}</button>}{task.can_cancel&&<button className="danger-text" onClick={()=>api(`/uploads/${task.id}`,{method:"DELETE"}).then(reload)}>取消</button>}</div></article>;
}
return <div className="page transfer-center"><header className="page-heading"><button className="icon-button page-back" aria-label="返回" onClick={()=>window.history.back()}><ArrowLeft/></button><div><p className="eyebrow">传输中心</p><h1>传输中心</h1><p>统一查看浏览器上传、aria2 后台下载、媒体库写入和资料解析。</p></div><div className="page-heading-actions"><button className="secondary" onClick={()=>tab==="uploads"?reload():loadDownloads()}><RefreshCw/>刷新</button>{tab==="uploads"?<button className="primary continue-upload" onClick={onUpload}><Upload/>继续上传</button>:<button className="primary" onClick={()=>setShowAdd(true)}><Download/>新建下载</button>}</div></header>
<nav className="segment-nav transfer-tabs"><button className={tab==="uploads"?"active":""} onClick={()=>setTab("uploads")}>上传记录 {attentionCount>0&&<b aria-label={`${attentionCount} 条需要关注`}>{attentionCount}</b>}</button><button className={tab==="downloads"?"active":""} onClick={()=>setTab("downloads")}>后台下载 <b>{downloads.length}</b></button></nav>
{error&&<div className="error banner"><AlertCircle/>{error}</div>}
{tab==="uploads"?<><div className="transfer-summary" role="status" aria-label={`上传记录:进行中 ${uploadPage.active_count},失败 ${uploadPage.failed_count},历史 ${historyCount}`}><span className="active"><Upload/>进行中 <b>{uploadPage.active_count}</b></span><span className={uploadPage.failed_count?"failed":""}><AlertCircle/>失败 <b>{uploadPage.failed_count}</b></span><span><Clock3/>历史 <b>{historyCount}</b></span></div><div className="transfer-list">{uploads.map(renderUploadTask)}{!uploads.length&&<div className="empty"><Upload/><h3>还没有上传记录</h3><p>点击“继续上传”,把视频导入本地或网盘媒体库。</p></div>}</div>{uploadPage.pages>1&&<nav className="transfer-pagination" aria-label="上传任务分页"><button className="secondary" disabled={paging||uploadPage.page<=1} onClick={()=>void changePage(uploadPage.page-1)}>上一页</button><span> {uploadPage.page} / {uploadPage.pages} <small> {uploadPage.total} </small></span><button className="secondary" disabled={paging||uploadPage.page>=uploadPage.pages} onClick={()=>void changePage(uploadPage.page+1)}>下一页</button></nav>}</>:<div className="transfer-list download-list">{runtimeLoad.phase==="loading"||runtimeLoad.phase==="idle"?<AsyncNotice label="正在检查 aria2 与后台下载…"/>:runtimeLoad.phase==="error"?<div className="empty compact async-error"><AlertCircle/><h3>后台下载状态加载失败</h3><p>{runtimeLoad.error}</p><button className="secondary" onClick={()=>void loadDownloads()}><RefreshCw/>重试</button></div>:<>{runtime&&!runtime.available&&<div className="warning-callout"><AlertCircle/><span><strong>系统 aria2c 不可用</strong>请先在飞牛中安装 aria2ImageFind 不会私自下载另一套运行时。</span></div>}{downloads.map(task=>{const target=sources.find(source=>source.id===task.source_id);return <article key={task.id}><span className={`transfer-icon ${task.status}`}>{task.status==="completed"?<CheckCircle2/>:task.status==="failed"?<AlertCircle/>:<Download/>}</span><div className="transfer-main"><div><strong>{task.display_name}</strong><span>{labels[task.status]||task.status} · {Math.round(task.progress*100)}%</span></div><p className="transfer-target">目标:{target?.name||"媒体库"}/{task.relative_path} · {task.download_speed?`${formatSize(task.download_speed)}/s`:formatSize(task.total_bytes)}</p><i className="download-progress"><b style={{transform:`scaleX(${Math.max(0,Math.min(1,task.progress))})`}}/></i><p>{task.error||`${formatSize(task.completed_bytes)} / ${formatSize(task.total_bytes)}`}</p></div><div className="transfer-actions">{task.status==="downloading"&&<button onClick={()=>action(task,"pause")}><Pause/>暂停</button>}{task.status==="paused"&&<button onClick={()=>action(task,"resume")}><Play/>继续</button>}{task.status==="failed"&&<button onClick={()=>action(task,"retry")}><RefreshCw/>重试</button>}<button className="danger-text" onClick={()=>removeDownload(task)}><Trash2/>移除</button></div></article>})}{!downloads.length&&<div className="empty"><Download/><h3>还没有后台下载</h3><p>支持 HTTP/HTTPS/FTP、磁力链接和 Torrent</p></div>}</>}</div>}
{showAdd&&<div className="modal-backdrop"><form className="dialog download-dialog" onSubmit={createDownload}><header><div><p className="eyebrow">后台下载</p><h2>新建后台下载</h2></div><button type="button" className="icon-button" onClick={()=>setShowAdd(false)}><X/></button></header><label>下载地址<input autoFocus value={url} onChange={event=>setUrl(event.target.value)} placeholder="https://…、ftp://… 或 magnet:?…"/></label><label>目标媒体库<select value={sourceId} onChange={event=>setSourceId(event.target.value)}>{writable.map(source=><option key={source.id} value={source.id}>{source.name}</option>)}</select></label><label>保存目录<input value={relativePath} onChange={event=>setRelativePath(event.target.value)} placeholder="可留空,Torrent 会保留安全目录结构"/></label><button type="button" className="secondary wide" onClick={()=>torrentInput.current?.click()}><Folder/>选择 .torrent 文件</button><input ref={torrentInput} hidden type="file" accept=".torrent,application/x-bittorrent" onChange={event=>createTorrent(event.target.files?.[0])}/>{runtimeLoad.phase!=="ready"?<AsyncNotice compact label="正在检查 aria2…"/>:!runtime?.available&&<div className="error"><AlertCircle/>系统未安装 aria2c</div>}<footer><button type="button" className="secondary" onClick={()=>setShowAdd(false)}>取消</button><button className="primary" disabled={busy||runtimeLoad.phase!=="ready"||!runtime?.available||!url.trim()||!sourceId}>{busy?<RefreshCw className="spin"/>:<Download/>}开始下载</button></footer></form></div>}
</div>;
}
function ActorsPage({actors,people,reload,onSearch}:{actors:Actor[];people:Person[];reload:()=>void;onSearch:(name:string)=>void}){
const [tab,setTab]=useState<"actors"|"people">("actors");const [actorEdit,setActorEdit]=useState<Actor|"new"|null>(null);const [personEdit,setPersonEdit]=useState<Person|null>(null);
return <div className="page"><header className="page-heading"><button className="icon-button page-back" aria-label="返回" onClick={()=>window.history.back()}><ArrowLeft/></button><div><p className="eyebrow">演员与人物</p><h1>演员与人物</h1><p>演员资料可以关联本地 AI 聚类的人物;命名、合并和拆分不会上传人脸。</p></div><div className="page-heading-actions"><button className={tab==="actors"?"secondary active":"secondary"} onClick={()=>setTab("actors")}>演员资料</button><button className={tab==="people"?"secondary active":"secondary"} onClick={()=>setTab("people")}>AI 人物</button>{tab==="actors"&&<button className="primary" onClick={()=>setActorEdit("new")}><Plus/>添加演员</button>}</div></header>
{tab==="actors"?<div className="actor-grid">{actors.map(actor=><article key={actor.id}><span>{actor.thumbnail_url?<img src={appMediaUrl(actor.thumbnail_url)} alt={actor.name}/>:<UserCircle2/>}</span><h3>{actor.name}</h3><p>{actor.video_count||0} 部影片{actor.person_id?" · 已关联人物":" · 未关联人物"}</p><div className="actor-actions"><button onClick={()=>onSearch(actor.name)}>查看影片</button><button onClick={()=>setActorEdit(actor)}><Pencil/>管理</button></div></article>)}{!actors.length&&<div className="empty"><Users/><h3>尚未添加演员</h3><p>添加演员后可以关联 AI 识别的人物。</p></div>}</div>:<div className="actor-grid people-grid">{people.filter(person=>!person.hidden).map(person=><article key={person.id}><span>{person.thumbnail_url?<img src={appMediaUrl(person.thumbnail_url)} alt={person.name}/>:<UserCircle2/>}</span><h3>{person.name}</h3><p>{person.face_count} 张人脸{person.actor_id?" · 已关联演员":""}</p><button onClick={()=>setPersonEdit(person)}><Pencil/>管理人物</button></article>)}{!people.length&&<div className="empty"><Users/><h3>尚未识别人物</h3><p>安装人物模型并重新索引视频后会在这里显示。</p></div>}</div>}
{actorEdit&&<ActorEditor value={actorEdit==="new"?undefined:actorEdit} actors={actors} people={people} onClose={()=>setActorEdit(null)} onSaved={()=>{setActorEdit(null);reload()}}/>}{personEdit&&<PersonEditor value={personEdit} people={people} onClose={()=>setPersonEdit(null)} onSaved={()=>{setPersonEdit(null);reload()}}/>}
</div>;
}
function ActorEditor({value,actors,people,onClose,onSaved}:{value?:Actor;actors:Actor[];people:Person[];onClose:()=>void;onSaved:()=>void}){
const [name,setName]=useState(value?.name||"");const [aliases,setAliases]=useState(value?.aliases?.join("")||"");const [personId,setPersonId]=useState(value?.person_id||"");const [mergeTarget,setMergeTarget]=useState("");const [error,setError]=useState("");
async function save(event:FormEvent){event.preventDefault();try{await api(value?`/actors/${value.id}`:"/actors",{method:value?"PATCH":"POST",body:JSON.stringify({name,aliases:aliases.split(/[,]/).map(item=>item.trim()).filter(Boolean),person_id:personId||null})});onSaved()}catch(reason){setError((reason as Error).message)}}
async function merge(){if(!value||!mergeTarget)return;await api(`/actors/${mergeTarget}/merge`,{method:"POST",body:JSON.stringify({source_ids:[value.id]})});onSaved()}
async function remove(){if(!value||!await appConfirm(`删除演员“${value.name}”`,"只会删除演员资料,影片文件不会删除。",true))return;await api(`/actors/${value.id}`,{method:"DELETE"});onSaved()}
return <div className="modal-backdrop"><form className="dialog" onSubmit={save}><header><div><p className="eyebrow">演员资料</p><h2>{value?"管理演员":"添加演员"}</h2></div><button type="button" className="icon-button" onClick={onClose}><X/></button></header><label>演员名称<input required value={name} onChange={event=>setName(event.target.value)}/></label><label>别名(逗号分隔)<input value={aliases} onChange={event=>setAliases(event.target.value)}/></label><label>关联 AI 人物<select value={personId} onChange={event=>setPersonId(event.target.value)}><option value="">暂不关联</option>{people.map(person=><option value={person.id} key={person.id}>{person.name} · {person.face_count} 张人脸</option>)}</select></label>{value&&<div className="management-row"><select value={mergeTarget} onChange={event=>setMergeTarget(event.target.value)}><option value="">选择合并目标</option>{actors.filter(actor=>actor.id!==value.id).map(actor=><option value={actor.id} key={actor.id}>{actor.name}</option>)}</select><button type="button" onClick={merge}>合并</button><button type="button" className="danger-text" onClick={remove}>删除演员</button></div>}{error&&<div className="error"><AlertCircle/>{error}</div>}<footer><button type="button" className="secondary" onClick={onClose}>取消</button><button className="primary"><Pencil/>保存</button></footer></form></div>;
}
function PersonEditor({value,people,onClose,onSaved}:{value:Person;people:Person[];onClose:()=>void;onSaved:()=>void}){
type Face={id:string;thumbnail_url:string;video_title:string;timestamp_ms:number};
const [name,setName]=useState(value.name);const [hidden,setHidden]=useState(Boolean(value.hidden));const [faces,setFaces]=useState<Face[]>([]);const [selected,setSelected]=useState<string[]>([]);const [mergeTarget,setMergeTarget]=useState("");const [splitName,setSplitName]=useState("");const [error,setError]=useState("");
useEffect(()=>{api<Face[]>(`/people/${value.id}/faces`).then(setFaces).catch(reason=>setError((reason as Error).message))},[value.id]);
async function save(){await api(`/people/${value.id}`,{method:"PATCH",body:JSON.stringify({name,hidden})});onSaved()}
async function merge(){if(!mergeTarget)return;await api(`/people/${mergeTarget}/merge`,{method:"POST",body:JSON.stringify({source_ids:[value.id]})});onSaved()}
async function split(){if(!selected.length)return;await api(`/people/${value.id}/split`,{method:"POST",body:JSON.stringify({face_ids:selected,name:splitName})});onSaved()}
return <div className="modal-backdrop"><section className="dialog person-dialog"><header><div><p className="eyebrow">人物管理</p><h2>管理 AI 人物</h2></div><button className="icon-button" onClick={onClose}><X/></button></header><div className="form-row"><label>人物名称<input value={name} onChange={event=>setName(event.target.value)}/></label><label className="check"><input type="checkbox" checked={hidden} onChange={event=>setHidden(event.target.checked)}/>隐藏该人物</label></div><div className="face-grid">{faces.map(face=><label key={face.id}><input type="checkbox" checked={selected.includes(face.id)} onChange={event=>setSelected(current=>event.target.checked?[...current,face.id]:current.filter(id=>id!==face.id))}/><img src={appMediaUrl(face.thumbnail_url)} alt={face.video_title}/><span>{face.video_title}<small>{formatTime(face.timestamp_ms)}</small></span></label>)}</div><div className="management-row"><input value={splitName} onChange={event=>setSplitName(event.target.value)} placeholder="拆分后人物名称(可选)"/><button onClick={split} disabled={!selected.length}>拆分所选人脸</button></div><div className="management-row"><select value={mergeTarget} onChange={event=>setMergeTarget(event.target.value)}><option value="">选择合并目标人物</option>{people.filter(person=>person.id!==value.id).map(person=><option key={person.id} value={person.id}>{person.name}</option>)}</select><button onClick={merge} disabled={!mergeTarget}>合并到目标</button></div>{error&&<div className="error"><AlertCircle/>{error}</div>}<footer><button className="secondary" onClick={onClose}>取消</button><button className="primary" onClick={save}><Pencil/>保存</button></footer></section></div>;
}
function ProfilePage({profile,preferences,onPreferences,onProfile,onLogout,onPlay,masked,accessMode}:{profile:Profile|null;preferences:Preferences;onPreferences:(value:Partial<Preferences>)=>void;onProfile:(value:Profile)=>void;onLogout:()=>void;onPlay:(video:Video,position?:number)=>void;masked:boolean;accessMode:AccessMode}){
const [tab,setTab]=useState<"overview"|"favorites"|"likes"|"history"|"account">("overview");const [items,setItems]=useState<Video[]>([]);const [busy,setBusy]=useState(true);const [error,setError]=useState("");const [managing,setManaging]=useState(false);const [selected,setSelected]=useState<string[]>([]);
const [nickname,setNickname]=useState(profile?.nickname||"");const [passwords,setPasswords]=useState({current_password:"",new_password:"",confirm:""});
useEffect(()=>setNickname(profile?.nickname||""),[profile?.nickname]);
const loadItems=useCallback(async()=>{if(tab==="account")return;const query={overview:"?continue_only=true&sort=last_played&limit=20",favorites:"?favorite=true&limit=200",likes:"?liked=true&limit=200",history:"?played_only=true&sort=last_played&limit=200"}[tab];setBusy(true);try{setItems(await api<Video[]>(`/videos${query}`));setError("")}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}},[tab]);
useEffect(()=>{setManaging(false);setSelected([]);void loadItems()},[loadItems]);
async function saveNickname(event:FormEvent){event.preventDefault();setError("");try{const value=await api<Profile>("/profile",{method:"PATCH",body:JSON.stringify({nickname})});onProfile(value);toast("昵称已更新")}catch(reason){setError((reason as Error).message)}}
async function changePassword(event:FormEvent){event.preventDefault();setError("");if(passwords.new_password!==passwords.confirm)return setError("两次输入的新密码不一致");setBusy(true);try{await api("/profile/password",{method:"PATCH",body:JSON.stringify({current_password:passwords.current_password,new_password:passwords.new_password})});setCsrf("");onLogout()}catch(reason){setError((reason as Error).message)}finally{setBusy(false);setPasswords({current_password:"",new_password:"",confirm:""})}}
async function cleanSelected(){if(!selected.length||!["favorites","likes","history"].includes(tab))return;const action=tab==="favorites"?"unfavorite":tab==="likes"?"unlike":"clear_history";const label=tab==="favorites"?"取消收藏":tab==="likes"?"取消喜欢":"清除观看记录";if(!await appConfirm(`${label} ${selected.length} 部影片`,tab==="history"?"会清除播放进度和最后观看时间,不会删除影片。":"影片文件和其他状态不会改变。",tab==="history"))return;setBusy(true);try{await api("/videos/state/bulk",{method:"PATCH",body:JSON.stringify({video_ids:selected,action})});setItems(current=>current.filter(item=>!selected.includes(item.id)));setSelected([]);setManaging(false);onProfile(await api<Profile>("/profile"));toast(`已${label}`)}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
const tabs:Array<[typeof tab,string]>=[["overview","概览"],["favorites","收藏"],["likes","喜欢"],["history","观看记录"],["account","个人资料"]];
return <div className="page profile-page"><header className="page-heading profile-page-heading"><button className="icon-button page-back" aria-label="返回" onClick={()=>window.history.back()}><ArrowLeft/></button><div><h1>个人中心</h1><p>你的观看、收藏与偏好只保存在私有空间。</p></div></header><section className="profile-hero"><AvatarMark profile={profile} className="profile-avatar"/><div><h2>{profile?.nickname||"管理员"}</h2><p>ImageFind 管理员 · 本地账户 · 创建于 {profile?.created_at?new Date(profile.created_at).toLocaleDateString():"当前设备"}</p></div><button className="secondary profile-edit" onClick={()=>setTab("account")}><Pencil/>编辑资料</button></section><nav className="segment-nav" aria-label="个人中心分段">{tabs.map(([key,label])=><button className={tab===key?"active":""} key={key} onClick={()=>setTab(key)}>{label}</button>)}</nav>{error&&<div className="error banner"><AlertCircle/>{error}</div>}
{tab==="overview"&&<><div className="profile-stats">{[["收藏",profile?.counts.favorites||0,<Bookmark/>],["喜欢",profile?.counts.likes||0,<ThumbsUp/>],["观看记录",profile?.counts.history||0,<Clock3/>],["继续观看",profile?.counts.continue_watching||0,<Play/>]].map(([label,value,icon])=><article key={String(label)}>{icon}<span><strong>{value as number}</strong><small>{label as string}</small></span></article>)}</div><section className="section profile-continue"><header><div><h2>继续观看</h2><p>从上次停下的位置继续</p></div></header>{busy?<div className="loading"><RefreshCw className="spin"/></div>:items.length?<div className="profile-continue-grid">{items.map(video=><ContinueCard key={video.id} video={video} onPlay={onPlay} hidden={masked}/>)}</div>:<div className="empty compact"><Clock3/><h3>还没有未看完的影片</h3></div>}</section></>}
{["favorites","likes","history"].includes(tab)&&<section className="section profile-videos"><header><div><h2>{tab==="favorites"?"我的收藏":tab==="likes"?"我喜欢的影片":"观看记录"}</h2><p>{items.length} 部影片</p></div>{items.length>0&&<button className={managing?"secondary active":"secondary"} onClick={()=>{setManaging(!managing);setSelected([])}}>{managing?"完成":"管理"}</button>}</header>{managing&&<div className="profile-selection-bar"><button className="secondary" onClick={()=>setSelected(selected.length===items.length?[]:items.map(item=>item.id))}>{selected.length===items.length?"取消全选":"全选"}</button><span>已选择 {selected.length} </span><button className="primary destructive" disabled={!selected.length||busy} onClick={cleanSelected}>{tab==="favorites"?"取消收藏":tab==="likes"?"取消喜欢":"清除记录"}</button></div>}{busy?<div className="loading"><RefreshCw className="spin"/></div>:items.length?<div className="video-grid">{items.map(video=><VideoCard key={video.id} video={video} onPlay={onPlay} hidden={masked} selecting={managing} selected={selected.includes(video.id)} onSelect={item=>setSelected(current=>current.includes(item.id)?current.filter(id=>id!==item.id):[...current,item.id])}/>)}</div>:<div className="empty compact"><Film/><h3>这里暂时没有影片</h3></div>}</section>}
{tab==="account"&&<div className="profile-forms"><form className="panel" onSubmit={saveNickname}><h2><UserCircle2/>个人资料</h2><label>昵称<input minLength={1} maxLength={60} value={nickname} onChange={event=>setNickname(event.target.value)}/></label><button className="primary">保存昵称</button></form><section className="panel"><h2><Play/>播放与隐私</h2><div className="preference-list"><PreferenceSwitch title="自动播放下一部" description="影片结束后继续推荐列表" checked={preferences.autoplay} onChange={value=>onPreferences({autoplay:value})}/><PreferenceSwitch title="默认遮罩封面" description="登录后在当前管理员偏好中生效" checked={preferences.mask_covers} onChange={value=>onPreferences({mask_covers:value})}/><ThemeSelector value={preferences.theme} onChange={value=>onPreferences({theme:value})}/></div></section><form className="panel password-panel" onSubmit={changePassword}><h2><KeyRound/>{accessMode==="gateway"?"修改 ImageFind 回退密码":"修改管理员密码"}</h2><label>{accessMode==="gateway"?"当前回退密码":"当前密码"}<input type="password" value={passwords.current_password} onChange={event=>setPasswords({...passwords,current_password:event.target.value})}/></label><label>新密码<input type="password" minLength={10} value={passwords.new_password} onChange={event=>setPasswords({...passwords,new_password:event.target.value})}/></label><label>确认新密码<input type="password" minLength={10} value={passwords.confirm} onChange={event=>setPasswords({...passwords,confirm:event.target.value})}/></label><p className="muted">{accessMode==="gateway"?"此密码用于直接 Web/API 入口,不会修改飞牛密码。修改后会撤销全部 ImageFind 会话。":"修改后会撤销所有设备上的登录会话,并要求重新登录。"}</p><button className="primary" disabled={busy}><KeyRound/>修改并重新登录</button></form></div>}
</div>
}
function collectionGroups(items:CollectionTreeItem[],depth=0):Array<{item:CollectionTreeItem;depth:number}>{
const result:Array<{item:CollectionTreeItem;depth:number}>=[];
for(const item of items){if(item.kind!=="group")continue;result.push({item,depth});result.push(...collectionGroups(item.children||[],depth+1))}
return result;
}
function collectionItemById(items:CollectionTreeItem[],id:string):CollectionTreeItem|undefined{
for(const item of items){if(item.id===id)return item;const child=collectionItemById(item.children||[],id);if(child)return child}
}
function collectionSiblings(items:CollectionTreeItem[],parentId:string|null):CollectionTreeItem[]{
if(parentId===null)return items;const parent=collectionItemById(items,parentId);return parent?.children||[];
}
function CollectionsPage({collections,allVideos,groups,reload,onPlay,route,onRoute}:{collections:CollectionItem[];allVideos:Video[];groups:TagGroup[];reload:()=>Promise<void>;onPlay:(video:Video)=>void;route:CollectionRoute;onRoute:(route:CollectionRoute,replace?:boolean)=>void}){
const active=route.id||"";const managing=route.mode==="manage";
const [detail,setDetail]=useState<CollectionDetail|null>(null);
const [selected,setSelected]=useState<string[]>([]);
const [adding,setAdding]=useState(false);
const [busy,setBusy]=useState(false);
const [error,setError]=useState("");
const [rulesOpen,setRulesOpen]=useState(false);const [ruleTagIds,setRuleTagIds]=useState<string[]>([]);const [rulesLoading,setRulesLoading]=useState(false);
const [collapsed,setCollapsed]=useState<string[]>([]);const [addParent,setAddParent]=useState("");const [moving,setMoving]=useState<CollectionTreeItem|null>(null);
const dragged=useRef("");
const loadDetail=useCallback(async(id=active)=>{
if(!id){setDetail(null);return}
setDetail(null);
try{setDetail(normalizeCollectionDetail(await api<CollectionDetail>(`/collections/${id}`)));setError("")}
catch(reason){setError((reason as Error).message);setDetail(null)}
},[active]);
useEffect(()=>{void loadDetail()},[loadDetail]);
useEffect(()=>{if(route.mode==="detail"&&!active)onRoute({mode:"index"},true)},[active,onRoute,route.mode]);
useEffect(()=>{if(!managing||!active){setRuleTagIds([]);setRulesOpen(false);return}setRulesLoading(true);api<TagItem[]>(`/collections/${active}/tags`).then(rows=>setRuleTagIds(rows.map(row=>row.id))).catch(reason=>setError((reason as Error).message)).finally(()=>setRulesLoading(false))},[active,managing]);
useEffect(()=>{if(active&&!collections.some(item=>item.id===active)){onRoute({mode:managing?"manage":"index"},true);setDetail(null)}},[active,collections,managing,onRoute]);
async function create(){
const name=await appPrompt("新建合集","","输入合集名称");if(!name?.trim())return;
try{const value=await api<CollectionItem>("/collections",{method:"POST",body:JSON.stringify({name:name.trim()})});await reload();onRoute({mode:"manage",id:value.id},true);toast("合集已创建")}
catch(reason){setError((reason as Error).message)}
}
async function rename(){
if(!detail)return;const name=await appPrompt("重命名合集",detail.name);if(!name?.trim()||name.trim()===detail.name)return;
try{await api(`/collections/${detail.id}`,{method:"PATCH",body:JSON.stringify({name:name.trim()})});await reload();await loadDetail(detail.id);toast("合集已重命名")}
catch(reason){setError((reason as Error).message)}
}
async function editDescription(){
if(!detail)return;const description=await appPrompt("编辑合集简介",detail.description||"","可以留空");if(description===null)return;
try{await api(`/collections/${detail.id}`,{method:"PATCH",body:JSON.stringify({description})});await reload();await loadDetail(detail.id);toast("合集简介已更新")}
catch(reason){setError((reason as Error).message)}
}
async function dissolve(){
if(!detail||!await appConfirm(`解散“${detail.name}”`,`${detail.video_count} 部影片会移出合集,但不会删除影片文件。`,true))return;
try{await api(`/collections/${detail.id}`,{method:"DELETE"});setDetail(null);await reload();onRoute({mode:"manage"},true);toast("合集已解散")}
catch(reason){setError((reason as Error).message)}
}
async function addSelected(){
if(!detail||!selected.length)return;setBusy(true);
try{await api(`/collections/${detail.id}/videos`,{method:"POST",body:JSON.stringify({video_ids:selected,parent_id:addParent||null})});setSelected([]);setAdding(false);await reload();await loadDetail(detail.id);toast(`已加入 ${selected.length} 部影片`)}
catch(reason){setError((reason as Error).message)}finally{setBusy(false)}
}
async function remove(video:Video){
if(!detail)return;
try{await api(`/collections/${detail.id}/videos/${video.id}`,{method:"DELETE"});await reload();await loadDetail(detail.id);toast("已移出合集")}
catch(reason){setError((reason as Error).message)}
}
async function setCover(video:Video){
if(!detail)return;
try{await api(`/collections/${detail.id}`,{method:"PATCH",body:JSON.stringify({cover_video_id:video.id})});await reload();await loadDetail(detail.id);toast("已设为合集封面")}
catch(reason){setError((reason as Error).message)}
}
function toggleRule(group:TagGroup,tagId:string){setRuleTagIds(current=>{if(current.includes(tagId))return current.filter(id=>id!==tagId);const retained=group.selection_mode==="single"?current.filter(id=>!group.tags.some(tag=>tag.id===id)):current;return [...retained,tagId]})}
async function saveRules(){if(!detail)return;setBusy(true);try{const value=await api<{applied:number}>(`/collections/${detail.id}/tags`,{method:"PUT",body:JSON.stringify({tag_ids:ruleTagIds})});setRulesOpen(false);await reload();await loadDetail(detail.id);toast(`默认分类已应用到 ${value.applied} 部影片`)}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
async function addGroup(parentId:string|null=null){
if(!detail)return;const name=await appPrompt(parentId?"新建子分组":"新建分组","","可以使用章节、小节或任何适合内容的名称");if(!name?.trim())return;
try{await api(`/collections/${detail.id}/groups`,{method:"POST",body:JSON.stringify({name:name.trim(),parent_id:parentId})});await loadDetail(detail.id);toast("分组已创建")}
catch(reason){setError((reason as Error).message)}
}
async function renameGroup(item:CollectionTreeItem){
if(!detail)return;const name=await appPrompt("重命名分组",item.name||"");if(!name?.trim()||name.trim()===item.name)return;
try{await api(`/collections/${detail.id}/groups/${item.id}`,{method:"PATCH",body:JSON.stringify({name:name.trim()})});await loadDetail(detail.id);toast("分组已重命名")}
catch(reason){setError((reason as Error).message)}
}
async function removeGroup(item:CollectionTreeItem){
if(!detail||!await appConfirm(`删除“${item.name}”`,"其中的影片和子分组会提升到上一级,不会删除任何视频。",true))return;
try{await api(`/collections/${detail.id}/groups/${item.id}`,{method:"DELETE"});await loadDetail(detail.id);toast("分组已删除,内容已提升")}
catch(reason){setError((reason as Error).message)}
}
async function moveItem(item:CollectionTreeItem,parentId:string|null,position:number){
if(!detail)return;
try{await api(`/collections/${detail.id}/items/${item.id}/move`,{method:"PATCH",body:JSON.stringify({parent_id:parentId,position})});setMoving(null);await loadDetail(detail.id)}
catch(reason){setError((reason as Error).message)}
}
function shiftItem(item:CollectionTreeItem,direction:-1|1){if(!detail)return;const parent=item.parent_id||null;const siblings=collectionSiblings(detail.items,parent);const index=siblings.findIndex(value=>value.id===item.id);const target=index+direction;if(index<0||target<0||target>=siblings.length)return;void moveItem(item,parent,target)}
function dropItem(event:ReactDragEvent<HTMLElement>,target:CollectionTreeItem,inside=false){event.preventDefault();if(!detail||!dragged.current||dragged.current===target.id)return;const source=collectionItemById(detail.items,dragged.current);if(!source)return;const parent=inside&&target.kind==="group"?target.id:target.parent_id||null;const siblings=collectionSiblings(detail.items,parent);const position=inside&&target.kind==="group"?siblings.length:Math.max(0,siblings.findIndex(item=>item.id===target.id));dragged.current="";void moveItem(source,parent,position)}
const candidates=allVideos.filter(video=>!detail?.videos.some(item=>item.id===video.id));
function renderManageNodes(nodes:CollectionTreeItem[],depth=0):ReactNode{
if(!detail)return null;
return nodes.map((item,index)=>{
const indent={"--collection-depth":depth} as CSSProperties;
if(item.kind==="group"){
const closed=collapsed.includes(item.id);
return <div className="collection-tree-branch" key={item.id}><article className="collection-group-row" style={indent} draggable onDragStart={()=>{dragged.current=item.id}} onDragOver={event=>event.preventDefault()} onDrop={event=>dropItem(event,item,true)}><button className="collection-group-toggle" aria-expanded={!closed} onClick={()=>setCollapsed(current=>current.includes(item.id)?current.filter(id=>id!==item.id):[...current,item.id])}><ChevronRight/><Folder/><span><strong>{item.name}</strong><small>{item.video_count||0} · {formatTime(item.duration_ms)}</small></span></button><div className="collection-row-actions"><button aria-label="上移分组" disabled={index===0} onClick={()=>shiftItem(item,-1)}><ArrowUp/></button><button aria-label="下移分组" disabled={index===nodes.length-1} onClick={()=>shiftItem(item,1)}><ArrowDown/></button><button onClick={()=>addGroup(item.id)}><FolderPlus/>子分组</button><button onClick={()=>setMoving(item)}>移动</button><button onClick={()=>renameGroup(item)}><Pencil/>重命名</button><button className="danger-text" onClick={()=>removeGroup(item)}><Trash2/>删除</button></div></article>{!closed&&<div className="collection-tree-children">{renderManageNodes(item.children||[],depth+1)}{!(item.children||[]).length&&<button className="collection-tree-empty" onClick={()=>addGroup(item.id)}><Plus/>在“{item.name}”中新建子分组</button>}</div>}</div>;
}
const video=item.video||allVideos.find(value=>value.id===item.video_id);if(!video)return null;const title=mediaTitle(video);
return <article className="collection-video-row" style={indent} draggable key={item.id} onDragStart={()=>{dragged.current=item.id}} onDragOver={event=>event.preventDefault()} onDrop={event=>dropItem(event,item)}><span className="collection-drag"><GripVertical/><b>{index+1}</b></span><button className="collection-video-cover" onClick={()=>onPlay(video)}><Thumb src={video.thumbnail_url} title={title}/><i>{formatTime(video.duration_ms)}</i></button><div><strong>{title}</strong><small>{item.path.length?item.path.join(" / "):"未分组"}</small>{detail.cover_video_id===video.id&&<b>合集封面</b>}</div><div className="collection-row-actions"><button aria-label="上移" disabled={index===0} onClick={()=>shiftItem(item,-1)}><ArrowUp/></button><button aria-label="下移" disabled={index===nodes.length-1} onClick={()=>shiftItem(item,1)}><ArrowDown/></button><button onClick={()=>setMoving(item)}>移动</button><button onClick={()=>setCover(video)}>设为封面</button><button className="danger-text" onClick={()=>remove(video)}>移出</button></div></article>;
});
}
function renderPreviewNodes(nodes:CollectionTreeItem[],depth=0):ReactNode{
return nodes.map((item,index)=>{
const indent={"--collection-depth":depth} as CSSProperties;
if(item.kind==="group"){
const closed=collapsed.includes(item.id);
return <section className="collection-preview-branch" key={item.id}><button className="collection-preview-group" style={indent} aria-expanded={!closed} onClick={()=>setCollapsed(current=>current.includes(item.id)?current.filter(id=>id!==item.id):[...current,item.id])}><ChevronRight/><Folder/><span><strong>{item.name}</strong><small>{item.video_count||0} 部影片 · {formatTime(item.duration_ms)}</small></span></button>{!closed&&<div className="collection-preview-children">{renderPreviewNodes(item.children||[],depth+1)}</div>}</section>;
}
const video=item.video||allVideos.find(value=>value.id===item.video_id);if(!video)return null;const title=mediaTitle(video);
return <button className="collection-preview-video" style={indent} key={item.id} onClick={()=>onPlay(video)} aria-label={`播放 ${title}`}><span className="collection-preview-index">{index+1}</span><span className="collection-preview-cover"><Thumb src={video.thumbnail_url} title={title}/><i>{formatTime(video.duration_ms)}</i></span><span className="collection-preview-copy"><strong>{title}</strong><small>{item.path.length?item.path.join(" / "):"合集根级"}</small></span><Play className="collection-preview-play"/></button>;
});
}
function orderedVideos(nodes:CollectionTreeItem[]):Video[]{
const result:Video[]=[];
for(const item of nodes){if(item.kind==="group")result.push(...orderedVideos(item.children||[]));else{const video=item.video||allVideos.find(value=>value.id===item.video_id);if(video?.available)result.push(video)}}
return result;
}
const playableVideos=detail?orderedVideos(detail.items):[];
const firstPlayable=playableVideos[0]||detail?.videos.find(video=>video.available);
if(route.mode==="index")return <div className="page series-page collections-page collections-browser">
<header className="page-heading"><button className="icon-button page-back" aria-label="返回" onClick={()=>window.history.back()}><ArrowLeft/></button><div><h1>合集</h1><p>按合集连续浏览影片,进入后可查看章节与小节。</p></div><button className="secondary" onClick={()=>onRoute({mode:"manage"})}><Settings/>管理合集</button></header>
<div className="collection-overview collection-browser-grid">{collections.map(item=><button className="collection-overview-card" key={item.id} onClick={()=>onRoute({mode:"detail",id:item.id})}><span><Thumb src={item.thumbnail_url} title={item.name}/><i>{item.video_count} </i></span><h2>{item.name}</h2><p>{item.description||"暂无简介"}</p><small>总时长 {item.duration_ms?formatTime(item.duration_ms):"待识别"}</small></button>)}{!collections.length&&<div className="empty"><Boxes/><h3>还没有合集</h3><p>进入管理模式创建合集,把适合连续观看的影片整理在一起。</p><button className="primary" onClick={()=>onRoute({mode:"manage"})}><Settings/>管理合集</button></div>}</div>
</div>;
if(route.mode==="detail")return <div className="page series-page collections-page collection-preview-page">
<header className="collection-preview-nav"><button className="icon-button" aria-label="返回合集列表" onClick={()=>window.history.back()}><ArrowLeft/></button><span>合集详情</span><button className="secondary" onClick={()=>onRoute({mode:"manage",id:active})}><Settings/>管理合集</button></header>
{error&&<div className="error banner"><AlertCircle/>{error}</div>}
{detail?<><section className="collection-preview-hero"><span><Thumb src={detail.thumbnail_url} title={detail.name} loading="eager"/></span><div><h1>{detail.name}</h1><p>{detail.description||"这个合集还没有简介。"}</p><small>{detail.video_count} 部影片 · 总时长 {detail.duration_ms?formatTime(detail.duration_ms):"待识别"}</small></div><button className="primary" disabled={!firstPlayable} onClick={()=>firstPlayable&&onPlay(firstPlayable)}><Play fill="currentColor"/>播放全部</button></section><section className="collection-preview-directory"><header><div><h2>合集目录</h2><p>按章节顺序连续观看</p></div><span>{detail.video_count} </span></header><div>{renderPreviewNodes(detail.items)}{!detail.items.length&&<div className="empty compact"><Boxes/><h3>合集还是空的</h3><p>进入管理模式添加影片。</p></div>}</div></section></>:error?<div className="empty compact collection-preview-error"><AlertCircle/><h3>合集读取失败</h3><p>{error}</p><button className="secondary" onClick={()=>void loadDetail()}><RefreshCw/>重新加载</button></div>:<div className="loading"><RefreshCw className="spin"/><p>正在读取合集…</p></div>}
</div>;
return <div className="page series-page collections-page collections-manager">
<header className="page-heading"><button className="icon-button page-back" aria-label="返回" onClick={()=>window.history.back()}><ArrowLeft/></button><div><h1>管理合集</h1><p>编辑合集资料、默认分类、章节层级和播放顺序。</p></div><div className="page-heading-actions"><button className="primary" onClick={create}><Plus/>新建合集</button><button className="secondary" onClick={()=>window.history.back()}><CheckCircle2/>完成</button></div></header>
{error&&<div className="error banner"><AlertCircle/>{error}</div>}
<div className="series-layout collection-layout"><aside className="series-list collection-list"><button className={!active?"active":""} onClick={()=>{onRoute({mode:"manage"},true);setAdding(false);setSelected([])}}><Boxes/><span><strong>全部合集</strong><small>{collections.length} 个合集</small></span></button>{collections.map(item=><button className={active===item.id?"active":""} key={item.id} onClick={()=>{onRoute({mode:"manage",id:item.id},true);setAdding(false);setSelected([])}}><span className="series-thumb"><Thumb src={item.thumbnail_url} title={item.name}/></span><span><strong>{item.name}</strong><small>{item.video_count} · {formatTime(item.duration_ms)}</small></span></button>)}</aside>
<section className="series-content collection-content">{active?detail?<>
<header className="collection-hero"><span><Thumb src={detail.thumbnail_url} title={detail.name} loading="eager"/></span><div><p className="eyebrow">合集详情</p><h2>{detail.name}</h2><p>{detail.description||"这个合集还没有简介。"}</p><small>{detail.video_count} 部影片 · 总时长 {formatTime(detail.duration_ms)}</small></div><div><button className={`secondary ${rulesOpen?"active":""}`} onClick={()=>setRulesOpen(!rulesOpen)}><Tags/>默认分类</button><button className="secondary" onClick={rename}><Pencil/>重命名</button><button className="secondary" onClick={editDescription}><Share2/>简介</button><button className="danger-text" onClick={dissolve}><Trash2/>解散</button></div></header>
{rulesOpen&&<section className="collection-tag-rules"><header><div><strong>合集默认分类</strong><small>立即补充现有视频,之后加入的视频也会自动继承;取消规则不会删除视频已有分类。</small></div></header>{rulesLoading?<AsyncNotice compact label="正在读取默认分类…"/>:<div>{groups.filter(group=>group.tags.length).map(group=><fieldset key={group.id}><legend>{group.name}<small>{group.selection_mode==="single"?"单选":"多选"}</small></legend><div>{group.tags.map(tag=><button type="button" className={ruleTagIds.includes(tag.id)?"active":""} aria-pressed={ruleTagIds.includes(tag.id)} key={tag.id} onClick={()=>toggleRule(group,tag.id)}>{ruleTagIds.includes(tag.id)&&<CheckCircle2/>}{tag.name}</button>)}</div></fieldset>)}</div>}<footer><span>已选择 {ruleTagIds.length} 个分类</span><button className="primary" disabled={busy||rulesLoading} onClick={saveRules}>{busy?<RefreshCw className="spin"/>:<CheckCircle2/>}保存并应用</button></footer></section>}
<div className="collection-toolbar"><div><strong>合集目录</strong><span>分组可以任意嵌套;深度优先顺序就是连续播放顺序</span></div><div><button className="secondary" onClick={()=>addGroup(null)}><FolderPlus/>新建分组</button><button className={adding?"secondary active":"primary"} onClick={()=>{setAdding(!adding);setSelected([])}}>{adding?<X/>:<ListPlus/>}{adding?"关闭选择":"添加影片"}</button></div></div>
{adding&&<section className="collection-add-panel"><header><strong>选择要加入的影片</strong><span>从其他合集选择时会自动移动到当前合集</span><label>加入到<select value={addParent} onChange={event=>setAddParent(event.target.value)}><option value="">合集根级(未分组)</option>{collectionGroups(detail.items).map(({item,depth})=><option key={item.id} value={item.id}>{" ".repeat(depth)}{item.name}</option>)}</select></label></header><div className="collection-candidates">{candidates.map(video=><label key={video.id}><input type="checkbox" checked={selected.includes(video.id)} onChange={event=>setSelected(current=>event.target.checked?[...current,video.id]:current.filter(id=>id!==video.id))}/><span><Thumb src={video.thumbnail_url} title={video.title}/></span><div><strong>{video.title}</strong><small>{video.collection_name?`当前在“${video.collection_name}”`:`${video.catalog_code||"未加入合集"}`}</small></div></label>)}</div><footer><span>已选择 {selected.length} </span><button className="primary" disabled={!selected.length||busy} onClick={addSelected}>{busy?<RefreshCw className="spin"/>:<Plus/>}加入当前层级</button></footer></section>}
<div className="collection-video-list collection-tree">{renderManageNodes(detail.items)}{!detail.items.length&&<div className="empty compact"><Boxes/><h3>合集还是空的</h3><p>可以先建立章节,也可以直接添加影片。</p><button className="primary" onClick={()=>setAdding(true)}><Plus/>添加影片</button></div>}</div>
</>:<div className="loading"><RefreshCw className="spin"/><p>正在读取合集…</p></div>:<div className="collection-overview">{collections.map(item=><button className="collection-overview-card" key={item.id} onClick={()=>onRoute({mode:"manage",id:item.id},true)}><span><Thumb src={item.thumbnail_url} title={item.name}/><i>{item.video_count} </i></span><h2>{item.name}</h2><p>{item.description||"暂无简介"}</p><small>总时长 {formatTime(item.duration_ms)}</small></button>)}{!collections.length&&<div className="empty"><Boxes/><h3>还没有合集</h3><p>创建合集,把适合连续观看的影片整理在一起。</p><button className="primary" onClick={create}><Plus/>创建第一个合集</button></div>}</div>}</section>
</div>{moving&&detail&&<div className="modal-backdrop collection-move-backdrop" onMouseDown={event=>{if(event.target===event.currentTarget)setMoving(null)}}><section className="dialog collection-move-dialog" role="dialog" aria-modal="true"><header><div><p className="eyebrow">移动项目</p><h2>移动“{moving.name||moving.video?.title||"视频"}</h2></div><button className="icon-button" onClick={()=>setMoving(null)}><X/></button></header><div className="collection-move-list"><button onClick={()=>moveItem(moving,null,detail.items.length)}><Folder/><span><strong>{detail.name}</strong><small>合集根级(未分组)</small></span></button>{collectionGroups(detail.items).map(({item,depth})=>{const disabled=item.id===moving.id||(moving.kind==="group"&&Boolean(collectionItemById(moving.children||[],item.id)));return <button disabled={disabled} style={{paddingLeft:14+depth*18}} key={item.id} onClick={()=>moveItem(moving,item.id,(item.children||[]).length)}><Folder/><span><strong>{item.name}</strong><small>{item.video_count||0} 部影片</small></span></button>})}</div></section></div>}
</div>
}
function BackupPanel(){
const [status,setStatus]=useState<BackupStatus|null>(null);const [statusError,setStatusError]=useState("");
const [scope,setScope]=useState<"keys"|"full">("full");const [exportPassword,setExportPassword]=useState("");const [exportConfirm,setExportConfirm]=useState("");
const [exportBusy,setExportBusy]=useState(false);const [exportError,setExportError]=useState("");const [exports,setExports]=useState<BackupExport[]|null>(null);const [downloading,setDownloading]=useState("");
const [restoreFile,setRestoreFile]=useState<File|null>(null);const [restorePassword,setRestorePassword]=useState("");const [restoreConfirmed,setRestoreConfirmed]=useState(false);
const [restoreBusy,setRestoreBusy]=useState(false);const [restoreError,setRestoreError]=useState("");const [restoreResult,setRestoreResult]=useState<BackupResult|null>(null);const restoreInput=useRef<HTMLInputElement>(null);
const loadStatus=useCallback(()=>api<BackupStatus>("/backups/status").then(value=>{setStatus(value);setStatusError("")}).catch(reason=>setStatusError((reason as Error).message)),[]);
const loadExports=useCallback(()=>api<BackupExport[]>("/backups").then(setExports).catch(reason=>setExportError((reason as Error).message)),[]);
useEffect(()=>{loadStatus();loadExports()},[loadStatus,loadExports]);
useEffect(()=>{
if(!exports?.some(item=>item.status==="queued"||item.status==="running"))return;
const timer=window.setInterval(loadExports,2000);return()=>window.clearInterval(timer);
},[exports,loadExports]);
async function exportFile(event:FormEvent){
event.preventDefault();setExportError("");
if(exportPassword!==exportConfirm){setExportError("两次输入的备份密码不一致");return}
if(exportPassword.length<10){setExportError("备份密码至少需要 10 个字符");return}
setExportBusy(true);
try{const value=await api<BackupExport>("/backups",{method:"POST",body:JSON.stringify({scope,password:exportPassword})});setExports(current=>[value,...(current||[])]);toast("备份任务已提交,可离开此页面后台生成")}
catch(reason){setExportError((reason as Error).message)}
finally{setExportBusy(false);setExportPassword("");setExportConfirm("")}
}
async function downloadExport(item:BackupExport){
setDownloading(item.id);setExportError("");
try{await downloadGeneratedBackup(item.id)}catch(reason){setExportError((reason as Error).message)}finally{setDownloading("")}
}
async function restoreFileNow(event:FormEvent){
event.preventDefault();setRestoreError("");setRestoreResult(null);
if(!restoreFile)return setRestoreError("请选择 .ifbackup 备份文件");
if(!status?.can_restore)return setRestoreError("当前系统不是空系统,无法恢复备份");
if(!restoreConfirmed)return setRestoreError("请先勾选恢复确认");
setRestoreBusy(true);
try{const value=await restoreBackup<BackupResult>(restoreFile,restorePassword,true);setRestoreResult(value);setRestoreFile(null);setRestoreConfirmed(false);if(restoreInput.current)restoreInput.current.value="";await loadStatus()}
catch(reason){setRestoreError((reason as Error).message)}
finally{setRestoreBusy(false);setRestorePassword("")}
}
const blockerNames:Record<string,string>={sources:"来源",videos:"影片",people:"AI 人物",actors:"演员",tags:"标签"};
const countNames:Record<string,string>={sources:"来源",videos:"影片",tag_groups:"分类组",tags:"标签",actors:"演员",video_metadata:"影片资料",video_actors:"影片演员关系",video_tags:"影片标签关系",video_state:"收藏与进度",settings:"偏好与镜像配置"};
return <section className="panel backup-panel"><h2><ShieldCheck/>系统备份与恢复</h2><p className="muted">生成单一加密文件。备份密码独立于管理员密码,忘记后无法解密;不会包含管理员、会话、API Token、媒体文件、模型或 AI 索引。</p>
<div className={`backup-readiness ${status?.can_restore?"ready":"blocked"}`}>{statusError?<><AlertCircle/>{statusError}</>:!status?<><RefreshCw className="spin"/>正在检查恢复条件…</>:status.can_restore?<><CheckCircle2/><span><strong>当前是空系统,可以恢复</strong><small>现有管理员和 API Token 会保留,不会被备份覆盖。</small></span></>:<><AlertCircle/><span><strong>当前系统已有业务数据,不能恢复</strong><small>{Object.entries(status.blockers).map(([key,value])=>`${blockerNames[key]||key} ${value}`).join(" · ")}</small></span></>}</div>
<div className="backup-columns"><form className="backup-action" onSubmit={exportFile}><header><Download/><span><strong>导出加密备份</strong><small>所有备份都包含来源登录凭据与 rclone crypt 密钥。</small></span></header>
<fieldset className="backup-scope"><legend>备份范围</legend><label><input type="radio" name="backup-scope" checked={scope==="full"} onChange={()=>setScope("full")}/><span><strong>完整备份</strong><small>密钥、来源、影片身份和全部用户元数据</small></span></label><label><input type="radio" name="backup-scope" checked={scope==="keys"} onChange={()=>setScope("keys")}/><span><strong>仅密钥与来源</strong><small>适合只迁移本地、WebDAV AList 连接配置</small></span></label></fieldset>
<label>独立备份密码<input type="password" autoComplete="new-password" minLength={10} maxLength={256} value={exportPassword} onChange={event=>setExportPassword(event.target.value)} placeholder="10256 个字符"/></label><label>确认备份密码<input type="password" autoComplete="new-password" minLength={10} maxLength={256} value={exportConfirm} onChange={event=>setExportConfirm(event.target.value)}/></label>
<button className="primary backup-submit" disabled={exportBusy}>{exportBusy?<RefreshCw className="spin"/>:<Download/>}{exportBusy?"正在提交…":"后台生成备份"}</button>
<div className="backup-history"><header><strong>最近生成记录</strong><button type="button" className="icon-button" aria-label="刷新备份记录" onClick={loadExports}><RefreshCw/></button></header>{exports===null?<AsyncNotice compact label="正在读取备份记录…"/>:exports.length?exports.map(item=><article className={item.status} key={item.id}><span className="backup-state-icon">{item.status==="completed"?<CheckCircle2/>:item.status==="failed"?<AlertCircle/>:<RefreshCw className="spin"/>}</span><div><strong>{item.filename||`${item.scope==="full"?"完整":"密钥与来源"}备份`}</strong><small>{item.status==="queued"?"等待后台任务开始":item.status==="running"?"正在加密并生成":item.status==="failed"?item.error||"生成失败":`${formatSize(item.size_bytes||0)} · 7 天内可下载`}</small></div>{item.status==="completed"&&<button type="button" className="secondary" disabled={downloading===item.id} onClick={()=>downloadExport(item)}>{downloading===item.id?<RefreshCw className="spin"/>:<Download/>}{downloading===item.id?"准备中":"下载"}</button>}</article>):<p className="muted">还没有后台备份记录。</p>}</div>{exportError&&<div className="error"><AlertCircle/>{exportError}</div>}
</form><form className="backup-action" onSubmit={restoreFileNow}><header><Upload/><span><strong>恢复到这台设备</strong><small>只允许恢复到没有来源、影片、人物、演员或用户标签的系统。</small></span></header>
<label className="backup-file-picker"><Upload/><span><strong>{restoreFile?.name||"选择 .ifbackup 文件"}</strong><small>{restoreFile?formatSize(restoreFile.size):`最大 ${formatSize(status?.max_upload_bytes||2*1024**3)}`}</small></span><input ref={restoreInput} type="file" accept=".ifbackup,application/vnd.imagefind.backup" onChange={event=>{const file=event.target.files?.[0]||null;setRestoreFile(file);setRestoreError("");setRestoreResult(null)}}/></label>
<label>备份密码<input type="password" autoComplete="current-password" minLength={10} maxLength={256} value={restorePassword} onChange={event=>setRestorePassword(event.target.value)} placeholder="创建备份时使用的密码"/></label><label className="backup-confirm"><input type="checkbox" checked={restoreConfirmed} onChange={event=>setRestoreConfirmed(event.target.checked)}/><span><strong>我确认恢复到空系统</strong><small>保留当前管理员和 Token;恢复后将为启用的来源自动创建扫描任务。</small></span></label>
<button className="primary backup-submit" disabled={restoreBusy||!status?.can_restore||!restoreFile||restorePassword.length<10||!restoreConfirmed}>{restoreBusy?<RefreshCw className="spin"/>:<Upload/>}{restoreBusy?"正在校验并恢复…":"确认恢复"}</button>{restoreResult&&<div className="backup-restore-result"><div className="backup-result"><CheckCircle2/><span><strong>恢复完成 · {restoreResult.scope==="full"?"完整备份":"仅密钥与来源"}</strong><small>{Object.entries(restoreResult.counts).filter(([,value])=>value).map(([key,value])=>`${countNames[key]||key} ${value}`).join(" · ")||"备份中没有业务记录"}</small></span></div><p>已创建 {restoreResult.scan_jobs.length} 个来源扫描任务</p>{restoreResult.warnings.map((warning,index)=><p className="backup-warning" key={index}><AlertCircle/>{warning}</p>)}</div>}{restoreError&&<div className="error"><AlertCircle/>{restoreError}</div>}
</form></div>
</section>
}
function SettingsPage({ preferences, sources, onPreferences }: {preferences:Preferences;sources:Source[];onPreferences:(value:Partial<Preferences>)=>void }) {
const [section,setSection]=useState<"ai"|"storage"|"backup"|"account"|"tasks">("ai");
const [models,setModels]=useState<Record<string,any>>({}); const [tokens,setTokens]=useState<any[]>([]); const [created,setCreated]=useState("");
const [modelJob,setModelJob]=useState(""); const [modelBusy,setModelBusy]=useState(false);
const [modelMessage,setModelMessage]=useState(""); const [modelError,setModelError]=useState("");
const [hfEndpoint,setHfEndpoint]=useState("https://hf-mirror.com");
const [pipSources,setPipSources]=useState({indexUrl:"https://pypi.tuna.tsinghua.edu.cn/simple",pytorchIndexUrl:"https://download.pytorch.org/whl/cpu"});
const [proxy,setProxy]=useState({enabled:false,url:"",username:"",password:""});const [storage,setStorage]=useState<StorageUsage|null>(null);const [proxyTest,setProxyTest]=useState<Record<string,{ok:boolean;latency_ms:number;error?:string}>|null>(null);const [resources,setResources]=useState<ResourceStatus|null>(null);
const [capacities,setCapacities]=useState<Record<string,SourceCapacity>>({});const [capacityLoading,setCapacityLoading]=useState(false);
const [capacityEdit,setCapacityEdit]=useState<{sourceId:string;totalTib:string;availableTib:string;busy:boolean;error?:string}|null>(null);
const [speechConfig,setSpeechConfig]=useState<SpeechConfig|null>(null);const [speechLoad,setSpeechLoad]=useState<LoadStatus>(idleLoad());const [speechBusy,setSpeechBusy]=useState(false);const [speechMessage,setSpeechMessage]=useState("");
const [uploadInfo,setUploadInfo]=useState<{filename:string;size_bytes:number;sha256:string}|null>(null);
const [jobData,setJobData]=useState<JobPage>({items:[],page:1,page_size:10,total:0,pages:1,retryable_failed_count:0});
const [jobLane,setJobLane]=useState<"all"|JobLane>("all");
const [modelsLoad,setModelsLoad]=useState<LoadStatus>(idleLoad());const [tokensLoad,setTokensLoad]=useState<LoadStatus>(idleLoad());const [storageLoad,setStorageLoad]=useState<LoadStatus>(idleLoad());const [jobsLoad,setJobsLoad]=useState<LoadStatus>(idleLoad());const [resourcesLoad,setResourcesLoad]=useState<LoadStatus>(idleLoad());
const [resourceBusy,setResourceBusy]=useState(false);const [rcloneBusy,setRcloneBusy]=useState(false);
const [retryingJob,setRetryingJob]=useState("");
const [retryingFailedJobs,setRetryingFailedJobs]=useState(false);
const [cancellingJob,setCancellingJob]=useState("");
const [webdav,setWebdav]=useState<WebDavServerConfig|null>(null);const [webdavLoad,setWebdavLoad]=useState<LoadStatus>(idleLoad());const [webdavBusy,setWebdavBusy]=useState(false);const [webdavError,setWebdavError]=useState("");const [webdavToken,setWebdavToken]=useState("");
const publicWebdavUrl=useMemo(()=>{if(!webdav)return "";const hostname=window.location.hostname;const host=hostname.includes(":")?`[${hostname}]`:hostname;return `http://${host}:${webdav.direct_port}/webdav/`},[webdav]);
const modelInput=useRef<HTMLInputElement>(null);
const loadModels=useCallback(async(quiet=false)=>{if(!quiet)setModelsLoad(current=>({phase:current.phase==="ready"?"ready":"loading",refreshing:current.phase==="ready"}));try{const m=await api<Record<string,any>>("/models?summary=true");setModels(m);setHfEndpoint(m.hf_endpoint||"https://hf-mirror.com");setPipSources({indexUrl:m.pip?.index_url||"https://pypi.tuna.tsinghua.edu.cn/simple",pytorchIndexUrl:m.pip?.pytorch_index_url||""});setProxy(current=>({enabled:Boolean(m.proxy?.enabled),url:m.proxy?.url||"",username:m.proxy?.username||"",password:current.password}));setModelsLoad({phase:"ready",refreshing:false})}catch(reason){const message=(reason as Error).message;setModelsLoad(current=>current.phase==="ready"?{phase:"ready",refreshing:false,error:message}:{phase:"error",refreshing:false,error:message})}},[]);
const loadTokens=useCallback(async()=>{setTokensLoad(current=>({phase:current.phase==="ready"?"ready":"loading",refreshing:current.phase==="ready"}));try{setTokens(await api<any[]>("/tokens"));setTokensLoad({phase:"ready",refreshing:false})}catch(reason){const message=(reason as Error).message;setTokensLoad(current=>current.phase==="ready"?{phase:"ready",refreshing:false,error:message}:{phase:"error",refreshing:false,error:message})}},[]);
const loadStorage=useCallback(async(force=false)=>{setStorageLoad(current=>({phase:current.phase==="ready"?"ready":"loading",refreshing:current.phase==="ready"}));try{setStorage(await api<StorageUsage>(`/storage/usage${force?"?refresh=true":""}`));setStorageLoad({phase:"ready",refreshing:false})}catch(reason){const message=(reason as Error).message;setStorageLoad(current=>current.phase==="ready"?{phase:"ready",refreshing:false,error:message}:{phase:"error",refreshing:false,error:message})}},[]);
const loadCapacities=useCallback(async(force=false)=>{setCapacityLoading(true);try{const rows=await Promise.all(sources.map(async source=>{try{return await api<SourceCapacity>(`/sources/${source.id}/capacity${force?"?refresh=true":""}`)}catch(reason){return {source_id:source.id,scope:source.kind==="local"?"local":"remote",status:"unreachable",total_bytes:0,used_bytes:0,available_bytes:0,provider:source.kind==="local"?"local":"webdav",collected_at:new Date().toISOString(),cached_seconds:30,reason:(reason as Error).message} as SourceCapacity}}));setCapacities(Object.fromEntries(rows.map(value=>[value.source_id,value])))}finally{setCapacityLoading(false)}},[sources]);
function beginCapacityEdit(source:Source){const current=capacities[source.id];const configuredTotal=source.config.capacity_override_total_bytes;const configuredAvailable=source.config.capacity_override_available_bytes;const manual=configuredTotal!==undefined&&configuredAvailable!==undefined||current?.provider==="manual";const total=Number(configuredTotal??(current?.provider==="manual"?current.total_bytes:0));const available=Number(configuredAvailable??(current?.provider==="manual"?current.available_bytes:0));setCapacityEdit({sourceId:source.id,totalTib:manual&&total>0?(total/1024**4).toFixed(2):"",availableTib:manual?(available/1024**4).toFixed(2):"",busy:false})}
async function saveCapacityOverride(event:FormEvent){event.preventDefault();if(!capacityEdit)return;const total=Number(capacityEdit.totalTib);const available=Number(capacityEdit.availableTib);if(!Number.isFinite(total)||total<=0||!Number.isFinite(available)||available<0||available>total){setCapacityEdit({...capacityEdit,error:"可用容量必须大于等于 0,且不能超过总容量。"});return}setCapacityEdit({...capacityEdit,busy:true,error:undefined});try{const value=await api<SourceCapacity>(`/sources/${capacityEdit.sourceId}/capacity`,{method:"PATCH",body:JSON.stringify({total_bytes:Math.round(total*1024**4),available_bytes:Math.round(available*1024**4)})});setCapacities(current=>({...current,[value.source_id]:value}));setCapacityEdit(null);toast("云盘容量已按手工值显示") }catch(reason){setCapacityEdit(current=>current?{...current,busy:false,error:(reason as Error).message}:null)}}
async function clearCapacityOverride(){if(!capacityEdit)return;setCapacityEdit({...capacityEdit,busy:true,error:undefined});try{const value=await api<SourceCapacity>(`/sources/${capacityEdit.sourceId}/capacity`,{method:"PATCH",body:JSON.stringify({total_bytes:null,available_bytes:null})});setCapacities(current=>({...current,[value.source_id]:value}));setCapacityEdit(null);toast("已恢复自动读取云盘配额")}catch(reason){setCapacityEdit(current=>current?{...current,busy:false,error:(reason as Error).message}:null)}}
const loadWebdav=useCallback(async()=>{setWebdavLoad({phase:"loading",refreshing:false});try{setWebdav(await api<WebDavServerConfig>("/webdav/config"));setWebdavLoad({phase:"ready",refreshing:false})}catch(reason){setWebdavLoad({phase:"error",refreshing:false,error:(reason as Error).message})}},[]);
const loadSpeech=useCallback(async()=>{setSpeechLoad(current=>({phase:current.phase==="ready"?"ready":"loading",refreshing:current.phase==="ready"}));try{setSpeechConfig(await api<SpeechConfig>("/speech/config"));setSpeechLoad({phase:"ready",refreshing:false})}catch(reason){const message=(reason as Error).message;setSpeechLoad(current=>current.phase==="ready"?{phase:"ready",refreshing:false,error:message}:{phase:"error",refreshing:false,error:message})}},[]);
const load=useCallback(()=>Promise.all([loadModels(),loadTokens(),loadStorage(),loadWebdav(),loadSpeech()]),[loadModels,loadSpeech,loadStorage,loadTokens,loadWebdav]);
useEffect(()=>{void load()},[load]);
useEffect(()=>{if(section==="storage")void loadCapacities()},[loadCapacities,section]);
const loadJobs=useCallback(async(page=jobData.page)=>{setJobsLoad(current=>({phase:current.phase==="ready"?"ready":"loading",refreshing:current.phase==="ready"}));try{const lane=jobLane==="all"?"":`&lane=${jobLane}`;setJobData(await api<JobPage>(`/jobs?page=${page}&page_size=10${lane}`));setJobsLoad({phase:"ready",refreshing:false})}catch(reason){const message=(reason as Error).message;setJobsLoad(current=>current.phase==="ready"?{phase:"ready",refreshing:false,error:message}:{phase:"error",refreshing:false,error:message})}},[jobData.page,jobLane]);
const activeInstallation=Object.values(models.installations||{}).some(value=>{
const state=value as ModelInstallation;return state.status==="queued"||state.status==="running";
});
useEffect(()=>{
const activeAi=jobData.items.some(job=>job.status==="running"&&["index_video","transcribe_audio","suggest_tags"].includes(job.kind));
if(section!=="ai"||(!activeAi&&!activeInstallation))return;
const refresh=()=>{if(!document.hidden)void loadModels(true)};
const timer=window.setInterval(refresh,activeInstallation?1500:3500);document.addEventListener("visibilitychange",refresh);
return()=>{window.clearInterval(timer);document.removeEventListener("visibilitychange",refresh)};
},[activeInstallation,jobData.items,loadModels,section]);
useEffect(()=>{
if(section!=="tasks"&&!modelJob&&!activeInstallation)return;
void loadJobs();const timer=window.setInterval(()=>void loadJobs(),2500);return()=>window.clearInterval(timer);
},[activeInstallation,loadJobs,modelJob,section]);
useEffect(()=>{if(section!=="tasks")return;const refresh=async()=>{if(document.hidden)return;setResourcesLoad(current=>({phase:current.phase==="ready"?"ready":"loading",refreshing:current.phase==="ready"}));try{setResources(await api<ResourceStatus>("/system/resources"));setResourcesLoad({phase:"ready",refreshing:false})}catch(reason){const message=(reason as Error).message;setResourcesLoad(current=>current.phase==="ready"?{phase:"ready",refreshing:false,error:message}:{phase:"error",refreshing:false,error:message})}};void refresh();const timer=window.setInterval(()=>void refresh(),3000);return()=>window.clearInterval(timer)},[section]);
useEffect(()=>{
if(!modelJob)return;
const job=jobData.items.find(item=>item.id===modelJob);
if(!job)return;
setModelMessage(job.message||`模型安装 ${Math.round(job.progress*100)}%`);
if(job.status==="completed"){setModelJob("");setModelMessage(job.message||"模型安装完成,首次检索时才会加载到内存");load();}
if(job.status==="failed"){setModelJob("");setModelError((job.error||"模型安装失败").split("\n")[0]);}
},[jobData.items,modelJob,load]);
async function installOnline(component:ModelComponent|"all"="all",variant?:"small"|"medium"){
setModelBusy(true);setModelError("");setModelMessage(component==="all"?"正在排队全部缺失模型…":"正在创建模型下载任务…");
try{const value=await api<{job_id:string|null;job_ids:Record<string,string>}>("/models/install",{method:"POST",body:JSON.stringify({component,...(variant?{variant}:{})})});setModelMessage(Object.keys(value.job_ids).length?`已排队 ${variant?`Whisper ${variant==="medium"?"Medium":"Small"}`:"所选"}模型安装任务`:`所选模型均已安装`);await loadModels(true);}
catch(reason){setModelError((reason as Error).message);setModelMessage("");}finally{setModelBusy(false);}
}
async function uploadModels(file?:File){
if(!file)return;
if(!file.name.toLowerCase().endsWith(".tar.gz")&&!file.name.toLowerCase().endsWith(".tgz")){setModelError("请选择 .tar.gz 或 .tgz 模型包");return;}
setModelBusy(true);setModelError("");setModelMessage(`正在上传 ${file.name},请勿关闭页面…`);
try{const value=await api<{job_id:string;filename:string;size_bytes:number;sha256:string}>("/models/upload",{method:"POST",body:file,headers:{"Content-Type":"application/octet-stream","X-Model-Filename":file.name}});setUploadInfo(value);setModelJob(value.job_id);setModelMessage("模型包已上传,正在校验文件清单并原子启用");}
catch(reason){setModelError((reason as Error).message);setModelMessage("");}finally{setModelBusy(false);if(modelInput.current)modelInput.current.value="";}
}
async function saveDownloadSources(){setModelBusy(true);setModelError("");try{const value=await api<{hf_endpoint:string;pip:{index_url:string;pytorch_index_url:string}}>("/models/config",{method:"PATCH",body:JSON.stringify({hf_endpoint:hfEndpoint,pip_index_url:pipSources.indexUrl,pytorch_index_url:pipSources.pytorchIndexUrl})});setHfEndpoint(value.hf_endpoint);setPipSources({indexUrl:value.pip.index_url,pytorchIndexUrl:value.pip.pytorch_index_url});setModelMessage("AI 下载源已保存,后续运行环境任务将使用新地址");await load()}catch(reason){setModelError((reason as Error).message)}finally{setModelBusy(false)}}
async function saveProxy(){setModelBusy(true);setModelError("");try{await api("/models/config",{method:"PATCH",body:JSON.stringify({proxy_enabled:proxy.enabled,proxy_url:proxy.url,proxy_username:proxy.username,...(proxy.password?{proxy_password:proxy.password}:{})})});setProxy({...proxy,password:""});setModelMessage(proxy.enabled?"AI 下载代理已启用并加密保存":"AI 下载代理已关闭,保存的地址和凭据仍会保留");await load()}catch(reason){setModelError((reason as Error).message)}finally{setModelBusy(false)}}
async function testProxy(){setModelBusy(true);setProxyTest(null);setModelError("");try{setProxyTest(await api("/models/proxy/test",{method:"POST",body:"{}"}))}catch(reason){setModelError((reason as Error).message)}finally{setModelBusy(false)}}
async function removeModel(component:"visual"|"ocr"|"faces"|"audio"){if(!await appConfirm("删除模型组件",`将删除 ${component} 模型权重并保留既有 AI 索引。`,true))return;setModelBusy(true);try{const value=await api<{freed_bytes:number}>(`/models/${component}`,{method:"DELETE"});setModelMessage(`已释放 ${formatSize(value.freed_bytes)}`);await load()}catch(reason){setModelError((reason as Error).message)}finally{setModelBusy(false)}}
async function cleanupVisual(){if(!await appConfirm("清理重复画面模型",`将删除已有 safetensors 对应的 PyTorch/TensorFlow 重复权重,预计释放 ${formatSize(models.visual_cleanup?.reclaimable_bytes||0)}。OpenVINO 模型与配置不会删除。`,true))return;setModelBusy(true);try{const value=await api<{freed_bytes:number;removed:number}>("/models/visual/cleanup",{method:"POST",body:"{}"});setModelMessage(`已清理 ${value.removed} 个重复权重,释放 ${formatSize(value.freed_bytes)}`);await load()}catch(reason){setModelError((reason as Error).message)}finally{setModelBusy(false)}}
async function reconcile(){setModelBusy(true);setModelError("");try{const value=await api<{queued:number}>("/index/reconcile",{method:"POST",body:"{}"});setModelMessage(value.queued?`已排队补齐 ${value.queued} 部影片`:`没有需要补齐的影片`);await load()}catch(reason){setModelError((reason as Error).message)}finally{setModelBusy(false)}}
async function saveSpeechConfig(){if(!speechConfig)return;setSpeechBusy(true);setSpeechMessage("");try{setSpeechConfig(await api<SpeechConfig>("/speech/config",{method:"PATCH",body:JSON.stringify({language_policy:speechConfig.language_policy,quality_profile:speechConfig.quality_profile,model_variant:speechConfig.model_variant})}));setSpeechMessage("语音识别策略已保存,将用于新任务和重新识别");toast("语音识别策略已保存");await loadModels(true)}catch(reason){setSpeechLoad(current=>({...current,error:(reason as Error).message}))}finally{setSpeechBusy(false)}}
async function removeAudioVariant(variant:"small"|"medium"){if(!await appConfirm("删除音频模型",`将删除 Whisper ${variant==="medium"?"Medium":"Small"} 权重,既有转写索引会保留。`,true))return;setModelBusy(true);setModelError("");try{const value=await api<{freed_bytes:number}>(`/models/audio?variant=${variant}`,{method:"DELETE"});setModelMessage(`已释放 ${formatSize(value.freed_bytes)}`);await loadModels(true)}catch(reason){setModelError((reason as Error).message)}finally{setModelBusy(false)}}
async function reconcileSpeech(){setSpeechBusy(true);setSpeechMessage("");try{const value=await api<{scanned:number;flagged:number;current:number;queued:number}>("/speech/reconcile",{method:"POST",body:"{}"});setSpeechMessage(value.flagged?`已扫描 ${value.scanned} 部,发现 ${value.flagged} 部低质量转写并排队重做`:`已扫描 ${value.scanned} 部,未发现需要重做的低质量转写`);toast("存量音频转写质量扫描完成")}catch(reason){setSpeechLoad(current=>({...current,error:(reason as Error).message}))}finally{setSpeechBusy(false)}}
async function retryJob(job:Job){setRetryingJob(job.id);setModelError("");try{await api(`/jobs/${job.id}/retry`,{method:"POST",body:"{}"});setModelMessage(`${jobLabel(job.kind)}已重新排队`);await loadJobs()}catch(reason){setModelError((reason as Error).message)}finally{setRetryingJob("")}}
async function retryFailedJobs(){const count=jobData.retryable_failed_count;if(!count)return;const scope=jobLane==="all"?"全部通道":jobLaneLabels[jobLane];if(!await appConfirm(`重试 ${count} 个失败任务`,`范围:${scope}。只会重新排队尚未被后续任务取代的失败记录;正在运行、已经完成或已被新任务接管的记录不会重复创建。`))return;setRetryingFailedJobs(true);setModelError("");try{const lane=jobLane==="all"?"":`?lane=${jobLane}`;const value=await api<{lane:string;retried:number;skipped:number;job_ids:string[]}>(`/jobs/retry-failed${lane}`,{method:"POST",body:"{}"});const message=value.retried?`已重新排队 ${value.retried} 个失败任务${value.skipped?`,跳过 ${value.skipped} 个`:""}`:"没有仍需重试的失败任务";setModelMessage(message);toast(message);await loadJobs(1)}catch(reason){setModelError((reason as Error).message)}finally{setRetryingFailedJobs(false)}}
async function cancelJob(job:Job){setCancellingJob(job.id);setModelError("");try{await api(`/jobs/${job.id}/cancel`,{method:"POST",body:"{}"});setModelMessage(`${jobLabel(job.kind)}正在安全停止`);await loadJobs()}catch(reason){setModelError((reason as Error).message)}finally{setCancellingJob("")}}
const jobInferenceSummary=(job:Job)=>{const value=job.inference_diagnostics;if(job.kind!=="transcribe_audio"||!value?.actual_device)return "";const device=String(value.actual_device).startsWith("GPU")?"Intel GPU":value.actual_device;const fallback=value.fallback_scope==="job"?" · 仅当前视频 CPU 兜底":value.fallback_scope==="component"?" · GPU 暂时熔断":"";const language=value.selected_language?` · ${value.selected_language}`:"";return `${device}${language}${fallback}`};
async function toggleBackgroundTasks(){if(!resources)return;setResources(await api<ResourceStatus>("/system/resources",{method:"PATCH",body:JSON.stringify({manual_pause:!resources.manual_pause})}))}
async function setResourceProfile(profile:ResourceProfile){if(!resources||profile===resources.profile)return;setResourceBusy(true);try{setResources(await api<ResourceStatus>("/system/resources",{method:"PATCH",body:JSON.stringify({profile})}));toast(`资源保护已切换为${({quiet:"安静",balanced:"平衡",turbo:"极速"} as const)[profile]}档`)}catch(reason){toast(`切换失败:${(reason as Error).message}`)}finally{setResourceBusy(false)}}
async function setAiGpuMode(mode:AiGpuMode){if(!resources||mode===resources.ai_gpu_mode)return;setResourceBusy(true);try{setResources(await api<ResourceStatus>("/system/resources",{method:"PATCH",body:JSON.stringify({ai_gpu_mode:mode})}));toast(`AI GPU 推理已切换为${({low:"低占用",balanced:"平衡",throughput:"吞吐"} as const)[mode]}档`)}catch(reason){toast(`切换失败:${(reason as Error).message}`)}finally{setResourceBusy(false)}}
async function cleanupCache(targets:Array<"remote_media"|"rclone_vfs">,title:string,message:string){if(!await appConfirm(title,message))return;setResourceBusy(true);try{const result=await api<{freed_bytes:number;removed:number;skipped_active:number}>("/system/resources/cache/cleanup",{method:"POST",body:JSON.stringify({targets})});toast(`已释放 ${formatSize(result.freed_bytes)},跳过 ${result.skipped_active} 个活动项`);setResources(await api<ResourceStatus>("/system/resources"));await loadStorage(true)}catch(reason){toast(`清理失败:${(reason as Error).message}`,"error")}finally{setResourceBusy(false)}}
async function cleanupRemoteCache(){return cleanupCache(["remote_media","rclone_vfs"],"清理可回收远程缓存","只会删除未被任务使用的 AI 远程媒体缓存和旧 rclone VFS 缓存,不影响视频、模型、索引或正在进行的任务。")}
async function cleanupStorageTarget(target:"remote_media"|"rclone_vfs"){return cleanupCache([target],target==="remote_media"?"清理 AI 远程媒体":"清理旧 rclone VFS 缓存",target==="remote_media"?"只删除未被 AI 任务使用的完整媒体缓存。":"只删除已确认没有活动连接的旧 rclone 缓存;当前正在播放或扫描的实例会被跳过。")}
async function reconcileRclone(){setRcloneBusy(true);try{const result=await api<{reaped:number;verifying:number;unresolved:number;status:ResourceStatus["rclone"]}>("/system/resources/rclone/reconcile",{method:"POST",body:"{}"});setResources(current=>current&&result.status?{...current,rclone:result.status}:current);toast(result.reaped?`已安全回收 ${result.reaped} 个旧 rclone 实例`:result.verifying?`${result.verifying} 个实例仍在安全观察期`:result.unresolved?`${result.unresolved} 个实例身份不匹配,未执行终止`:"没有需要回收的旧实例")}catch(reason){toast(`核验失败:${(reason as Error).message}`,"error")}finally{setRcloneBusy(false)}}
async function saveWebdav(){if(!webdav)return;setWebdavBusy(true);setWebdavError("");try{setWebdav(await api<WebDavServerConfig>("/webdav/config",{method:"PATCH",body:JSON.stringify({enabled:webdav.enabled,source_id:webdav.source_id,relative_path:webdav.relative_path})}));toast(webdav.enabled?"WebDAV 上传入口已启用":"WebDAV 上传入口已关闭")}catch(reason){setWebdavError((reason as Error).message)}finally{setWebdavBusy(false)}}
async function createWebdavToken(){setWebdavBusy(true);setWebdavError("");try{const value=await api<{token:string}>("/tokens",{method:"POST",body:JSON.stringify({name:`WebDAV 专用 Token ${tokens.filter(token=>String(token.name).startsWith("WebDAV 专用 Token")).length+1}`,scopes:["webdav"]})});setWebdavToken(value.token);await loadTokens();toast("WebDAV 专用 Token 已创建,请立即保存")}catch(reason){setWebdavError((reason as Error).message)}finally{setWebdavBusy(false)}}
const copy=(value:string)=>{void navigator.clipboard?.writeText(value);setModelMessage("下载地址已复制")};
const sourceLinks:Array<[string,string]>=[
["画面语义模型",models.sources?.visual_image],
["多语言文本模型",models.sources?.visual_text],
["RapidOCR 权重",models.sources?.ocr],
["Whisper small 音频模型",models.sources?.audio],
["Whisper medium 音频模型",models.sources?.audio_variants?.medium],
["人脸检测 XML",models.sources?.faces?.detector_xml],
["人脸检测 BIN",models.sources?.faces?.detector_bin],
["人物重识别 XML",models.sources?.faces?.reidentification_xml],
["人物重识别 BIN",models.sources?.faces?.reidentification_bin],
].filter((item):item is [string,string]=>Boolean(item[1]));
const installing=modelBusy||Boolean(modelJob)||activeInstallation;
const componentRows:Array<[ModelComponent,string,boolean]>=[["visual","画面语义",Boolean(models.visual_ready)],["ocr","OCR 文字",Boolean(models.ocr_ready)],["faces","人物识别",Boolean(models.faces_ready)],["audio","音频识别",Boolean(models.audio_ready)]];
const accelerator=models.accelerator||{};const acceleratorComponents=accelerator.components||{};
const acceleratorLabels:Record<string,string>={visual:"画面语义",ocr:"OCR 文字",faces:"人物识别",audio:"音频识别"};
const acceleratorStageLabels:Record<string,string>={image_encoder_compile:"图像编码器编译",text_encoder_compile:"文本编码器编译",encoder_compile:"编码器编译",inference:"推理",empty_result:"空结果复核",low_quality_result:"低质量结果复核",inference_stall:"GPU 推理停滞",job_fallback:"当前视频 CPU 兜底"};
const gpuId=(accelerator.devices||[]).find((device:string)=>device.startsWith("GPU"));
const gpuName=gpuId?accelerator.device_names?.[gpuId]||gpuId:"未检测到 Intel GPU";
const componentDevice=(component:any)=>{const actual=component?.actual_device||component?.device;const devices=component?.execution_devices||[];const usedGpu=devices.some((device:string)=>device.startsWith("GPU"))||actual?.startsWith("GPU");if(component?.state==="not_loaded")return component?.last_verified_at&&actual?`上次:${usedGpu?"Intel GPU":actual}`:"首次任务时加载";if(component?.state==="verifying_cpu"||component?.state==="job_fallback")return "当前视频 CPU 复核";if(component?.state==="probing_gpu")return "GPU 恢复探测";if(component?.state==="fallback")return component?.circuit_state==="open"?"CPU 暂时回退":"CPU 回退";if(component?.state==="unavailable")return "不可用";if(usedGpu)return "Intel GPU";return actual||"不可用"};
const settingsSections:Array<[typeof section,string]>=[["ai","AI 与网络"],["storage","存储"],["backup","备份恢复"],["account","账户与 API"],["tasks","任务与偏好"]];
const renderModelComponent=([key,label,ready]:[ModelComponent,string,boolean])=>{
const state=models.installations?.[key] as ModelInstallation|undefined;
const runtime=models.runtime_dependencies?.[key];
const health=models.component_health?.[key];
const runtimeReady=runtime?.state==="ready"||runtime?.state==="external"||runtime?.source==="bundled";
const operational=models.operational_components?.[key]??(ready&&runtimeReady&&health?.state!=="damaged");
const active=state?.status==="queued"||state?.status==="running";
const failed=state?.status==="failed"&&!operational;
const progress=Math.round(Math.max(0,Math.min(1,state?.progress||0))*100);
const runtimeDetail=runtime?.source==="bundled"?" · 内置运行环境":runtime?.state==="ready"?" · 运行环境已就绪":runtime?.state==="installing"?" · 正在安装运行环境":runtime?.state==="error"?` · 运行环境失败:${runtime.error||"请重试"}`:runtime?.state==="missing"?" · 首次下载会安装运行环境":"";
const detail=state?.status==="queued"?"等待处理,前面的 AI 任务完成后自动开始":state?.status==="running"?(state.message||"正在准备模型与运行环境"):failed?(state?.error||"模型或运行环境修复失败").split("\n")[0]:health?.state&&health.state!=="ready"?(health.error||"模型文件需要修复"):ready?`${models.component_versions?.[key]||models.manifest?.version||"已安装"} · ${formatSize(models.component_sizes?.[key]||0)}${runtimeDetail}`:`未安装,新影片会跳过此项分析${runtimeDetail}`;
return <article className={`model-component ${active?"running":operational?"ready":failed?"failed":"idle"}`} key={key}><span className={active?"installing":operational?"ready":"missing"}>{active?<RefreshCw className={state?.status==="running"?"spin":""}/>:operational?<CheckCircle2/>:<AlertCircle/>}</span><div><strong>{label}</strong><small>{detail}</small></div>{active?<div className="model-install-progress" aria-label={`${label}${state?.status==="queued"?"等待处理":`安装进度 ${progress}%`}`}><b>{state?.status==="queued"?"等待":`${progress}%`}</b><i><span style={{width:`${state?.status==="queued"?4:progress}%`}}/></i></div>:ready&&!operational?<button className="secondary model-retry" disabled={modelBusy||Boolean(modelJob)} onClick={()=>installOnline(key)}><RefreshCw/>修复</button>:failed?<button className="secondary model-retry" disabled={modelBusy||Boolean(modelJob)} onClick={()=>installOnline(key)}><RefreshCw/>重试</button>:ready?<button className="danger-text" disabled={installing} onClick={()=>removeModel(key)}><Trash2/>删除</button>:<button className="secondary model-download" disabled={modelBusy||Boolean(modelJob)} onClick={()=>installOnline(key)}><Download/>下载</button>}</article>;
};
return <div className="page settings-page"><header className="page-heading"><button className="icon-button page-back" aria-label="返回" onClick={()=>window.history.back()}><ArrowLeft/></button><div><h1>设置</h1><p>系统能力按任务分区,不需要一次理解所有选项。</p></div></header><div className="settings-workspace"><nav className="segment-nav settings-segments" aria-label="设置分区">{settingsSections.map(([key,label])=><button className={section===key?"active":""} key={key} onClick={()=>setSection(key)}><strong>{label}</strong><ChevronRight/></button>)}</nav><div className="settings-grid">
<section className={`panel model-panel settings-section ${section!=="ai"?"hidden":""}`}><h2><Boxes/>AI 模型</h2>{modelsLoad.phase!=="ready"?modelsLoad.phase==="error"?<div className="empty compact async-error"><AlertCircle/><h3>模型状态加载失败</h3><p>{modelsLoad.error}</p><button className="secondary" onClick={()=>void loadModels()}><RefreshCw/>重新加载</button></div>:<AsyncNotice label="正在读取模型、运行环境与推理设备…"/>:<>{(modelsLoad.refreshing||modelsLoad.error)&&<div className={`async-update inline ${modelsLoad.error?"failed":""}`}>{modelsLoad.error?<AlertCircle/>:<RefreshCw className="spin"/>}<span>{modelsLoad.error||"正在更新 AI 状态…"}</span>{modelsLoad.error&&<button onClick={()=>void loadModels()}>重试</button>}</div>}<div className="model-components">{componentRows.map(renderModelComponent)}</div><div className="shared-runtime"><span><Activity/></span><div><strong>共享 AI 运行环境</strong><small>{models.runtime_environment?.source==="bundled"?`已内置于安装包 · ${(models.runtime_environment.components||[]).length} 个组件`:models.runtime_environment?.state==="ready"?`${formatSize(models.runtime_environment.size_bytes||0)} · ${(models.runtime_environment.components||[]).length} 个组件已就绪`:models.runtime_environment?.state==="installing"?"正在按组件下载并校验依赖":models.runtime_environment?.state==="external"?"使用系统提供的运行环境":"首次安装模型时按需准备"}</small></div><b>{models.runtime_environment?.source==="bundled"?"已内置":models.runtime_environment?.state==="ready"?"已就绪":models.runtime_environment?.state==="installing"?"安装中":models.runtime_environment?.state==="external"?"系统提供":"未安装"}</b></div><section className={`accelerator-card ${gpuId?"gpu-ready":"cpu-only"}`}><header><span><Activity/></span><div><strong>推理加速</strong><small>{gpuName}</small></div><b>{gpuId?"GPU 优先":"CPU 回退"}</b></header><div className="accelerator-components">{Object.keys(acceleratorLabels).map(key=>{const component=acceleratorComponents[key]||{state:"not_loaded"};const stage=acceleratorStageLabels[component.failure_stage]||component.failure_stage;const failure=[stage,component.fallback_reason].filter(Boolean).join(" · ");return <article className={component.state} key={key}><span>{acceleratorLabels[key]}</span><strong>{componentDevice(component)}</strong>{failure&&<small>{failure}</small>}</article>})}</div>{accelerator.unavailable_reason&&<p><AlertCircle/>{accelerator.unavailable_reason}</p>}<footer>CPU 回退最多使用 {accelerator.cpu_threads||2} 个推理线程</footer></section><div className="metric-list"><div><span>待补齐影片</span><b>{models.pending_videos||0} </b></div><div><span>推理设备</span><b>{accelerator.selected||"自动"}</b></div></div><p className="muted">删除权重不会删除既有 AI 索引;重新安装后可以补齐新影片或更新索引。</p>
<section className="speech-policy">
<header><div><h3>语音识别策略</h3><p>先定位有声区间,再按所选语言与质量档位解码;不会把错误片段带入下一段。</p></div><Volume2/></header>
{speechLoad.phase==="loading"||speechLoad.phase==="idle"?<AsyncNotice compact label="正在读取语音识别策略…"/>:speechLoad.phase==="error"&&!speechConfig?<div className="error"><AlertCircle/>{speechLoad.error}<button onClick={()=>void loadSpeech()}>重试</button></div>:speechConfig&&<>
<fieldset><legend>识别模型</legend><div className="audio-model-variants">{(["small","medium"] as const).map(variant=>{const info=models.audio_variants?.[variant]||{};const state=models.installations?.[`audio:${variant}`] as ModelInstallation|undefined;const active=state?.status==="queued"||state?.status==="running";const progress=Math.round(Math.max(0,Math.min(1,state?.progress||0))*100);const selected=speechConfig.model_variant===variant;return <article className={`${selected?"selected":""} ${info.installed?"installed":"missing"}`} key={variant}><button type="button" className="audio-model-choice" disabled={!info.installed||active||(variant==="medium"&&!gpuId)} aria-pressed={selected} onClick={()=>setSpeechConfig({...speechConfig,model_variant:variant})}><span>{selected?<CheckCircle2/>:<Boxes/>}</span><div><strong>Whisper {variant==="medium"?"Medium INT8":"Small"}</strong><small>{variant==="medium"?"高质量 · 仅 Intel GPU · 约 0.78 GB":"标准模型 · 约 0.49 GB · 纯 CPU 设备可用"}</small></div></button>{active?<div className="audio-model-progress" role="status"><span>{state?.message||"正在下载安装"}</span><b>{state?.status==="queued"?"等待":`${progress}%`}</b><i><span style={{width:`${state?.status==="queued"?4:progress}%`}}/></i></div>:info.needs_update?<button className="secondary audio-model-install" disabled={modelBusy||Boolean(modelJob)||(variant==="medium"&&!gpuId)} onClick={()=>void installOnline("audio",variant)}><RefreshCw/>更新为 INT8 · {formatSize(info.size_bytes||0)}</button>:info.installed?<div className="audio-model-actions"><span>{selected?"当前选择":formatSize(info.size_bytes||0)}</span>{!selected&&<button className="danger-text" disabled={modelBusy} onClick={()=>void removeAudioVariant(variant)}><Trash2/>删除</button>}</div>:<button className="secondary audio-model-install" disabled={modelBusy||Boolean(modelJob)||(variant==="medium"&&!gpuId)} onClick={()=>void installOnline("audio",variant)}><Download/>{variant==="medium"&&!gpuId?"需要 Intel GPU":"下载安装"}</button>}</article>})}</div></fieldset>
<fieldset><legend>语言检测</legend><div className="speech-policy-options">{([['zh_priority','中文优先','音轨标签只作提示;弱检测或冲突时回到中文'],['auto','智能检测','由多个有声片段多数决,音轨标签仅用于平票'],['zh','固定中文','所有任务均明确按中文解码']] as const).map(([key,label,description])=><button type="button" className={speechConfig.language_policy===key?"active":""} aria-pressed={speechConfig.language_policy===key} key={key} onClick={()=>setSpeechConfig({...speechConfig,language_policy:key})}><strong>{label}</strong><small>{description}</small>{speechConfig.language_policy===key&&<CheckCircle2/>}</button>)}</div></fieldset>
<fieldset><legend>质量档位</legend><div className="speech-policy-options quality">{([['speed','速度优先','单 beam 解码,适合快速建立索引'],['balanced','均衡','CPU 会增加候选;Intel GPU 仍为单 beam'],['accuracy','准确率优先','CPU 使用 5 beamIntel GPU 受限为单 beam']] as const).map(([key,label,description])=><button type="button" className={speechConfig.quality_profile===key?"active":""} aria-pressed={speechConfig.quality_profile===key} key={key} onClick={()=>setSpeechConfig({...speechConfig,quality_profile:key})}><strong>{label}</strong><small>{description}</small>{speechConfig.quality_profile===key&&<CheckCircle2/>}</button>)}</div></fieldset>
<p className="speech-resource-note"><ShieldCheck/>Medium GPU、内存或推理验证失败时会停止任务,不会回退 CPU;旧转写会保留到新结果成功写入。</p>
{speechLoad.error&&<div className="error"><AlertCircle/>{speechLoad.error}<button onClick={()=>void loadSpeech()}>重新读取</button></div>}
<div className="speech-policy-actions"><button className="primary" disabled={speechBusy} onClick={()=>void saveSpeechConfig()}>{speechBusy?<RefreshCw className="spin"/>:<CheckCircle2/>}保存识别策略</button><button className="secondary" disabled={speechBusy} onClick={()=>void reconcileSpeech()}><ShieldCheck/>重建旧版音频索引</button></div>{speechMessage&&<p className="status-line"><Activity/>{speechMessage}</p>}
</>}
</section>
<div className="download-source-config"><h3>AI 下载源</h3><p>模型默认直连国内 Hugging Face 镜像,PyPI 使用清华镜像;下载模型不会占用已配置的海外代理流量。</p><div><label>Hugging Face 国内镜像<input type="url" value={hfEndpoint} onChange={event=>setHfEndpoint(event.target.value)} placeholder="https://hf-mirror.com"/></label><label>PyPI 主镜像<input type="url" value={pipSources.indexUrl} onChange={event=>setPipSources({...pipSources,indexUrl:event.target.value})} placeholder="https://pypi.tuna.tsinghua.edu.cn/simple"/></label><label>PyTorch CPU 轮子源(可留空)<input type="url" value={pipSources.pytorchIndexUrl} onChange={event=>setPipSources({...pipSources,pytorchIndexUrl:event.target.value})} placeholder="https://download.pytorch.org/whl/cpu"/></label></div><button className="secondary" disabled={installing} onClick={saveDownloadSources}>保存下载源</button></div>
<div className="proxy-config"><header><div><h3>HTTP/HTTPS 下载代理</h3><small>{proxy.enabled?"已启用:用于 OpenVINO 等非国内镜像来源":"已关闭:AI 下载直接连接目标站点"}</small></div><PreferenceSwitch title="使用下载代理" description="国内 Hugging Face 镜像始终直连;关闭时保留凭据" checked={proxy.enabled} onChange={enabled=>setProxy({...proxy,enabled})}/></header><div className="form-row"><label>代理地址<input type="url" value={proxy.url} onChange={event=>setProxy({...proxy,url:event.target.value})} placeholder="http://proxy.example:8080"/></label><label>用户名(可选)<input value={proxy.username} onChange={event=>setProxy({...proxy,username:event.target.value})}/></label></div><label>密码(可选)<input type="password" value={proxy.password} onChange={event=>setProxy({...proxy,password:event.target.value})} placeholder={models.proxy?.has_password?"已加密保存;留空保持不变":"可留空"}/></label><div><button className="secondary" disabled={installing} onClick={saveProxy}>保存代理设置</button><button className="secondary" disabled={installing} onClick={testProxy}><Wifi/>测试当前连接</button></div>{proxyTest&&<div className="proxy-results">{Object.entries(proxyTest).map(([name,result])=><span className={result.ok?"ok":"failed"} key={name}>{result.ok?<CheckCircle2/>:<AlertCircle/>}{name==="huggingface"?"Hugging Face 国内镜像":"OpenVINO"} · {result.ok?`${result.latency_ms} ms`:result.error}</span>)}</div>}</div>
<details className="model-sources"><summary>全部模型下载地址</summary>{sourceLinks.map(([label,url])=><div key={label}><span><strong>{label}</strong><a href={url} target="_blank" rel="noreferrer">{url}</a></span><button className="icon-button mini" title="复制地址" onClick={()=>copy(url)}><Copy/></button><a className="icon-button mini" title="打开地址" href={url} target="_blank" rel="noreferrer"><ExternalLink/></a></div>)}</details>
<div className="model-actions"><button className="secondary install-missing" disabled={installing||componentRows.every(([key])=>Boolean(models.operational_components?.[key]))} onClick={()=>installOnline("all")}>{installing?<RefreshCw className="spin"/>:<Download/>}安装或修复缺失组件</button>{models.visual_cleanup?.available&&<button className="secondary" disabled={installing} onClick={cleanupVisual}><Trash2/>清理重复权重 · {formatSize(models.visual_cleanup.reclaimable_bytes)}</button>}<a className="secondary" href={appUrl("/api/v1/models/offline-helper")} download="prepare-imagefind-models.py"><Download/>下载离线准备脚本</a><button className="secondary" disabled={installing} onClick={()=>modelInput.current?.click()}><Upload/>上传完整模型包</button><button className="secondary" disabled={installing} onClick={reconcile}><RefreshCw/>补齐 AI 索引</button><input ref={modelInput} hidden type="file" accept=".tar.gz,.tgz,application/gzip" onChange={event=>uploadModels(event.target.files?.[0])}/></div>
{uploadInfo&&<div className="model-upload-details"><span><b>文件</b>{uploadInfo.filename}</span><span><b>大小</b>{formatSize(uploadInfo.size_bytes)}</span><span><b>SHA-256</b><code>{uploadInfo.sha256}</code></span><span><b>阶段</b>{modelJob?"校验 / 解压 / 启用":"已完成"}</span></div>}{modelMessage&&<p className="status-line"><Activity/>{modelMessage}</p>}{modelError&&<><div className="error"><AlertCircle/>{modelError}</div><div className="model-error-actions"><a href={models.sources?.visual_image||"https://huggingface.co/sentence-transformers/clip-ViT-B-32"} target="_blank" rel="noreferrer">查看下载地址</a><a href={appUrl("/api/v1/models/offline-helper")} download>下载离线脚本</a><button onClick={()=>modelInput.current?.click()}>上传模型包</button></div></>}</>}</section>
<section className={`panel storage-panel settings-section ${section!=="storage"?"hidden":""}`}><h2><HardDrive/>磁盘占用</h2>{storageLoad.phase==="error"&&!storage?<div className="empty compact async-error"><AlertCircle/><h3>磁盘统计加载失败</h3><p>{storageLoad.error}</p><button className="secondary" onClick={()=>void loadStorage()}><RefreshCw/>重试</button></div>:!storage?<AsyncNotice label="正在统计磁盘占用…"/>:<>{(storageLoad.refreshing||storageLoad.error)&&<div className={`async-update inline ${storageLoad.error?"failed":""}`}>{storageLoad.error?<AlertCircle/>:<RefreshCw className="spin"/>}<span>{storageLoad.error||"正在重新统计…"}</span></div>}<div className="disk-summary"><strong>{formatSize(storage.disk.used_bytes)} / {formatSize(storage.disk.total_bytes)}</strong><span>可用 {formatSize(storage.disk.available_bytes)} · ImageFind {formatSize(storage.app_bytes)}</span><i><b style={{width:`${Math.min(100,storage.disk.used_bytes/storage.disk.total_bytes*100)}%`}}/></i></div><div className="storage-categories">{Object.entries(storage.categories).map(([key,value])=><div key={key}><span>{({database:"数据库",ai_index:"AI 索引",models:"模型",thumbnails:"缩略图",preview_cache:"预览与缓存",remote_cache:"远程媒体缓存",upload_staging:"上传临时文件",other:"其他数据"} as Record<string,string>)[key]||key}<small>{key==="ai_index"&&value.mode==="external"?"外部 Qdrant 无法统计":""}</small></span><b>{value.available?formatSize(value.bytes||0):"无法统计"}</b></div>)}</div><button className="secondary" disabled={storageLoad.refreshing} onClick={()=>void loadStorage(true)}><RefreshCw className={storageLoad.refreshing?"spin":""}/>重新统计</button></>}</section>
<section className={`panel storage-panel-v2 settings-section ${section!=="storage"?"hidden":""}`}><header className="storage-heading"><div><h2><HardDrive/>存储空间</h2><p className="muted">NAS 暂存空间与媒体来源容量分开统计,不会再用本地磁盘容量代替云盘配额。</p></div><button className="icon-button" disabled={storageLoad.refreshing||capacityLoading} aria-label="重新统计存储空间" onClick={()=>void Promise.all([loadStorage(true),loadCapacities(true)])}><RefreshCw className={storageLoad.refreshing||capacityLoading?"spin":""}/></button></header>{storageLoad.phase==="error"&&!storage?<div className="empty compact async-error"><AlertCircle/><h3>磁盘统计加载失败</h3><p>{storageLoad.error}</p><button className="secondary" onClick={()=>void loadStorage()}><RefreshCw/>重试</button></div>:!storage?<AsyncNotice label="正在统计存储空间…"/>:<>{(storageLoad.refreshing||storageLoad.error)&&<div className={`async-update inline ${storageLoad.error?"failed":""}`}>{storageLoad.error?<AlertCircle/>:<RefreshCw className="spin"/>}<span>{storageLoad.error||"正在重新统计…"}</span></div>}<div className="disk-summary"><strong>NAS 本地暂存 / 缓存</strong><span>可用 {formatSize(storage.disk.available_bytes)} · 已用 {formatSize(storage.disk.used_bytes)} / {formatSize(storage.disk.total_bytes)} · ImageFind {formatSize(storage.app_bytes)}</span><i><b style={{width:`${Math.min(100,storage.disk.total_bytes?storage.disk.used_bytes/storage.disk.total_bytes*100:0)}%`}}/></i></div><section className="source-capacities" aria-label="媒体来源容量"><header><strong>媒体来源容量</strong><small>{capacityLoading?"正在刷新远端配额…":"容量查询不会扫描媒体文件"}</small></header>{sources.map(source=>{const value=capacities[source.id];const editing=capacityEdit?.sourceId===source.id;return <article className={editing?"editing":""} key={source.id}><span className="source-capacity-icon">{source.kind==="local"?<HardDrive/>:<Cloud/>}</span><div className="source-capacity-copy"><strong>{source.name}</strong><small>{!value?"等待容量数据":value.status==="available"?(value.provider==="manual"?"手工设置 · 不会自动同步云盘":`${value.scope==="remote"?"云盘":"本地来源"} · ${value.provider}`):value.reason||"此来源未提供配额"}</small>{value?.status==="available"&&<i><b style={{transform:`scaleX(${value.total_bytes?value.used_bytes/value.total_bytes:0})`}}/></i>}</div><div className="source-capacity-actions"><b>{!value||capacityLoading?"读取中":value.status==="available"?`可用 ${formatSize(value.available_bytes)}`:value.status==="unsupported"?"未提供配额":"暂时不可达"}</b>{source.kind!=="local"&&<button className="secondary" type="button" aria-expanded={editing} onClick={()=>editing?setCapacityEdit(null):beginCapacityEdit(source)}>{editing?"收起":value?.provider==="manual"?"修改":"设置容量"}</button>}</div>{editing&&capacityEdit&&<form className="capacity-override-form" onSubmit={saveCapacityOverride}><p>OpenList 没有提供可靠配额时,可手工填写云盘页面显示的数值。这里只影响容量展示,不会限制上传。</p><div className="form-row"><label>总容量(TiB<input required inputMode="decimal" type="number" min="0.01" max="10240" step="0.01" value={capacityEdit.totalTib} onChange={event=>setCapacityEdit({...capacityEdit,totalTib:event.target.value,error:undefined})}/></label><label>可用容量(TiB<input required inputMode="decimal" type="number" min="0" max="10240" step="0.01" value={capacityEdit.availableTib} onChange={event=>setCapacityEdit({...capacityEdit,availableTib:event.target.value,error:undefined})}/></label></div>{capacityEdit.error&&<div className="error" role="alert"><AlertCircle/>{capacityEdit.error}</div>}<footer>{value?.provider==="manual"&&<button type="button" className="danger-text" disabled={capacityEdit.busy} onClick={()=>void clearCapacityOverride()}>恢复自动读取</button>}<span/><button type="button" className="secondary" disabled={capacityEdit.busy} onClick={()=>setCapacityEdit(null)}>取消</button><button className="primary" disabled={capacityEdit.busy}>{capacityEdit.busy?<RefreshCw className="spin"/>:<CheckCircle2/>}保存容量</button></footer></form>}</article>})}</section><div className="storage-categories">{Object.entries(storage.categories).map(([key,value])=><article className="storage-category" key={key}><div className="storage-category-main"><span><strong>{storageLabel(key)}</strong>{storageEntryNote(value)&&<small>{storageEntryNote(value)}</small>}{key==="ai_index"&&value.mode==="external"&&<small>外部 Qdrant 无法统计</small>}</span><b>{value.available?formatSize(value.bytes||0):"无法统计"}</b></div>{value.children&&<StorageBreakdown>{value.children}</StorageBreakdown>}{key==="remote_cache"&&<div className="storage-category-actions"><button className="secondary" disabled={resourceBusy||!(value.children?.remote_media_cache?.reclaimable_bytes)} onClick={()=>void cleanupStorageTarget("remote_media")}><Trash2/>清理 AI 远程媒体</button><button className="secondary" disabled={resourceBusy||!(value.children?.rclone_vfs_cache?.reclaimable_bytes)} onClick={()=>void cleanupStorageTarget("rclone_vfs")}><Trash2/>清理旧 rclone 缓存</button></div>}</article>)}</div></>}</section>
<div className={`settings-section backup-section ${section!=="backup"?"hidden":""}`}><BackupPanel/></div>
<section className={`panel settings-section ${section!=="account"?"hidden":""}`}><h2><KeyRound/>REST API Token</h2><p className="muted">Token 仅在创建时显示一次;普通 REST Token 拥有管理员权限,WebDAV 专用 Token 只能访问 WebDAV</p>{tokensLoad.phase==="loading"||tokensLoad.phase==="idle"?<AsyncNotice compact label="正在加载 Token…"/>:tokensLoad.phase==="error"?<div className="error"><AlertCircle/><span>{tokensLoad.error}</span><button onClick={()=>void loadTokens()}>重试</button></div>:<><button className="secondary" onClick={async()=>{const value=await api<{token:string}>("/tokens",{method:"POST",body:JSON.stringify({name:`Token ${tokens.length+1}`,scopes:["admin"]})});setCreated(value.token);void loadTokens();}}><Plus/>创建管理员 Token</button>{created&&<code className="token-value">{created}</code>}<div className="token-list">{tokens.map(token=><div key={token.id}><span>{token.name}</span><small>{token.revoked_at?"已撤销":`${token.last_used_at?"最近使用":"未使用"} · ${(token.scopes||["admin"]).map((scope:string)=>({admin:"管理员",webdav:"WebDAV",'media:read':"媒体只读"} as Record<string,string>)[scope]||scope).join("、")}`}</small>{!token.revoked_at&&<button onClick={()=>api(`/tokens/${token.id}`,{method:"DELETE"}).then(()=>loadTokens())}><Trash2/></button>}</div>)}</div></>}</section>
<section className={`panel webdav-server-panel settings-section ${section!=="account"?"hidden":""}`}>
<h2><Cloud/>WebDAV 后台上传</h2>
<p className="muted">让支持 WebDAV 的手机或电脑软件把文件直接上传到 ImageFind;第一层目录作为合集,后续目录自动映射为章节或小节。</p>
{(webdavLoad.phase!=="ready"||!webdav)&&(webdavLoad.phase==="error"?<div className="error"><AlertCircle/>{webdavLoad.error}<button onClick={()=>void loadWebdav()}>重试</button></div>:<AsyncNotice compact label="正在读取 WebDAV 配置…"/>)}
{webdavLoad.phase==="ready"&&webdav&&<>
{!webdav.direct_access&&<div className="warning-callout"><AlertCircle/><span><strong>直接 Web/API 访问未开启</strong>WebDAV 不经过飞牛 5666 网关。请先在飞牛“应用中心 ImageFind 配置”中开启直接访问,然后使用下方直连端口。</span></div>}
<PreferenceSwitch title="启用 WebDAV 入口" description={webdav.direct_access?"关闭时保留目标媒体库和目录设置":"请先在飞牛应用配置中开启直接 Web/API 访问"} checked={webdav.enabled} disabled={!webdav.direct_access} onChange={enabled=>setWebdav({...webdav,enabled})}/>
<div className="form-row webdav-fields"><label>默认可写媒体库<select value={webdav.source_id||""} onChange={event=>setWebdav({...webdav,source_id:event.target.value||null})}><option value="">请选择</option>{sources.filter(source=>source.config.writable||source.config.driver==="alist"||source.config.storage_backend==="openlist_native").map(source=><option key={source.id} value={source.id}>{source.name} · {sourceLabel(source)}</option>)}</select></label><label>目标基础目录(可选)<input value={webdav.relative_path} onChange={event=>setWebdav({...webdav,relative_path:event.target.value})} placeholder="例如:WebDAV 上传"/></label></div>
<div className="webdav-connection"><span><strong>服务器地址(直连)</strong><code>{publicWebdavUrl}</code></span><button className="secondary" onClick={()=>{void navigator.clipboard?.writeText(publicWebdavUrl);toast("WebDAV 地址已复制")}}><Copy/>复制</button><span><strong>用户名</strong><code>{webdav.username}</code></span><span><strong>密码</strong><code>使用专用 REST API Token</code></span><button className="secondary webdav-token-create" disabled={webdavBusy} onClick={()=>void createWebdavToken()}><KeyRound/>创建专用 WebDAV Token</button>{webdavToken&&<div className="webdav-token-once" role="status"><strong>仅显示一次,请立即复制</strong><code>{webdavToken}</code><button className="secondary" onClick={()=>{void navigator.clipboard?.writeText(webdavToken);toast("WebDAV Token 已复制")}}><Copy/>复制 Token</button></div>}<p>飞牛 5666 只用于网页统一网关,不支持标准 WebDAV。客户端请填写上面的 <b>{webdav.direct_port}</b> 端口;用户名固定为 <b>imagefind</b>,密码使用未撤销的专用 Token。支持浏览、读取和上传,但不能删除已入库视频。</p></div>
<button className="primary" disabled={webdavBusy||!webdav.direct_access||Boolean(webdav.enabled&&!webdav.source_id)} onClick={saveWebdav}>{webdavBusy?<RefreshCw className="spin"/>:<CheckCircle2/>}保存 WebDAV 设置</button>
{webdavError&&<div className="error"><AlertCircle/>{webdavError}</div>}
</>}
</section>
<section className={`panel settings-section ${section!=="tasks"?"hidden":""}`}><h2><Play/>播放与隐私</h2><div className="preference-list"><PreferenceSwitch title="自动播放下一部" description="当前影片结束后播放推荐列表第一项" checked={preferences.autoplay} onChange={value=>onPreferences({autoplay:value})}/><PreferenceSwitch title="默认遮罩封面" description="登录后在所有设备使用同一隐私偏好" checked={preferences.mask_covers} onChange={value=>onPreferences({mask_covers:value})}/><ThemeSelector value={preferences.theme} onChange={value=>onPreferences({theme:value})}/></div></section>
<section className={`panel jobs-panel settings-section ${section!=="tasks"?"hidden":""}`}><nav className="job-lane-filters" aria-label="后台任务通道">{([["all","全部"],...Object.entries(jobLaneLabels)] as Array<["all"|JobLane,string]>).map(([key,label])=><button className={jobLane===key?"active":""} key={key} onClick={()=>{setJobLane(key);setJobData(current=>({...current,page:1}))}}>{label}</button>)}</nav><header><div><h2><Activity/>后台任务</h2><p>{jobsLoad.phase==="ready"?`共 ${jobData.total} 条`:"正在读取…"}</p></div><span className="jobs-header-actions">{jobData.retryable_failed_count>0&&<button className="secondary job-retry-all" disabled={retryingFailedJobs} onClick={()=>void retryFailedJobs()}>{retryingFailedJobs?<RefreshCw className="spin"/>:<RefreshCw/>}{retryingFailedJobs?"正在重试":`重试全部失败 (${jobData.retryable_failed_count})`}</button>}<button className="icon-button" disabled={jobsLoad.refreshing} aria-label="刷新后台任务" onClick={()=>void loadJobs()}><RefreshCw className={jobsLoad.refreshing?"spin":""}/></button></span></header>{jobsLoad.phase==="loading"||jobsLoad.phase==="idle"?<AsyncNotice compact label="正在加载后台任务…"/>:jobsLoad.phase==="error"?<div className="empty compact async-error"><AlertCircle/><h3>后台任务加载失败</h3><p>{jobsLoad.error}</p><button className="secondary" onClick={()=>void loadJobs()}><RefreshCw/>重试</button></div>:<><div className="job-list">{jobsLoad.error&&<div className="async-update inline failed"><AlertCircle/><span>{jobsLoad.error}</span><button onClick={()=>void loadJobs()}>重试</button></div>}{jobData.items.map(job=><article className={`mini-job lane-${job.lane||jobLaneForKind(job.kind)}`} key={job.id}><span className={job.status}/><div><header><strong><span className="job-lane-badge">{jobLaneLabels[job.lane||jobLaneForKind(job.kind)]}</span>{jobLabel(job.kind)}</strong><b className={job.status}>{({queued:"等待",running:"进行中",completed:"已完成",failed:"失败",cancelled:"已取消"} as Record<string,string>)[job.status]||job.status}</b></header><p>{job.error||job.pause_reason||job.message||"等待任务更新"}</p>{jobInferenceSummary(job)&&<small className="job-inference-device">{jobInferenceSummary(job)}</small>}<footer><small>{new Date(job.finished_at||job.heartbeat_at||job.started_at||job.created_at).toLocaleString()} · 尝试 {job.attempts||0} </small><span className="job-row-actions"><strong>{Math.round(job.progress*100)}%</strong>{(job.status==="queued"||job.status==="running")&&<button className="secondary job-cancel" disabled={cancellingJob===job.id||Boolean(job.cancel_requested)} aria-label={`取消${jobLabel(job.kind)}`} onClick={()=>cancelJob(job)}>{cancellingJob===job.id||job.cancel_requested?<RefreshCw className="spin"/>:<X/>}{job.cancel_requested?"停止中":"取消"}</button>}{job.status==="failed"&&<button className="secondary job-retry" disabled={retryingJob===job.id} aria-label={`重试${jobLabel(job.kind)}`} onClick={()=>retryJob(job)}>{retryingJob===job.id?<RefreshCw className="spin"/>:<RefreshCw/>}重试</button>}</span></footer><i><span style={{width:`${Math.round(job.progress*100)}%`}}/></i></div></article>)}{!jobData.items.length&&<div className="empty compact"><Activity/><h3>暂无后台任务</h3></div>}</div><nav className="job-pagination" aria-label="后台任务分页"><button className="secondary" disabled={jobData.page<=1} onClick={()=>void loadJobs(jobData.page-1)}>上一页</button><span> {jobData.page} / {jobData.pages} </span><button className="secondary" disabled={jobData.page>=jobData.pages} onClick={()=>void loadJobs(jobData.page+1)}>下一页</button></nav></>}</section>
<section className={`panel resource-panel settings-section ${section!=="tasks"?"hidden":""}`}>
<header><div><h2><Activity/>资源保护</h2><p>前台访问优先,后台任务按磁盘压力自动让行</p></div><button className={resources?.manual_pause?"primary":"secondary"} disabled={!resources||resourceBusy} onClick={toggleBackgroundTasks}>{resources?.manual_pause?<Play/>:<Pause/>}{resources?.manual_pause?"继续后台任务":"暂停后台任务"}</button></header>
{resourcesLoad.phase==="error"&&!resources?<div className="error"><AlertCircle/>{resourcesLoad.error}</div>:resources?<>
{resourcesLoad.error&&<div className="async-update inline failed"><AlertCircle/><span>{resourcesLoad.error}</span></div>}
<div className="resource-profile" role="group" aria-label="后台性能档位">{(["quiet","balanced","turbo"] as ResourceProfile[]).map(profile=><button className={resources.profile===profile?"active":""} disabled={resourceBusy} key={profile} onClick={()=>void setResourceProfile(profile)}><strong>{({quiet:"安静",balanced:"平衡",turbo:"极速"} as const)[profile]}</strong><small>{({quiet:"机械盘或多人使用",balanced:"推荐,兼顾速度与响应",turbo:"设备空闲时加速"} as const)[profile]}</small></button>)}</div>
<section className="gpu-mode-panel" aria-labelledby="gpu-mode-title"><header><div><h3 id="gpu-mode-title">AI GPU 推理模式</h3><p>画面模型按档位调整 streams 与批量;Whisper 为避免解码器卡死始终单窗口 greedy</p></div><Activity/></header><div className="resource-profile gpu-mode-options" role="group" aria-label="AI GPU 推理模式">{(["low","balanced","throughput"] as AiGpuMode[]).map(mode=><button className={(resources.ai_gpu_mode||"balanced")===mode?"active":""} disabled={resourceBusy} key={mode} onClick={()=>void setAiGpuMode(mode)}><strong>{({low:"低占用",balanced:"平衡",throughput:"吞吐"} as const)[mode]}</strong><small>{({low:"1 stream · batch 1",balanced:"2 streams · batch 2 · 推荐",throughput:"3 streams · batch 4"} as const)[mode]}</small></button>)}</div><p className="muted">当前有效配置:{resources.ai_gpu_profile?.streams||2} streams · batch {resources.ai_gpu_profile?.batch_size||2}{resources.ai_gpu_profile?.degraded?"(主机资源持续受压,已自动降档)":""}</p>{resources.gpu&&<div className={`gpu-hardware ${resources.gpu.supported?"available":"unsupported"}`}><span><Activity/></span><div><strong>{resources.gpu.supported?`Intel GPU 硬件占用 ${resources.gpu.utilization_percent==null?"采样中":`${resources.gpu.utilization_percent}%`}`:"GPU 硬件占用不可采集"}</strong><small>{resources.gpu.memory_supported?`独立显存 ${formatSize(resources.gpu.memory_used_bytes||0)} / ${formatSize(resources.gpu.memory_total_bytes||0)}`:"共享系统内存,无独立显存指标"}{resources.gpu.reason?` · ${resources.gpu.reason}`:""}</small></div><b>{resources.gpu.collector||"unsupported"}</b></div>}</section>
<div className={`io-overview ${resources.io?.state||"normal"}`}><span><HardDrive/></span><div><strong>{resources.io?.state==="emergency"?"应用数据卷压力紧急":resources.io?.state==="paused"?"应用数据卷繁忙,后台让行":"应用数据卷 I/O 正常"}</strong><small>{resourceIoSummary(resources.io)}</small></div><b>{resources.io?.state==="emergency"?"紧急":resources.io?.state==="paused"?"受压":"正常"}</b></div>
<div className="resource-lanes">{(["ai","transfer","download","scan"] as JobLane[]).map(lane=>{const value=resources.lanes?.[lane]||{limit:1,running:0,queued:0,paused:0};const state=value.paused>0?"paused":value.running?"running":value.queued?"queued":"idle";return <article className={state} key={lane}><header><strong>{jobLaneLabels[lane]}</strong><b>{value.paused>0?"让行":value.running?"运行中":value.queued&&value.pressure_reason?"等待资源":value.queued?"等待":"空闲"}</b></header><p>运行 {value.running} · 排队 {value.queued} · 并发 {value.limit}</p>{value.pressure_reason&&<small>{value.pressure_reason}</small>}</article>})}</div>
{resources.remote_cache?<div className="resource-cache"><header><div><strong>远程媒体缓存</strong><small>{resources.remote_cache.entries} 个完整文件 · {resources.remote_cache.downloading?"正在顺序缓存":"当前无下载"}</small></div><button className="secondary" disabled={resourceBusy||!resources.remote_cache.evictable_bytes} onClick={()=>void cleanupRemoteCache()}><Trash2/>清理空闲缓存</button></header><i><b style={{width:`${Math.min(100,resources.remote_cache.limit_bytes?resources.remote_cache.used_bytes/resources.remote_cache.limit_bytes*100:0)}%`}}/></i><footer><span>{formatSize(resources.remote_cache.used_bytes)} / {formatSize(resources.remote_cache.limit_bytes)}</span><span>活动 {formatSize(resources.remote_cache.active_bytes)} · 可清理 {formatSize(resources.remote_cache.evictable_bytes)}</span></footer></div>:<AsyncNotice compact label="正在读取远程缓存状态…"/>}
<div className="metric-list"><div><span>CPU</span><b>{resources.cpu_percent}%</b></div><div><span>可用内存</span><b>{formatSize(resources.memory_available_bytes)}</b></div><div><span>运行 / 让行</span><b>{resources.running_jobs} / {resources.paused_jobs}</b></div><div><span>rclone 实例</span><b>{resources.rclone?`${resources.rclone.managed} 正常${resources.rclone.verifying?` · ${resources.rclone.verifying} 核验中`:""}${resources.rclone.unresolved?` · ${resources.rclone.unresolved} 待处理`:""}`:"正在读取"}</b></div><div><span>数据库</span><b>{resources.database?.pool_waiters?`连接排队 ${resources.database.pool_waiters}`:resources.database?.lock_retry_count?`事务冲突已恢复 ${resources.database.lock_retry_count} 次`:"PostgreSQL 正常"}</b></div></div>
{resources.database&&Boolean(resources.database.pool_waiters)&&<div className="warning-callout"><Activity/><span><strong>数据库连接正在排队</strong>请求会在连接可用后继续;可在诊断信息中查看连接池占用。</span></div>}{resources.pressure_reason&&<div className="warning-callout"><ShieldCheck/><span><strong>{resources.paused_jobs>0?"后台任务已自动让出资源":"新任务正在等待资源"}</strong>{resources.pressure_reason};资源恢复后会自动继续,不会标记失败。</span></div>}{resources.rclone&&<div className={`rclone-reconcile ${resources.rclone.verifying||resources.rclone.unresolved?"attention":"ready"}`}><span>{resources.rclone.verifying||resources.rclone.unresolved?<AlertCircle/>:<CheckCircle2/>}</span><div><strong>{resources.rclone.verifying?`${resources.rclone.verifying} 个旧实例正在安全核验`:resources.rclone.unresolved?`${resources.rclone.unresolved} 个实例身份无法确认`:"rclone 进程状态正常"}</strong><small>{resources.rclone.verifying?"每 60 秒自动采样;连续 3 次无连接、无 I/O 且观察满 2 分钟后安全回收":resources.rclone.unresolved?"注册身份与进程不一致,系统不会终止;可重启应用或人工检查":"没有重复计数;仅管理 ImageFind 自己创建的 rclone 实例"}</small><small>最近核验:{resources.rclone.last_checked_at?new Date(resources.rclone.last_checked_at).toLocaleString():"尚未执行"}{resources.rclone.reclaimed?` · 本次运行已回收 ${resources.rclone.reclaimed} 个`:""}</small></div><button className="secondary" disabled={rcloneBusy} onClick={()=>void reconcileRclone()}>{rcloneBusy?<RefreshCw className="spin"/>:<ShieldCheck/>}立即安全核验</button></div>}
<p className="muted">平衡档默认限制后台总带宽,并在 I/OCPU、内存或剩余空间达到阈值时自动让行。播放和前台 API 不限速,紧急保护不可关闭。</p>
</>:<AsyncNotice compact label="正在读取设备资源与磁盘压力…"/>}
</section>
</div></div></div>;
}
export default function App() {
const [configured,setConfigured]=useState<boolean|null>(null); const [authenticated,setAuthenticated]=useState(false);
const [liveEvents,setLiveEvents]=useState(false);
const [accessMode,setAccessMode]=useState<AccessMode>(appBase==="/"?"direct":"gateway");const [gatewayError,setGatewayError]=useState("");
const [page,setPage]=useState<Page>("home"); const [sidebarMini,setSidebarMini]=useState(false);
const [sources,setSources]=useState<Source[]>([]); const [videos,setVideos]=useState<Video[]>([]); const [uploads,setUploads]=useState<UploadTask[]>([]);const [uploadPage,setUploadPage]=useState<UploadPage>({items:[],status_items:[],active_count:0,failed_count:0,page:1,page_size:10,total:0,pages:1});const [downloads,setDownloads]=useState<DownloadTask[]>([]);
const [people,setPeople]=useState<Person[]>([]); const [actors,setActors]=useState<Actor[]>([]);const [groups,setGroups]=useState<TagGroup[]>([]);
const [profile,setProfile]=useState<Profile|null>(null);const [collections,setCollections]=useState<CollectionItem[]>([]);
const [loadStatus,setLoadStatus]=useState<Record<AppResource,LoadStatus>>({home:idleLoad(),sources:idleLoad(),videos:idleLoad(),uploads:idleLoad(),downloads:idleLoad(),people:idleLoad(),actors:idleLoad(),groups:idleLoad(),preferences:idleLoad(),activity:idleLoad(),profile:idleLoad(),collections:idleLoad()});
const [homeFeed,setHomeFeed]=useState<HomeFeed|null>(null);const [homeBrowseKey,setHomeBrowseKey]=useState<string|null>(null);const [homeBrowseItems,setHomeBrowseItems]=useState<Video[]>([]);const [homeBrowseBusy,setHomeBrowseBusy]=useState(false);const [homeBrowseDone,setHomeBrowseDone]=useState(false);const [collectionRoute,setCollectionRoute]=useState<CollectionRoute>({mode:"index"});
const [preferences,setPreferences]=useState<Preferences>({autoplay:true,mask_covers:false,theme:storedTheme(),home_video_columns:2,upload_paths:{}});const [activity,setActivity]=useState<ActivityItem[]>([]);const [unread,setUnread]=useState(0);
const [query,setQuery]=useState(""); const [results,setResults]=useState<Result[]>([]); const [searchBusy,setSearchBusy]=useState(false); const [searchMoreBusy,setSearchMoreBusy]=useState(false);const [searchCursor,setSearchCursor]=useState<string|null>(null);const [searchTotal,setSearchTotal]=useState(0); const [error,setError]=useState("");const [searchNotice,setSearchNotice]=useState("");const [searchCoverage,setSearchCoverage]=useState<SearchCoverage|null>(null);const [coverageLoading,setCoverageLoading]=useState(false);
const [activeVideo,setActiveVideo]=useState<Video|null>(null); const [startMs,setStartMs]=useState(0); const [previousPage,setPreviousPage]=useState<Page>("home");
const [uploadOpen,setUploadOpen]=useState(false); const [editVideo,setEditVideo]=useState<Video|null>(null); const [masked,setMasked]=useState(false);const [appearanceOpen,setAppearanceOpen]=useState(false);const [resolvedDark,setResolvedDark]=useState(()=>document.documentElement.dataset.theme==="dark");const [notificationsOpen,setNotificationsOpen]=useState(false);const [accountOpen,setAccountOpen]=useState(false);const [mobileMore,setMobileMore]=useState(false);const [mobileMoreMounted,setMobileMoreMounted]=useState(false);const [mobileMoreClosing,setMobileMoreClosing]=useState(false);
const [searchFilters,setSearchFilters]=useState<{source_ids:string[];recognition_types:RecognitionType[];min_width?:number;has_people?:boolean;min_duration_ms?:number;tag_ids:string[];sort:"relevance"|"newest"|"duration"}>({source_ids:[],recognition_types:[],tag_ids:[],sort:"relevance"});
const [pullDistance,setPullDistance]=useState(0);const [pullPhase,setPullPhase]=useState<PullRefreshPhase>("idle");const pullGesture=useRef({active:false,x:0,y:0});const pullDistanceRef=useRef(0);const pullSettleTimer=useRef<number|undefined>(undefined);
const imageInput=useRef<HTMLInputElement>(null);
const searchRequest=useRef<Record<string,unknown>|null>(null);
const moreSheet=useRef<HTMLDivElement>(null);const appearancePanel=useRef<HTMLSpanElement>(null);const touchStart=useRef(0);const mobileMoreNavigating=useRef(false);
const pendingCollection=useRef<string|undefined>(undefined);
const pageRef=useRef<Page>("home");const videosRef=useRef<Video[]>([]);const uploadPageNumber=useRef(1);const uploadRequestSequence=useRef(0);const pendingScroll=useRef<number|null>(null);
useEffect(()=>()=>window.clearTimeout(pullSettleTimer.current),[]);
useEffect(()=>{if(page!=="home"&&pullPhase!=="idle"){window.clearTimeout(pullSettleTimer.current);pullGesture.current.active=false;pullDistanceRef.current=0;setPullDistance(0);setPullPhase("idle")}},[page,pullPhase]);
const check=useCallback(async()=>{
try{
const status=await api<{configured:boolean;access_mode?:AccessMode}>("/status");
const mode=status.access_mode||"direct";setConfigured(status.configured);setAccessMode(mode);setGatewayError("");
if(!status.configured){setAuthenticated(false);if(mode==="gateway")setGatewayError("ImageFind 尚未初始化,请先开启直接 Web 入口设置回退密码。");return}
try{
const me=await api<{csrf?:string}>("/auth/me");if(me.csrf)setCsrf(me.csrf);
if(mode==="gateway"){
const media=await api<{gateway_media_token:string}>("/auth/gateway/media-token",{method:"POST",body:"{}"});
setGatewayMediaToken(media.gateway_media_token);
}else setGatewayMediaToken("");
setAuthenticated(true);
}catch(reason){
setCsrf("");setGatewayMediaToken("");
if(mode!=="gateway"){setAuthenticated(false);return}
setGatewaySession("");
try{const value=await api<{csrf_token:string;gateway_session_token:string;gateway_media_token:string}>("/auth/gateway",{method:"POST",body:"{}"});setGatewaySession(value.gateway_session_token);setGatewayMediaToken(value.gateway_media_token);setCsrf(value.csrf_token);await api("/auth/me");setAuthenticated(true)}
catch(gatewayReason){setGatewaySession("");setGatewayMediaToken("");setAuthenticated(false);setGatewayError((gatewayReason as Error).message||(reason as Error).message)}
}
}catch(reason){setConfigured(false);setAuthenticated(false);setGatewayError((reason as Error).message)}
},[]);
const runResource=useCallback(async<T,>(key:AppResource,request:()=>Promise<T>,apply:(value:T)=>void,quiet=false)=>{
if(!quiet)setLoadStatus(current=>{const prior=current[key];return {...current,[key]:{phase:prior.phase==="ready"?"ready":"loading",refreshing:prior.phase==="ready",error:undefined}}});
try{const value=await request();apply(value);setLoadStatus(current=>({...current,[key]:{phase:"ready",refreshing:false}}));return value}
catch(reason){const message=(reason as Error).message;setLoadStatus(current=>{const prior=current[key];return {...current,[key]:prior.phase==="ready"?{phase:"ready",refreshing:false,error:message}:{phase:"error",refreshing:false,error:message}}});return undefined}
},[]);
const loadSources=useCallback((quiet=false)=>runResource("sources",()=>api<Source[]>("/sources"),setSources,quiet),[runResource]);
const loadHome=useCallback((quiet=false)=>runResource("home",()=>api<HomeFeed>("/home?item_limit=10&tag_limit=3"),setHomeFeed,quiet),[runResource]);
const loadVideos=useCallback((quiet=false)=>runResource("videos",()=>api<Video[]>("/videos?limit=200"),setVideos,quiet),[runResource]);
const loadUploads=useCallback((quiet=false,requestedPage?:number)=>{
const target=requestedPage??uploadPageNumber.current;
const requestSequence=++uploadRequestSequence.current;
if(requestedPage!==undefined)uploadPageNumber.current=target;
return runResource("uploads",()=>api<UploadPage|UploadTask[]>(`/uploads?page=${target}&page_size=10`),value=>{
if(requestSequence!==uploadRequestSequence.current)return;
const normalized=normalizeUploadPage(value,target);uploadPageNumber.current=normalized.page;setUploadPage(normalized);setUploads(normalized.status_items)
},quiet);
},[runResource]);
const loadDownloads=useCallback((quiet=false)=>runResource("downloads",()=>api<DownloadTask[]>("/downloads"),setDownloads,quiet),[runResource]);
const loadPeople=useCallback((quiet=false)=>runResource("people",()=>api<Person[]>("/people"),setPeople,quiet),[runResource]);
const loadActors=useCallback((quiet=false)=>runResource("actors",()=>api<Actor[]>("/actors"),setActors,quiet),[runResource]);
const loadGroups=useCallback((quiet=false)=>runResource("groups",()=>api<TagGroup[]>("/tag-groups"),setGroups,quiet),[runResource]);
const loadPreferences=useCallback((quiet=false)=>runResource("preferences",()=>api<Preferences>("/preferences"),value=>{setPreferences(value);setMasked(value.mask_covers)},quiet),[runResource]);
const loadActivity=useCallback((quiet=false)=>runResource("activity",()=>api<{items:ActivityItem[];unread:number}>("/activity?limit=40"),events=>{setActivity(events.items);setUnread(events.unread)},quiet),[runResource]);
const loadProfile=useCallback((quiet=false)=>runResource("profile",()=>api<Profile>("/profile"),setProfile,quiet),[runResource]);
const loadCollections=useCallback((quiet=false)=>runResource("collections",()=>api<CollectionItem[]>("/collections"),setCollections,quiet),[runResource]);
const loadSecondary=useCallback(()=>Promise.all([loadPeople(),loadActors(),loadGroups(),loadPreferences(),loadActivity(),loadProfile(),loadCollections()]),[loadActivity,loadActors,loadCollections,loadGroups,loadPeople,loadPreferences,loadProfile]);
const reloadAll=useCallback(()=>Promise.all([loadHome(),loadSources(),loadVideos(),loadUploads(),loadDownloads(),loadSecondary()]),[loadHome,loadSources,loadVideos,loadUploads,loadDownloads,loadSecondary]);
useEffect(()=>{check()},[check]); useEffect(()=>{if(authenticated)reloadAll()},[authenticated,reloadAll]);
useEffect(()=>{
if(!authenticated){setLiveEvents(false);return}
let refreshTimer:number|undefined;const source=new EventSource(appUrl("/api/v1/events"),{withCredentials:true});
source.onopen=()=>setLiveEvents(true);
source.onmessage=event=>{setLiveEvents(true);try{const value=JSON.parse(event.data) as {topic?:string};if(value.topic==="jobs"){window.clearTimeout(refreshTimer);refreshTimer=window.setTimeout(()=>void Promise.all([loadUploads(true),loadDownloads(true),loadActivity(true)]),350)}}catch{/* malformed events are ignored */}};
source.onerror=()=>setLiveEvents(false);
return()=>{window.clearTimeout(refreshTimer);source.close();setLiveEvents(false)};
},[authenticated,loadActivity,loadDownloads,loadUploads]);
useEffect(()=>{
const media=window.matchMedia("(prefers-color-scheme: dark)");const sync=()=>{applyTheme(preferences.theme);setResolvedDark(document.documentElement.dataset.theme==="dark")};sync();
if(preferences.theme!=="system")return;
media.addEventListener?.("change",sync);return()=>media.removeEventListener?.("change",sync);
},[preferences.theme]);
useEffect(()=>{
if(!appearanceOpen)return;
const closeOnPointer=(event:PointerEvent)=>{if(!appearancePanel.current?.contains(event.target as Node))setAppearanceOpen(false)};
const closeOnKey=(event:KeyboardEvent)=>{if(event.key==="Escape")setAppearanceOpen(false)};
document.addEventListener("pointerdown",closeOnPointer);window.addEventListener("keydown",closeOnKey);
return()=>{document.removeEventListener("pointerdown",closeOnPointer);window.removeEventListener("keydown",closeOnKey)};
},[appearanceOpen]);
useEffect(()=>{
document.documentElement.dataset.imagefindAccess=accessMode;
document.body.classList.toggle("access-gateway-body",accessMode==="gateway");
return()=>document.body.classList.remove("access-gateway-body");
},[accessMode]);
const hasActiveTransfers=uploads.some(item=>["receiving","queued","transferring","indexing"].includes(item.status))||downloads.some(item=>["queued","downloading","transferring"].includes(item.status));
useEffect(()=>{if(!authenticated)return;const refresh=()=>{if(!document.hidden)void Promise.all([loadUploads(true),loadDownloads(true)])};const timer=window.setInterval(refresh,liveEvents?30000:hasActiveTransfers?1500:7000);document.addEventListener("visibilitychange",refresh);window.addEventListener("focus",refresh);return()=>{window.clearInterval(timer);document.removeEventListener("visibilitychange",refresh);window.removeEventListener("focus",refresh)}},[authenticated,hasActiveTransfers,liveEvents,loadUploads,loadDownloads]);
useEffect(()=>{if(!authenticated)return;const refresh=()=>{if(!document.hidden)void loadActivity(true)};const timer=window.setInterval(refresh,liveEvents?60000:15000);document.addEventListener("visibilitychange",refresh);return()=>{window.clearInterval(timer);document.removeEventListener("visibilitychange",refresh)}},[authenticated,liveEvents,loadActivity]);
useEffect(()=>{if(!authenticated||document.hidden)return;if(page==="actors")void Promise.all([loadPeople(),loadActors()]);else if(page==="tags")void loadGroups();else if(page==="series")void loadCollections();else if(page==="profile")void Promise.all([loadProfile(),loadPreferences()]);},[authenticated,page,loadActors,loadCollections,loadGroups,loadPeople,loadPreferences,loadProfile]);
useEffect(()=>{if(!authenticated||page!=="search")return;let active=true;setCoverageLoading(true);api<SearchCoverage>("/search/coverage").then(value=>{if(active)setSearchCoverage(value)}).catch(()=>{if(active)setSearchCoverage(null)}).finally(()=>{if(active)setCoverageLoading(false)});return()=>{active=false}},[authenticated,page]);
useEffect(()=>{pageRef.current=page},[page]);useEffect(()=>{videosRef.current=videos},[videos]);
useEffect(()=>{
const previousRestoration=window.history.scrollRestoration;window.history.scrollRestoration="manual";
const initial=window.history.state?.imagefindNavigation as AppHistoryState|undefined;
if(!initial)window.history.replaceState({...window.history.state,imagefindNavigation:{kind:"page",page:"home",scrollY:scrollPosition()}} as {imagefindNavigation:AppHistoryState},"");
const restore=(event:PopStateEvent)=>{
const state=event.state?.imagefindNavigation as AppHistoryState|undefined;
if(!state){setMobileMore(false);setHomeBrowseKey(null);setCollectionRoute({mode:"index"});setPage("home");scrollInstant();return}
if(state.kind==="more"){
setPage(state.page);setMobileMore(true);return;
}
setMobileMore(false);setPage(state.page);setPreviousPage(state.page==="player"?"home":pageRef.current);
setHomeBrowseKey(state.page==="home"?state.homeSection||null:null);
setCollectionRoute(state.page==="series"?{mode:state.collectionMode||(state.collectionId?"detail":"index"),id:state.collectionId}:{mode:"index"});
if(state.page==="player"&&state.videoId){const video=videosRef.current.find(item=>item.id===state.videoId);if(video){setActiveVideo(video);setStartMs(state.position||video.progress_ms||0)}}
pendingScroll.current=state.scrollY||0;
};
window.addEventListener("popstate",restore);return()=>{window.removeEventListener("popstate",restore);window.history.scrollRestoration=previousRestoration};
},[]);
useLayoutEffect(()=>{
if(pendingScroll.current===null)return;
const top=pendingScroll.current;pendingScroll.current=null;
// Restore before paint. Deferring by two animation frames let a freshly
// returned page become interactive first, then unexpectedly snapped its
// scroll position back to zero after the user had already started moving.
scrollInstant(top);
},[page]);
useEffect(()=>{
if(page!=="player"||activeVideo)return;
const state=window.history.state?.imagefindNavigation as AppHistoryState|undefined;
if(state?.kind!=="page"||!state.videoId)return;
const video=videos.find(item=>item.id===state.videoId);if(video){setActiveVideo(video);setStartMs(state.position||video.progress_ms||0)}
},[activeVideo,page,videos]);
useEffect(()=>{
if(mobileMore){setMobileMoreMounted(true);setMobileMoreClosing(false);return}
if(!mobileMoreMounted)return;
setMobileMoreClosing(true);const timer=window.setTimeout(()=>{setMobileMoreMounted(false);setMobileMoreClosing(false)},220);
return()=>window.clearTimeout(timer);
},[mobileMore,mobileMoreMounted]);
useLayoutEffect(()=>{
if(!mobileMoreMounted)return;
const scrollY=scrollPosition();const content=mobileScrollContainer();
const previous=document.activeElement as HTMLElement|null;
const keydown=(event:KeyboardEvent)=>{if(event.key==="Escape"&&(window.history.state?.imagefindNavigation as AppHistoryState|undefined)?.kind==="more")window.history.back();if(event.key==="Tab"){const focusable=[...(moreSheet.current?.querySelectorAll<HTMLElement>("button,[href],input")||[])].filter(item=>!item.hasAttribute("disabled"));if(!focusable.length)return;const first=focusable[0],last=focusable[focusable.length-1];if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus()}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus()}}};
window.addEventListener("keydown",keydown);document.body.classList.add("sheet-open");content?.classList.add("sheet-locked");
requestAnimationFrame(()=>moreSheet.current?.querySelector<HTMLElement>(".sheet-close")?.focus());
return()=>{window.removeEventListener("keydown",keydown);document.body.classList.remove("sheet-open");content?.classList.remove("sheet-locked");scrollInstant(mobileMoreNavigating.current?0:scrollY);mobileMoreNavigating.current=false;previous?.focus({preventScroll:true})};
},[mobileMoreMounted]);
const commitPage=useCallback((target:Page,options:{replace?:boolean;video?:Video;position?:number;collection?:CollectionRoute}={})=>{
const current=window.history.state?.imagefindNavigation as AppHistoryState|undefined;
if(current)window.history.replaceState({...window.history.state,imagefindNavigation:{...current,scrollY:scrollPosition()}},"");
if(target===pageRef.current&&!options.video&&!options.replace&&!options.collection){if(target==="home"&&homeBrowseKey){setHomeBrowseKey(null);setHomeBrowseItems([]);window.history.pushState({...window.history.state,imagefindNavigation:{kind:"page",page:"home",scrollY:0} as AppHistoryState},"")}scrollToPosition(0,"smooth");return}
const nextCollection=target==="series"?(options.collection||(pendingCollection.current?{mode:"detail",id:pendingCollection.current}:{mode:"index"})):undefined;
pendingCollection.current=undefined;
const state:AppHistoryState={kind:"page",page:target,scrollY:0,...(options.video?{videoId:options.video.id,position:options.position||0}:{}),...(nextCollection?{collectionMode:nextCollection.mode,collectionId:nextCollection.id}:{})};
window.history[options.replace?"replaceState":"pushState"]({...window.history.state,imagefindNavigation:state},"");
if(target!=="home"){setHomeBrowseKey(null);setHomeBrowseItems([])}
setCollectionRoute(nextCollection||{mode:"index"});
if(pageRef.current!=="player")setPreviousPage(pageRef.current);setNotificationsOpen(false);setAccountOpen(false);setPage(target);scrollInstant();
},[homeBrowseKey]);
const navigate=useCallback((target:Page)=>commitPage(target),[commitPage]);
const openCollectionRoute=useCallback((next:CollectionRoute,replace=false)=>commitPage("series",{collection:next,replace}),[commitPage]);
const setCollectionTarget=(id:string|undefined)=>{pendingCollection.current=id};
const logout=async()=>{await api("/auth/logout",{method:"POST",body:"{}"});setCsrf("");setGatewaySession("");setGatewayMediaToken("");setAuthenticated(false)};
const openMobileMore=()=>{mobileMoreNavigating.current=false;const scrollY=scrollPosition();const current=window.history.state?.imagefindNavigation as AppHistoryState|undefined;if(current)window.history.replaceState({...window.history.state,imagefindNavigation:{...current,scrollY}},"");window.history.pushState({...window.history.state,imagefindNavigation:{kind:"more",page:pageRef.current,scrollY} as AppHistoryState},"");setMobileMore(true)};
const closeMobileMore=()=>{const state=window.history.state?.imagefindNavigation as AppHistoryState|undefined;if(state?.kind==="more")window.history.back();else setMobileMore(false)};
const navigateFromMobileMore=(target:Page)=>{mobileMoreNavigating.current=true;setMobileMore(false);commitPage(target,{replace:true})};
const play=(video:Video,position=0)=>{const start=position||video.progress_ms||0;setPreviousPage(pageRef.current==="player"?previousPage:pageRef.current);setActiveVideo(video);setStartMs(start);commitPage("player",{video,position:start})};
const search=useCallback(async(text:string,image?:File,overrides?:Partial<typeof searchFilters>)=>{if(!text&&!image)return;const filters={...searchFilters,...overrides};if(image){filters.recognition_types=["visual"];setSearchFilters(filters)}setSearchBusy(true);setError("");setSearchNotice("");setSearchCursor(null);setSearchTotal(0);navigate("search");try{const imageId=image?(await uploadImage(image)).id:null;const request={text:text||null,image_id:imageId,limit:24,...filters};searchRequest.current=request;const value=await api<{items:Result[];next_cursor:string|null;total_candidates:number;query_notice?:string}>("/search",{method:"POST",body:JSON.stringify(request)});setResults(value.items);setSearchCursor(value.next_cursor);setSearchTotal(value.total_candidates);setSearchNotice(value.query_notice||"")}catch(reason){setResults([]);setError((reason as Error).message)}finally{setSearchBusy(false)}},[navigate,searchFilters]);
const loadMoreSearch=useCallback(async()=>{if(!searchCursor||!searchRequest.current||searchMoreBusy)return;setSearchMoreBusy(true);setError("");try{const value=await api<{items:Result[];next_cursor:string|null;total_candidates:number}>("/search",{method:"POST",body:JSON.stringify({...searchRequest.current,cursor:searchCursor})});setResults(current=>{const known=new Set(current.map(item=>item.frame_id));return [...current,...value.items.filter(item=>!known.has(item.frame_id))]});setSearchCursor(value.next_cursor);setSearchTotal(value.total_candidates)}catch(reason){setError((reason as Error).message)}finally{setSearchMoreBusy(false)}},[searchCursor,searchMoreBusy]);
useEffect(()=>{const handler=(event:Event)=>{const text=(event as CustomEvent<string>).detail;setQuery(text);search(text)};window.addEventListener("imagefind-search",handler);return()=>window.removeEventListener("imagefind-search",handler)},[search]);
const videoById=useMemo(()=>new Map(videos.map(video=>[video.id,video])),[videos]);
const updateVideoState=useCallback((video:Video,state:Partial<Video>)=>{setVideos(current=>current.map(item=>item.id===video.id?{...item,...state}:item));setActiveVideo(current=>current?.id===video.id?{...current,...state}:current)},[]);
const updatePreferences=useCallback(async(value:Partial<Preferences>)=>{const next=await api<Preferences>("/preferences",{method:"PATCH",body:JSON.stringify(value)});setPreferences(next);if(value.mask_covers!==undefined)setMasked(value.mask_covers)},[]);
const applySearchFilter=(value:Partial<typeof searchFilters>)=>{const next={...searchFilters,...value};setSearchFilters(next);if(query)search(query,undefined,next)};
const toggleRecognition=(value:RecognitionType)=>{const current=searchFilters.recognition_types;applySearchFilter({recognition_types:current.includes(value)?current.filter(item=>item!==value):[...current,value]})};
const homeVideoSections=useMemo(()=>homeFeed?[homeFeed.recent,...homeFeed.categories,...(homeFeed.unorganized?[homeFeed.unorganized]:[])]:[],[homeFeed]);
const homeBrowseSection=useMemo(()=>homeVideoSections.find(section=>section.id===homeBrowseKey),[homeBrowseKey,homeVideoSections]);
useEffect(()=>{if(!homeBrowseKey){setHomeBrowseItems([]);setHomeBrowseDone(false);return}if(!homeBrowseSection)return;setHomeBrowseItems(homeBrowseSection.items);setHomeBrowseDone(homeBrowseSection.items.length>=homeBrowseSection.total)},[homeBrowseKey,homeBrowseSection]);
function openHomeSection(section:HomeVideoSection){
const current=window.history.state?.imagefindNavigation as AppHistoryState|undefined;
if(current)window.history.replaceState({...window.history.state,imagefindNavigation:{...current,scrollY:scrollPosition()}},"");
window.history.pushState({...window.history.state,imagefindNavigation:{kind:"page",page:"home",scrollY:0,homeSection:section.id} as AppHistoryState},"");
setHomeBrowseKey(section.id);setHomeBrowseItems(section.items);setHomeBrowseDone(section.items.length>=section.total);scrollInstant();
}
async function loadMoreHomeSection(){
if(!homeBrowseSection||homeBrowseBusy||homeBrowseDone)return;setHomeBrowseBusy(true);
try{const params=new URLSearchParams({limit:"24",offset:String(homeBrowseItems.length)});if(homeBrowseSection.id==="recent")params.set("sort","added");else if(homeBrowseSection.id==="unorganized"){params.set("organized","false");params.set("sort","added")}else if(homeBrowseSection.tag_id)params.set("tag_ids",homeBrowseSection.tag_id);const next=await api<Video[]>(`/videos?${params}`);setHomeBrowseItems(current=>{const known=new Set(current.map(item=>item.id));return [...current,...next.filter(item=>!known.has(item.id))]});setHomeBrowseDone(next.length<24||homeBrowseItems.length+next.length>=homeBrowseSection.total)}catch(reason){toast((reason as Error).message,"error")}finally{setHomeBrowseBusy(false)}
}
function settlePull(){window.clearTimeout(pullSettleTimer.current);pullDistanceRef.current=0;setPullDistance(0);setPullPhase("settling");pullSettleTimer.current=window.setTimeout(()=>setPullPhase("idle"),220)}
function beginPull(event:ReactTouchEvent<HTMLElement>){if(page!=="home"||pullPhase!=="idle"||!window.matchMedia("(max-width: 720px)").matches||event.currentTarget.scrollTop>1)return;const touch=event.touches[0];window.clearTimeout(pullSettleTimer.current);pullGesture.current={active:true,x:touch.clientX,y:touch.clientY}}
function movePull(event:ReactTouchEvent<HTMLElement>){if(!pullGesture.current.active)return;const touch=event.touches[0];const dx=touch.clientX-pullGesture.current.x;const dy=touch.clientY-pullGesture.current.y;if(dy<=0||Math.abs(dx)>Math.abs(dy)){pullGesture.current.active=false;settlePull();return}event.preventDefault();const distance=Math.min(112,dy*.48);pullDistanceRef.current=distance;setPullDistance(distance);setPullPhase(distance>=72?"ready":"pulling")}
async function endPull(){if(!pullGesture.current.active)return;pullGesture.current.active=false;if(pullDistanceRef.current<72){settlePull();return}pullDistanceRef.current=54;setPullDistance(54);setPullPhase("refreshing");try{await Promise.all([loadHome(true),loadVideos(true),loadCollections(true),loadProfile(true),loadUploads(true)]);toast("首页已刷新")}catch(reason){toast((reason as Error).message,"error")}finally{settlePull()}}
if(configured===null)return <div className="splash"><span className="brand-symbol"/><Sparkles className="pulse"/></div>;
if(accessMode==="gateway"&&(!configured||gatewayError))return <GatewayAccessScreen message={gatewayError||"请使用飞牛管理员账号重新打开 ImageFind。"}/>;
if(!configured||!authenticated)return <AuthScreen configured={Boolean(configured)} onReady={()=>{setConfigured(true);setAuthenticated(true)}}/>;
const nav: Array<[Page,string,ReactNode]>=[["home","首页",<Library/>],["search","搜索",<Search/>],["library","资料库",<Folder/>],["series","合集",<Boxes/>],["uploads","上传中心",<Upload/>],["tags","分类与标签",<Tags/>],["actors","演员与人物",<Users/>],["settings","设置",<Settings/>]];
const mobileMorePages:Page[]=["library","series","uploads","tags","actors","settings"];
const mobileSecondaryPages:Page[]=[...mobileMorePages,"player"];
const mobileHeaderlessPages:Page[]=[...mobileSecondaryPages,"profile","search"];
const transfersLoading=loadStatus.uploads.phase!=="ready"||loadStatus.downloads.phase!=="ready";
const profileLoading=loadStatus.profile.phase!=="ready";
const recognitionOptions:Array<[RecognitionType,string]>=[["visual","画面语义"],["ocr","OCR 文字"],["person","人物识别"],["audio","音频识别"],["subtitle","字幕"],["metadata","影片资料"]];
return <div className={`${sidebarMini?"app-shell mini":"app-shell"} mobile-page-${page} ${accessMode==="gateway"?"access-gateway":"access-direct"} ${masked?"covers-masked":""} ${mobileHeaderlessPages.includes(page)?"mobile-headerless":""} ${mobileSecondaryPages.includes(page)?"mobile-secondary":"mobile-primary"}`}>
<header className="topbar"><div className="top-brand"><button className="icon-button desktop-sidebar-toggle" aria-label="切换侧栏" onClick={()=>setSidebarMini(!sidebarMini)}><Menu/></button><button className="brand" aria-label="返回首页" onClick={()=>navigate("home")}><span className="brand-symbol"><Film/></span><strong>ImageFind</strong><i>LOCAL</i></button></div>
<form className="global-search" onSubmit={event=>{event.preventDefault();search(query)}}><Search/><input value={query} onChange={e=>setQuery(e.target.value)} placeholder="搜索画面、文字、人物或声音"/><kbd> K</kbd><button aria-label="提交搜索"><Search/></button><button type="button" className="image-search" title="以图搜视频" aria-label="以图搜视频" onClick={()=>imageInput.current?.click()}><ImageIcon/></button><input ref={imageInput} hidden type="file" accept="image/*" onChange={e=>{const file=e.target.files?.[0];if(file)search("",file);e.currentTarget.value=""}}/></form>
<div className="top-actions">
<span className="top-upload"><UploadStatusButton uploads={uploads} uploadCounts={uploadPage} downloads={downloads} loading={transfersLoading} onUpload={()=>setUploadOpen(true)} onCenter={()=>navigate("uploads")} label="上传"/></span>
<span className="top-menu-wrap appearance-toolbar" ref={appearancePanel}>
<button className={`icon-button appearance-trigger ${masked?"masked":""}`} aria-haspopup="dialog" aria-expanded={appearanceOpen} title="外观与隐私" aria-label={`外观与隐私:当前${resolvedDark?"深色":"浅色"},封面${masked?"已遮罩":"未遮罩"}`} onClick={()=>{const open=!appearanceOpen;setAppearanceOpen(open);if(open){setNotificationsOpen(false);setAccountOpen(false)}}}>
{resolvedDark?<Moon/>:<Sun/>}<i aria-hidden="true">{masked?<EyeOff/>:<Eye/>}</i>
</button>
{appearanceOpen&&<span className="appearance-popover" role="dialog" aria-label="外观与隐私快捷设置">
<header><span><strong>外观与隐私</strong><small>立即应用到所有页面</small></span><button className="icon-button" aria-label="关闭外观设置" onClick={()=>setAppearanceOpen(false)}><X/></button></header>
<PreferenceSwitch title="遮罩媒体封面" description="隐藏首页、合集和影片列表中的代表画面" checked={masked} onChange={value=>updatePreferences({mask_covers:value}).catch(reason=>toast((reason as Error).message,"error"))}/>
<ThemeSelector value={preferences.theme} onChange={value=>updatePreferences({theme:value}).catch(reason=>toast((reason as Error).message,"error"))}/>
<footer><span className={`appearance-resolved ${resolvedDark?"dark":"light"}`}>{resolvedDark?<Moon/>:<Sun/>}当前显示为{resolvedDark?"深色":"浅色"}</span></footer>
</span>}
</span>
<span className="top-menu-wrap"><button className="icon-button notification-button" aria-label="通知与活动" onClick={async()=>{const open=!notificationsOpen;setNotificationsOpen(open);setAccountOpen(false);setAppearanceOpen(false);if(open){await api("/activity/read",{method:"POST",body:"{}"});setUnread(0)}}}><Bell/>{unread>0&&<b>{Math.min(99,unread)}</b>}</button>{notificationsOpen&&<span className="activity-popover"><header><strong>通知与活动</strong><button aria-label="关闭通知" onClick={()=>setNotificationsOpen(false)}><X/></button></header>{loadStatus.activity.phase==="idle"||loadStatus.activity.phase==="loading"?<AsyncNotice compact label="正在加载活动…"/>:loadStatus.activity.phase==="error"?<div className="popover-load-error"><AlertCircle/><span>{loadStatus.activity.error}</span><button onClick={()=>void loadActivity()}>重试</button></div>:activity.length?activity.slice(0,15).map(item=><article key={`${item.type}-${item.id}`}><span className={item.status}/><div><strong>{item.type==="upload"?"上传任务":item.type==="download"?"后台下载":item.type==="tag_suggestion"?"AI 标签建议":jobLabel(item.kind)}</strong><p>{item.error||item.message||item.status}</p><small>{new Date(item.timestamp).toLocaleString()}</small></div></article>):<p className="muted">暂无活动</p>}<button className="wide secondary" onClick={()=>{setNotificationsOpen(false);navigate("settings")}}>查看后台任务</button></span>}</span>
<span className="top-menu-wrap"><button className="avatar" aria-label="账户菜单" onClick={()=>{setAccountOpen(!accountOpen);setNotificationsOpen(false);setAppearanceOpen(false)}}><AvatarMark profile={profile} loading={profileLoading}/></button>{accountOpen&&<span className="account-popover"><button onClick={()=>{navigate("profile");setAccountOpen(false)}}><UserCircle2/>个人中心</button><button onClick={()=>{navigate("settings");setAccountOpen(false)}}><Settings/>设置</button>{accessMode==="direct"&&<button onClick={logout}><LogOut/>退出登录</button>}</span>}</span>
</div>
</header>
<aside className="sidebar"><nav className="primary-nav"><p className="nav-section-label">发现</p>{nav.slice(0,2).map(([key,label,icon],index)=><button key={key} aria-label={label} aria-current={page===key?"page":undefined} className={`${page===key?"active":""} nav-item-${index+1}`} onClick={()=>navigate(key)}>{icon}<span>{label}</span></button>)}<p className="nav-section-label">媒体</p>{nav.slice(2,4).map(([key,label,icon],offset)=><button key={key} aria-label={label} aria-current={page===key?"page":undefined} className={`${page===key?"active":""} nav-item-${offset+3}`} onClick={()=>navigate(key)}>{icon}<span>{label}</span></button>)}<p className="nav-section-label">管理</p>{nav.slice(4,7).map(([key,label,icon],offset)=>key==="uploads"?<UploadStatusButton key={key} uploads={uploads} uploadCounts={uploadPage} downloads={downloads} loading={transfersLoading} onUpload={()=>window.matchMedia("(max-width: 720px)").matches?setUploadOpen(true):navigate("uploads")} onCenter={()=>navigate("uploads")} label="上传中心"/>:<button key={key} aria-label={label} aria-current={page===key?"page":undefined} className={`${page===key?"active":""} nav-item-${offset+5}`} onClick={()=>navigate(key)}>{icon}<span>{label}</span></button>)}<button aria-label="个人中心" aria-current={page==="profile"?"page":undefined} className={`mobile-profile-button ${page==="profile"?"active":""}`} onClick={()=>navigate("profile")}><AvatarMark profile={profile} loading={profileLoading} className="mobile-profile-avatar"/><span>个人中心</span></button><button aria-label="更多" aria-expanded={mobileMore} aria-haspopup="dialog" className={`mobile-more-button ${mobileMorePages.includes(page)?"active":""}`} onClick={()=>mobileMore?closeMobileMore():openMobileMore()}><MoreVertical/><span>更多</span></button></nav><div className="side-section"><p>媒体来源</p>{loadStatus.sources.phase==="ready"?sources.slice(0,4).map(source=><button key={source.id} onClick={()=>navigate("library")}>{source.config.mode==="encrypted"?<LockKeyhole/>:source.kind==="local"?<HardDrive/>:<Cloud/>}<span>{source.name}</span></button>):<AsyncNotice compact label="正在加载来源…"/>}</div><div className="side-footer"><button aria-label="设置" aria-current={page==="settings"?"page":undefined} className={`${page==="settings"?"active":""} nav-item-8`} onClick={()=>navigate("settings")}><Settings/><span>设置</span></button><span><i/>本地 AI 正常</span>{accessMode==="direct"&&<button onClick={logout}><LogOut/><span>退出</span></button>}</div></aside>
{mobileMoreMounted&&createPortal(<><button tabIndex={-1} className={`mobile-more-backdrop ${mobileMoreClosing?"closing":""}`} aria-label="关闭更多菜单" onClick={closeMobileMore}/><div ref={moreSheet} className={`mobile-more-menu ${mobileMoreClosing?"closing":""}`} role="dialog" aria-modal="true" aria-label="更多页面" onTouchStart={event=>{touchStart.current=event.touches[0].clientY}} onTouchEnd={event=>{const list=(event.target as HTMLElement).closest(".mobile-more-list") as HTMLElement|null;if(event.changedTouches[0].clientY-touchStart.current>70&&(!list||list.scrollTop===0))closeMobileMore()}}><i className="sheet-handle"/><header><strong>更多</strong><button className="icon-button sheet-close" aria-label="关闭" onClick={closeMobileMore}><X/></button></header><div className="mobile-more-list"><div className="mobile-more-grid">{nav.filter(([key])=>mobileMorePages.includes(key)).map(([key,label,icon])=>key==="uploads"?<UploadStatusButton key={key} uploads={uploads} uploadCounts={uploadPage} downloads={downloads} loading={transfersLoading} onUpload={()=>{closeMobileMore();setUploadOpen(true)}} onCenter={()=>navigateFromMobileMore("uploads")} label="上传中心" alwaysCenter className={`mobile-more-item ${page===key?"active":""}`}/>:<button key={key} aria-current={page===key?"page":undefined} className={`mobile-more-item ${page===key?"active":""}`} onClick={()=>navigateFromMobileMore(key)}><span className="mobile-more-icon">{icon}</span><span>{label}</span></button>)}</div>{accessMode==="direct"&&<footer className="mobile-more-footer"><button onClick={()=>{void logout();closeMobileMore()}}><LogOut/><span>退出登录</span></button></footer>}</div></div></>,document.body)}
<main className={`content pull-${pullPhase}`} style={{"--pull-distance":`${pullDistance}px`,"--pull-rotation":`${pullDistance*2.2}deg`} as CSSProperties} onTouchStart={beginPull} onTouchMove={movePull} onTouchEnd={()=>void endPull()} onTouchCancel={()=>{pullGesture.current.active=false;settlePull()}}>
{page==="search"&&(coverageLoading?<div className="search-coverage-shell"><div className="search-coverage loading-state"><RefreshCw className="spin"/><span>正在读取音频搜索覆盖率…</span></div></div>:searchCoverage&&<div className="search-coverage-shell"><div className={`search-coverage ${searchCoverage.failed?"has-failures":""}`} role="status"><div><span><Volume2/><strong>音频可搜索覆盖率 {searchCoverage.percent}%</strong></span><small>{searchCoverage.searchable} / {searchCoverage.total} 部当前可搜索 · 其中 {searchCoverage.indexed} 部已用当前模型处理{searchCoverage.empty?` · ${searchCoverage.empty} 部未检测到语音`:""}</small></div><i><b style={{width:`${searchCoverage.percent}%`}}/></i>{(searchCoverage.queued>0||searchCoverage.running>0||searchCoverage.failed>0)&&<p>{searchCoverage.running?`${searchCoverage.running} 部正在识别`:""}{searchCoverage.queued?`${searchCoverage.running?" · ":""}${searchCoverage.queued} 部排队中`:""}{searchCoverage.failed?` · ${searchCoverage.failed} 部失败,可在视频详情重试`:""}</p>}</div></div>)}
{page==="home"&&pullPhase!=="idle"&&<div className={`pull-refresh ${pullPhase}`} role="status"><RefreshCw className={pullPhase==="refreshing"?"spin":""}/><span>{pullPhase==="refreshing"?"正在刷新":pullPhase==="ready"?"松开刷新":pullPhase==="settling"?"刷新完成":"下拉刷新"}</span></div>}
{page==="home"&&<AsyncBoundary status={loadStatus.home} label="首页内容" onRetry={()=>void loadHome()}><div className="page home-page"><section className="home-search" aria-label="多模态搜索入口"><div><Sparkles/><span><strong>搜索视频里的任何内容</strong><small>画面、文字、人物、声音与字幕</small></span></div><button onClick={()=>navigate("search")}><Search/>试试"海边日落"<kbd> K</kbd></button></section>{homeFeed?.video_count?homeBrowseKey&&homeBrowseSection?<section className="home-focused-browser"><header><button className="icon-button" aria-label="返回首页分区" onClick={()=>window.history.back()}><ArrowLeft/></button><div><h1>{homeBrowseSection.title}</h1><p>{homeBrowseSection.group_name?`${homeBrowseSection.group_name} · `:""} {homeBrowseSection.total} 部影片</p></div><div className="home-layout-toggle" role="group" aria-label="首页视频布局"><button aria-label="单列显示" aria-pressed={preferences.home_video_columns===1} className={preferences.home_video_columns===1?"active":""} onClick={()=>updatePreferences({home_video_columns:1}).catch(reason=>toast((reason as Error).message,"error"))}><List/></button><button aria-label="双列显示" aria-pressed={preferences.home_video_columns===2} className={preferences.home_video_columns===2?"active":""} onClick={()=>updatePreferences({home_video_columns:2}).catch(reason=>toast((reason as Error).message,"error"))}><Grid2X2/></button></div></header><div className={`video-grid home-video-grid columns-${preferences.home_video_columns}`}>{homeBrowseItems.map(video=><VideoCard key={video.id} video={video} onPlay={play} hidden={masked}/>)}</div>{!homeBrowseDone&&<button className="secondary home-browse-more" disabled={homeBrowseBusy} onClick={()=>void loadMoreHomeSection()}>{homeBrowseBusy?<RefreshCw className="spin"/>:<ArrowDown/>}{homeBrowseBusy?"正在加载":"加载更多"}</button>}</section>:<div className="home-feed"><HomeVideoRail section={homeFeed.recent} columns={preferences.home_video_columns} hidden={masked} onPlay={play} onViewAll={()=>openHomeSection(homeFeed.recent)} showLayout onColumns={value=>updatePreferences({home_video_columns:value}).catch(reason=>toast((reason as Error).message,"error"))}/>{!!homeFeed.collections.items.length&&<HomeCollectionRail collections={homeFeed.collections.items} total={homeFeed.collections.total} onOpen={id=>{setCollectionTarget(id);navigate("series")}} onViewAll={()=>{setCollectionTarget(undefined);navigate("series")}}/>}{homeFeed.categories.map(section=><HomeVideoRail key={section.id} section={section} columns={preferences.home_video_columns} hidden={masked} onPlay={play} onViewAll={()=>openHomeSection(section)}/>) }{!!homeFeed.people.length&&<section className="home-content-rail home-people-rail" aria-labelledby="home-section-people"><header><div><h2 id="home-section-people">演员与人物</h2><p>按人物快速找到相关影片</p></div><button className="text-button" onClick={()=>navigate("actors")}>查看全部 <ChevronRight/></button></header><div className="people-row">{homeFeed.people.map(actor=><button className="home-person-card" aria-label={`查看 ${actor.name} 的影片`} key={actor.id} onClick={()=>{setQuery(actor.name);search(actor.name)}}><span>{actor.thumbnail_url?<img src={appUrl(actor.thumbnail_url)} alt=""/>:<UserCircle2/>}</span><strong>{actor.name}</strong><small>{actor.video_count} 部影片</small></button>)}</div></section>}{homeFeed.unorganized&&<HomeVideoRail section={homeFeed.unorganized} columns={preferences.home_video_columns} hidden={masked} onPlay={play} onViewAll={()=>openHomeSection(homeFeed.unorganized!)}/>}</div>:<div className="empty home-empty"><Film/><h3>媒体库还是空的</h3><p>添加本地目录或网盘媒体库,然后上传或扫描视频。</p><button className="primary" onClick={()=>navigate("library")}><Plus/>添加资料库</button></div>}</div></AsyncBoundary>}
{page==="search"&&<AsyncBoundary status={mergeLoadStatus(loadStatus.sources,loadStatus.groups,loadStatus.videos)} label="搜索筛选项" onRetry={()=>void Promise.all([loadSources(),loadGroups(),loadVideos()])}><div className="page search-page"><form className="mobile-search-header" aria-label="移动端搜索" onSubmit={event=>{event.preventDefault();search(query)}}><Search/><input aria-label="搜索内容" value={query} onChange={event=>setQuery(event.target.value)} placeholder="搜索影片、人物、文字或音频"/><button type="button" aria-label="以图搜索" onClick={()=>imageInput.current?.click()}><ImageIcon/></button><button className="mobile-search-submit">搜索</button></form><header className="search-heading"><div><p className="eyebrow">AI 搜索</p><h1>{query?`“${query}”的搜索结果`:"搜索你的媒体库"}</h1><p>匹配画面语义、OCR、人物、视频语音、字幕和影片资料</p></div><span>已显示 {results.length}{searchTotal?` / ${searchTotal}`:""} 个相关片段</span></header>{searchNotice&&<div className="warning-callout search-notice" role="status"><AlertCircle/><span>{searchNotice}</span></div>}<div className="recognition-filter" role="group" aria-label="识别类型"><button aria-pressed={!searchFilters.recognition_types.length} className={!searchFilters.recognition_types.length?"active":""} onClick={()=>applySearchFilter({recognition_types:[]})}>全部</button>{recognitionOptions.map(([key,label])=><button key={key} aria-pressed={searchFilters.recognition_types.includes(key)} className={searchFilters.recognition_types.includes(key)?"active":""} onClick={()=>toggleRecognition(key)}>{label}</button>)}</div><div className="filter-row"><select value={searchFilters.sort} onChange={event=>applySearchFilter({sort:event.target.value as typeof searchFilters.sort})}><option value="relevance">相关度最高</option><option value="newest">最近更新</option><option value="duration">时长最长</option></select><select value={searchFilters.source_ids[0]||""} onChange={event=>applySearchFilter({source_ids:event.target.value?[event.target.value]:[]})}><option value="">全部来源</option>{sources.map(source=><option key={source.id} value={source.id}>{source.name}</option>)}</select><select value={searchFilters.tag_ids[0]||""} onChange={event=>applySearchFilter({tag_ids:event.target.value?[event.target.value]:[]})}><option value="">全部标签</option>{groups.map(group=><optgroup key={group.id} label={group.name}>{group.tags.map(tag=><option key={tag.id} value={tag.id}>{tag.name}</option>)}</optgroup>)}</select><button className={searchFilters.min_width?"active":""} onClick={()=>applySearchFilter({min_width:searchFilters.min_width?undefined:3800})}>4K</button><button className={searchFilters.has_people?"active":""} onClick={()=>applySearchFilter({has_people:searchFilters.has_people?undefined:true})}>有人物</button><button className={searchFilters.min_duration_ms?"active":""} onClick={()=>applySearchFilter({min_duration_ms:searchFilters.min_duration_ms?undefined:1800000})}>30 分钟以上</button></div>{error&&<div className="error banner"><AlertCircle/>{error}</div>}{searchBusy?<div className="loading"><RefreshCw className="spin"/><p>正在检索本地 AI 索引…</p></div>:<div className="result-list">{results.map(result=>{const video=videoById.get(result.video_id);const detail=result.match_details?.[0];const title=video?mediaTitle(video):cleanMediaTitle(result.display_name);const activate=()=>video&&play(video,result.segment_start_ms);return <article className="search-result" key={result.frame_id} role="button" tabIndex={0} aria-label={`播放 ${title},从 ${formatTime(result.segment_start_ms)} 开始`} onClick={activate} onKeyDown={event=>{if(event.key==="Enter"||event.key===" "){event.preventDefault();activate()}}}><div className="result-thumb"><Thumb src={result.thumbnail_url} title={title}/><span className="duration">{formatTime(result.timestamp_ms)}</span><i><Play fill="currentColor"/></i></div><div><h2>{title}</h2><p>{result.catalog_code||result.source_name} · 命中时间 {formatTime(result.segment_start_ms)}{formatTime(result.segment_end_ms)}</p><div className="match-card"><span><Sparkles/>AI 匹配</span><div>{result.match_sources.map(source=><b key={source}>{({semantic:"画面语义",image:"相似画面",ocr:"OCR 文字",audio:"音频识别",subtitle:"字幕",person:"人物识别",metadata:"影片资料",filename:"文件名",path:"路径"} as Record<string,string>)[source]||source}</b>)}</div><strong>{result.score.toFixed(3)}</strong></div>{detail?.text&&<blockquote className="match-excerpt">{detail.text}</blockquote>}</div></article>})}{searchCursor&&<button className="secondary search-load-more" disabled={searchMoreBusy} onClick={()=>void loadMoreSearch()}>{searchMoreBusy?<RefreshCw className="spin"/>:<ArrowDown/>}{searchMoreBusy?"正在加载":"加载更多"}</button>}{!results.length&&<div className="empty"><Search/><h3>输入关键词或上传截图</h3><p>可以搜索视频中说过的话、画面文字、人物、字幕或场景。</p></div>}</div>}</div></AsyncBoundary>}
{page==="library"&&<AsyncBoundary status={mergeLoadStatus(loadStatus.sources,loadStatus.videos)} label="媒体资料库" onRetry={()=>void Promise.all([loadSources(),loadVideos()])}><LibraryPage sources={sources} videos={videos} uploads={uploads} uploadCounts={uploadPage} downloads={downloads} transfersLoading={transfersLoading} reload={reloadAll} onUpload={()=>setUploadOpen(true)} onUploadCenter={()=>navigate("uploads")}/></AsyncBoundary>}
{page==="series"&&<AsyncBoundary status={mergeLoadStatus(loadStatus.collections,loadStatus.videos,loadStatus.groups)} label="合集" onRetry={()=>void Promise.all([loadCollections(),loadVideos(),loadGroups()])}><CollectionsPage collections={collections} allVideos={videos} groups={groups} route={collectionRoute} onRoute={openCollectionRoute} reload={async()=>{await Promise.all([loadHome(true),loadCollections(),loadVideos()])}} onPlay={play}/></AsyncBoundary>}
{page==="tags"&&<AsyncBoundary status={mergeLoadStatus(loadStatus.groups,loadStatus.videos)} label="分类与标签" onRetry={()=>void Promise.all([loadGroups(),loadVideos()])}><TagsPage groups={groups} videos={videos} reload={loadSecondary} onVideosChanged={()=>void Promise.all([loadHome(true),loadVideos()])}/></AsyncBoundary>}
{page==="actors"&&<AsyncBoundary status={mergeLoadStatus(loadStatus.actors,loadStatus.people)} label="演员与人物" onRetry={()=>void Promise.all([loadActors(),loadPeople()])}><ActorsPage actors={actors} people={people} reload={loadSecondary} onSearch={name=>{setQuery(name);search(name)}}/></AsyncBoundary>}
{page==="uploads"&&<AsyncBoundary status={mergeLoadStatus(loadStatus.uploads,loadStatus.downloads,loadStatus.sources)} label="传输任务" onRetry={()=>void Promise.all([loadUploads(),loadDownloads(),loadSources()])}><UploadsPage uploads={uploadPage.items} uploadPage={uploadPage} downloads={downloads} onDownloads={setDownloads} sources={sources} reload={()=>void loadUploads()} onPage={next=>loadUploads(false,next)} onUpload={()=>setUploadOpen(true)}/></AsyncBoundary>} {page==="profile"&&<AsyncBoundary status={mergeLoadStatus(loadStatus.profile,loadStatus.preferences)} label="个人中心" onRetry={()=>void Promise.all([loadProfile(),loadPreferences()])}><ProfilePage profile={profile} preferences={preferences} onPreferences={updatePreferences} onProfile={setProfile} onLogout={()=>{setAuthenticated(false);void check()}} onPlay={play} masked={masked} accessMode={accessMode}/></AsyncBoundary>} {page==="settings"&&<SettingsPage preferences={preferences} sources={sources} onPreferences={updatePreferences}/>}
{page==="player"&&activeVideo&&<PlayerPage key={activeVideo.id} video={activeVideo} startMs={startMs} videos={videos} collections={collections} onBack={()=>window.history.back()} onPlay={play} onEdit={()=>setEditVideo(activeVideo)} onDeleted={async()=>{setActiveVideo(null);navigate(previousPage==="player"?"home":previousPage);await Promise.all([loadHome(true),loadCollections(),loadVideos(),api<Profile>("/profile").then(setProfile)])}} preferences={preferences} onPreferences={updatePreferences} onState={updateVideoState} onCollectionsChanged={async()=>{await Promise.all([loadHome(true),loadCollections(),loadVideos()])}}/>}
</main>
<UploadDialog open={uploadOpen} sources={sources} uploads={uploads} groups={groups} collections={collections} preferences={preferences} onPreferences={updatePreferences} onClose={()=>setUploadOpen(false)} onChanged={()=>Promise.all([loadHome(true),loadUploads(true,1),loadVideos(true)])} onCollectionsChanged={async()=>{await Promise.all([loadHome(true),loadCollections(true)])}}/>
{editVideo&&<MetadataDialog video={editVideo} groups={groups} seriesOptions={collections.map(item=>item.name)} onClose={()=>setEditVideo(null)} onSaved={async()=>{await Promise.all([loadHome(true),loadVideos(),loadCollections()]);const updated=(await api<Video[]>("/videos?limit=200")).find(item=>item.id===editVideo.id);if(updated)setActiveVideo(updated)}}/>}
<AppDialogHost/>
<ToastHost/>
</div>;
}