添加扫码登录功能 (#601)

* feat: 添加扫码登录功能,支持生成二维码并轮询登录状态

* feat: 增强扫码登录功能的测试,完善二维码生成与状态轮询的文档注释

* refactor: 后端改动之:1)拆分 login 到 credential 中;2)扫码登录和刷新凭据时复用相同的 extract 函数;3)精简注释。

* refactor: 前端改动之:1)扫码在单独的弹窗页处理;2)不同 status 下采用相同布局,避免状态变化导致布局跳动

* format

---------

Co-authored-by: zkl <i@zkl2333.com>
This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2026-01-11 12:59:48 +08:00
committed by GitHub
parent 64eecaa822
commit 5944298f10
15 changed files with 5915 additions and 50 deletions

View File

@@ -26,7 +26,9 @@ import type {
Notifier,
UpdateFilteredVideoStatusRequest,
UpdateFilteredVideoStatusResponse,
ResetFilteredVideoStatusRequest
ResetFilteredVideoStatusRequest,
QrcodeGenerateResponse as GenerateQrcodeResponse,
QrcodePollResponse as PollQrcodeResponse
} from './types';
import { wsManager } from './ws';
@@ -266,6 +268,14 @@ class ApiClient {
return this.post<boolean>('/task/download');
}
async generateQrcode(): Promise<ApiResponse<GenerateQrcodeResponse>> {
return this.post<GenerateQrcodeResponse>('/login/qrcode/generate');
}
async pollQrcode(qrcodeKey: string): Promise<ApiResponse<PollQrcodeResponse>> {
return this.get<PollQrcodeResponse>('/login/qrcode/poll', { qrcode_key: qrcodeKey });
}
subscribeToLogs(onMessage: (data: string) => void) {
return wsManager.subscribeToLogs(onMessage);
}
@@ -313,6 +323,8 @@ const api = {
updateConfig: (config: Config) => apiClient.updateConfig(config),
getDashboard: () => apiClient.getDashboard(),
triggerDownloadTask: () => apiClient.triggerDownloadTask(),
generateQrcode: () => apiClient.generateQrcode(),
pollQrcode: (qrcodeKey: string) => apiClient.pollQrcode(qrcodeKey),
subscribeToSysInfo: (onMessage: (data: SysInfo) => void) =>
apiClient.subscribeToSysInfo(onMessage),

View File

@@ -0,0 +1,269 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { toast } from 'svelte-sonner';
import api from '$lib/api';
import type { Credential, ApiError } from '$lib/types';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import QRCode from 'qrcode';
/**
* 扫码登录组件
*
* 状态流转:
* loading -> showing -> (success | expired | error)
* success 会调用 onSuccess 回调,由父组件关闭弹窗,不需要内部做处理
*
* @prop onSuccess - 登录成功回调,接收完整的凭证对象
*/
// 常量配置
const QR_EXPIRE_TIME = 180; // 二维码有效期(秒)
const POLL_INTERVAL = 2000; // 轮询间隔(毫秒)
const COUNTDOWN_INTERVAL = 1000; // 倒计时更新间隔(毫秒)
const QR_SIZE = 256; // 二维码图片尺寸(像素)
const QR_MARGIN = 2; // 二维码边距
export let onSuccess: (credential: Credential) => void;
export function init() {
generateQrcode();
}
type Status = 'loading' | 'showing' | 'expired' | 'error';
let status: Status = 'loading';
let qrcodeUrl = ''; // B站返回的二维码 URL需要转换为图片
let qrcodeKey = ''; // 用于轮询的认证 token
let qrcodeDataUrl = ''; // 生成的二维码图片 Data URL
let countdown = QR_EXPIRE_TIME; // 倒计时
let pollInterval: ReturnType<typeof setInterval> | null = null;
let countdownInterval: ReturnType<typeof setInterval> | null = null;
let scanned = false; // 是否已扫描
let errorMessage = '';
let isPolling = false; // 轮询标志,确保轮询排他性
/**
* 生成二维码
*
* 1. 停止之前的轮询和倒计时(确保排他性)
* 2. 调用后端 API 获取二维码信息
* 3. 将 URL 转换为二维码图片
* 4. 开始轮询登录状态
*/
async function generateQrcode() {
// 先停止之前的轮询和倒计时(排他性)
stopPolling();
stopCountdown();
status = 'loading';
errorMessage = '';
scanned = false;
try {
const response = await api.generateQrcode();
qrcodeUrl = response.data.url;
qrcodeKey = response.data.qrcode_key;
countdown = QR_EXPIRE_TIME;
// 将 URL 转换为二维码图片
qrcodeDataUrl = await QRCode.toDataURL(qrcodeUrl, {
width: QR_SIZE,
margin: QR_MARGIN,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
status = 'showing';
// 开始轮询和倒计时
startPolling();
startCountdown();
} catch (error) {
console.error('生成二维码失败:', error);
status = 'error';
errorMessage = (error as ApiError).message || '生成二维码失败';
toast.error('生成二维码失败', {
description: (error as ApiError).message
});
}
}
/**
* 轮询登录状态
*
* 每次调用前检查 isPolling 标志,确保轮询排他性。
* 异步请求后再次检查,防止在请求过程中状态已改变。
*/
async function pollStatus() {
// 如果已经停止轮询,直接返回
if (!qrcodeKey || !isPolling) return;
try {
const response = await api.pollQrcode(qrcodeKey);
const pollResult = response.data;
// 再次检查是否还在轮询(防止在请求过程中状态改变)
if (!isPolling) return;
if (pollResult.status === 'success') {
stopPolling();
stopCountdown();
onSuccess(pollResult.credential);
} else if (pollResult.status === 'pending') {
scanned = pollResult.scanned || false;
} else if (pollResult.status === 'expired') {
stopPolling();
stopCountdown();
status = 'expired';
}
} catch (error) {
console.error('轮询登录状态失败:', error);
}
}
/**
* 启动轮询
*
* 设置轮询标志并启动定时器
*/
function startPolling() {
isPolling = true;
pollInterval = setInterval(pollStatus, POLL_INTERVAL);
}
/**
* 停止轮询
*
* 立即设置轮询标志为 false清除定时器
*/
function stopPolling() {
isPolling = false; // 立即设置标志为 false
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
/**
* 启动倒计时
*
* 每秒减少倒计时,到期后自动停止轮询并标记为过期
*/
function startCountdown() {
countdownInterval = setInterval(() => {
countdown--;
if (countdown <= 0) {
stopPolling();
stopCountdown();
status = 'expired';
}
}, COUNTDOWN_INTERVAL);
}
/**
* 停止倒计时
*
* 清除倒计时定时器
*/
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}
onDestroy(() => {
stopPolling();
stopCountdown();
});
</script>
<div class="qr-login-container">
<Card.Root class="border-0 shadow-none">
<Card.Content class="p-4">
<div class="flex flex-col items-center gap-4">
<!-- 二维码容器 - 始终显示边框 -->
<div class="border-border relative rounded-lg border-2 bg-white p-3">
{#if status === 'loading'}
<!-- 加载状态 -->
<div class="flex h-48 w-48 items-center justify-center">
<LoaderCircle class="text-muted-foreground h-8 w-8 animate-spin" />
</div>
{:else if status === 'showing'}
<!-- 显示二维码 -->
<img src={qrcodeDataUrl} alt="登录二维码" class="h-48 w-48" />
{:else}
<!-- 过期或错误状态 - 显示占位图标 -->
<div class="flex h-48 w-48 items-center justify-center">
<RefreshCw class="text-muted-foreground h-12 w-12" />
</div>
{/if}
</div>
<!-- 状态提示文本 -->
<div class="text-muted-foreground space-y-2 text-center text-sm">
{#if status === 'loading'}
<p>正在生成二维码...</p>
{:else if status === 'showing'}
{#if scanned}
<div class="flex items-center justify-center gap-2">
<LoaderCircle class="h-4 w-4 animate-spin" />
<p>已扫描,请在手机上确认登录</p>
</div>
{:else}
<p>请使用哔哩哔哩 APP 扫描二维码</p>
{/if}
{:else if status === 'expired'}
<p>二维码已过期</p>
{:else if status === 'error'}
<p class="text-destructive">{errorMessage}</p>
{/if}
<!-- 倒计时 - 始终显示 -->
<div class="flex items-center justify-center gap-2">
<span class="text-muted-foreground text-xs">有效时间:</span>
<span
class="font-mono text-sm font-bold"
class:text-primary={countdown > 0}
class:text-muted-foreground={countdown <= 0}
>
{#if status === 'showing'}
{Math.floor(countdown / 60)}:{String(countdown % 60).padStart(2, '0')}
{:else}
-:--
{/if}
</span>
</div>
</div>
<!-- 操作按钮 - 根据状态变化 -->
{#if status === 'loading'}
<Button variant="outline" size="sm" class="w-full" disabled>
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
加载中...
</Button>
{:else if status === 'showing'}
<Button variant="outline" size="sm" onclick={generateQrcode} class="w-full">
<RefreshCw class="mr-2 h-4 w-4" />
刷新二维码
</Button>
{:else}
<Button variant="outline" size="sm" onclick={generateQrcode} class="w-full">
<RefreshCw class="mr-2 h-4 w-4" />
重新获取二维码
</Button>
{/if}
</div>
</Card.Content>
</Card.Root>
</div>
<style>
.qr-login-container {
width: 100%;
}
</style>

View File

@@ -349,3 +349,24 @@ export interface TaskStatus {
export interface UpdateVideoSourceResponse {
ruleDisplay: string;
}
// 扫码登录相关类型
export interface QrcodeGenerateResponse {
url: string;
qrcode_key: string;
}
export type QrcodePollResponse =
| {
status: 'success';
credential: Credential;
}
| {
status: 'pending';
message: string;
scanned?: boolean;
}
| {
status: 'expired';
message: string;
};

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
@@ -10,12 +10,14 @@
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import PasswordInput from '$lib/components/custom/password-input.svelte';
import QrLogin from '$lib/components/custom/qr-login.svelte';
import NotifierDialog from './NotifierDialog.svelte';
import InfoIcon from '@lucide/svelte/icons/info';
import QrCodeIcon from '@lucide/svelte/icons/qr-code';
import api from '$lib/api';
import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import type { Config, ApiError, Notifier } from '$lib/types';
import type { Config, ApiError, Notifier, Credential } from '$lib/types';
let frontendToken = ''; // 前端认证token
let config: Config | null = null;
@@ -31,6 +33,10 @@
let editingNotifierIndex: number | null = null;
let isEditing = false;
// QR 登录 Dialog 相关
let showQrLoginDialog = false;
let qrLoginComponent: QrLogin;
function openAddNotifierDialog() {
editingNotifier = null;
editingNotifierIndex = null;
@@ -168,6 +174,21 @@
}
}
function handleQrLoginSuccess(credential: Credential) {
if (!formData) return;
// 自动填充凭证到 formData
formData.credential = credential;
toast.success('扫码登录成功,已填充凭据');
// 自动保存配置
saveConfig();
// 关闭弹窗
showQrLoginDialog = false;
}
onMount(() => {
setBreadcrumb([{ label: '设置' }]);
@@ -349,6 +370,27 @@
<!-- B站认证 -->
<Tabs.Content value="auth" class="mt-6 space-y-6">
<div class="flex items-center justify-between">
<div class="space-y-1">
<Label class="text-base font-semibold">快速登录</Label>
<p class="text-muted-foreground text-sm">使用哔哩哔哩 APP 扫码登录,自动填充凭据</p>
</div>
<Button
onclick={() => {
showQrLoginDialog = true;
tick().then(() => {
qrLoginComponent!.init();
});
}}
>
<QrCodeIcon class="mr-2 h-4 w-4" />
扫码登录
</Button>
</div>
<Separator />
<!-- 原有的手动输入 Cookie 表单 -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="sessdata">SESSDATA</Label>
@@ -964,3 +1006,20 @@
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<!-- QR 登录弹窗 -->
<Dialog.Root bind:open={showQrLoginDialog}>
<Dialog.Portal>
<Dialog.Overlay class="bg-background/80 fixed inset-0 z-50 backdrop-blur-sm" />
<Dialog.Content
class="bg-background fixed top-[50%] left-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg"
>
<Dialog.Header>
<Dialog.Title>扫码登录</Dialog.Title>
<Dialog.Description>使用哔哩哔哩 APP 扫描二维码登录</Dialog.Description>
</Dialog.Header>
<QrLogin bind:this={qrLoginComponent} onSuccess={handleQrLoginSuccess} />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>