- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone - 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号 - 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码 - 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
279 lines
9.7 KiB
TypeScript
279 lines
9.7 KiB
TypeScript
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))
|
||
}
|