添加新功能,完善项目

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
+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) {