Merge pull request #1 from KarCharon/main
feat: add login/auth system + blog page with rainbow design
This commit is contained in:
commit
6037a8186c
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,3 +26,4 @@ lerna-debug.log*
|
||||
|
||||
# --- Server ---
|
||||
Server/target
|
||||
Server/.vite
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
import { CursorFollower } from './components/CursorFollower'
|
||||
import { LoginPage } from './components/LoginPage'
|
||||
import { BlogPage } from './components/BlogPage'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
|
||||
function App() {
|
||||
// 登录态从 localStorage 恢复:已登录直接进博客页,否则进登录页。
|
||||
const auth = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<CursorFollower />
|
||||
<LoginPage />
|
||||
{auth.username ? (
|
||||
<BlogPage username={auth.username} onSignOut={auth.signOut} />
|
||||
) : (
|
||||
<LoginPage onSignIn={auth.signIn} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
38
Client/src/api/auth.ts
Normal file
38
Client/src/api/auth.ts
Normal file
@ -0,0 +1,38 @@
|
||||
// 登录接口封装。后端基址默认本机 8080,可用 VITE_API_BASE 覆盖
|
||||
// (与 useSystemStats 保持一致)。
|
||||
const API_BASE =
|
||||
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080'
|
||||
|
||||
export interface LoginResult {
|
||||
ok: boolean
|
||||
username: string
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交用户名/明文密码到后端校验。
|
||||
* - 成功:解析 200 响应体(含 token)。
|
||||
* - 凭据错误:后端返回 401,这里抛出可读错误。
|
||||
* - 后端不可达:fetch 失败,抛出网络错误。
|
||||
*/
|
||||
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/web/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
} catch {
|
||||
throw new Error('无法连接服务器,请确认后端已启动')
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
throw new Error('用户名或密码错误')
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`登录失败(HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
return (await res.json()) as LoginResult
|
||||
}
|
||||
184
Client/src/components/BlogPage.tsx
Normal file
184
Client/src/components/BlogPage.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useSystemStats } from '../hooks/useSystemStats'
|
||||
import { fadeUp, stagger } from './motion'
|
||||
import { AnnouncementBar } from './AnnouncementBar'
|
||||
import { RainbowSpotlight } from './RainbowSpotlight'
|
||||
|
||||
interface BlogPageProps {
|
||||
/** 当前登录用户名 */
|
||||
username: string
|
||||
/** 退出登录 */
|
||||
onSignOut: () => void
|
||||
}
|
||||
|
||||
interface Post {
|
||||
title: string
|
||||
date: string
|
||||
readingTime: string
|
||||
excerpt: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
// 示例文章数据(占位)。将来可换成从后端 /api/web/posts 拉取。
|
||||
const POSTS: Post[] = [
|
||||
{
|
||||
title: 'An Interactive Guide to CSS Gradients',
|
||||
date: 'Jun 2026',
|
||||
readingTime: '12 min',
|
||||
excerpt:
|
||||
'从线性到锥形渐变,拆解每个参数如何影响最终画面,并顺手做一道流动的彩虹。',
|
||||
tag: 'CSS',
|
||||
},
|
||||
{
|
||||
title: '用 Rust + axum 写一个会呼吸的监控后端',
|
||||
date: 'May 2026',
|
||||
readingTime: '9 min',
|
||||
excerpt:
|
||||
'采样与请求解耦、RwLock 快照、CORS 只拦浏览器——把顶部栏那行系统状态讲透。',
|
||||
tag: 'Rust',
|
||||
},
|
||||
{
|
||||
title: 'framer-motion 里那些恰到好处的微交互',
|
||||
date: 'Apr 2026',
|
||||
readingTime: '7 min',
|
||||
excerpt: '光标跟随、入场编排、spring 手感。动效不是炫技,是把注意力放对地方。',
|
||||
tag: 'Motion',
|
||||
},
|
||||
{
|
||||
title: '为什么我给只有 20 个用户的系统拒绝了数据库',
|
||||
date: 'Mar 2026',
|
||||
readingTime: '6 min',
|
||||
excerpt: '一个进程内 HashMap 就够了。聊聊在小规模下,简单是怎样跑赢“正确架构”的。',
|
||||
tag: 'Backend',
|
||||
},
|
||||
]
|
||||
|
||||
export function BlogPage({ username, onSignOut }: BlogPageProps) {
|
||||
// 顶部栏沿用系统状态彩蛋,保持与登录页同一风格。
|
||||
const stats = useSystemStats()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col bg-bg">
|
||||
<AnnouncementBar text={stats.text} online={stats.online} />
|
||||
|
||||
{/* 顶部导航 */}
|
||||
<header className="relative z-20 border-b border-line/60">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-6 py-5">
|
||||
<span className="rainbow-text font-display text-lg font-semibold tracking-tight">
|
||||
cc.dev
|
||||
</span>
|
||||
<div className="flex items-center gap-4 text-sm text-muted">
|
||||
<span className="hidden sm:inline">
|
||||
Signed in as <span className="text-text">{username}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onSignOut}
|
||||
className="rounded-full border border-line px-4 py-1.5 text-xs font-medium uppercase tracking-[0.16em] text-muted transition-colors hover:border-accent hover:text-accent"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero:整段全宽,彩虹光斑横跨整个页面;文字内容仍居中限宽 */}
|
||||
<section className="relative w-full overflow-hidden py-20 sm:py-28">
|
||||
<RainbowSpotlight />
|
||||
|
||||
<motion.div
|
||||
variants={stagger(0.15, 0.12)}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="relative z-10 mx-auto flex w-full max-w-3xl flex-col gap-6 px-6"
|
||||
>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="text-xs font-medium uppercase tracking-[0.3em] text-accent"
|
||||
>
|
||||
Personal blog
|
||||
</motion.span>
|
||||
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
className="font-display text-5xl font-medium leading-[1.05] text-text sm:text-6xl lg:text-7xl"
|
||||
>
|
||||
Hello, I'm <span className="rainbow-text italic">cc</span>
|
||||
<span className="cursor-bar ml-1 inline-block not-italic text-accent">_</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="max-w-xl text-base leading-relaxed text-muted"
|
||||
>
|
||||
我在这里写关于前端动效、Rust 后端和把事情做简单的笔记。把鼠标移到这片区域,
|
||||
会有一道彩虹跟着你——灵感来自{' '}
|
||||
<a
|
||||
href="https://www.joshwcomeau.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent underline-offset-4 hover:underline"
|
||||
>
|
||||
Josh W. Comeau
|
||||
</a>
|
||||
。
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* 文章列表 */}
|
||||
<section className="mx-auto w-full max-w-3xl border-t border-line/60 px-6 py-14">
|
||||
<h2 className="mb-10 text-xs font-medium uppercase tracking-[0.3em] text-muted">
|
||||
Latest writing
|
||||
</h2>
|
||||
|
||||
<motion.ul
|
||||
variants={stagger(0.1, 0.08)}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex flex-col"
|
||||
>
|
||||
{POSTS.map((post) => (
|
||||
<motion.li key={post.title} variants={fadeUp}>
|
||||
<a
|
||||
href="#"
|
||||
className="group flex flex-col gap-3 border-b border-line/50 py-7 transition-colors sm:flex-row sm:items-baseline sm:gap-8"
|
||||
>
|
||||
{/* 左侧彩虹竖条:hover 时出现 */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="rainbow-gradient hidden w-1 self-stretch rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100 sm:block"
|
||||
/>
|
||||
<div className="w-28 shrink-0 font-mono text-xs uppercase tracking-wider text-muted">
|
||||
<div>{post.date}</div>
|
||||
<div className="mt-1 text-accent/70">{post.tag}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="rainbow-clip font-display text-xl font-medium text-text transition-colors duration-200 group-hover:text-transparent">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<span className="mt-2 inline-block text-xs text-muted/70">
|
||||
{post.readingTime} read
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* 页脚:一条流动彩虹装饰线 */}
|
||||
<footer className="mt-auto">
|
||||
<div className="rainbow-gradient h-1 w-full" />
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-6 py-8 text-xs text-muted">
|
||||
<span>© {new Date().getFullYear()} cc</span>
|
||||
<span className="font-mono tracking-wider">built with React · Rust · 🌈</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,14 +1,35 @@
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { fadeUp, stagger } from './motion'
|
||||
import { TextField } from './ui/TextField'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
// 右侧登录表单:纯 UI,暂无后端逻辑。
|
||||
export function LoginForm() {
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
interface LoginFormProps {
|
||||
/** 校验凭据;失败时抛错 */
|
||||
onSignIn: (username: string, password: string, remember: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
// 右侧登录表单:接 Rust 后端鉴权。无注册入口(用户由后端写死)。
|
||||
export function LoginForm({ onSignIn }: LoginFormProps) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
// TODO: 接入 Rust 后端鉴权
|
||||
if (submitting) return
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSignIn(username.trim(), password, remember)
|
||||
// 成功后由上层切换到博客页,本组件随之卸载,无需额外处理。
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败')
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -25,7 +46,15 @@ export function LoginForm() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-7">
|
||||
<motion.div variants={fadeUp}>
|
||||
<TextField label="Email" type="email" name="email" autoComplete="email" />
|
||||
<TextField
|
||||
label="Username"
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeUp}>
|
||||
@ -34,9 +63,23 @@ export function LoginForm() {
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-sm text-[#e07a5f]"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
className="flex items-center justify-between text-sm"
|
||||
@ -44,29 +87,20 @@ export function LoginForm() {
|
||||
<label className="flex cursor-pointer items-center gap-2 text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="h-4 w-4 accent-[var(--color-accent)]"
|
||||
/>
|
||||
Remember me
|
||||
</label>
|
||||
<a
|
||||
href="#"
|
||||
className="text-muted underline-offset-4 transition-colors hover:text-accent hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeUp} className="mt-2">
|
||||
<Button type="submit">Sign in</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</form>
|
||||
|
||||
<motion.p variants={fadeUp} className="mt-8 text-center text-sm text-muted">
|
||||
New here?{' '}
|
||||
<a href="#" className="text-accent underline-offset-4 hover:underline">
|
||||
Create an account
|
||||
</a>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { useSystemStats } from '../hooks/useSystemStats'
|
||||
import type { Auth } from '../hooks/useAuth'
|
||||
import { AnnouncementBar } from './AnnouncementBar'
|
||||
import { AtmospherePanel } from './AtmospherePanel'
|
||||
import { LoginForm } from './LoginForm'
|
||||
|
||||
export function LoginPage() {
|
||||
interface LoginPageProps {
|
||||
onSignIn: Auth['signIn']
|
||||
}
|
||||
|
||||
export function LoginPage({ onSignIn }: LoginPageProps) {
|
||||
// 每秒轮询后端系统状态,展示在顶部栏。
|
||||
const stats = useSystemStats()
|
||||
|
||||
@ -14,7 +19,7 @@ export function LoginPage() {
|
||||
<main className="grid flex-1 grid-cols-1 lg:grid-cols-[1.4fr_1fr]">
|
||||
<AtmospherePanel />
|
||||
<div className="flex items-center justify-center bg-bg">
|
||||
<LoginForm />
|
||||
<LoginForm onSignIn={onSignIn} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
62
Client/src/components/RainbowSpotlight.tsx
Normal file
62
Client/src/components/RainbowSpotlight.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useRef } from 'react'
|
||||
import { motion, useMotionValue, useReducedMotion, useSpring } from 'framer-motion'
|
||||
|
||||
// 跟随鼠标的彩虹光斑(仿 joshwcomeau 的交互彩虹)。
|
||||
// 自身 pointer-events-none、置于内容下层,靠监听父元素的鼠标事件取坐标,
|
||||
// 因此不会挡住 hero 里的文字交互。挂在一个 relative 容器内使用。
|
||||
export function RainbowSpotlight() {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const reduce = useReducedMotion()
|
||||
|
||||
const x = useMotionValue(0)
|
||||
const y = useMotionValue(0)
|
||||
const springX = useSpring(x, { stiffness: 180, damping: 28, mass: 0.6 })
|
||||
const springY = useSpring(y, { stiffness: 180, damping: 28, mass: 0.6 })
|
||||
const opacity = useMotionValue(0)
|
||||
const springOpacity = useSpring(opacity, { stiffness: 120, damping: 26 })
|
||||
|
||||
// 在父元素上挂监听:pointer-events-none 收不到事件,故委托给父级。
|
||||
const attach = (node: HTMLDivElement | null) => {
|
||||
ref.current = node
|
||||
const parent = node?.parentElement
|
||||
if (!parent) return
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = parent.getBoundingClientRect()
|
||||
x.set(e.clientX - rect.left)
|
||||
y.set(e.clientY - rect.top)
|
||||
opacity.set(0.45)
|
||||
}
|
||||
const onLeave = () => opacity.set(0)
|
||||
parent.addEventListener('mousemove', onMove)
|
||||
parent.addEventListener('mouseleave', onLeave)
|
||||
}
|
||||
|
||||
const RAINBOW =
|
||||
'conic-gradient(from 0deg, #ff5f6d, #ffb13d, #ffe14d, #4ade80, #38bdf8, #a78bfa, #f472b6, #ff5f6d)'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={attach}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="absolute h-[40rem] w-[40rem] rounded-full"
|
||||
style={{
|
||||
x: springX,
|
||||
y: springY,
|
||||
translateX: '-50%',
|
||||
translateY: '-50%',
|
||||
opacity: springOpacity,
|
||||
background: RAINBOW,
|
||||
filter: 'blur(90px)',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
animate={reduce ? undefined : { rotate: 360 }}
|
||||
transition={
|
||||
reduce ? undefined : { duration: 14, ease: 'linear', repeat: Infinity }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -17,6 +17,7 @@ export function Button({ children, className = '', ...props }: ButtonProps) {
|
||||
'group relative w-full overflow-hidden rounded-full bg-accent px-6 py-3.5 ' +
|
||||
'text-sm font-medium uppercase tracking-[0.18em] text-bg ' +
|
||||
'transition-colors hover:bg-accent-soft ' +
|
||||
'disabled:cursor-not-allowed disabled:opacity-60 ' +
|
||||
className
|
||||
}
|
||||
{...props}
|
||||
|
||||
62
Client/src/hooks/useAuth.ts
Normal file
62
Client/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { login as loginRequest } from '../api/auth'
|
||||
|
||||
// localStorage 键:记住登录态时把会话写在这里,刷新后仍处于登录态。
|
||||
const STORAGE_KEY = 'ip.auth'
|
||||
|
||||
interface Session {
|
||||
username: string
|
||||
token: string
|
||||
}
|
||||
|
||||
function loadSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<Session>
|
||||
if (typeof parsed.username === 'string' && typeof parsed.token === 'string') {
|
||||
return { username: parsed.username, token: parsed.token }
|
||||
}
|
||||
} catch {
|
||||
// 解析失败(被手动改坏等)当作未登录
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface Auth {
|
||||
/** 已登录则为当前用户名,否则为 null */
|
||||
username: string | null
|
||||
/** 校验凭据;remember 为 true 时持久化到 localStorage */
|
||||
signIn: (username: string, password: string, remember: boolean) => Promise<void>
|
||||
/** 退出登录并清除持久化会话 */
|
||||
signOut: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录态管理。初始值从 localStorage 恢复(记住登录态),
|
||||
* 因此刷新页面后仍停留在登录后界面。
|
||||
*/
|
||||
export function useAuth(): Auth {
|
||||
const [session, setSession] = useState<Session | null>(loadSession)
|
||||
|
||||
const signIn = useCallback(
|
||||
async (username: string, password: string, remember: boolean) => {
|
||||
const result = await loginRequest(username, password)
|
||||
const next: Session = { username: result.username, token: result.token }
|
||||
setSession(next)
|
||||
if (remember) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
setSession(null)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}, [])
|
||||
|
||||
return { username: session?.username ?? null, signIn, signOut }
|
||||
}
|
||||
@ -99,10 +99,121 @@ body {
|
||||
animation: blink 1.15s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
/* ── 彩虹(仿 joshwcomeau)─────────────────────────────────────
|
||||
一条横向流动的多色渐变。首尾同色,配合 200% 宽度做无缝平移。
|
||||
既可裁切到文字,也可铺在装饰条上。 */
|
||||
@keyframes rainbow-pan {
|
||||
to {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
.rainbow-gradient {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
#ff5f6d,
|
||||
#ffb13d,
|
||||
#ffe14d,
|
||||
#4ade80,
|
||||
#38bdf8,
|
||||
#a78bfa,
|
||||
#f472b6,
|
||||
#ff5f6d
|
||||
);
|
||||
background-size: 200% auto;
|
||||
animation: rainbow-pan 6s linear infinite;
|
||||
}
|
||||
|
||||
/* 把流动彩虹裁切进文字(始终显示彩虹色) */
|
||||
.rainbow-text {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
#ff5f6d,
|
||||
#ffb13d,
|
||||
#ffe14d,
|
||||
#4ade80,
|
||||
#38bdf8,
|
||||
#a78bfa,
|
||||
#f472b6,
|
||||
#ff5f6d
|
||||
);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: rainbow-pan 6s linear infinite;
|
||||
}
|
||||
|
||||
/* 把彩虹裁进文字但不强制颜色:默认用不透明文字色盖住,
|
||||
hover 时把文字色设为 transparent(group-hover:text-transparent)即露出彩虹。 */
|
||||
.rainbow-clip {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
#ff5f6d,
|
||||
#ffb13d,
|
||||
#ffe14d,
|
||||
#4ade80,
|
||||
#38bdf8,
|
||||
#a78bfa,
|
||||
#f472b6,
|
||||
#ff5f6d
|
||||
);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: rainbow-pan 6s linear infinite;
|
||||
}
|
||||
|
||||
/* ── 整页彩虹极光背景(玻璃栏透出的就是它)──────────────────────
|
||||
固定铺满视口、置于所有内容之下。多个彩色径向光斑缓慢漂移,
|
||||
再大幅模糊成柔和极光。底色为暗色,避免内容区透出时发灰。 */
|
||||
.aurora {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -10;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.aurora::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -25%;
|
||||
background:
|
||||
radial-gradient(28% 30% at 18% 28%, rgba(255, 95, 109, 0.55), transparent 70%),
|
||||
radial-gradient(26% 30% at 82% 26%, rgba(56, 189, 248, 0.50), transparent 70%),
|
||||
radial-gradient(30% 34% at 72% 76%, rgba(167, 139, 250, 0.50), transparent 70%),
|
||||
radial-gradient(28% 32% at 26% 80%, rgba(74, 222, 128, 0.45), transparent 70%),
|
||||
radial-gradient(24% 28% at 50% 50%, rgba(255, 177, 61, 0.40), transparent 70%);
|
||||
filter: blur(70px);
|
||||
animation: drift 28s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── 两侧液态玻璃竖栏 ─────────────────────────────────────────
|
||||
backdrop-filter 把背后的极光磨成柔光;内侧一道高光边模拟玻璃厚度。 */
|
||||
.glass-rail {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(16px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(140%);
|
||||
box-shadow: inset 0 0 60px rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* 中间内容纸张:也带轻微玻璃感(压暗 + 模糊背后极光保证可读) */
|
||||
.glass-sheet {
|
||||
background: rgba(23, 19, 15, 0.82);
|
||||
backdrop-filter: blur(22px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(120%);
|
||||
}
|
||||
|
||||
/* ── reduced-motion 降级 ────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mesh,
|
||||
.cursor-bar {
|
||||
.cursor-bar,
|
||||
.rainbow-gradient,
|
||||
.rainbow-text,
|
||||
.rainbow-clip,
|
||||
.aurora::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
72
Server/Cargo.lock
generated
72
Server/Cargo.lock
generated
@ -66,6 +66,15 @@ version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@ -78,6 +87,35 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
@ -146,6 +184,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
@ -517,11 +565,23 @@ dependencies = [
|
||||
"axum",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@ -700,6 +760,12 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
@ -712,6 +778,12 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
||||
@ -3,10 +3,15 @@ name = "server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "RustServer"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.9"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
sha2 = "0.10.9"
|
||||
sysinfo = "0.39.3"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
tower-http = { version = "0.7.0", features = ["cors", "fs"] }
|
||||
|
||||
93
Server/src/auth.rs
Normal file
93
Server/src/auth.rs
Normal file
@ -0,0 +1,93 @@
|
||||
//! 用户存储与登录校验。
|
||||
//!
|
||||
//! 设计取舍:用户数很少(≤ 20),所以**不上数据库**——一个进程内的
|
||||
//! `HashMap<用户名, 密码SHA256(hex)>` 足矣,启动时构建一次、之后只读。
|
||||
//! 数据库要付出的连接池、迁移、Schema 维护成本,在这个规模下并不划算。
|
||||
//!
|
||||
//! 现在用户表是**写死**在 `UserStore::seed()` 里的(仅 `cc` 一个)。
|
||||
//! 将来用户变多时,迁移路径很短,且 handler 完全无需改动:
|
||||
//! 1. 改成从 TOML/JSON 文件读:把 `seed()` 换成读文件 → 解析 → 填 map;
|
||||
//! 2. 真要运行时增删用户 / 权限 / 审计,再考虑换 SQLite/Postgres。
|
||||
//!
|
||||
//! 口令永不明文存储:表里存的是 SHA256(hex),登录时把传入明文同样
|
||||
//! 哈希后做**等长比较**。(注意:SHA256 仅用于“不落明文”,并非抗暴力破解的
|
||||
//! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// 进程内用户表:用户名 → 密码的 SHA256(小写 hex)。
|
||||
pub struct UserStore {
|
||||
users: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl UserStore {
|
||||
/// 构建写死的用户表。将来可替换为「从文件读取」。
|
||||
pub fn seed() -> Self {
|
||||
let mut users = HashMap::new();
|
||||
|
||||
// cc / 192118Lht
|
||||
// 下面这串是 SHA256("192118Lht") 的 hex,明文不进源码。
|
||||
users.insert(
|
||||
"cc".to_string(),
|
||||
"0086e83c9b108b227eed55425e9641286f42bd0c31a1b95afbb9edd6d3aa6234".to_string(),
|
||||
);
|
||||
|
||||
Self { users }
|
||||
}
|
||||
|
||||
/// 校验用户名 + 明文密码是否匹配。
|
||||
///
|
||||
/// 用户名不存在与密码错误都返回 `false`,不向外区分两者
|
||||
/// (避免泄露“某用户名是否存在”)。
|
||||
pub fn verify(&self, username: &str, password: &str) -> bool {
|
||||
match self.users.get(username) {
|
||||
Some(expected_hex) => {
|
||||
let actual_hex = sha256_hex(password);
|
||||
constant_time_eq(actual_hex.as_bytes(), expected_hex.as_bytes())
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 签发一个登录 token。
|
||||
///
|
||||
/// 不引入 `rand`/`uuid`:用「纳秒时间戳 + 进程内单调计数器」拼出唯一输入,
|
||||
/// 再 SHA256 成不可预测的 hex。够当登录态标识用;将来要做真正的会话校验,
|
||||
/// 应换成签名 token(如 JWT)或服务端会话表。
|
||||
pub fn issue_token() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
sha256_hex(&format!("{nanos}-{n}"))
|
||||
}
|
||||
|
||||
/// 计算 SHA256 并返回小写 hex 字符串。
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
let digest = Sha256::digest(input.as_bytes());
|
||||
let mut out = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 等长定值时间比较,避免按字节短路造成的时序侧信道。
|
||||
/// 两串长度不同直接判否(hex 定长 64,正常等长)。
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
//! - `GET /api/web/stats` 给 React 前端(挂 CORS)
|
||||
//! - `GET /api/client/health` 给未来 .NET 客户端(不挂 CORS)
|
||||
|
||||
mod auth;
|
||||
mod monitor;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
@ -1,21 +1,31 @@
|
||||
//! 前端(React)专用接口。挂 CORS,仅放行前端开发源。
|
||||
|
||||
use axum::{Json, Router, extract::State, http::Method, routing::get};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
http::{Method, StatusCode, header},
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
|
||||
use crate::{monitor::Stats, state::AppState};
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
// CORS:只有浏览器会校验。放行前端开发源 + GET 方法即可。
|
||||
// CORS:只有浏览器会校验。放行前端开发源。
|
||||
// 登录是 POST + JSON,所以要额外放行 POST 方法与 Content-Type 请求头,
|
||||
// 否则浏览器的预检(OPTIONS)会被拦下。
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list([
|
||||
"http://localhost:5173".parse().unwrap(),
|
||||
"http://127.0.0.1:5173".parse().unwrap(),
|
||||
]))
|
||||
.allow_methods([Method::GET]);
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_headers([header::CONTENT_TYPE]);
|
||||
|
||||
Router::new()
|
||||
.route("/api/web/stats", get(stats))
|
||||
.route("/api/web/login", post(login))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
@ -25,3 +35,35 @@ async fn stats(State(state): State<AppState>) -> Json<Stats> {
|
||||
let snapshot = state.snapshot.read().await.clone();
|
||||
Json(snapshot)
|
||||
}
|
||||
|
||||
/// 登录请求体:用户名 + 明文密码(开发期走 HTTP;生产应套 HTTPS)。
|
||||
#[derive(Deserialize)]
|
||||
struct LoginRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
/// 登录成功响应。`token` 目前只是给前端持久化「登录态」用的标识,
|
||||
/// 暂无受保护接口去校验它;将来加鉴权中间件时再赋予真实含义。
|
||||
#[derive(Serialize)]
|
||||
struct LoginResponse {
|
||||
ok: bool,
|
||||
username: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// 校验用户名/密码。成功 200 + token,失败 401。
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, StatusCode> {
|
||||
if state.users.verify(&req.username, &req.password) {
|
||||
Ok(Json(LoginResponse {
|
||||
ok: true,
|
||||
username: req.username,
|
||||
token: crate::auth::issue_token(),
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,18 +7,22 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::auth::UserStore;
|
||||
use crate::monitor::Stats;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// 最新一次采样快照。
|
||||
pub snapshot: Arc<RwLock<Stats>>,
|
||||
/// 用户表:启动时构建一次、之后只读,故无需锁,用 `Arc` 共享即可。
|
||||
pub users: Arc<UserStore>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
snapshot: Arc::new(RwLock::new(Stats::default())),
|
||||
users: Arc::new(UserStore::seed()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user