Compare commits

...

11 Commits

Author SHA1 Message Date
cc
f576b03315 feat(start): 填充起始页快捷入口并补文档
搜索框下方快捷入口由占位空槽改为 8 个实链接:
第一行 DeepSeek/GitHub/Bilibili/知乎/FMHY,
第二行 Rust/C++/C# 文档(col-start-2 居中)。
品牌图标内联 simple-icons 单 path,FMHY 用字标。
新增 docs/startpage-quicklinks.md 记录搜索框 + 入口及增删方法。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:10:38 +08:00
cc
d7d520058c docs: 补登录/入口门走 MySQL 的说明,修起始页过期引用
- 新增 docs/auth-login-gate.md:入口门 + 登录的 MySQL 后端、表结构、
  建库步骤、DATABASE_URL、无库兜底、安全待办
- startpage 文档:层级图删掉已移除的「齿轮」;weather.env 示例补 DATABASE_URL

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:49:04 +08:00
cc
4c8c4360ad feat(weather): 起始页天气/定位细节完善
- 新增 Client/src/lib/amap.ts:高德 JS 定位封装
- weather.ts / useWeather.ts:带经纬度/城市请求,定位失败不兜底
- Server weather.rs:相应调整
- 文档同步;.gitignore 忽略本地 .codegraph 索引

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:45:39 +08:00
cc
b2a0b563c9 feat(client): 隐藏登录入口改为后端校验 URL hash
去掉起始页右下角设置齿轮(SettingsGear),登录页不再有任何 UI 入口;
改为监听地址栏 `#<hash>`,经 checkGate() 交后端 /api/web/gate 校验,
命中合法开发者才放行到登录页。合法 hash 只存在后端、前端不写死。

- 新增 api/gate.ts;App.tsx 用 hashchange 监听 + 防竞态校验
- 返回起始页时抹掉地址栏 hash,避免密钥留在历史里
- Server/.gitignore 忽略 dev_gate.txt(秘密,单独部署)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:45:30 +08:00
cc
58bed0ae44 feat(server): 登录与开发者入口门改走 MySQL
UserStore/DevGate 不再写死,改为从 MySQL 实时查询(每次校验一条索引查询、
不缓存整表,照顾内存):
- 新增 db_pool_from_env(),UserStore 与 DevGate 共用一个 sqlx 懒连接池
- verify() 查 ccuser 表、allows() 查 dev_gate 表,均改为 async
- 未设置 DATABASE_URL 时各自回退到内置/文件兜底,便于本地无库开发

连接串经环境变量 DATABASE_URL 注入(线上写在 weather.env)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:43:23 +08:00
cc
f4342e7c82 docs: 天气定位失败时不兜底、不显示
线上不设 WEATHER_DEFAULT_LOCATION:高德定位失败即返回 failed,
不再回退默认城市、也不调和风,前端静默不显示。更新文档说明。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 20:35:54 +08:00
cc
a185e56c22 feat: 导航起始页 + 天气/定位 + 天气动态效果
起始页(StartPage)
- 极简玻璃风导航页作未登录首页:时段问候、Bing 默认可切换搜索、10 个快捷位、右下齿轮进登录
- 昼夜背景自动切换(light/dark),按天气图标码 + 本地时间判断
- 天气动态效果(WeatherFx canvas):雨/雪/雷,克制风格,尊重 reduced-motion

天气与定位(前后端)
- 后端 /api/web/weather:高德 IP 定位(城市+坐标) + 和风实时天气
- 和风用 JWT(EdDSA/Ed25519)认证,密钥走环境变量、仅后端持有
- 每日限流(高德 300、和风 900,北京 0 点归零) + 10 分钟按 IP 缓存
- 前端 useWeather + WeatherIcon(7 类极简 SVG)

导航与登录
- 浏览器返回键支持(History API);返回即登出
- 记住我:勾选存 localStorage、不勾存 sessionStorage,默认不勾

其他
- .gitignore 忽略 *.pem/*.env 等密钥
- 新增文档 docs/startpage-weather-location.md 及部署 spec/plan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 20:13:18 +08:00
cc
06bbf65d71 feat: read PORT from env var, default 8080 2026-06-18 17:08:03 +08:00
Ryan
c61b8de776 重构登录后博客页:Lunada 渐变背景 + 两侧图片液态玻璃;登录报错改英文
- 登录页凭据/网络/HTTP 错误提示改为英文
- 博客页改为:通栏顶部彩虹栏 + 通栏 Hero(彩虹光标动效)+ 图片·内容·图片三栏
- 两侧图片用 import.meta.glob 自动索引 src/assets/sides/,按高度自适应、居中,叠加液态玻璃覆层
- 背景改为 Lunada 渐变极光
- 性能优化:极光改静止、中间纸/顶栏去掉 backdrop-filter,降低多层毛玻璃每帧重算开销

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:57:00 +08:00
CC
6037a8186c
Merge pull request #1 from KarCharon/main
feat: add login/auth system + blog page with rainbow design
2026-06-18 00:02:20 +08:00
cc
58284f3fe0 feat: add login/auth system + blog page with rainbow design
- Client: auth API wrapper (api/auth.ts) + useAuth hook with localStorage persistence

- Client: LoginPage/LoginForm with remember-me and loading states

- Client: BlogPage with hero section, rainbow spotlight cursor effect, post list

- Client: CSS rainbow gradient utilities for text/borders/backgrounds

- Server: POST /api/web/login with username/password verification + token

- Server: CORS extended to POST + Content-Type header

- Server: auth.rs token issuer, serde dependency for JSON

- Chore: gitignore Server/.vite

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 00:00:38 +08:00
41 changed files with 4930 additions and 38 deletions

9
.gitignore vendored
View File

@ -26,3 +26,12 @@ lerna-debug.log*
# --- Server ---
Server/target
Server/.vite
# --- Tooling本地索引/缓存,勿入库)---
.codegraph/
# --- Secrets密钥/私钥/本地环境,永不入库)---
*.pem
*.env
weather.env

View File

@ -1,11 +1,60 @@
import { useEffect, useState } from 'react'
import { CursorFollower } from './components/CursorFollower'
import { LoginPage } from './components/LoginPage'
import { BlogPage } from './components/BlogPage'
import { StartPage } from './components/StartPage'
import { useAuth } from './hooks/useAuth'
import { checkGate } from './api/gate'
function App() {
// 登录态从 localStorage/sessionStorage 恢复:已登录直接进博客页。
const auth = useAuth()
// 是否显示登录页。入口 hash 是否合法由后端判定,故初值为 false等校验通过再开。
const [showLogin, setShowLogin] = useState(false)
const signOut = auth.signOut
// 监听地址栏 hash把 `#` 后那串发给后端校验,命中合法开发者才放行到登录页。
// 合法 hash 列表只存在后端,前端不写死,扒 JS 也看不到合法值。
useEffect(() => {
let alive = true
const check = async () => {
const hash = window.location.hash.replace(/^#/, '')
const ok = hash ? await checkGate(hash) : false
// 防竞态:异步返回时若组件已卸载或 hash 又变了,丢弃这次结果。
if (alive && hash === window.location.hash.replace(/^#/, '')) {
setShowLogin(ok)
}
}
check()
window.addEventListener('hashchange', check)
return () => {
alive = false
window.removeEventListener('hashchange', check)
}
}, [])
// 回到导航页:清掉登录态,并抹掉地址栏里的密钥(防止留在历史里被看到)。
const goStart = () => {
signOut()
setShowLogin(false)
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname + window.location.search)
}
}
let view
if (auth.username) {
view = <BlogPage username={auth.username} onSignOut={goStart} />
} else if (showLogin) {
view = <LoginPage onSignIn={auth.signIn} onBack={goStart} />
} else {
view = <StartPage />
}
return (
<>
<CursorFollower />
<LoginPage />
{view}
</>
)
}

38
Client/src/api/auth.ts Normal file
View 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('Cannot reach the server. Please make sure the backend is running.')
}
if (res.status === 401) {
throw new Error('Incorrect username or password')
}
if (!res.ok) {
throw new Error(`Sign-in failed (HTTP ${res.status})`)
}
return (await res.json()) as LoginResult
}

24
Client/src/api/gate.ts Normal file
View File

@ -0,0 +1,24 @@
// 入口门校验。把 URL `#` 后那串 hash 发给后端,问“是不是合法开发者的入口”。
// 后端基址默认本机 8080可用 VITE_API_BASE 覆盖(与 auth/useSystemStats 一致)。
const API_BASE =
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080'
/**
* hash true false
* / 200 false
*/
export async function checkGate(hash: string): Promise<boolean> {
if (!hash) return false
try {
const res = await fetch(`${API_BASE}/api/web/gate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash }),
})
if (!res.ok) return false
const data = (await res.json()) as { ok?: boolean }
return data.ok === true
} catch {
return false
}
}

51
Client/src/api/weather.ts Normal file
View File

@ -0,0 +1,51 @@
// 天气接口封装。后端在同一进程托管前端 + API
// - 开发期前端在 5173/5174后端在 8080 → 用绝对地址(触发 CORS已在后端放行
// - 生产期前端由后端同源托管 → 用相对地址(空 base免配置即可工作。
const API_BASE =
(import.meta.env.VITE_API_BASE as string | undefined) ??
(import.meta.env.DEV ? 'http://localhost:8080' : '')
export type WeatherResult =
| {
ok: true
cached: boolean
/** 城市名(来自高德定位) */
city: string
/** 天气文字,如“多云”(来自和风) */
text: string
/** 气温 ℃(来自和风) */
temp: string
/** 和风天气图标码 */
icon: string
obsTime: string
}
| {
ok: false
reason: 'quota_exceeded' | 'not_configured' | 'failed'
/** quota_exceeded 时为 'amap' | 'qweather' */
provider?: string
message?: string
}
/** 前端定位结果(来自 AMap.Geolocation缺省则后端走 IP 定位兜底。 */
export interface WeatherLocation {
lon: number
lat: number
city?: string
}
/**
*
* `loc` / IP
*/
export async function fetchWeather(loc?: WeatherLocation): Promise<WeatherResult> {
let url = `${API_BASE}/api/web/weather`
if (loc) {
const p = new URLSearchParams({ lon: String(loc.lon), lat: String(loc.lat) })
if (loc.city) p.set('city', loc.city)
url += `?${p.toString()}`
}
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as WeatherResult
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

View File

@ -0,0 +1,40 @@
# 登录后页面 · 两侧图片区
这里的图片走 **bundler import**:`BlogPage.tsx` 用 Vite 的 `import.meta.glob`
**自动索引本文件夹下的所有图片**,因此:
- **丢图进来即生效,无需改代码**;删图同理。
- 打包时自动加内容哈希(缓存失效)、构建期校验(图缺了会编译报错)、可接入优化。
- 引用路径不再写死字符串,所以**不要**再用 `public/` 里的 `/images/...` 方式。
## 取图规则
`BlogPage` 把本目录的图**按文件名升序**排列后取用:
| 顺序 | 位置 |
| ---- | ---- |
| 第 1 张 | 左栏 |
| 第 2 张 | 右栏 |
所以建议用 `side-01.jpg`、`side-02.jpg` 这种带序号的名字控制顺序;
想交换左右,改文件名顺序即可。多于两张时只会用到前两张(可按需扩展代码)。
支持的扩展名:`jpg` / `jpeg` / `png` / `webp`(在 `BlogPage.tsx` 的 glob 模式里调整)。
## 推荐尺寸 / 比例
两侧栏是**窄而高的竖条**(中间内容固定 `max-w-3xl`,两侧吃掉剩余宽度),
图片用 `background cover` 填满、居中裁切:
- **方向**:竖版(portrait)
- **比例**:约 `2:3` ~ `9:16`(页面越长越偏 `9:16`)
- **分辨率**:建议 ≥ `1000 × 1600`,高分屏清晰可用 `1200 × 1800`+
- **主体**:重要内容放画面中央(上下/左右边缘可能被裁)
- **格式**:`webp`(体积最小)> `jpg` > `png`;插画/需透明用 `png`
缺图时该区域会自动透出页面的 Lunada 渐变背景,不会显示裂图占位符。
## 当前文件
- `side-01.jpg` —— 左栏
- `side-02.jpg` —— 右栏

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

View File

@ -0,0 +1,39 @@
# Startpage 背景图
导航起始页(`src/components/StartPage.tsx`)的背景图放在这个目录里,
**按昼夜自动切换**
- 白天 → `light.*`(如 `light.jpg`
- 夜晚 → `dark.*`(如 `dark.png`
放好这两张文件即可生效,无需改代码。
## 工作方式
`StartPage` 用 Vite 的 `import.meta.glob` 索引本目录,按文件名精确取
`light.*` / `dark.*`,再按昼夜选用。
昼夜判断:**优先用天气接口返回的图标码**(晴/多云/阵雨类区分昼夜),
图标码判断不了的天气(雨、雪、雾、阴等无昼夜之分)则用**本地时间兜底**
18:0006:00 视为夜晚)。
回退顺序:选中的那张缺失 → 用另一张 → 都缺则用目录里排序第一张 →
再无则用中性渐变(构建始终成功)。
## 命名与规格
- 必须命名为 `light.*``dark.*`(扩展名任意:`.jpg/.jpeg/.png/.webp`)。
其它名字(如 `dark33.png`)不会被选用,只会白白打进包里,建议删掉。
- 格式:`webp`(最小)> `jpg` > `png`
- 横图、够大:≥ 1920×1080最好 2560×1440+
- 图上还会叠一层暗色 scrim + 半透明黑遮罩(保证文字可读),所以偏暗、
低对比的图效果最好。
## 本地预览(仅 `npm run dev`
不开后端也能预览昼夜与天气动效,加 URL 参数即可:
- `?bg=light` / `?bg=dark` —— 强制白天/夜晚背景
- `?fx=rain` / `?fx=snow` / `?fx=thunder` / `?fx=none` —— 强制动态效果
例:`http://localhost:5173/?bg=dark&fx=snow`

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -0,0 +1,226 @@
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',
},
]
// 两侧图片区的图片:用 Vite 的 import.meta.glob 自动索引 src/assets/sides/ 下的所有图。
// 走 bundler import —— 自带内容哈希、构建期校验、可优化;丢新图进该文件夹即生效,无需改代码。
// 按文件名升序取用:第 1 张 → 左栏,第 2 张 → 右栏。详见 src/assets/sides/README.md。
const sideImages = Object.entries(
import.meta.glob<string>('../assets/sides/*.{jpg,jpeg,png,webp}', {
eager: true,
query: '?url',
import: 'default',
}),
)
.sort(([pathA], [pathB]) => pathA.localeCompare(pathB))
.map(([, url]) => url)
const SIDE_IMAGE_LEFT = sideImages[0]
const SIDE_IMAGE_RIGHT = sideImages[1] ?? sideImages[0]
export function BlogPage({ username, onSignOut }: BlogPageProps) {
// 顶部栏沿用系统状态彩蛋,保持与登录页同一风格。
const stats = useSystemStats()
return (
<div className="relative flex min-h-svh flex-col">
{/* 整页 Lunada 渐变极光背景,固定铺满视口、置于所有内容之下 */}
<div className="aurora" aria-hidden />
<AnnouncementBar text={stats.text} online={stats.online} />
{/* 顶部彩虹栏:通栏,横跨左右图片区之上;底部一条流动彩虹线 */}
<header className="glass-sheet relative z-20 w-full">
<div className="mx-auto flex w-full max-w-6xl 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>
<div className="rainbow-gradient h-1 w-full" aria-hidden />
</header>
{/* Hero 通栏:彩虹光标动效 + 大字标题 + 灵感来源,占满一整行(在三栏之上) */}
<section className="glass-sheet relative z-10 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>
{/* · () ·
max-w-3xl flex-1 */}
<div className="relative flex flex-1 justify-center">
{/* ()()
Lunada */}
<aside
className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block"
style={{ backgroundImage: `url(${SIDE_IMAGE_LEFT})`, backgroundSize: 'auto 100%' }}
aria-hidden
/>
{/* 中间内容纸(玻璃纸张) */}
<div className="glass-sheet relative flex w-full max-w-3xl flex-col">
<main className="flex-1">
{/* 文章列表 */}
<section className="w-full 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="flex w-full 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>
{/* 右侧图片区(与左侧同样的高度适配 + 居中) */}
<aside
className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block"
style={{ backgroundImage: `url(${SIDE_IMAGE_RIGHT})`, backgroundSize: 'auto 100%' }}
aria-hidden
/>
</div>
</div>
)
}

View File

@ -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(false)
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 : 'Sign-in failed')
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>
)
}

View File

@ -1,20 +1,49 @@
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']
/** 返回导航页(从起始页齿轮进来时可用) */
onBack?: () => void
}
export function LoginPage({ onSignIn, onBack }: LoginPageProps) {
// 每秒轮询后端系统状态,展示在顶部栏。
const stats = useSystemStats()
return (
<div className="flex min-h-svh flex-col bg-bg">
<div className="relative flex min-h-svh flex-col bg-bg">
<AnnouncementBar text={stats.text} online={stats.online} />
{onBack && (
<button
type="button"
onClick={onBack}
className="absolute right-4 top-[3.25rem] z-30 flex items-center gap-1.5 rounded-full border border-line bg-surface/80 px-3.5 py-1.5 text-sm text-text shadow-sm backdrop-blur-sm transition-colors hover:border-accent hover:text-accent"
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
Home
</button>
)}
<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>

View 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>
)
}

View File

@ -0,0 +1,131 @@
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { fadeUp, stagger } from './motion'
import { useWeather } from '../hooks/useWeather'
import { Greeting } from './start/Greeting'
import { SearchBar } from './start/SearchBar'
import { QuickLinks } from './start/QuickLinks'
import { WeatherFx } from './start/WeatherFx'
// 背景图:放 src/assets/startpage/ 下。按昼夜选 light / dark
// 缺这两张时回退到目录里排序第一张,再无则用渐变。说明见该目录 README。
const bgFiles = import.meta.glob<string>('../assets/startpage/*.{jpg,jpeg,png,webp}', {
eager: true,
query: '?url',
import: 'default',
})
function bgByName(name: string): string | undefined {
return Object.entries(bgFiles).find(([p]) => p.toLowerCase().includes(`/${name}.`))?.[1]
}
const lightBg = bgByName('light')
const darkBg = bgByName('dark')
const anyBg = Object.entries(bgFiles)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, url]) => url)[0]
// 和风图标码中能区分昼夜的子集(晴/多云/阵雨)。其余码无昼夜之分。
const NIGHT_ICONS = new Set(['150', '151', '152', '153', '350', '351', '456', '457'])
const DAY_ICONS = new Set(['100', '101', '102', '103', '300', '301', '400', '401'])
// 昼夜判断图标码优先判断不了的码用本地时间兜底18:0006:00 为夜)。
function isNight(icon?: string): boolean {
if (icon) {
if (NIGHT_ICONS.has(icon)) return true
if (DAY_ICONS.has(icon)) return false
}
const h = new Date().getHours()
return h < 6 || h >= 18
}
// 动态效果类型:只做雨/雪两类,其余静态。
function fxKind(icon?: string): 'rain' | 'snow' | 'none' {
const n = icon ? Number.parseInt(icon, 10) : NaN
if (Number.isNaN(n)) return 'none'
if ((n >= 400 && n <= 499) || n === 901) return 'snow'
if (n >= 300 && n <= 399) return 'rain'
return 'none'
}
function isThunder(icon?: string): boolean {
return icon ? ['302', '303', '304'].includes(icon) : false
}
export function StartPage() {
const wx = useWeather()
// 每分钟一拍,让昼夜随时间自动翻(即便天气没刷新)。
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 60_000)
return () => clearInterval(id)
}, [])
const icon = wx.data && wx.data.ok ? wx.data.icon : undefined
// 开发预览(仅 DEV?bg=light|dark 强制背景;?fx=rain|snow|thunder|none 强制动效。
// 例http://localhost:5173/?bg=dark&fx=snow
const ov = import.meta.env.DEV ? new URLSearchParams(window.location.search) : null
const ovBg = ov?.get('bg')
const ovFx = ov?.get('fx')
const night = ovBg ? ovBg === 'dark' : isNight(icon)
const bg = (night ? darkBg : lightBg) ?? lightBg ?? darkBg ?? anyBg
let kind = fxKind(icon)
let thunder = isThunder(icon)
if (ovFx === 'snow') {
kind = 'snow'
thunder = false
} else if (ovFx === 'thunder') {
kind = 'rain'
thunder = true
} else if (ovFx === 'rain') {
kind = 'rain'
thunder = false
} else if (ovFx === 'none') {
kind = 'none'
thunder = false
}
return (
<div className="relative min-h-svh w-full overflow-hidden">
{/* 背景层:有图用图,无图回退渐变 */}
{bg ? (
<div
className="absolute inset-0 -z-20 bg-cover bg-center"
style={{ backgroundImage: `url(${bg})` }}
/>
) : (
<div className="absolute inset-0 -z-20 bg-gradient-to-br from-[#243140] via-[#36475a] to-[#566578]" />
)}
{/* scrim上下压暗保证玻璃与文字在任意图片上都可读 */}
<div className="absolute inset-0 -z-10 bg-gradient-to-b from-black/45 via-black/15 to-black/55" />
{/* ▼▼ 半透明黑遮罩:整体压暗背景。调透明度改下面的 0.50=全透明1=纯黑)▼▼ */}
<div className="absolute inset-0 -z-10" style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }} />
{/* 天气动态层:雨/雪粒子(克制),位于遮罩之上、内容之下 */}
<WeatherFx kind={kind} thunder={thunder} />
<motion.main
variants={stagger(0.15, 0.12)}
initial="hidden"
animate="show"
className="relative z-10 mx-auto flex min-h-svh max-w-2xl flex-col items-center justify-center gap-9 px-6"
>
<motion.div variants={fadeUp} className="w-full">
<Greeting wx={wx} />
</motion.div>
<motion.div variants={fadeUp} className="w-full">
<SearchBar />
</motion.div>
<motion.div variants={fadeUp} className="w-full">
<QuickLinks />
</motion.div>
</motion.main>
</div>
)
}

View File

@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import type { WeatherState } from '../../hooks/useWeather'
import { WeatherIcon } from './WeatherIcon'
// 按本地小时给出 早/中/晚/夜 问候(默认英文,改中文只需替换下面的文案)。
function greetingFor(hour: number): string {
if (hour >= 5 && hour < 12) return 'Good morning'
if (hour >= 12 && hour < 18) return 'Good afternoon'
if (hour >= 18 && hour < 23) return 'Good evening'
return 'Good night'
}
// 天气行:根据后端返回的业务态分别渲染。
function WeatherLine({ wx }: { wx: WeatherState }) {
let content = null
if (!wx.loading && wx.data) {
const d = wx.data
if (d.ok) {
content = (
<>
<WeatherIcon code={d.icon} size={18} />
<span>
{d.city} · {d.text} · {d.temp}°
</span>
</>
)
} else if (d.reason === 'quota_exceeded') {
// “表个态”:当日额度用完。
content = <span className="text-white/55">, 0 </span>
}
// not_configured / failed静默不打扰页面
}
// 固定最小高度,避免天气加载完成后页面跳动。
return (
<div className="mt-3 flex min-h-[1.5rem] items-center justify-center gap-2 font-sans text-sm text-white/70">
{content}
</div>
)
}
export function Greeting({ wx }: { wx: WeatherState }) {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
// 每 30s 刷新,足够让分钟与跨时段问候保持准确。
const id = setInterval(() => setNow(new Date()), 30_000)
return () => clearInterval(id)
}, [])
const greeting = greetingFor(now.getHours())
const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const date = now.toLocaleDateString([], {
weekday: 'long',
month: 'long',
day: 'numeric',
})
return (
<div className="text-center text-white [text-shadow:0_1px_16px_rgba(0,0,0,0.45)]">
<p className="font-sans text-xs uppercase tracking-[0.4em] text-white/65">
{time} · {date}
</p>
<h1 className="mt-4 font-display text-5xl font-light italic leading-tight sm:text-6xl">
{greeting}
<span className="text-white/45">.</span>
</h1>
<WeatherLine wx={wx} />
</div>
)
}

View File

@ -0,0 +1,97 @@
import { motion } from 'framer-motion'
import type { ReactNode } from 'react'
// 快捷入口:第一行 5 个网站,第二行 3 个文档。每项 { icon, label, href }。
interface Shortcut {
label?: string
href?: string
icon?: ReactNode
}
// 品牌图标:单 pathfill=currentColor跟随文字颜色。
const GithubIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
const DeepSeekIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden>
<path d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45" />
</svg>
)
const BilibiliIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden>
<path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z" />
</svg>
)
const ZhihuIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden>
<path d="M5.721 0C2.251 0 0 2.25 0 5.719V18.28C0 21.751 2.252 24 5.721 24h12.56C21.751 24 24 21.75 24 18.281V5.72C24 2.249 21.75 0 18.281 0zm1.964 4.078c-.271.73-.5 1.434-.68 2.11h4.587c.545-.006.445 1.168.445 1.171H9.384a58.104 58.104 0 01-.112 3.797h2.712c.388.023.393 1.251.393 1.266H9.183a9.223 9.223 0 01-.408 2.102l.757-.604c.452.456 1.512 1.712 1.906 2.177.473.681.063 2.081.063 2.081l-2.794-3.382c-.653 2.518-1.845 3.607-1.845 3.607-.523.468-1.58.82-2.64.516 2.218-1.73 3.44-3.917 3.667-6.497H4.491c0-.015.197-1.243.806-1.266h2.71c.024-.32.086-3.254.086-3.797H6.598c-.136.406-.158.447-.268.753-.594 1.095-1.603 1.122-1.907 1.155.906-1.821 1.416-3.6 1.591-4.064.425-1.124 1.671-1.125 1.671-1.125zM13.078 6h6.377v11.33h-2.573l-2.184 1.373-.401-1.373h-1.219zm1.313 1.219v8.86h.623l.263.937 1.455-.938h1.456v-8.86z" />
</svg>
)
// FMHY 不在 simple-icons 图标库里,用字标,跟其余图标同色同风格。
const FmhyIcon = (
<span className="text-[15px] font-bold leading-none tracking-tight" aria-hidden>
FMHY
</span>
)
const RustIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden>
<path d="M23.8346 11.7033l-1.0073-.6236a13.7268 13.7268 0 00-.0283-.2936l.8656-.8069a.3483.3483 0 00-.1154-.578l-1.1066-.414a8.4958 8.4958 0 00-.087-.2856l.6904-.9587a.3462.3462 0 00-.2257-.5446l-1.1663-.1894a9.3574 9.3574 0 00-.1407-.2622l.49-1.0761a.3437.3437 0 00-.0274-.3361.3486.3486 0 00-.3006-.154l-1.1845.0416a6.7444 6.7444 0 00-.1873-.2268l.2723-1.153a.3472.3472 0 00-.417-.4172l-1.1532.2724a14.0183 14.0183 0 00-.2278-.1873l.0415-1.1845a.3442.3442 0 00-.49-.328l-1.076.491c-.0872-.0476-.1742-.0952-.2623-.1407l-.1903-1.1673A.3483.3483 0 0016.256.955l-.9597.6905a8.4867 8.4867 0 00-.2855-.086l-.414-1.1066a.3483.3483 0 00-.5781-.1154l-.8069.8666a9.2936 9.2936 0 00-.2936-.0284L12.2946.1683a.3462.3462 0 00-.5892 0l-.6236 1.0073a13.7383 13.7383 0 00-.2936.0284L9.9803.3374a.3462.3462 0 00-.578.1154l-.4141 1.1065c-.0962.0274-.1903.0567-.2855.086L7.744.955a.3483.3483 0 00-.5447.2258L7.009 2.348a9.3574 9.3574 0 00-.2622.1407l-1.0762-.491a.3462.3462 0 00-.49.328l.0416 1.1845a7.9826 7.9826 0 00-.2278.1873L3.8413 3.425a.3472.3472 0 00-.4171.4171l.2713 1.1531c-.0628.075-.1255.1509-.1863.2268l-1.1845-.0415a.3462.3462 0 00-.328.49l.491 1.0761a9.167 9.167 0 00-.1407.2622l-1.1662.1894a.3483.3483 0 00-.2258.5446l.6904.9587a13.303 13.303 0 00-.087.2855l-1.1065.414a.3483.3483 0 00-.1155.5781l.8656.807a9.2936 9.2936 0 00-.0283.2935l-1.0073.6236a.3442.3442 0 000 .5892l1.0073.6236c.008.0982.0182.1964.0283.2936l-.8656.8079a.3462.3462 0 00.1155.578l1.1065.4141c.0273.0962.0567.1914.087.2855l-.6904.9587a.3452.3452 0 00.2268.5447l1.1662.1893c.0456.088.0922.1751.1408.2622l-.491 1.0762a.3462.3462 0 00.328.49l1.1834-.0415c.0618.0769.1235.1528.1873.2277l-.2713 1.1541a.3462.3462 0 00.4171.4161l1.153-.2713c.075.0638.151.1255.2279.1863l-.0415 1.1845a.3442.3442 0 00.49.327l1.0761-.49c.087.0486.1741.0951.2622.1407l.1903 1.1662a.3483.3483 0 00.5447.2268l.9587-.6904a9.299 9.299 0 00.2855.087l.414 1.1066a.3452.3452 0 00.5781.1154l.8079-.8656c.0972.0111.1954.0203.2936.0294l.6236 1.0073a.3472.3472 0 00.5892 0l.6236-1.0073c.0982-.0091.1964-.0183.2936-.0294l.8069.8656a.3483.3483 0 00.578-.1154l.4141-1.1066a8.4626 8.4626 0 00.2855-.087l.9587.6904a.3452.3452 0 00.5447-.2268l.1903-1.1662c.088-.0456.1751-.0931.2622-.1407l1.0762.49a.3472.3472 0 00.49-.327l-.0415-1.1845a6.7267 6.7267 0 00.2267-.1863l1.1531.2713a.3472.3472 0 00.4171-.416l-.2713-1.1542c.0628-.0749.1255-.1508.1863-.2278l1.1845.0415a.3442.3442 0 00.328-.49l-.49-1.076c.0475-.0872.0951-.1742.1407-.2623l1.1662-.1893a.3483.3483 0 00.2258-.5447l-.6904-.9587.087-.2855 1.1066-.414a.3462.3462 0 00.1154-.5781l-.8656-.8079c.0101-.0972.0202-.1954.0283-.2936l1.0073-.6236a.3442.3442 0 000-.5892zm-6.7413 8.3551a.7138.7138 0 01.2986-1.396.714.714 0 11-.2997 1.396zm-.3422-2.3142a.649.649 0 00-.7715.5l-.3573 1.6685c-1.1035.501-2.3285.7795-3.6193.7795a8.7368 8.7368 0 01-3.6951-.814l-.3574-1.6684a.648.648 0 00-.7714-.499l-1.473.3158a8.7216 8.7216 0 01-.7613-.898h7.1676c.081 0 .1356-.0141.1356-.088v-2.536c0-.074-.0536-.0881-.1356-.0881h-2.0966v-1.6077h2.2677c.2065 0 1.1065.0587 1.394 1.2088.0901.3533.2875 1.5044.4232 1.8729.1346.413.6833 1.2381 1.2685 1.2381h3.5716a.7492.7492 0 00.1296-.0131 8.7874 8.7874 0 01-.8119.9526zM6.8369 20.024a.714.714 0 11-.2997-1.396.714.714 0 01.2997 1.396zM4.1177 8.9972a.7137.7137 0 11-1.304.5791.7137.7137 0 011.304-.579zm-.8352 1.9813l1.5347-.6824a.65.65 0 00.33-.8585l-.3158-.7147h1.2432v5.6025H3.5669a8.7753 8.7753 0 01-.2834-3.348zm6.7343-.5437V8.7836h2.9601c.153 0 1.0792.1772 1.0792.8697 0 .575-.7107.7815-1.2948.7815zm10.7574 1.4862c0 .2187-.008.4363-.0243.651h-.9c-.09 0-.1265.0586-.1265.1477v.413c0 .973-.5487 1.1846-1.0296 1.2382-.4576.0517-.9648-.1913-1.0275-.4717-.2704-1.5186-.7198-1.8436-1.4305-2.4034.8817-.5599 1.799-1.386 1.799-2.4915 0-1.1936-.819-1.9458-1.3769-2.3153-.7825-.5163-1.6491-.6195-1.883-.6195H5.4682a8.7651 8.7651 0 014.907-2.7699l1.0974 1.151a.648.648 0 00.9182.0213l1.227-1.1743a8.7753 8.7753 0 016.0044 4.2762l-.8403 1.8982a.652.652 0 00.33.8585l1.6178.7188c.0283.2875.0425.577.0425.8717zm-9.3006-9.5993a.7128.7128 0 11.984 1.0316.7137.7137 0 01-.984-1.0316zm8.3389 6.71a.7107.7107 0 01.9395-.3625.7137.7137 0 11-.9405.3635z" />
</svg>
)
const CppIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden>
<path d="M22.394 6c-.167-.29-.398-.543-.652-.69L12.926.22c-.509-.294-1.34-.294-1.848 0L2.26 5.31c-.508.293-.923 1.013-.923 1.6v10.18c0 .294.104.62.271.91.167.29.398.543.652.69l8.816 5.09c.508.293 1.34.293 1.848 0l8.816-5.09c.254-.147.485-.4.652-.69.167-.29.27-.616.27-.91V6.91c.003-.294-.1-.62-.268-.91zM12 19.11c-3.92 0-7.109-3.19-7.109-7.11 0-3.92 3.19-7.11 7.11-7.11a7.133 7.133 0 016.156 3.553l-3.076 1.78a3.567 3.567 0 00-3.08-1.78A3.56 3.56 0 008.444 12 3.56 3.56 0 0012 15.555a3.57 3.57 0 003.08-1.778l3.078 1.78A7.135 7.135 0 0112 19.11zm7.11-6.715h-.79v.79h-.79v-.79h-.79v-.79h.79v-.79h.79v.79h.79zm2.962 0h-.79v.79h-.79v-.79h-.79v-.79h.79v-.79h.79v.79h.79z" />
</svg>
)
const CsharpIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden>
<path d="M1.194 7.543v8.913c0 1.103.588 2.122 1.544 2.674l7.718 4.456a3.086 3.086 0 0 0 3.088 0l7.718-4.456a3.087 3.087 0 0 0 1.544-2.674V7.543a3.084 3.084 0 0 0-1.544-2.673L13.544.414a3.086 3.086 0 0 0-3.088 0L2.738 4.87a3.085 3.085 0 0 0-1.544 2.673Zm5.403 2.914v3.087a.77.77 0 0 0 .772.772.773.773 0 0 0 .772-.772.773.773 0 0 1 1.317-.546.775.775 0 0 1 .226.546 2.314 2.314 0 1 1-4.631 0v-3.087c0-.615.244-1.203.679-1.637a2.312 2.312 0 0 1 3.274 0c.434.434.678 1.023.678 1.637a.769.769 0 0 1-.226.545.767.767 0 0 1-1.091 0 .77.77 0 0 1-.226-.545.77.77 0 0 0-.772-.772.771.771 0 0 0-.772.772Zm12.35 3.087a.77.77 0 0 1-.772.772h-.772v.772a.773.773 0 0 1-1.544 0v-.772h-1.544v.772a.773.773 0 0 1-1.317.546.775.775 0 0 1-.226-.546v-.772H12a.771.771 0 1 1 0-1.544h.772v-1.543H12a.77.77 0 1 1 0-1.544h.772v-.772a.773.773 0 0 1 1.317-.546.775.775 0 0 1 .226.546v.772h1.544v-.772a.773.773 0 0 1 1.544 0v.772h.772a.772.772 0 0 1 0 1.544h-.772v1.543h.772a.776.776 0 0 1 .772.772Zm-3.088-2.315h-1.544v1.543h1.544v-1.543Z" />
</svg>
)
const SHORTCUTS: Shortcut[] = [
{ label: 'DeepSeek', href: 'https://chat.deepseek.com', icon: DeepSeekIcon },
{ label: 'GitHub', href: 'https://github.com', icon: GithubIcon },
{ label: 'Bilibili', href: 'https://www.bilibili.com', icon: BilibiliIcon },
{ label: '知乎', href: 'https://www.zhihu.com', icon: ZhihuIcon },
{ label: 'FMHY', href: 'https://fm-hy.top', icon: FmhyIcon },
{ label: 'Rust 文档', href: 'https://rustwiki.org/zh-CN/book/', icon: RustIcon },
{ label: 'C++ 文档', href: 'https://en.cppreference.com/', icon: CppIcon },
{ label: 'C# 文档', href: 'https://learn.microsoft.com/dotnet/csharp/', icon: CsharpIcon },
]
export function QuickLinks() {
return (
// grid-cols-58 个入口自然排成第一行 5 个、第二行 3 个(左对齐)。
<div className="mx-auto grid w-full max-w-xl grid-cols-5 gap-3 sm:gap-4">
{SHORTCUTS.map((item, i) => (
<motion.a
key={i}
href={item.href}
target="_blank"
rel="noreferrer"
aria-label={item.label}
title={item.label}
whileHover={{ scale: 1.12, y: -4 }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 22 }}
// 第二行第一个入口从第 2 列起3 个占第 2/3/4 列即居中。
className={`glass-panel flex aspect-square items-center justify-center rounded-2xl text-white/75 transition-colors hover:text-white${
i === 5 ? ' col-start-2' : ''
}`}
>
{item.icon}
</motion.a>
))}
</div>
)
}

View File

@ -0,0 +1,158 @@
import { useState, type FormEvent } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
interface Engine {
name: string
url: string
}
// 默认 Bing预留可切换的其他引擎。url 后直接拼 encodeURIComponent(query)。
const ENGINES: Record<string, Engine> = {
bing: { name: 'Bing', url: 'https://www.bing.com/search?q=' },
google: { name: 'Google', url: 'https://www.google.com/search?q=' },
baidu: { name: '百度', url: 'https://www.baidu.com/s?wd=' },
duckduckgo: { name: 'DuckDuckGo', url: 'https://duckduckgo.com/?q=' },
}
const STORAGE_KEY = 'ip.startpage.engine'
function loadEngine(): string {
const saved = localStorage.getItem(STORAGE_KEY)
return saved && saved in ENGINES ? saved : 'bing'
}
export function SearchBar() {
const [engine, setEngine] = useState(loadEngine)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
function pick(key: string) {
setEngine(key)
localStorage.setItem(STORAGE_KEY, key)
setOpen(false)
}
function submit(e: FormEvent) {
e.preventDefault()
const q = query.trim()
if (!q) return
window.location.href = ENGINES[engine].url + encodeURIComponent(q)
}
return (
<form onSubmit={submit} className="relative mx-auto w-full max-w-xl">
<div className="glass-panel flex items-center gap-1 rounded-full p-1.5 pl-2">
{/* 引擎选择 */}
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex shrink-0 items-center gap-1 rounded-full px-3 py-2 text-sm font-medium text-white/85 transition-colors hover:bg-white/10"
aria-haspopup="listbox"
aria-expanded={open}
>
{ENGINES[engine].name}
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform ${open ? 'rotate-180' : ''}`}
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<span className="h-5 w-px shrink-0 bg-white/15" aria-hidden />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search with ${ENGINES[engine].name}`}
autoComplete="off"
spellCheck={false}
className="min-w-0 flex-1 bg-transparent px-2 py-2 text-white placeholder:text-white/45 focus:outline-none"
aria-label="Search query"
/>
<button
type="submit"
aria-label="Search"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/15 hover:text-white"
>
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
</button>
</div>
{/* 引擎下拉 */}
<AnimatePresence>
{open && (
<>
{/* 点空白处关闭 */}
<button
type="button"
aria-hidden
tabIndex={-1}
onClick={() => setOpen(false)}
className="fixed inset-0 z-10 cursor-default"
/>
<motion.ul
role="listbox"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.16 }}
className="glass-panel absolute left-0 top-full z-20 mt-2 w-44 overflow-hidden rounded-2xl p-1.5"
>
{Object.entries(ENGINES).map(([key, eng]) => (
<li key={key}>
<button
type="button"
role="option"
aria-selected={key === engine}
onClick={() => pick(key)}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-white/12 ${
key === engine ? 'text-white' : 'text-white/70'
}`}
>
{eng.name}
{key === engine && (
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
)}
</button>
</li>
))}
</motion.ul>
</>
)}
</AnimatePresence>
</form>
)
}

View File

@ -0,0 +1,122 @@
import { useEffect, useRef } from 'react'
// 天气动态层:克制的雨/雪粒子canvas。雷雨叠偶发闪电。
// 尊重「减少动态效果」系统设置kind='none' 时不渲染。
interface WeatherFxProps {
kind: 'rain' | 'snow' | 'none'
thunder?: boolean
}
export function WeatherFx({ kind, thunder = false }: WeatherFxProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
if (kind === 'none') return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let w = 0
let h = 0
const rand = (a: number, b: number) => a + Math.random() * (b - a)
const resize = () => {
w = window.innerWidth
h = window.innerHeight
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = w * dpr
canvas.height = h * dpr
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
window.addEventListener('resize', resize)
interface P {
x: number
y: number
len: number
r: number
vx: number
vy: number
sway: number
phase: number
}
const make = (): P =>
kind === 'rain'
? { x: rand(0, w), y: rand(-h, 0), len: rand(12, 22), r: 0, vx: rand(1.2, 2.2), vy: rand(13, 19), sway: 0, phase: 0 }
: { x: rand(0, w), y: rand(-h, 0), len: 0, r: rand(1, 2.6), vx: 0, vy: rand(0.6, 1.6), sway: rand(0.3, 1), phase: rand(0, Math.PI * 2) }
// 克制:粒子数随屏宽缩放但封顶
const count = kind === 'rain' ? Math.min(90, Math.round(w / 14)) : Math.min(60, Math.round(w / 22))
const parts: P[] = Array.from({ length: count }, make)
let flash = 0
let nextFlashAt = thunder ? performance.now() + rand(2500, 7000) : Infinity
let raf = 0
const draw = (t: number) => {
ctx.clearRect(0, 0, w, h)
if (kind === 'rain') {
ctx.strokeStyle = 'rgba(255,255,255,0.22)'
ctx.lineWidth = 1
ctx.lineCap = 'round'
for (const p of parts) {
ctx.beginPath()
ctx.moveTo(p.x, p.y)
ctx.lineTo(p.x - p.vx * 1.4, p.y - p.len)
ctx.stroke()
p.x += p.vx
p.y += p.vy
if (p.y - p.len > h) {
p.x = rand(0, w)
p.y = rand(-40, 0)
}
}
if (thunder) {
if (t >= nextFlashAt) {
flash = 0.5
nextFlashAt = t + rand(4000, 11000)
}
if (flash > 0) {
ctx.fillStyle = `rgba(255,255,255,${flash})`
ctx.fillRect(0, 0, w, h)
flash = Math.max(0, flash - 0.04)
}
}
} else {
ctx.fillStyle = 'rgba(255,255,255,0.7)'
for (const p of parts) {
p.phase += 0.01
ctx.beginPath()
ctx.arc(p.x + Math.sin(p.phase) * p.sway, p.y, p.r, 0, Math.PI * 2)
ctx.fill()
p.y += p.vy
if (p.y - p.r > h) {
p.x = rand(0, w)
p.y = rand(-20, 0)
}
}
}
raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', resize)
ctx.clearRect(0, 0, w, h)
}
}, [kind, thunder])
if (kind === 'none') return null
return <canvas ref={canvasRef} aria-hidden className="pointer-events-none fixed inset-0 z-0" />
}

View File

@ -0,0 +1,83 @@
// 把和风天气的图标码now.icon3 位数字)映射成一组极简线性图标,
// 与导航页的玻璃风格统一:纯 SVG、stroke=currentColor、可缩放、离线可用。
// 和风图标码分组见 https://dev.qweather.com/docs/resource/icons/
type Category = 'sun' | 'moon' | 'cloud' | 'rain' | 'thunder' | 'snow' | 'fog'
function categorize(code: string): Category {
const n = Number.parseInt(code, 10)
if (Number.isNaN(n)) return 'cloud'
if (n === 100 || n === 900) return 'sun' // 晴 / 热
if (n === 150) return 'moon' // 晴(夜)
if ([101, 102, 103, 104, 151, 152, 153].includes(n)) return 'cloud' // 多云 / 阴
if ([302, 303, 304].includes(n)) return 'thunder' // 雷阵雨
if (n >= 300 && n <= 399) return 'rain'
if ((n >= 400 && n <= 499) || n === 901) return 'snow' // 雪 / 冷
if (n >= 500 && n <= 515) return 'fog' // 雾 / 霾 / 沙尘
return 'cloud'
}
function shape(cat: Category) {
switch (cat) {
case 'sun':
return (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" />
</>
)
case 'moon':
return <path d="M20 14.5A8 8 0 0 1 9.5 4 7 7 0 1 0 20 14.5z" />
case 'cloud':
return <path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" />
case 'rain':
return (
<>
<path d="M20 16.58A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25" />
<path d="M8 19v2M12 19v3M16 19v2" />
</>
)
case 'thunder':
return (
<>
<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9" />
<path d="M13 11l-4 6h6l-4 6" />
</>
)
case 'snow':
return (
<>
<path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" />
<path d="M8 16h.01M8 20h.01M12 18h.01M12 22h.01M16 16h.01M16 20h.01" />
</>
)
case 'fog':
return <path d="M3 8h13M6 12h12M4 16h11M8 20h9" />
}
}
interface WeatherIconProps {
/** 和风 now.icon 码 */
code: string
size?: number
className?: string
}
export function WeatherIcon({ code, size = 20, className }: WeatherIconProps) {
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden
>
{shape(categorize(code))}
</svg>
)
}

View File

@ -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}

View File

@ -0,0 +1,77 @@
import { useCallback, useState } from 'react'
import { login as loginRequest } from '../api/auth'
// storage 键:登录态写在浏览器。
// - 勾“记住我” → localStorage关浏览器再开仍登录
// - 不勾 → sessionStorage刷新仍在关标签页/浏览器即清)
const STORAGE_KEY = 'ip.auth'
interface Session {
username: string
token: string
}
function parseSession(raw: string | null): Session | null {
if (!raw) return null
try {
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
}
// 先读 localStorage记住我再读 sessionStorage仅本次会话
function loadSession(): Session | null {
return (
parseSession(localStorage.getItem(STORAGE_KEY)) ??
parseSession(sessionStorage.getItem(STORAGE_KEY))
)
}
export interface Auth {
/** 已登录则为当前用户名,否则为 null */
username: string | null
/** 校验凭据remember 为 true 时持久化到 localStorage否则只存 sessionStorage */
signIn: (username: string, password: string, remember: boolean) => Promise<void>
/** 退出登录并清除浏览器里的会话(两种 storage 都清) */
signOut: () => void
}
/**
* localStorage / sessionStorage
*
*/
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)
const raw = JSON.stringify(next)
if (remember) {
// 记住我:持久化,关浏览器再开仍登录。
localStorage.setItem(STORAGE_KEY, raw)
sessionStorage.removeItem(STORAGE_KEY)
} else {
// 不记住:仅本次会话,刷新仍在、关标签页/浏览器即清。
sessionStorage.setItem(STORAGE_KEY, raw)
localStorage.removeItem(STORAGE_KEY)
}
},
[],
)
const signOut = useCallback(() => {
setSession(null)
localStorage.removeItem(STORAGE_KEY)
sessionStorage.removeItem(STORAGE_KEY)
}, [])
return { username: session?.username ?? null, signIn, signOut }
}

View File

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
import { fetchWeather, type WeatherResult } from '../api/weather'
import { locate } from '../lib/amap'
export interface WeatherState {
loading: boolean
/** 后端返回的结果(含限流/未配置等业务态);网络错误时为 null */
data: WeatherResult | null
/** 网络层错误(后端不可达) */
error: boolean
}
/**
* 30 10
* 便
*/
export function useWeather(intervalMs = 30 * 60 * 1000): WeatherState {
const [state, setState] = useState<WeatherState>({ loading: true, data: null, error: false })
useEffect(() => {
let cancelled = false
const load = async () => {
try {
// 先前端定位(未配 JS Key 或失败 → null后端用 IP 兜底。locate 不抛错。
const geo = await locate()
const data = await fetchWeather(geo ?? undefined)
if (!cancelled) setState({ loading: false, data, error: false })
} catch {
if (!cancelled) setState({ loading: false, data: null, error: true })
}
}
load()
const id = setInterval(load, intervalMs)
return () => {
cancelled = true
clearInterval(id)
}
}, [intervalMs])
return state
}

View File

@ -99,10 +99,131 @@ 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 时把文字色设为 transparentgroup-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;
}
/* 整页渐变极光背景玻璃栏透出的就是它
固定铺满视口置于所有内容之下底色为 Lunada 渐变
uigradientsLunada#A7BFE8 #6190E8上面叠几团同色系
蓝光斑缓慢漂移并大幅模糊让平面渐变产生液态流动感 */
.aurora {
position: fixed;
inset: 0;
z-index: -10;
overflow: hidden;
/* Lunada 渐变打底 */
background: linear-gradient(135deg, #6190e8 0%, #89a8e6 48%, #a7bfe8 100%);
}
.aurora::before {
content: '';
position: absolute;
inset: -25%;
background:
radial-gradient(30% 34% at 18% 28%, rgba(97, 144, 232, 0.65), transparent 70%),
radial-gradient(28% 32% at 82% 24%, rgba(167, 191, 232, 0.70), transparent 70%),
radial-gradient(34% 38% at 72% 78%, rgba(120, 162, 236, 0.60), transparent 70%),
radial-gradient(26% 30% at 26% 82%, rgba(192, 211, 245, 0.55), transparent 70%),
radial-gradient(24% 28% at 50% 50%, rgba(140, 175, 240, 0.45), transparent 70%);
filter: blur(60px);
/* 静止:持续漂移会让上层多个 backdrop-filter 每帧重算模糊,是卡顿主因 */
}
/* 中间内容纸张压暗的暖色玻璃模糊背后极光保证可读
两侧各一道高光描边与左右玻璃竖栏拼出连续的玻璃接缝 */
.glass-sheet {
/* 底色 84% 不透明背后极光本就几乎透不过来省掉昂贵的 backdrop-filter
观感基本无差却避免这条又高又大的区域每帧重算模糊 */
background: rgba(23, 19, 15, 0.84);
box-shadow:
inset 1px 0 0 rgba(255, 255, 255, 0.12),
inset -1px 0 0 rgba(255, 255, 255, 0.12),
0 30px 80px rgba(20, 30, 60, 0.30);
}
/* 起始页玻璃面板极简浅色玻璃叠在背景图上
与暗色 .glass-sheet 不同这里要真模糊让背后照片透出液态质感
半透明白 + 细高光描边 + 适度饱和放在任何背景图上都成立 */
.glass-panel {
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 10px 30px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
}
/* ── reduced-motion 降级 ────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.mesh,
.cursor-bar {
.cursor-bar,
.rainbow-gradient,
.rainbow-text,
.rainbow-clip,
.aurora::before {
animation: none;
}
}

122
Client/src/lib/amap.ts Normal file
View File

@ -0,0 +1,122 @@
// 高德 JS APIAMap.Geolocation前端定位封装。
//
// 设计:
// - Key 与安全密钥都从环境变量读,**不写死**`VITE_AMAP_JS_KEY` / `VITE_AMAP_JS_CODE`。
// 两者是高德「Web端(JS API)」专属密钥,和后端的 Web 服务 REST `AMAP_KEY` 是两套。
// - 未配置 Key → `locate()` 直接返回 null由后端 IP 定位兜底(即旧行为,绝不退化)。
// - 定位两段式先浏览器原生定位GPS/WiFi精度高但**需 HTTPS**,非 localhost 的
// HTTP 页面下浏览器会禁用);失败/被拒 → 退回高德纯 IP 城市定位getCityInfo
// - 任何失败都吞掉返回 null不打扰页面后端继续兜底。
const KEY = import.meta.env.VITE_AMAP_JS_KEY as string | undefined
const CODE = import.meta.env.VITE_AMAP_JS_CODE as string | undefined
export interface GeoResult {
/** 经度 */
lon: number
/** 纬度 */
lat: number
/** 城市名(直辖市/定位精度不足时可能为空) */
city?: string
}
// 高德 SDK 没有官方类型,这里只声明用到的最小子集。
interface AMapGeolocation {
getCurrentPosition(cb: (status: string, result: GeoPositionResult) => void): void
getCityInfo(cb: (status: string, result: GeoCityResult) => void): void
}
interface GeoPositionResult {
position?: { lng: number; lat: number }
addressComponent?: { city?: string; province?: string }
}
interface GeoCityResult {
/** [lng, lat] 城市中心 */
center?: [number, number]
city?: string
province?: string
}
interface AMapNamespace {
plugin(name: string, onload: () => void): void
Geolocation: new (opts: Record<string, unknown>) => AMapGeolocation
}
declare global {
interface Window {
AMap?: AMapNamespace
_AMapSecurityConfig?: { securityJsCode: string }
}
}
let sdkPromise: Promise<AMapNamespace> | null = null
/** 按需注入高德 JSAPI 脚本(单例)。安全密钥须在脚本加载前挂到 window。 */
function loadSdk(): Promise<AMapNamespace> {
if (sdkPromise) return sdkPromise
sdkPromise = new Promise((resolve, reject) => {
if (!KEY) return reject(new Error('AMap JS key not configured'))
if (window.AMap) return resolve(window.AMap)
if (CODE) window._AMapSecurityConfig = { securityJsCode: CODE }
const s = document.createElement('script')
s.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(KEY)}&plugin=AMap.Geolocation`
s.async = true
s.onload = () =>
window.AMap ? resolve(window.AMap) : reject(new Error('AMap SDK init failed'))
s.onerror = () => reject(new Error('AMap SDK script load error'))
document.head.appendChild(s)
})
return sdkPromise
}
/**
* Key null
*
*/
export async function locate(): Promise<GeoResult | null> {
if (!KEY) return null
let AMap: AMapNamespace
try {
AMap = await loadSdk()
} catch {
return null
}
return new Promise<GeoResult | null>((resolve) => {
let settled = false
const finish = (r: GeoResult | null) => {
if (!settled) {
settled = true
resolve(r)
}
}
// 整体兜底超时,避免插件卡死不回调。
const timer = setTimeout(() => finish(null), 12_000)
const done = (r: GeoResult | null) => {
clearTimeout(timer)
finish(r)
}
AMap.plugin('AMap.Geolocation', () => {
const geo = new AMap.Geolocation({ enableHighAccuracy: true, timeout: 10_000 })
geo.getCurrentPosition((status, result) => {
if (status === 'complete' && result.position) {
done({
lon: result.position.lng,
lat: result.position.lat,
city: result.addressComponent?.city || result.addressComponent?.province,
})
} else {
// 原生/精确定位失败(多因 HTTP 下被禁或用户拒绝)→ 退回纯 IP 城市定位。
geo.getCityInfo((s2, r2) => {
if (s2 === 'complete' && r2.center) {
done({ lon: r2.center[0], lat: r2.center[1], city: r2.city || r2.province })
} else {
done(null)
}
})
}
})
})
})
}

3
Server/.gitignore vendored
View File

@ -1 +1,4 @@
/target
# 开发者入口 hash 列表(秘密,单独部署,勿提交)
/dev_gate.txt

1889
Server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,10 +3,19 @@ name = "server"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "RustServer"
path = "src/main.rs"
[dependencies]
axum = "0.8.9"
chrono = "0.4"
jsonwebtoken = "9"
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "rustls-tls"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = "0.10.9"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "mysql", "macros"] }
sysinfo = "0.39.3"
tokio = { version = "1.52.3", features = ["full"] }
tower-http = { version = "0.7.0", features = ["cors", "fs"] }

226
Server/src/auth.rs Normal file
View File

@ -0,0 +1,226 @@
//! 用户存储与登录校验 + 开发者入口门。
//!
//! 两者默认都走 **MySQL**,且**每次校验直接查库(走 IO不在内存里缓存整表**——
//! 机器内存紧张,宁可每次打一次很轻的索引查询,也不常驻一份副本:
//! - 登录:表 `ccuser(username, password_sha256)`
//! - 入口门:表 `dev_gate(gate_hash)`。
//! 连接串由环境变量 `DATABASE_URL` 指定,`UserStore` 与 `DevGate` **共用同一个
//! 连接池**(见 `db_pool_from_env`),避免开两份连接。未设置 `DATABASE_URL` 时各自
//! 回退到内置/文件兜底,方便本地无 DB 时开发。
//!
//! 口令永不明文存储:库里存的是 SHA256(hex),登录时把传入明文同样
//! 哈希后做**等长比较**。注意SHA256 仅用于“不落明文”,并非抗暴力破解的
//! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。)
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use sqlx::mysql::{MySqlPool, MySqlPoolOptions};
/// 按 `DATABASE_URL` 建一个**懒连接**池(启动不阻塞,首次查询才真正建连),
/// 供 `UserStore` 与 `DevGate` 共用。未设置该环境变量则返回 `None`,调用方各自兜底。
pub fn db_pool_from_env() -> Option<MySqlPool> {
match std::env::var("DATABASE_URL") {
Ok(url) if !url.trim().is_empty() => {
let pool = MySqlPoolOptions::new()
.max_connections(5)
.connect_lazy(&url)
.expect("DATABASE_URL 格式非法");
Some(pool)
}
_ => None,
}
}
/// 开发者「入口门」:一组合法的入口 hash。
///
/// 访问 `域名/#<hash>` 时,前端把 `<hash>` 发给后端,只有命中这里的某一个,
/// 才放行去显示登录页。这样合法 hash **只存在后端**,不进前端打包产物,
/// 扒前端 JS 也看不到;且每个开发者一串、可单独增删。
///
/// 注意:入口 hash 只是“藏门”的共享口令,**不是身份凭证**。真正证明身份仍靠
/// 登录(用户名 + 密码,见 `UserStore`)。
pub enum DevGate {
/// 每次校验查 MySQL `dev_gate` 表(不缓存整表)。
Db(MySqlPool),
/// 内置/文件载入的 hash 集合,仅作无 DB 时的兜底。
Memory(HashSet<String>),
}
impl DevGate {
/// 有连接池就走 MySQL否则读 `GATE_FILE`(默认 `dev_gate.txt`),文件也没有再 `seed()`。
pub fn new(pool: Option<MySqlPool>) -> Self {
match pool {
Some(p) => {
eprintln!("[gate] 入口校验走 MySQL");
DevGate::Db(p)
}
None => Self::load_file(),
}
}
/// 从 `GATE_FILE`(默认 `dev_gate.txt`)读入口 hash读不到回退内置 `seed()`。
/// 文件格式:一行一个 hash`#` 之后为注释,空行忽略。
fn load_file() -> Self {
let path = std::env::var("GATE_FILE").unwrap_or_else(|_| "dev_gate.txt".to_string());
match std::fs::read_to_string(&path) {
Ok(text) => {
let hashes = Self::parse(&text);
eprintln!("[gate] 从 {path} 载入 {} 个入口 hash", hashes.len());
DevGate::Memory(hashes)
}
Err(_) => {
eprintln!("[gate] 未找到 {path},回退到内置写死列表");
Self::seed()
}
}
}
/// 把文件文本解析为 hash 集合:丢掉 `#` 注释与空行,每行取剩下的非空串。
fn parse(text: &str) -> HashSet<String> {
text.lines()
.map(|line| line.split('#').next().unwrap_or("").trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
/// 内置写死的入口 hash 表,作为无 DB / 无文件时的兜底。
pub fn seed() -> Self {
let mut hashes = HashSet::new();
// ccSHA256("192118Lht") 的前 12 位。对应入口 域名/#0086e83c9b10
hashes.insert("0086e83c9b10".to_string());
DevGate::Memory(hashes)
}
/// 该 hash 是否属于某个合法开发者。DB 模式下每次查库(命中即放行);
/// 查询出错按“不放行”处理,不泄露后端内部状态。
pub async fn allows(&self, hash: &str) -> bool {
match self {
DevGate::Memory(hashes) => hashes.contains(hash),
DevGate::Db(pool) => {
let row: Result<Option<(String,)>, sqlx::Error> =
sqlx::query_as("SELECT gate_hash FROM dev_gate WHERE gate_hash = ?")
.bind(hash)
.fetch_optional(pool)
.await;
match row {
Ok(found) => found.is_some(),
Err(e) => {
eprintln!("[gate] 查询 dev_gate 失败: {e}");
false
}
}
}
}
}
}
/// 用户校验后端:优先连 MySQL无 `DATABASE_URL` 时退回内置写死表。
pub enum UserStore {
/// 从 MySQL 的 `ccuser` 表查 `password_sha256`。
Db(MySqlPool),
/// 内置写死表(用户名 → 密码SHA256 hex仅作无 DB 时的兜底。
Memory(HashMap<String, String>),
}
impl UserStore {
/// 有连接池就走 MySQL每次校验查 `ccuser`);否则回退到内置 `seed()`。
pub fn new(pool: Option<MySqlPool>) -> Self {
match pool {
Some(p) => {
eprintln!("[auth] 用户校验走 MySQL");
UserStore::Db(p)
}
None => {
eprintln!("[auth] 未设置 DATABASE_URL回退到内置写死用户表");
Self::seed()
}
}
}
/// 内置写死的单用户表,作为 `load()` 无 DB 配置时的兜底。
pub fn seed() -> Self {
let mut users = HashMap::new();
// cc / 192118Lht
// 下面这串是 SHA256("192118Lht") 的 hex明文不进源码。
users.insert(
"cc".to_string(),
"0086e83c9b108b227eed55425e9641286f42bd0c31a1b95afbb9edd6d3aa6234".to_string(),
);
UserStore::Memory(users)
}
/// 校验用户名 + 明文密码是否匹配。
///
/// 用户名不存在、密码错误、乃至 DB 查询出错,都一律返回 `false`
/// 不向外区分(避免泄露“某用户名是否存在”或后端内部状态)。
pub async fn verify(&self, username: &str, password: &str) -> bool {
let actual_hex = sha256_hex(password);
let expected_hex = match self {
UserStore::Memory(users) => users.get(username).cloned(),
UserStore::Db(pool) => {
let row: Result<Option<(String,)>, sqlx::Error> =
sqlx::query_as("SELECT password_sha256 FROM ccuser WHERE username = ?")
.bind(username)
.fetch_optional(pool)
.await;
match row {
Ok(found) => found.map(|(hex,)| hex),
Err(e) => {
eprintln!("[auth] 查询 ccuser 失败: {e}");
None
}
}
}
};
match expected_hex {
Some(expected) => constant_time_eq(actual_hex.as_bytes(), expected.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
}

View File

@ -4,9 +4,13 @@
//! - `GET /api/web/stats` 给 React 前端(挂 CORS
//! - `GET /api/client/health` 给未来 .NET 客户端(不挂 CORS
mod auth;
mod monitor;
mod routes;
mod state;
mod weather;
use std::net::SocketAddr;
use state::AppState;
@ -19,11 +23,15 @@ async fn main() {
let app = routes::build_router(state);
let addr = "0.0.0.0:8080";
let listener = tokio::net::TcpListener::bind(addr)
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let addr = format!("0.0.0.0:{port}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("无法绑定 0.0.0.0:8080");
.unwrap_or_else(|_| panic!("无法绑定 {addr}"));
println!("backend listening on http://{addr}");
axum::serve(listener, app).await.expect("server error");
// 用 connect-info 让 handler 能拿到对端 socket 地址(天气接口要按访问者 IP 定位/限流)。
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.await
.expect("server error");
}

View File

@ -1,21 +1,42 @@
//! 前端React专用接口。挂 CORS仅放行前端开发源。
use axum::{Json, Router, extract::State, http::Method, routing::get};
use std::net::SocketAddr;
use axum::{
Json, Router,
extract::{ConnectInfo, Query, State},
http::{HeaderMap, Method, StatusCode, header},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::{monitor::Stats, state::AppState};
use crate::{
monitor::Stats,
state::AppState,
weather::{Located, WeatherOutcome},
};
pub fn router(state: AppState) -> Router {
// CORS只有浏览器会校验。放行前端开发源 + GET 方法即可。
// CORS只有浏览器会校验。放行前端开发源Vite 默认 5173被占用时会用 5174
// 登录是 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(),
"http://localhost:5174".parse().unwrap(),
"http://127.0.0.1:5174".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/gate", post(gate))
.route("/api/web/login", post(login))
.route("/api/web/weather", get(weather))
.layer(cors)
.with_state(state)
}
@ -25,3 +46,135 @@ async fn stats(State(state): State<AppState>) -> Json<Stats> {
let snapshot = state.snapshot.read().await.clone();
Json(snapshot)
}
/// 入口门请求体:前端把 URL `#` 后那串 hash 发来校验。
#[derive(Deserialize)]
struct GateRequest {
hash: String,
}
/// 入口门响应。
#[derive(Serialize)]
struct GateResponse {
ok: bool,
}
/// 校验入口 hash 是否属于某个合法开发者。
///
/// 命中 200 + `{ok:true}`(前端据此放行到登录页);未命中返回 401。
/// 注意:这只决定“是否显示登录页”,**真正的身份校验仍在 `login`**。
async fn gate(
State(state): State<AppState>,
Json(req): Json<GateRequest>,
) -> Result<Json<GateResponse>, StatusCode> {
if state.gate.allows(&req.hash).await {
Ok(Json(GateResponse { ok: true }))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
/// 登录请求体:用户名 + 明文密码(开发期走 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).await {
Ok(Json(LoginResponse {
ok: true,
username: req.username,
token: crate::auth::issue_token(),
}))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
/// 前端可选带上的定位参数:经纬度 + 城市名(来自 AMap.Geolocation 插件)。
/// 三者都缺省 → 后端退回高德 IP 定位兜底。
#[derive(Deserialize)]
struct WeatherQuery {
lon: Option<f64>,
lat: Option<f64>,
city: Option<String>,
}
/// 天气 + 定位:
/// - 前端带 `lon/lat`AMap.Geolocation 定位结果)→ 直接用,只调和风。
/// - 未带 → 高德按访问者 IP 定位取城市作兜底。
///
/// 内部自带每日限流(高德 300 / 和风 900北京时间 0 点归零)与短期缓存。
async fn weather(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Query(params): Query<WeatherQuery>,
) -> Json<Value> {
let ip = client_ip(&headers, addr);
// 经纬度齐全才算前端定位成功;否则交给后端 IP 兜底。
let front = match (params.lon, params.lat) {
(Some(lon), Some(lat)) => Some(Located::from_front(lon, lat, params.city)),
_ => None,
};
let outcome = state.weather.get(&ip, front).await;
let q = state.weather.quota_snapshot();
let quota = json!({
"amap": { "remaining": q.amap_remaining, "limit": q.amap_limit },
"qweather": { "remaining": q.qweather_remaining, "limit": q.qweather_limit },
});
let body = match outcome {
WeatherOutcome::Fresh(d) => json!({
"ok": true, "cached": false,
"city": d.city, "text": d.text, "temp": d.temp, "icon": d.icon, "obsTime": d.obs_time,
"quota": quota,
}),
WeatherOutcome::Cached(d) => json!({
"ok": true, "cached": true,
"city": d.city, "text": d.text, "temp": d.temp, "icon": d.icon, "obsTime": d.obs_time,
"quota": quota,
}),
WeatherOutcome::QuotaExceeded { provider } => json!({
"ok": false, "reason": "quota_exceeded", "provider": provider,
"quota": quota,
}),
WeatherOutcome::NotConfigured => json!({
"ok": false, "reason": "not_configured",
}),
WeatherOutcome::Failed(message) => json!({
"ok": false, "reason": "failed", "message": message,
}),
};
Json(body)
}
/// 取访问者真实 IP优先反代透传的 `X-Forwarded-For` 首段,否则用对端 socket 地址。
fn client_ip(headers: &HeaderMap, addr: SocketAddr) -> String {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(first) = xff.split(',').next() {
let ip = first.trim();
if !ip.is_empty() {
return ip.to_string();
}
}
}
addr.ip().to_string()
}

View File

@ -7,18 +7,31 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use crate::auth::{self, DevGate, UserStore};
use crate::monitor::Stats;
use crate::weather::WeatherService;
#[derive(Clone)]
pub struct AppState {
/// 最新一次采样快照。
pub snapshot: Arc<RwLock<Stats>>,
/// 用户校验后端MySQL 或内置兜底):内部已是连接池/只读 map`Arc` 共享即可。
pub users: Arc<UserStore>,
/// 开发者入口门:合法入口 hash 集合,只读共享。
pub gate: Arc<DevGate>,
/// 天气/定位服务(自带每日限流 + 缓存)。
pub weather: Arc<WeatherService>,
}
impl AppState {
pub fn new() -> Self {
// 一个连接池给登录校验与入口门共用(克隆是廉价的、共享底层连接)。
let pool = auth::db_pool_from_env();
Self {
snapshot: Arc::new(RwLock::new(Stats::default())),
users: Arc::new(UserStore::new(pool.clone())),
gate: Arc::new(DevGate::new(pool)),
weather: Arc::new(WeatherService::from_env()),
}
}
}

404
Server/src/weather.rs Normal file
View File

@ -0,0 +1,404 @@
//! 天气与定位服务:高德(定位 + 城市名)+ 和风(实时天气)。
//!
//! 设计要点:
//! - **密钥只在后端**(环境变量读取),前端永远拿不到,也无法绕过限流。
//! - **每日限流**按服务商各自计数:高德 300/天、和风 900/天,北京时间 0 点归零。
//! 额度用完时返回 `QuotaExceeded`,由前端「表个态」。
//! - **短期缓存**(默认 10 分钟,按访问者 IP前端轮询不会浪费上游额度。
//! - 城市名来自高德 IP 定位;和风只用来取天气文字/气温/图标码。
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use chrono::{Datelike, FixedOffset, Utc};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde::Serialize;
/// 北京时区UTC+8下的“今天”序号自公元元年的天数跨 0 点即变化。
fn beijing_day() -> i32 {
let tz = FixedOffset::east_opt(8 * 3600).expect("valid offset");
Utc::now().with_timezone(&tz).date_naive().num_days_from_ce()
}
/// 单服务商的每日额度计数器。
struct DailyQuota {
limit: u32,
used: u32,
day: i32,
}
impl DailyQuota {
fn new(limit: u32) -> Self {
Self { limit, used: 0, day: beijing_day() }
}
/// 跨天则归零。
fn roll(&mut self) {
let today = beijing_day();
if today != self.day {
self.day = today;
self.used = 0;
}
}
fn has_budget(&mut self) -> bool {
self.roll();
self.used < self.limit
}
fn consume(&mut self) {
self.roll();
self.used = self.used.saturating_add(1);
}
fn remaining(&mut self) -> u32 {
self.roll();
self.limit.saturating_sub(self.used)
}
}
/// 对外的天气数据(城市来自高德,其余来自和风)。
#[derive(Clone)]
pub struct WeatherData {
pub city: String,
pub text: String,
pub temp: String,
pub icon: String,
pub obs_time: String,
}
/// 一次取数的结果。
pub enum WeatherOutcome {
Fresh(WeatherData),
Cached(WeatherData),
/// 某服务商当日额度用尽。
QuotaExceeded { provider: &'static str },
/// 未配置密钥。
NotConfigured,
/// 上游调用失败。
Failed(String),
}
/// 当日剩余额度快照(给前端展示/调试)。
pub struct QuotaSnapshot {
pub amap_remaining: u32,
pub amap_limit: u32,
pub qweather_remaining: u32,
pub qweather_limit: u32,
}
struct CacheEntry {
at: Instant,
data: WeatherData,
}
struct Inner {
amap: DailyQuota,
qweather: DailyQuota,
cache: HashMap<String, CacheEntry>,
}
/// 定位结果:城市名 + 和风用的 "lon,lat" 坐标。
/// 既可来自高德 IP 定位,也可来自前端 AMap.Geolocation 插件。
pub struct Located {
city: String,
/// "lon,lat"
coord: String,
}
impl Located {
/// 前端定位AMap.Geolocation传来的经纬度 → Located。
/// 坐标按和风要求格式化成 "lon,lat"(两位小数足够,且能把附近用户聚到同一缓存键)。
pub fn from_front(lon: f64, lat: f64, city: Option<String>) -> Self {
let city = city
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.unwrap_or_else(|| "未知".to_string());
Self { city, coord: format!("{lon:.2},{lat:.2}") }
}
}
/// 和风 JWT 认证所需材料(控制台创建凭据时拿到)。
struct QWeatherAuth {
/// Ed25519 私钥PKCS#8 PEM解析出的签名密钥
encoding_key: EncodingKey,
/// 项目 ID → JWT 的 sub
sub: String,
/// 凭据 ID → JWT 头里的 kid
kid: String,
}
/// 和风 JWT 载荷。
#[derive(Serialize)]
struct QWeatherClaims {
sub: String,
iat: i64,
exp: i64,
}
/// 用私钥签发一个短时效15 分钟)的和风 JWT。
/// iat 回拨 30s 容忍时钟偏差。
fn sign_qweather_jwt(auth: &QWeatherAuth) -> Result<String, String> {
let now = Utc::now().timestamp();
let claims = QWeatherClaims { sub: auth.sub.clone(), iat: now - 30, exp: now + 900 };
let mut header = Header::new(Algorithm::EdDSA);
header.kid = Some(auth.kid.clone());
jsonwebtoken::encode(&header, &claims, &auth.encoding_key).map_err(|e| e.to_string())
}
/// 从环境变量装配和风 JWT 认证;任一缺失则返回 None接口报 not_configured
fn load_qweather_auth() -> Option<QWeatherAuth> {
let sub = std::env::var("QWEATHER_PROJECT_ID").ok().filter(|s| !s.is_empty())?;
let kid = std::env::var("QWEATHER_KEY_ID").ok().filter(|s| !s.is_empty())?;
let pem = qweather_private_key_pem()?;
match EncodingKey::from_ed_pem(pem.as_bytes()) {
Ok(encoding_key) => Some(QWeatherAuth { encoding_key, sub, kid }),
Err(e) => {
eprintln!("和风私钥解析失败: {e}");
None
}
}
}
/// 读取和风私钥:优先文件路径,其次直接给的 PEM 内容(支持 \n 转义)。
fn qweather_private_key_pem() -> Option<String> {
if let Some(path) =
std::env::var("QWEATHER_PRIVATE_KEY_PATH").ok().filter(|s| !s.is_empty())
{
return std::fs::read_to_string(path).ok();
}
std::env::var("QWEATHER_PRIVATE_KEY")
.ok()
.filter(|s| !s.is_empty())
.map(|s| s.replace("\\n", "\n"))
}
struct Now {
text: String,
temp: String,
icon: String,
obs_time: String,
}
pub struct WeatherService {
amap_key: Option<String>,
qweather: Option<QWeatherAuth>,
qweather_host: String,
/// 内网/本地 IP 高德无法定位时的兜底坐标 "lon,lat"。
default_location: Option<String>,
default_city: Option<String>,
cache_ttl: Duration,
inner: Mutex<Inner>,
http: reqwest::Client,
}
fn env_u32(key: &str, default: u32) -> u32 {
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
}
impl WeatherService {
pub fn from_env() -> Self {
let amap_limit = env_u32("AMAP_DAILY_LIMIT", 300);
let qweather_limit = env_u32("QWEATHER_DAILY_LIMIT", 900);
let cache_secs = env_u32("WEATHER_CACHE_SECS", 600) as u64;
Self {
amap_key: std::env::var("AMAP_KEY").ok().filter(|s| !s.is_empty()),
qweather: load_qweather_auth(),
qweather_host: std::env::var("QWEATHER_HOST")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "https://devapi.qweather.com".to_string()),
default_location: std::env::var("WEATHER_DEFAULT_LOCATION").ok().filter(|s| !s.is_empty()),
default_city: std::env::var("WEATHER_DEFAULT_CITY").ok().filter(|s| !s.is_empty()),
cache_ttl: Duration::from_secs(cache_secs),
inner: Mutex::new(Inner {
amap: DailyQuota::new(amap_limit),
qweather: DailyQuota::new(qweather_limit),
cache: HashMap::new(),
}),
http: reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.expect("build http client"),
}
}
pub fn quota_snapshot(&self) -> QuotaSnapshot {
let mut inner = self.inner.lock().unwrap();
QuotaSnapshot {
amap_remaining: inner.amap.remaining(),
amap_limit: inner.amap.limit,
qweather_remaining: inner.qweather.remaining(),
qweather_limit: inner.qweather.limit,
}
}
/// 取天气:先查缓存,再查额度,最后才调上游。注意全程不跨 await 持锁。
///
/// `front` 为前端 AMap.Geolocation 传来的定位(坐标 + 城市):
/// - 有:直接用,**不调高德**(也不消耗高德额度),只调和风。
/// - 无:退回高德 IP 定位兜底(需 `AMAP_KEY`)。
pub async fn get(&self, ip: &str, front: Option<Located>) -> WeatherOutcome {
if self.qweather.is_none() {
return WeatherOutcome::NotConfigured;
}
// 没有前端坐标时才依赖高德 IP 定位,故此时才要求 AMAP_KEY。
if front.is_none() && self.amap_key.is_none() {
return WeatherOutcome::NotConfigured;
}
// 缓存键前端定位按坐标聚合不同位置各一份IP 兜底按访问者 IP。
let cache_key = match &front {
Some(f) => format!("c:{}", f.coord),
None => format!("ip:{ip}"),
};
// 1) 命中新鲜缓存直接返回;否则预检所需额度(走 IP 兜底才需要高德)。
{
let mut inner = self.inner.lock().unwrap();
if let Some(e) = inner.cache.get(&cache_key) {
if e.at.elapsed() < self.cache_ttl {
return WeatherOutcome::Cached(e.data.clone());
}
}
if front.is_none() && !inner.amap.has_budget() {
return WeatherOutcome::QuotaExceeded { provider: "amap" };
}
if !inner.qweather.has_budget() {
return WeatherOutcome::QuotaExceeded { provider: "qweather" };
}
}
// 2) 定位:前端给了就用;否则高德 IP 定位兜底(成功才计数)。
let loc = match front {
Some(f) => f,
None => match self.locate(ip).await {
Ok(l) => {
self.inner.lock().unwrap().amap.consume();
l
}
Err(e) => return WeatherOutcome::Failed(format!("amap: {e}")),
},
};
// 3) 和风实时天气(成功才计数)
let now = match self.weather_now(&loc.coord).await {
Ok(n) => {
self.inner.lock().unwrap().qweather.consume();
n
}
Err(e) => return WeatherOutcome::Failed(format!("qweather: {e}")),
};
let data = WeatherData {
city: loc.city,
text: now.text,
temp: now.temp,
icon: now.icon,
obs_time: now.obs_time,
};
self.inner
.lock()
.unwrap()
.cache
.insert(cache_key, CacheEntry { at: Instant::now(), data: data.clone() });
WeatherOutcome::Fresh(data)
}
/// 高德 IP 定位:拿城市名 + 城市中心坐标。
/// 用 `serde_json::Value` 宽松解析(高德对空字段会返回 `[]` 而非字符串)。
async fn locate(&self, ip: &str) -> Result<Located, String> {
let key = self.amap_key.as_ref().unwrap();
let url = format!("https://restapi.amap.com/v3/ip?key={key}&ip={ip}");
let v: serde_json::Value = self
.http
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let rect = v.get("rectangle").and_then(|x| x.as_str()).unwrap_or("");
let city = v.get("city").and_then(|x| x.as_str()).unwrap_or("");
let province = v.get("province").and_then(|x| x.as_str()).unwrap_or("");
if let Some(coord) = rectangle_center(rect) {
let name = if !city.is_empty() {
city.to_string()
} else if !province.is_empty() {
province.to_string()
} else {
"未知".to_string()
};
return Ok(Located { city: name, coord });
}
// 内网/本地 IP高德定位为空 → 用兜底坐标(若配置了)。
if let Some(coord) = &self.default_location {
return Ok(Located {
city: self.default_city.clone().unwrap_or_else(|| "默认".to_string()),
coord: coord.clone(),
});
}
Err("location unavailable (private IP? set WEATHER_DEFAULT_LOCATION)".to_string())
}
/// 和风实时天气JWT 认证Authorization: Bearer
async fn weather_now(&self, coord: &str) -> Result<Now, String> {
let auth = self.qweather.as_ref().unwrap();
let token = sign_qweather_jwt(auth)?;
let host = self.qweather_host.trim_end_matches('/');
let url = format!("{host}/v7/weather/now?location={coord}");
let v: serde_json::Value = self
.http
.get(&url)
.bearer_auth(token)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let code = v.get("code").and_then(|x| x.as_str()).unwrap_or("");
if code != "200" {
return Err(format!("qweather code {code}"));
}
let now = v.get("now").ok_or("missing `now`")?;
Ok(Now {
text: now.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string(),
temp: now.get("temp").and_then(|x| x.as_str()).unwrap_or("").to_string(),
icon: now.get("icon").and_then(|x| x.as_str()).unwrap_or("999").to_string(),
obs_time: now.get("obsTime").and_then(|x| x.as_str()).unwrap_or("").to_string(),
})
}
}
/// 把高德 rectangle "lon1,lat1;lon2,lat2" 求中心点,格式化成 "lon,lat"(两位小数)。
fn rectangle_center(rect: &str) -> Option<String> {
if rect.is_empty() {
return None;
}
let mut lons = Vec::new();
let mut lats = Vec::new();
for corner in rect.split(';') {
let mut it = corner.split(',');
let lon: f64 = it.next()?.trim().parse().ok()?;
let lat: f64 = it.next()?.trim().parse().ok()?;
lons.push(lon);
lats.push(lat);
}
if lons.is_empty() {
return None;
}
let lon = lons.iter().sum::<f64>() / lons.len() as f64;
let lat = lats.iter().sum::<f64>() / lats.len() as f64;
Some(format!("{lon:.2},{lat:.2}"))
}

72
docs/auth-login-gate.md Normal file
View File

@ -0,0 +1,72 @@
# 登录 + 开发者入口门MySQL 后端)
两道关卡,都由后端 Rust`Server/src/auth.rs`)实现,**默认实时查 MySQL**
1. **入口门(藏门)**:起始页没有任何登录 UI 入口(设置齿轮已移除)。只有访问
`http://<host>/#<hash>``<hash>` 命中后端 `dev_gate` 表,前端才放行到登录页。
2. **登录(身份)**:登录页提交用户名 + 密码,后端按 `ccuser` 表里的 SHA256 校验。
> 入口门只是「把门藏起来」的共享口令,**不是身份凭证**;真正鉴权是第 2 步的登录。
## 请求流
```
浏览器 #<hash>
└─ POST /api/web/gate { hash } → DevGate.allows() 查 dev_gate 表 → 命中才显示登录页
登录页提交
└─ POST /api/web/login { username, pwd } → UserStore.verify() 查 ccuser 表 → 200 + token / 401
```
- 前端:`Client/src/api/gate.ts``checkGate`)、`Client/src/App.tsx`(监听 `hashchange`、防竞态、
回起始页时抹掉地址栏 hash、登录 `Client/src/api/auth.ts`
- 后端:`Server/src/auth.rs`、路由 `Server/src/routes/web.rs``/api/web/gate`、`/api/web/login`。
## 存储MySQL不缓存按需查库
机器内存紧张,所以**不把整表读进内存**`UserStore` 与 `DevGate` 都是
`Db(pool) | Memory(兜底)` 两态DB 模式下每次校验跑一条索引查询。
二者**共用同一个 sqlx 懒连接池**`auth::db_pool_from_env()`max 5 连接),避免开两份连接。
- 连接串来自环境变量 `DATABASE_URL`(线上写在 `/opt/internet-project/weather.env`
`mysql://KarCharon:<password>@127.0.0.1:3306/InternetProject`
- **未设 `DATABASE_URL`** → 各自回退:用户用内置写死表(仅 `cc`)、入口门读 `GATE_FILE`
(默认 `dev_gate.txt`)再退内置。方便本地无 DB 时开发。
- 依赖:`sqlx 0.8`features `runtime-tokio, tls-rustls, mysql, macros`
### 表结构(库 `InternetProject`utf8mb4
```sql
CREATE TABLE ccuser (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_sha256 CHAR(64) NOT NULL, -- SHA256(明文) 的小写 hex
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE dev_gate (
id INT AUTO_INCREMENT PRIMARY KEY,
gate_hash VARCHAR(128) NOT NULL UNIQUE, -- 与前端 URL `#` 后那串原样匹配
label VARCHAR(64) DEFAULT NULL, -- 备注(开发者名)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
- 加用户:`INSERT INTO ccuser (username, password_sha256) VALUES ('名', SHA2('明文',256));`
- 加开发者入口:`INSERT INTO dev_gate (gate_hash, label) VALUES ('<hash>', '名');`
DB 模式即时生效,无需重启。)
- 当前 `cc`:登录密码 `192118Lht`;入口 hash `0086e83c9b10`= 该密码 SHA256 的前 12 位)。
## 一次性搭库服务器本机MySQL 仅监听 127.0.0.1
```sql
CREATE DATABASE InternetProject CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'KarCharon'@'localhost' IDENTIFIED WITH mysql_native_password BY '<password>';
GRANT ALL PRIVILEGES ON InternetProject.* TO 'KarCharon'@'localhost';
-- 然后建上面两张表、插入初始数据。
```
## 安全注记(待办)
- 密码是**裸 SHA256无盐**,仅保证「不落明文」,不抗暴力破解。对外网开放前应换
Argon2/bcrypt 这类慢哈希。
- 登录返回的 `token` 目前**没有任何接口校验**,受保护路由实际尚未真正鉴权(见
`issue_token` 注释)。加鉴权中间件时再赋予其真实含义。

View File

@ -0,0 +1,55 @@
# 起始页:搜索框 + 快捷入口(已实现)
导航起始页在问候语 / 天气行下方有两块:可切换搜索引擎的**搜索框**
以及一组**快捷入口**(第一行 5 个网站、第二行 3 个文档,居中)。
- 前端:
- `Client/src/components/start/SearchBar.tsx`:搜索框 + 引擎下拉
- `Client/src/components/start/QuickLinks.tsx`:快捷入口宫格
## 搜索框(`SearchBar.tsx`
- 内置 4 个引擎,默认 **Bing**Bing / Google / 百度 / DuckDuckGo。
每个引擎是 `{ name, url }``url` 后直接拼 `encodeURIComponent(query)`
- 选中的引擎存 `localStorage`(键 `ip.startpage.engine`),下次进页面记住。
- 提交后 `window.location.href` 跳到引擎搜索结果(当前标签页)。
- 要加引擎:往 `ENGINES` 里加一项即可,下拉会自动多一行。
## 快捷入口(`QuickLinks.tsx`
- 数据是一个 `SHORTCUTS: Shortcut[]`,每项 `{ label, href, icon }`
当前 8 个,每个用 `<motion.a target="_blank">` 新标签页打开:
| 行 | 入口 | 链接 |
|----|------|------|
| 一 | DeepSeek | `https://chat.deepseek.com` |
| 一 | GitHub | `https://github.com` |
| 一 | Bilibili | `https://www.bilibili.com` |
| 一 | 知乎 | `https://www.zhihu.com` |
| 一 | FMHY | `https://fm-hy.top` |
| 二 | Rust 文档 | `https://rustwiki.org/zh-CN/book/` |
| 二 | C++ 文档 | `https://en.cppreference.com/` |
| 二 | C# 文档 | `https://learn.microsoft.com/dotnet/csharp/` |
- **布局**:容器是 `grid-cols-5`8 个元素自然排成「第一行 5 个、第二行 3 个」。
第二行第一个入口加 `col-start-2`,让那 3 个落在第 2/3/4 列即**居中**。
- **图标**:品牌 logo 用 [simple-icons](https://simpleicons.org/) 的单 `path`
内联成 SVG、`fill=currentColor` 跟随文字颜色(默认 `white/75`、悬停纯白),
和玻璃风、天气图标统一,零依赖。
- **FMHY** 不在 simple-icons 里,用紧凑 `FMHY` 字标占位(同色同风格)。
- **C#** 用 simple-icons 的 `csharp`(六边形 + C#),不是单独的 `#`(sharp)。
- 悬停有 framer-motion 弹簧放大上浮动效(与其它起始页元素一致)。
### 增 / 删一个入口
1. 若是品牌站,去 simple-icons 复制对应图标的 `path d="…"`,照现有 `XxxIcon`
写一个内联 SVG 常量(`width/height=22`、`fill=currentColor`);没有官方图标就
仿 `FmhyIcon``<span>` 字标。
2. 往 `SHORTCUTS``{ label, href, icon }`
3. 调整居中:第二行第一个入口要带 `col-start-N`。当前规则写死在 `i === 5`
(第 6 个起是第二行);若每行个数变了,按新的「第二行起始下标 / 居中起始列」改这两处。
## 部署
纯前端改动,按 `docs/startpage-weather-location.md`「只改前端」一节:
`npm run build` → 清空服务器 `static/` → 上传 `dist/*`,后端进程不用动。

View File

@ -0,0 +1,171 @@
# 起始页:天气 + 定位(已实现)
导航起始页问候语下方展示「天气图标 · 城市 · 天气 · 气温」。
**定位优先用前端高德 `AMap.Geolocation`(能 GPS 就 GPS失败退回后端高德 IP 定位;
天气始终来自和风实时天气(私钥只在后端)。**
## 架构
```
浏览器
├─ AMap.Geolocation 前端定位JS API
│ ├─ 浏览器原生定位GPS/WiFi精度高**需 HTTPS**)→ 坐标 + 城市
│ └─ 失败/被拒 → 高德纯 IP 城市定位getCityInfo
│ ↓ 带 lon,lat,city
└─ GET /api/web/weather[?lon=&lat=&city=] (后端)
├─ 带了坐标 → 直接用,**不调高德**(省高德额度)
├─ 没带坐标 → 高德 /v3/ip?ip=<访问者IP> 兜底 → 城市名 + 城市中心坐标
└─ 和风 /v7/weather/now?location= → 天气文字 / 气温 / 图标码
```
> **为什么前端定位 + 后端兜底两套并存:** 前端原生定位GPS能到街道级、还能绕开
> 「数据中心 / CGNAT / 境外 IP 高德返回空」的痛点,但**浏览器原生定位在非 localhost 的
> HTTP 页面下被禁用**。当前线上是裸 HTTP所以现阶段前端只会走它的 IP 退路(精度等同后端
> IP 定位);**等站点上了 HTTPSGPS 会自动开始生效,无需改代码**。前端定位彻底失败时,
> 后端再用 REST Key 做一次 IP 定位作双保险。
- 后端:`Server/src/weather.rs`(服务 + 限流 + 缓存,`get()` 接收可选前端坐标)、
路由 `Server/src/routes/web.rs``/api/web/weather`(解析可选 `lon/lat/city`)。
- 前端:
- `Client/src/lib/amap.ts`:加载高德 JSAPI + `AMap.Geolocation`,封装 `locate()`
- `Client/src/api/weather.ts`、`Client/src/hooks/useWeather.ts`先定位再拉取30 分钟刷新)
- `Client/src/components/StartPage.tsx`:统一持有天气状态,派生**昼夜背景**与**天气动效**,并把天气传给 `Greeting`
- `Client/src/components/start/Greeting.tsx`:展示「天气图标 · 城市 · 天气 · 气温」
- `Client/src/components/start/WeatherIcon.tsx`:天气文字旁的静态图标
- `Client/src/components/start/WeatherFx.tsx`:雨/雪 canvas 动态层
## 限流与缓存(后端内置)
- **高德 300 次/天、和风 900 次/天**,各自独立计数,**北京时间 0 点归零**。
- 任一服务商额度用尽 → 接口返回 `{ ok:false, reason:"quota_exceeded", provider }`
前端显示「天气请求次数已用完,明日 0 点恢复」。
- **缓存 10 分钟**:前端带坐标时按 `坐标` 聚合缓存,未带时按访问者 `IP` 缓存;
前端每 30 分钟刷新一次,正常用量远低于额度。
- 计数只在“真的调用了上游”时增加(命中缓存不计数)。
**前端已定位(带了坐标)时不调高德、也不消耗高德额度**,只消耗和风额度。
## 环境变量
### 前端Vite`Client/.env.local`**不进仓库**
高德 **Web端(JS API)** 密钥,给 `AMap.Geolocation` 前端定位用。与后端 `AMAP_KEY` 是**两套**。
不配则前端不定位、直接由后端 IP 兜底旧行为不会报错。Vite 在 dev 与 build 时都会加载
`.env.local`,故本地构建出的 `dist` 会内置此 KeyJS Key 本就暴露在前端,靠高德控制台
**「安全域名」白名单**约束,不是机密)。
| 变量 | 必填 | 说明 |
|------|------|------|
| `VITE_AMAP_JS_KEY` | 否 | 高德 **Web端(JS API)** Key |
| `VITE_AMAP_JS_CODE` | 否 | 高德 **安全密钥(securityJsCode)**JSAPI 2.0 起与 Key 成对必需,缺它定位报 `INVALID_USER_SCODE` |
### 后端
后端启动时读取(缺和风 JWT 时接口返回 `not_configured`,前端静默不展示;
`AMAP_KEY` 仅 IP 兜底路径需要,前端总能定位时可不配):
| 变量 | 必填 | 说明 |
|------|------|------|
| `AMAP_KEY` | 兜底必填 | 高德 **Web服务(REST)** Key仅前端定位失败的 IP 兜底路径用 |
| `QWEATHER_HOST` | 是 | 和风**专属 API Host**(控制台给的,如 `https://xxxx.qweatherapi.com` |
| `QWEATHER_PROJECT_ID` | 是 | 和风**项目 ID** → JWT 的 `sub` |
| `QWEATHER_KEY_ID` | 是 | 和风**凭据 ID** → JWT 头的 `kid` |
| `QWEATHER_PRIVATE_KEY_PATH` | 二选一 | Ed25519 私钥(PKCS#8 PEM)**文件路径**(推荐,多行 PEM 用文件最省事) |
| `QWEATHER_PRIVATE_KEY` | 二选一 | 直接给私钥 PEM 内容(可用 `\n` 转义换行) |
| `AMAP_DAILY_LIMIT` | 否 | 默认 `300` |
| `QWEATHER_DAILY_LIMIT` | 否 | 默认 `900` |
| `WEATHER_CACHE_SECS` | 否 | 缓存秒数,默认 `600` |
| `WEATHER_DEFAULT_LOCATION` | 否 | 内网/本地 IP 定位失败时的兜底坐标 `经度,纬度`(如 `116.40,39.90` |
| `WEATHER_DEFAULT_CITY` | 否 | 兜底坐标对应的城市名 |
> 和风认证用 **JWT(EdDSA/Ed25519)**:后端用私钥签发 15 分钟有效的 token
> 以 `Authorization: Bearer` 调用。私钥**只放后端**,绝不进前端/仓库。
> 定位失败的处理:高德对**数据中心 / DNS / 部分移动·CGNAT / 境外 IP** 会返回空。
> 若**不设** `WEATHER_DEFAULT_LOCATION`,定位失败时接口直接返回 `failed`
> **不再调和风**(省额度)→ 前端静默不显示天气。**线上有意不设兜底**,就是这个效果。
>
> 本地开发时访问者 IP 是 `127.0.0.1`,高德无法定位 → 想在本地看到天气,可临时设
> `WEATHER_DEFAULT_LOCATION` + `WEATHER_DEFAULT_CITY`(仅本地 `weather.env`,别带上线)。
## 本地运行
后端(在 `Server/`WSL 或 Windows 均可):
```bash
AMAP_KEY=xxx \
QWEATHER_HOST=https://xxxx.qweatherapi.com \
QWEATHER_PROJECT_ID=项目ID QWEATHER_KEY_ID=凭据ID \
QWEATHER_PRIVATE_KEY_PATH=./qweather-ed25519.pem \
WEATHER_DEFAULT_LOCATION=116.40,39.90 WEATHER_DEFAULT_CITY=北京 \
PORT=8080 cargo run
```
前端 `cd Client && npm run dev`,打开起始页即可看到天气行。
## 部署:改了后端时(重新编译)
改了 `Server/` 下代码时上线需要**重新编译 Rust 二进制**
1. WSL 里 `cd /mnt/f/Code/InternetProject/Server && cargo build --release`
2. scp 覆盖服务器 `/opt/internet-project/RustServer` + 前端 `dist/*`
3. 私钥与密钥建议写进服务器上的一个文件再统一加载,避免出现在命令历史里。
例如服务器放 `/opt/internet-project/qweather.pem``/opt/internet-project/weather.env`
```bash
# weather.env 内容(不进仓库)
AMAP_KEY=xxx
QWEATHER_HOST=https://xxxx.qweatherapi.com
QWEATHER_PROJECT_ID=项目ID
QWEATHER_KEY_ID=凭据ID
QWEATHER_PRIVATE_KEY_PATH=/opt/internet-project/qweather.pem
PORT=8548
# 登录 / 入口门走 MySQL见 docs/auth-login-gate.md
DATABASE_URL=mysql://KarCharon:******@127.0.0.1:3306/InternetProject
```
启动:
```bash
ssh root@8.130.143.54 "pkill RustServer; cd /opt/internet-project && \
set -a && . ./weather.env && set +a && nohup ./RustServer > server.log 2>&1 &"
```
## 天气图标方案
未引入图标库/字体,而是把和风 `now.icon` 码归成 7 类
(晴日/晴夜/多云阴/雨/雷/雪/雾霾),各画一个极简线性 SVG`WeatherIcon.tsx`
`stroke=currentColor` 跟随文字颜色,矢量可缩放、离线可用,和玻璃风统一。
要更精细可换成和风官方图标集,但当前方案零依赖、足够清爽。
## 昼夜背景切换
背景图按昼夜在 `light.*` / `dark.*` 之间自动切换(图片放
`Client/src/assets/startpage/`,详见该目录 README
- **昼夜判断**`StartPage.tsx`):优先用 `now.icon`——`150/151/152/153/350/351/456/457`
判为夜、`100/101/102/103/300/301/400/401` 判为昼;其余无昼夜之分的天气码用
**本地时间兜底**18:0006:00 为夜)。每分钟一拍,跨时段会自动翻。
- **回退**:选中的图缺失 → 另一张 → 目录里排序第一张 → 中性渐变。
## 天气动态效果
`WeatherFx.tsx` 用一个轻量 canvas 粒子层按天气渲染(**克制**风格、低透明度、
尊重系统「减少动态效果」、`kind='none'` 不挂载):
| 天气(`now.icon` | 效果 |
|------|------|
| 雨类 `300399` | 斜向落雨;其中雷阵雨 `302/303/304` 叠**偶发闪电**一闪 |
| 雪类 `400499` 及冷 `901` | 缓慢飘落雪花 |
| 其余 | 无(静态) |
层级:背景图(-z-20) → 渐变 scrim + 半透明黑遮罩(-z-10) → **天气动效(z-0)** → 内容(z-10)。
## 本地预览开关(仅 `npm run dev`
不开后端 / 不等真实天气也能预览昼夜背景与动效,加 URL 参数即可:
- `?bg=light` / `?bg=dark` —— 强制白天/夜晚背景
- `?fx=rain` / `?fx=snow` / `?fx=thunder` / `?fx=none` —— 强制动效
例:`http://localhost:5173/?bg=dark&fx=snow`。这些参数 `import.meta.env.DEV` 为真才生效,**线上无效**,线上完全由真实天气驱动。
## 再次部署的两种情况
- **只改前端**(动效、背景、样式等):前端单独重发即可——`npm run build` →
清空服务器 `static/` → 上传 `dist/*`。后端进程不用动(实时读磁盘静态文件)。
- **改了后端**`Server/` 下任何代码):需在 WSL `cargo build --release`
覆盖 `/opt/internet-project/RustServer` → 带环境变量重启(见上「部署:改了后端时」)。

View File

@ -0,0 +1,232 @@
# InternetProject Deployment Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deploy the React + Rust full-stack app to `root@8.130.143.54`, accessible at `http://8.130.143.54:8548`.
**Architecture:** Rust binary (`RustServer`) serves both the REST API and React static files from a single process. React is built locally, Rust is cross-compiled in WSL, both artifacts are uploaded via scp and started with nohup.
**Tech Stack:** React 19 + Vite 8 (frontend), Rust + Axum 0.8 (backend), Ubuntu 22.04 (server), WSL2 mirrored network (build env)
## Global Constraints
- Server: `root@8.130.143.54`, Ubuntu 22.04, x86_64
- Deploy path: `/opt/internet-project/` on server
- Static files served from: `/opt/internet-project/static/`
- Port: `8548` (via `PORT=8548` env var at runtime)
- WSL mirrored network + `autoProxy=true` — no manual proxy config needed
- `main.rs` already updated to read `PORT` env var (default `8080`)
- Do NOT install any toolchain on the remote server
---
### Task 1: Build React Frontend
**Files:**
- Build: `Client/``Client/dist/`
**Interfaces:**
- Produces: `Client/dist/index.html` + `Client/dist/assets/*` (consumed by Task 3)
- [ ] **Step 1: Install dependencies (if not already installed)**
Run in PowerShell from `F:\Code\InternetProject\Client`:
```powershell
npm install
```
Expected: `node_modules/` populated, no errors.
- [ ] **Step 2: Build the frontend**
```powershell
npm run build
```
Expected output ends with something like:
```
dist/index.html x.xx kB
dist/assets/index-[hash].js xxx.xx kB
✓ built in x.xxs
```
- [ ] **Step 3: Verify dist output**
```powershell
Get-ChildItem Client\dist
```
Expected: `index.html`, `assets/`, `favicon.svg`, `icons.svg` present.
- [ ] **Step 4: Commit the main.rs port change**
```powershell
git add Server/src/main.rs
git commit -m "feat: read PORT from env var, default 8080"
```
---
### Task 2: Build Rust Binary in WSL
**Files:**
- Build: `Server/``Server/target/release/RustServer`
**Interfaces:**
- Produces: `/mnt/f/Code/InternetProject/Server/target/release/RustServer` — Linux x86_64 ELF binary (consumed by Task 3)
- [ ] **Step 1: Open WSL and navigate to project**
In a WSL terminal:
```bash
cd /mnt/f/Code/InternetProject/Server
```
- [ ] **Step 2: Check Rust toolchain is installed**
```bash
rustc --version
```
If not installed:
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
```
Expected: `rustc 1.xx.x (...)` printed.
- [ ] **Step 3: Build release binary**
```bash
cargo build --release
```
First build takes 25 minutes (downloads + compiles dependencies). Expected final line:
```
Finished `release` profile [optimized] target(s) in Xs
```
- [ ] **Step 4: Verify binary exists and is Linux ELF**
```bash
file target/release/RustServer
```
Expected:
```
target/release/RustServer: ELF 64-bit LSB pie executable, x86-64, ...
```
---
### Task 3: Upload Artifacts to Server
**Files:**
- Remote create: `/opt/internet-project/` (directory)
- Remote create: `/opt/internet-project/RustServer` (binary)
- Remote create: `/opt/internet-project/static/` (frontend files)
**Interfaces:**
- Consumes: `Server/target/release/RustServer` (from Task 2), `Client/dist/*` (from Task 1)
- Produces: server-side deploy directory ready to run (consumed by Task 4)
- [ ] **Step 1: Create deploy directory on server**
Run in WSL:
```bash
ssh root@8.130.143.54 "mkdir -p /opt/internet-project/static"
```
Expected: no output, exit 0.
- [ ] **Step 2: Upload the Rust binary**
```bash
scp /mnt/f/Code/InternetProject/Server/target/release/RustServer \
root@8.130.143.54:/opt/internet-project/RustServer
```
Expected: progress bar, then returns to prompt.
- [ ] **Step 3: Upload frontend static files**
```bash
scp -r /mnt/f/Code/InternetProject/Client/dist/* \
root@8.130.143.54:/opt/internet-project/static/
```
Expected: multiple files transferred (index.html, assets/, etc.).
- [ ] **Step 4: Make binary executable**
```bash
ssh root@8.130.143.54 "chmod +x /opt/internet-project/RustServer"
```
- [ ] **Step 5: Verify remote layout**
```bash
ssh root@8.130.143.54 "ls -lh /opt/internet-project/ && ls /opt/internet-project/static/"
```
Expected:
```
total XX
-rwxr-xr-x 1 root root XXM ... RustServer
drwxr-xr-x 2 root root ... static/
assets favicon.svg icons.svg index.html
```
---
### Task 4: Start the Server and Verify
**Files:**
- Remote: `/opt/internet-project/server.log` (created on start)
**Interfaces:**
- Consumes: deploy directory from Task 3
- Produces: running service at `http://8.130.143.54:8548`
- [ ] **Step 1: Start server with nohup**
```bash
ssh root@8.130.143.54 "cd /opt/internet-project && nohup PORT=8548 ./RustServer > server.log 2>&1 &"
```
Expected: no output (background process started).
- [ ] **Step 2: Check server started successfully**
```bash
ssh root@8.130.143.54 "sleep 1 && cat /opt/internet-project/server.log"
```
Expected:
```
backend listening on http://0.0.0.0:8548
```
- [ ] **Step 3: Verify API endpoint responds**
```bash
curl http://8.130.143.54:8548/api/client/health
```
Expected: HTTP 200 with a JSON response (or empty 200).
- [ ] **Step 4: Verify frontend loads**
Open `http://8.130.143.54:8548` in a browser. Expected: React app renders correctly (login page / blog page visible).
- [ ] **Step 5: Verify API stats endpoint**
```bash
curl http://8.130.143.54:8548/api/web/stats
```
Expected: JSON with system metrics (CPU, memory, etc.).
---
## Operations Reference
**Stop server:**
```bash
ssh root@8.130.143.54 "pkill RustServer"
```
**View live logs:**
```bash
ssh root@8.130.143.54 "tail -f /opt/internet-project/server.log"
```
**Redeploy after code changes:**
Re-run Tasks 14. For frontend-only changes, skip Task 2.

View File

@ -0,0 +1,39 @@
# Deployment Design: InternetProject to 8.130.143.54
## Summary
Deploy a React (Vite) frontend + Rust (Axum) backend to a remote Ubuntu 22.04 server. The Rust binary serves both the API and the static frontend files. Port 8548, manual start via nohup.
## Architecture
Single-binary deployment. Rust binary at `/opt/internet-project/RustServer` serves:
- `GET /api/web/stats` — system metrics for the React frontend
- `GET /api/client/health` — health check for future .NET client
- All other paths → static files from `/opt/internet-project/static/` (SPA fallback to index.html)
Access: `http://8.130.143.54:8548`
## Server Info
- OS: Ubuntu 22.04.5 LTS, x86_64
- RAM: 1.6 GB, Disk: 35 GB free
- Tools pre-installed: git only (no Rust, no Node)
## Build Strategy
Cross-compile locally via WSL (mirrored network mode + autoProxy=true — no manual proxy config needed).
- **React**: `npm run build` in Windows PowerShell or WSL → `Client/dist/`
- **Rust**: `cargo build --release` in WSL → `Server/target/release/RustServer`
- Port is read from `PORT` env var (default 8080); code change already applied to `main.rs`
## Deploy Steps
1. Build React frontend (`npm run build`)
2. Build Rust binary in WSL (`cargo build --release`)
3. Upload via scp: binary + `Client/dist/*``/opt/internet-project/`
4. Start: `nohup PORT=8548 ./RustServer > server.log 2>&1 &`
## Process Management
Manual start (nohup). Stop with `pkill RustServer`. Logs at `/opt/internet-project/server.log`.