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,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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 ?? []),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: '首页' }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ' +
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: '统计「同时登录终端」时回看的天数窗口。',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user