mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
优化
This commit is contained in:
@@ -8,6 +8,8 @@ export const embyAPI = {
|
||||
updateMount: (id: string, payload: { name?: string; proxy_play?: boolean; enabled?: boolean }) =>
|
||||
api.put<EmbyMount>(`/admin/emby/mounts/${id}`, payload).then((r) => r.data),
|
||||
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
|
||||
reorderMounts: (ids: string[]) =>
|
||||
api.put<{ ok: boolean }>('/admin/emby/mounts/reorder', { ids }).then((r) => r.data),
|
||||
listAccountViews: (accountId: string) =>
|
||||
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
|
||||
fullMountAccount: (accountId: string, proxy: boolean) =>
|
||||
|
||||
@@ -39,8 +39,8 @@ export function APIConfigsPanel() {
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="glass-panel overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="glass-panel table-scroll">
|
||||
<table className="min-w-[640px] w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">服务</th>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function Layout() {
|
||||
const isPlayPage = location.pathname.startsWith('/play')
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
<LayoutSidebars
|
||||
sidebar={sidebar}
|
||||
isAdmin={permissions.isAdmin}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function LayoutHeader({
|
||||
hideSearch,
|
||||
}: LayoutHeaderProps) {
|
||||
return (
|
||||
<header className="relative flex h-20 shrink-0 items-center justify-between gap-4 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
|
||||
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
|
||||
{/* Left: Mobile Menu button or Brand Logo */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
@@ -71,7 +71,7 @@ export function LayoutHeader({
|
||||
</div>
|
||||
|
||||
{/* Middle: Search Box */}
|
||||
<div className="flex-1 max-w-xl mx-auto">
|
||||
<div className="flex min-w-0 flex-1 max-w-xl mx-auto">
|
||||
{!hideSearch && <LayoutHeaderSearch />}
|
||||
</div>
|
||||
|
||||
@@ -155,8 +155,8 @@ function LayoutHeaderSearch() {
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3.5 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500"
|
||||
size={15}
|
||||
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -169,8 +169,8 @@ function LayoutHeaderSearch() {
|
||||
if (results.length > 0) setIsOpen(true)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索电影、剧集、演员…"
|
||||
className="w-full h-10 pl-10 pr-9 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
|
||||
placeholder="搜索媒体…"
|
||||
className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
|
||||
/>
|
||||
{loading ? (
|
||||
<LoaderCircle size={15} className="absolute right-3.5 text-brand-500 animate-spin" />
|
||||
@@ -288,8 +288,10 @@ function LayoutHeaderActions({
|
||||
}: LayoutHeaderActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
|
||||
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
|
||||
<div className="hidden items-center gap-3 sm:flex md:gap-4">
|
||||
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||
<span className="h-6 w-px bg-[var(--app-border)]" />
|
||||
</div>
|
||||
<LayoutUserMenu
|
||||
user={user}
|
||||
isOpen={isProfileOpen}
|
||||
|
||||
@@ -324,7 +324,7 @@ export function PlayerControls({
|
||||
onMouseLeave={() => setControlsHovered(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-white">
|
||||
<div className="flex flex-wrap items-center gap-2 text-white sm:flex-nowrap sm:gap-2.5">
|
||||
{/* 上一集 */}
|
||||
<button
|
||||
onClick={onPrevEpisode}
|
||||
@@ -362,24 +362,25 @@ export function PlayerControls({
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={displayTime}
|
||||
onMouseDown={handleSeekStart}
|
||||
onTouchStart={handleSeekStart}
|
||||
onChange={(e) => handleSeekChange(Number(e.target.value))}
|
||||
onMouseUp={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
|
||||
onTouchEnd={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
|
||||
className="min-w-0 flex-1 cursor-pointer accent-rose-500"
|
||||
aria-label="播放进度"
|
||||
/>
|
||||
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums text-white/85">
|
||||
{formatTime(displayTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
<div className="order-2 flex min-w-0 basis-full items-center gap-2 sm:order-none sm:basis-auto sm:flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={displayTime}
|
||||
onMouseDown={handleSeekStart}
|
||||
onTouchStart={handleSeekStart}
|
||||
onChange={(e) => handleSeekChange(Number(e.target.value))}
|
||||
onMouseUp={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
|
||||
onTouchEnd={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
|
||||
className="min-w-0 flex-1 cursor-pointer accent-rose-500"
|
||||
aria-label="播放进度"
|
||||
/>
|
||||
<span className="shrink-0 font-mono text-[10px] tabular-nums text-white/85 sm:text-xs">
|
||||
{formatTime(displayTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pipSupported && (
|
||||
<button
|
||||
@@ -456,7 +457,7 @@ export function PlayerControls({
|
||||
title={hasPlaylist ? '选集列表' : '当前无更多剧集'}
|
||||
>
|
||||
<ListVideo size={15} />
|
||||
选集
|
||||
<span className="hidden sm:inline">选集</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -472,7 +473,7 @@ export function PlayerControls({
|
||||
title="弹幕设置"
|
||||
>
|
||||
<MessageSquareText size={15} />
|
||||
弹幕
|
||||
<span className="hidden sm:inline">弹幕</span>
|
||||
{danmakuEnabled && <span className="h-1.5 w-1.5 rounded-full bg-lime-400" />}
|
||||
</button>
|
||||
|
||||
@@ -490,7 +491,7 @@ export function PlayerControls({
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => changeVolume(Number(e.target.value))}
|
||||
className="w-16 accent-rose-500"
|
||||
className="hidden w-16 accent-rose-500 sm:block"
|
||||
aria-label="音量"
|
||||
/>
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ export function PlayerDanmakuPanel({
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 z-30 w-80 rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md"
|
||||
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-auto sm:w-80"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
|
||||
@@ -89,7 +89,7 @@ export function PlayerPlaylistPanel({
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 bottom-20 z-30 flex w-80 sm:w-96 flex-col rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md overflow-hidden"
|
||||
className="absolute inset-x-3 top-12 bottom-3 z-30 flex flex-col overflow-hidden rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-20 sm:w-96"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
||||
|
||||
@@ -22,17 +22,17 @@ export function openManageLibrariesDialog(): Promise<void> {
|
||||
function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
className="flex h-[calc(100dvh-1rem)] sm:h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl sm:rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h3 className="font-display text-base sm:text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -42,7 +42,7 @@ function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<AdminLibraryPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+3
-2
@@ -277,10 +277,11 @@ body {
|
||||
.badge-neutral { @apply badge bg-gray-100 text-gray-600 border border-gray-200/50; }
|
||||
|
||||
/* ── Page Headings ── */
|
||||
.page-heading { @apply font-display text-3xl font-extrabold tracking-tight text-gray-900; }
|
||||
.page-heading { @apply font-display text-2xl font-extrabold tracking-tight text-gray-900 sm:text-3xl; }
|
||||
.page-subtitle { @apply mt-1 text-sm text-gray-500; }
|
||||
|
||||
/* ── Data Tables Redesign ── */
|
||||
/* ── Mobile Data Tables ── */
|
||||
.table-scroll { @apply overflow-x-auto overscroll-x-contain; -webkit-overflow-scrolling: touch; }
|
||||
.data-table { @apply w-full text-left text-sm border-collapse; }
|
||||
.data-table thead { @apply border-b border-gray-200 bg-gray-50/70; }
|
||||
.data-table th { @apply px-5 py-4 text-xs font-bold uppercase tracking-wider text-gray-500 whitespace-nowrap; }
|
||||
|
||||
@@ -42,8 +42,8 @@ export function AdminUsersTable({
|
||||
onDeleteUser,
|
||||
}: AdminUsersTableProps) {
|
||||
return (
|
||||
<div className="glass-panel overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="glass-panel table-scroll">
|
||||
<table className="min-w-[900px] w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">用户名</th>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
Globe,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -107,7 +110,7 @@ function AccountDialog({
|
||||
disabled={!!existing}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">用户名</span>
|
||||
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
@@ -301,6 +304,89 @@ export function EmbyMountPage() {
|
||||
const [accountDialog, setAccountDialog] = useState<StrmAccount | null | 'new'>(null)
|
||||
const [mountDialogFor, setMountDialogFor] = useState<StrmAccount | null>(null)
|
||||
const [testingID, setTestingID] = useState<string | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
const [reordering, setReordering] = useState(false)
|
||||
|
||||
const applyReorder = async (nextMounts: EmbyMount[]) => {
|
||||
const prevMounts = mounts
|
||||
setMounts(nextMounts)
|
||||
setReordering(true)
|
||||
try {
|
||||
await embyAPI.reorderMounts(nextMounts.map((m) => m.id))
|
||||
toast.success('媒体库顺序已更新')
|
||||
} catch (err) {
|
||||
setMounts(prevMounts)
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setReordering(false)
|
||||
}
|
||||
}
|
||||
|
||||
const moveMount = (m: EmbyMount, direction: 'up' | 'down') => {
|
||||
const acctMounts = mounts.filter((item) => item.account_id === m.account_id)
|
||||
const idx = acctMounts.findIndex((item) => item.id === m.id)
|
||||
if (idx < 0) return
|
||||
if (direction === 'up' && idx === 0) return
|
||||
if (direction === 'down' && idx === acctMounts.length - 1) return
|
||||
|
||||
const targetIdx = direction === 'up' ? idx - 1 : idx + 1
|
||||
const newAcctMounts = [...acctMounts]
|
||||
const [moved] = newAcctMounts.splice(idx, 1)
|
||||
newAcctMounts.splice(targetIdx, 0, moved)
|
||||
|
||||
let acctPointer = 0
|
||||
const nextMounts = mounts.map((item) => {
|
||||
if (item.account_id === m.account_id) {
|
||||
return newAcctMounts[acctPointer++]
|
||||
}
|
||||
return item
|
||||
})
|
||||
applyReorder(nextMounts)
|
||||
}
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingId(id)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, overId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverId !== overId) {
|
||||
setDragOverId(overId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (overId: string, accountId: string) => {
|
||||
if (!draggingId || draggingId === overId) {
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
return
|
||||
}
|
||||
const acctMounts = mounts.filter((item) => item.account_id === accountId)
|
||||
const fromIdx = acctMounts.findIndex((item) => item.id === draggingId)
|
||||
const toIdx = acctMounts.findIndex((item) => item.id === overId)
|
||||
if (fromIdx < 0 || toIdx < 0) {
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
return
|
||||
}
|
||||
const newAcctMounts = [...acctMounts]
|
||||
const [moved] = newAcctMounts.splice(fromIdx, 1)
|
||||
newAcctMounts.splice(toIdx, 0, moved)
|
||||
|
||||
let acctPointer = 0
|
||||
const nextMounts = mounts.map((item) => {
|
||||
if (item.account_id === accountId) {
|
||||
return newAcctMounts[acctPointer++]
|
||||
}
|
||||
return item
|
||||
})
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
applyReorder(nextMounts)
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [accts, mts] = await Promise.all([
|
||||
@@ -454,8 +540,33 @@ export function EmbyMountPage() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{acctMounts.map((m) => (
|
||||
<div key={m.id} className={`flex items-center gap-3 rounded-xl border px-4 py-3 ${m.enabled ? 'border-sand-200 bg-gray-50/60' : 'border-gray-100 bg-gray-50 opacity-60'}`}>
|
||||
{acctMounts.map((m, index) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onDragOver={(e) => handleDragOver(e, m.id)}
|
||||
onDrop={() => handleDrop(m.id, acct.id)}
|
||||
className={`flex items-center gap-3 rounded-xl border px-3 py-3 transition-colors ${
|
||||
draggingId === m.id
|
||||
? 'border-dashed border-brand-400 bg-brand-50/20 opacity-50'
|
||||
: dragOverId === m.id
|
||||
? 'border-brand-400 bg-brand-50/30'
|
||||
: m.enabled
|
||||
? 'border-sand-200 bg-gray-50/60'
|
||||
: 'border-gray-100 bg-gray-50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, m.id)}
|
||||
onDragEnd={() => {
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
}}
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing text-sand-400 hover:text-ink-600 p-0.5"
|
||||
title="按住拖拽调整顺序"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<Tv size={16} className="shrink-0 text-brand-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-ink-600">
|
||||
@@ -466,6 +577,24 @@ export function EmbyMountPage() {
|
||||
{m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => moveMount(m, 'up')}
|
||||
disabled={index === 0 || reordering}
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-ink-100 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveMount(m, 'down')}
|
||||
disabled={index === acctMounts.length - 1 || reordering}
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-ink-100 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDown size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleMountProxy(m, !m.proxy_play)}
|
||||
disabled={!m.enabled}
|
||||
|
||||
@@ -26,8 +26,8 @@ export function FileEntriesTable({
|
||||
onChoose,
|
||||
}: FileEntriesTableProps) {
|
||||
return (
|
||||
<div className="glass-panel overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="glass-panel table-scroll">
|
||||
<table className="min-w-[700px] w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 py-2">
|
||||
|
||||
@@ -48,36 +48,44 @@ export function LibrariesHeader({
|
||||
onManageLibraries: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
|
||||
<p className="mt-1 text-sm text-ink-50">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">媒体库</h1>
|
||||
<p className="mt-1 text-xs text-ink-50 sm:text-sm">
|
||||
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{repairMsg && <span className="text-xs text-ink-50">{repairMsg}</span>}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||
<EpisodeArtworkToggle
|
||||
checked={repairEpisodeArtwork}
|
||||
onChange={onRepairEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-10"
|
||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
||||
>
|
||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||
</button>
|
||||
<Link to="/scraper/queue" className="btn-outline inline-flex items-center gap-1.5" title="查看正在进行的刮削任务与进度">
|
||||
<Link
|
||||
to="/scraper/queue"
|
||||
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
title="查看正在进行的刮削任务与进度"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>刮削队列</span>
|
||||
</Link>
|
||||
<button type="button" onClick={onManageLibraries} className="btn-outline">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageLibraries}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
>
|
||||
管理媒体库
|
||||
</button>
|
||||
</div>
|
||||
@@ -102,7 +110,7 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
||||
<p className="text-sm text-ink-50">按目录进入完整媒体库;下方每个目录也会直接展示最新内容。</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{previews.map((preview, index) => (
|
||||
<motion.div
|
||||
key={preview.library.id}
|
||||
@@ -142,9 +150,9 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="group flex overflow-hidden rounded-3xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
||||
className="group flex overflow-hidden rounded-2xl sm:rounded-3xl border border-sand-200 bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
||||
>
|
||||
<div className={`grid h-24 w-36 shrink-0 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<div className={`grid h-20 w-24 sm:h-24 sm:w-36 shrink-0 gap-1 overflow-hidden rounded-xl sm:rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
@@ -159,25 +167,32 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-2 flex h-full items-center justify-center text-brand-500">
|
||||
{TYPE_ICONS[library.type] ?? <FolderOpen size={34} />}
|
||||
{TYPE_ICONS[library.type] ?? <FolderOpen size={28} className="sm:h-8 sm:w-8" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between px-4 py-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between px-3 sm:px-4 py-0.5 sm:py-1">
|
||||
<div>
|
||||
<div className="mb-1 inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
<div className="mb-1 flex items-center justify-between gap-1.5">
|
||||
<span className="inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-sand-500 sm:hidden">
|
||||
{preview.total.toLocaleString()} 个条目
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="truncate font-display text-xl font-black text-ink-600 group-hover:text-brand-600">
|
||||
<h2 className="line-clamp-2 font-display text-sm font-bold leading-tight text-ink-600 group-hover:text-brand-600 sm:truncate sm:text-xl sm:font-black">
|
||||
{library.name}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-1 break-all text-xs text-ink-50" title={library.path}>
|
||||
<p className="mt-0.5 line-clamp-1 break-all text-[11px] text-ink-50 sm:mt-1 sm:text-xs" title={library.path}>
|
||||
{displayPath}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs font-bold">
|
||||
<span className="text-sand-600">{preview.total.toLocaleString()} 个条目</span>
|
||||
<span className="text-brand-600">浏览全部</span>
|
||||
<div className="mt-1 flex items-center justify-between text-xs font-bold sm:mt-0">
|
||||
<span className="hidden text-sand-600 sm:inline">{preview.total.toLocaleString()} 个条目</span>
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[11px] text-brand-600 sm:text-xs">
|
||||
浏览全部 <ArrowRight size={12} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -190,19 +205,19 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) {
|
||||
const displayPath = libraryDisplayPath(library.path)
|
||||
|
||||
return (
|
||||
<section className="rounded-[1.7rem] border border-sand-200 bg-white/75 p-4 shadow-card">
|
||||
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<section className="rounded-2xl sm:rounded-[1.7rem] border border-sand-200 bg-white/75 p-3.5 sm:p-4 shadow-card">
|
||||
<div className="mb-3 sm:mb-4 flex flex-wrap items-end justify-between gap-2 sm:gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 inline-flex items-center gap-2 rounded-full bg-brand-50 px-2.5 py-1 text-[11px] font-bold text-brand-700">
|
||||
<div className="mb-1 inline-flex items-center gap-1.5 sm:gap-2 rounded-full bg-brand-50 px-2 sm:px-2.5 py-0.5 sm:py-1 text-[10px] sm:text-[11px] font-bold text-brand-700">
|
||||
{TYPE_ICONS[library.type] ?? <LibraryIcon size={14} />}
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</div>
|
||||
<h2 className="truncate font-display text-2xl font-black text-ink-600">{library.name}</h2>
|
||||
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">{library.name}</h2>
|
||||
<p className="mt-1 line-clamp-1 break-all text-xs text-ink-50">
|
||||
<span title={library.path}>{displayPath}</span> · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部
|
||||
</p>
|
||||
</div>
|
||||
<Link to={`/library/${library.id}`} className="btn-outline shrink-0">
|
||||
<Link to={`/library/${library.id}`} className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm shrink-0">
|
||||
浏览全部
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
|
||||
@@ -46,13 +46,13 @@ export function LibraryPageHeader({
|
||||
const displayPath = library ? libraryDisplayPath(library.path) : ''
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">
|
||||
{library?.name ?? '媒体库'}
|
||||
<span className="text-sand-500"> ({itemCount})</span>
|
||||
</h1>
|
||||
{library && <p className="text-sm text-ink-50" title={library.path}>{library.type} · {displayPath}</p>}
|
||||
{library && <p className="text-xs text-ink-50 sm:text-sm" title={library.path}>{library.type} · {displayPath}</p>}
|
||||
{loadingAllText && <p className="mt-1 text-xs text-sand-500">{loadingAllText}</p>}
|
||||
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
|
||||
</div>
|
||||
@@ -69,18 +69,27 @@ export function LibraryPageHeader({
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-9"
|
||||
className="h-9 text-xs sm:text-sm"
|
||||
/>
|
||||
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
||||
<button
|
||||
onClick={onScan}
|
||||
disabled={scanning}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
>
|
||||
{scanning ? '扫描中…' : '扫描媒体库'}
|
||||
</button>
|
||||
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
|
||||
<button
|
||||
onClick={onScrape}
|
||||
disabled={scraping}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
title="对整个媒体库执行刮削元数据"
|
||||
>
|
||||
{scraping ? '刮削中…' : '整库刮削元数据'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline"
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||
>
|
||||
{repairing ? '修复中…' : '修复+重刮整库'}
|
||||
|
||||
@@ -83,8 +83,8 @@ export function LibraryStorageStats() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-display text-lg font-semibold text-ink-600">按媒体库</h3>
|
||||
<div className="glass-panel overflow-x-auto !p-3">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="glass-panel table-scroll !p-3">
|
||||
<table className="min-w-[500px] w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
|
||||
@@ -171,8 +171,8 @@ export function ManualOrganizePanel({
|
||||
|
||||
function ManualOrganizePreviewTable({ items }: { items: PreviewItem[] }) {
|
||||
return (
|
||||
<div className="max-h-72 overflow-auto rounded-xl border border-gray-200 bg-white/70">
|
||||
<table className="w-full text-left text-xs">
|
||||
<div className="table-scroll max-h-72 overflow-y-auto rounded-xl border border-gray-200 bg-white/70">
|
||||
<table className="min-w-[700px] w-full text-left text-xs">
|
||||
<thead className="sticky top-0 bg-white text-sand-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">动作</th>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ProfileFormModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="glass-panel max-h-[calc(100dvh-2rem)] w-full max-w-lg overflow-y-auto !p-4 sm:!p-6">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑 Profile' : '创建 Profile'}
|
||||
</h2>
|
||||
|
||||
@@ -88,7 +88,7 @@ export function ProfilePreferenceFields({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="首选字幕">
|
||||
<select
|
||||
className="input-base"
|
||||
|
||||
@@ -450,7 +450,7 @@ export function ScraperQueuePage() {
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-1.5 text-xs animate-in fade-in zoom-in-95">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-2 text-xs animate-in fade-in zoom-in-95">
|
||||
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
|
||||
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
|
||||
<button
|
||||
@@ -507,8 +507,8 @@ export function ScraperQueuePage() {
|
||||
: `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="table-scroll">
|
||||
<table className="min-w-[960px] w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200/80 bg-gray-50/50 text-[11px] font-bold uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-3 text-center">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -48,7 +48,7 @@ function DialogShell({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={
|
||||
'flex max-h-[88vh] w-full flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl ' +
|
||||
'flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl ' +
|
||||
(wide ? 'max-w-3xl' : 'max-w-lg')
|
||||
}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -67,7 +67,7 @@ function DialogShell({
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">{children}</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -192,7 +192,7 @@ export function StrmAccountDialog({
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
{/* 提供方选择 */}
|
||||
{!existing && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{PROVIDER_OPTIONS.map((option) => {
|
||||
const Icon =
|
||||
option.provider === 'cloud115'
|
||||
@@ -435,10 +435,20 @@ function Strm115AuthPanel({
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const filteredApps = (sources?.built_in ?? []).filter(
|
||||
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
(sources?.built_in ?? []).filter(
|
||||
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
|
||||
),
|
||||
[appKeyword, sources],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) {
|
||||
setAppID(filteredApps[0].app_id)
|
||||
}
|
||||
}, [appID, filteredApps])
|
||||
|
||||
const buildStartPayload = () => {
|
||||
switch (authSource) {
|
||||
case 'built_in_appid':
|
||||
@@ -453,6 +463,11 @@ function Strm115AuthPanel({
|
||||
}
|
||||
|
||||
const startAuth = async () => {
|
||||
if (authSource === 'built_in_appid' && filteredApps.length === 0) {
|
||||
toast.error('未找到匹配的官方应用')
|
||||
return
|
||||
}
|
||||
|
||||
setStarting(true)
|
||||
setAuthStatus('')
|
||||
try {
|
||||
@@ -552,6 +567,7 @@ function Strm115AuthPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{filteredApps.length === 0 && <p className="text-xs text-sand-500">未找到匹配的官方应用</p>}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
@@ -577,7 +593,12 @@ function Strm115AuthPanel({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={startAuth} disabled={starting} className="neon-button disabled:opacity-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAuth}
|
||||
disabled={starting || (authSource === 'built_in_appid' && filteredApps.length === 0)}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{starting ? <Loader2 size={16} className="animate-spin" /> : <QrCode size={16} />}
|
||||
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
|
||||
</button>
|
||||
|
||||
@@ -502,8 +502,8 @@ function RecordSection({ records, onDeleted }: { records: StrmSyncRecord[]; onDe
|
||||
{records.length === 0 ? (
|
||||
<p className="rounded-xl bg-gray-50 px-4 py-6 text-center text-sm text-sand-500">还没有同步记录</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="table-scroll">
|
||||
<table className="min-w-[980px] w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">时间</th>
|
||||
|
||||
@@ -470,7 +470,7 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
|
||||
|
||||
{/* Selected Batch Toolbar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-1.5 text-xs animate-in fade-in zoom-in-95">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-2 text-xs animate-in fade-in zoom-in-95">
|
||||
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
|
||||
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
|
||||
<button
|
||||
@@ -529,8 +529,8 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
|
||||
: `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="table-scroll">
|
||||
<table className="min-w-[900px] w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200/80 bg-gray-50/50 text-[11px] font-bold uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-3 text-center">
|
||||
|
||||
@@ -97,7 +97,7 @@ export function WatchHistoryPage() {
|
||||
return (
|
||||
<div
|
||||
key={h.id}
|
||||
className="glass-panel flex items-center gap-4 !p-3"
|
||||
className="glass-panel flex flex-col gap-3 !p-3 sm:flex-row sm:items-center sm:gap-4"
|
||||
>
|
||||
<div className="h-16 w-12 shrink-0 overflow-hidden rounded-lg bg-surface-900">
|
||||
{m.poster_url ? (
|
||||
@@ -109,14 +109,14 @@ export function WatchHistoryPage() {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Link
|
||||
to={`/media/${m.id}`}
|
||||
className="font-medium text-ink-600 transition hover:text-brand-500"
|
||||
className="block truncate font-medium text-ink-600 transition hover:text-brand-500"
|
||||
>
|
||||
{m.title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-xs text-ink-50">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-50">
|
||||
<span>{fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)}</span>
|
||||
<span>{new Date(h.watched_at).toLocaleString()}</span>
|
||||
{h.completed && (
|
||||
@@ -132,7 +132,7 @@ export function WatchHistoryPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:shrink-0">
|
||||
<Link
|
||||
to={`/play/${m.id}`}
|
||||
className="neon-button !px-3 !py-1 !text-xs"
|
||||
|
||||
@@ -99,6 +99,14 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
hint: 'NAS 建议 1;用于扫描、整理洗版和手动探测,避免同时启动多个 ffprobe 进程',
|
||||
defaultValue: '1',
|
||||
},
|
||||
{
|
||||
key: 'cache.images_max_size_mb',
|
||||
label: '图片缓存上限 (MB)',
|
||||
type: 'number',
|
||||
hint: '海报、剧照等图片代理缓存目录 (cache/images) 的最大占用空间。超过上限时自动按修改时间清理最旧的文件。设为 0 表示不限制,默认 500MB',
|
||||
defaultValue: '500',
|
||||
placeholder: '500',
|
||||
},
|
||||
{
|
||||
key: 'https.enabled',
|
||||
label: '启用 HTTPS',
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface EmbyMount {
|
||||
remote_view_name: string
|
||||
collection_type: string
|
||||
name: string
|
||||
sort_order?: number
|
||||
proxy_play: boolean
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
|
||||
Reference in New Issue
Block a user