feat(ui): 重做登录页并替换默认站点图标

- 登录页改为左侧品牌区 + 右侧表单卡,补字段标签、三步流程与 DCAuth 等待面板
- 新增自定义站点图标(SVG / ICO / apple-touch-icon / 192 / 512)
- index.html 补齐图标、theme-color 与页面描述
- 修复 client.ts 中既有的 any 类型告警
This commit is contained in:
yml2213
2026-10-21 22:04:44 +08:00
parent 2f5a63ae27
commit 3761267015
9 changed files with 749 additions and 154 deletions
+9 -2
View File
@@ -1,9 +1,16 @@
<!doctype html>
<html lang="en">
<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="#0b1020" />
<meta
name="description"
content="直播运营后台:直播账号、任务、自动化与数据的一站式管理平台。"
/>
<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: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+9 -1
View File
@@ -25,7 +25,15 @@ api.interceptors.response.use(
if (typeof detail === 'string') {
msg = detail;
} else if (Array.isArray(detail) && detail.length > 0) {
msg = detail.map((d: any) => d?.msg || (typeof d === 'string' ? d : JSON.stringify(d))).join('; ');
msg = detail
.map((item: unknown) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object' && 'msg' in item) {
return String((item as { msg?: unknown }).msg ?? '');
}
return JSON.stringify(item);
})
.join('; ');
} else if (detail && typeof detail === 'object') {
msg = JSON.stringify(detail);
}
+446
View File
@@ -34,3 +34,449 @@ html[data-theme='dark'] body {
.huya-collab-home-drop .ant-upload-btn {
padding: 10px 12px !important;
}
/* ---------- 登录页 ---------- */
.login-page {
--login-card-bg: rgba(255, 255, 255, 0.92);
--login-border: rgba(15, 23, 42, 0.08);
--login-text: #0f172a;
--login-muted: #64748b;
--login-step-bg: rgba(15, 23, 42, 0.03);
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
overflow: hidden;
background:
radial-gradient(900px 520px at 12% 8%, rgba(99, 102, 241, 0.28), transparent 62%),
radial-gradient(820px 520px at 88% 92%, rgba(6, 182, 212, 0.24), transparent 60%),
#eef2ff;
}
.login-page.is-dark {
--login-card-bg: rgba(15, 23, 42, 0.86);
--login-border: rgba(148, 163, 184, 0.16);
--login-text: #e2e8f0;
--login-muted: #94a3b8;
--login-step-bg: rgba(148, 163, 184, 0.06);
background:
radial-gradient(900px 520px at 12% 8%, rgba(79, 70, 229, 0.32), transparent 62%),
radial-gradient(820px 520px at 88% 92%, rgba(6, 182, 212, 0.2), transparent 60%),
#070b16;
}
.login-backdrop {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(148, 163, 184, 0.12) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
background-size: 46px 46px;
mask-image: radial-gradient(ellipse at 50% 42%, #000 26%, transparent 74%);
-webkit-mask-image: radial-gradient(ellipse at 50% 42%, #000 26%, transparent 74%);
opacity: 0.7;
}
.login-page.is-dark .login-backdrop {
background-image:
linear-gradient(rgba(148, 163, 184, 0.16) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.16) 1px, transparent 1px);
opacity: 0.5;
}
.login-shell {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.05fr) 440px;
width: min(1040px, 100%);
overflow: hidden;
border-radius: 28px;
border: 1px solid var(--login-border);
background: var(--login-card-bg);
box-shadow: 0 40px 90px -40px rgba(15, 23, 42, 0.55);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.login-brand {
position: relative;
display: flex;
flex-direction: column;
gap: 34px;
padding: 44px 46px;
color: #fff;
background:
radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.3), transparent 56%),
linear-gradient(150deg, #4338ca 0%, #2563eb 48%, #06b6d4 100%);
}
.login-brand::before,
.login-brand::after {
content: '';
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.login-brand::before {
width: 320px;
height: 320px;
right: -120px;
top: -90px;
background: rgba(255, 255, 255, 0.14);
}
.login-brand::after {
width: 240px;
height: 240px;
left: -110px;
bottom: -120px;
background: rgba(7, 11, 22, 0.18);
}
.login-brand-head {
position: relative;
display: flex;
align-items: center;
gap: 12px;
}
.login-brand-head strong {
display: block;
font-size: 16px;
letter-spacing: 0.3px;
}
.login-brand-head span {
display: block;
margin-top: 2px;
font-size: 11.5px;
letter-spacing: 1.2px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.66);
}
.login-brand-body {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.login-brand-body h1 {
margin: 0 0 14px;
font-size: 34px;
line-height: 1.28;
letter-spacing: -0.6px;
font-weight: 700;
}
.login-brand-body p {
margin: 0 0 30px;
max-width: 340px;
font-size: 14px;
line-height: 1.8;
color: rgba(255, 255, 255, 0.78);
}
.login-features {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.login-features li {
display: flex;
align-items: center;
gap: 12px;
}
.login-feature-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
flex: none;
border-radius: 12px;
font-size: 17px;
color: #fff;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.login-features strong {
display: block;
font-size: 13.5px;
font-weight: 600;
}
.login-features em {
display: block;
margin-top: 2px;
font-style: normal;
font-size: 12.5px;
color: rgba(255, 255, 255, 0.7);
}
.login-brand-foot {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 18px;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.login-version {
padding: 2px 8px;
border-radius: 999px;
font-variant-numeric: tabular-nums;
color: #fff;
background: rgba(255, 255, 255, 0.16);
}
.login-card {
display: flex;
flex-direction: column;
padding: 42px 40px 30px;
min-height: 560px;
}
.login-card-head {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 22px;
}
.login-card-head h2 {
margin: 0;
font-size: 23px;
line-height: 1.3;
letter-spacing: -0.3px;
color: var(--login-text);
}
.login-card-head p {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--login-muted);
}
.login-logo {
flex: none;
border-radius: 14px;
filter: drop-shadow(0 10px 18px rgba(37, 99, 235, 0.28));
}
.login-steps {
list-style: none;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 0 0 20px;
padding: 0;
}
.login-steps li {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 10px;
border-radius: 12px;
border: 1px solid var(--login-border);
background: var(--login-step-bg);
font-size: 12.5px;
color: var(--login-muted);
transition: all 0.2s ease;
}
.login-steps li.is-active {
color: #38bdf8;
border-color: rgba(56, 189, 248, 0.45);
background: rgba(56, 189, 248, 0.12);
}
.login-step-icon {
display: inline-flex;
font-size: 14px;
}
.login-form .ant-form-item {
margin-bottom: 16px;
}
.login-form .ant-form-item-label {
padding-bottom: 4px;
}
.login-form .ant-form-item-label > label {
font-size: 12.5px;
color: var(--login-muted);
}
.login-form .ant-input-affix-wrapper,
.login-form .ant-input {
border-radius: 11px;
}
.login-form .ant-input-affix-wrapper .ant-input {
border-radius: 0;
}
.login-code-btn {
height: auto !important;
padding: 0 2px !important;
font-size: 12.5px;
}
.login-submit-item {
margin-top: 22px;
margin-bottom: 0 !important;
}
.login-submit-item .ant-btn {
height: 46px;
border-radius: 12px;
font-weight: 600;
letter-spacing: 0.3px;
}
.login-submit-item .ant-btn-primary {
border: none;
background: linear-gradient(135deg, #4f46e5 0%, #2563eb 55%, #06b6d4 100%);
box-shadow: 0 16px 30px -16px rgba(37, 99, 235, 0.9);
}
.login-submit-item .ant-btn-primary:not(:disabled):hover {
background: linear-gradient(135deg, #4338ca 0%, #1d4ed8 55%, #0891b2 100%);
}
.login-waiting {
display: flex;
gap: 12px;
margin-bottom: 16px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(56, 189, 248, 0.35);
background: rgba(56, 189, 248, 0.1);
}
.login-waiting-icon {
display: grid;
place-items: center;
width: 32px;
height: 32px;
flex: none;
border-radius: 50%;
font-size: 17px;
color: #38bdf8;
background: rgba(56, 189, 248, 0.16);
animation: login-pulse 1.4s ease-in-out infinite;
}
@keyframes login-pulse {
0%,
100% {
transform: scale(1);
opacity: 0.85;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
.login-waiting-body strong {
display: block;
font-size: 13.5px;
color: var(--login-text);
}
.login-waiting-body p {
margin: 4px 0 9px;
font-size: 12px;
line-height: 1.6;
color: var(--login-muted);
}
.login-copy {
font-size: 12px;
}
.login-notice {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 18px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.1);
color: #b45309;
font-size: 12.5px;
line-height: 1.65;
}
.login-page.is-dark .login-notice {
color: #fbbf24;
}
.login-switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
padding-top: 16px;
border-top: 1px dashed var(--login-border);
font-size: 12.5px;
color: var(--login-muted);
}
.login-switch-center {
justify-content: center;
border-top: none;
padding-top: 8px;
}
@media (max-width: 980px) {
.login-shell {
grid-template-columns: 1fr;
width: min(462px, 100%);
}
.login-brand {
display: none;
}
.login-card {
min-height: 0;
padding: 34px 26px 26px;
}
}
@media (max-width: 420px) {
.login-page {
padding: 16px;
}
.login-steps li {
padding: 8px 6px;
font-size: 11.5px;
}
.login-card-head h2 {
font-size: 21px;
}
}
+268 -150
View File
@@ -1,11 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Card, Divider, Form, Input, Space, Typography } from 'antd';
import { Button, Form, Input, Space, Typography } from 'antd';
import type { InputRef } from 'antd';
import { message } from '../utils/antdMessage';
import {
ArrowLeftOutlined,
DesktopOutlined,
LoadingOutlined,
LockOutlined,
MobileOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SafetyOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
@@ -16,11 +21,50 @@ import { getErrorMessage } from '../utils/error';
import { useTheme } from '../store/useTheme';
import { useAppInfo } from '../hooks/useAppInfo';
const { Title, Text } = Typography;
const { Text } = Typography;
type LoginMode = 'secure' | 'legacy';
type LoginValues = { username: string; password: string; smsCode?: string };
const FLOW_STEPS = [
{ title: '账号密码', icon: <LockOutlined /> },
{ title: '短信验证码', icon: <MobileOutlined /> },
{ title: '本机确认', icon: <SafetyCertificateOutlined /> },
];
const BRAND_FEATURES = [
{ icon: <LockOutlined />, title: '账号密码', desc: '第一重校验,拒绝弱口令' },
{ icon: <MobileOutlined />, title: '短信验证码', desc: '绑定手机接收一次性验证码' },
{ icon: <DesktopOutlined />, title: '本机 DCAuth', desc: 'Ed25519 设备签名,换机即失效' },
];
function LogoMark({ size = 40 }: { size?: number }) {
return (
<svg
className="login-logo"
width={size}
height={size}
viewBox="0 0 64 64"
role="img"
aria-label="直播运营后台"
>
<defs>
<linearGradient id="loginBrand" x1="4" y1="2" x2="60" y2="62" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#6366F1" />
<stop offset="0.55" stopColor="#3B82F6" />
<stop offset="1" stopColor="#06B6D4" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="17" fill="url(#loginBrand)" />
<path
d="M25.6 20.4c0-1.15 1.26-1.86 2.24-1.26l18.2 11.6c.94.6.94 1.98 0 2.58l-18.2 11.6c-.98.6-2.24-.11-2.24-1.26V20.4Z"
fill="#fff"
/>
<circle cx="47.5" cy="16.5" r="6.2" fill="#F43F5E" stroke="#0B1020" strokeWidth="2.6" />
</svg>
);
}
function errorCodeOf(error: unknown): string {
return (error as { errorCode?: string } | null)?.errorCode || '';
}
@@ -228,161 +272,235 @@ export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
}
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: isDark
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
<Card
style={{
width: 420,
boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)',
}}
>
<Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
直播运营后台
</Title>
<Text
type="secondary"
style={{
display: 'block',
textAlign: 'center',
marginTop: -20,
marginBottom: 24,
fontSize: 12,
}}
>
当前版本 {versionText}
</Text>
<div className={`login-page${isDark ? ' is-dark' : ''}`}>
<div className="login-backdrop" aria-hidden="true" />
<div className="login-shell">
<aside className="login-brand">
<div className="login-brand-head">
<LogoMark size={44} />
<div>
<strong>直播运营后台</strong>
<span>Live Operations Console</span>
</div>
</div>
{mode === 'secure' ? (
<>
<Alert
type="info"
showIcon
message="安全登录"
description="用户名密码验证后,还需短信验证码和本机 DCAuth 设备签名。"
style={{ marginBottom: 16 }}
/>
<div className="login-brand-body">
<h1>
安全登录,
<br />
从这台设备开始
</h1>
<p>账号密码、短信验证码与本机 DCAuth 签名三重校验,账号被盗也进不来。</p>
{waiting ? (
<Alert
type="warning"
showIcon
message="等待本机确认"
description={
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<span>请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth。</span>
{launchUrl ? (
<Text code copyable={{ text: launchUrl }} style={{ wordBreak: 'break-all' }}>
{launchUrl}
<ul className="login-features">
{BRAND_FEATURES.map((item) => (
<li key={item.title}>
<span className="login-feature-icon">{item.icon}</span>
<span>
<strong>{item.title}</strong>
<em>{item.desc}</em>
</span>
</li>
))}
</ul>
</div>
<div className="login-brand-foot">
<span className="login-version">{versionText}</span>
<span>仅限内部运营人员使用</span>
</div>
</aside>
<section className="login-card">
<header className="login-card-head">
<LogoMark size={46} />
<div>
<h2>{mode === 'secure' ? '安全登录' : '密码登录'}</h2>
<p>
直播运营后台 · <span>{versionText}</span>
</p>
</div>
</header>
{mode === 'secure' ? (
<>
<ol className="login-steps">
{FLOW_STEPS.map((step, index) => (
<li
key={step.title}
className={waiting && index === 2 ? 'is-active' : undefined}
>
<span className="login-step-icon">{step.icon}</span>
<span>{step.title}</span>
</li>
))}
</ol>
{waiting ? (
<div className="login-waiting">
<span className="login-waiting-icon">
<LoadingOutlined />
</span>
<div className="login-waiting-body">
<strong>等待本机确认</strong>
<p>请在 DCAuth 弹窗中点击「允许登录」;没弹出就点下面按钮重新唤起。</p>
<Space size={8} wrap>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => launchProtocol(launchUrl)}
>
重新唤起 DCAuth
</Button>
<Text code copyable={{ text: launchUrl }} className="login-copy">
复制协议链接
</Text>
) : null}
</Space>
}
style={{ marginBottom: 16 }}
/>
) : null}
</Space>
</div>
</div>
) : null}
<Form form={form} onFinish={handleSecureLogin} size="large" requiredMark={false}>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input
prefix={<UserOutlined />}
placeholder="用户名(中文 / 英文 / 数字)"
autoComplete="username"
/>
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
autoComplete="current-password"
ref={passwordRef}
/>
</Form.Item>
<Form.Item
name="smsCode"
rules={[{ required: true, message: '请输入短信验证码' }]}
<Form
form={form}
layout="vertical"
className="login-form"
onFinish={handleSecureLogin}
size="large"
requiredMark={false}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder={phoneMasked ? `6 位验证码(${phoneMasked})` : '6 位短信验证码'}
maxLength={6}
autoComplete="one-time-code"
addonAfter={
<Button
type="link"
size="small"
htmlType="button"
loading={sendingCode}
disabled={countdown > 0 || loading}
onClick={() => void sendCode()}
style={{ padding: 0, height: 'auto' }}
>
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
安全登录 · 拉起本机验证
</Button>
</Form.Item>
</Form>
<Form.Item
label="账号"
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="中文 / 英文 / 数字账号"
autoComplete="username"
disabled={waiting}
/>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入登录密码"
autoComplete="current-password"
ref={passwordRef}
disabled={waiting}
/>
</Form.Item>
<Form.Item
label="短信验证码"
name="smsCode"
rules={[{ required: true, message: '请输入短信验证码' }]}
>
<Input
prefix={<SafetyOutlined />}
placeholder={phoneMasked ? `已发送至 ${phoneMasked}` : '6 位数字验证码'}
maxLength={6}
inputMode="numeric"
autoComplete="one-time-code"
disabled={waiting}
addonAfter={
<Button
type="link"
size="small"
htmlType="button"
loading={sendingCode}
disabled={countdown > 0 || loading || waiting}
onClick={() => void sendCode()}
className="login-code-btn"
>
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Form.Item className="login-submit-item">
<Button type="primary" htmlType="submit" loading={loading} block>
{waiting ? '等待本机确认…' : '安全登录 · 验证本机'}
</Button>
</Form.Item>
</Form>
<Divider plain style={{ margin: '4px 0 12px' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
未绑定手机号的旧账号
</Text>
</Divider>
<Button type="link" block onClick={() => switchMode('legacy')} disabled={loading}>
旧账号仅密码登录
</Button>
</>
) : (
<>
<Alert
type="warning"
showIcon
message="旧账号仅密码登录"
description="仅未绑定手机号的账号可用;已绑定手机号的账号会被要求使用安全登录。"
style={{ marginBottom: 16 }}
/>
<Form form={form} onFinish={handleLegacyLogin} size="large" requiredMark={false}>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
autoComplete="username"
/>
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
autoComplete="current-password"
ref={passwordRef}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
密码登录
<div className="login-switch">
<span>未绑定手机号的旧账号</span>
<Button
type="link"
size="small"
onClick={() => switchMode('legacy')}
disabled={loading || waiting}
>
仅密码登录
</Button>
</Form.Item>
</Form>
<Button type="link" block onClick={() => switchMode('secure')} disabled={loading}>
<MobileOutlined /> 返回安全登录
</Button>
</>
)}
</Card>
</div>
</>
) : (
<>
<div className="login-notice">
<SafetyCertificateOutlined />
<span>
仅未绑定手机号的账号可用;已绑定手机号的账号会被要求使用安全登录。
</span>
</div>
<Form
form={form}
layout="vertical"
className="login-form"
onFinish={handleLegacyLogin}
size="large"
requiredMark={false}
>
<Form.Item
label="账号"
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="中文 / 英文 / 数字账号"
autoComplete="username"
/>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入登录密码"
autoComplete="current-password"
ref={passwordRef}
/>
</Form.Item>
<Form.Item className="login-submit-item">
<Button type="primary" htmlType="submit" loading={loading} block>
密码登录
</Button>
</Form.Item>
</Form>
<div className="login-switch login-switch-center">
<Button
type="link"
size="small"
icon={<ArrowLeftOutlined />}
onClick={() => switchMode('secure')}
disabled={loading}
>
返回安全登录
</Button>
</div>
</>
)}
</section>
</div>
</div>
);
}