342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
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))
|
||
}
|