添加扫码登录功能 (#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

@@ -123,3 +123,8 @@ pub struct UpdateVideoSourceRequest {
pub struct DefaultPathRequest {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct PollQrcodeRequest {
pub qrcode_key: String,
}

View File

@@ -3,6 +3,7 @@ use bili_sync_entity::*;
use sea_orm::{DerivePartialModel, FromQueryResult};
use serde::Serialize;
use crate::bilibili::{PollStatus, Qrcode};
use crate::utils::status::{PageStatus, VideoStatus};
#[derive(Serialize)]
@@ -213,3 +214,7 @@ pub struct VideoSourceDetail {
pub struct UpdateVideoSourceResponse {
pub rule_display: Option<String>,
}
pub type GenerateQrcodeResponse = Qrcode;
pub type PollQrcodeResponse = PollStatus;

View File

@@ -0,0 +1,34 @@
use std::sync::Arc;
use anyhow::Result;
use axum::Router;
use axum::extract::{Extension, Query};
use axum::routing::{get, post};
use crate::api::request::PollQrcodeRequest;
use crate::api::response::{GenerateQrcodeResponse, PollQrcodeResponse};
use crate::api::wrapper::{ApiError, ApiResponse};
use crate::bilibili::{BiliClient, Credential};
pub(super) fn router() -> Router {
Router::new()
.route("/login/qrcode/generate", post(generate_qrcode))
.route("/login/qrcode/poll", get(poll_qrcode))
}
/// 生成扫码登录二维码
pub async fn generate_qrcode(
Extension(bili_client): Extension<Arc<BiliClient>>,
) -> Result<ApiResponse<GenerateQrcodeResponse>, ApiError> {
Ok(ApiResponse::ok(Credential::generate_qrcode(&bili_client.client).await?))
}
/// 轮询扫码登录状态
pub async fn poll_qrcode(
Extension(bili_client): Extension<Arc<BiliClient>>,
Query(params): Query<PollQrcodeRequest>,
) -> Result<ApiResponse<PollQrcodeResponse>, ApiError> {
Ok(ApiResponse::ok(
Credential::poll_qrcode(&bili_client.client, &params.qrcode_key).await?,
))
}

View File

@@ -12,6 +12,7 @@ use crate::config::VersionedConfig;
mod config;
mod dashboard;
mod login;
mod me;
mod task;
mod video_sources;
@@ -25,6 +26,7 @@ pub fn router() -> Router {
"/api",
config::router()
.merge(me::router())
.merge(login::router())
.merge(video_sources::router())
.merge(videos::router())
.merge(dashboard::router())

View File

@@ -9,7 +9,7 @@ use rsa::sha2::Sha256;
use rsa::{Oaep, RsaPublicKey};
use serde::{Deserialize, Serialize};
use crate::bilibili::{Client, Validate};
use crate::bilibili::{BiliError, Client, Validate};
const MIXIN_KEY_ENC_TAB: [usize; 64] = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38,
@@ -17,6 +17,13 @@ const MIXIN_KEY_ENC_TAB: [usize; 64] = [
20, 34, 44, 52,
];
mod qrcode_status_code {
pub const SUCCESS: i64 = 0;
pub const NOT_SCANNED: i64 = 86101;
pub const SCANNED_UNCONFIRMED: i64 = 86090;
pub const EXPIRED: i64 = 86038;
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct Credential {
pub sessdata: String,
@@ -32,6 +39,28 @@ pub struct WbiImg {
pub(crate) sub_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Qrcode {
pub url: String,
pub qrcode_key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum PollStatus {
Success {
credential: Credential,
},
Pending {
message: String,
#[serde(default)]
scanned: bool,
},
Expired {
message: String,
},
}
impl WbiImg {
pub fn into_mixin_key(self) -> Option<String> {
let key = match (get_filename(self.img_url.as_str()), get_filename(self.sub_url.as_str())) {
@@ -56,6 +85,78 @@ impl Credential {
Ok(serde_json::from_value(res["data"]["wbi_img"].take())?)
}
pub async fn generate_qrcode(client: &Client) -> Result<Qrcode> {
let mut res = client
.request(
Method::GET,
"https://passport.bilibili.com/x/passport-login/web/qrcode/generate",
None,
)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?
.validate()?;
Ok(serde_json::from_value(res["data"].take())?)
}
pub async fn poll_qrcode(client: &Client, qrcode_key: &str) -> Result<PollStatus> {
let mut resp = client
.request(
Method::GET,
"https://passport.bilibili.com/x/passport-login/web/qrcode/poll",
None,
)
.query(&[("qrcode_key", qrcode_key)])
.send()
.await?
.error_for_status()?;
let headers = std::mem::take(resp.headers_mut());
let json = resp.json::<serde_json::Value>().await?.validate()?;
let code = json["data"]["code"].as_i64().context("missing 'code' field in data")?;
match code {
qrcode_status_code::SUCCESS => {
let mut credential = Self::extract(headers, json)?;
credential.buvid3 = Self::get_buvid3(client).await?;
Ok(PollStatus::Success { credential })
}
qrcode_status_code::NOT_SCANNED => Ok(PollStatus::Pending {
message: "未扫描".to_owned(),
scanned: false,
}),
qrcode_status_code::SCANNED_UNCONFIRMED => Ok(PollStatus::Pending {
message: "已扫描,请在手机上确认登录".to_owned(),
scanned: true,
}),
qrcode_status_code::EXPIRED => Ok(PollStatus::Expired {
message: "二维码已过期".to_owned(),
}),
_ => {
bail!(BiliError::InvalidResponse(json.to_string()));
}
}
}
/// 获取 buvid3 浏览器指纹
///
/// 参考 https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/buvid3_4.md
async fn get_buvid3(client: &Client) -> Result<String> {
let resp = client
.request(Method::GET, "https://api.bilibili.com/x/web-frontend/getbuvid", None)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?
.validate()?;
resp["data"]["buvid"]
.as_str()
.context("missing 'buvid' field in data")
.map(|s| s.to_string())
}
/// 检查凭据是否有效
pub async fn need_refresh(&self, client: &Client) -> Result<bool> {
let res = client
@@ -124,7 +225,7 @@ JNrRuoEUXpabUzGB8QIDAQAB
}
async fn get_new_credential(&self, client: &Client, csrf: &str) -> Result<Credential> {
let mut res = client
let mut resp = client
.request(
Method::POST,
"https://passport.bilibili.com/x/passport-login/web/cookie/refresh",
@@ -141,37 +242,10 @@ JNrRuoEUXpabUzGB8QIDAQAB
.send()
.await?
.error_for_status()?;
// 必须在 .json 前取出 headers否则 res 会被消耗
let headers = std::mem::take(res.headers_mut());
let res = res.json::<serde_json::Value>().await?.validate()?;
let set_cookies = headers.get_all(header::SET_COOKIE);
let mut credential = Self {
buvid3: self.buvid3.clone(),
..Self::default()
};
let required_cookies = HashSet::from(["SESSDATA", "bili_jct", "DedeUserID"]);
let cookies: Vec<Cookie> = set_cookies
.iter()
.filter_map(|x| x.to_str().ok())
.filter_map(|x| Cookie::parse(x).ok())
.filter(|x| required_cookies.contains(x.name()))
.collect();
ensure!(
cookies.len() == required_cookies.len(),
"not all required cookies found"
);
for cookie in cookies {
match cookie.name() {
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
"bili_jct" => credential.bili_jct = cookie.value().to_string(),
"DedeUserID" => credential.dedeuserid = cookie.value().to_string(),
_ => unreachable!(),
}
}
match res["data"]["refresh_token"].as_str() {
Some(token) => credential.ac_time_value = token.to_string(),
None => bail!("refresh_token not found"),
}
let headers = std::mem::take(resp.headers_mut());
let json = resp.json::<serde_json::Value>().await?.validate()?;
let mut credential = Self::extract(headers, json)?;
credential.buvid3 = self.buvid3.clone();
Ok(credential)
}
@@ -195,6 +269,36 @@ JNrRuoEUXpabUzGB8QIDAQAB
.validate()?;
Ok(())
}
/// 解析 header 和 json获取除 buvid3 字段外全部填充的 Credential
fn extract(headers: header::HeaderMap, json: serde_json::Value) -> Result<Credential> {
let mut credential = Credential::default();
let required_cookies = HashSet::from(["SESSDATA", "bili_jct", "DedeUserID"]);
let cookies: Vec<Cookie> = headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|x| x.to_str().ok())
.filter_map(|x| Cookie::parse(x).ok())
.filter(|x| required_cookies.contains(x.name()))
.collect();
ensure!(
cookies.len() == required_cookies.len(),
"not all required cookies found"
);
for cookie in cookies {
match cookie.name() {
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
"bili_jct" => credential.bili_jct = cookie.value().to_string(),
"DedeUserID" => credential.dedeuserid = cookie.value().to_string(),
_ => unreachable!(),
}
}
match json["data"]["refresh_token"].as_str() {
Some(token) => credential.ac_time_value = token.to_string(),
None => bail!("refresh_token not found"),
}
Ok(credential)
}
}
// 用指定的 pattern 正则表达式在 doc 中查找,返回第一个匹配的捕获组
@@ -246,4 +350,94 @@ mod tests {
"bar=%E4%BA%94%E4%B8%80%E5%9B%9B&baz=1919810&foo=one%20one%20four"
);
}
#[test]
fn test_extract_credential_success() {
let mut headers = header::HeaderMap::new();
headers.append(
header::SET_COOKIE,
"SESSDATA=test_sessdata; Path=/; Domain=bilibili.com".parse().unwrap(),
);
headers.append(
header::SET_COOKIE,
"bili_jct=test_jct; Path=/; Domain=bilibili.com".parse().unwrap(),
);
headers.append(
header::SET_COOKIE,
"DedeUserID=123456; Path=/; Domain=bilibili.com".parse().unwrap(),
);
let json = serde_json::json!({
"data": {
"refresh_token": "test_refresh_token"
}
});
let credential = Credential::extract(headers, json).unwrap();
assert_eq!(credential.sessdata, "test_sessdata");
assert_eq!(credential.bili_jct, "test_jct");
assert_eq!(credential.dedeuserid, "123456");
assert_eq!(credential.ac_time_value, "test_refresh_token");
assert!(credential.buvid3.is_empty());
}
#[test]
fn test_extract_credential_missing_sessdata() {
let headers = header::HeaderMap::new();
let json = serde_json::json!({
"data": {
"refresh_token": "test_refresh_token"
}
});
assert!(Credential::extract(headers, json).is_err());
}
#[test]
fn test_extract_credential_missing_refresh_token() {
let mut headers = header::HeaderMap::new();
headers.append(header::SET_COOKIE, "SESSDATA=test_sessdata".parse().unwrap());
headers.append(header::SET_COOKIE, "bili_jct=test_jct".parse().unwrap());
headers.append(header::SET_COOKIE, "DedeUserID=123456".parse().unwrap());
let json = serde_json::json!({
"data": {}
});
assert!(Credential::extract(headers, json).is_err());
}
#[ignore = "requires manual testing with real QR code scan"]
#[tokio::test]
async fn test_qrcode_login_flow() -> Result<()> {
let client = Client::new();
// 1. 生成二维码
let qr_response = Credential::generate_qrcode(&client).await?;
println!("二维码 URL: {}", qr_response.url);
println!("qrcode_key: {}", qr_response.qrcode_key);
println!("\n请使用 B 站 APP 扫描二维码...\n");
// 2. 轮询登录状态(最多轮询 90 次,每 2 秒一次,共 180 秒)
for i in 1..=90 {
println!("{} 次轮询...", i);
let status = Credential::poll_qrcode(&client, &qr_response.qrcode_key).await?;
match status {
PollStatus::Success { credential } => {
println!("\n登录成功!");
println!("SESSDATA: {}", credential.sessdata);
println!("bili_jct: {}", credential.bili_jct);
println!("buvid3: {}", credential.buvid3);
println!("DedeUserID: {}", credential.dedeuserid);
println!("ac_time_value: {}", credential.ac_time_value);
return Ok(());
}
PollStatus::Pending { message, scanned } => {
println!("状态: {}, 已扫描: {}", message, scanned);
}
PollStatus::Expired { message } => {
println!("\n二维码已过期: {}", message);
anyhow::bail!("二维码过期");
}
}
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
bail!("轮询超时")
}
}

View File

@@ -4,8 +4,8 @@ use thiserror::Error;
pub enum BiliError {
#[error("response missing 'code' or 'message' field, full response: {0}")]
InvalidResponse(String),
#[error("API returned error code {0}, message: {1}, full response: {2}")]
ErrorResponse(i64, String, String),
#[error("API returned error code {0}, full response: {1}")]
ErrorResponse(i64, String),
#[error("risk control triggered by server, full response: {0}")]
RiskControlOccurred(String),
#[error("no video streams available (may indicate risk control)")]

View File

@@ -2,13 +2,13 @@ use std::borrow::Cow;
use std::sync::Arc;
pub use analyzer::{BestStream, FilterOption};
use anyhow::{Result, bail, ensure};
use anyhow::{Context, Result, bail, ensure};
use arc_swap::ArcSwapOption;
use chrono::serde::ts_seconds;
use chrono::{DateTime, Utc};
pub use client::{BiliClient, Client};
pub use collection::{Collection, CollectionItem, CollectionType};
pub use credential::Credential;
pub use credential::{Credential, PollStatus, Qrcode};
pub use danmaku::DanmakuOption;
pub use dynamic::Dynamic;
pub use error::BiliError;
@@ -51,17 +51,13 @@ impl Validate for serde_json::Value {
type Output = serde_json::Value;
fn validate(self) -> Result<Self::Output> {
let (code, msg) = match (self["code"].as_i64(), self["message"].as_str()) {
(Some(code), Some(msg)) => (code, msg),
_ => bail!(BiliError::InvalidResponse(self.to_string())),
};
let code = self["code"]
.as_i64()
.with_context(|| BiliError::InvalidResponse(self.to_string()))?;
if code == -352 || !self["data"]["v_voucher"].is_null() {
bail!(BiliError::RiskControlOccurred(self.to_string()));
}
ensure!(
code == 0,
BiliError::ErrorResponse(code, msg.to_owned(), self.to_string())
);
ensure!(code == 0, BiliError::ErrorResponse(code, self.to_string()));
Ok(self)
}
}

View File

@@ -133,7 +133,7 @@ pub async fn fetch_video_details(
"获取视频 {} - {} 的详细信息失败,错误为:{:#}",
&video_model.bvid, &video_model.name, e
);
if let Some(BiliError::ErrorResponse(-404, _, _)) = e.downcast_ref::<BiliError>() {
if let Some(BiliError::ErrorResponse(-404, _)) = e.downcast_ref::<BiliError>() {
let mut video_active_model: bili_sync_entity::video::ActiveModel = video_model.into();
video_active_model.valid = Set(false);
video_active_model.save(connection).await?;

View File

@@ -4,6 +4,10 @@
"workspaces": {
"": {
"name": "bili-sync-web",
"dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4",
},
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.2",
@@ -262,6 +266,10 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@25.0.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.51.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/type-utils": "8.51.0", "@typescript-eslint/utils": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.51.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A=="],
@@ -288,6 +296,8 @@
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -304,10 +314,14 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -374,6 +388,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
@@ -386,6 +402,10 @@
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
@@ -434,6 +454,8 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
@@ -456,6 +478,8 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
@@ -546,6 +570,8 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
@@ -556,6 +582,8 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
@@ -576,8 +604,14 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
@@ -594,6 +628,8 @@
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -604,6 +640,10 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
@@ -646,6 +686,8 @@
"typescript-eslint": ["typescript-eslint@8.51.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.51.0", "@typescript-eslint/parser": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -656,10 +698,20 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
@@ -700,6 +752,8 @@
"tailwind-variants/tailwind-merge": ["tailwind-merge@3.0.2", "", {}, "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="],
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
@@ -707,5 +761,11 @@
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
}
}

5204
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -47,5 +47,9 @@
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"type": "module"
"type": "module",
"dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4"
}
}

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>