Files
order_site/apps/frontend/src/pages/admin/AdminLoginPage.tsx
T
yml2213 040100f290 feat(dcauth): 新增后台手机号与设备认证登录
登录链路改为账号密码 + 短信验证码 + DCAuth 设备签名,并新增后台手机号绑定、
登录日志、设备绑定与重置接口。
2026-10-21 20:46:24 +08:00

342 lines
12 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 {
DashboardOutlined,
LockOutlined,
SafetyCertificateOutlined,
SettingOutlined,
UserOutlined,
} from '@ant-design/icons'
import { Alert, App, Button, Divider, Form, Input, Space, Typography } from 'antd'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import {
completeAdminLogin,
fetchAdminDeviceStatus,
loginAdmin,
prepareAdminLogin,
sendAdminLoginSmsCode,
} from '@/services/admin'
import type { AdminDevicePrepareResponse } from '@/types/admin'
import { setAdminSession } from '@/utils/admin-auth'
type LoginForm = {
username: string
password: string
smsCode: string
}
const brandFeatures = [
{
icon: <DashboardOutlined />,
title: '运营看板',
desc: '订单、打手、财务数据一目了然',
},
{
icon: <SafetyCertificateOutlined />,
title: '本机认证',
desc: '账号密码 + 短信验证码 + DCAuth 设备签名',
},
{
icon: <SettingOutlined />,
title: '灵活配置',
desc: '平台、规则、费率在线可调',
},
]
export default function AdminLoginPage() {
const navigate = useNavigate()
const { message } = App.useApp()
const [form] = Form.useForm<LoginForm>()
const [loading, setLoading] = useState(false)
const [sendingCode, setSendingCode] = useState(false)
const [countdown, setCountdown] = useState(0)
const [phoneMasked, setPhoneMasked] = useState('')
const [waiting, setWaiting] = useState(false)
const [devLaunchUrl, setDevLaunchUrl] = useState('')
const timerRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (timerRef.current) window.clearInterval(timerRef.current)
}
}, [])
function 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)
}
async function sendCode() {
try {
const values = await form.validateFields(['username', 'password'])
setSendingCode(true)
const response = await sendAdminLoginSmsCode({
username: values.username.trim(),
password: values.password.trim(),
})
setPhoneMasked(response.data.phoneMasked)
startCountdown(60)
message.success(`验证码已发送至 ${response.data.phoneMasked}`)
} catch (error) {
message.error(error instanceof Error ? error.message : '验证码发送失败')
} finally {
setSendingCode(false)
}
}
async function submitLogin(values: LoginForm) {
setLoading(true)
setWaiting(false)
try {
const prepared = await prepareAdminLogin({
username: values.username.trim(),
password: values.password.trim(),
smsCode: values.smsCode.trim(),
apiBase: window.location.origin,
})
setPhoneMasked(prepared.data.phoneMasked)
setWaiting(true)
setDevLaunchUrl(import.meta.env.DEV ? prepared.data.launchUrl : '')
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认')
launchProtocol(prepared.data.launchUrl)
const completed = await waitForDevice(prepared.data)
setAdminSession(completed.token, completed.expiresAt, completed.user)
message.success('登录成功')
void navigate('/admin/dashboard', { replace: true })
} catch (error) {
const code = (error as { errorCode?: string })?.errorCode
if (code === 'admin_phone_unbound') {
message.warning('该账号未绑定手机号,请联系管理员绑定后再使用安全登录')
} else {
message.error(error instanceof Error ? error.message : '登录失败')
}
} finally {
setLoading(false)
setWaiting(false)
setDevLaunchUrl('')
}
}
async function submitLegacyLogin() {
let values: Pick<LoginForm, 'username' | 'password'>
try {
values = await form.validateFields(['username', 'password'])
} catch {
return
}
setLoading(true)
try {
const response = await loginAdmin({
username: values.username.trim(),
password: values.password.trim(),
})
setAdminSession(response.data.token, response.data.expiresAt, response.data.user)
message.success('登录成功')
void navigate('/admin/dashboard', { replace: true })
} catch (error) {
message.error(error instanceof Error ? error.message : '账号密码登录失败')
} finally {
setLoading(false)
}
}
async function waitForDevice(prepared: AdminDevicePrepareResponse) {
for (let attempt = 0; attempt < 120; attempt += 1) {
await sleep(1000)
const status = await fetchAdminDeviceStatus(prepared.challengeId, prepared.pollToken)
if (status.data.status === 'authorized' && status.data.deviceTicket) {
const completed = await completeAdminLogin({
challengeId: prepared.challengeId,
pollToken: prepared.pollToken,
deviceTicket: status.data.deviceTicket,
})
return completed.data
}
if (status.data.status === 'expired' || status.data.status === 'rejected') {
throw new Error(status.data.message || '本机验证已过期,请重新登录')
}
if (attempt === 4) {
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth.exe')
}
}
throw new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起')
}
return (
<main className="auth-page">
<div className="auth-shell">
<aside className="auth-brand">
<div className="auth-brand-logo">
<span className="auth-logo-mark">
<DashboardOutlined />
</span>
<strong>Order Site Admin</strong>
</div>
<div className="auth-brand-body">
<h2>运营后台管理中心</h2>
<p>账号密码验证通过后,还需短信验证码和本机 DCAuth 设备签名才能进入后台。</p>
<div className="auth-brand-features">
{brandFeatures.map((item) => (
<div className="auth-brand-feature" key={item.title}>
<span className="auth-feature-icon">{item.icon}</span>
<div className="auth-brand-feature-text">
<strong>{item.title}</strong>
<span>{item.desc}</span>
</div>
</div>
))}
</div>
</div>
<div className="auth-brand-footer">
© {new Date().getFullYear()} Order Site · 保留所有权利
</div>
</aside>
<section className="auth-form-side">
<div className="auth-form-card">
<div className="auth-mobile-brand">
<span className="auth-logo-mark">
<DashboardOutlined />
</span>
<strong>Order Site Admin</strong>
</div>
<div className="auth-form-header">
<h1>运营后台登录</h1>
<p>账号密码 + 短信验证码 + 本机 DCAuth 设备验证</p>
</div>
{waiting ? (
<>
<Alert
type="info"
showIcon
message="等待本机确认"
description="请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth.exe。"
style={{ marginBottom: 16 }}
/>
{import.meta.env.DEV && devLaunchUrl ? (
<Alert
type="warning"
showIcon
message="macOS 开发调试"
description={
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Typography.Text type="secondary">
浏览器无法直接唤起 Mac 程序。复制下面的链接,在终端执行:
</Typography.Text>
<Typography.Paragraph copyable={{ text: devLaunchUrl }} style={{ margin: 0 }}>
{devLaunchUrl}
</Typography.Paragraph>
<Typography.Text code copyable={{ text: `cd apps/dcauth && go run . "${devLaunchUrl}"` }}>
cd apps/dcauth && go run . "{devLaunchUrl}"
</Typography.Text>
</Space>
}
style={{ marginBottom: 16 }}
/>
) : null}
</>
) : null}
<Form<LoginForm>
form={form}
layout="vertical"
requiredMark={false}
onFinish={submitLogin}
>
<Form.Item
name="username"
label="账号"
rules={[{ required: true, message: '请输入账号' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="中文 / 英文 / 数字账号"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="输入密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item
name="smsCode"
label={`短信验证码${phoneMasked ? `(${phoneMasked})` : ''}`}
rules={[{ required: true, message: '请输入短信验证码' }]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="6 位短信验证码"
maxLength={6}
autoComplete="one-time-code"
addonAfter={
<Button
type="link"
size="small"
htmlType="button"
loading={sendingCode}
disabled={countdown > 0}
onClick={() => void sendCode()}
style={{ padding: 0, height: 'auto' }}
>
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading} size="large">
安全登录 · 拉起本机验证
</Button>
</Form>
<Divider plain style={{ margin: '18px 0 12px' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
技术测试账号 / 未绑定手机号
</Typography.Text>
</Divider>
<Button type="link" block onClick={() => void submitLegacyLogin()} disabled={loading}>
账号密码登录(测试 / 过渡)
</Button>
<div className="auth-form-footer">仅限授权运营人员访问</div>
</div>
</section>
</div>
</main>
)
}
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))
}