mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 12:26:36 +08:00
添加新功能,完善项目
This commit is contained in:
@@ -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: '首页' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user