mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
优化
This commit is contained in:
@@ -1,31 +1,27 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Plus,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Tv,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
import { strmAPI, type Strm115Source, type StrmRemoteEntry } from '../api/strm'
|
||||
import { strmAPI, type StrmRemoteEntry } from '../api/strm'
|
||||
import type { SettingDef } from './SettingsRow'
|
||||
import { SettingRow } from './SettingsRow'
|
||||
import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from '../types/strm'
|
||||
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { Strm115AuthPanel } from './strm-dialogs/Strm115AuthPanel'
|
||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||
import {
|
||||
defaultEmbyRemoteLines,
|
||||
@@ -496,309 +492,6 @@ export function StrmAccountDialog({
|
||||
// ─── 115 登录二维码(canvas 渲染;115 返回的是扫码页面链接而非图片) ───────────
|
||||
|
||||
// QR 尺寸:物理像素 = CSS 像素(避免 canvas 被 CSS 缩放导致裁切/模糊)
|
||||
const QR_SIZE = 176
|
||||
|
||||
function QrCanvas({ content }: { content: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !content) return
|
||||
QRCode.toCanvas(canvas, content, {
|
||||
width: QR_SIZE,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#1f2937', light: '#ffffff' },
|
||||
})
|
||||
.then(() => setError(''))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : '二维码生成失败'))
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<div className="relative h-44 w-44 shrink-0 rounded-xl border border-gray-200 bg-white">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={QR_SIZE}
|
||||
height={QR_SIZE}
|
||||
role="img"
|
||||
aria-label="115 授权二维码"
|
||||
style={{ width: QR_SIZE, height: QR_SIZE }}
|
||||
/>
|
||||
{error && (
|
||||
<div className="absolute inset-0 grid place-items-center rounded-xl bg-gray-50 p-3 text-center text-xs text-rose-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 115 开放平台授权面板 ─────────────────────────────────────────────────────
|
||||
|
||||
type AuthSourceKey = 'built_in_appid' | 'custom_appid' | 'built_in_relay' | 'third_party_service'
|
||||
|
||||
const AUTH_SOURCE_OPTIONS: { key: AuthSourceKey; label: string; desc: string; needsRelayKey?: boolean }[] = [
|
||||
{ key: 'built_in_appid', label: '官方应用目录', desc: '设备码扫码授权,无需回跳' },
|
||||
{ key: 'custom_appid', label: '自定义 APP ID', desc: '使用自己申请的开放平台应用' },
|
||||
{ key: 'built_in_relay', label: '中继授权', desc: 'QMediaSync / MQFamily 中继', needsRelayKey: true },
|
||||
{ key: 'third_party_service', label: '第三方服务', desc: 'MoviePilot / CloudDrive' },
|
||||
]
|
||||
|
||||
function Strm115AuthPanel({
|
||||
existing,
|
||||
accountName,
|
||||
onAuthed,
|
||||
}: {
|
||||
existing: StrmAccount | null
|
||||
accountName: string
|
||||
onAuthed: (account: StrmAccount) => void
|
||||
}) {
|
||||
const [sources, setSources] = useState<{ built_in: Strm115Source[]; relay: Strm115Source[]; third_party: Strm115Source[] } | null>(null)
|
||||
const [relayKeyConfigured, setRelayKeyConfigured] = useState(false)
|
||||
const [authSource, setAuthSource] = useState<AuthSourceKey>('built_in_appid')
|
||||
const [thirdParty, setThirdParty] = useState<'moviepilot' | 'clouddrive'>('moviepilot')
|
||||
const [relayProvider, setRelayProvider] = useState<'qmediasync' | 'mqfamily'>('qmediasync')
|
||||
const [appID, setAppID] = useState('100195125')
|
||||
const [appKeyword, setAppKeyword] = useState('')
|
||||
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [authUI, setAuthUI] = useState<{ sessionId: string; accountId: string; mode: 'qrcode' | 'url'; authUrl?: string; qrcodeUrl?: string } | null>(null)
|
||||
const [authStatus, setAuthStatus] = useState('')
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
useEffect(() => stopPolling, [])
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await strmAPI.list115Sources()
|
||||
setSources(data)
|
||||
const settings = await strmAPI.getSettings().catch(() => null)
|
||||
const key = settings?.['strm.115_relay_key']
|
||||
setRelayKeyConfigured(Boolean(key && key.trim()))
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
(sources?.built_in ?? []).filter(
|
||||
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
|
||||
),
|
||||
[appKeyword, sources],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) {
|
||||
setAppID(filteredApps[0].app_id)
|
||||
}
|
||||
}, [appID, filteredApps])
|
||||
|
||||
const buildStartPayload = () => {
|
||||
switch (authSource) {
|
||||
case 'built_in_appid':
|
||||
return { auth_source: 'built_in_appid', app_id: appID }
|
||||
case 'custom_appid':
|
||||
return { auth_source: 'custom_appid', app_id: appID }
|
||||
case 'built_in_relay':
|
||||
return { auth_source: 'built_in_relay', provider: relayProvider }
|
||||
case 'third_party_service':
|
||||
return { auth_source: 'third_party_service', provider: thirdParty }
|
||||
}
|
||||
}
|
||||
|
||||
const startAuth = async () => {
|
||||
if (authSource === 'built_in_appid' && filteredApps.length === 0) {
|
||||
toast.error('未找到匹配的官方应用')
|
||||
return
|
||||
}
|
||||
|
||||
setStarting(true)
|
||||
setAuthStatus('')
|
||||
try {
|
||||
// 115 授权需要账号 ID:没有则先创建空凭据账号
|
||||
let account = existing
|
||||
if (!account) {
|
||||
account = await strmAPI.createAccount({ name: accountName || '115 网盘', provider: 'cloud115', config: {} })
|
||||
}
|
||||
const result = await strmAPI.start115OAuth(account.id, buildStartPayload())
|
||||
setAuthUI(
|
||||
result.mode === 'qrcode'
|
||||
? { sessionId: result.session_id, accountId: account.id, mode: 'qrcode', qrcodeUrl: result.qrcode?.qrcode }
|
||||
: { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url },
|
||||
)
|
||||
stopPolling()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await strmAPI.poll115OAuth(account.id, result.session_id)
|
||||
setAuthStatus(status.tip)
|
||||
if (status.status === 'confirmed') {
|
||||
stopPolling()
|
||||
const updated = await strmAPI.testAccount(account.id)
|
||||
onAuthed(updated)
|
||||
}
|
||||
if (status.status === 'expired') {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
toast.error('授权已过期,请重新发起')
|
||||
}
|
||||
} catch {
|
||||
/* 轮询失败等下一次 */
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAuthWindow = () => {
|
||||
if (authUI?.mode === 'url' && authUI.authUrl) {
|
||||
window.open(authUI.authUrl, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
|
||||
const resetAuth = () => {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
setAuthStatus('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl bg-gray-50 p-4">
|
||||
{existing?.has_credential && (
|
||||
<p className="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-600">
|
||||
✓ 该账号已授权;重新授权会替换现有令牌
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!authUI && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AUTH_SOURCE_OPTIONS.map((option) => {
|
||||
const active = authSource === option.key
|
||||
const disabled = Boolean(option.needsRelayKey && !relayKeyConfigured)
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setAuthSource(option.key)}
|
||||
className={
|
||||
'rounded-xl border-2 p-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-40 ' +
|
||||
(active ? 'border-brand-400 bg-brand-50' : 'border-gray-100 bg-white hover:border-gray-200')
|
||||
}
|
||||
>
|
||||
<p className="text-sm font-bold text-ink-600">{option.label}</p>
|
||||
<p className="text-[11px] text-sand-500">{option.desc}</p>
|
||||
{disabled && <p className="text-[10px] text-rose-400">需在 STRM 设置配置共享密钥</p>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{authSource === 'built_in_appid' && (
|
||||
<div className="space-y-2">
|
||||
<Field label="选择官方应用" hint="应用目录来自 QMediaSync 内置;媒体播放器 / 飞牛 / 恒星等">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={14} className="shrink-0 text-sand-400" />
|
||||
<input className={inputCls} placeholder="搜索应用名称或 ID…" value={appKeyword} onChange={(e) => setAppKeyword(e.target.value)} />
|
||||
</div>
|
||||
<select className={inputCls} value={appID} onChange={(e) => setAppID(e.target.value)} size={5}>
|
||||
{filteredApps.map((source) => (
|
||||
<option key={source.app_id} value={source.app_id}>
|
||||
{source.display_name}({source.app_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{filteredApps.length === 0 && <p className="text-xs text-sand-500">未找到匹配的官方应用</p>}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{authSource === 'custom_appid' && (
|
||||
<Field label="自定义 APP ID" hint="使用自己在 115 开放平台申请的应用 ID">
|
||||
<input className={inputCls} value={appID} placeholder="100195125" onChange={(e) => setAppID(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'built_in_relay' && (
|
||||
<Field label="中继服务">
|
||||
<select className={inputCls} value={relayProvider} onChange={(e) => setRelayProvider(e.target.value as typeof relayProvider)}>
|
||||
<option value="qmediasync">QMediaSync(oauth.qmediasync.cn)</option>
|
||||
<option value="mqfamily">MQFamily(api.mqfamily.top)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'third_party_service' && (
|
||||
<Field label="第三方授权服务">
|
||||
<select className={inputCls} value={thirdParty} onChange={(e) => setThirdParty(e.target.value as typeof thirdParty)}>
|
||||
<option value="moviepilot">MoviePilot(https://movie-pilot.org)</option>
|
||||
<option value="clouddrive">CloudDrive(redirect115.zhenyunpan.com)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAuth}
|
||||
disabled={starting || (authSource === 'built_in_appid' && filteredApps.length === 0)}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{starting ? <Loader2 size={16} className="animate-spin" /> : <QrCode size={16} />}
|
||||
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authUI && (
|
||||
<div className="space-y-3">
|
||||
{authUI.mode === 'qrcode' && authUI.qrcodeUrl ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<QrCanvas content={authUI.qrcodeUrl} />
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<p className="font-medium text-ink-600">{authStatus || '等待扫码…'}</p>
|
||||
<p className="text-xs text-sand-500">请使用 115 手机客户端扫码并确认授权,5 分钟内有效</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-ink-100">请点击下方按钮在新窗口完成授权:</p>
|
||||
<button type="button" onClick={openAuthWindow} className="neon-button">
|
||||
<ExternalLink size={16} />
|
||||
打开授权页面
|
||||
</button>
|
||||
<p className="text-xs text-sand-500">完成后回到此页面等待自动确认(授权成功后可关闭弹窗)</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Loader2 size={14} className="animate-spin text-brand-500" />
|
||||
<span className="text-ink-50">{authStatus}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAuth}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重新发起授权
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── STRM 设置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SETTING_DEFS: SettingDef[] = [
|
||||
{ key: 'strm.base_url', label: 'STRM 链接基础地址', type: 'text', hint: '生成的 strm 文件指向的播放地址;留空依次自动使用 app.server_url,最后回退到本机地址(http://127.0.0.1:端口)。Emby 在其他设备时请配置局域网/公网地址' },
|
||||
{ key: 'strm.video_ext', label: '视频扩展名', type: 'text', hint: '逗号分隔,命中即生成 .strm' },
|
||||
|
||||
Reference in New Issue
Block a user