feat: 导航起始页 + 天气/定位 + 天气动态效果
起始页(StartPage) - 极简玻璃风导航页作未登录首页:时段问候、Bing 默认可切换搜索、10 个快捷位、右下齿轮进登录 - 昼夜背景自动切换(light/dark),按天气图标码 + 本地时间判断 - 天气动态效果(WeatherFx canvas):雨/雪/雷,克制风格,尊重 reduced-motion 天气与定位(前后端) - 后端 /api/web/weather:高德 IP 定位(城市+坐标) + 和风实时天气 - 和风用 JWT(EdDSA/Ed25519)认证,密钥走环境变量、仅后端持有 - 每日限流(高德 300、和风 900,北京 0 点归零) + 10 分钟按 IP 缓存 - 前端 useWeather + WeatherIcon(7 类极简 SVG) 导航与登录 - 浏览器返回键支持(History API);返回即登出 - 记住我:勾选存 localStorage、不勾存 sessionStorage,默认不勾 其他 - .gitignore 忽略 *.pem/*.env 等密钥 - 新增文档 docs/startpage-weather-location.md 及部署 spec/plan Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5
.gitignore
vendored
@ -27,3 +27,8 @@ lerna-debug.log*
|
||||
# --- Server ---
|
||||
Server/target
|
||||
Server/.vite
|
||||
|
||||
# --- Secrets(密钥/私钥/本地环境,永不入库)---
|
||||
*.pem
|
||||
*.env
|
||||
weather.env
|
||||
|
||||
@ -1,20 +1,58 @@
|
||||
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'
|
||||
|
||||
function App() {
|
||||
// 登录态从 localStorage 恢复:已登录直接进博客页,否则进登录页。
|
||||
// 登录态从 localStorage/sessionStorage 恢复:已登录直接进博客页。
|
||||
const auth = useAuth()
|
||||
// 未登录默认进导航页;点齿轮切到登录页。
|
||||
const [showLogin, setShowLogin] = useState(false)
|
||||
const signOut = auth.signOut
|
||||
|
||||
// 进登录页:往浏览器历史压一条记录,便于返回键退回。
|
||||
const openLogin = () => {
|
||||
window.history.pushState({ page: 'login' }, '')
|
||||
setShowLogin(true)
|
||||
}
|
||||
|
||||
// “返回等于登出”:退回导航页时清掉登录态。
|
||||
// 浏览器返回键 / 登录页返回 / 博客页登出 都汇到这里。
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
signOut()
|
||||
setShowLogin(false)
|
||||
}
|
||||
window.addEventListener('popstate', onPop)
|
||||
return () => window.removeEventListener('popstate', onPop)
|
||||
}, [signOut])
|
||||
|
||||
// 博客页登出:若是从登录流程进来的(历史里有 login 记录),走 history.back()
|
||||
// 让浏览器历史保持干净;否则(如记住登录直接进博客)直接回首页。
|
||||
const signOutFromBlog = () => {
|
||||
if (window.history.state?.page === 'login') {
|
||||
window.history.back()
|
||||
} else {
|
||||
signOut()
|
||||
setShowLogin(false)
|
||||
}
|
||||
}
|
||||
|
||||
let view
|
||||
if (auth.username) {
|
||||
view = <BlogPage username={auth.username} onSignOut={signOutFromBlog} />
|
||||
} else if (showLogin) {
|
||||
view = <LoginPage onSignIn={auth.signIn} onBack={() => window.history.back()} />
|
||||
} else {
|
||||
view = <StartPage onOpenSettings={openLogin} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CursorFollower />
|
||||
{auth.username ? (
|
||||
<BlogPage username={auth.username} onSignOut={auth.signOut} />
|
||||
) : (
|
||||
<LoginPage onSignIn={auth.signIn} />
|
||||
)}
|
||||
{view}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
35
Client/src/api/weather.ts
Normal file
@ -0,0 +1,35 @@
|
||||
// 天气接口封装。后端在同一进程托管前端 + 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
|
||||
}
|
||||
|
||||
/** 拉取一次天气;网络层失败(后端不可达等)会抛错,由调用方兜底。 */
|
||||
export async function fetchWeather(): Promise<WeatherResult> {
|
||||
const res = await fetch(`${API_BASE}/api/web/weather`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as WeatherResult
|
||||
}
|
||||
BIN
Client/src/assets/sides/1.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
Client/src/assets/sides/2.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 249 KiB |
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
|
After Width: | Height: | Size: 844 KiB |
BIN
Client/src/assets/startpage/light.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@ -148,16 +148,13 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
|
||||
{/* 三栏:左侧图片 · 中间内容(文章列表) · 右侧图片。
|
||||
中间内容保持原比例(max-w-3xl),两侧 flex-1 图片区吃掉剩余留白(窄屏隐藏)。 */}
|
||||
<div className="relative flex flex-1 justify-center">
|
||||
{/* 左侧图片区:高度适配展示框、宽度按比例(不拉伸变形)、居中(水平溢出裁切);
|
||||
上覆液态玻璃。缺图时透出 Lunada 背景。 */}
|
||||
{/* 左侧图片区:高度适配展示框、宽度按比例(不拉伸变形)、居中(水平溢出裁切)。
|
||||
缺图时透出 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-image absolute inset-0" />
|
||||
</aside>
|
||||
/>
|
||||
|
||||
{/* 中间内容纸(玻璃纸张) */}
|
||||
<div className="glass-sheet relative flex w-full max-w-3xl flex-col">
|
||||
@ -217,15 +214,12 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
|
||||
</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 className="glass-image absolute inset-0" />
|
||||
</aside>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -14,7 +14,7 @@ interface LoginFormProps {
|
||||
export function LoginForm({ onSignIn }: LoginFormProps) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(true)
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
|
||||
@ -6,16 +6,40 @@ import { LoginForm } from './LoginForm'
|
||||
|
||||
interface LoginPageProps {
|
||||
onSignIn: Auth['signIn']
|
||||
/** 返回导航页(从起始页齿轮进来时可用) */
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
export function LoginPage({ onSignIn }: LoginPageProps) {
|
||||
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">
|
||||
|
||||
139
Client/src/components/StartPage.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
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 { SettingsGear } from './start/SettingsGear'
|
||||
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
|
||||
}
|
||||
|
||||
interface StartPageProps {
|
||||
/** 点右下齿轮:切到登录页 */
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function StartPage({ onOpenSettings }: StartPageProps) {
|
||||
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>
|
||||
|
||||
<SettingsGear onClick={onOpenSettings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
40
Client/src/components/start/QuickLinks.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
// 预留 10 个快捷入口,先全部留空。后续每项填 { icon, label, href } 即可。
|
||||
interface Shortcut {
|
||||
label?: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const SHORTCUTS: Shortcut[] = Array.from({ length: 10 }, () => ({}))
|
||||
|
||||
export function QuickLinks() {
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-xl grid-cols-5 gap-3 sm:gap-4">
|
||||
{SHORTCUTS.map((item, i) => (
|
||||
<motion.button
|
||||
key={i}
|
||||
type="button"
|
||||
whileHover={{ scale: 1.12, y: -4 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 22 }}
|
||||
className="glass-panel flex aspect-square items-center justify-center rounded-2xl text-white/30 transition-colors hover:text-white/55"
|
||||
aria-label={item.label ?? `Shortcut ${i + 1} (empty)`}
|
||||
>
|
||||
{/* 占位「+」:填入图标前的空槽 */}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M12 6v12M6 12h12" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
34
Client/src/components/start/SettingsGear.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface SettingsGearProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
// 右下角齿轮:悬停放大并旋转,点击跳转登录页。
|
||||
export function SettingsGear({ onClick }: SettingsGearProps) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label="Settings — go to sign in"
|
||||
whileHover={{ scale: 1.15, rotate: 35 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 18 }}
|
||||
className="glass-panel fixed bottom-6 right-6 z-30 flex h-12 w-12 items-center justify-center rounded-full text-white/80 hover:text-white"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
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
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { login as loginRequest } from '../api/auth'
|
||||
|
||||
// localStorage 键:记住登录态时把会话写在这里,刷新后仍处于登录态。
|
||||
// storage 键:登录态写在浏览器。
|
||||
// - 勾“记住我” → localStorage(关浏览器再开仍登录)
|
||||
// - 不勾 → sessionStorage(刷新仍在,关标签页/浏览器即清)
|
||||
const STORAGE_KEY = 'ip.auth'
|
||||
|
||||
interface Session {
|
||||
@ -9,10 +11,9 @@ interface Session {
|
||||
token: string
|
||||
}
|
||||
|
||||
function loadSession(): Session | null {
|
||||
function parseSession(raw: string | null): Session | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<Session>
|
||||
if (typeof parsed.username === 'string' && typeof parsed.token === 'string') {
|
||||
return { username: parsed.username, token: parsed.token }
|
||||
@ -23,18 +24,26 @@ function loadSession(): Session | null {
|
||||
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 */
|
||||
/** 校验凭据;remember 为 true 时持久化到 localStorage,否则只存 sessionStorage */
|
||||
signIn: (username: string, password: string, remember: boolean) => Promise<void>
|
||||
/** 退出登录并清除持久化会话 */
|
||||
/** 退出登录并清除浏览器里的会话(两种 storage 都清) */
|
||||
signOut: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录态管理。初始值从 localStorage 恢复(记住登录态),
|
||||
* 因此刷新页面后仍停留在登录后界面。
|
||||
* 登录态管理。初始值从 localStorage / sessionStorage 恢复,
|
||||
* 因此刷新页面后仍停留在登录后界面(“记住我”决定关浏览器后是否还在)。
|
||||
*/
|
||||
export function useAuth(): Auth {
|
||||
const [session, setSession] = useState<Session | null>(loadSession)
|
||||
@ -44,9 +53,14 @@ export function useAuth(): Auth {
|
||||
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, JSON.stringify(next))
|
||||
// 记住我:持久化,关浏览器再开仍登录。
|
||||
localStorage.setItem(STORAGE_KEY, raw)
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
} else {
|
||||
// 不记住:仅本次会话,刷新仍在、关标签页/浏览器即清。
|
||||
sessionStorage.setItem(STORAGE_KEY, raw)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
},
|
||||
@ -56,6 +70,7 @@ export function useAuth(): Auth {
|
||||
const signOut = useCallback(() => {
|
||||
setSession(null)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
}, [])
|
||||
|
||||
return { username: session?.username ?? null, signIn, signOut }
|
||||
|
||||
40
Client/src/hooks/useWeather.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchWeather, type WeatherResult } from '../api/weather'
|
||||
|
||||
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 {
|
||||
const data = await fetchWeather()
|
||||
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
|
||||
}
|
||||
@ -191,24 +191,6 @@ body {
|
||||
/* 静止:持续漂移会让上层多个 backdrop-filter 每帧重算模糊,是卡顿主因 */
|
||||
}
|
||||
|
||||
/* ── 两侧液态玻璃竖栏 ─────────────────────────────────────────
|
||||
backdrop-filter 把背后的极光磨成柔光;上下渐变的白色叠层 + 内侧两道
|
||||
高光边模拟玻璃厚度,做出「液态玻璃」的体积感。 */
|
||||
.glass-rail {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.12),
|
||||
rgba(255, 255, 255, 0.03) 55%,
|
||||
rgba(255, 255, 255, 0.08)
|
||||
);
|
||||
backdrop-filter: blur(18px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(150%);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(255, 255, 255, 0.06),
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.20),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.20);
|
||||
}
|
||||
|
||||
/* 中间内容纸张:压暗的暖色玻璃(模糊背后极光保证可读),
|
||||
两侧各一道高光描边,与左右玻璃竖栏拼出连续的玻璃接缝。 */
|
||||
.glass-sheet {
|
||||
@ -221,26 +203,17 @@ body {
|
||||
0 30px 80px rgba(20, 30, 60, 0.30);
|
||||
}
|
||||
|
||||
/* 两侧图片上的「液态玻璃」覆层:backdrop-filter 把图片磨成柔光,
|
||||
叠一道斜向高光 + 内侧四边亮边,做出通透的玻璃质感。
|
||||
盖在 <aside> 的背景图之上(inset-0 铺满)。 */
|
||||
.glass-image {
|
||||
backdrop-filter: blur(6px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(6px) saturate(150%);
|
||||
background: linear-gradient(
|
||||
125deg,
|
||||
rgba(255, 255, 255, 0.24) 0%,
|
||||
rgba(255, 255, 255, 0.06) 28%,
|
||||
rgba(255, 255, 255, 0) 52%,
|
||||
rgba(255, 255, 255, 0.05) 72%,
|
||||
rgba(255, 255, 255, 0.14) 100%
|
||||
);
|
||||
/* ── 起始页玻璃面板(极简浅色玻璃,叠在背景图上)──────────────
|
||||
与暗色 .glass-sheet 不同:这里要真模糊,让背后照片透出液态质感。
|
||||
半透明白 + 细高光描边 + 适度饱和,放在任何背景图上都成立。 */
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
box-shadow:
|
||||
inset 0 0 90px rgba(255, 255, 255, 0.10),
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.22),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.12);
|
||||
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 降级 ────────────────────────────────────── */
|
||||
|
||||
1194
Server/Cargo.lock
generated
@ -9,6 +9,9 @@ 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"
|
||||
|
||||
@ -8,6 +8,9 @@ mod auth;
|
||||
mod monitor;
|
||||
mod routes;
|
||||
mod state;
|
||||
mod weather;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
@ -27,5 +30,8 @@ async fn main() {
|
||||
.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,24 +1,29 @@
|
||||
//! 前端(React)专用接口。挂 CORS,仅放行前端开发源。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
http::{Method, StatusCode, header},
|
||||
extract::{ConnectInfo, 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::WeatherOutcome};
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
// CORS:只有浏览器会校验。放行前端开发源。
|
||||
// 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, Method::POST])
|
||||
.allow_headers([header::CONTENT_TYPE]);
|
||||
@ -26,6 +31,7 @@ pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/web/stats", get(stats))
|
||||
.route("/api/web/login", post(login))
|
||||
.route("/api/web/weather", get(weather))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
@ -67,3 +73,58 @@ async fn login(
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
|
||||
/// 天气 + 定位:高德按访问者 IP 定位取城市,和风取实时天气。
|
||||
/// 内部自带每日限流(高德 300 / 和风 900,北京时间 0 点归零)与短期缓存。
|
||||
async fn weather(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
) -> Json<Value> {
|
||||
let ip = client_ip(&headers, addr);
|
||||
let outcome = state.weather.get(&ip).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()
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ use tokio::sync::RwLock;
|
||||
|
||||
use crate::auth::UserStore;
|
||||
use crate::monitor::Stats;
|
||||
use crate::weather::WeatherService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@ -16,6 +17,8 @@ pub struct AppState {
|
||||
pub snapshot: Arc<RwLock<Stats>>,
|
||||
/// 用户表:启动时构建一次、之后只读,故无需锁,用 `Arc` 共享即可。
|
||||
pub users: Arc<UserStore>,
|
||||
/// 天气/定位服务(自带每日限流 + 缓存)。
|
||||
pub weather: Arc<WeatherService>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -23,6 +26,7 @@ impl AppState {
|
||||
Self {
|
||||
snapshot: Arc::new(RwLock::new(Stats::default())),
|
||||
users: Arc::new(UserStore::seed()),
|
||||
weather: Arc::new(WeatherService::from_env()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
373
Server/src/weather.rs
Normal file
@ -0,0 +1,373 @@
|
||||
//! 天气与定位服务:高德(定位 + 城市名)+ 和风(实时天气)。
|
||||
//!
|
||||
//! 设计要点:
|
||||
//! - **密钥只在后端**(环境变量读取),前端永远拿不到,也无法绕过限流。
|
||||
//! - **每日限流**按服务商各自计数:高德 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>,
|
||||
}
|
||||
|
||||
struct Located {
|
||||
city: String,
|
||||
/// "lon,lat"
|
||||
coord: String,
|
||||
}
|
||||
|
||||
/// 和风 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 持锁。
|
||||
pub async fn get(&self, ip: &str) -> WeatherOutcome {
|
||||
if self.amap_key.is_none() || self.qweather.is_none() {
|
||||
return WeatherOutcome::NotConfigured;
|
||||
}
|
||||
|
||||
// 1) 命中新鲜缓存直接返回;否则预检两家额度(任一用尽即报)。
|
||||
{
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
if let Some(e) = inner.cache.get(ip) {
|
||||
if e.at.elapsed() < self.cache_ttl {
|
||||
return WeatherOutcome::Cached(e.data.clone());
|
||||
}
|
||||
}
|
||||
if !inner.amap.has_budget() {
|
||||
return WeatherOutcome::QuotaExceeded { provider: "amap" };
|
||||
}
|
||||
if !inner.qweather.has_budget() {
|
||||
return WeatherOutcome::QuotaExceeded { provider: "qweather" };
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 高德定位(成功才计数)
|
||||
let loc = 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(ip.to_string(), 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}"))
|
||||
}
|
||||
133
docs/startpage-weather-location.md
Normal file
@ -0,0 +1,133 @@
|
||||
# 起始页:天气 + 定位(已实现)
|
||||
|
||||
导航起始页问候语下方展示「天气图标 · 城市 · 天气 · 气温」。
|
||||
**城市来自高德 IP 定位,天气来自和风实时天气。** 密钥只在后端,前端不可见。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
浏览器 → GET /api/web/weather (后端)
|
||||
├─ 高德 /v3/ip?ip=<访问者IP> → 城市名 + 城市中心坐标
|
||||
└─ 和风 /v7/weather/now?location= → 天气文字 / 气温 / 图标码
|
||||
```
|
||||
|
||||
- 后端:`Server/src/weather.rs`(服务 + 限流 + 缓存)、路由 `Server/src/routes/web.rs` 的 `/api/web/weather`。
|
||||
- 前端:
|
||||
- `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 点恢复」。
|
||||
- **按访问者 IP 缓存 10 分钟**:前端每 30 分钟刷新一次,正常用量远低于额度。
|
||||
- 计数只在“真的调用了上游”时增加(命中缓存不计数)。
|
||||
|
||||
## 环境变量
|
||||
|
||||
后端启动时读取(缺高德 Key 或和风 JWT 任一项时接口返回 `not_configured`,前端静默不展示):
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `AMAP_KEY` | 是 | 高德 **Web服务(REST)** Key |
|
||||
| `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` 调用。私钥**只放后端**,绝不进前端/仓库。
|
||||
|
||||
> 本地开发时访问者 IP 是 `127.0.0.1`,高德无法定位 → 建议设 `WEATHER_DEFAULT_LOCATION` +
|
||||
> `WEATHER_DEFAULT_CITY` 才能在本地看到天气;部署后用访问者真实公网 IP,自动定位。
|
||||
|
||||
## 本地运行
|
||||
|
||||
后端(在 `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
|
||||
```
|
||||
启动:
|
||||
```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) → 齿轮(z-30)。
|
||||
|
||||
## 本地预览开关(仅 `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
@ -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
@ -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`.
|
||||