Compare commits
12 Commits
main
...
feat/blog-
| Author | SHA1 | Date | |
|---|---|---|---|
| 0245194fa3 | |||
| a52e2b92a4 | |||
| 60aa5146b4 | |||
| c7cccb91c9 | |||
| edde910cfe | |||
| e435a79950 | |||
| 6620be379b | |||
| f1487f509e | |||
| f89345b6fa | |||
| d25c65ef81 | |||
| edb975d818 | |||
| 9d113b72f8 |
1476
Client/package-lock.json
generated
@ -14,7 +14,9 @@
|
||||
"@fontsource/hanken-grotesk": "^5.2.8",
|
||||
"framer-motion": "^12.40.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
@ -20,11 +20,16 @@ function App() {
|
||||
)
|
||||
// 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名
|
||||
const [wantsFolder, setWantsFolder] = useState<string | null>(null)
|
||||
// 文章路由:#post/<id> → 进 BlogPage 并首屏打开该文章
|
||||
const [postId, setPostId] = useState<string | null>(() => {
|
||||
const h = window.location.hash.replace(/^#/, '')
|
||||
return h.startsWith('post/') ? h.slice('post/'.length) : null
|
||||
})
|
||||
// 路由是否还在异步解析中。非 #photo 的 hash 要等 checkFolder/checkGate 返回才知道
|
||||
// 去向;解析完成前先不渲染任何目标页,避免已登录时一闪 BlogPage 兜底再跳回。
|
||||
const [resolving, setResolving] = useState(() => {
|
||||
const h = window.location.hash.replace(/^#/, '')
|
||||
return !!h && h !== PHOTO_HASH
|
||||
return !!h && h !== PHOTO_HASH && !h.startsWith('post/')
|
||||
})
|
||||
const signOut = auth.signOut
|
||||
|
||||
@ -36,14 +41,16 @@ function App() {
|
||||
const check = async () => {
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
const photo = hash === PHOTO_HASH
|
||||
const post = hash.startsWith('post/') ? hash.slice('post/'.length) : null
|
||||
if (alive) {
|
||||
setWantsPhoto(photo)
|
||||
setWantsFolder(null)
|
||||
setPostId(post)
|
||||
setShowLogin(false)
|
||||
// #photo 与无 hash 都能同步定向,不需要异步解析
|
||||
setResolving(!!hash && !photo)
|
||||
// #photo / #post/<id> / 无 hash 都能同步定向,不需要异步解析
|
||||
setResolving(!!hash && !photo && !post)
|
||||
}
|
||||
if (photo || !hash) return
|
||||
if (photo || post || !hash) return
|
||||
|
||||
// 先查是否是已注册的特殊文件夹
|
||||
const isFolder = await checkFolder(hash)
|
||||
@ -104,9 +111,16 @@ function App() {
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
view = <BlogPage username={auth.username} onSignOut={goStart} />
|
||||
view = (
|
||||
<BlogPage
|
||||
username={auth.username}
|
||||
token={auth.token ?? ''}
|
||||
initialPostId={postId}
|
||||
onSignOut={goStart}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else if (wantsPhoto || wantsFolder) {
|
||||
} else if (wantsPhoto || wantsFolder || postId) {
|
||||
// 照片墙 / 特殊文件夹未登录:复用登录页,去掉顶部服务器负载栏
|
||||
view = <LoginPage onSignIn={auth.signIn} onBack={goStart} hideStats />
|
||||
} else if (showLogin) {
|
||||
|
||||
97
Client/src/api/folderMedia.ts
Normal file
@ -0,0 +1,97 @@
|
||||
const API_BASE =
|
||||
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080'
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
export type MediaType = 'image' | 'video'
|
||||
export type MediaStatus = 'ready' | 'processing' | 'failed'
|
||||
|
||||
export interface FolderMediaItem {
|
||||
id: string
|
||||
type: MediaType
|
||||
status: MediaStatus
|
||||
}
|
||||
|
||||
export interface FolderMediaList {
|
||||
items: FolderMediaItem[]
|
||||
max?: number
|
||||
totalBytes: number
|
||||
overQuota: boolean
|
||||
}
|
||||
|
||||
export type MediaUploadError = 'full' | 'too-large' | 'bad-type' | 'forbidden' | 'failed'
|
||||
|
||||
export async function listFolderMedia(token: string, folder: string): Promise<FolderMediaList> {
|
||||
const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media`, {
|
||||
headers: authHeaders(token),
|
||||
})
|
||||
if (res.status === 403) throw new Error('forbidden')
|
||||
if (!res.ok) throw new Error(`list media failed: ${res.status}`)
|
||||
return (await res.json()) as FolderMediaList
|
||||
}
|
||||
|
||||
export async function uploadFolderMedia(
|
||||
token: string,
|
||||
folder: string,
|
||||
file: File,
|
||||
): Promise<FolderMediaItem> {
|
||||
const body = new FormData()
|
||||
body.append('file', file, file.name)
|
||||
const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
body,
|
||||
})
|
||||
if (res.ok) return (await res.json()) as FolderMediaItem
|
||||
if (res.status === 403) throw new Error('forbidden')
|
||||
const reason: MediaUploadError =
|
||||
res.status === 409
|
||||
? 'full'
|
||||
: res.status === 413
|
||||
? 'too-large'
|
||||
: res.status === 415
|
||||
? 'bad-type'
|
||||
: 'failed'
|
||||
throw new Error(reason)
|
||||
}
|
||||
|
||||
export async function fetchFolderMediaUrl(
|
||||
token: string,
|
||||
folder: string,
|
||||
id: string,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media/${id}`, {
|
||||
headers: authHeaders(token),
|
||||
})
|
||||
if (res.status === 403) throw new Error('forbidden')
|
||||
if (!res.ok) throw new Error(`get media failed: ${res.status}`)
|
||||
return URL.createObjectURL(await res.blob())
|
||||
}
|
||||
|
||||
export async function getFolderMediaStatus(
|
||||
token: string,
|
||||
folder: string,
|
||||
id: string,
|
||||
): Promise<FolderMediaItem> {
|
||||
const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media/${id}/status`, {
|
||||
headers: authHeaders(token),
|
||||
})
|
||||
if (res.status === 403) throw new Error('forbidden')
|
||||
if (!res.ok) throw new Error(`get media status failed: ${res.status}`)
|
||||
return (await res.json()) as FolderMediaItem
|
||||
}
|
||||
|
||||
export async function deleteFolderMedia(
|
||||
token: string,
|
||||
folder: string,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/api/web/folder/${folder}/media/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
})
|
||||
if (res.status === 403) throw new Error('forbidden')
|
||||
if (!res.ok && res.status !== 404) throw new Error(`delete media failed: ${res.status}`)
|
||||
}
|
||||
@ -105,7 +105,8 @@ export async function fetchFolderPhotoUrl(
|
||||
export interface WallState {
|
||||
bgIndex: number
|
||||
decorations: Record<string, unknown>[]
|
||||
photoLayout: Record<string, Record<string, unknown>>
|
||||
mediaLayout?: Record<string, Record<string, unknown>>
|
||||
photoLayout?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** 读取文件夹的共享照片墙状态;无存档返回 null */
|
||||
|
||||
92
Client/src/api/posts.ts
Normal file
@ -0,0 +1,92 @@
|
||||
// 博客文章接口封装。基址与 photos.ts 一致:生产留空走同源相对路径。
|
||||
const API_BASE =
|
||||
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080'
|
||||
|
||||
export interface Post {
|
||||
id: string
|
||||
title: string
|
||||
tag: string
|
||||
body: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type PostInput = { title: string; tag: string; body: string }
|
||||
|
||||
/** 保存失败的可区分原因,供 UI 给出对应提示 */
|
||||
export type PostError = 'full' | 'too-large' | 'bad-input' | 'failed'
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
function saveError(status: number): PostError {
|
||||
return status === 409
|
||||
? 'full'
|
||||
: status === 413
|
||||
? 'too-large'
|
||||
: status === 400
|
||||
? 'bad-input'
|
||||
: 'failed'
|
||||
}
|
||||
|
||||
/** 列出当前用户全部文章(已按最新在前排序)与数量上限 */
|
||||
export async function listPosts(token: string): Promise<{ items: Post[]; max: number }> {
|
||||
const res = await fetch(`${API_BASE}/api/web/posts`, { headers: authHeaders(token) })
|
||||
if (!res.ok) throw new Error(`list failed: ${res.status}`)
|
||||
return (await res.json()) as { items: Post[]; max: number }
|
||||
}
|
||||
|
||||
/** 取单篇文章 */
|
||||
export async function getPost(token: string, id: string): Promise<Post> {
|
||||
const res = await fetch(`${API_BASE}/api/web/posts/${id}`, { headers: authHeaders(token) })
|
||||
if (!res.ok) throw new Error(`get failed: ${res.status}`)
|
||||
return (await res.json()) as Post
|
||||
}
|
||||
|
||||
/** 新建文章,返回服务器分配的完整文章对象 */
|
||||
export async function createPost(token: string, input: PostInput): Promise<Post> {
|
||||
const res = await fetch(`${API_BASE}/api/web/posts`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (res.ok) return (await res.json()) as Post
|
||||
throw new Error(saveError(res.status))
|
||||
}
|
||||
|
||||
/** 更新文章 */
|
||||
export async function updatePost(token: string, id: string, input: PostInput): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/api/web/posts/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!res.ok) throw new Error(saveError(res.status))
|
||||
}
|
||||
|
||||
/** 删除文章(404 视为已删,不报错) */
|
||||
export async function deletePost(token: string, id: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/api/web/posts/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
})
|
||||
if (!res.ok && res.status !== 404) throw new Error(`delete failed: ${res.status}`)
|
||||
}
|
||||
|
||||
/** 估算阅读时长(按 ~200 字/分钟,最少 1 分钟) */
|
||||
export function readingTime(body: string): string {
|
||||
const mins = Math.max(1, Math.round(body.trim().length / 200))
|
||||
return `${mins} min`
|
||||
}
|
||||
|
||||
/** 取正文前若干字做摘要,去掉常见 Markdown 标记 */
|
||||
export function excerpt(body: string, max = 80): string {
|
||||
const plain = body
|
||||
.replace(/```[\s\S]*?```/g, ' ') // 代码块
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接/图片 → 文本
|
||||
.replace(/[#>*_`~]+/g, ' ') // 标记符号
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return plain.length > max ? `${plain.slice(0, max)}…` : plain
|
||||
}
|
||||
@ -27,24 +27,12 @@ export type WeatherResult =
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** 前端定位结果(来自 AMap.Geolocation);缺省则后端走 IP 定位兜底。 */
|
||||
export interface WeatherLocation {
|
||||
lon: number
|
||||
lat: number
|
||||
city?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取一次天气;网络层失败(后端不可达等)会抛错,由调用方兜底。
|
||||
* 传入 `loc` 时把经纬度/城市带给后端,后端据此跳过高德 IP 定位、直接取天气。
|
||||
* 后端按访问 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()}`
|
||||
}
|
||||
export async function fetchWeather(): Promise<WeatherResult> {
|
||||
const url = `${API_BASE}/api/web/weather`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as WeatherResult
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 144 KiB |
@ -1,61 +1,25 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useSystemStats } from '../hooks/useSystemStats'
|
||||
import { fadeUp, stagger } from './motion'
|
||||
import { AnnouncementBar } from './AnnouncementBar'
|
||||
import { RainbowSpotlight } from './RainbowSpotlight'
|
||||
import { PostArticle } from './PostArticle'
|
||||
import { PostEditor } from './PostEditor'
|
||||
import { listPosts, readingTime, excerpt, type Post } from '../api/posts'
|
||||
|
||||
interface BlogPageProps {
|
||||
/** 当前登录用户名 */
|
||||
username: string
|
||||
/** 鉴权 token(调 posts 接口用) */
|
||||
token: string
|
||||
/** 进入时若带 #post/<id>,首屏直接打开该文章 */
|
||||
initialPostId: string | null
|
||||
/** 退出登录 */
|
||||
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。
|
||||
// 两侧图片区:沿用原 import.meta.glob 索引 src/assets/sides/。
|
||||
const sideImages = Object.entries(
|
||||
import.meta.glob<string>('../assets/sides/*.{jpg,jpeg,png,webp}', {
|
||||
eager: true,
|
||||
@ -69,18 +33,149 @@ const sideImages = Object.entries(
|
||||
const SIDE_IMAGE_LEFT = sideImages[0]
|
||||
const SIDE_IMAGE_RIGHT = sideImages[1] ?? sideImages[0]
|
||||
|
||||
export function BlogPage({ username, onSignOut }: BlogPageProps) {
|
||||
// 顶部栏沿用系统状态彩蛋,保持与登录页同一风格。
|
||||
function formatDate(secs: number): string {
|
||||
return new Date(secs * 1000).toLocaleDateString('zh-CN', { year: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
export function BlogPage({ username, token, initialPostId, onSignOut }: BlogPageProps) {
|
||||
const stats = useSystemStats()
|
||||
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 编辑器浮层:editing=true 时无视路由优先显示编辑器;editingPost=null 为新建。
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editingPost, setEditingPost] = useState<Post | null>(null)
|
||||
// 文章重挂载计数:保存编辑后 bump,作为 PostArticle 的 key 强制重拉
|
||||
// (否则 id 不变时其 useEffect 不会重新 fetch,显示旧内容)。
|
||||
const [articleNonce, setArticleNonce] = useState(0)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true)
|
||||
listPosts(token)
|
||||
.then((r) => setPosts(r.items))
|
||||
.catch(() => setPosts([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
// 列表↔文章由地址栏驱动(App 是唯一真相源):点文章只改 hash。
|
||||
const openPost = (id: string) => {
|
||||
window.location.hash = `post/${id}`
|
||||
}
|
||||
const backToList = () => {
|
||||
setEditing(false)
|
||||
if (window.location.hash) window.location.hash = ''
|
||||
}
|
||||
const startNew = () => {
|
||||
setEditingPost(null)
|
||||
setEditing(true)
|
||||
}
|
||||
const startEdit = (post: Post) => {
|
||||
setEditingPost(post)
|
||||
setEditing(true)
|
||||
}
|
||||
const onEditorSaved = () => {
|
||||
// 编辑既有文章:hash 仍是 #post/<id>,关掉编辑器即回到该文章;bump
|
||||
// articleNonce 让 PostArticle 重挂载、重拉到最新内容。
|
||||
// 新建:hash 为空,关掉后回列表。两种都刷新列表数据。
|
||||
setEditing(false)
|
||||
setArticleNonce((n) => n + 1)
|
||||
refresh()
|
||||
}
|
||||
|
||||
const showHero = !editing && !initialPostId
|
||||
|
||||
let center
|
||||
if (editing) {
|
||||
center = (
|
||||
<PostEditor
|
||||
token={token}
|
||||
post={editingPost ?? undefined}
|
||||
onSaved={onEditorSaved}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
)
|
||||
} else if (initialPostId) {
|
||||
center = (
|
||||
<PostArticle
|
||||
key={`${initialPostId}-${articleNonce}`}
|
||||
token={token}
|
||||
id={initialPostId}
|
||||
onBack={backToList}
|
||||
onEdit={startEdit}
|
||||
onDeleted={() => {
|
||||
refresh()
|
||||
backToList()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
center = (
|
||||
<section className="w-full px-6 py-14">
|
||||
<div className="mb-10 flex items-center justify-between">
|
||||
<h2 className="text-xs font-medium uppercase tracking-[0.3em] text-muted">
|
||||
Latest writing
|
||||
</h2>
|
||||
<button
|
||||
onClick={startNew}
|
||||
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"
|
||||
>
|
||||
写文章
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted">加载中…</p>
|
||||
) : posts.length === 0 ? (
|
||||
<p className="text-sm text-muted">还没有文章,点右上角「写文章」开始第一篇吧。</p>
|
||||
) : (
|
||||
<motion.ul
|
||||
variants={stagger(0.1, 0.08)}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex flex-col"
|
||||
>
|
||||
{posts.map((post) => (
|
||||
<motion.li key={post.id} variants={fadeUp}>
|
||||
<button
|
||||
onClick={() => openPost(post.id)}
|
||||
className="group flex w-full flex-col gap-3 border-b border-line/50 py-7 text-left transition-colors sm:flex-row sm:items-baseline sm:gap-8"
|
||||
>
|
||||
<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>{formatDate(post.createdAt)}</div>
|
||||
{post.tag && <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">{excerpt(post.body)}</p>
|
||||
<span className="mt-2 inline-block text-xs text-muted/70">
|
||||
{readingTime(post.body)} read
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
@ -101,120 +196,65 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
|
||||
<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"
|
||||
{showHero && (
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="text-xs font-medium uppercase tracking-[0.3em] text-accent"
|
||||
>
|
||||
Josh W. Comeau
|
||||
</a>
|
||||
。
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</section>
|
||||
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">{username}</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>
|
||||
<main className="flex-1">{center}</main>
|
||||
|
||||
<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">
|
||||
<div className="flex w-full flex-col items-center gap-3 px-6 py-8 text-center text-xs text-muted">
|
||||
<span>© {new Date().getFullYear()} cc</span>
|
||||
<span className="font-mono tracking-wider">built with React · Rust · 🌈</span>
|
||||
<span>苏ICP备2026046305号-1</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%' }}
|
||||
|
||||
67
Client/src/components/Markdown.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
// 把 Markdown 渲染套上站点暖色编辑风。只渲染自有可信内容;
|
||||
// react-markdown 默认不解析原始 HTML(未引 rehype-raw),天然防注入。
|
||||
const components: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mt-8 mb-4 font-display text-3xl font-medium text-text">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mt-7 mb-3 font-display text-2xl font-medium text-text">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mt-6 mb-2 font-display text-xl font-medium text-text">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => <p className="my-4 leading-relaxed text-muted">{children}</p>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent underline-offset-4 hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-4 list-disc pl-6 text-muted marker:text-accent/60">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="my-4 list-decimal pl-6 text-muted marker:text-accent/60">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="my-1 leading-relaxed">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-4 border-l-2 border-accent pl-4 italic text-muted/90">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => <pre className="my-4">{children}</pre>,
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = /language-/.test(className ?? '')
|
||||
return isBlock ? (
|
||||
<code className="block overflow-x-auto rounded-lg border border-line bg-surface px-4 py-3 font-mono text-sm text-text">
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className="rounded bg-surface px-1.5 py-0.5 font-mono text-[0.85em] text-accent-soft">
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
img: ({ src, alt }) => (
|
||||
<img src={typeof src === 'string' ? src : ''} alt={alt ?? ''} className="my-4 max-w-full rounded-lg" />
|
||||
),
|
||||
hr: () => <hr className="my-8 border-line" />,
|
||||
}
|
||||
|
||||
export function Markdown({ children }: { children: string }) {
|
||||
// data-selectable:全站默认禁选,正文区放开以便复制。
|
||||
return (
|
||||
<div data-selectable>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -10,13 +10,19 @@ import {
|
||||
} from 'react'
|
||||
import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos'
|
||||
import {
|
||||
listFolderPhotos,
|
||||
uploadFolderPhoto,
|
||||
deleteFolderPhoto,
|
||||
fetchFolderPhotoUrl,
|
||||
fetchFolderState,
|
||||
saveFolderState,
|
||||
} from '../api/folders'
|
||||
import {
|
||||
listFolderMedia,
|
||||
uploadFolderMedia,
|
||||
deleteFolderMedia,
|
||||
fetchFolderMediaUrl,
|
||||
getFolderMediaStatus,
|
||||
type FolderMediaItem,
|
||||
type MediaStatus,
|
||||
type MediaUploadError,
|
||||
} from '../api/folderMedia'
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
照片墙 Photo Wall —— 互动式墙面照片展示。
|
||||
@ -39,7 +45,7 @@ interface PhotoWallPageProps {
|
||||
onForbidden?: () => void
|
||||
}
|
||||
|
||||
type ElType = 'photo' | 'note' | 'tape' | 'sticker' | 'stamp' | 'doodle'
|
||||
type ElType = 'photo' | 'video' | 'note' | 'tape' | 'sticker' | 'stamp' | 'doodle'
|
||||
|
||||
interface El {
|
||||
id: number
|
||||
@ -60,8 +66,10 @@ interface El {
|
||||
// note
|
||||
text?: string
|
||||
color?: string
|
||||
/** 服务器图片 id(仅 type==='photo'),也是 localStorage 排版的 key */
|
||||
photoId?: string
|
||||
/** 服务器媒体 id(type 为 photo/video 时存在),也是布局保存的 key */
|
||||
mediaId?: string
|
||||
/** 视频处理状态;图片恒为 ready */
|
||||
mediaStatus?: MediaStatus
|
||||
// sticker
|
||||
bg?: string
|
||||
// doodle
|
||||
@ -109,7 +117,7 @@ function transformOf(el: El, extraScale = 1) {
|
||||
}
|
||||
|
||||
/** 每张照片的本地排版覆盖(按服务器图片 id 存浏览器) */
|
||||
interface PhotoLayout {
|
||||
interface MediaLayout {
|
||||
x: number
|
||||
y: number
|
||||
rot: number
|
||||
@ -122,12 +130,13 @@ interface PhotoLayout {
|
||||
interface SavedState {
|
||||
bgIndex: number
|
||||
decorations: El[]
|
||||
photoLayout: Record<string, PhotoLayout>
|
||||
mediaLayout: Record<string, MediaLayout>
|
||||
photoLayout?: Record<string, MediaLayout>
|
||||
}
|
||||
|
||||
function loadInitial(storageKey: string) {
|
||||
let decorations: El[] = []
|
||||
let photoLayout: Record<string, PhotoLayout> = {}
|
||||
let mediaLayout: Record<string, MediaLayout> = {}
|
||||
let bgIndex = 0
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
@ -137,16 +146,18 @@ function loadInitial(storageKey: string) {
|
||||
if (Array.isArray(data.decorations)) {
|
||||
decorations = data.decorations
|
||||
} else if (Array.isArray(data.els)) {
|
||||
decorations = data.els.filter((e) => e.type !== 'photo')
|
||||
decorations = data.els.filter((e) => e.type !== 'photo' && e.type !== 'video')
|
||||
}
|
||||
if (data.photoLayout && typeof data.photoLayout === 'object') {
|
||||
photoLayout = data.photoLayout
|
||||
if (data.mediaLayout && typeof data.mediaLayout === 'object') {
|
||||
mediaLayout = data.mediaLayout
|
||||
} else if (data.photoLayout && typeof data.photoLayout === 'object') {
|
||||
mediaLayout = data.photoLayout
|
||||
}
|
||||
}
|
||||
} catch { /* 损坏的存档当作空墙 */ }
|
||||
const nextId = decorations.reduce((m, e) => Math.max(m, e.id), 0) + 1
|
||||
const maxZ = Math.max(10, ...decorations.map((e) => e.z || 10))
|
||||
return { decorations, photoLayout, bgIndex, nextId, maxZ }
|
||||
return { decorations, mediaLayout, bgIndex, nextId, maxZ }
|
||||
}
|
||||
|
||||
/** 人类可读的字节大小 */
|
||||
@ -165,8 +176,9 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
const initial = useMemo(() => loadInitial(storageKey), [])
|
||||
|
||||
const [els, setEls] = useState<El[]>(initial.decorations)
|
||||
const layoutRef = useRef<Record<string, PhotoLayout>>(initial.photoLayout)
|
||||
const layoutRef = useRef<Record<string, MediaLayout>>(initial.mediaLayout)
|
||||
const objUrls = useRef<string[]>([])
|
||||
const pollTimers = useRef(new Map<string, ReturnType<typeof setInterval>>())
|
||||
const [bgIndex, setBgIndex] = useState(initial.bgIndex)
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
@ -250,24 +262,30 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
try {
|
||||
const decorations = els.filter((e) => e.type !== 'photo')
|
||||
const photoLayout: Record<string, PhotoLayout> = { ...layoutRef.current }
|
||||
const decorations = els.filter((e) => e.type !== 'photo' && e.type !== 'video')
|
||||
const mediaLayout: Record<string, MediaLayout> = { ...layoutRef.current }
|
||||
for (const e of els) {
|
||||
if (e.type === 'photo' && e.photoId) {
|
||||
photoLayout[e.photoId] = {
|
||||
if ((e.type === 'photo' || e.type === 'video') && e.mediaId) {
|
||||
mediaLayout[e.mediaId] = {
|
||||
x: e.x, y: e.y, rot: e.rot, scale: e.scale, z: e.z,
|
||||
w: e.w ?? 160, caption: e.caption ?? '',
|
||||
}
|
||||
}
|
||||
}
|
||||
layoutRef.current = photoLayout
|
||||
localStorage.setItem(storageKey, JSON.stringify({ bgIndex, decorations, photoLayout }))
|
||||
layoutRef.current = mediaLayout
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
bgIndex,
|
||||
decorations,
|
||||
mediaLayout,
|
||||
photoLayout: mediaLayout,
|
||||
}))
|
||||
// 特殊文件夹:同步保存到服务器
|
||||
if (!isPersonal && folderName) {
|
||||
saveFolderState(token, folderName, {
|
||||
bgIndex,
|
||||
decorations: decorations as unknown as Record<string, unknown>[],
|
||||
photoLayout: photoLayout as unknown as Record<string, Record<string, unknown>>,
|
||||
mediaLayout: mediaLayout as unknown as Record<string, Record<string, unknown>>,
|
||||
photoLayout: mediaLayout as unknown as Record<string, Record<string, unknown>>,
|
||||
}).then(() => {
|
||||
setSaveLabel('已保存')
|
||||
}).catch((e) => {
|
||||
@ -285,7 +303,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearTimeout(t)
|
||||
}, [els, bgIndex, showToast, storageKey])
|
||||
}, [els, bgIndex, showToast, storageKey, isPersonal, folderName, token])
|
||||
|
||||
/* ── 懒加载 html2canvas ──────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
@ -320,6 +338,51 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
[mutate, isPersonal, maxItems, showToast],
|
||||
)
|
||||
|
||||
const mediaDimensions = (url: string, type: FolderMediaItem['type']): Promise<{ w: number; h: number }> =>
|
||||
new Promise((resolve) => {
|
||||
if (type === 'video') {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.onloadedmetadata = () => resolve({ w: video.videoWidth || 16, h: video.videoHeight || 9 })
|
||||
video.onerror = () => resolve({ w: 16, h: 9 })
|
||||
video.src = url
|
||||
return
|
||||
}
|
||||
const img = new Image()
|
||||
img.onload = () => resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 })
|
||||
img.onerror = () => resolve({ w: 1, h: 1 })
|
||||
img.src = url
|
||||
})
|
||||
|
||||
const startMediaPolling = useCallback((mediaId: string) => {
|
||||
if (!folderName || pollTimers.current.has(mediaId)) return
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const item = await getFolderMediaStatus(token, folderName, mediaId)
|
||||
if (item.status === 'processing') return
|
||||
clearInterval(timer)
|
||||
pollTimers.current.delete(mediaId)
|
||||
if (item.status === 'failed') {
|
||||
setEls((prev) => prev.map((e) => e.mediaId === mediaId ? { ...e, mediaStatus: 'failed' } : e))
|
||||
return
|
||||
}
|
||||
const url = await fetchFolderMediaUrl(token, folderName, mediaId)
|
||||
objUrls.current.push(url)
|
||||
const dims = await mediaDimensions(url, item.type)
|
||||
setEls((prev) => prev.map((e) => {
|
||||
if (e.mediaId !== mediaId) return e
|
||||
const w = e.w ?? 210
|
||||
return { ...e, src: url, mediaStatus: 'ready', h: Math.round(w * (dims.h / dims.w)) }
|
||||
}))
|
||||
} catch {
|
||||
clearInterval(timer)
|
||||
pollTimers.current.delete(mediaId)
|
||||
setEls((prev) => prev.map((e) => e.mediaId === mediaId ? { ...e, mediaStatus: 'failed' } : e))
|
||||
}
|
||||
}, 3000)
|
||||
pollTimers.current.set(mediaId, timer)
|
||||
}, [folderName, token])
|
||||
|
||||
/* ── 图片加载(个人墙 / 文件夹统一处理)────────────────────── */
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
@ -328,13 +391,20 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
try {
|
||||
for (const u of objUrls.current) URL.revokeObjectURL(u)
|
||||
objUrls.current = []
|
||||
setEls((prev) => prev.filter((e) => e.type !== 'photo'))
|
||||
for (const timer of pollTimers.current.values()) clearInterval(timer)
|
||||
pollTimers.current.clear()
|
||||
setEls((prev) => prev.filter((e) => e.type !== 'photo' && e.type !== 'video'))
|
||||
|
||||
const listFn = isPersonal
|
||||
? () => listPhotos(token).then(r => ({ items: r.items, max: r.max, totalBytes: 0, overQuota: false }))
|
||||
: () => listFolderPhotos(token, folderName!)
|
||||
const listResult = isPersonal
|
||||
? await listPhotos(token).then(r => ({
|
||||
items: r.items.map((item) => ({ id: item.id, type: 'image' as const, status: 'ready' as const })),
|
||||
max: r.max,
|
||||
totalBytes: 0,
|
||||
overQuota: false,
|
||||
}))
|
||||
: await listFolderMedia(token, folderName!)
|
||||
|
||||
const { items, max, totalBytes, overQuota: over } = await listFn()
|
||||
const { items, max, totalBytes, overQuota: over } = listResult
|
||||
if (!alive) return
|
||||
if (!isPersonal) {
|
||||
setMaxItems(max ?? 100)
|
||||
@ -346,11 +416,29 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
setLoading(false)
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === 'video' && item.status !== 'ready') {
|
||||
const layout = layoutRef.current[item.id]
|
||||
const pos = spawnPos(40)
|
||||
const w = layout?.w ?? 210
|
||||
const base: El = {
|
||||
id: idRef.current++, type: 'video', mediaId: item.id, mediaStatus: item.status,
|
||||
src: '', w, h: Math.round(w * 0.62),
|
||||
x: layout?.x ?? pos.x, y: layout?.y ?? pos.y,
|
||||
rot: layout?.rot ?? rand(-10, 10), scale: layout?.scale ?? 1,
|
||||
z: layout?.z ?? ++zRef.current, caption: layout?.caption ?? '',
|
||||
fix: 'tape', fixRot: rand(-3, 3),
|
||||
}
|
||||
if (layout) zRef.current = Math.max(zRef.current, layout.z)
|
||||
setEls((prev) => [...prev, base])
|
||||
if (item.status === 'processing' && folderName) startMediaPolling(item.id)
|
||||
continue
|
||||
}
|
||||
|
||||
let url: string
|
||||
try {
|
||||
url = isPersonal
|
||||
? await fetchPhotoObjectUrl(token, item.id)
|
||||
: await fetchFolderPhotoUrl(token, folderName!, item.id)
|
||||
: await fetchFolderMediaUrl(token, folderName!, item.id)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
@ -358,12 +446,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
objUrls.current.push(url)
|
||||
|
||||
const layout = layoutRef.current[item.id]
|
||||
const dims = await new Promise<{ w: number; h: number }>((resolve) => {
|
||||
const img = new Image()
|
||||
img.onload = () => resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 })
|
||||
img.onerror = () => resolve({ w: 1, h: 1 })
|
||||
img.src = url
|
||||
})
|
||||
const dims = await mediaDimensions(url, item.type)
|
||||
if (!alive) return
|
||||
|
||||
const ratio = dims.h / dims.w
|
||||
@ -372,15 +455,18 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
const pos = spawnPos(40)
|
||||
const base: El = layout
|
||||
? {
|
||||
id: idRef.current++, type: 'photo', src: url, photoId: item.id,
|
||||
id: idRef.current++, type: item.type === 'video' ? 'video' : 'photo',
|
||||
src: url, mediaId: item.id, mediaStatus: item.status,
|
||||
w: layout.w, h: Math.round(layout.w * ratio),
|
||||
x: layout.x, y: layout.y, rot: layout.rot, scale: layout.scale,
|
||||
z: layout.z, caption: layout.caption,
|
||||
fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
|
||||
}
|
||||
: {
|
||||
id: idRef.current++, type: 'photo', src: url, photoId: item.id,
|
||||
w: 170, h: Math.round(170 * ratio),
|
||||
id: idRef.current++, type: item.type === 'video' ? 'video' : 'photo',
|
||||
src: url, mediaId: item.id, mediaStatus: item.status,
|
||||
w: item.type === 'video' ? 210 : 170,
|
||||
h: Math.round((item.type === 'video' ? 210 : 170) * ratio),
|
||||
x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, z: ++zRef.current,
|
||||
caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
|
||||
}
|
||||
@ -428,11 +514,12 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
setEls((prev) => [...prev, ...decos])
|
||||
}
|
||||
// 恢复排版(服务器端覆盖本地默认排版)
|
||||
if (serverState.photoLayout && typeof serverState.photoLayout === 'object') {
|
||||
for (const [photoId, pl] of Object.entries(serverState.photoLayout)) {
|
||||
const restoredLayout = serverState.mediaLayout ?? serverState.photoLayout
|
||||
if (restoredLayout && typeof restoredLayout === 'object') {
|
||||
for (const [mediaId, pl] of Object.entries(restoredLayout)) {
|
||||
if (pl && typeof pl === 'object') {
|
||||
const p = pl as Record<string, unknown>
|
||||
layoutRef.current[photoId] = {
|
||||
layoutRef.current[mediaId] = {
|
||||
x: Number(p.x) || 0,
|
||||
y: Number(p.y) || 0,
|
||||
rot: Number(p.rot) || 0,
|
||||
@ -468,6 +555,8 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const u of objUrls.current) URL.revokeObjectURL(u)
|
||||
for (const timer of pollTimers.current.values()) clearInterval(timer)
|
||||
pollTimers.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
@ -523,39 +612,76 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
})
|
||||
}
|
||||
|
||||
function dataUrlToFile(dataUrl: string, name: string) {
|
||||
const [meta, b64] = dataUrl.split(',')
|
||||
const mime = meta.match(/^data:([^;]+)/)?.[1] ?? 'application/octet-stream'
|
||||
const bin = atob(b64)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
return new File([bytes], name, { type: mime })
|
||||
}
|
||||
|
||||
/* ── 图片上传 ─────────────────────────────────────────────── */
|
||||
const handleFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
const list = Array.from(files)
|
||||
const imgs = list.filter((f) => f.type.startsWith('image/'))
|
||||
if (!imgs.length) {
|
||||
const mediaFiles = list.filter((f) => f.type.startsWith('image/') || (!isPersonal && f.type.startsWith('video/')))
|
||||
if (!mediaFiles.length) {
|
||||
if (list.some((f) => /heic/i.test(f.type) || /\.heic$/i.test(f.name)))
|
||||
showToast('HEIC 格式需先转换为 JPG/PNG')
|
||||
return
|
||||
}
|
||||
imgs.forEach((file) => {
|
||||
if (file.size > 10 * 1024 * 1024) showToast('图片较大,建议压缩后上传')
|
||||
mediaFiles.forEach((file) => {
|
||||
if (file.type.startsWith('image/') && file.size > 10 * 1024 * 1024) showToast('图片较大,建议压缩后上传')
|
||||
// 个人墙有张数上限
|
||||
if (maxPhotos > 0) {
|
||||
const count = elsRef.current.filter((e) => e.type === 'photo').length
|
||||
if (count >= maxPhotos) {
|
||||
showToast(`每位用户最多 ${maxPhotos} 张照片`)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 特殊文件夹:总数上限(图片 + 装饰)
|
||||
if (!isPersonal && maxItems > 0 && elsRef.current.length >= maxItems) {
|
||||
showToast(`已达上限(${maxItems} 条)`)
|
||||
return
|
||||
}
|
||||
if (!isPersonal && file.type.startsWith('video/')) {
|
||||
uploadFolderMedia(token, folderName!, file)
|
||||
.then((item) => {
|
||||
const pos = spawnPos(40)
|
||||
const w = 210
|
||||
addEl({
|
||||
type: 'video', src: '', mediaId: item.id, mediaStatus: item.status,
|
||||
w, h: Math.round(w * 0.62),
|
||||
x: pos.x, y: pos.y, rot: rand(-10, 10), scale: 1,
|
||||
caption: '', fix: 'tape', fixRot: rand(-3, 3),
|
||||
})
|
||||
if (item.status === 'processing') startMediaPolling(item.id)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (err.message === 'forbidden') {
|
||||
onForbidden?.()
|
||||
return
|
||||
}
|
||||
const reason = err.message as MediaUploadError
|
||||
showToast(
|
||||
reason === 'full' ? `已达上限(${maxItems} 条)`
|
||||
: reason === 'too-large' ? '视频过大(上限 500MB)'
|
||||
: reason === 'bad-type' ? '不支持的视频格式'
|
||||
: '上传失败',
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (ev) => {
|
||||
const rawSrc = ev.target?.result as string
|
||||
// 个人墙有张数上限
|
||||
if (maxPhotos > 0) {
|
||||
const count = elsRef.current.filter((e) => e.type === 'photo').length
|
||||
if (count >= maxPhotos) {
|
||||
showToast(`每位用户最多 ${maxPhotos} 张照片`)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 特殊文件夹:总数上限(图片 + 装饰)
|
||||
if (!isPersonal && maxItems > 0 && elsRef.current.length >= maxItems) {
|
||||
showToast(`已达上限(${maxItems} 条)`)
|
||||
return
|
||||
}
|
||||
// 客户端压缩:仅当原文件 > 1MB 时才压(大图缩小 + 转 JPEG q=0.78),小图原样上传
|
||||
const src = file.size > 1024 * 1024 ? await compressImage(rawSrc) : rawSrc
|
||||
const uploadFn = isPersonal
|
||||
? (t: string, d: string) => uploadPhoto(t, d)
|
||||
: (t: string, d: string) => uploadFolderPhoto(t, folderName!, d)
|
||||
: (t: string, d: string) => uploadFolderMedia(t, folderName!, dataUrlToFile(d, file.name || 'image.jpg')).then((item) => item.id)
|
||||
|
||||
uploadFn(token, src)
|
||||
.then((id) => {
|
||||
@ -567,7 +693,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
const roll = Math.random()
|
||||
const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
|
||||
addEl({
|
||||
type: 'photo', src, photoId: id, w, h,
|
||||
type: 'photo', src, mediaId: id, mediaStatus: 'ready', w, h,
|
||||
x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1,
|
||||
caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
|
||||
})
|
||||
@ -581,7 +707,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
}
|
||||
const reason = err.message as UploadError
|
||||
showToast(
|
||||
reason === 'full' ? `每位用户最多 ${maxPhotos} 张照片`
|
||||
reason === 'full' ? (isPersonal ? `每位用户最多 ${maxPhotos} 张照片` : `已达上限(${maxItems} 条)`)
|
||||
: reason === 'too-large' ? '图片过大(上限 8MB)'
|
||||
: reason === 'bad-type' ? '不支持的图片格式'
|
||||
: '上传失败',
|
||||
@ -591,7 +717,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
},
|
||||
[addEl, spawnPos, showToast, token, isPersonal, folderName, maxPhotos, onForbidden],
|
||||
[addEl, spawnPos, showToast, token, isPersonal, folderName, maxPhotos, maxItems, onForbidden, startMediaPolling],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -706,13 +832,13 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
mutate((p) => p.map((e) => (e.id === id ? { ...e, scale: s } : e)))
|
||||
const removeEl = useCallback((id: number) => {
|
||||
const target = elsRef.current.find((e) => e.id === id)
|
||||
if (target?.type === 'photo' && target.photoId) {
|
||||
if ((target?.type === 'photo' || target?.type === 'video') && target.mediaId) {
|
||||
if (isPersonal) {
|
||||
deletePhoto(token, target.photoId).catch(() => {})
|
||||
deletePhoto(token, target.mediaId).catch(() => {})
|
||||
} else {
|
||||
deleteFolderPhoto(token, folderName!, target.photoId).catch(() => {})
|
||||
deleteFolderMedia(token, folderName!, target.mediaId).catch(() => {})
|
||||
}
|
||||
delete layoutRef.current[target.photoId]
|
||||
delete layoutRef.current[target.mediaId]
|
||||
if (target.src?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(target.src)
|
||||
objUrls.current = objUrls.current.filter((u) => u !== target.src)
|
||||
@ -759,14 +885,14 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
|
||||
/* ── 双击编辑 ────────────────────────────────────────────── */
|
||||
const startEdit = (el: El) => {
|
||||
setEditText(el.type === 'photo' ? el.caption ?? '' : el.text ?? '')
|
||||
setEditText((el.type === 'photo' || el.type === 'video') ? el.caption ?? '' : el.text ?? '')
|
||||
setEditingId(el.id)
|
||||
}
|
||||
const commitEdit = (id: number) => {
|
||||
const val = editText
|
||||
mutate((p) =>
|
||||
p.map((e) =>
|
||||
e.id === id ? (e.type === 'photo' ? { ...e, caption: val.trim() } : { ...e, text: val }) : e,
|
||||
e.id === id ? ((e.type === 'photo' || e.type === 'video') ? { ...e, caption: val.trim() } : { ...e, text: val }) : e,
|
||||
),
|
||||
)
|
||||
setEditingId(null)
|
||||
@ -916,6 +1042,46 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
)}
|
||||
</>
|
||||
)
|
||||
case 'video':
|
||||
return (
|
||||
<>
|
||||
<div className="pw-video-wrap" style={{ height: el.h }}>
|
||||
{el.mediaStatus === 'processing' && (
|
||||
<div className="pw-video-state">
|
||||
<div className="pw-video-spinner" />
|
||||
<strong>thinking</strong>
|
||||
<span>完成后会自动变成播放器</span>
|
||||
</div>
|
||||
)}
|
||||
{el.mediaStatus === 'failed' && (
|
||||
<div className="pw-video-state pw-video-failed">
|
||||
<strong>视频处理失败</strong>
|
||||
<span>右键删除后可重新上传</span>
|
||||
</div>
|
||||
)}
|
||||
{el.mediaStatus === 'ready' && el.src && (
|
||||
<video src={el.src} controls controlsList="nodownload" preload="metadata" />
|
||||
)}
|
||||
</div>
|
||||
{editingId === el.id ? (
|
||||
<input
|
||||
className="pw-caption-input"
|
||||
autoFocus
|
||||
value={editText}
|
||||
placeholder="写点什么..."
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onBlur={() => commitEdit(el.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commitEdit(el.id) }
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="pw-caption">{el.caption}</div>
|
||||
)}
|
||||
<div className="pw-fix-tape" style={{ transform: `translateX(-50%) rotate(${el.fixRot ?? 0}deg)` }} />
|
||||
</>
|
||||
)
|
||||
case 'note':
|
||||
return (
|
||||
<>
|
||||
@ -968,7 +1134,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
function elClass(el: El) {
|
||||
const base = 'pw-el'
|
||||
const byType: Record<ElType, string> = {
|
||||
photo: 'pw-photo', note: 'pw-note', tape: 'pw-tape',
|
||||
photo: 'pw-photo', video: 'pw-video', note: 'pw-note', tape: 'pw-tape',
|
||||
sticker: 'pw-sticker', stamp: 'pw-stamp', doodle: 'pw-doodle',
|
||||
}
|
||||
return `${base} ${byType[el.type]}`
|
||||
@ -976,7 +1142,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
|
||||
function elStyle(el: El): CSSProperties {
|
||||
const s: CSSProperties = { transform: transformOf(el), zIndex: el.z }
|
||||
if (el.type === 'photo') s.width = el.w
|
||||
if (el.type === 'photo' || el.type === 'video') s.width = el.w
|
||||
if (el.type === 'tape') {
|
||||
s.width = el.w
|
||||
s.background = `repeating-linear-gradient(90deg, rgba(255,255,255,0.2) 0 2px, transparent 2px 4px), ${el.color}`
|
||||
@ -988,7 +1154,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
}
|
||||
|
||||
const menuEl = menu ? els.find((e) => e.id === menu.id) : null
|
||||
const isMenuPhoto = menuEl?.type === 'photo'
|
||||
const isMenuPhoto = menuEl?.type === 'photo' || menuEl?.type === 'video'
|
||||
|
||||
return (
|
||||
<div className="pw-root">
|
||||
@ -1004,7 +1170,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
{els.length}/{maxItems}
|
||||
</span>
|
||||
)}
|
||||
<button className="pw-tbtn" onClick={() => fileRef.current?.click()}>上传照片</button>
|
||||
<button className="pw-tbtn" onClick={() => fileRef.current?.click()}>{isPersonal ? '上传照片' : '上传媒体'}</button>
|
||||
<button className="pw-tbtn" onClick={makeNote}>添加便签</button>
|
||||
|
||||
<div className="pw-deco" style={{ position: 'relative' }}>
|
||||
@ -1093,7 +1259,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
style={elStyle(el)}
|
||||
onPointerDown={(e) => onElPointerDown(e, el)}
|
||||
onDoubleClick={(e) => {
|
||||
if (el.type === 'photo' || el.type === 'note') { e.stopPropagation(); startEdit(el) }
|
||||
if (el.type === 'photo' || el.type === 'video' || el.type === 'note') { e.stopPropagation(); startEdit(el) }
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
@ -1104,7 +1270,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
{renderInner(el)}
|
||||
</div>
|
||||
))}
|
||||
{dragHint && <div className="pw-drophint">拖入图片即可添加到照片墙</div>}
|
||||
{dragHint && <div className="pw-drophint">{isPersonal ? '拖入图片即可添加到照片墙' : '拖入图片或视频即可添加到照片墙'}</div>}
|
||||
</div>
|
||||
|
||||
{/* ── 右键菜单 ── */}
|
||||
@ -1198,7 +1364,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
|
||||
{toast && <div className="pw-toast">{toast}</div>}
|
||||
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||||
<input ref={fileRef} type="file" accept={isPersonal ? 'image/*' : 'image/*,video/*'} multiple style={{ display: 'none' }}
|
||||
onChange={(e) => { if (e.target.files) handleFiles(e.target.files); e.target.value = '' }} />
|
||||
</div>
|
||||
)
|
||||
|
||||
123
Client/src/components/PostArticle.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { deletePost, getPost, readingTime, type Post } from '../api/posts'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
interface PostArticleProps {
|
||||
token: string
|
||||
id: string
|
||||
onBack: () => void
|
||||
onEdit: (post: Post) => void
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
function formatDate(secs: number): string {
|
||||
return new Date(secs * 1000).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function PostArticle({ token, id, onBack, onEdit, onDeleted }: PostArticleProps) {
|
||||
const [post, setPost] = useState<Post | null>(null)
|
||||
const [status, setStatus] = useState<'loading' | 'ok' | 'notfound'>('loading')
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setStatus('loading')
|
||||
getPost(token, id)
|
||||
.then((p) => {
|
||||
if (alive) {
|
||||
setPost(p)
|
||||
setStatus('ok')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setStatus('notfound')
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [token, id])
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!post) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deletePost(token, post.id)
|
||||
onDeleted()
|
||||
} catch {
|
||||
setDeleting(false)
|
||||
setConfirmDel(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return <section className="w-full px-6 py-14 text-sm text-muted">加载中…</section>
|
||||
}
|
||||
|
||||
if (status === 'notfound' || !post) {
|
||||
return (
|
||||
<section className="w-full px-6 py-14">
|
||||
<p className="text-sm text-muted">这篇文章不存在或已被删除。</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mt-4 rounded-full border border-line px-4 py-1.5 text-xs uppercase tracking-[0.16em] text-muted transition-colors hover:border-accent hover:text-accent"
|
||||
>
|
||||
← 返回列表
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="w-full px-6 py-14">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-xs uppercase tracking-[0.16em] text-muted transition-colors hover:text-accent"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onEdit(post)}
|
||||
className="text-xs uppercase tracking-[0.16em] text-muted transition-colors hover:text-accent"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
{confirmDel ? (
|
||||
<button
|
||||
onClick={doDelete}
|
||||
disabled={deleting}
|
||||
className="text-xs uppercase tracking-[0.16em] text-[#ff8585] disabled:opacity-50"
|
||||
>
|
||||
{deleting ? '删除中…' : '确认删除'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDel(true)}
|
||||
className="text-xs uppercase tracking-[0.16em] text-muted transition-colors hover:text-[#ff8585]"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 font-mono text-xs uppercase tracking-wider text-accent/70">
|
||||
{post.tag}
|
||||
</div>
|
||||
<h1 className="rainbow-clip font-display text-4xl font-medium text-text">{post.title}</h1>
|
||||
<div className="mt-3 font-mono text-xs uppercase tracking-wider text-muted">
|
||||
{formatDate(post.createdAt)} · {readingTime(post.body)} read
|
||||
</div>
|
||||
|
||||
<div className="rainbow-gradient mt-6 mb-8 h-px w-full opacity-60" aria-hidden />
|
||||
|
||||
<Markdown>{post.body}</Markdown>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
107
Client/src/components/PostEditor.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { createPost, updatePost, type Post, type PostError } from '../api/posts'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
interface PostEditorProps {
|
||||
token: string
|
||||
post?: Post
|
||||
onSaved: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ERROR_TEXT: Record<PostError, string> = {
|
||||
full: '文章数已达上限(200 篇)',
|
||||
'too-large': '正文太长了(上限 64KB)',
|
||||
'bad-input': '标题不能为空,标签也别太长',
|
||||
failed: '保存失败,稍后再试',
|
||||
}
|
||||
|
||||
export function PostEditor({ token, post, onSaved, onCancel }: PostEditorProps) {
|
||||
const [title, setTitle] = useState(post?.title ?? '')
|
||||
const [tag, setTag] = useState(post?.tag ?? '')
|
||||
const [body, setBody] = useState(post?.body ?? '')
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const input = { title, tag, body }
|
||||
if (post) await updatePost(token, post.id, input)
|
||||
else await createPost(token, input)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
const reason = (e as Error).message as PostError
|
||||
setError(ERROR_TEXT[reason] ?? ERROR_TEXT.failed)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="w-full px-6 py-14">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h2 className="text-xs font-medium uppercase tracking-[0.3em] text-muted">
|
||||
{post ? 'Edit post' : 'New post'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setPreview((p) => !p)}
|
||||
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"
|
||||
>
|
||||
{preview ? 'Write' : 'Preview'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<article>
|
||||
<h1 className="mb-6 font-display text-3xl font-medium text-text">
|
||||
{title || '(无标题)'}
|
||||
</h1>
|
||||
<Markdown>{body || '_正文为空_'}</Markdown>
|
||||
</article>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="标题"
|
||||
className="w-full rounded-lg border border-line bg-surface px-4 py-3 font-display text-2xl text-text outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
placeholder="标签(如 Rust、随笔)"
|
||||
className="w-full rounded-lg border border-line bg-surface px-4 py-2 text-sm text-text outline-none focus:border-accent"
|
||||
/>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="正文,支持 Markdown…"
|
||||
rows={16}
|
||||
className="w-full resize-y rounded-lg border border-line bg-surface px-4 py-3 font-mono text-sm leading-relaxed text-text outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-4 text-sm text-[#ff8585]">{error}</p>}
|
||||
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="rounded-full bg-accent px-6 py-2 text-sm font-medium text-bg transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="rounded-full border border-line px-6 py-2 text-sm font-medium text-muted transition-colors hover:border-accent hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -147,6 +147,10 @@ export function StartPage() {
|
||||
|
||||
{/* 底部分类抽屉:按类型分组的快捷入口,悬停某类时菜单向上抽出 */}
|
||||
<CategoryDock />
|
||||
|
||||
<span className="fixed bottom-1.5 left-1/2 z-20 -translate-x-1/2 text-[11px] leading-none text-white/50">
|
||||
苏ICP备2026046305号-1
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -41,24 +41,12 @@ const ZhihuIcon = (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const XIcon = (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden>
|
||||
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const YoutubeIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden>
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SteamIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden>
|
||||
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// 无可靠 simple-icons path 的站点:用字标,跟其余图标同色同风格。
|
||||
const Wordmark = (text: string) => (
|
||||
<span className="text-[13px] font-bold leading-none tracking-tight" aria-hidden>
|
||||
@ -99,15 +87,6 @@ const DevGlyph = (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const GameGlyph = (
|
||||
<svg {...strokeProps}>
|
||||
<path d="M7 12h4M9 10v4" />
|
||||
<circle cx="15.5" cy="11" r="0.6" fill="currentColor" />
|
||||
<circle cx="17.5" cy="13" r="0.6" fill="currentColor" />
|
||||
<rect x="2.5" y="6.5" width="19" height="11" rx="5.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// 分类顺序即从左到右的展示顺序。每类的 links 即抽屉里的内容。
|
||||
const CATEGORIES: Category[] = [
|
||||
{
|
||||
@ -116,8 +95,6 @@ const CATEGORIES: Category[] = [
|
||||
glyph: SocialGlyph,
|
||||
links: [
|
||||
{ label: '知乎', href: 'https://www.zhihu.com', icon: ZhihuIcon },
|
||||
{ label: '微博', href: 'https://weibo.com', icon: Wordmark('微博') },
|
||||
{ label: 'X', href: 'https://x.com', icon: XIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -137,18 +114,6 @@ const CATEGORIES: Category[] = [
|
||||
links: [
|
||||
{ label: 'GitHub', href: 'https://github.com', icon: GithubIcon },
|
||||
{ label: 'DeepSeek', href: 'https://chat.deepseek.com', icon: DeepSeekIcon },
|
||||
{ label: 'Rust 文档', href: 'https://rustwiki.org/zh-CN/book/', icon: Wordmark('Rs') },
|
||||
{ label: 'C++ 文档', href: 'https://en.cppreference.com/', icon: Wordmark('C++') },
|
||||
{ label: 'C# 文档', href: 'https://learn.microsoft.com/dotnet/csharp/', icon: Wordmark('C#') },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'game',
|
||||
label: '游戏',
|
||||
glyph: GameGlyph,
|
||||
links: [
|
||||
{ label: 'Steam', href: 'https://store.steampowered.com', icon: SteamIcon },
|
||||
{ label: 'Epic Games', href: 'https://store.epicgames.com', icon: Wordmark('Epic') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchWeather, type WeatherResult } from '../api/weather'
|
||||
import { locate } from '../lib/amap'
|
||||
|
||||
export interface WeatherState {
|
||||
loading: boolean
|
||||
@ -22,9 +21,8 @@ export function useWeather(intervalMs = 30 * 60 * 1000): WeatherState {
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
// 先前端定位(未配 JS Key 或失败 → null,后端用 IP 兜底)。locate 不抛错。
|
||||
const geo = await locate()
|
||||
const data = await fetchWeather(geo ?? undefined)
|
||||
// 不触发浏览器定位权限;后端按访问 IP 做城市定位,失败时前端静默不显示天气。
|
||||
const data = await fetchWeather()
|
||||
if (!cancelled) setState({ loading: false, data, error: false })
|
||||
} catch {
|
||||
if (!cancelled) setState({ loading: false, data: null, error: true })
|
||||
|
||||
@ -473,6 +473,62 @@ textarea,
|
||||
pointer-events: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.pw-video {
|
||||
background: #fffaf2;
|
||||
border: 1px solid rgba(69, 52, 38, 0.16);
|
||||
box-shadow: 8px 14px 28px rgba(0, 0, 0, 0.22);
|
||||
padding: 10px 10px 22px;
|
||||
}
|
||||
.pw-video-wrap {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #171717;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pw-video-wrap video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #111;
|
||||
}
|
||||
.pw-video-state {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #f8efe0;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
padding: 18px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.14), transparent 28%),
|
||||
linear-gradient(135deg, #2b2b2b, #141414);
|
||||
}
|
||||
.pw-video-state strong {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pw-video-state span {
|
||||
color: rgba(248, 239, 224, 0.75);
|
||||
}
|
||||
.pw-video-failed {
|
||||
background: linear-gradient(135deg, #3a1515, #191111);
|
||||
}
|
||||
.pw-video-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid rgba(248, 239, 224, 0.28);
|
||||
border-top-color: #f8efe0;
|
||||
border-radius: 999px;
|
||||
animation: pw-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes pw-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.pw-caption {
|
||||
font-family: 'Comic Sans MS', 'Comic Sans', cursive;
|
||||
font-size: 12px;
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
// 高德 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
27
Server/Cargo.lock
generated
@ -75,6 +75,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
@ -352,6 +353,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@ -968,6 +978,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
|
||||
@ -8,7 +8,7 @@ name = "RustServer"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.9"
|
||||
axum = { version = "0.8.9", features = ["multipart"] }
|
||||
base64 = "0.22"
|
||||
chrono = "0.4"
|
||||
jsonwebtoken = "9"
|
||||
|
||||
@ -17,9 +17,14 @@ pub const MAX_ITEMS: usize = 100;
|
||||
|
||||
/// 服务器端照片墙状态(装饰 + 排版,不含图片字节)
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WallState {
|
||||
#[serde(rename = "bgIndex", alias = "bg_index")]
|
||||
pub bg_index: usize,
|
||||
pub decorations: Vec<Decoration>,
|
||||
#[serde(default, alias = "photoLayout", alias = "photo_layout")]
|
||||
pub media_layout: std::collections::HashMap<String, PhotoLayout>,
|
||||
#[serde(default, skip_deserializing, skip_serializing_if = "std::collections::HashMap::is_empty")]
|
||||
pub photo_layout: std::collections::HashMap<String, PhotoLayout>,
|
||||
}
|
||||
|
||||
@ -276,3 +281,63 @@ fn mime_for_ext(ext: &str) -> &'static str {
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wall_state_deserializes_legacy_disk_photo_layout() {
|
||||
let json = r#"{
|
||||
"bg_index": 2,
|
||||
"decorations": [],
|
||||
"photo_layout": {
|
||||
"a1b2c3d4.jpg": { "x": 1.0, "y": 2.0, "rot": 3.0, "scale": 1.0, "z": 4, "w": 160.0, "caption": "old" }
|
||||
}
|
||||
}"#;
|
||||
let state: WallState = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(state.bg_index, 2);
|
||||
assert_eq!(state.media_layout.get("a1b2c3d4.jpg").unwrap().caption, "old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_state_deserializes_api_photo_layout_alias() {
|
||||
let json = r#"{
|
||||
"bgIndex": 1,
|
||||
"decorations": [],
|
||||
"photoLayout": {
|
||||
"a1b2c3d4.jpg": { "x": 1.0, "y": 2.0, "rot": 3.0, "scale": 1.0, "z": 4, "w": 160.0, "caption": "api-old" }
|
||||
}
|
||||
}"#;
|
||||
let state: WallState = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(state.bg_index, 1);
|
||||
assert_eq!(state.media_layout.get("a1b2c3d4.jpg").unwrap().caption, "api-old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_state_serializes_media_layout_in_new_shape() {
|
||||
let mut media_layout = std::collections::HashMap::new();
|
||||
media_layout.insert(
|
||||
"a1b2c3d4.jpg".to_string(),
|
||||
PhotoLayout {
|
||||
x: 1.0,
|
||||
y: 2.0,
|
||||
rot: 3.0,
|
||||
scale: 1.0,
|
||||
z: 4,
|
||||
w: 160.0,
|
||||
caption: "new".to_string(),
|
||||
},
|
||||
);
|
||||
let state = WallState {
|
||||
bg_index: 0,
|
||||
decorations: Vec::new(),
|
||||
media_layout: media_layout.clone(),
|
||||
photo_layout: media_layout,
|
||||
};
|
||||
let value = serde_json::to_value(state).unwrap();
|
||||
assert!(value.get("mediaLayout").is_some());
|
||||
assert!(value.get("photoLayout").is_some());
|
||||
assert!(value.get("photo_layout").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,10 @@
|
||||
|
||||
mod auth;
|
||||
mod folders;
|
||||
mod media;
|
||||
mod monitor;
|
||||
mod photos;
|
||||
mod posts;
|
||||
mod routes;
|
||||
mod state;
|
||||
mod weather;
|
||||
|
||||
484
Server/src/media.rs
Normal file
@ -0,0 +1,484 @@
|
||||
//! Special-folder media storage: images plus videos with async ffmpeg transcode.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::folders::MAX_ITEMS;
|
||||
use crate::photos::{self, MAX_BYTES};
|
||||
|
||||
pub const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MediaType {
|
||||
Image,
|
||||
Video,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MediaStatus {
|
||||
Ready,
|
||||
Processing,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub media_type: MediaType,
|
||||
pub status: MediaStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UploadKind {
|
||||
Image(&'static str),
|
||||
Video(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum MediaError {
|
||||
Full,
|
||||
TooLarge,
|
||||
BadType,
|
||||
BadFolder,
|
||||
Io,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VideoMeta {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
media_type: MediaType,
|
||||
status: MediaStatus,
|
||||
original_name: String,
|
||||
created_at: String,
|
||||
output_file: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FolderMediaStore {
|
||||
base: PathBuf,
|
||||
transcodes: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VideoJob {
|
||||
pub folder: String,
|
||||
pub id: String,
|
||||
pub input_path: PathBuf,
|
||||
pub output_path: PathBuf,
|
||||
pub meta_path: PathBuf,
|
||||
}
|
||||
|
||||
impl FolderMediaStore {
|
||||
pub fn from_env() -> Self {
|
||||
let base = std::env::var("SPECIAL_DIR")
|
||||
.unwrap_or_else(|_| "special-folders".to_string());
|
||||
Self::new(base)
|
||||
}
|
||||
|
||||
pub fn new(base: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
base: base.into(),
|
||||
transcodes: Arc::new(Semaphore::new(1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn folder_dir(&self, folder: &str) -> Option<PathBuf> {
|
||||
let safe = photos::sanitize_user(folder)?;
|
||||
Some(self.base.join(safe))
|
||||
}
|
||||
|
||||
fn media_dir(&self, folder: &str) -> Option<PathBuf> {
|
||||
Some(self.folder_dir(folder)?.join("media"))
|
||||
}
|
||||
|
||||
fn tmp_dir(&self, folder: &str) -> Option<PathBuf> {
|
||||
Some(self.folder_dir(folder)?.join(".tmp"))
|
||||
}
|
||||
|
||||
pub fn list(&self, folder: &str) -> (Vec<MediaItem>, u64) {
|
||||
let Some(dir) = self.folder_dir(folder) else {
|
||||
return (Vec::new(), 0);
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
let mut total_bytes = 0u64;
|
||||
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
total_bytes += meta.len();
|
||||
}
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if photos::valid_id(name) {
|
||||
items.push(MediaItem {
|
||||
id: name.to_string(),
|
||||
media_type: MediaType::Image,
|
||||
status: MediaStatus::Ready,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(media_dir) = self.media_dir(folder) {
|
||||
if let Ok(rd) = std::fs::read_dir(media_dir) {
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(&path) {
|
||||
if let Ok(meta) = serde_json::from_slice::<VideoMeta>(&bytes) {
|
||||
let item = MediaItem {
|
||||
id: meta.id.clone(),
|
||||
media_type: MediaType::Video,
|
||||
status: meta.status,
|
||||
};
|
||||
if let Some(parent) = path.parent().and_then(|p| p.parent()) {
|
||||
let output = parent.join(&meta.output_file);
|
||||
if let Ok(meta) = std::fs::metadata(output) {
|
||||
total_bytes += meta.len();
|
||||
}
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
(items, total_bytes)
|
||||
}
|
||||
|
||||
pub fn count_items(&self, folder: &str, decoration_count: usize) -> usize {
|
||||
let _ = MAX_ITEMS;
|
||||
self.list(folder).0.len() + decoration_count
|
||||
}
|
||||
|
||||
pub fn save_image_bytes(&self, folder: &str, ext: &str, bytes: &[u8]) -> Result<MediaItem, MediaError> {
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(MediaError::TooLarge);
|
||||
}
|
||||
if !matches!(ext, "jpg" | "png" | "gif" | "webp") {
|
||||
return Err(MediaError::BadType);
|
||||
}
|
||||
let dir = self.folder_dir(folder).ok_or(MediaError::BadFolder)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|_| MediaError::Io)?;
|
||||
let id = format!("{}.{}", new_stem(), ext);
|
||||
std::fs::write(dir.join(&id), bytes).map_err(|_| MediaError::Io)?;
|
||||
Ok(MediaItem {
|
||||
id,
|
||||
media_type: MediaType::Image,
|
||||
status: MediaStatus::Ready,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn begin_video_upload(&self, folder: &str, ext: &str, original_name: &str) -> Result<VideoJob, MediaError> {
|
||||
if !matches!(ext, "mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv") {
|
||||
return Err(MediaError::BadType);
|
||||
}
|
||||
let folder_dir = self.folder_dir(folder).ok_or(MediaError::BadFolder)?;
|
||||
let media_dir = self.media_dir(folder).ok_or(MediaError::BadFolder)?;
|
||||
let tmp_dir = self.tmp_dir(folder).ok_or(MediaError::BadFolder)?;
|
||||
std::fs::create_dir_all(&folder_dir).map_err(|_| MediaError::Io)?;
|
||||
std::fs::create_dir_all(&media_dir).map_err(|_| MediaError::Io)?;
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|_| MediaError::Io)?;
|
||||
|
||||
let id = new_stem();
|
||||
let input_path = tmp_dir.join(format!("{id}.{ext}"));
|
||||
let output_path = folder_dir.join(format!("{id}.mp4"));
|
||||
let meta_path = media_dir.join(format!("{id}.json"));
|
||||
let meta = VideoMeta {
|
||||
id: id.clone(),
|
||||
media_type: MediaType::Video,
|
||||
status: MediaStatus::Processing,
|
||||
original_name: original_name.to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
output_file: format!("{id}.mp4"),
|
||||
error: None,
|
||||
};
|
||||
write_meta(&meta_path, &meta)?;
|
||||
Ok(VideoJob {
|
||||
folder: folder.to_string(),
|
||||
id,
|
||||
input_path,
|
||||
output_path,
|
||||
meta_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish_video_upload(&self, job: VideoJob) -> Result<MediaItem, MediaError> {
|
||||
self.spawn_transcode(job.clone());
|
||||
Ok(MediaItem {
|
||||
id: job.id,
|
||||
media_type: MediaType::Video,
|
||||
status: MediaStatus::Processing,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spawn_transcode(&self, job: VideoJob) {
|
||||
let semaphore = self.transcodes.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = match semaphore.acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
};
|
||||
let args = ffmpeg_args(&job.input_path, &job.output_path);
|
||||
let status = Command::new("ffmpeg").args(&args).status().await;
|
||||
match status {
|
||||
Ok(code) if code.success() => {
|
||||
update_video_status(&job.meta_path, MediaStatus::Ready, None);
|
||||
let _ = tokio::fs::remove_file(&job.input_path).await;
|
||||
}
|
||||
Ok(code) => {
|
||||
update_video_status(
|
||||
&job.meta_path,
|
||||
MediaStatus::Failed,
|
||||
Some(format!("ffmpeg exited with status {code}")),
|
||||
);
|
||||
let _ = tokio::fs::remove_file(&job.input_path).await;
|
||||
let _ = tokio::fs::remove_file(&job.output_path).await;
|
||||
}
|
||||
Err(err) => {
|
||||
update_video_status(&job.meta_path, MediaStatus::Failed, Some(err.to_string()));
|
||||
let _ = tokio::fs::remove_file(&job.input_path).await;
|
||||
let _ = tokio::fs::remove_file(&job.output_path).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn read(&self, folder: &str, id: &str) -> Option<(&'static str, Vec<u8>)> {
|
||||
let dir = self.folder_dir(folder)?;
|
||||
if photos::valid_id(id) {
|
||||
let bytes = std::fs::read(dir.join(id)).ok()?;
|
||||
let ext = id.rsplit_once('.').map(|(_, ext)| ext)?;
|
||||
return Some((mime_for_image_ext(ext), bytes));
|
||||
}
|
||||
if valid_video_id(id) {
|
||||
let meta = self.video_meta(folder, id)?;
|
||||
if meta.status != MediaStatus::Ready {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(dir.join(meta.output_file)).ok()?;
|
||||
return Some(("video/mp4", bytes));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn status(&self, folder: &str, id: &str) -> Option<MediaItem> {
|
||||
if photos::valid_id(id) {
|
||||
let dir = self.folder_dir(folder)?;
|
||||
if dir.join(id).is_file() {
|
||||
return Some(MediaItem {
|
||||
id: id.to_string(),
|
||||
media_type: MediaType::Image,
|
||||
status: MediaStatus::Ready,
|
||||
});
|
||||
}
|
||||
}
|
||||
let meta = self.video_meta(folder, id)?;
|
||||
Some(MediaItem {
|
||||
id: meta.id,
|
||||
media_type: MediaType::Video,
|
||||
status: meta.status,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete(&self, folder: &str, id: &str) -> bool {
|
||||
let Some(dir) = self.folder_dir(folder) else {
|
||||
return false;
|
||||
};
|
||||
if photos::valid_id(id) {
|
||||
return std::fs::remove_file(dir.join(id)).is_ok();
|
||||
}
|
||||
if !valid_video_id(id) {
|
||||
return false;
|
||||
}
|
||||
let meta = self.video_meta(folder, id);
|
||||
let mut removed = false;
|
||||
if let Some(meta) = meta {
|
||||
removed |= std::fs::remove_file(dir.join(meta.output_file)).is_ok();
|
||||
}
|
||||
if let Some(tmp_dir) = self.tmp_dir(folder) {
|
||||
if let Ok(rd) = std::fs::read_dir(tmp_dir) {
|
||||
for entry in rd.flatten() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if name.starts_with(id) {
|
||||
removed |= std::fs::remove_file(entry.path()).is_ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(media_dir) = self.media_dir(folder) {
|
||||
removed |= std::fs::remove_file(media_dir.join(format!("{id}.json"))).is_ok();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn video_meta(&self, folder: &str, id: &str) -> Option<VideoMeta> {
|
||||
if !valid_video_id(id) {
|
||||
return None;
|
||||
}
|
||||
let path = self.media_dir(folder)?.join(format!("{id}.json"));
|
||||
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn classify_upload(content_type: Option<&str>, filename: Option<&str>) -> Option<UploadKind> {
|
||||
let mime = content_type.unwrap_or("").to_ascii_lowercase();
|
||||
match mime.as_str() {
|
||||
"image/jpeg" => return Some(UploadKind::Image("jpg")),
|
||||
"image/png" => return Some(UploadKind::Image("png")),
|
||||
"image/gif" => return Some(UploadKind::Image("gif")),
|
||||
"image/webp" => return Some(UploadKind::Image("webp")),
|
||||
"video/mp4" => return Some(UploadKind::Video("mp4")),
|
||||
"video/quicktime" => return Some(UploadKind::Video("mov")),
|
||||
"video/webm" => return Some(UploadKind::Video("webm")),
|
||||
"video/x-msvideo" => return Some(UploadKind::Video("avi")),
|
||||
"video/x-matroska" => return Some(UploadKind::Video("mkv")),
|
||||
_ => {}
|
||||
}
|
||||
let ext = filename
|
||||
.and_then(|name| name.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()))?;
|
||||
match ext.as_str() {
|
||||
"jpg" | "jpeg" => Some(UploadKind::Image("jpg")),
|
||||
"png" => Some(UploadKind::Image("png")),
|
||||
"gif" => Some(UploadKind::Image("gif")),
|
||||
"webp" => Some(UploadKind::Image("webp")),
|
||||
"mp4" => Some(UploadKind::Video("mp4")),
|
||||
"mov" => Some(UploadKind::Video("mov")),
|
||||
"m4v" => Some(UploadKind::Video("m4v")),
|
||||
"webm" => Some(UploadKind::Video("webm")),
|
||||
"avi" => Some(UploadKind::Video("avi")),
|
||||
"mkv" => Some(UploadKind::Video("mkv")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn valid_video_id(id: &str) -> bool {
|
||||
id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
pub fn ffmpeg_args(input: &Path, output: &Path) -> Vec<String> {
|
||||
vec![
|
||||
"-y".to_string(),
|
||||
"-i".to_string(),
|
||||
input.to_string_lossy().to_string(),
|
||||
"-vf".to_string(),
|
||||
"scale='min(iw,if(gte(iw,ih),1280,720))':'min(ih,if(gte(iw,ih),720,1280))':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=24".to_string(),
|
||||
"-c:v".to_string(),
|
||||
"libx264".to_string(),
|
||||
"-preset".to_string(),
|
||||
"veryfast".to_string(),
|
||||
"-crf".to_string(),
|
||||
"30".to_string(),
|
||||
"-maxrate".to_string(),
|
||||
"900k".to_string(),
|
||||
"-bufsize".to_string(),
|
||||
"1800k".to_string(),
|
||||
"-c:a".to_string(),
|
||||
"aac".to_string(),
|
||||
"-b:a".to_string(),
|
||||
"96k".to_string(),
|
||||
"-movflags".to_string(),
|
||||
"+faststart".to_string(),
|
||||
output.to_string_lossy().to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn write_meta(path: &Path, meta: &VideoMeta) -> Result<(), MediaError> {
|
||||
let bytes = serde_json::to_vec_pretty(meta).map_err(|_| MediaError::Io)?;
|
||||
std::fs::write(path, bytes).map_err(|_| MediaError::Io)
|
||||
}
|
||||
|
||||
fn update_video_status(path: &Path, status: MediaStatus, error: Option<String>) {
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut meta) = serde_json::from_slice::<VideoMeta>(&bytes) else {
|
||||
return;
|
||||
};
|
||||
meta.status = status;
|
||||
meta.error = error.map(|msg| msg.chars().take(240).collect());
|
||||
let _ = write_meta(path, &meta);
|
||||
}
|
||||
|
||||
fn mime_for_image_ext(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"jpg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn new_stem() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let next = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let digest = Sha256::digest(format!("{nanos}-{next}").as_bytes());
|
||||
digest.iter().take(8).map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classify_upload_accepts_common_images_and_videos() {
|
||||
assert_eq!(classify_upload(Some("image/jpeg"), Some("a.jpg")), Some(UploadKind::Image("jpg")));
|
||||
assert_eq!(classify_upload(Some("image/png"), Some("a.png")), Some(UploadKind::Image("png")));
|
||||
assert_eq!(classify_upload(Some("video/mp4"), Some("clip.mp4")), Some(UploadKind::Video("mp4")));
|
||||
assert_eq!(classify_upload(Some("video/quicktime"), Some("clip.mov")), Some(UploadKind::Video("mov")));
|
||||
assert_eq!(classify_upload(Some("application/octet-stream"), Some("clip.mkv")), Some(UploadKind::Video("mkv")));
|
||||
assert_eq!(classify_upload(Some("text/plain"), Some("note.txt")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_video_id_is_lower_hex_stem_only() {
|
||||
assert!(valid_video_id("a1b2c3d4e5f60708"));
|
||||
assert!(!valid_video_id("a1b2.mp4"));
|
||||
assert!(!valid_video_id("../a1b2c3d4e5f60708"));
|
||||
assert!(!valid_video_id("A1b2c3d4e5f60708"));
|
||||
assert!(!valid_video_id(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_args_match_low_bandwidth_target() {
|
||||
let args = ffmpeg_args(Path::new("/tmp/in.mov"), Path::new("/tmp/out.mp4"));
|
||||
let joined = args.join(" ");
|
||||
assert!(joined.contains("-vf"));
|
||||
assert!(joined.contains("fps=24"));
|
||||
assert!(joined.contains("-maxrate 900k"));
|
||||
assert!(joined.contains("-bufsize 1800k"));
|
||||
assert!(joined.contains("-b:a 96k"));
|
||||
assert!(joined.contains("+faststart"));
|
||||
assert!(joined.contains("force_original_aspect_ratio=decrease"));
|
||||
assert!(joined.contains("force_divisible_by=2"));
|
||||
assert!(joined.contains("/tmp/in.mov"));
|
||||
assert!(joined.contains("/tmp/out.mp4"));
|
||||
}
|
||||
}
|
||||
320
Server/src/posts.rs
Normal file
@ -0,0 +1,320 @@
|
||||
//! 个人博客文章存储:按用户隔离到 BLOG_DIR/<user>/,每篇一个 <hex>.json。
|
||||
//! 仿 photos.rs(廉价 Clone、串行化 计数→写 临界区)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::photos::sanitize_user;
|
||||
|
||||
pub const MAX_POSTS: usize = 200;
|
||||
pub const MAX_BODY: usize = 64 * 1024;
|
||||
pub const MAX_TITLE: usize = 200;
|
||||
pub const MAX_TAG: usize = 40;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PostError {
|
||||
Full,
|
||||
TooLarge,
|
||||
BadInput,
|
||||
BadUser,
|
||||
NotFound,
|
||||
Io,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub tag: String,
|
||||
pub body: String,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: u64,
|
||||
#[serde(rename = "updatedAt")]
|
||||
pub updated_at: u64,
|
||||
}
|
||||
|
||||
pub struct PostDraft {
|
||||
pub title: String,
|
||||
pub tag: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PostStore {
|
||||
base: PathBuf,
|
||||
lock: std::sync::Arc<std::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl PostStore {
|
||||
pub fn from_env() -> Self {
|
||||
let base = std::env::var("BLOG_DIR").unwrap_or_else(|_| "blog".to_string());
|
||||
Self::new(base)
|
||||
}
|
||||
|
||||
pub fn new(base: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
base: base.into(),
|
||||
lock: std::sync::Arc::new(std::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_dir(&self, user: &str) -> Option<PathBuf> {
|
||||
Some(self.base.join(sanitize_user(user)?))
|
||||
}
|
||||
|
||||
pub fn list(&self, user: &str) -> Vec<Post> {
|
||||
let Some(dir) = self.user_dir(user) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<Post> = json_files(&dir)
|
||||
.iter()
|
||||
.filter_map(|p| read_one(p))
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn read(&self, user: &str, id: &str) -> Option<Post> {
|
||||
if !valid_id(id) {
|
||||
return None;
|
||||
}
|
||||
let dir = self.user_dir(user)?;
|
||||
read_one(&dir.join(format!("{id}.json")))
|
||||
}
|
||||
|
||||
pub fn create(&self, user: &str, draft: PostDraft) -> Result<Post, PostError> {
|
||||
let draft = validate(draft)?;
|
||||
let dir = self.user_dir(user).ok_or(PostError::BadUser)?;
|
||||
// 串行化 计数→写:防止并发创建越过 MAX_POSTS。
|
||||
let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if json_files(&dir).len() >= MAX_POSTS {
|
||||
return Err(PostError::Full);
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|_| PostError::Io)?;
|
||||
let now = now_secs();
|
||||
let id = new_stem();
|
||||
let post = Post {
|
||||
id,
|
||||
title: draft.title,
|
||||
tag: draft.tag,
|
||||
body: draft.body,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
write_one(&dir.join(format!("{}.json", post.id)), &post)?;
|
||||
Ok(post)
|
||||
}
|
||||
|
||||
pub fn update(&self, user: &str, id: &str, draft: PostDraft) -> Result<Post, PostError> {
|
||||
if !valid_id(id) {
|
||||
return Err(PostError::NotFound);
|
||||
}
|
||||
let draft = validate(draft)?;
|
||||
let dir = self.user_dir(user).ok_or(PostError::BadUser)?;
|
||||
let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path = dir.join(format!("{id}.json"));
|
||||
let mut post = read_one(&path).ok_or(PostError::NotFound)?;
|
||||
post.title = draft.title;
|
||||
post.tag = draft.tag;
|
||||
post.body = draft.body;
|
||||
post.updated_at = now_secs();
|
||||
write_one(&path, &post)?;
|
||||
Ok(post)
|
||||
}
|
||||
|
||||
pub fn delete(&self, user: &str, id: &str) -> bool {
|
||||
if !valid_id(id) {
|
||||
return false;
|
||||
}
|
||||
match self.user_dir(user) {
|
||||
Some(dir) => std::fs::remove_file(dir.join(format!("{id}.json"))).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验并归一化草稿(trim 标题)。
|
||||
fn validate(draft: PostDraft) -> Result<PostDraft, PostError> {
|
||||
let title = draft.title.trim().to_string();
|
||||
if title.is_empty() || title.chars().count() > MAX_TITLE {
|
||||
return Err(PostError::BadInput);
|
||||
}
|
||||
if draft.tag.chars().count() > MAX_TAG {
|
||||
return Err(PostError::BadInput);
|
||||
}
|
||||
if draft.body.len() > MAX_BODY {
|
||||
return Err(PostError::TooLarge);
|
||||
}
|
||||
Ok(PostDraft { title, tag: draft.tag, body: draft.body })
|
||||
}
|
||||
|
||||
/// 目录下所有 `*.json` 文件路径(目录不存在 → 空)。
|
||||
fn json_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
if p.extension().and_then(|x| x.to_str()) == Some("json") {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_one(path: &Path) -> Option<Post> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
fn write_one(path: &Path, post: &Post) -> Result<(), PostError> {
|
||||
let bytes = serde_json::to_vec(post).map_err(|_| PostError::Io)?;
|
||||
std::fs::write(path, bytes).map_err(|_| PostError::Io)
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 16 位 hex 文件名干(纳秒 + 进程内计数器哈希;读取已鉴权,无需密码学随机)。
|
||||
fn new_stem() -> 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);
|
||||
let digest = Sha256::digest(format!("{nanos}-{n}").as_bytes());
|
||||
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// 文章 id 合法性:非空、全小写 hex、长度 ≤ 32;杜绝路径穿越。
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 32
|
||||
&& id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TmpDir(PathBuf);
|
||||
impl TmpDir {
|
||||
fn new() -> Self {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let p = std::env::temp_dir().join(format!("blog-test-{nanos}"));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TmpDir(p)
|
||||
}
|
||||
}
|
||||
impl Drop for TmpDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn draft(title: &str, body: &str) -> PostDraft {
|
||||
PostDraft { title: title.into(), tag: "Note".into(), body: body.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_id_guards_hex_and_path() {
|
||||
assert!(valid_id("abc123"));
|
||||
assert!(!valid_id("ABC123")); // 非小写
|
||||
assert!(!valid_id("../x"));
|
||||
assert!(!valid_id("a.b"));
|
||||
assert!(!valid_id(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_list_read_update_delete_round_trip() {
|
||||
let tmp = TmpDir::new();
|
||||
let store = PostStore::new(&tmp.0);
|
||||
assert_eq!(store.list("cc").len(), 0);
|
||||
|
||||
let p = store.create("cc", draft("Hello", "world body")).unwrap();
|
||||
assert!(valid_id(&p.id));
|
||||
assert_eq!(p.title, "Hello");
|
||||
assert_eq!(p.created_at, p.updated_at);
|
||||
|
||||
let listed = store.list("cc");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, p.id);
|
||||
|
||||
let got = store.read("cc", &p.id).unwrap();
|
||||
assert_eq!(got.body, "world body");
|
||||
|
||||
let updated = store
|
||||
.update("cc", &p.id, draft("Hello2", "new body"))
|
||||
.unwrap();
|
||||
assert_eq!(updated.title, "Hello2");
|
||||
assert_eq!(updated.created_at, p.created_at); // 创建时间不变
|
||||
assert!(updated.updated_at >= p.created_at);
|
||||
|
||||
assert!(store.delete("cc", &p.id));
|
||||
assert_eq!(store.list("cc").len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_is_newest_first() {
|
||||
let tmp = TmpDir::new();
|
||||
let store = PostStore::new(&tmp.0);
|
||||
let a = store.create("cc", draft("A", "a")).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
let b = store.create("cc", draft("B", "b")).unwrap();
|
||||
let listed = store.list("cc");
|
||||
assert_eq!(listed[0].id, b.id);
|
||||
assert_eq!(listed[1].id, a.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
let tmp = TmpDir::new();
|
||||
let store = PostStore::new(&tmp.0);
|
||||
assert_eq!(store.create("cc", draft(" ", "body")), Err(PostError::BadInput));
|
||||
let long_title: String = "x".repeat(MAX_TITLE + 1);
|
||||
assert_eq!(store.create("cc", draft(&long_title, "b")), Err(PostError::BadInput));
|
||||
let big_body: String = "x".repeat(MAX_BODY + 1);
|
||||
assert_eq!(store.create("cc", draft("ok", &big_body)), Err(PostError::TooLarge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_max_posts() {
|
||||
let tmp = TmpDir::new();
|
||||
let store = PostStore::new(&tmp.0);
|
||||
for i in 0..MAX_POSTS {
|
||||
store.create("cc", draft(&format!("t{i}"), "b")).unwrap();
|
||||
}
|
||||
assert_eq!(store.create("cc", draft("over", "b")), Err(PostError::Full));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolates_users() {
|
||||
let tmp = TmpDir::new();
|
||||
let store = PostStore::new(&tmp.0);
|
||||
let p = store.create("cc", draft("mine", "b")).unwrap();
|
||||
assert!(store.read("dave", &p.id).is_none());
|
||||
assert_eq!(store.list("dave").len(), 0);
|
||||
assert!(!store.delete("dave", &p.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_update_reject_bad_id() {
|
||||
let tmp = TmpDir::new();
|
||||
let store = PostStore::new(&tmp.0);
|
||||
assert!(store.read("cc", "../secret").is_none());
|
||||
assert_eq!(store.update("cc", "../secret", draft("x", "y")), Err(PostError::NotFound));
|
||||
assert!(!store.delete("cc", "../secret"));
|
||||
}
|
||||
}
|
||||
@ -4,24 +4,27 @@ use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{ConnectInfo, Query, State},
|
||||
extract::{ConnectInfo, Multipart, State},
|
||||
http::{HeaderMap, Method, StatusCode, header},
|
||||
routing::{get, post, put},
|
||||
routing::{get, post},
|
||||
};
|
||||
use axum::extract::{DefaultBodyLimit, Path as AxPath};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
|
||||
use crate::folders::{FolderRegistry, WallState, QUOTA_BYTES, MAX_ITEMS};
|
||||
use crate::media::{MediaError, MediaItem, UploadKind, MAX_VIDEO_BYTES, classify_upload};
|
||||
use crate::photos::{SaveError, MAX_BYTES, MAX_PHOTOS};
|
||||
use crate::posts::{Post, PostDraft, PostError, MAX_POSTS};
|
||||
use crate::routes::extract::AuthUser;
|
||||
|
||||
use crate::{
|
||||
monitor::Stats,
|
||||
state::AppState,
|
||||
weather::{Located, WeatherOutcome},
|
||||
weather::WeatherOutcome,
|
||||
};
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
@ -35,7 +38,7 @@ pub fn router(state: AppState) -> Router {
|
||||
"http://localhost:5174".parse().unwrap(),
|
||||
"http://127.0.0.1:5174".parse().unwrap(),
|
||||
]))
|
||||
.allow_methods([Method::GET, Method::POST, Method::DELETE])
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
|
||||
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]);
|
||||
|
||||
// 照片接口接收 base64 上传,需放宽 body 上限到 16MB;其余接口保持
|
||||
@ -52,10 +55,21 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/web/folder/{name}/photos/{id}", get(get_folder_photo).delete(delete_folder_photo))
|
||||
.layer(DefaultBodyLimit::max(16 * 1024 * 1024));
|
||||
|
||||
let folder_media_routes = Router::new()
|
||||
.route("/api/web/folder/{name}/media", get(list_folder_media).post(upload_folder_media))
|
||||
.route("/api/web/folder/{name}/media/{id}", get(get_folder_media).delete(delete_folder_media))
|
||||
.route("/api/web/folder/{name}/media/{id}/status", get(get_folder_media_status))
|
||||
.layer(DefaultBodyLimit::max(700 * 1024 * 1024));
|
||||
|
||||
// 照片墙状态路由(body 较小,用默认 2MB 即可)
|
||||
let state_routes = Router::new()
|
||||
.route("/api/web/folder/{name}/state", get(get_folder_state).put(save_folder_state));
|
||||
|
||||
// 博客文章路由(正文是文本,默认 2MB body 上限足够)。
|
||||
let post_routes = Router::new()
|
||||
.route("/api/web/posts", get(list_posts).post(create_post))
|
||||
.route("/api/web/posts/{id}", get(get_post).put(update_post).delete(delete_post));
|
||||
|
||||
Router::new()
|
||||
.route("/api/web/stats", get(stats))
|
||||
.route("/api/web/gate", post(gate))
|
||||
@ -63,7 +77,9 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/web/weather", get(weather))
|
||||
.merge(photo_routes)
|
||||
.merge(folder_routes)
|
||||
.merge(folder_media_routes)
|
||||
.merge(state_routes)
|
||||
.merge(post_routes)
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
@ -136,33 +152,17 @@ async fn login(
|
||||
}
|
||||
}
|
||||
|
||||
/// 前端可选带上的定位参数:经纬度 + 城市名(来自 AMap.Geolocation 插件)。
|
||||
/// 三者都缺省 → 后端退回高德 IP 定位兜底。
|
||||
#[derive(Deserialize)]
|
||||
struct WeatherQuery {
|
||||
lon: Option<f64>,
|
||||
lat: Option<f64>,
|
||||
city: Option<String>,
|
||||
}
|
||||
|
||||
/// 天气 + 定位:
|
||||
/// - 前端带 `lon/lat`(AMap.Geolocation 定位结果)→ 直接用,只调和风。
|
||||
/// - 未带 → 高德按访问者 IP 定位取城市作兜底。
|
||||
/// 后端只按访问者 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 outcome = state.weather.get(&ip).await;
|
||||
|
||||
let q = state.weather.quota_snapshot();
|
||||
let quota = json!({
|
||||
@ -317,6 +317,7 @@ async fn check_exists(
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FolderPhotoList {
|
||||
items: Vec<FolderPhotoItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@ -411,6 +412,157 @@ async fn delete_folder_photo(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FolderMediaList {
|
||||
items: Vec<MediaItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max: Option<usize>,
|
||||
total_bytes: u64,
|
||||
over_quota: bool,
|
||||
}
|
||||
|
||||
async fn list_folder_media(
|
||||
AuthUser(user): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
AxPath(name): AxPath<String>,
|
||||
) -> Result<Json<FolderMediaList>, StatusCode> {
|
||||
check_access(&state.folders, &name, &user)?;
|
||||
let (items, total_bytes) = state.folder_media.list(&name);
|
||||
Ok(Json(FolderMediaList {
|
||||
items,
|
||||
max: Some(MAX_ITEMS),
|
||||
total_bytes,
|
||||
over_quota: total_bytes > QUOTA_BYTES,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upload_folder_media(
|
||||
AuthUser(user): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
AxPath(name): AxPath<String>,
|
||||
multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<MediaItem>), StatusCode> {
|
||||
check_access(&state.folders, &name, &user)?;
|
||||
save_media_from_multipart(state, name, multipart).await
|
||||
}
|
||||
|
||||
async fn get_folder_media(
|
||||
AuthUser(user): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
AxPath((name, id)): AxPath<(String, String)>,
|
||||
) -> Response {
|
||||
if check_access(&state.folders, &name, &user).is_err() {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
match state.folder_media.read(&name, &id) {
|
||||
Some((mime, bytes)) => ([(header::CONTENT_TYPE, mime)], bytes).into_response(),
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_folder_media_status(
|
||||
AuthUser(user): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
AxPath((name, id)): AxPath<(String, String)>,
|
||||
) -> Result<Json<MediaItem>, StatusCode> {
|
||||
check_access(&state.folders, &name, &user)?;
|
||||
state.folder_media.status(&name, &id).map(Json).ok_or(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
async fn delete_folder_media(
|
||||
AuthUser(user): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
AxPath((name, id)): AxPath<(String, String)>,
|
||||
) -> StatusCode {
|
||||
if check_access(&state.folders, &name, &user).is_err() {
|
||||
return StatusCode::NOT_FOUND;
|
||||
}
|
||||
if state.folder_media.delete(&name, &id) {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_media_from_multipart(
|
||||
state: AppState,
|
||||
folder: String,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<MediaItem>), StatusCode> {
|
||||
while let Some(mut field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? {
|
||||
if field.name() != Some("file") {
|
||||
continue;
|
||||
}
|
||||
let filename = field.file_name().map(|s| s.to_string()).unwrap_or_else(|| "upload".to_string());
|
||||
let content_type = field.content_type().map(|s| s.to_string());
|
||||
let Some(kind) = classify_upload(content_type.as_deref(), Some(&filename)) else {
|
||||
return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||
};
|
||||
|
||||
let decoration_count = state
|
||||
.folder_photos
|
||||
.load_state(&folder)
|
||||
.map(|wall| wall.decorations.len())
|
||||
.unwrap_or(0);
|
||||
if state.folder_media.count_items(&folder, decoration_count) >= MAX_ITEMS {
|
||||
return Err(StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
match kind {
|
||||
UploadKind::Image(ext) => {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = field.chunk().await.map_err(|_| StatusCode::BAD_REQUEST)? {
|
||||
if bytes.len() + chunk.len() > MAX_BYTES {
|
||||
return Err(StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let item = state.folder_media.save_image_bytes(&folder, ext, &bytes).map_err(media_error_status)?;
|
||||
return Ok((StatusCode::CREATED, Json(item)));
|
||||
}
|
||||
UploadKind::Video(ext) => {
|
||||
let job = state
|
||||
.folder_media
|
||||
.begin_video_upload(&folder, ext, &filename)
|
||||
.map_err(media_error_status)?;
|
||||
let mut written = 0u64;
|
||||
let mut out = tokio::fs::File::create(&job.input_path)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
while let Some(chunk) = field.chunk().await.map_err(|_| StatusCode::BAD_REQUEST)? {
|
||||
written += chunk.len() as u64;
|
||||
if written > MAX_VIDEO_BYTES {
|
||||
let _ = tokio::fs::remove_file(&job.input_path).await;
|
||||
let _ = tokio::fs::remove_file(&job.meta_path).await;
|
||||
return Err(StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
out.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
}
|
||||
out.flush()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let item = state.folder_media.finish_video_upload(job).map_err(media_error_status)?;
|
||||
return Ok((StatusCode::ACCEPTED, Json(item)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
fn media_error_status(err: MediaError) -> StatusCode {
|
||||
match err {
|
||||
MediaError::Full => StatusCode::CONFLICT,
|
||||
MediaError::TooLarge => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
MediaError::BadType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
MediaError::BadFolder => StatusCode::BAD_REQUEST,
|
||||
MediaError::Io => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
照片墙状态接口(装饰 + 排版,服务器共享)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
@ -433,8 +585,10 @@ struct StateSaveRequest {
|
||||
#[serde(rename = "bgIndex")]
|
||||
bg_index: usize,
|
||||
decorations: Vec<serde_json::Value>,
|
||||
#[serde(rename = "mediaLayout")]
|
||||
media_layout: Option<std::collections::HashMap<String, serde_json::Value>>,
|
||||
#[serde(rename = "photoLayout")]
|
||||
photo_layout: std::collections::HashMap<String, serde_json::Value>,
|
||||
photo_layout: Option<std::collections::HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// PUT /api/web/folder/{name}/state
|
||||
@ -450,14 +604,16 @@ async fn save_folder_state(
|
||||
.into_iter()
|
||||
.filter_map(|v| serde_json::from_value(v).ok())
|
||||
.collect();
|
||||
let photo_layout: std::collections::HashMap<String, crate::folders::PhotoLayout> = body.photo_layout
|
||||
let layout_values = body.media_layout.or(body.photo_layout).unwrap_or_default();
|
||||
let media_layout: std::collections::HashMap<String, crate::folders::PhotoLayout> = layout_values
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| serde_json::from_value(v).ok().map(|pl| (k, pl)))
|
||||
.collect();
|
||||
let wall_state = WallState {
|
||||
bg_index: body.bg_index,
|
||||
decorations,
|
||||
photo_layout,
|
||||
media_layout: media_layout.clone(),
|
||||
photo_layout: media_layout,
|
||||
};
|
||||
match state.folder_photos.save_state(&name, &wall_state) {
|
||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||
@ -465,3 +621,85 @@ async fn save_folder_state(
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
博客文章接口(按用户隔离,全部 AuthUser 鉴权)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PostListResponse {
|
||||
items: Vec<Post>,
|
||||
max: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PostInput {
|
||||
title: String,
|
||||
tag: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
/// PostError → HTTP 状态码(与 photos 同构)。
|
||||
fn post_status(e: PostError) -> StatusCode {
|
||||
match e {
|
||||
PostError::Full => StatusCode::CONFLICT,
|
||||
PostError::TooLarge => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
PostError::BadInput | PostError::BadUser => StatusCode::BAD_REQUEST,
|
||||
PostError::NotFound => StatusCode::NOT_FOUND,
|
||||
PostError::Io => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_posts(AuthUser(user): AuthUser, State(state): State<AppState>) -> Json<PostListResponse> {
|
||||
Json(PostListResponse {
|
||||
items: state.posts.list(&user),
|
||||
max: MAX_POSTS,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_post(
|
||||
AuthUser(user): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<PostInput>,
|
||||
) -> Result<(StatusCode, Json<Post>), StatusCode> {
|
||||
let draft = PostDraft { title: req.title, tag: req.tag, body: req.body };
|
||||
state
|
||||
.posts
|
||||
.create(&user, draft)
|
||||
.map(|p| (StatusCode::CREATED, Json(p)))
|
||||
.map_err(post_status)
|
||||
}
|
||||
|
||||
async fn get_post(
|
||||
AuthUser(user): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
AxPath(id): AxPath<String>,
|
||||
) -> Result<Json<Post>, StatusCode> {
|
||||
state.posts.read(&user, &id).map(Json).ok_or(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
async fn update_post(
|
||||
AuthUser(user): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
AxPath(id): AxPath<String>,
|
||||
Json(req): Json<PostInput>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let draft = PostDraft { title: req.title, tag: req.tag, body: req.body };
|
||||
state
|
||||
.posts
|
||||
.update(&user, &id, draft)
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(post_status)
|
||||
}
|
||||
|
||||
async fn delete_post(
|
||||
AuthUser(user): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
AxPath(id): AxPath<String>,
|
||||
) -> StatusCode {
|
||||
if state.posts.delete(&user, &id) {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,8 +9,10 @@ use tokio::sync::RwLock;
|
||||
|
||||
use crate::auth::{self, DevGate, UserStore, WallUserStore};
|
||||
use crate::folders::{FolderRegistry, FolderStore};
|
||||
use crate::media::FolderMediaStore;
|
||||
use crate::monitor::Stats;
|
||||
use crate::photos::PhotoStore;
|
||||
use crate::posts::PostStore;
|
||||
use crate::weather::WeatherService;
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -31,6 +33,10 @@ pub struct AppState {
|
||||
pub folders: FolderRegistry,
|
||||
/// 特殊文件夹图片仓库(按文件夹隔离的磁盘目录)。
|
||||
pub folder_photos: FolderStore,
|
||||
/// 特殊文件夹媒体仓库(图片 + 视频)。
|
||||
pub folder_media: FolderMediaStore,
|
||||
/// 个人博客文章仓库(按用户隔离的磁盘目录)。
|
||||
pub posts: PostStore,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -47,6 +53,8 @@ impl AppState {
|
||||
photos: PhotoStore::from_env(),
|
||||
folders: FolderRegistry::from_env(),
|
||||
folder_photos: FolderStore::from_env(),
|
||||
folder_media: FolderMediaStore::from_env(),
|
||||
posts: PostStore::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,25 +100,12 @@ struct Inner {
|
||||
}
|
||||
|
||||
/// 定位结果:城市名 + 和风用的 "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)解析出的签名密钥
|
||||
@ -236,26 +223,18 @@ impl WeatherService {
|
||||
}
|
||||
|
||||
/// 取天气:先查缓存,再查额度,最后才调上游。注意全程不跨 await 持锁。
|
||||
///
|
||||
/// `front` 为前端 AMap.Geolocation 传来的定位(坐标 + 城市):
|
||||
/// - 有:直接用,**不调高德**(也不消耗高德额度),只调和风。
|
||||
/// - 无:退回高德 IP 定位兜底(需 `AMAP_KEY`)。
|
||||
pub async fn get(&self, ip: &str, front: Option<Located>) -> WeatherOutcome {
|
||||
/// 定位只使用访问者 IP,不向浏览器请求定位权限。
|
||||
pub async fn get(&self, ip: &str) -> WeatherOutcome {
|
||||
if self.qweather.is_none() {
|
||||
return WeatherOutcome::NotConfigured;
|
||||
}
|
||||
// 没有前端坐标时才依赖高德 IP 定位,故此时才要求 AMAP_KEY。
|
||||
if front.is_none() && self.amap_key.is_none() {
|
||||
if 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}"),
|
||||
};
|
||||
let cache_key = format!("ip:{ip}");
|
||||
|
||||
// 1) 命中新鲜缓存直接返回;否则预检所需额度(走 IP 兜底才需要高德)。
|
||||
// 1) 命中新鲜缓存直接返回;否则预检所需额度。
|
||||
{
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
if let Some(e) = inner.cache.get(&cache_key) {
|
||||
@ -263,7 +242,7 @@ impl WeatherService {
|
||||
return WeatherOutcome::Cached(e.data.clone());
|
||||
}
|
||||
}
|
||||
if front.is_none() && !inner.amap.has_budget() {
|
||||
if !inner.amap.has_budget() {
|
||||
return WeatherOutcome::QuotaExceeded { provider: "amap" };
|
||||
}
|
||||
if !inner.qweather.has_budget() {
|
||||
@ -271,16 +250,13 @@ impl WeatherService {
|
||||
}
|
||||
}
|
||||
|
||||
// 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}")),
|
||||
},
|
||||
// 2) 高德 IP 定位(成功才计数)。
|
||||
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) 和风实时天气(成功才计数)
|
||||
|
||||
95
docs/blog.md
Normal file
@ -0,0 +1,95 @@
|
||||
# 写博客 Blog(个人 Markdown 博客 + `#post/<id>` 路由)
|
||||
|
||||
登录后的个人博客:按登录用户隔离,可写 / 存 / 读 / 改 / 删 Markdown 文章。文章正文走
|
||||
Markdown 渲染,单篇文章有 `#post/<id>` 可分享路由(刷新/直链都能定位到该文章)。
|
||||
|
||||
**职责切分**:文章正文**按登录用户存服务器**(最多 200 篇/人、严格私有、JWT 鉴权),
|
||||
每篇一个 JSON 文件;`readingTime`/`excerpt` 等派生信息不入库,前端实时算。
|
||||
|
||||
设计方案见 [specs/2026-06-25-blog-writing-design.md](superpowers/specs/2026-06-25-blog-writing-design.md),
|
||||
实现计划见 [plans/2026-06-25-blog-writing.md](superpowers/plans/2026-06-25-blog-writing.md)。
|
||||
|
||||
## 入口与路由
|
||||
|
||||
博客页是开发者入口门(`dev_gate`)登录后的**默认页**(非 `#photo`、非特殊文件夹时)。
|
||||
列表↔文章的唯一真相源是 `App.tsx` 的地址栏 hash:
|
||||
|
||||
```
|
||||
浏览器(已登录,无 #photo / 文件夹 hash)
|
||||
└─ BlogPage:Hero + 文章列表
|
||||
点某篇文章 → 地址栏置 #post/<id>
|
||||
└─ App 解析 #post/<id> → 传 initialPostId 给 BlogPage → 渲染 PostArticle 全文
|
||||
未登录直接开 #post/<id> → 复用登录页,登录后落到该文章
|
||||
```
|
||||
|
||||
- 列表↔文章由 hash 驱动(可分享、可刷新、浏览器后退回列表);**编辑器浮层不进 URL**
|
||||
(不可分享,符合设计)。
|
||||
- 文章页「编辑」保存后,靠 `articleNonce` 作 `PostArticle` 的 `key` 强制重挂载,
|
||||
保证显示最新内容(否则 id 不变其 fetch effect 不重跑)。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
前端:
|
||||
- `Client/src/App.tsx` — 解析 `#post/<id>`:置 `postId`,传 `token`/`initialPostId` 给
|
||||
`BlogPage`;`#post/<id>` 未登录时也走登录页。
|
||||
- `Client/src/components/BlogPage.tsx` — 外壳(Hero / 三栏 / 页脚)+ 中心三态内容
|
||||
(列表 / 文章 / 编辑器),列表读真实数据,写死的占位数组已删除。
|
||||
- `Client/src/components/PostArticle.tsx` — 全文阅读页(Markdown 渲染、编辑入口、
|
||||
删除二次确认)。
|
||||
- `Client/src/components/PostEditor.tsx` — 写 / 改文章(标题 / 标签 / 正文 + Markdown 预览)。
|
||||
- `Client/src/components/Markdown.tsx` — `react-markdown` + `remark-gfm`,套站点暖色编辑风;
|
||||
默认不解析原始 HTML(未引 `rehype-raw`),天然防注入。
|
||||
- `Client/src/api/posts.ts` — 文章接口封装(见下)+ 派生工具 `readingTime`/`excerpt`。
|
||||
|
||||
后端(Rust):
|
||||
- `Server/src/posts.rs` — `PostStore`:按用户隔离到 `BLOG_DIR/<用户>/<hex>.json`,含
|
||||
路径安全(`valid_id` 仅小写 hex ≤32)、限额、`create` 加锁防并发越额;单测覆盖
|
||||
CRUD / 倒序 / 坏输入 / 上限 / 用户隔离 / 坏 id。
|
||||
- `Server/src/state.rs` — `AppState` 新增 `posts: PostStore` 字段。
|
||||
- `Server/src/routes/web.rs` — 文章接口(见下);CORS 放行 `PUT`。
|
||||
- `Server/src/main.rs` — `mod posts;`。
|
||||
- 复用 `Server/src/routes/extract.rs` 的 `AuthUser` 提取器、`photos::sanitize_user`。
|
||||
|
||||
## 接口(均经 `AuthUser` 鉴权,按用户隔离)
|
||||
|
||||
| 方法 | 路径 | 行为 |
|
||||
|---|---|---|
|
||||
| GET | `/api/web/posts` | 列出当前用户文章(按 `createdAt` 倒序,`{ items:[Post], max:200 }`) |
|
||||
| POST | `/api/web/posts` | 新建(body `{ title, tag, body }`)→ 201 + `Post`;满 200 篇 409、超 64KB 413、标题空/标签过长 400 |
|
||||
| GET | `/api/web/posts/{id}` | 取单篇 `Post`(属主校验,非法/不存在 404) |
|
||||
| PUT | `/api/web/posts/{id}` | 更新 → 204(创建时间不变,更新时间刷新) |
|
||||
| DELETE | `/api/web/posts/{id}` | 删除 → 204(不存在 404) |
|
||||
|
||||
`Post` 字段 camelCase:`id, title, tag, body, createdAt, updatedAt`(秒级时间戳)。
|
||||
上限常量:`MAX_POSTS=200`、`MAX_BODY=64KiB`、标题 ≤200 字、标签 ≤40 字,标题 trim 后不得为空。
|
||||
|
||||
存储:`BLOG_DIR`(默认 `blog`,线上 `/opt/internet-project/blog/`)下每用户一子目录,
|
||||
每篇随机十六进制文件名 `<hex>.json`。读取已鉴权,文件名干无需密码学随机。
|
||||
|
||||
## 功能
|
||||
|
||||
- **写 / 改**:标题、标签、Markdown 正文;编辑器内「Write / Preview」切换实时预览。
|
||||
- **读**:全文 Markdown 渲染(标题/列表/引用/代码块/链接/图片/分隔线/GFM 表格等),
|
||||
顶部显示标签、日期、估算阅读时长。
|
||||
- **删**:文章页二次确认(「删除」→「确认删除」)。
|
||||
- **列表**:最新在前,每篇卡片显示日期、标签、摘要(剥除 Markdown 标记)、阅读时长;
|
||||
空态引导「写文章」。
|
||||
- **派生**:`readingTime`(~200 字/分钟,最少 1 分钟)、`excerpt`(剥标记取前 80 字)
|
||||
均前端实时算,不入库。
|
||||
|
||||
## 实现注记
|
||||
|
||||
- **路由真相源单一**:列表↔文章统一由 `App.tsx` 经 hash 决定(`initialPostId`),
|
||||
`BlogPage` 仅管编辑器浮层,避免双方争用 URL。
|
||||
- **就地编辑刷新**:`PostArticle` 的 fetch effect 依赖 `[token, id]`,id 不变时不重拉;
|
||||
故编辑保存后 bump `articleNonce` 并用作 `key` 强制重挂载。
|
||||
- **Markdown code 边界**:以 `language-` 类名区分行内/块级,未指定语言的围栏块按行内样式
|
||||
渲染(写 ` ```lang ` 即按块级)——个人博客可接受,不引额外判定增复杂度。
|
||||
- **防注入**:未引 `rehype-raw`,原始 HTML 不解析;正文区 `data-selectable` 放开选择以便复制。
|
||||
|
||||
## 线上访问
|
||||
|
||||
`http://8.130.143.54:8548/`(开发者入口门登录后即默认进博客页)。博客引入后端改动 →
|
||||
走**全量重部署**(重编 Rust → 传二进制 → `restart_rust.sh` 重启),非 frontend-only。
|
||||
存储目录 `BLOG_DIR` 默认 `blog`(即 `/opt/internet-project/blog/`),首次写文章自动建
|
||||
`blog/<用户>/`。复用现有 `weather.env` 的 `DATABASE_URL`(登录校验)与 `JWT_SECRET`(token 签名)。
|
||||
@ -72,6 +72,10 @@
|
||||
- **背景**:软木板 / 黑板 / 白墙 / 牛皮纸,工具栏循环切换。
|
||||
- **上传**:按钮选择 / 拖入画布 / 剪贴板粘贴(`FileReader` 读 Base64 → 传服务器);
|
||||
满 10 张前端拦下、服务器 409 兜底。
|
||||
- **特殊文件夹视频(未实现,规划中)**:当前代码和线上能力仍是图片照片墙;特殊文件夹
|
||||
(`#<文件夹名>`)后续将支持上传图片或视频。视频上传后先显示
|
||||
「thinking」占位卡,服务器用 `ffmpeg` 压缩成 3M 带宽友好的 MP4 后自动变为
|
||||
播放器。个人 `#photo` 暂不开放视频上传。不提供显式视频下载按钮。
|
||||
- **历史**:撤销/重做 Ctrl+Z / Ctrl+Y,最多 30 步。
|
||||
- **持久化**:图片存服务器;**排版/装饰**操作后防抖 1s 自动写 `localStorage`,key
|
||||
`photo-wall-state`(`{ bgIndex, decorations, photoLayout }`,`photoLayout` 按服务器
|
||||
@ -94,3 +98,7 @@
|
||||
(重编 Rust → 传二进制 → `restart_rust.sh` 重启),非 frontend-only。需在服务器
|
||||
`weather.env` 配 `JWT_SECRET`;首次上传自动建 `photo-wall/<用户>/`。**因 token 格式
|
||||
从旧的时间戳哈希变为 JWT,上线后用户需重新登录一次。**
|
||||
|
||||
特殊文件夹视频媒体目前仅有设计与实现计划,尚未落到当前代码。规划见
|
||||
[specs/2026-06-29-special-folder-video-media-design.md](superpowers/specs/2026-06-29-special-folder-video-media-design.md);
|
||||
实现后依赖服务器安装 `ffmpeg`,后端改动需全量重部署。
|
||||
|
||||
@ -1,34 +1,24 @@
|
||||
# 起始页:天气 + 定位(已实现)
|
||||
|
||||
导航起始页问候语下方展示「天气图标 · 城市 · 天气 · 气温」。
|
||||
**定位优先用前端高德 `AMap.Geolocation`(能 GPS 就 GPS),失败退回后端高德 IP 定位;
|
||||
天气始终来自和风实时天气(私钥只在后端)。**
|
||||
**前端不请求浏览器定位权限。定位只由后端按访问 IP 调高德 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= → 天气文字 / 气温 / 图标码
|
||||
└─ GET /api/web/weather (后端)
|
||||
├─ 高德 /v3/ip?ip=<访问者IP> → 城市名 + 城市中心坐标
|
||||
├─ 查不到位置 → 返回 failed,前端不展示天气
|
||||
└─ 查到位置 → 和风 /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`)。
|
||||
- 后端:`Server/src/weather.rs`(服务 + 限流 + 缓存,`get()` 只接收访问 IP)、
|
||||
路由 `Server/src/routes/web.rs` 的 `/api/web/weather`(不接收前端位置参数)。
|
||||
- 前端:
|
||||
- `Client/src/lib/amap.ts`:加载高德 JSAPI + `AMap.Geolocation`,封装 `locate()`
|
||||
- `Client/src/api/weather.ts`、`Client/src/hooks/useWeather.ts`(先定位再拉取,30 分钟刷新)
|
||||
- `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`:天气文字旁的静态图标
|
||||
@ -39,33 +29,19 @@
|
||||
- **高德 300 次/天、和风 900 次/天**,各自独立计数,**北京时间 0 点归零**。
|
||||
- 任一服务商额度用尽 → 接口返回 `{ ok:false, reason:"quota_exceeded", provider }`,
|
||||
前端显示「天气请求次数已用完,明日 0 点恢复」。
|
||||
- **缓存 10 分钟**:前端带坐标时按 `坐标` 聚合缓存,未带时按访问者 `IP` 缓存;
|
||||
前端每 30 分钟刷新一次,正常用量远低于额度。
|
||||
- **缓存 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 兜底路径需要,前端总能定位时可不配):
|
||||
缺高德 REST Key 时接口返回 `not_configured`,前端静默不展示):
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `AMAP_KEY` | 兜底必填 | 高德 **Web服务(REST)** 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` |
|
||||
|
||||
@ -260,6 +260,56 @@ frontend-only):重编 Rust → 传二进制 → `restart_rust.sh` 重启。
|
||||
- `PHOTO_DIR`(可选,默认 `photo-wall`,即 `/opt/internet-project/photo-wall/`)——
|
||||
图片按用户存到其下子目录,首次上传自动创建。
|
||||
|
||||
**特殊文件夹视频媒体(规划,后端改动 + ffmpeg 运行时依赖):**
|
||||
特殊文件夹支持视频上传后,服务器需要安装 `ffmpeg` 并确保 Rust 服务进程能在 `PATH` 中调用。
|
||||
这次改动会新增 `/api/web/folder/{name}/media*` 接口和 multipart 上传,需**全量重部署**:
|
||||
重编 Rust → 传二进制和前端 `dist/*` → `restart_rust.sh` 重启。
|
||||
|
||||
服务器安装检查:
|
||||
```bash
|
||||
ssh root@8.130.143.54 "ffmpeg -version | head -1"
|
||||
```
|
||||
|
||||
如未安装,按服务器系统包管理器安装,例如 Debian/Ubuntu:
|
||||
```bash
|
||||
ssh root@8.130.143.54 "apt-get update && apt-get install -y ffmpeg"
|
||||
```
|
||||
|
||||
如果通过 Nginx 的 80 端口访问线上站点,还要把反代请求体上限放大到与 Rust
|
||||
multipart 路由一致,否则 30MB 级视频会先被 Nginx 默认上限拦截并返回 413:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 700m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8548;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
修改后验证并重载:
|
||||
```bash
|
||||
ssh root@8.130.143.54 "nginx -t && systemctl reload nginx"
|
||||
```
|
||||
|
||||
转码目标固定为 720p 档、24fps、视频约 900kbps、音频 96kbps、MP4 faststart,
|
||||
用于适配 3M 带宽播放。不提供显式视频下载入口;`controlsList="nodownload"` 只是 UI 降噪,
|
||||
不是防下载安全边界。
|
||||
|
||||
**写博客(2026-06-27,后端改动):**
|
||||
个人博客(登录后默认页,`#post/<id>` 文章路由,详见 [blog.md](../../blog.md))引入了后端
|
||||
改动(新增 `posts.rs` + 5 个 `/api/web/posts*` 接口、CORS 放行 PUT),需**全量重部署**
|
||||
(重编 Rust → 传二进制 → `restart_rust.sh` 重启),非 frontend-only。新增一个可选运行时配置:
|
||||
- `BLOG_DIR`(可选,默认 `blog`,即 `/opt/internet-project/blog/`)—— 文章按用户存到其下
|
||||
子目录,每篇一个 `<hex>.json`,首次写文章自动创建。
|
||||
- 复用现有 `weather.env` 的 `DATABASE_URL`(登录校验)与 `JWT_SECRET`(token 签名),
|
||||
无需新增密钥。
|
||||
|
||||
> Startpage backgrounds: large PNG/JPG source images are pre-compressed to WebP
|
||||
> (quality ~82) before building — keeps each background under ~600 KB for the
|
||||
> 3 Mbps server link. Two sets live under `Client/src/assets/startpage/`:
|
||||
|
||||
@ -2,6 +2,12 @@
|
||||
|
||||
> **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.
|
||||
|
||||
> **状态(2026-06-27):✅ 全部 7 个任务完成。** `cargo test` 17 passed、前端 `tsc -b` +
|
||||
> `npm run build` 通过,并已全量重部署到生产 `http://8.130.143.54:8548` 并跑通端到端
|
||||
> CRUD(登录 → 列表 → 增/查/改/删,测试数据已清理)。功能文档见 [../../blog.md](../../blog.md)。
|
||||
> **与计划的唯一偏差**:Task 1 给 `Post` 补了 `derive(PartialEq, Eq)`——测试里对
|
||||
> `Result<Post, _>` 用 `assert_eq!` 需要它,原计划骨架漏了,否则测试无法编译。
|
||||
|
||||
**Goal:** 把博客页从写死的占位数组变成按用户隔离、可写/存/读/改/删的 Markdown 个人博客,文章走 `#post/<id>` 可分享路由。
|
||||
|
||||
**Architecture:** 后端新增文件存储 `PostStore`(仿 `PhotoStore`,`blog/<user>/<id>.json`)+ 5 个 `AuthUser` 鉴权接口。前端新增 `api/posts.ts`、`Markdown`(react-markdown)、`PostEditor`、`PostArticle`,并改造 `BlogPage` 为外壳 + 三态中心内容。`App.tsx` 识别 `#post/<id>` 作为列表↔文章的唯一真相源,`BlogPage` 只管编辑器浮层。
|
||||
@ -41,7 +47,7 @@
|
||||
- `pub enum PostError { Full, TooLarge, BadInput, BadUser, NotFound, Io }`
|
||||
- `pub fn valid_id(&str) -> bool`、常量 `MAX_POSTS`、`MAX_BODY`、`MAX_TITLE`、`MAX_TAG`
|
||||
|
||||
- [ ] **Step 1: 写失败测试** —— 新建 `Server/src/posts.rs`,先放完整模块代码(实现 + 测试一起,TDD 在这一步用"先写测试块、再让它编译通过"的方式;因 Rust 模块需先能编译,按下面顺序:先贴实现骨架使其编译,再贴测试)。为符合 TDD,**先只写测试模块**(实现项暂留 `todo!()` 骨架),运行确认失败。
|
||||
- [x] **Step 1: 写失败测试** —— 新建 `Server/src/posts.rs`,先放完整模块代码(实现 + 测试一起,TDD 在这一步用"先写测试块、再让它编译通过"的方式;因 Rust 模块需先能编译,按下面顺序:先贴实现骨架使其编译,再贴测试)。为符合 TDD,**先只写测试模块**(实现项暂留 `todo!()` 骨架),运行确认失败。
|
||||
|
||||
先写骨架 + 测试:
|
||||
|
||||
@ -266,12 +272,12 @@ mod tests {
|
||||
mod posts;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
- [x] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `cd Server && cargo test posts::`
|
||||
Expected: 编译通过但运行时 panic(`todo!()`),多个测试 FAIL。
|
||||
|
||||
- [ ] **Step 3: 用真实实现替换 `todo!()` 骨架**
|
||||
- [x] **Step 3: 用真实实现替换 `todo!()` 骨架**
|
||||
|
||||
把 `impl PostStore` 里五个方法体与文件末尾(`valid_id` 之后、`#[cfg(test)]` 之前)的私有辅助函数替换/补全为:
|
||||
|
||||
@ -408,12 +414,12 @@ fn new_stem() -> String {
|
||||
|
||||
注意:删掉原骨架里 `impl PostStore { ... }` 结尾那个 `}` 与方法体的 `todo!()`,确保 `validate`/`json_files` 等自由函数在 `impl` 块**之外**。
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
- [x] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `cd Server && cargo test posts::`
|
||||
Expected: PASS(7 个测试全绿)。`enforces_max_posts` 较慢(写 200 文件)可接受。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd Server && git add src/posts.rs src/main.rs
|
||||
@ -434,7 +440,7 @@ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
- Consumes: Task 1 的 `PostStore`、`Post`、`PostDraft`、`PostError`、`MAX_POSTS`。
|
||||
- Produces: HTTP 接口 `GET/POST /api/web/posts`、`GET/PUT/DELETE /api/web/posts/{id}`,响应形如 `{ items: Post[], max }` 与 `Post`。
|
||||
|
||||
- [ ] **Step 1: 接入 AppState**
|
||||
- [x] **Step 1: 接入 AppState**
|
||||
|
||||
`Server/src/state.rs`,在 `use crate::photos::PhotoStore;` 下加:
|
||||
|
||||
@ -455,7 +461,7 @@ use crate::posts::PostStore;
|
||||
posts: PostStore::from_env(),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: CORS 放行 PUT**
|
||||
- [x] **Step 2: CORS 放行 PUT**
|
||||
|
||||
`Server/src/routes/web.rs` 第 38 行 `.allow_methods([Method::GET, Method::POST, Method::DELETE])` 改为:
|
||||
|
||||
@ -463,7 +469,7 @@ use crate::posts::PostStore;
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 注册 posts 路由**
|
||||
- [x] **Step 3: 注册 posts 路由**
|
||||
|
||||
`Server/src/routes/web.rs` 的 `router()` 里,在 `let state_routes = ...` 之后、`Router::new()` 链之前加:
|
||||
|
||||
@ -482,7 +488,7 @@ use crate::posts::PostStore;
|
||||
.layer(cors)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 加 5 个 handler**
|
||||
- [x] **Step 4: 加 5 个 handler**
|
||||
|
||||
`Server/src/routes/web.rs` 顶部 `use` 区,在 `use crate::photos::{...};` 下加:
|
||||
|
||||
@ -576,12 +582,12 @@ async fn delete_post(
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 编译 + 跑全部测试**
|
||||
- [x] **Step 5: 编译 + 跑全部测试**
|
||||
|
||||
Run: `cd Server && cargo build && cargo test`
|
||||
Expected: 编译通过、全部测试 PASS(无新测试,但确认接线不破坏已有)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd Server && git add src/state.rs src/routes/web.rs
|
||||
@ -608,7 +614,7 @@ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
- `readingTime(body: string) => string`、`excerpt(body: string, max?) => string`
|
||||
- `type PostInput = { title: string; tag: string; body: string }`
|
||||
|
||||
- [ ] **Step 1: 写文件**
|
||||
- [x] **Step 1: 写文件**
|
||||
|
||||
```ts
|
||||
// 博客文章接口封装。基址与 photos.ts 一致:生产留空走同源相对路径。
|
||||
@ -705,12 +711,12 @@ export function excerpt(body: string, max = 80): string {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 类型检查**
|
||||
- [x] **Step 2: 类型检查**
|
||||
|
||||
Run: `cd Client && npx tsc -b`
|
||||
Expected: 无错误(新文件自洽,未被引用也应通过类型检查)。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Client && git add src/api/posts.ts
|
||||
@ -730,12 +736,12 @@ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
**Interfaces:**
|
||||
- Produces: `export function Markdown({ children }: { children: string })` —— 把 Markdown 字符串渲染为套了站点暖色编辑风的 React 节点。
|
||||
|
||||
- [ ] **Step 1: 装依赖**
|
||||
- [x] **Step 1: 装依赖**
|
||||
|
||||
Run: `cd Client && npm install react-markdown remark-gfm`
|
||||
Expected: `package.json` 的 `dependencies` 多出 `react-markdown` 与 `remark-gfm`;`npm install` 成功。
|
||||
|
||||
- [ ] **Step 2: 写组件**
|
||||
- [x] **Step 2: 写组件**
|
||||
|
||||
```tsx
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
@ -807,12 +813,12 @@ export function Markdown({ children }: { children: string }) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 类型检查**
|
||||
- [x] **Step 3: 类型检查**
|
||||
|
||||
Run: `cd Client && npx tsc -b`
|
||||
Expected: 无错误。若报 `Components` 导出路径不符,改为 `import ReactMarkdown from 'react-markdown'` + `import type { Components } from 'react-markdown'`。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Client && git add package.json package-lock.json src/components/Markdown.tsx
|
||||
@ -841,7 +847,7 @@ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
export function PostEditor(props: PostEditorProps)
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 写组件**
|
||||
- [x] **Step 1: 写组件**
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
@ -953,12 +959,12 @@ export function PostEditor({ token, post, onSaved, onCancel }: PostEditorProps)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 类型检查**
|
||||
- [x] **Step 2: 类型检查**
|
||||
|
||||
Run: `cd Client && npx tsc -b`
|
||||
Expected: 无错误(组件暂未被引用,类型自洽即可)。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Client && git add src/components/PostEditor.tsx
|
||||
@ -988,7 +994,7 @@ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
export function PostArticle(props: PostArticleProps)
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 写组件**
|
||||
- [x] **Step 1: 写组件**
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from 'react'
|
||||
@ -1116,12 +1122,12 @@ export function PostArticle({ token, id, onBack, onEdit, onDeleted }: PostArticl
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 类型检查**
|
||||
- [x] **Step 2: 类型检查**
|
||||
|
||||
Run: `cd Client && npx tsc -b`
|
||||
Expected: 无错误。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Client && git add src/components/PostArticle.tsx
|
||||
@ -1142,7 +1148,7 @@ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
- Consumes: `listPosts`、`readingTime`、`excerpt`、`Post`(Task 3);`PostEditor`(Task 5);`PostArticle`(Task 6)。
|
||||
- Produces: `BlogPage` 新签名 `{ username, token, initialPostId, onSignOut }`。
|
||||
|
||||
- [ ] **Step 1: 改 App.tsx —— 识别 post 路由并传参**
|
||||
- [x] **Step 1: 改 App.tsx —— 识别 post 路由并传参**
|
||||
|
||||
`Client/src/App.tsx` 改动如下。
|
||||
|
||||
@ -1206,7 +1212,7 @@ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
} else if (wantsPhoto || wantsFolder || postId) {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 改 BlogPage.tsx —— 外壳 + 三态中心内容**
|
||||
- [x] **Step 2: 改 BlogPage.tsx —— 外壳 + 三态中心内容**
|
||||
|
||||
整体替换 `Client/src/components/BlogPage.tsx` 为下面内容(保留原 Hero / 三栏 / 页脚视觉,删除写死的 `POSTS`,列表改读真实数据;`PostArticle`/`PostEditor` 渲染进中间玻璃纸):
|
||||
|
||||
@ -1478,12 +1484,12 @@ export function BlogPage({ username, token, initialPostId, onSignOut }: BlogPage
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 类型检查 + 构建**
|
||||
- [x] **Step 3: 类型检查 + 构建**
|
||||
|
||||
Run: `cd Client && npx tsc -b && npm run build`
|
||||
Expected: 类型检查无错误、`vite build` 成功。
|
||||
|
||||
- [ ] **Step 4: 端到端手工验证**
|
||||
- [x] **Step 4: 端到端手工验证**
|
||||
|
||||
启动后端(需 `DATABASE_URL`):`cd Server && cargo run`(建议设 `BLOG_DIR=./blog-dev`)。
|
||||
启动前端:`cd Client && npm run dev`,浏览器进登录入口、登录后到博客页。逐项确认:
|
||||
@ -1497,7 +1503,7 @@ Expected: 类型检查无错误、`vite build` 成功。
|
||||
7. 后退:从文章页点浏览器后退 → 回到列表。
|
||||
8. 隔离:换另一账号登录 → 看不到上一个账号的文章。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd Client && git add src/App.tsx src/components/BlogPage.tsx
|
||||
|
||||
2022
docs/superpowers/plans/2026-06-29-special-folder-video-media.md
Normal file
@ -2,13 +2,15 @@
|
||||
|
||||
> 在已有照片墙(`#photo` 个人墙,按用户存图)之上,增加一类**共享的、命名的公共相册**:
|
||||
> 通过地址栏 `#<文件夹名>` 进入,名单内的特定用户登录后可共同上传/浏览/删除,
|
||||
> 复用照片墙的互动界面,无单文件夹张数上限,但总量超 1 GiB 时给管理员页面提示。
|
||||
> 复用照片墙的互动界面。特殊文件夹不使用个人 `#photo` 的 10 张上限,但当前实现有
|
||||
> `MAX_ITEMS = 100` 的总元素上限(图片 + 装饰);总字节超 1 GiB 时给管理员页面提示。
|
||||
>
|
||||
> 前置阅读:[photo-wall.md](../../photo-wall.md)(照片墙服务器存图)、
|
||||
> [photo-wall-server-storage-design.md](2026-06-24-photo-wall-server-storage-design.md)、
|
||||
> [auth-login-gate.md](../../auth-login-gate.md)(登录 + dev_gate 入口门)。
|
||||
>
|
||||
> 状态:需求文档,**待实现**(留作后续 writing-plans 的输入)。
|
||||
> 状态:基础特殊文件夹相册已实现;本文件保留设计依据并同步当前约束。视频媒体仍是后续规划,
|
||||
> 见 §10。
|
||||
|
||||
## 1. 目标与关键决策
|
||||
|
||||
@ -18,10 +20,10 @@
|
||||
| 写权限 | 名单内**任何登录用户**可上传/删除(协作式:谁删大家都没了) |
|
||||
| 访问控制 | **按文件夹的用户白名单**,名单只存服务器配置文件 |
|
||||
| 界面 | **复用 `PhotoWallPage`**(拖拽/装饰/导出),去掉 10 张上限 |
|
||||
| 张数上限 | 无 |
|
||||
| 张数上限 | 不使用个人墙 10 张上限;当前受 `MAX_ITEMS = 100` 总元素上限约束 |
|
||||
| 配额 | **1 GiB 软阈值**:不拦上传,超了在页面给管理员看警告 |
|
||||
| 管理员 | 写死用户名 `cc`(可访问所有文件夹 + 看配额警告) |
|
||||
| 文件夹注册 | 服务器配置文件 `special_folders.txt`,前端不写死,加一行即生效 |
|
||||
| 文件夹注册 | 服务器配置文件 `special_folders.txt`,前端不写死;当前实现加一行后需重启 Rust 服务 |
|
||||
| 登录 | 同一套 `ccuser`(复用现有登录 + JWT) |
|
||||
|
||||
非目标(YAGNI):每文件夹独立配额数值、上传者归属/审计、回收站、文件夹的网页管理后台、
|
||||
@ -61,8 +63,9 @@ family cc,alice,bob
|
||||
trip2026 cc,carol
|
||||
```
|
||||
|
||||
- 后端解析为 `Map<文件夹名, Set<用户名>>`。**每次校验时读文件**(文件很小,开销可忽略),
|
||||
这样新增/改名单**即时生效、无需重启**——与 `dev_gate` 文件模式同理。
|
||||
- 后端解析为 `Map<文件夹名, Set<用户名>>`。当前实现是在服务启动时读取配置文件;
|
||||
新增或修改名单后需要重启 Rust 服务才会生效。若后续要支持热更新,应改为每次校验读文件
|
||||
或增加配置缓存刷新机制。
|
||||
- 文件夹名合法性:复用用户名级别的字符集白名单 `[A-Za-z0-9_-]`、长度 1–64(既作目录名安全,
|
||||
又避免与 `#photo` 等保留字冲突——`photo` 不应出现在注册表里)。
|
||||
|
||||
@ -71,7 +74,8 @@ trip2026 cc,carol
|
||||
- 独立根目录 `SPECIAL_DIR`(默认 `special-folders`,线上 `/opt/internet-project/special-folders/`),
|
||||
**与个人墙的 `PHOTO_DIR/<用户>/` 物理隔开**,杜绝文件夹名与用户名撞车。
|
||||
- 每文件夹一子目录 `SPECIAL_DIR/<文件夹名>/`,每图随机 hex 文件名(复用 `photos.rs` 的
|
||||
`valid_id`、`parse_data_url`、随机文件名、路径安全逻辑)。**无张数上限**。
|
||||
`valid_id`、`parse_data_url`、随机文件名、路径安全逻辑)。不使用个人墙 10 张上限,
|
||||
但上传会受 `MAX_ITEMS = 100` 总元素上限约束。
|
||||
- 单图大小上限沿用 `MAX_BYTES = 8 MiB`。
|
||||
|
||||
## 4. 后端接口
|
||||
@ -82,8 +86,8 @@ trip2026 cc,carol
|
||||
| 方法 | 路径 | 鉴权 | 行为 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/web/folder` | 无(登录前) | body `{name}`;该名是否注册 → `{ok}` |
|
||||
| GET | `/api/web/folder/{name}/photos` | AuthUser + 白名单 | `{ items:[{id}], totalBytes, overQuota }` |
|
||||
| POST | `/api/web/folder/{name}/photos` | AuthUser + 白名单 | 上传 `{dataUrl}`,无张数上限 |
|
||||
| GET | `/api/web/folder/{name}/photos` | AuthUser + 白名单 | `{ items:[{id}], max, totalBytes, overQuota }` |
|
||||
| POST | `/api/web/folder/{name}/photos` | AuthUser + 白名单 | 上传 `{dataUrl}`,受 `MAX_ITEMS` 总元素上限约束 |
|
||||
| GET | `/api/web/folder/{name}/photos/{id}` | AuthUser + 白名单 | 流式返回字节 |
|
||||
| DELETE | `/api/web/folder/{name}/photos/{id}` | AuthUser + 白名单 | 删除 |
|
||||
|
||||
@ -92,12 +96,14 @@ trip2026 cc,carol
|
||||
2. 登录用户既不在该文件夹白名单、又不是管理员 `cc` → `403`。
|
||||
3. 通过 → 执行。
|
||||
|
||||
**错误码**:未注册 `404`、无权 `403`、单图超 8MiB `413`、类型不在白名单 `415`、
|
||||
`{id}` 非法/不存在 `404`、IO `500`。上传**不因配额被拒**(软阈值)。
|
||||
**错误码**:未注册 `404`、无权 `403`、总元素达到 `MAX_ITEMS` 时 `409`、单图超 8MiB
|
||||
`413`、类型不在白名单 `415`、`{id}` 非法/不存在 `404`、IO `500`。上传**不因 1 GiB
|
||||
软配额被拒**(只提示管理员)。
|
||||
|
||||
**配额字段**(GET list 返回):
|
||||
- `totalBytes`:该文件夹当前总字节(遍历目录累加文件大小)。
|
||||
- `overQuota`:`totalBytes > 1 GiB`(`1024^3`)即 `true`。
|
||||
- `max`:当前总元素上限,现为 `MAX_ITEMS = 100`。
|
||||
|
||||
## 5. 管理员配额提示
|
||||
|
||||
@ -115,10 +121,12 @@ trip2026 cc,carol
|
||||
- **数据源**:图片走 `/api/web/folder/{name}/photos` 系列接口(按文件夹),而非按用户。
|
||||
建议把图片接口抽象成一个"源"参数(个人墙 = 用户源,特殊文件夹 = `folder:<name>` 源),
|
||||
让 `PhotoWallPage` 通过 props 接收"如何 list/upload/get/delete",避免组件内写死。
|
||||
- **无 10 张上限**:上传不做客户端张数拦截(个人墙保留 10 上限)。
|
||||
- **排版/装饰各人各存浏览器**:图片共享,但每个浏览器的摆放/装饰是各自的。localStorage key
|
||||
**按文件夹区分**:`photo-wall-state:folder:<name>`(个人墙仍用 `photo-wall-state`),
|
||||
互不串。`photoLayout` 仍按服务器图片 id 关联。
|
||||
- **无个人 10 张上限,但有总元素上限**:个人墙仍是 10 张;特殊文件夹由服务器返回 `max`
|
||||
并按图片 + 装饰总数限制,当前为 100 条。
|
||||
- **排版/装饰服务器共享,浏览器保留本地副本**:图片共享,背景、装饰和图片排版也通过
|
||||
`/api/web/folder/{name}/state` 保存到服务器,所有有权用户看到同一份墙面状态。
|
||||
浏览器仍写 `localStorage` key `photo-wall-state:folder:<name>` 作为本地副本/兜底;
|
||||
`photoLayout` 按服务器图片 id 关联。
|
||||
- **删除是共享副作用**:任何有权用户删图 → 服务器文件没了 → 其他人下次加载即不见
|
||||
(各自浏览器里该 id 的排版条目成为孤儿,加载时被忽略,无害)。
|
||||
- **管理员警告条**:见 §5。
|
||||
@ -126,28 +134,36 @@ trip2026 cc,carol
|
||||
|
||||
## 7. 部署与配置
|
||||
|
||||
- 后端改动 → **全量重部署**(同照片墙服务器存图流程)。
|
||||
- 后端改动 → **全量重部署**(同照片墙服务器存图流程)。修改特殊文件夹注册表后,当前实现
|
||||
也需要重启 Rust 服务重新读取配置。
|
||||
- 新增运行时配置:
|
||||
- `SPECIAL_DIR`(可选,默认 `special-folders`)。
|
||||
- `SPECIAL_FOLDERS_FILE`(可选,默认 `special_folders.txt`)——线上放
|
||||
`/opt/internet-project/special_folders.txt`,内容即文件夹→用户名单。
|
||||
- 新增一个特殊文件夹的运维动作:在 `special_folders.txt` 加一行(文件夹名 + 允许用户),
|
||||
即时生效;首次上传自动建 `special-folders/<名字>/` 目录。**不改代码、不重部署前端。**
|
||||
重启 Rust 服务后生效;首次上传自动建 `special-folders/<名字>/` 目录。**不改代码、不重部署前端。**
|
||||
|
||||
## 8. 复用与新增一览
|
||||
|
||||
- **复用**:`auth.rs`(JWT)、`routes/extract.rs`(`AuthUser`)、`photos.rs` 的
|
||||
`valid_id`/`parse_data_url`/随机文件名/路径安全、前端 `PhotoWallPage`/`api/photos.ts` 思路。
|
||||
- **新增**(后端):特殊文件夹注册表解析(读 `special_folders.txt`)、按文件夹的存储视图与
|
||||
- **新增**(后端):特殊文件夹注册表解析(启动时读 `special_folders.txt`)、按文件夹的存储视图与
|
||||
配额统计、`/api/web/folder` 存在性接口、4 个 `/api/web/folder/{name}/photos*` 接口、
|
||||
访问控制(白名单 + 管理员放行)。
|
||||
- **新增/改动**(前端):`App.tsx` 入口路由加"特殊文件夹"分支、按文件夹的图片 API、
|
||||
`PhotoWallPage` 接受"数据源 + 是否限额 + 是否管理员 + 配额状态"等 props、管理员警告条、
|
||||
按文件夹的 localStorage key。
|
||||
|
||||
## 9. 待实现时需在计划里钉死的点
|
||||
## 9. 后续优化点
|
||||
|
||||
- `PhotoWallPage` 的"数据源"抽象接口签名(list/upload/get/delete 的统一形状)。
|
||||
- 注册表文件解析的容错(坏行跳过、重复文件夹名取首条还是报错)。
|
||||
- 注册表热更新:当前启动时读取,若需要运维即时生效,应改为每次校验读文件或增加刷新机制。
|
||||
- API JSON 命名一致性:设计层建议保持前端友好的 camelCase 字段,例如 `totalBytes`、
|
||||
`overQuota`、`bgIndex`、`photoLayout`;实现和客户端应保持一致。
|
||||
- `totalBytes` 统计的性能(目录文件数大时遍历开销)——当前规模可接受,必要时缓存。
|
||||
- 管理员常量的位置与日后可配置化的留口。
|
||||
|
||||
## 10. 后续扩展:视频媒体
|
||||
|
||||
特殊文件夹的视频上传、异步转码、媒体接口和 `mediaLayout` 兼容策略由
|
||||
[2026-06-29-special-folder-video-media-design.md](2026-06-29-special-folder-video-media-design.md)
|
||||
接续设计。个人 `#photo` 不在该扩展范围内。
|
||||
|
||||
@ -0,0 +1,247 @@
|
||||
# 设计:特殊文件夹视频媒体(上传后服务器压缩)
|
||||
|
||||
> 在特殊文件夹共享照片墙(`#<文件夹名>`)里支持视频作为墙上媒体元素。
|
||||
> 视频上传后先显示处理中占位卡,服务器用 `ffmpeg` 压缩为低带宽 MP4,完成后在墙上播放。
|
||||
> 本设计只开放给特殊文件夹;个人 `#photo` 暂不开放视频入口。
|
||||
>
|
||||
> 状态:规划中,当前代码尚未实现 `/media*` 接口、视频上传、转码和视频卡片渲染。
|
||||
|
||||
前置阅读:
|
||||
- [photo-wall.md](../../photo-wall.md)
|
||||
- [2026-06-24-special-folders-design.md](2026-06-24-special-folders-design.md)
|
||||
- [2026-06-24-photo-wall-server-storage-design.md](2026-06-24-photo-wall-server-storage-design.md)
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
目标:
|
||||
- 特殊文件夹支持图片和视频混合展示。
|
||||
- 视频像照片一样可以上传、拖拽、缩放、置顶/置底、删除。
|
||||
- 视频上传后不阻塞页面等待转码;墙上立即出现“thinking”风格的处理中卡片。
|
||||
- 服务器将常见视频格式统一压缩为适合 3M 带宽播放的 MP4。
|
||||
- 不提供显式视频下载入口,不添加下载按钮;这不是防下载安全边界。
|
||||
|
||||
非目标:
|
||||
- 个人 `#photo` 视频上传。
|
||||
- 多清晰度转码、自适应码率、HLS/DASH。
|
||||
- 视频封面生成、时间轴截图、在线播放统计。
|
||||
- 大视频断点续传。
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
前端把 `PhotoWallPage` 从“照片元素”扩展为“媒体元素”,新增 `video` 类型。特殊文件夹使用新的媒体 API 加载图片和视频;图片仍按现有视觉展示,视频按状态渲染为处理中卡片、失败卡片或 `<video controls preload="metadata">` 播放器。
|
||||
|
||||
后端把特殊文件夹存储从“图片列表”升级为“媒体列表”。图片可以同步保存并立即进入 `ready` 状态。视频上传后先写入临时文件,创建状态文件,后台调用 `ffmpeg` 转码;转码完成后只保留压缩后的 `.mp4` 和状态文件,删除原始临时文件。
|
||||
|
||||
`ffmpeg -movflags +faststart` 只用于优化浏览器在线播放体验:把 MP4 索引移动到文件开头,让 `<video>` 不必等完整文件下载完才开始播放。UI 不提供显式下载按钮;浏览器原生视频控件仍需加 `controlsList="nodownload"` 降低下载入口暴露,但这不构成防下载或防复制安全边界。
|
||||
|
||||
## 3. 媒体模型
|
||||
|
||||
特殊文件夹媒体项:
|
||||
|
||||
```ts
|
||||
type MediaType = 'image' | 'video'
|
||||
type MediaStatus = 'ready' | 'processing' | 'failed'
|
||||
|
||||
interface FolderMediaItem {
|
||||
id: string
|
||||
type: MediaType
|
||||
status: MediaStatus
|
||||
}
|
||||
```
|
||||
|
||||
服务器为视频维护状态文件,建议路径为 `SPECIAL_DIR/<folder>/media/<id>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4e5f60708",
|
||||
"type": "video",
|
||||
"status": "processing",
|
||||
"originalName": "trip.mov",
|
||||
"createdAt": "2026-06-29T12:34:56Z",
|
||||
"outputFile": "a1b2c3d4e5f60708.mp4",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
状态语义:
|
||||
- `processing`:原视频已接收,后台正在转码。前端显示处理中占位卡并轮询状态。
|
||||
- `ready`:媒体可通过 `GET /media/{id}` 读取。图片返回原图,视频返回压缩后的 MP4。
|
||||
- `failed`:转码失败。前端显示失败卡片,允许删除后重传。
|
||||
|
||||
## 4. 后端接口
|
||||
|
||||
新增特殊文件夹媒体接口,旧 `/photos*` 接口先保留,前端特殊文件夹切到 `/media*`:
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 行为 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/web/folder/{name}/media` | AuthUser + 白名单 | 列出图片和视频,返回 `{ items, max, totalBytes, overQuota }` |
|
||||
| POST | `/api/web/folder/{name}/media` | AuthUser + 白名单 | multipart 上传一个图片或视频 |
|
||||
| GET | `/api/web/folder/{name}/media/{id}` | AuthUser + 白名单 | 读取 `ready` 媒体字节 |
|
||||
| GET | `/api/web/folder/{name}/media/{id}/status` | AuthUser + 白名单 | 读取单个媒体状态 |
|
||||
| DELETE | `/api/web/folder/{name}/media/{id}` | AuthUser + 白名单 | 删除媒体文件、临时文件和状态文件 |
|
||||
|
||||
上传响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4e5f60708",
|
||||
"type": "video",
|
||||
"status": "processing"
|
||||
}
|
||||
```
|
||||
|
||||
错误码:
|
||||
- `403`:用户无权访问该特殊文件夹。
|
||||
- `404`:文件夹或媒体不存在。
|
||||
- `409`:达到特殊文件夹元素上限。
|
||||
- `413`:上传体或文件过大。
|
||||
- `415`:不支持的媒体格式。
|
||||
- `500`:IO 或转码任务启动失败。
|
||||
|
||||
## 5. 上传与格式
|
||||
|
||||
视频上传使用 multipart,而不是 data URL。原因是视频文件可能很大,data URL 会额外增加约三分之一体积,并迫使前后端把大文件整体放进内存。multipart 更适合 500MB 级文件。
|
||||
|
||||
支持的输入:
|
||||
- 图片:沿用现有 `jpg/png/gif/webp`。
|
||||
- 视频:支持常见格式,至少包括 `mp4/mov/m4v/webm/avi/mkv`。
|
||||
|
||||
约束:
|
||||
- 单个原始视频最大 500MB。
|
||||
- 单个图片仍沿用 8MiB 上限。
|
||||
- 同一个特殊文件夹仍受总元素上限约束,图片、视频、装饰合计不超过当前 `MAX_ITEMS`。
|
||||
- 视频上传不限制时长。
|
||||
|
||||
## 6. 视频转码
|
||||
|
||||
运行时依赖:服务器必须安装 `ffmpeg`,并保证 Rust 服务进程可以调用。
|
||||
|
||||
转码目标适配 3M 服务器带宽:
|
||||
- 容器:MP4。
|
||||
- 视频编码:H.264。
|
||||
- 音频编码:AAC。
|
||||
- 输出限制到 720p 档:横屏最大 1280x720,竖屏最大 720x1280,保持原方向,不放大小视频。
|
||||
- 帧率限制到 24fps。
|
||||
- 视频码率目标约 900kbps,缓冲 1800kbps。
|
||||
- 音频码率 96kbps。
|
||||
- `+faststart` 优化在线播放。
|
||||
|
||||
建议 ffmpeg 参数方向:
|
||||
|
||||
```bash
|
||||
ffmpeg -y -i input \
|
||||
-vf "scale='min(iw,if(gte(iw,ih),1280,720))':'min(ih,if(gte(iw,ih),720,1280))':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=24" \
|
||||
-c:v libx264 -preset veryfast -crf 30 -maxrate 900k -bufsize 1800k \
|
||||
-c:a aac -b:a 96k \
|
||||
-movflags +faststart output.mp4
|
||||
```
|
||||
|
||||
实现计划必须用横屏、竖屏和近正方形视频测试该 filter,确认不会放大小视频,且输出不会超过
|
||||
1280x720 或 720x1280 的目标边界。
|
||||
|
||||
并发策略:
|
||||
- Rust 进程内视频转码并发限制为 1。
|
||||
- 上传请求只负责接收文件、创建状态、启动后台任务,然后立即返回。
|
||||
- 后台任务成功后把状态改为 `ready`,删除临时原视频。
|
||||
- 后台任务失败后把状态改为 `failed`,删除临时原视频,保留短错误摘要。
|
||||
|
||||
## 7. 前端交互
|
||||
|
||||
特殊文件夹工具栏把“上传照片”改为“上传媒体”。选择文件、拖拽文件都支持图片和视频;剪贴板仍只要求保持图片粘贴可用,视频粘贴不是本期目标。
|
||||
|
||||
渲染规则:
|
||||
- `image + ready`:沿用当前照片卡片。
|
||||
- `video + processing`:显示软木板风格占位卡,文案为“thinking”。
|
||||
- `video + ready`:显示带控件的 `<video controls preload="metadata">`,不自动播放。
|
||||
- `video + failed`:显示“视频处理失败”,允许右键删除。
|
||||
|
||||
视频卡片行为:
|
||||
- 支持拖拽、缩放、旋转、置顶/置底、删除。
|
||||
- 双击编辑说明文字,逻辑与照片 caption 一致。
|
||||
- 用户主动点击才播放,避免多个视频同时消耗 3M 带宽。
|
||||
- 不显示显式下载按钮;`<video>` 使用 `controlsList="nodownload"`。该属性只是 UI 降噪,
|
||||
不能当成安全控制。
|
||||
|
||||
轮询:
|
||||
- 上传视频返回 `processing` 后,前端创建本地元素并开始轮询 `/status`。
|
||||
- 轮询间隔建议 3 秒。
|
||||
- 状态变为 `ready` 后获取 object URL 并替换播放器。
|
||||
- 状态变为 `failed` 后停止轮询并显示失败卡。
|
||||
- 组件卸载时停止轮询并释放 object URL。
|
||||
|
||||
## 8. 布局与状态兼容
|
||||
|
||||
当前特殊文件夹共享状态使用 `photoLayout`。本功能升级为通用 `mediaLayout`:
|
||||
|
||||
```ts
|
||||
interface MediaLayout {
|
||||
x: number
|
||||
y: number
|
||||
rot: number
|
||||
scale: number
|
||||
z: number
|
||||
w: number
|
||||
caption: string
|
||||
}
|
||||
|
||||
interface WallState {
|
||||
bgIndex: number
|
||||
decorations: El[]
|
||||
mediaLayout: Record<string, MediaLayout>
|
||||
}
|
||||
```
|
||||
|
||||
兼容策略:
|
||||
- 前端读取状态时,如果存在 `mediaLayout`,优先使用。
|
||||
- 如果只有旧的 `photoLayout`,前端把它当作 `mediaLayout` 读取。
|
||||
- 保存状态时写 `mediaLayout`;可以同时保留 `photoLayout` 一段时间以兼容旧前端,但新前端不再依赖它。
|
||||
- 后端 state 接口必须同步迁移:`PUT /api/web/folder/{name}/state` 需要接受
|
||||
`mediaLayout` 和旧 `photoLayout`;`GET` 返回 `mediaLayout`,并在兼容期同时返回
|
||||
`photoLayout`。仅改前端类型不够。
|
||||
- 已有图片布局不丢失;新视频布局按同一媒体 id 保存。
|
||||
|
||||
## 9. 删除与清理
|
||||
|
||||
删除媒体时:
|
||||
- `ready image`:删除图片文件。
|
||||
- `ready video`:删除压缩后的 `.mp4` 和状态文件。
|
||||
- `processing video`:写入删除标记或直接删除状态文件;后台任务结束后发现状态不存在即清理输出。
|
||||
- `failed video`:删除状态文件和可能残留的临时文件。
|
||||
|
||||
列表接口不返回孤儿临时文件。启动时不要求恢复未完成任务;若服务重启导致任务中断,残留 `processing` 可以在下一次列表时标记为 `failed` 或由清理逻辑处理。
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
后端:
|
||||
- 媒体 MIME/扩展名识别。
|
||||
- 视频 id 与图片 id 的路径安全校验。
|
||||
- 状态文件 `processing -> ready -> failed` 读写。
|
||||
- 特殊文件夹权限:未注册 404、无权 403、有权通过。
|
||||
- 删除会清理成品、状态和临时文件。
|
||||
- ffmpeg 命令构造包含 720p、24fps、900k、96k、`+faststart`。
|
||||
- ffmpeg 缩放测试覆盖横屏、竖屏、正方形/近正方形和小尺寸输入,确认不超过目标尺寸且不放大。
|
||||
|
||||
前端:
|
||||
- 特殊文件夹图片上传和显示仍可用。
|
||||
- 上传视频后立即出现“thinking”占位。
|
||||
- 轮询到 `ready` 后替换为播放器。
|
||||
- `failed` 状态显示失败卡并可删除。
|
||||
- 视频拖拽、缩放、删除、caption、保存布局不破坏图片和装饰。
|
||||
- `<video>` 渲染包含 `controlsList="nodownload"`,且产品文案只承诺不提供下载入口,
|
||||
不承诺防下载。
|
||||
|
||||
端到端:
|
||||
- 在本地安装 `ffmpeg` 后上传一个小视频,确认转码完成、播放不卡、刷新后布局保留。
|
||||
- 生产部署后用特殊文件夹验证上传视频、等待处理、播放、删除。
|
||||
|
||||
## 11. 部署与文档
|
||||
|
||||
这是后端改动,需要全量重部署:重编 Rust、上传二进制和前端静态文件、重启 Rust 服务。
|
||||
|
||||
服务器新增运行时依赖:
|
||||
- `ffmpeg`。
|
||||
|
||||
文档需要更新:
|
||||
- `docs/photo-wall.md`:说明特殊文件夹支持视频媒体,个人墙暂不支持。
|
||||
- `docs/superpowers/plans/2026-06-18-deploy.md`:补充安装 `ffmpeg` 和视频转码部署注意事项。
|
||||
- `docs/superpowers/specs/2026-06-24-special-folders-design.md`:标注媒体接口和视频扩展已由本设计接续。
|
||||