1659 lines
313 KiB
TypeScript
1659 lines
313 KiB
TypeScript
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>本地、WebDAV、AList 直连与加密保险库使用统一索引。</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>请先在飞牛中安装 aria2;ImageFind 不会私自下载另一套运行时。</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="10–256 个字符"/></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 beam;Intel 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/O、CPU、内存或剩余空间达到阈值时自动让行。播放和前台 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>;
|
||
}
|