Files
affiliate_dash/frontend/src/pages/Login.tsx
T
yml2213 bab522bcb3 feat(auth): 接入 DCAuth v2 登录并完善成员管理
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone
- 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号
- 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码
- 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
2026-10-21 22:37:49 +08:00

279 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Alert, Button, Divider, Form, Input, Space, Typography, message } from 'antd'
import { LockOutlined, RightOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons'
import { authApi } from '../api'
import type { PreparedDeviceLogin } from '../types'
import { useAuth } from '../store/authContext'
type LoginForm = {
username: string
password: string
smsCode: string
}
export default function Login() {
const { login, applyLoginResult } = useAuth()
const navigate = useNavigate()
const [form] = Form.useForm<LoginForm>()
const [loading, setLoading] = useState(false)
const [sending, setSending] = useState(false)
const [countdown, setCountdown] = useState(0)
const [waiting, setWaiting] = useState(false)
const [phoneMasked, setPhoneMasked] = useState('')
const timerRef = useRef<number | null>(null)
const submittingRef = useRef(false)
useEffect(() => {
return () => {
if (timerRef.current) window.clearInterval(timerRef.current)
}
}, [])
const startCountdown = (seconds: number) => {
setCountdown(seconds)
if (timerRef.current) window.clearInterval(timerRef.current)
timerRef.current = window.setInterval(() => {
setCountdown((current) => {
if (current <= 1) {
if (timerRef.current) window.clearInterval(timerRef.current)
return 0
}
return current - 1
})
}, 1000)
}
const sendCode = async () => {
try {
const values = await form.validateFields(['username', 'password'])
setSending(true)
const result = await authApi.sendLoginSmsCode(values.username.trim(), values.password)
setPhoneMasked(result.phoneMasked)
startCountdown(60)
message.success(`验证码已发送至 ${result.phoneMasked}`)
} catch (error) {
const text = error instanceof Error ? error.message : '验证码发送失败'
if (text.includes('未绑定手机号')) {
message.warning(`${text},可使用下方「旧账号仅密码登录」`)
} else {
message.error(text)
}
} finally {
setSending(false)
}
}
const onFinish = async (values: LoginForm) => {
if (submittingRef.current) return
submittingRef.current = true
setLoading(true)
setWaiting(false)
try {
const prepared = await authApi.prepareLogin({
username: values.username.trim(),
password: values.password,
smsCode: values.smsCode.trim(),
apiBase: window.location.origin,
})
setPhoneMasked(prepared.phoneMasked)
setWaiting(true)
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认')
launchProtocol(prepared.launchUrl)
const result = await waitForDevice(prepared)
applyLoginResult(result)
message.success('登录成功,欢迎回来!')
navigate('/', { replace: true })
} catch (error) {
const text = error instanceof Error ? error.message : '登录失败,请检查账号密码'
if (text.includes('未绑定手机号')) {
message.warning(`${text},可使用下方「旧账号仅密码登录」`)
} else if (text.includes('绑定设备不一致')) {
message.error('当前设备与账号绑定设备不一致,请联系管理员重置设备')
} else {
message.error(text)
}
} finally {
submittingRef.current = false
setLoading(false)
setWaiting(false)
}
}
const submitLegacyLogin = async () => {
if (submittingRef.current) return
let values: LoginForm
try {
values = await form.validateFields(['username', 'password'])
} catch {
return
}
submittingRef.current = true
setLoading(true)
try {
await login(values.username.trim(), values.password)
message.success('登录成功,欢迎回来!')
navigate('/', { replace: true })
} catch (error) {
message.error(error instanceof Error ? error.message : '账号密码登录失败')
} finally {
submittingRef.current = false
setLoading(false)
}
}
return (
<div className="login-wrapper">
<div className="login-card">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<div className="login-logo">S</div>
<Typography.Title level={3} style={{ margin: '0 0 6px 0', color: '#ffffff', fontWeight: 700 }}>
Skin Hub 游戏皮肤分销平台
</Typography.Title>
<Typography.Text style={{ color: '#94a3b8', fontSize: 13 }}>
账号密码 + 短信验证码 + DCAuth 本机设备签名
</Typography.Text>
</div>
{waiting ? (
<Alert
type="info"
showIcon
message="等待本机确认"
description="请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth 客户端。"
/>
) : null}
<Form form={form} layout="vertical" onFinish={onFinish} requiredMark={false}>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
style={{ marginBottom: 18 }}
>
<Input
prefix={<UserOutlined style={{ color: '#64748b' }} />}
placeholder="用户名(支持中文 / 英文)"
size="large"
autoComplete="username"
style={{ borderRadius: 8 }}
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
style={{ marginBottom: 18 }}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#64748b' }} />}
placeholder="密码"
size="large"
autoComplete="current-password"
style={{ borderRadius: 8 }}
/>
</Form.Item>
<Form.Item
name="smsCode"
rules={[{ required: true, message: '请输入短信验证码' }]}
style={{ marginBottom: 24 }}
>
<Input
prefix={<SafetyCertificateOutlined style={{ color: '#64748b' }} />}
placeholder={`6 位短信验证码${phoneMasked ? `(${phoneMasked})` : ''}`}
size="large"
maxLength={6}
autoComplete="one-time-code"
style={{ borderRadius: 8 }}
addonAfter={
<Button
type="link"
size="small"
htmlType="button"
loading={sending}
disabled={countdown > 0}
onClick={() => void sendCode()}
style={{ padding: 0, height: 'auto' }}
>
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
size="large"
icon={<RightOutlined />}
style={{
height: 46,
borderRadius: 8,
fontSize: 15,
fontWeight: 600,
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
boxShadow: '0 4px 14px rgba(37, 99, 235, 0.4)',
}}
>
安全登录 · 拉起本机验证
</Button>
</Form.Item>
</Form>
<Divider plain style={{ margin: '0 0 4px' }}>
<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>未绑定手机号的历史账号</Typography.Text>
</Divider>
<Button type="link" block disabled={loading} onClick={() => void submitLegacyLogin()}>
旧账号仅密码登录
</Button>
<Typography.Paragraph
style={{ fontSize: 12, margin: 0, textAlign: 'center', color: '#64748b' }}
>
已绑定手机号的账号必须通过短信验证码与 DCAuth 本机签名登录
</Typography.Paragraph>
</Space>
</div>
</div>
)
}
async function waitForDevice(prepared: PreparedDeviceLogin) {
for (let attempt = 0; attempt < 120; attempt += 1) {
await sleep(1000)
const status = await authApi.deviceChallengeStatus(prepared.challengeId, prepared.pollToken)
if (status.status === 'authorized' && status.deviceTicket) {
return authApi.completeLogin({
challengeId: prepared.challengeId,
pollToken: prepared.pollToken,
deviceTicket: status.deviceTicket,
})
}
if (status.status === 'expired' || status.status === 'rejected') {
throw new Error(status.message || '本机验证已过期,请重新登录')
}
if (attempt === 4) {
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth 客户端')
}
}
throw new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起')
}
function launchProtocol(url: string) {
const anchor = document.createElement('a')
anchor.href = url
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
window.setTimeout(() => anchor.remove(), 1000)
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}