Tauri + NextJS 扫码登录
在现代桌面应用中,扫码登录已成为一种便捷且安全的认证方式。
尤其适用于用户已经在手机端完成登录或身份验证的场景。
相比传统账号密码登录,扫码登录降低了用户操作门槛,并提升了安全性。
具体步骤如下:
- 生成一个临时 Token,并以此生成一个二维码。
- 在桌面端获取并展示二维码。
- 通过轮询查询二维码状态。
- 手机端扫码确认。
- 桌面端登录成功。
服务端
为了实现扫码登录功能,服务端需要承担二维码生成与状态管理的逻辑。我们将使用 Node.js + Express 快速搭建一个极简的演示服务,完成以下几个核心任务:
- 生成登录用的唯一标识(Token)及对应二维码;
- 管理二维码的生命周期与状态(等待中、已过期、已确认等);
- 提供状态查询接口,供前端实时监听;
- 处理移动端扫码与确认登录请求。
创建项目文件夹并初始化
mkdir qrcode-server
cd qrcode-server
pnpm init
安装依赖
pnpm add express qrcode cors body-parser
pnpm add @types/node @types/express @types/qrcode @types/cors @types/body-parser typescript -D
初始化 TS 配置
npx tsc --init
基本配置
创建 src/main.ts
文件。
// 引入依赖
import express from 'express'
import QRCode from 'qrcode'
import fs from 'fs'
import cors from 'cors'
import bodyParser from 'body-parser'
// 创建 express 实例
const app = express()
// 登录记录类型
type Record = {
identifier: string
status: 'wait' | 'expired' | 'confirmed' | 'handling' | 'invalid'
timer: null | NodeJS.Timeout
username: string | null
}
// 单个二维码记录(Demo 中仅支持一个登录会话)
const record: Record = {
identifier: '',
status: 'wait',
timer: null,
username: null,
}
// 中间件配置
// 跨域配置
app.use(cors())
// 请求体解析
app.use(bodyParser.json())
// 静态资源托管
app.use('/', express.static('static'))
获取二维码接口
默认二维码一分钟后过期,当生成新的二维码时,旧的二维码直接作废。
app.get('/api/login/qrcode', async (req, res) => {
const identifier = +new Date()
record.identifier = identifier.toString()
record.status = 'wait'
// 生成二维码
const file = await QRCode.toBuffer('token#' + identifier, {
type: 'png', width: 300, margin: 1
})
// 写入文件
fs.writeFileSync('static/qrcode.png', file, 'binary')
res.header('Content-Type', 'application/json').send({
identifier,
qrcodeUrl: '/qrcode.png',
})
if (record.timer) {
clearTimeout(record.timer)
}
record.timer = setTimeout(
() => {
record.identifier = ''
record.status = 'expired'
if (record.timer) {
clearTimeout(record.timer)
}
record.timer = null
},
// 一分钟后过期
1 * 60 * 1000,
)
})
扫码二维码接口
用来标记和更改二维码状态。
app.post('/api/login/scan', async (req, res) => {
const { identifier } = req.body
const result = { status: 'success' }
// 标识不对,标记为作废
if (identifier !== record.identifier) {
result.status = record.status = 'invalid'
} else {
// 标识匹配,标记为正在处理
result.status = record.status = 'handling'
}
res.header('Content-Type', 'application/json').send(result)
})
查询二维码状态接口
app.get('/api/login/status', async (req, res) => {
const identifier = req.query.identifier
const result = { status: record.status, username: record.username }
if (identifier !== record.identifier && record.identifier) {
result.status = 'invalid'
}
res.header('Content-Type', 'application/json').send(result)
})
移动端扫码登录接口
app.post('/api/login', async (req, res) => {
// 从移动端获取扫描的二维码标识,用户 token 和用户名
const { identifier, token, username } = req.body
const result = { status: 'success' }
// 与当前二维码标识不对 无效
if (identifier !== record.identifier) {
result.status = 'invalid'
}
// 匹配标识 允许登录
else {
record.status = 'confirmed'
}
record.username = username
res.header('Content-Type', 'application/json').send(result)
})
启动项目
// 运行在 5500 端口上
app.listen(5500, () => {
console.log('run in 5500')
})
NodeJS v23.6.0 开始,Node 原生支持运行 .ts
文件。
如果没有升级的话需要使用 tsc
编译一下。
node src/main.ts
桌面端
创建文件 qrcode-server/static/index.html
。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>扫码登录 / 注册</title>
<style>
body {
font-family: sans-serif;
text-align: center;
padding-top: 50px;
}
#qrcode {
width: 200px;
height: 200px;
margin: 20px auto;
border: 1px solid #ccc;
}
.expired {
filter: brightness(.9) blur(5px);
}
</style>
</head>
<body>
<h1>扫码登录 / 注册</h1>
<div id="qrcode">正在加载二维码...</div>
<p id="status">等待扫码...</p>
<p id='countdown'>有效时间: 0</p>
<script>
let identifier = null
let timer = null
const qrcodeDiv = document.getElementById("qrcode")
const statusText = document.getElementById("status")
const countdown = document.getElementById("countdown")
const TIME = 1 * 60
async function fetchQRCode() {
try {
const res = await fetch('/api/login/qrcode')
const data = await res.json()
identifier = data.identifier
const imageUrl = data.qrcodeUrl
qrcodeDiv.classList = []
qrcodeDiv.innerHTML = `<img src="${imageUrl}" alt="扫码二维码" width="200" height="200" />`
statusText.innerText = "请使用手机扫码登录"
let t = TIME
countdown.innerText = "有效时间: " + t--
if (timer) clearInterval(timer)
timer = setInterval(() => {
if (t < 0) return clearInterval(timer)
countdown.innerText = "有效时间: " + t--
}, 1000)
pollLoginStatus()
} catch (err) {
statusText.innerText = "获取二维码失败,请重试"
}
}
async function pollLoginStatus() {
const interval = setInterval(async () => {
if (!identifier) return
try {
const res = await fetch(`/api/login/status?identifier=${encodeURIComponent(identifier)}`)
const data = await res.json()
if (data.status === 'confirmed') {
clearInterval(interval)
if (timer) clearInterval(timer)
statusText.innerText = `登录成功,欢迎 ${data.username || '用户'}!`
} else if (data.status === 'expired') {
clearInterval(interval)
if (timer) clearInterval(timer)
qrcodeDiv.classList.add('expired')
statusText.innerText = '二维码已过期,请刷新重试'
} else if (data.status === 'handling') {
statusText.innerText = '请在手机上确认登录'
}
} catch (err) {
console.err({ err })
clearInterval(interval)
statusText.innerText = "登录状态查询失败"
}
}, 2000) // 每 2 秒轮询一次
}
fetchQRCode();
</script>
</body>
</html>
运行服务端后,访问 localhost:5500/index.html
即可访问桌面端网页。
移动端
可以参考 官方教程 来创建 Tauri + NextJS 项目。
然后安装使用 Tauri 官方提供的扫码插件:
pnpm tauri add barcode-scanner
iOS 项目要额外增加一个 src-tauri/Info.ios.plist
文件。 用来标注获取相机权限。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Read QR codes</string>
</dict>
</plist>
随后创建 src/app/scan/page.tsx
和 src/app/scan/sure/page.tsx
两个页面文件,分别用于扫码和确认登录。
// src/app/scan/page.tsx
'use client'
import style from './page.module.scss'
import { scan, Format } from '@tauri-apps/plugin-barcode-scanner'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
export default function Scan() {
const [scanStatus, setScanStatus] = useState(false)
const router = useRouter()
useEffect(() => {
setScanStatus(true)
// Tauri 的扫码插件不会唤起系统扫码界面,
// 而是直接在当前 WebView 界面上渲染摄像头画面,
// 可能被背景或其他组件遮挡,因此需要将页面背景隐藏。
document.body.style.background = 'none'
scan({ windowed: true, formats: [Format.QRCode], cameraDirection: 'back' })
.then(({ content = '' }) => content.split('#')[1] ?? '')
.then(async (identifier) => {
const response = await fetch('http://localhost:5500/api/login/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier }),
})
const { status } = await response.json()
return { status, identifier }
})
.then(({ status, identifier }) => {
if (status === 'handling') {
router.replace('/scan/sure?content=' + identifier)
} else {
throw status
}
})
.catch((err) => {
console.error(err)
router.push('/')
})
.finally(() => {
setScanStatus(false)
document.body.style.background = ''
})
}, [])
return <div className={scanStatus ? style.container : ''}></div>
}
页面对应的样式文件。为页面增加了一个扫描的动效。
// src/app/scan/page.module.scss
@charset "UTF-8";
.container {
width: 100vw;
height: 100vh;
position: relative;
background: transparent;
&::after {
position: absolute;
content: '';
width: 100%;
height: 0.125rem;
background-color: skyblue;
animation: scan-line 3s infinite linear;
}
}
@keyframes scan-line {
from {
margin-top: 0;
}
to {
margin-top: 100%;
}
}
// src/app/scan/sure/page.tsx
'use client'
import Image from 'next/image'
import style from './page.module.scss'
import { useRouter, useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
function Sure() {
const router = useRouter()
const query = useSearchParams()
const identifier = query.get('content')
const confirmLogin = async () => {
const response = await fetch('http://localhost:5500/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier,
token: identifier,
username: 'admin',
}),
})
const { status } = await response.json()
if (status === 'success') {
console.log('登录成功')
router.replace('/')
} else {
console.log('登录失败')
}
}
return (
<div className={style.container}>
<div className={style.desktop}>
<Image
className={style.effect}
src='/images/desktop/effect.webp'
alt='desktop effect'
width={1920}
height={1080}
/>
</div>
<div className={style.buttongroup}>
<button className={style.sure} onClick={confirmLogin}>
确认登录
</button>
<button className={style.cancel} onClick={() => router.back()}>
取消
</button>
</div>
</div>
)
}
export default function SurePage() {
return (
<Suspense fallback={<></>}>
<Sure />
</Suspense>
)
}
结尾
通过本文,我们完成了一个基于 Tauri + NextJS 的扫码登录功能,从服务端二维码生成、状态轮询,到移动端扫码确认,构建了一套完整的登录流程。
这个 Demo 简单但清晰,涵盖了扫码登录的核心逻辑,适合作为实际项目的起点或参考。
Tauri + NextJS 扫码登录
http://www.inksha.com/archives/tauri-nextjs-sao-ma-deng-lu