Compare commits
11 Commits
2205408252
...
f576b03315
| Author | SHA1 | Date | |
|---|---|---|---|
| f576b03315 | |||
| d7d520058c | |||
| 4c8c4360ad | |||
| b2a0b563c9 | |||
| 58bed0ae44 | |||
| f4342e7c82 | |||
| a185e56c22 | |||
| 06bbf65d71 | |||
|
|
c61b8de776 | ||
|
|
6037a8186c | ||
| 58284f3fe0 |
9
.gitignore
vendored
9
.gitignore
vendored
@ -26,3 +26,12 @@ lerna-debug.log*
|
||||
|
||||
# --- Server ---
|
||||
Server/target
|
||||
Server/.vite
|
||||
|
||||
# --- Tooling(本地索引/缓存,勿入库)---
|
||||
.codegraph/
|
||||
|
||||
# --- Secrets(密钥/私钥/本地环境,永不入库)---
|
||||
*.pem
|
||||
*.env
|
||||
weather.env
|
||||
|
||||
@ -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
38
Client/src/api/auth.ts
Normal file
@ -0,0 +1,38 @@
|
||||
// 登录接口封装。后端基址默认本机 8080,可用 VITE_API_BASE 覆盖
|
||||
// (与 useSystemStats 保持一致)。
|
||||
const API_BASE =
|
||||
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080'
|
||||
|
||||
export interface LoginResult {
|
||||
ok: boolean
|
||||
username: string
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交用户名/明文密码到后端校验。
|
||||
* - 成功:解析 200 响应体(含 token)。
|
||||
* - 凭据错误:后端返回 401,这里抛出可读错误。
|
||||
* - 后端不可达:fetch 失败,抛出网络错误。
|
||||
*/
|
||||
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/web/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
} catch {
|
||||
throw new Error('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
24
Client/src/api/gate.ts
Normal 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
51
Client/src/api/weather.ts
Normal 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
|
||||
}
|
||||
BIN
Client/src/assets/sides/1.png
Normal file
BIN
Client/src/assets/sides/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
BIN
Client/src/assets/sides/2.jpg
Normal file
BIN
Client/src/assets/sides/2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
BIN
Client/src/assets/sides/Aria.jpg
Normal file
BIN
Client/src/assets/sides/Aria.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
40
Client/src/assets/sides/README.md
Normal file
40
Client/src/assets/sides/README.md
Normal 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` —— 右栏
|
||||
BIN
Client/src/assets/sides/sunna.jpg
Normal file
BIN
Client/src/assets/sides/sunna.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
39
Client/src/assets/startpage/README.md
Normal file
39
Client/src/assets/startpage/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Startpage 背景图
|
||||
|
||||
导航起始页(`src/components/StartPage.tsx`)的背景图放在这个目录里,
|
||||
**按昼夜自动切换**:
|
||||
|
||||
- 白天 → `light.*`(如 `light.jpg`)
|
||||
- 夜晚 → `dark.*`(如 `dark.png`)
|
||||
|
||||
放好这两张文件即可生效,无需改代码。
|
||||
|
||||
## 工作方式
|
||||
|
||||
`StartPage` 用 Vite 的 `import.meta.glob` 索引本目录,按文件名精确取
|
||||
`light.*` / `dark.*`,再按昼夜选用。
|
||||
|
||||
昼夜判断:**优先用天气接口返回的图标码**(晴/多云/阵雨类区分昼夜),
|
||||
图标码判断不了的天气(雨、雪、雾、阴等无昼夜之分)则用**本地时间兜底**
|
||||
(18:00–06: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`
|
||||
BIN
Client/src/assets/startpage/dark.png
Normal file
BIN
Client/src/assets/startpage/dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 844 KiB |
BIN
Client/src/assets/startpage/light.jpg
Normal file
BIN
Client/src/assets/startpage/light.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
226
Client/src/components/BlogPage.tsx
Normal file
226
Client/src/components/BlogPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
62
Client/src/components/RainbowSpotlight.tsx
Normal file
62
Client/src/components/RainbowSpotlight.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useRef } from 'react'
|
||||
import { motion, useMotionValue, useReducedMotion, useSpring } from 'framer-motion'
|
||||
|
||||
// 跟随鼠标的彩虹光斑(仿 joshwcomeau 的交互彩虹)。
|
||||
// 自身 pointer-events-none、置于内容下层,靠监听父元素的鼠标事件取坐标,
|
||||
// 因此不会挡住 hero 里的文字交互。挂在一个 relative 容器内使用。
|
||||
export function RainbowSpotlight() {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const reduce = useReducedMotion()
|
||||
|
||||
const x = useMotionValue(0)
|
||||
const y = useMotionValue(0)
|
||||
const springX = useSpring(x, { stiffness: 180, damping: 28, mass: 0.6 })
|
||||
const springY = useSpring(y, { stiffness: 180, damping: 28, mass: 0.6 })
|
||||
const opacity = useMotionValue(0)
|
||||
const springOpacity = useSpring(opacity, { stiffness: 120, damping: 26 })
|
||||
|
||||
// 在父元素上挂监听:pointer-events-none 收不到事件,故委托给父级。
|
||||
const attach = (node: HTMLDivElement | null) => {
|
||||
ref.current = node
|
||||
const parent = node?.parentElement
|
||||
if (!parent) return
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = parent.getBoundingClientRect()
|
||||
x.set(e.clientX - rect.left)
|
||||
y.set(e.clientY - rect.top)
|
||||
opacity.set(0.45)
|
||||
}
|
||||
const onLeave = () => opacity.set(0)
|
||||
parent.addEventListener('mousemove', onMove)
|
||||
parent.addEventListener('mouseleave', onLeave)
|
||||
}
|
||||
|
||||
const RAINBOW =
|
||||
'conic-gradient(from 0deg, #ff5f6d, #ffb13d, #ffe14d, #4ade80, #38bdf8, #a78bfa, #f472b6, #ff5f6d)'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={attach}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="absolute h-[40rem] w-[40rem] rounded-full"
|
||||
style={{
|
||||
x: springX,
|
||||
y: springY,
|
||||
translateX: '-50%',
|
||||
translateY: '-50%',
|
||||
opacity: springOpacity,
|
||||
background: RAINBOW,
|
||||
filter: 'blur(90px)',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
animate={reduce ? undefined : { rotate: 360 }}
|
||||
transition={
|
||||
reduce ? undefined : { duration: 14, ease: 'linear', repeat: Infinity }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
Client/src/components/StartPage.tsx
Normal file
131
Client/src/components/StartPage.tsx
Normal 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:00–06: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.5(0=全透明,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>
|
||||
)
|
||||
}
|
||||
74
Client/src/components/start/Greeting.tsx
Normal file
74
Client/src/components/start/Greeting.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
Client/src/components/start/QuickLinks.tsx
Normal file
97
Client/src/components/start/QuickLinks.tsx
Normal 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
|
||||
}
|
||||
|
||||
// 品牌图标:单 path,fill=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-5:8 个入口自然排成第一行 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>
|
||||
)
|
||||
}
|
||||
158
Client/src/components/start/SearchBar.tsx
Normal file
158
Client/src/components/start/SearchBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
Client/src/components/start/WeatherFx.tsx
Normal file
122
Client/src/components/start/WeatherFx.tsx
Normal 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" />
|
||||
}
|
||||
83
Client/src/components/start/WeatherIcon.tsx
Normal file
83
Client/src/components/start/WeatherIcon.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
// 把和风天气的图标码(now.icon,3 位数字)映射成一组极简线性图标,
|
||||
// 与导航页的玻璃风格统一:纯 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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
77
Client/src/hooks/useAuth.ts
Normal file
77
Client/src/hooks/useAuth.ts
Normal 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 }
|
||||
}
|
||||
43
Client/src/hooks/useWeather.ts
Normal file
43
Client/src/hooks/useWeather.ts
Normal 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
|
||||
}
|
||||
@ -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 时把文字色设为 transparent(group-hover:text-transparent)即露出彩虹。 */
|
||||
.rainbow-clip {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
#ff5f6d,
|
||||
#ffb13d,
|
||||
#ffe14d,
|
||||
#4ade80,
|
||||
#38bdf8,
|
||||
#a78bfa,
|
||||
#f472b6,
|
||||
#ff5f6d
|
||||
);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: rainbow-pan 6s linear infinite;
|
||||
}
|
||||
|
||||
/* ── 整页渐变极光背景(玻璃栏透出的就是它)──────────────────────
|
||||
固定铺满视口、置于所有内容之下。底色为 Lunada 渐变
|
||||
(uigradients「Lunada」:#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
122
Client/src/lib/amap.ts
Normal file
@ -0,0 +1,122 @@
|
||||
// 高德 JS API(AMap.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
3
Server/.gitignore
vendored
@ -1 +1,4 @@
|
||||
/target
|
||||
|
||||
# 开发者入口 hash 列表(秘密,单独部署,勿提交)
|
||||
/dev_gate.txt
|
||||
|
||||
1889
Server/Cargo.lock
generated
1889
Server/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
226
Server/src/auth.rs
Normal 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();
|
||||
|
||||
// cc:SHA256("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
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
404
Server/src/weather.rs
Normal 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
72
docs/auth-login-gate.md
Normal 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` 注释)。加鉴权中间件时再赋予其真实含义。
|
||||
55
docs/startpage-quicklinks.md
Normal file
55
docs/startpage-quicklinks.md
Normal 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/*`,后端进程不用动。
|
||||
171
docs/startpage-weather-location.md
Normal file
171
docs/startpage-weather-location.md
Normal 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 定位);**等站点上了 HTTPS,GPS 会自动开始生效,无需改代码**。前端定位彻底失败时,
|
||||
> 后端再用 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` 会内置此 Key(JS 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:00–06:00 为夜)。每分钟一拍,跨时段会自动翻。
|
||||
- **回退**:选中的图缺失 → 另一张 → 目录里排序第一张 → 中性渐变。
|
||||
|
||||
## 天气动态效果
|
||||
|
||||
`WeatherFx.tsx` 用一个轻量 canvas 粒子层按天气渲染(**克制**风格、低透明度、
|
||||
尊重系统「减少动态效果」、`kind='none'` 不挂载):
|
||||
|
||||
| 天气(`now.icon`) | 效果 |
|
||||
|------|------|
|
||||
| 雨类 `300–399` | 斜向落雨;其中雷阵雨 `302/303/304` 叠**偶发闪电**一闪 |
|
||||
| 雪类 `400–499` 及冷 `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` → 带环境变量重启(见上「部署:改了后端时」)。
|
||||
232
docs/superpowers/plans/2026-06-18-deploy.md
Normal file
232
docs/superpowers/plans/2026-06-18-deploy.md
Normal 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 2–5 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 1–4. For frontend-only changes, skip Task 2.
|
||||
39
docs/superpowers/specs/2026-06-18-deploy-design.md
Normal file
39
docs/superpowers/specs/2026-06-18-deploy-design.md
Normal 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`.
|
||||
Loading…
Reference in New Issue
Block a user