feat(ui): 新增 Skin Hub 品牌图标并统一登录页标识

- 新增蓝紫渐变宝石图标(SVG / ICO / apple-touch-icon / 192 / 512)
- 登录页品牌位由字母 S 换成同一 BrandMark
- index.html 补齐图标、theme-color 与页面描述
This commit is contained in:
yml2213
2026-10-21 22:40:12 +08:00
parent bab522bcb3
commit 6a2b95d300
7 changed files with 237 additions and 118 deletions
+8 -1
View File
@@ -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
View File
@@ -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
}
return current - 1
})
}, 1000)
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}`)
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));
}