添加新功能,完善项目

This commit is contained in:
truewhile
2026-09-22 16:22:29 +08:00
parent 47899284a5
commit b5ce4646cf
85 changed files with 7103 additions and 982 deletions
+4
View File
@@ -8,6 +8,10 @@ import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
// Serialize arrays as repeated params without brackets: genre=a&genre=b
// instead of axios's default genre[]=a&genre[]=b which Gin's QueryArray
// does not recognise.
paramsSerializer: { indexes: null },
})
export const LONG_REQUEST_TIMEOUT = 120_000
+49
View File
@@ -0,0 +1,49 @@
import { api } from './client'
// 设备管理客户端。
//
// 普通用户走 /me/devices(服务端按会话身份过滤,前端无法越权指定他人);
// 管理员走 /admin/users/:id/devices 代管。
export interface DeviceInfo {
id: string
device_id: string
device_name?: string
client?: string
last_ip?: string
last_seen_at?: string
last_play_at?: string
kicked: boolean
online: boolean
playing: boolean
warnings: number
}
interface DeviceListResponse {
devices: DeviceInfo[] | null
}
export const devicesAPI = {
listMine: () =>
api.get<DeviceListResponse>('/me/devices').then((r) => r.data.devices ?? []),
kickMine: (deviceId: string) =>
api.post(`/me/devices/${encodeURIComponent(deviceId)}/kick`).then((r) => r.data),
kickAllMine: () => api.post('/me/devices/kick-all').then((r) => r.data),
listForUser: (userId: string) =>
api
.get<DeviceListResponse>(`/admin/users/${encodeURIComponent(userId)}/devices`)
.then((r) => r.data.devices ?? []),
kickForUser: (userId: string, deviceId: string) =>
api
.post(
`/admin/users/${encodeURIComponent(userId)}/devices/${encodeURIComponent(deviceId)}/kick`,
)
.then((r) => r.data),
kickAllForUser: (userId: string) =>
api.post(`/admin/users/${encodeURIComponent(userId)}/devices/kick-all`).then((r) => r.data),
}
+46 -3
View File
@@ -1,6 +1,18 @@
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
import type { Library, LibraryRoot, Media, PlaybackInfo, ScanResult } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import {
EMPTY_LIBRARY_FILTERS,
toFilterQuery,
type LibraryFilterParams,
} from '../utils/libraryFilters'
/** 媒体库筛选面板的可选项。 */
export interface LibraryFacets {
genres: Array<{ name: string; count: number }>
year_min: number
year_max: number
}
export interface MediaPage {
items: Media[]
@@ -165,7 +177,12 @@ export const libraryAPI = {
id: string,
page = 1,
pageSize = 50,
options?: { groupVersions?: boolean; sort?: string; order?: 'asc' | 'desc' },
options?: {
groupVersions?: boolean
sort?: string
order?: 'asc' | 'desc'
filters?: LibraryFilterParams
},
) =>
api
.get<MediaPage>(`/libraries/${id}/media`, {
@@ -175,20 +192,46 @@ export const libraryAPI = {
group_versions: options?.groupVersions === false ? 0 : undefined,
sort: options?.sort,
order: options?.order,
...toFilterQuery(options?.filters ?? EMPTY_LIBRARY_FILTERS),
},
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
/** 媒体库筛选面板的可选项:类型清单与年份区间。 */
facets: (id: string) =>
api
.get<LibraryFacets>(`/libraries/${id}/facets`, { timeout: LONG_REQUEST_TIMEOUT })
.then((r) => r.data),
/** 「随便看看」:按同一套筛选条件随机取一条,无命中时抛 404。 */
random: (id: string, filters?: LibraryFilterParams) =>
api
.get<Media>(`/libraries/${id}/random`, {
params: toFilterQuery(filters ?? EMPTY_LIBRARY_FILTERS),
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
listSeries: (
id: string,
page = 1,
pageSize = 500,
options?: { sort?: string; order?: 'asc' | 'desc' },
options?: {
sort?: string
order?: 'asc' | 'desc'
filters?: LibraryFilterParams
},
) =>
api
.get<SeriesPage>(`/libraries/${id}/series`, {
params: { page, page_size: pageSize, sort: options?.sort, order: options?.order },
params: {
page,
page_size: pageSize,
sort: options?.sort,
order: options?.order,
...toFilterQuery(options?.filters ?? EMPTY_LIBRARY_FILTERS),
},
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
+23
View File
@@ -0,0 +1,23 @@
import { api } from './client'
// 统计相关客户端。
//
// 个人统计走 /watch-history/stats(服务端按会话身份取数);全站排行走
// /stats/top-users,服务端仅允许管理员访问。
export interface TopUserEntry {
user_id: string
username: string
plays: number
}
interface TopUsersResponse {
items: TopUserEntry[] | null
}
export const statsAPI = {
topUsers: (limit = 5) =>
api
.get<TopUsersResponse>(`/stats/top-users?limit=${limit}`)
.then((r) => r.data.items ?? []),
}
+27
View File
@@ -0,0 +1,27 @@
import { api } from './client'
// Telegram 通知绑定客户端。
//
// 绑定流程:startBind() 拿一次性码 → 用户在 Bot 里发 /bind <码> → 轮询 status()
// 直到 bound 变 true。服务端不接收用户手填的 chat id。
export interface TelegramStatus {
bound: boolean
chat_id_masked?: string
}
export interface TelegramBindCode {
code: string
expires_in_seconds: number
}
export const telegramAPI = {
status: () => api.get<TelegramStatus>('/me/telegram').then((r) => r.data),
startBind: () =>
api.post<TelegramBindCode>('/me/telegram/bind-code').then((r) => r.data),
unbind: () => api.delete('/me/telegram').then((r) => r.data),
test: () => api.post<{ success: boolean; error?: string }>('/admin/telegram/test').then((r) => r.data),
}
+4
View File
@@ -14,6 +14,8 @@ const MediaDetailPageLoader = () =>
const PlayerPageLoader = () => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage }))
const WatchHistoryPageLoader = () =>
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage }))
const WatchStatsPageLoader = () =>
import('./pages/WatchStatsPage').then((m) => ({ default: m.WatchStatsPage }))
const HomePage = lazy(HomePageLoader)
const LibraryPage = lazy(LibraryPageLoader)
@@ -30,6 +32,7 @@ const FileManagerPage = lazy(() =>
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
)
const WatchHistoryPage = lazy(WatchHistoryPageLoader)
const WatchStatsPage = lazy(WatchStatsPageLoader)
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
const ProfileManagementPage = lazy(() =>
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
@@ -105,6 +108,7 @@ export const appRoutes: AppRoute[] = [
{ path: 'profile', element: <ProfilePage /> },
{ path: 'dlna', element: <DlnaPage /> },
{ path: 'history', element: <WatchHistoryPage /> },
{ path: 'history/stats', element: <WatchStatsPage /> },
{ path: 'poster-wall', element: <PosterWallPage /> },
{ path: 'play-profiles', element: <ProfileManagementPage /> },
{ path: 'api-configs', element: <Navigate to="/settings?group=api-configs" replace /> },
+164
View File
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { Loader2, LogOut, MonitorSmartphone, RefreshCw, Smartphone, Tv } from 'lucide-react'
import { devicesAPI, type DeviceInfo } from '../api/devices'
// MyDevicesPanel 让用户自己看清「谁在用我的账号」,并能把可疑终端踢下线。
//
// 踢下线后该终端必须重新登录;这正是防共享策略生效时的自救路径。
export function MyDevicesPanel() {
const [devices, setDevices] = useState<DeviceInfo[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const [kickingAll, setKickingAll] = useState(false)
const mountedRef = useRef(true)
const load = useCallback(async () => {
try {
const rows = await devicesAPI.listMine()
if (mountedRef.current) setDevices(rows)
} catch {
if (mountedRef.current) toast.error('设备列表加载失败')
} finally {
if (mountedRef.current) setLoading(false)
}
}, [])
useEffect(() => {
mountedRef.current = true
void load()
return () => {
mountedRef.current = false
}
}, [load])
const kick = async (device: DeviceInfo) => {
const id = device.device_id || device.id
setBusyId(id)
try {
await devicesAPI.kickMine(id)
toast.success('该设备已下线,需要重新登录')
await load()
} catch {
toast.error('踢下线失败')
} finally {
setBusyId(null)
}
}
const kickAll = async () => {
setKickingAll(true)
try {
await devicesAPI.kickAllMine()
toast.success('全部设备已下线')
await load()
} catch {
toast.error('操作失败')
} finally {
setKickingAll(false)
}
}
return (
<section className="glass-panel space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="flex items-center gap-2 font-display text-lg font-semibold text-ink-600">
<MonitorSmartphone size={20} className="text-brand-500" />
我的设备
</h2>
<p className="mt-1 text-sm text-ink-50">
网页、手机与电视端登录过的终端都会出现在这里。发现陌生设备时请踢下线并修改密码。
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => void load()}
className="rounded-xl border border-[var(--app-border)] p-2 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="刷新"
>
<RefreshCw size={16} />
</button>
<button
type="button"
onClick={() => void kickAll()}
disabled={kickingAll || devices.length === 0}
className="inline-flex items-center gap-2 rounded-xl border border-rose-300 px-3 py-2 text-sm font-semibold text-rose-500 transition-colors hover:bg-rose-50 disabled:opacity-40"
>
{kickingAll ? <Loader2 size={16} className="animate-spin" /> : <LogOut size={16} />}
全部下线
</button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-8 text-ink-50">
<Loader2 className="animate-spin" />
</div>
) : devices.length === 0 ? (
<p className="rounded-2xl border border-dashed border-[var(--app-border)] p-6 text-center text-sm text-[var(--app-muted)]">
暂无设备记录。使用 Emby / Infuse 等客户端登录后会自动出现在这里。
</p>
) : (
<ul className="space-y-2">
{devices.map((device) => (
<li
key={device.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--app-text)]">
{deviceIcon(device)}
<span className="truncate">
{device.device_name || device.client || '未知设备'}
</span>
{device.playing && (
<span className="rounded-md bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-bold text-emerald-500">
播放中
</span>
)}
{device.kicked && (
<span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-bold text-rose-500">
已下线
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap gap-x-3 text-xs text-[var(--app-muted)]">
{device.client && <span>{device.client}</span>}
{device.last_ip && <span className="font-mono">{device.last_ip}</span>}
{device.last_seen_at && (
<span>最近活跃 {formatTime(device.last_seen_at)}</span>
)}
</div>
</div>
<button
type="button"
onClick={() => void kick(device)}
disabled={busyId === (device.device_id || device.id)}
className="shrink-0 rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-semibold text-[var(--app-muted)] transition-colors hover:border-rose-300 hover:text-rose-500 disabled:opacity-40"
>
{busyId === (device.device_id || device.id) ? '下线中…' : '踢下线'}
</button>
</li>
))}
</ul>
)}
</section>
)
}
function deviceIcon(device: DeviceInfo) {
const client = (device.client || '').toLowerCase()
if (client.includes('tv') || client.includes('infuse') || client.includes('emby')) {
return <Tv size={16} className="shrink-0 text-brand-500" />
}
return <Smartphone size={16} className="shrink-0 text-brand-500" />
}
function formatTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
+189
View File
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { Check, Copy, Loader2, Send, Unlink } from 'lucide-react'
import { telegramAPI, type TelegramStatus } from '../api/telegram'
// TelegramBindPanel 完成「网页生成一次性码 → Bot 内 /bind」的绑定流程。
//
// 绑定成功后,新设备登录、被踢下线、账号即将到期等事件都会推到用户的
// Telegram,避免这些动作变成静默行为。
const POLL_INTERVAL_MS = 3000
export function TelegramBindPanel() {
const [status, setStatus] = useState<TelegramStatus | null>(null)
const [code, setCode] = useState('')
const [expiresIn, setExpiresIn] = useState(0)
const [starting, setStarting] = useState(false)
const [unbinding, setUnbinding] = useState(false)
const [copied, setCopied] = useState(false)
const mountedRef = useRef(true)
const refreshStatus = useCallback(async () => {
try {
const next = await telegramAPI.status()
if (mountedRef.current) setStatus(next)
} catch {
if (mountedRef.current) setStatus({ bound: false })
}
}, [])
useEffect(() => {
mountedRef.current = true
void refreshStatus()
return () => {
mountedRef.current = false
}
}, [refreshStatus])
// 倒计时归零即作废本地展示的码,避免用户拿着过期码反复尝试。
useEffect(() => {
if (expiresIn <= 0) return
const timer = setInterval(() => {
setExpiresIn((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [expiresIn])
// 等待用户在 Bot 里完成绑定:只在存在待消费的码时轮询。
useEffect(() => {
if (!code || expiresIn <= 0 || status?.bound) return
const timer = setInterval(() => {
void refreshStatus()
}, POLL_INTERVAL_MS)
return () => clearInterval(timer)
}, [code, expiresIn, status?.bound, refreshStatus])
useEffect(() => {
if (status?.bound && code) {
setCode('')
setExpiresIn(0)
toast.success('Telegram 绑定成功')
}
}, [status?.bound, code])
const startBind = async () => {
if (starting) return
setStarting(true)
setCopied(false)
try {
const res = await telegramAPI.startBind()
setCode(res.code)
setExpiresIn(res.expires_in_seconds)
} catch {
toast.error('生成绑定码失败,请检查服务端是否可用')
} finally {
setStarting(false)
}
}
const copyCode = async () => {
if (!code) return
try {
await navigator.clipboard.writeText(`/bind ${code}`)
setCopied(true)
toast.success('已复制 /bind 指令')
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('复制失败,请手动输入')
}
}
const unbind = async () => {
setUnbinding(true)
try {
await telegramAPI.unbind()
setCode('')
setExpiresIn(0)
await refreshStatus()
toast.success('已解除绑定')
} catch {
toast.error('解除绑定失败')
} finally {
setUnbinding(false)
}
}
const bound = Boolean(status?.bound)
return (
<section className="glass-panel space-y-4">
<div>
<h2 className="flex items-center gap-2 font-display text-lg font-semibold text-ink-600">
<Send size={20} className="text-brand-500" />
Telegram 通知
</h2>
<p className="mt-1 text-sm text-ink-50">
绑定后,新设备登录、设备被踢下线、账号即将到期等事件会推送到你的 Telegram。
未绑定时这些事件只记录在服务端日志中。
</p>
</div>
{bound ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-emerald-200 bg-emerald-50/40 p-4">
<div className="text-sm text-ink-600">
<span className="font-semibold">已绑定</span>
{status?.chat_id_masked && (
<span className="ml-2 font-mono text-xs text-ink-50">
{status.chat_id_masked}
</span>
)}
</div>
<button
type="button"
onClick={() => void unbind()}
disabled={unbinding}
className="inline-flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-3 py-2 text-sm font-semibold text-[var(--app-muted)] transition-colors hover:border-rose-300 hover:text-rose-500 disabled:opacity-40"
>
{unbinding ? <Loader2 size={16} className="animate-spin" /> : <Unlink size={16} />}
解除绑定
</button>
</div>
) : (
<div className="space-y-3">
<ol className="list-decimal space-y-1 pl-5 text-sm text-ink-50">
<li>点击下方按钮生成 6 位绑定码(5 分钟内有效)。</li>
<li>在 Telegram 里打开 MeBox Bot,发送 <code className="font-mono">/bind 绑定码</code>。</li>
<li>收到「绑定成功」回复即完成,本页会自动刷新状态。</li>
</ol>
{code && expiresIn > 0 ? (
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-brand-200 bg-brand-50/40 p-4">
<span className="select-all font-mono text-2xl font-black tracking-widest text-brand-600">
{code}
</span>
<button
type="button"
onClick={() => void copyCode()}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-100 px-3 py-1.5 text-xs font-semibold text-brand-700 transition-colors hover:bg-brand-200"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? '已复制' : '复制 /bind 指令'}
</button>
<span className="text-xs text-ink-50">
剩余 {Math.floor(expiresIn / 60)}:
{(expiresIn % 60).toString().padStart(2, '0')}
</span>
</div>
) : (
<button
type="button"
onClick={() => void startBind()}
disabled={starting}
className="neon-button"
>
{starting ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
生成绑定码
</button>
)}
</div>
)}
</section>
)
}
+4
View File
@@ -127,6 +127,10 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
if (pathname.startsWith('/library/')) {
return { to: '/libraries', label: '媒体库' }
}
// 统计页从观看历史进入,返回链也回到那里,而不是一路跳回首页。
if (pathname === '/history/stats') {
return { to: '/history', label: '观看历史' }
}
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
return { to: '/', label: '首页' }
}
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { Activity } from 'lucide-react'
import { statsAPI, type TopUserEntry } from '../api/stats'
import type { User } from '../types'
// ActiveUsersStrip 在用户管理页顶部展示播放次数最多的几个账号。
//
// 只做展示与定位,不做筛选:管理员关心的是「谁在用」,点一下滚动到该用户
// 行即可继续原来的操作。非管理员拿到 403 时静默隐藏,避免出现红色报错条。
export function ActiveUsersStrip({ users }: { users: User[] }) {
const [top, setTop] = useState<TopUserEntry[]>([])
useEffect(() => {
let cancelled = false
statsAPI
.topUsers(5)
.then((rows) => {
if (!cancelled) setTop(rows)
})
.catch(() => {
if (!cancelled) setTop([])
})
return () => {
cancelled = true
}
}, [])
if (top.length === 0) return null
const knownIDs = new Set(users.map((u) => u.id))
const focusUser = (userID: string) => {
const row = document.getElementById(`admin-user-${userID}`)
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
return (
<section className="glass-panel">
<div className="flex flex-wrap items-center gap-3">
<span className="flex items-center gap-1.5 text-xs font-semibold text-ink-100">
<Activity size={14} className="text-brand-500" />
活跃用户 Top {top.length}
</span>
<div className="flex flex-wrap items-center gap-2">
{top.map((entry) => {
// 已删除的账号仍会出现在历史统计里,此时不可点击。
const exists = knownIDs.has(entry.user_id)
return (
<button
key={entry.user_id}
type="button"
disabled={!exists}
onClick={() => focusUser(entry.user_id)}
title={exists ? '定位到该用户' : '该账号已不存在'}
className="rounded-xl border border-sand-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-ink-600 transition-colors enabled:hover:border-brand-300 enabled:hover:text-brand-600 disabled:opacity-50"
>
{entry.username || entry.user_id}
<span className="ml-1.5 font-mono text-[10px] text-sand-500">
{entry.plays} 次
</span>
</button>
)
})}
</div>
</div>
</section>
)
}
+195
View File
@@ -0,0 +1,195 @@
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Loader2, LogOut, MonitorSmartphone, RefreshCw, X } from 'lucide-react'
import { devicesAPI, type DeviceInfo } from '../api/devices'
import type { User } from '../types'
// AdminUserDevicesDialog 让管理员代管某个用户的登录设备。
//
// 使用场景:用户忘记密码/账号疑似外借/需要清掉旧电视端会话时,管理员不必
// 让用户自己操作,也不必进数据库。
type AdminUserDevicesDialogProps = {
user: User | null
isOpen: boolean
onClose: () => void
}
export function AdminUserDevicesDialog({ user, isOpen, onClose }: AdminUserDevicesDialogProps) {
const [devices, setDevices] = useState<DeviceInfo[]>([])
const [loading, setLoading] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
const [kickingAll, setKickingAll] = useState(false)
const load = useCallback(async (userId: string) => {
setLoading(true)
try {
const rows = await devicesAPI.listForUser(userId)
setDevices(rows)
} catch {
toast.error('加载设备列表失败')
setDevices([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!isOpen || !user) return
setDevices([])
void load(user.id)
}, [isOpen, user, load])
if (!isOpen || !user) return null
const kick = async (device: DeviceInfo) => {
const id = device.device_id || device.id
setBusyId(id)
try {
await devicesAPI.kickForUser(user.id, id)
toast.success('设备已下线')
await load(user.id)
} catch {
toast.error('踢下线失败')
} finally {
setBusyId(null)
}
}
const kickAll = async () => {
setKickingAll(true)
try {
await devicesAPI.kickAllForUser(user.id)
toast.success(`已下线 ${user.username} 的全部设备`)
await load(user.id)
} catch {
toast.error('操作失败')
} finally {
setKickingAll(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-2xl overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-sand-100 p-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
<MonitorSmartphone size={20} />
</div>
<div>
<h3 className="font-display text-lg font-bold text-ink-600">登录设备管理</h3>
<p className="text-xs text-sand-500">
用户:<span className="font-semibold text-ink-600">{user.username}</span>
<span className="ml-2 text-sand-400">
共 {devices.length} 台
</span>
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-sand-500 transition-colors hover:bg-sand-100 hover:text-ink-600"
>
<X size={18} />
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto p-5">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-xs text-sand-500">
<Loader2 size={16} className="animate-spin text-brand-500" />
正在加载设备…
</div>
) : devices.length === 0 ? (
<div className="rounded-2xl bg-sand-50/50 py-10 text-center text-xs text-sand-500">
该用户暂无设备记录
</div>
) : (
<ul className="space-y-2">
{devices.map((device) => (
<li
key={device.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-sand-200 p-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-ink-600">
<span className="truncate">
{device.device_name || device.client || '未知设备'}
</span>
{device.online && (
<span className="rounded-md bg-emerald-50 px-1.5 py-0.5 text-[10px] font-bold text-emerald-600">
在线
</span>
)}
{device.playing && (
<span className="rounded-md bg-brand-50 px-1.5 py-0.5 text-[10px] font-bold text-brand-600">
播放中
</span>
)}
{device.kicked && (
<span className="rounded-md bg-rose-50 px-1.5 py-0.5 text-[10px] font-bold text-rose-600">
已下线
</span>
)}
{device.warnings > 0 && (
<span className="rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold text-amber-600">
警告 {device.warnings}
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap gap-x-3 text-[11px] text-sand-500">
{device.client && <span>{device.client}</span>}
{device.last_ip && <span className="font-mono">{device.last_ip}</span>}
{device.last_seen_at && <span>最近活跃 {formatTime(device.last_seen_at)}</span>}
</div>
</div>
<button
type="button"
onClick={() => void kick(device)}
disabled={busyId === (device.device_id || device.id)}
className="shrink-0 rounded-xl border border-sand-200 px-3 py-1.5 text-[11px] font-semibold text-sand-500 transition-colors hover:border-rose-300 hover:text-rose-500 disabled:opacity-40"
>
{busyId === (device.device_id || device.id) ? '下线中…' : '踢下线'}
</button>
</li>
))}
</ul>
)}
</div>
<div className="flex items-center justify-between gap-2.5 border-t border-sand-100 bg-sand-50/60 px-5 py-3.5">
<button
type="button"
onClick={() => void load(user.id)}
className="inline-flex items-center gap-1.5 rounded-xl border border-sand-200 bg-white px-3 py-2 text-xs font-semibold text-ink-600 transition-colors hover:bg-sand-100"
>
<RefreshCw size={13} />
刷新
</button>
<button
type="button"
disabled={kickingAll || devices.length === 0}
onClick={() => void kickAll()}
className="inline-flex items-center gap-1.5 rounded-xl border border-rose-300 bg-white px-3 py-2 text-xs font-semibold text-rose-500 transition-colors hover:bg-rose-50 disabled:opacity-40"
>
{kickingAll ? <Loader2 size={13} className="animate-spin" /> : <LogOut size={13} />}
全部下线
</button>
</div>
</div>
</div>
)
}
function formatTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
+12
View File
@@ -6,8 +6,10 @@ import type { User } from '../types'
import { confirmAction } from '../components/confirmAction'
import { requestPassword } from '../components/requestPassword'
import { AdminUserLibrariesDialog } from '../components/AdminUserLibrariesDialog'
import { AdminUserDevicesDialog } from './AdminUserDevicesDialog'
import { AdminUsersForm } from './AdminUsersForm'
import { AdminUsersTable } from './AdminUsersTable'
import { ActiveUsersStrip } from './ActiveUsersStrip'
const DEFAULT_MAX_USERS = 20
@@ -23,6 +25,7 @@ export function AdminUsersPanel() {
const [editingUsername, setEditingUsername] = useState('')
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState<User | null>(null)
const [managingDevicesUser, setManagingDevicesUser] = useState<User | null>(null)
const refresh = async () => {
const data = await adminAPI.listUsers()
@@ -187,6 +190,8 @@ export function AdminUsersPanel() {
onSubmit={handleCreate}
/>
<ActiveUsersStrip users={users} />
<AdminUsersTable
users={users}
editingID={editingID}
@@ -198,6 +203,7 @@ export function AdminUsersPanel() {
onStartEdit={startEdit}
onResetPassword={resetPassword}
onConfigureLibraries={(u) => setConfiguringLibrariesUser(u)}
onManageDevices={(u) => setManagingDevicesUser(u)}
onToggleStatus={toggleStatus}
onDeleteUser={deleteUser}
/>
@@ -211,6 +217,12 @@ export function AdminUsersPanel() {
await refresh()
}}
/>
<AdminUserDevicesDialog
user={managingDevicesUser}
isOpen={Boolean(managingDevicesUser)}
onClose={() => setManagingDevicesUser(null)}
/>
</div>
)
}
+11 -1
View File
@@ -2,6 +2,7 @@ import {
FolderLock,
KeyRound,
Loader2,
MonitorSmartphone,
Pencil,
ShieldCheck,
Trash2,
@@ -23,6 +24,7 @@ type AdminUsersTableProps = {
onStartEdit: (user: User) => void
onResetPassword: (user: User) => void
onConfigureLibraries: (user: User) => void
onManageDevices: (user: User) => void
onToggleStatus: (user: User) => void
onDeleteUser: (user: User) => void
}
@@ -38,6 +40,7 @@ export function AdminUsersTable({
onStartEdit,
onResetPassword,
onConfigureLibraries,
onManageDevices,
onToggleStatus,
onDeleteUser,
}: AdminUsersTableProps) {
@@ -57,7 +60,7 @@ export function AdminUsersTable({
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-gray-200">
<tr key={u.id} id={`admin-user-${u.id}`} className="border-t border-gray-200">
<td className="py-2 text-ink-600">
{editingID === u.id ? (
<input
@@ -147,6 +150,13 @@ export function AdminUsersTable({
>
{resettingPasswordID === u.id ? <Loader2 size={12} className="animate-spin" /> : <KeyRound size={12} />}
</button>
<button
className="rounded-lg border border-sky-400/40 px-2 py-1 text-xs text-sky-500 hover:bg-sky-400/10"
title="登录设备管理"
onClick={() => onManageDevices(u)}
>
<MonitorSmartphone size={12} />
</button>
<button
className={
'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' +
+237
View File
@@ -0,0 +1,237 @@
import { useEffect, useState } from 'react'
import { ChevronDown, Dices, Filter, Loader2, X } from 'lucide-react'
import type { LibraryFacets } from '../api/library'
import {
hasActiveFilters,
normalizeYearRange,
type LibraryFilterParams,
} from '../utils/libraryFilters'
type LibraryFilterBarProps = {
facets: LibraryFacets | null
loadingFacets: boolean
filters: LibraryFilterParams
onApply: (filters: LibraryFilterParams) => void
onReset: () => void
/** 「随便看看」:接收面板当前有效草稿(已与 URL 保持同步后)作为筛选条件。 */
onRandom: (filters: LibraryFilterParams) => void
randomBusy: boolean
}
// LibraryFilterBar 提供库内筛选与「随便看看」。
//
// 交互约定:面板内的改动先落到本地 draft,点「应用」才写回 URL 并重新查询。
// 类型多选是高频操作,逐个触发请求会让大库反复重扫,因此不做即时生效。
export function LibraryFilterBar({
facets,
loadingFacets,
filters,
onApply,
onReset,
onRandom,
randomBusy,
}: LibraryFilterBarProps) {
const [open, setOpen] = useState(() => hasActiveFilters(filters))
const [draft, setDraft] = useState<LibraryFilterParams>(filters)
const [yearMin, setYearMin] = useState(filters.year_min ? String(filters.year_min) : '')
const [yearMax, setYearMax] = useState(filters.year_max ? String(filters.year_max) : '')
const [ratingMin, setRatingMin] = useState(filters.rating_min ? String(filters.rating_min) : '')
// URL 变化(例如浏览器返回)时把草稿同步回来,避免面板显示与结果不一致。
useEffect(() => {
setDraft(filters)
setYearMin(filters.year_min ? String(filters.year_min) : '')
setYearMax(filters.year_max ? String(filters.year_max) : '')
setRatingMin(filters.rating_min ? String(filters.rating_min) : '')
}, [filters])
const active = hasActiveFilters(filters)
const toggleGenre = (name: string) => {
setDraft((prev) => {
const exists = prev.genres.includes(name)
return {
...prev,
genres: exists ? prev.genres.filter((item) => item !== name) : [...prev.genres, name],
}
})
}
/** 把当前草稿(含年份/评分输入框)合并成一个完整的 LibraryFilterParams。 */
const collectDraft = (): LibraryFilterParams => {
const range = normalizeYearRange(
yearMin ? Number(yearMin) : undefined,
yearMax ? Number(yearMax) : undefined,
)
const rating = ratingMin ? Number(ratingMin) : undefined
return {
genres: draft.genres,
...range,
rating_min: Number.isFinite(rating) && (rating ?? 0) > 0 ? rating : undefined,
unwatched: draft.unwatched,
}
}
const apply = () => {
onApply(collectDraft())
}
/** 随便看看:若草稿与已应用的筛选不同,先把草稿写入 URL,再用草稿发起随机。
* 这样「应用筛选」与「随便看看」之间的不一致窗口缩短到零。 */
const handleRandom = () => {
const effective = collectDraft()
// 简单比对:序列化为 JSON 后对比,草稿改动均可检测到。
const draftChanged = JSON.stringify(effective) !== JSON.stringify(filters)
if (draftChanged) {
onApply(effective)
}
onRandom(effective)
}
const reset = () => {
setDraft({ genres: [] })
setYearMin('')
setYearMax('')
setRatingMin('')
onReset()
}
return (
<section className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]">
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="inline-flex items-center gap-2 text-sm font-semibold text-[var(--app-text)]"
>
<Filter size={15} className="text-brand-500" />
筛选
{active && (
<span className="rounded-md bg-brand-500/15 px-1.5 py-0.5 text-[10px] font-bold text-brand-500">
已启用
</span>
)}
<ChevronDown
size={14}
className={`text-[var(--app-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
<div className="flex items-center gap-2">
{active && (
<button
type="button"
onClick={reset}
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-muted)] transition-colors hover:text-[var(--app-text)]"
>
<X size={12} />
清除筛选
</button>
)}
<button
type="button"
onClick={handleRandom}
disabled={randomBusy}
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-3 py-1.5 text-xs font-bold text-[var(--app-brand-text)] transition-opacity disabled:opacity-50"
title="按当前筛选条件随机播放一条"
>
{randomBusy ? <Loader2 size={13} className="animate-spin" /> : <Dices size={13} />}
随便看看
</button>
</div>
</div>
{open && (
<div className="space-y-4 border-t border-[var(--app-border)] px-3 py-3">
<div className="space-y-2">
<div className="text-xs font-semibold text-[var(--app-muted)]">类型</div>
{loadingFacets ? (
<div className="flex items-center gap-2 py-2 text-xs text-[var(--app-muted)]">
<Loader2 size={14} className="animate-spin" />
正在加载类型…
</div>
) : !facets || facets.genres.length === 0 ? (
<div className="py-2 text-xs text-[var(--app-muted)]">
这个媒体库还没有刮削出类型信息
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{facets.genres.map((genre) => {
const selected = draft.genres.includes(genre.name)
return (
<button
key={genre.name}
type="button"
onClick={() => toggleGenre(genre.name)}
className={
'rounded-lg border px-2 py-1 text-xs transition-colors ' +
(selected
? 'border-brand-500 bg-brand-500 text-white'
: 'border-[var(--app-border)] text-[var(--app-subtle)] hover:bg-[var(--app-hover)]')
}
>
{genre.name}
<span className="ml-1 opacity-70">{genre.count}</span>
</button>
)
})}
</div>
)}
</div>
<div className="flex flex-wrap items-end gap-3">
<label className="text-xs font-semibold text-[var(--app-muted)]">
年份
<div className="mt-1 flex items-center gap-1.5">
<input
type="number"
className="input-base w-20"
placeholder={facets?.year_min ? String(facets.year_min) : '不限'}
value={yearMin}
onChange={(e) => setYearMin(e.target.value)}
/>
<span className="text-[var(--app-muted)]">–</span>
<input
type="number"
className="input-base w-20"
placeholder={facets?.year_max ? String(facets.year_max) : '不限'}
value={yearMax}
onChange={(e) => setYearMax(e.target.value)}
/>
</div>
</label>
<label className="text-xs font-semibold text-[var(--app-muted)]">
最低评分
<input
type="number"
step="0.5"
min="0"
max="10"
className="input-base mt-1 w-20"
placeholder="不限"
value={ratingMin}
onChange={(e) => setRatingMin(e.target.value)}
/>
</label>
<label className="flex items-center gap-2 pb-2 text-xs font-semibold text-[var(--app-muted)]">
<input
type="checkbox"
className="h-4 w-4 accent-brand-500"
checked={Boolean(draft.unwatched)}
onChange={(e) => setDraft((prev) => ({ ...prev, unwatched: e.target.checked }))}
/>
只看未看完
</label>
<button type="button" onClick={apply} className="neon-button mb-0.5">
应用筛选
</button>
</div>
</div>
)}
</section>
)
}
+77 -3
View File
@@ -1,9 +1,17 @@
import { useCallback, useMemo, useState, Fragment, type ReactNode } from 'react'
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import type { Media } from '../types'
import { useAuthStore } from '../stores/auth'
import { libraryAPI, type LibraryFacets } from '../api/library'
import {
EMPTY_LIBRARY_FILTERS,
parseLibraryFilters,
withFilterParams,
type LibraryFilterParams,
} from '../utils/libraryFilters'
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
import {
sortMediaList,
@@ -15,6 +23,7 @@ import { LibraryPageDialogs } from './LibraryPageDialogs'
import { PageBackButton } from '../components/PageBackButton'
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import { LibraryPageHeader } from './LibraryPageHeader'
import { LibraryFilterBar } from './LibraryFilterBar'
import { LibraryMediaSections } from './LibraryMediaSections'
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
import { useLibraryData } from './useLibraryData'
@@ -28,6 +37,7 @@ export function LibraryPage() {
const { id = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation()
const navigate = useNavigate()
const role = useAuthStore((s) => s.user?.role)
const canFavorite = usePermission('can_favorite')
const { isFavourite, toggleFavourite } = useFavourites()
@@ -57,6 +67,60 @@ export function LibraryPage() {
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
// 筛选状态放在 URL:可分享、刷新保持、浏览器返回可撤销。
const libraryFilters = useMemo(
() => parseLibraryFilters(location.search),
[location.search],
)
const [facets, setFacets] = useState<LibraryFacets | null>(null)
const [loadingFacets, setLoadingFacets] = useState(true)
const [randomBusy, setRandomBusy] = useState(false)
useEffect(() => {
let cancelled = false
setLoadingFacets(true)
libraryAPI
.facets(id)
.then((data) => {
if (!cancelled) setFacets(data)
})
.catch(() => {
if (!cancelled) setFacets(null)
})
.finally(() => {
if (!cancelled) setLoadingFacets(false)
})
return () => {
cancelled = true
}
}, [id])
const applyFilters = (next: LibraryFilterParams) => {
navigate({ search: withFilterParams(location.search, next) }, { replace: true })
}
const resetFilters = () => {
navigate({ search: withFilterParams(location.search, EMPTY_LIBRARY_FILTERS) }, { replace: true })
}
// 「随便看看」直接进播放页:多一步详情页会削弱「随手看点什么」的意图。
// 接受来自 LibraryFilterBar 的有效草稿筛选(草稿与已应用不同时,面板已事先
// 调用 applyFilters 把草稿写入 URL,再用相同的条件发起随机请求以保证一致)。
const handleRandom = async (filters: LibraryFilterParams) => {
setRandomBusy(true)
try {
const media = await libraryAPI.random(id, filters)
navigate(`/play/${media.id}`)
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status
toast.error(
status === 404 ? '没有符合当前筛选条件的媒体' : '随机播放失败,请稍后重试',
)
} finally {
setRandomBusy(false)
}
}
const {
library,
items,
@@ -73,7 +137,7 @@ export function LibraryPage() {
seriesCards,
loadingAllText,
reloadCurrentLibrary,
} = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder)
} = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder, libraryFilters)
// 常规排序由服务端全局完成;只有 random 模式才在客户端洗牌。
const displayedItems = useMemo(() => {
@@ -254,6 +318,16 @@ export function LibraryPage() {
/>
)}
<LibraryFilterBar
facets={facets}
loadingFacets={loadingFacets}
filters={libraryFilters}
onApply={applyFilters}
onReset={resetFilters}
onRandom={(f) => void handleRandom(f)}
randomBusy={randomBusy}
/>
<LibraryMediaSections
isSeries={isSeries}
items={displayedItems}
+6
View File
@@ -4,6 +4,8 @@ import { Check, Copy, EyeOff, KeyRound, Loader2, Save, Tv } from 'lucide-react'
import { authAPI } from '../api/auth'
import { profileAPI } from '../api/profile'
import { MyDevicesPanel } from '../components/MyDevicesPanel'
import { TelegramBindPanel } from '../components/TelegramBindPanel'
import { requestPassword } from '../components/requestPassword'
import { useAuthStore } from '../stores/auth'
@@ -288,6 +290,10 @@ export function ProfilePage() {
</div>
) : null}
</section>
<MyDevicesPanel />
<TelegramBindPanel />
</div>
)
}
+2
View File
@@ -13,6 +13,7 @@ import { AdultSettingsPanel } from './AdultSettingsPanel'
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
import { SettingRow } from './SettingsRow'
import { TelegramNotifyPanel } from './TelegramNotifyPanel'
import { ALL_KEYS, GROUPS } from './settingsGroups'
export function SettingsPage() {
@@ -176,6 +177,7 @@ export function SettingsPage() {
{group.key === 'adult' && <AdultSettingsPanel />}
{group.key === 'general' && <FFToolsPanel onInstalled={() => refresh().catch(() => undefined)} />}
{group.key === 'about' && <AboutSettingsPanel />}
{group.key === 'device-notify' && <TelegramNotifyPanel />}
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
<form onSubmit={onSave} className="glass-panel space-y-4">
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react'
import { Loader2, Send } from 'lucide-react'
import { telegramAPI } from '../api/telegram'
// TelegramNotifyPanel 提供「发送测试消息」按钮。
//
// 通知通道最容易出的问题是 Token 或 Chat ID 填错,而这类错误平时是完全静默的
// (发送失败只写服务端日志)。这个按钮把失败原因直接带回界面。
export function TelegramNotifyPanel() {
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null)
const runTest = async () => {
setBusy(true)
setResult(null)
try {
const res = await telegramAPI.test()
setResult(
res.success
? { ok: true, message: '测试消息已发送,请检查 Telegram。' }
: { ok: false, message: res.error || '发送失败' },
)
} catch (err) {
setResult({
ok: false,
message: err instanceof Error ? err.message : '发送失败',
})
} finally {
setBusy(false)
}
}
return (
<div className="glass-panel space-y-3">
<div>
<h3 className="font-display text-sm font-bold text-ink-100">通知通道自检</h3>
<p className="mt-0.5 text-xs text-sand-500">
保存上方设置后点击发送。测试消息会发到「管理员 Chat ID」,用于确认 Token
与会话 ID 都正确。
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void runTest()}
disabled={busy}
className="btn-secondary inline-flex items-center gap-2"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
<span>发送测试消息</span>
</button>
{result && (
<span
className={
'text-xs font-semibold ' + (result.ok ? 'text-emerald-400' : 'text-rose-400')
}
>
{result.message}
</span>
)}
</div>
</div>
)
}
+8 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Clock, Play, Trash2 } from 'lucide-react'
import { BarChart3, Clock, Play, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { historyAPI } from '../api/history'
@@ -77,6 +77,13 @@ export function WatchHistoryPage() {
<h1 className="font-display text-3xl font-bold text-ink-600">观看历史</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link
to="/history/stats"
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-bold text-gray-600 shadow-sm transition hover:border-brand-300 hover:text-brand-600"
>
<BarChart3 size={14} />
观看统计
</Link>
<button
onClick={() => clearByStatus('incomplete')}
disabled={busy !== '' || items.every((item) => item.completed)}
+281
View File
@@ -0,0 +1,281 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Clock, Film, Loader2, Star, Tv } from 'lucide-react'
import { ARTWORK, imageURL } from '../api/client'
import { historyAPI } from '../api/history'
import type { HistoryStats } from '../types/history'
import { PageHeader } from '../components/PageHeader'
import { usePermission } from '../hooks/usePermission'
// WatchStatsPage 展示个人观看统计。
//
// 只消费已有数据(PlaybackHistory 聚合),因此这里没有任何写操作;图表也用
// 纯 CSS 柱状呈现,不引入图表库 —— 30 根柱子的信息量不值得多一个依赖。
export function WatchStatsPage() {
const navigate = useNavigate()
const canViewHistory = usePermission('can_view_history')
const [stats, setStats] = useState<HistoryStats | null>(null)
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
// Redirect denied users before issuing any API calls.
useEffect(() => {
if (!canViewHistory) {
navigate('/', { replace: true })
}
}, [canViewHistory, navigate])
const load = useCallback(async () => {
try {
const data = await historyAPI.stats()
setStats(data)
setFailed(false)
} catch {
setFailed(true)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
if (loading) {
return (
<div className="flex items-center justify-center py-32 text-[var(--app-muted)]">
<Loader2 className="animate-spin" />
</div>
)
}
if (failed || !stats) {
return (
<div className="space-y-6">
<PageHeader title="观看统计" description="你的观看时长、类型分布与最近记录" />
<p className="glass-panel p-6 text-center text-sm text-[var(--app-muted)]">
统计加载失败,请稍后重试。
</p>
</div>
)
}
const daily = stats.daily ?? []
const byType = stats.by_library_type ?? []
const recent = stats.recent ?? []
const hasAnyHistory = stats.total > 0
return (
<div className="space-y-6">
<PageHeader title="观看统计" description="你的观看时长、类型分布与最近记录" />
{!hasAnyHistory ? (
<p className="glass-panel p-8 text-center text-sm text-[var(--app-muted)]">
还没有观看记录。播放任意内容后,这里会展示观看时长与类型分布。
</p>
) : (
<>
<section className="grid gap-4 sm:grid-cols-3">
<StatCard
icon={<Clock size={16} />}
label="累计观看"
value={formatHours(stats.watched_hours)}
hint={`共 ${stats.total} 条播放记录`}
/>
<StatCard
icon={<Star size={16} />}
label="已看完"
value={String(stats.completed)}
hint={
stats.last_watched
? `最近观看 ${formatDate(stats.last_watched)}`
: '尚无观看时间'
}
/>
<StatCard
icon={<Film size={16} />}
label="正在看"
value={String(stats.in_progress ?? 0)}
hint="未标记看完的条目"
/>
</section>
<section className="glass-panel space-y-4">
<div className="flex items-baseline justify-between">
<h2 className="font-display text-lg font-semibold text-[var(--app-text)]">
近 30 天观看时长
</h2>
<span className="text-xs text-[var(--app-muted)]">
{daily.length > 0 ? `${daily.length} 天有记录` : '暂无记录'}
</span>
</div>
{daily.length === 0 ? (
<p className="py-6 text-center text-sm text-[var(--app-muted)]">
近 30 天没有观看记录
</p>
) : (
<DailyBars daily={daily} />
)}
</section>
<section className="glass-panel space-y-3">
<h2 className="font-display text-lg font-semibold text-[var(--app-text)]">
类型分布
</h2>
{byType.length === 0 ? (
<p className="py-4 text-center text-sm text-[var(--app-muted)]">暂无数据</p>
) : (
<ul className="space-y-2">
{byType.map((row) => {
const max = Math.max(...byType.map((item) => item.watch_ms), 1)
const ratio = Math.max(2, Math.round((row.watch_ms / max) * 100))
return (
<li key={row.type} className="flex items-center gap-3">
<span className="flex w-20 shrink-0 items-center gap-1.5 text-xs font-semibold text-[var(--app-subtle)]">
{typeIcon(row.type)}
{typeLabel(row.type)}
</span>
<span className="h-2.5 flex-1 overflow-hidden rounded-full bg-[var(--app-hover)]">
<span
className="block h-full rounded-full bg-brand-500"
style={{ width: `${ratio}%` }}
/>
</span>
<span className="w-24 shrink-0 text-right font-mono text-xs text-[var(--app-muted)]">
{formatHours(row.watch_ms / 1000 / 3600)} · {row.count} 条
</span>
</li>
)
})}
</ul>
)}
</section>
{recent.length > 0 && (
<section className="glass-panel space-y-3">
<h2 className="font-display text-lg font-semibold text-[var(--app-text)]">
最近看过
</h2>
<ul className="grid gap-2 sm:grid-cols-2">
{recent.map((row) => (
<li key={row.history.id}>
<Link
to={`/media/${row.history.media_id}`}
className="flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2.5 transition-colors hover:bg-[var(--app-hover)]"
>
<span className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
{row.media?.poster_url && (
<img
src={imageURL(
row.media.poster_url,
row.media.updated_at,
ARTWORK.posterTiny,
)}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-[var(--app-text)]">
{row.media?.title ?? '未知媒体'}
</span>
<span className="mt-0.5 block text-[11px] text-[var(--app-muted)]">
{formatDate(row.history.watched_at)}
{row.history.completed ? ' · 已看完' : ' · 在看'}
</span>
</span>
</Link>
</li>
))}
</ul>
</section>
)}
</>
)}
</div>
)
}
function StatCard({
icon,
label,
value,
hint,
}: {
icon: React.ReactNode
label: string
value: string
hint: string
}) {
return (
<div className="glass-panel space-y-1">
<div className="flex items-center gap-2 text-xs font-semibold text-[var(--app-muted)]">
{icon}
{label}
</div>
<div className="font-display text-2xl font-black text-[var(--app-text)]">{value}</div>
<div className="text-[11px] text-[var(--app-muted)]">{hint}</div>
</div>
)
}
// DailyBars 用纯 CSS 画 30 天柱状图。柱高按区间内最大值归一化,最小值留 4%
// 高度,否则「有记录但很少」的那天在视觉上会消失。
function DailyBars({
daily,
}: {
daily: Array<{ day: string; watch_ms: number; plays: number }>
}) {
const max = Math.max(...daily.map((item) => item.watch_ms), 1)
return (
<div className="flex h-32 items-end gap-1">
{daily.map((item) => {
const ratio = Math.max(4, Math.round((item.watch_ms / max) * 100))
return (
<div
key={item.day}
className="group relative flex-1 rounded-t bg-brand-500/70 transition-colors hover:bg-brand-500"
style={{ height: `${ratio}%` }}
title={`${item.day} · ${formatHours(item.watch_ms / 1000 / 3600)} · ${item.plays} 条`}
/>
)
})}
</div>
)
}
function formatHours(hours: number): string {
if (!Number.isFinite(hours) || hours <= 0) return '0 小时'
if (hours < 1) {
const minutes = Math.round(hours * 60)
return `${minutes} 分钟`
}
return `${hours.toFixed(hours >= 10 ? 0 : 1)} 小时`
}
function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString()
}
function typeLabel(type: string): string {
const labels: Record<string, string> = {
movie: '电影',
tv: '剧集',
anime: '动漫',
variety: '综艺',
music: '音乐',
adult: 'Adult',
}
return labels[type] ?? '其他'
}
function typeIcon(type: string) {
if (type === 'tv' || type === 'anime' || type === 'variety') {
return <Tv size={13} />
}
return <Film size={13} />
}
@@ -0,0 +1,76 @@
import type { SettingGroup } from './settingsGroupTypes'
// 设备管控与通知设置。
//
// 这些键原先只能通过 Telegram Bot 命令调整,网页端没有任何入口;本分组把它们
// 暴露成可维护的表单,并解释每个值的实际作用。
export const deviceNotifySettingsGroup: SettingGroup = {
key: 'device-notify',
label: '设备与通知',
description: '登录设备管控、防共享策略与 Telegram 通知通道',
items: [
{
key: 'telegram.enabled',
label: '启用 Telegram 通知',
type: 'toggle',
defaultValue: 'false',
hint: '总开关。关闭时所有通知静默跳过,不影响任何业务流程。',
},
{
key: 'telegram.bot_token',
label: 'Bot Token',
type: 'text',
placeholder: '123456:AA...',
hint: 'BotFather 发放的 Token。保存后再次打开会显示为脱敏值;保持脱敏值不动即表示不修改。',
},
{
key: 'telegram.admin_chat_id',
label: '管理员 Chat ID',
type: 'text',
placeholder: '例如 123456789',
hint: '接收运维通知(任务失败、设备策略动作)的会话 ID。可先用 Bot 给管理员发消息,再从日志或 getUpdates 获取。',
},
{
key: 'device.antishare_enabled',
label: '启用防共享',
type: 'toggle',
defaultValue: 'false',
hint: '开启后,超过并发播放/登录终端上限会禁用账号;设备指纹变化按警告累计处理。默认关闭。',
},
{
key: 'device.max_concurrent_play',
label: '最大并发播放设备',
type: 'number',
defaultValue: '3',
hint: '同一账号在判定窗口内同时在线的播放设备数上限。',
},
{
key: 'device.max_logged_clients',
label: '最大同时登录终端',
type: 'number',
defaultValue: '3',
hint: '活跃天数窗口内允许的登录终端数量上限。',
},
{
key: 'device.warn_threshold',
label: '设备指纹警告阈值',
type: 'number',
defaultValue: '2',
hint: '设备指纹异常累计超过该次数后禁用账号。警告会同步通知用户本人。',
},
{
key: 'device.play_window_seconds',
label: '并发判定窗口(秒)',
type: 'number',
defaultValue: '90',
hint: '统计「同时播放」时回看的时间窗口。',
},
{
key: 'device.client_active_days',
label: '登录设备活跃天数',
type: 'number',
defaultValue: '30',
hint: '统计「同时登录终端」时回看的天数窗口。',
},
],
}
+2
View File
@@ -1,5 +1,6 @@
import { adultSettingsGroup } from './settingsGroupAccess'
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
import { deviceNotifySettingsGroup } from './settingsGroupDeviceNotify'
import { generalSettingsGroup } from './settingsGroupGeneral'
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
import type { SettingGroup } from './settingsGroupTypes'
@@ -22,6 +23,7 @@ export const aboutSettingsGroup: SettingGroup = {
export const GROUPS: SettingGroup[] = [
generalSettingsGroup,
deviceNotifySettingsGroup,
databaseSettingsGroup,
apiConfigsSettingsGroup,
recognitionWordsSettingsGroup,
+24 -7
View File
@@ -5,6 +5,11 @@ import { libraryAPI } from '../api/library'
import type { Library, Media } from '../types'
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
import {
EMPTY_LIBRARY_FILTERS,
serializeLibraryFilters,
type LibraryFilterParams,
} from '../utils/libraryFilters'
import type { SortField, SortOrder } from '../utils/mediaSort'
import { MAX_RESTORE_PAGES, readListPosition, writeListPosition } from '../hooks/useListPositionMemory'
@@ -13,6 +18,7 @@ export function useLibraryData(
selectedSeries: SeriesCard | null,
sortField: SortField,
sortOrder: SortOrder,
filters: LibraryFilterParams = EMPTY_LIBRARY_FILTERS,
) {
const [library, setLibrary] = useState<Library | null>(null)
const [items, setItems] = useState<Media[]>([])
@@ -44,9 +50,10 @@ export function useLibraryData(
const modeRef = useRef<'media' | 'series'>('media')
const moreInFlightRef = useRef(false)
// 分页位置按「媒体库 + 排序」记忆:详情页返回、甚至换完排序再切回来,
// 都能回到上次加载到的页数。
const positionKey = `library:${libraryID}:${sortField}:${sortOrder}`
// 分页位置按「媒体库 + 排序 + 筛选」记忆:详情页返回、甚至换完排序或筛选后
// 再切回来,都能回到上次加载到的页数。
const filterKey = serializeLibraryFilters(filters)
const positionKey = `library:${libraryID}:${sortField}:${sortOrder}:${filterKey}`
// 拉取并追加下一页。滚动哨兵、按钮和首屏分页恢复共用这一条路径,
// 避免两套分页逻辑各自算页码。
@@ -60,6 +67,7 @@ export function useLibraryData(
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib), {
sort: sortField,
order: sortOrder,
filters,
})
if (seq !== requestSeqRef.current) return false
const pageItems = data.items ?? []
@@ -72,6 +80,7 @@ export function useLibraryData(
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), {
sort: sortField,
order: sortOrder,
filters,
})
if (seq !== requestSeqRef.current) return false
const pageItems = data.items ?? []
@@ -93,7 +102,7 @@ export function useLibraryData(
}
return false
}
}, [libraryID, positionKey, sortField, sortOrder])
}, [libraryID, positionKey, sortField, sortOrder, filters])
const loadMore = useCallback(async (options?: { remember?: boolean }) => {
if (moreInFlightRef.current || !hasMoreRef.current) return
@@ -168,14 +177,22 @@ export function useLibraryData(
const pageSize = pageSizeFor(lib)
try {
if (seriesMode) {
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, {
sort: sortField,
order: sortOrder,
filters,
})
if (cancelled || seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setServerSeriesCards(pageItems)
loadedCountRef.current = pageItems.length
totalRef.current = data.total ?? pageItems.length
} else {
const data = await libraryAPI.listMedia(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
const data = await libraryAPI.listMedia(libraryID, 1, pageSize, {
sort: sortField,
order: sortOrder,
filters,
})
if (cancelled || seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setItems(pageItems)
@@ -214,7 +231,7 @@ export function useLibraryData(
cancelled = true
requestSeqRef.current += 1
}
}, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder])
}, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder, filters])
useEffect(() => {
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
+20
View File
@@ -11,10 +11,30 @@ export interface HistoryItem {
media?: Media
}
export interface HistoryDailyStat {
day: string
watch_ms: number
plays: number
}
export interface HistoryTypeStat {
type: string
watch_ms: number
count: number
}
export interface HistoryStats {
total: number
completed: number
watched_ms: number
watched_hours: number
last_watched?: string
/** 正在看(未标记看完)的条目数。 */
in_progress?: number
/** 近 30 天中有观看记录的日子,未观看的日期不返回。 */
daily?: HistoryDailyStat[]
/** 按媒体库类型聚合的观看时长与条目数。 */
by_library_type?: HistoryTypeStat[]
/** 最近看过的若干条,含 media 详情用于渲染卡片。 */
recent?: Array<{ history: HistoryItem; media?: Media }>
}
+119
View File
@@ -0,0 +1,119 @@
// 媒体库筛选条件:解析/序列化为 URL 查询串。
//
// 放在 URL 而不是组件状态里,是为了让「筛选后的库」可以直接分享、刷新后保持、
// 以及用浏览器返回键撤销上一步筛选。
export type LibraryFilterParams = {
genres: string[]
year_min?: number
year_max?: number
rating_min?: number
unwatched?: boolean
}
export const EMPTY_LIBRARY_FILTERS: LibraryFilterParams = { genres: [] }
const GENRE_PARAM = 'genre'
const YEAR_MIN_PARAM = 'year_min'
const YEAR_MAX_PARAM = 'year_max'
const RATING_MIN_PARAM = 'rating_min'
const UNWATCHED_PARAM = 'unwatched'
/** 判断是否有任何筛选生效(用于显示「清除筛选」与空态文案)。 */
export function hasActiveFilters(filters: LibraryFilterParams): boolean {
return (
filters.genres.length > 0 ||
Boolean(filters.year_min) ||
Boolean(filters.year_max) ||
Boolean(filters.rating_min) ||
Boolean(filters.unwatched)
)
}
export function parseLibraryFilters(search: string): LibraryFilterParams {
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
const filters: LibraryFilterParams = {
genres: params.getAll(GENRE_PARAM).filter((value) => value.trim() !== ''),
}
const yearMin = Number(params.get(YEAR_MIN_PARAM))
if (Number.isFinite(yearMin) && yearMin > 0) filters.year_min = yearMin
const yearMax = Number(params.get(YEAR_MAX_PARAM))
if (Number.isFinite(yearMax) && yearMax > 0) filters.year_max = yearMax
const ratingMin = Number(params.get(RATING_MIN_PARAM))
if (Number.isFinite(ratingMin) && ratingMin > 0) filters.rating_min = ratingMin
if (isTruthy(params.get(UNWATCHED_PARAM))) filters.unwatched = true
return filters
}
/**
* serializeLibraryFilters 生成筛选参数的查询串(不含前导 `?`,也不含排序等
* 其它参数)。空筛选返回空串。
*/
export function serializeLibraryFilters(filters: LibraryFilterParams): string {
const params = new URLSearchParams()
filters.genres.forEach((genre) => {
const trimmed = genre.trim()
if (trimmed) params.append(GENRE_PARAM, trimmed)
})
if (filters.year_min) params.set(YEAR_MIN_PARAM, String(filters.year_min))
if (filters.year_max) params.set(YEAR_MAX_PARAM, String(filters.year_max))
if (filters.rating_min) params.set(RATING_MIN_PARAM, String(filters.rating_min))
if (filters.unwatched) params.set(UNWATCHED_PARAM, '1')
return params.toString()
}
/**
* toFilterQuery 把筛选条件转成 axios params,供列表与随机接口共用。
* 键名与服务端解析保持一致。
*/
export function toFilterQuery(filters: LibraryFilterParams): Record<string, unknown> {
const query: Record<string, unknown> = {}
if (filters.genres.length > 0) query[GENRE_PARAM] = filters.genres
if (filters.year_min) query[YEAR_MIN_PARAM] = filters.year_min
if (filters.year_max) query[YEAR_MAX_PARAM] = filters.year_max
if (filters.rating_min) query[RATING_MIN_PARAM] = filters.rating_min
if (filters.unwatched) query[UNWATCHED_PARAM] = 1
return query
}
/**
* withFilterParams 在原查询串上叠加筛选参数,同时保留其它参数(如排序记忆)。
*/
export function withFilterParams(search: string, filters: LibraryFilterParams): string {
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
params.delete(GENRE_PARAM)
params.delete(YEAR_MIN_PARAM)
params.delete(YEAR_MAX_PARAM)
params.delete(RATING_MIN_PARAM)
params.delete(UNWATCHED_PARAM)
filters.genres.forEach((genre) => {
const trimmed = genre.trim()
if (trimmed) params.append(GENRE_PARAM, trimmed)
})
if (filters.year_min) params.set(YEAR_MIN_PARAM, String(filters.year_min))
if (filters.year_max) params.set(YEAR_MAX_PARAM, String(filters.year_max))
if (filters.rating_min) params.set(RATING_MIN_PARAM, String(filters.rating_min))
if (filters.unwatched) params.set(UNWATCHED_PARAM, '1')
const query = params.toString()
return query ? `?${query}` : ''
}
/**
* normalizeYearRange 修正用户输入的年份区间:上限早于下限时交换,
* 避免一次无心的顺序颠倒把结果筛成空。
*/
export function normalizeYearRange(
min?: number,
max?: number,
): { year_min?: number; year_max?: number } {
if (min && max && min > max) {
return { year_min: max, year_max: min }
}
return { year_min: min, year_max: max }
}
function isTruthy(value: string | null): boolean {
if (!value) return false
const normalized = value.toLowerCase()
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'
}