feat(ui): 新增 Skin Hub 品牌图标并统一登录页标识
- 新增蓝紫渐变宝石图标(SVG / ICO / apple-touch-icon / 192 / 512) - 登录页品牌位由字母 S 换成同一 BrandMark - index.html 补齐图标、theme-color 与页面描述
This commit is contained in:
+8
-1
@@ -2,8 +2,15 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Skin Hub 游戏皮肤分销平台:商品、订单、发货与钱包的一体化管理后台。"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>游戏皮肤分销系统</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 1015 B |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
+205
-116
@@ -1,138 +1,202 @@
|
||||
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'
|
||||
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
|
||||
username: string;
|
||||
password: string;
|
||||
smsCode: string;
|
||||
};
|
||||
|
||||
/** 与站点图标同源的品牌标记:蓝紫渐变 + 白色宝石。 */
|
||||
function BrandMark({ size = 48 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
role="img"
|
||||
aria-label="Skin Hub 游戏皮肤分销平台"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="skinBrand"
|
||||
x1="8"
|
||||
y1="4"
|
||||
x2="56"
|
||||
y2="60"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#3B82F6" />
|
||||
<stop offset="0.55" stopColor="#2563EB" />
|
||||
<stop offset="1" stopColor="#7C3AED" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="17" fill="url(#skinBrand)" />
|
||||
<polygon points="20,16 44,16 52,27 32,52 12,27" fill="#fff" />
|
||||
<path
|
||||
d="M12 27h40M20 16l6 11M44 16l-6 11M26 27l6 25 6-25"
|
||||
fill="none"
|
||||
stroke="#2563EB"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startCountdown = (seconds: number) => {
|
||||
setCountdown(seconds)
|
||||
if (timerRef.current) window.clearInterval(timerRef.current)
|
||||
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
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return current - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
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}`)
|
||||
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},可使用下方「旧账号仅密码登录」`)
|
||||
const text = error instanceof Error ? error.message : "验证码发送失败";
|
||||
if (text.includes("未绑定手机号")) {
|
||||
message.warning(`${text},可使用下方「旧账号仅密码登录」`);
|
||||
} else {
|
||||
message.error(text)
|
||||
message.error(text);
|
||||
}
|
||||
} finally {
|
||||
setSending(false)
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: LoginForm) => {
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
setLoading(true)
|
||||
setWaiting(false)
|
||||
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)
|
||||
});
|
||||
setPhoneMasked(prepared.phoneMasked);
|
||||
setWaiting(true);
|
||||
message.info("已发起本机验证,请在弹出的 DCAuth 窗口中确认");
|
||||
launchProtocol(prepared.launchUrl);
|
||||
|
||||
const result = await waitForDevice(prepared)
|
||||
applyLoginResult(result)
|
||||
message.success('登录成功,欢迎回来!')
|
||||
navigate('/', { replace: true })
|
||||
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('当前设备与账号绑定设备不一致,请联系管理员重置设备')
|
||||
const text =
|
||||
error instanceof Error ? error.message : "登录失败,请检查账号密码";
|
||||
if (text.includes("未绑定手机号")) {
|
||||
message.warning(`${text},可使用下方「旧账号仅密码登录」`);
|
||||
} else if (text.includes("绑定设备不一致")) {
|
||||
message.error("当前设备与账号绑定设备不一致,请联系管理员重置设备");
|
||||
} else {
|
||||
message.error(text)
|
||||
message.error(text);
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setLoading(false)
|
||||
setWaiting(false)
|
||||
submittingRef.current = false;
|
||||
setLoading(false);
|
||||
setWaiting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submitLegacyLogin = async () => {
|
||||
if (submittingRef.current) return
|
||||
let values: LoginForm
|
||||
if (submittingRef.current) return;
|
||||
let values: LoginForm;
|
||||
try {
|
||||
values = await form.validateFields(['username', 'password'])
|
||||
values = await form.validateFields(["username", "password"]);
|
||||
} catch {
|
||||
return
|
||||
return;
|
||||
}
|
||||
submittingRef.current = true
|
||||
setLoading(true)
|
||||
submittingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(values.username.trim(), values.password)
|
||||
message.success('登录成功,欢迎回来!')
|
||||
navigate('/', { replace: true })
|
||||
await login(values.username.trim(), values.password);
|
||||
message.success("登录成功,欢迎回来!");
|
||||
navigate("/", { replace: true });
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '账号密码登录失败')
|
||||
message.error(
|
||||
error instanceof Error ? error.message : "账号密码登录失败",
|
||||
);
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setLoading(false)
|
||||
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 }}>
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div className="login-logo">
|
||||
<BrandMark size={48} />
|
||||
</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 }}>
|
||||
<Typography.Text style={{ color: "#94a3b8", fontSize: 13 }}>
|
||||
账号密码 + 短信验证码 + DCAuth 本机设备签名
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -146,14 +210,19 @@ export default function Login() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} requiredMark={false}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
requiredMark={false}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
rules={[{ required: true, message: "请输入用户名" }]}
|
||||
style={{ marginBottom: 18 }}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#64748b' }} />}
|
||||
prefix={<UserOutlined style={{ color: "#64748b" }} />}
|
||||
placeholder="用户名(支持中文 / 英文)"
|
||||
size="large"
|
||||
autoComplete="username"
|
||||
@@ -163,11 +232,11 @@ export default function Login() {
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
rules={[{ required: true, message: "请输入密码" }]}
|
||||
style={{ marginBottom: 18 }}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#64748b' }} />}
|
||||
prefix={<LockOutlined style={{ color: "#64748b" }} />}
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
autoComplete="current-password"
|
||||
@@ -177,12 +246,14 @@ export default function Login() {
|
||||
|
||||
<Form.Item
|
||||
name="smsCode"
|
||||
rules={[{ required: true, message: '请输入短信验证码' }]}
|
||||
rules={[{ required: true, message: "请输入短信验证码" }]}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Input
|
||||
prefix={<SafetyCertificateOutlined style={{ color: '#64748b' }} />}
|
||||
placeholder={`6 位短信验证码${phoneMasked ? `(${phoneMasked})` : ''}`}
|
||||
prefix={
|
||||
<SafetyCertificateOutlined style={{ color: "#64748b" }} />
|
||||
}
|
||||
placeholder={`6 位短信验证码${phoneMasked ? `(${phoneMasked})` : ""}`}
|
||||
size="large"
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
@@ -195,9 +266,9 @@ export default function Login() {
|
||||
loading={sending}
|
||||
disabled={countdown > 0}
|
||||
onClick={() => void sendCode()}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
style={{ padding: 0, height: "auto" }}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "获取验证码"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -216,8 +287,9 @@ export default function Login() {
|
||||
borderRadius: 8,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
|
||||
boxShadow: '0 4px 14px rgba(37, 99, 235, 0.4)',
|
||||
background:
|
||||
"linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)",
|
||||
boxShadow: "0 4px 14px rgba(37, 99, 235, 0.4)",
|
||||
}}
|
||||
>
|
||||
安全登录 · 拉起本机验证
|
||||
@@ -225,54 +297,71 @@ export default function Login() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider plain style={{ margin: '0 0 4px' }}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>未绑定手机号的历史账号</Typography.Text>
|
||||
<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
|
||||
type="link"
|
||||
block
|
||||
disabled={loading}
|
||||
onClick={() => void submitLegacyLogin()}
|
||||
>
|
||||
旧账号仅密码登录
|
||||
</Button>
|
||||
|
||||
<Typography.Paragraph
|
||||
style={{ fontSize: 12, margin: 0, textAlign: 'center', color: '#64748b' }}
|
||||
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) {
|
||||
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 (status.status === "expired" || status.status === "rejected") {
|
||||
throw new Error(status.message || "本机验证已过期,请重新登录");
|
||||
}
|
||||
if (attempt === 4) {
|
||||
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth 客户端')
|
||||
message.info(
|
||||
"如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth 客户端",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error('等待本机验证超时,请确认 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)
|
||||
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))
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user