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.tsxsrc/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
作者
inksha
发布于
2025年06月11日
许可协议