feat: add folder media support

This commit is contained in:
cc 2026-07-09 23:55:17 +08:00
parent a52e2b92a4
commit 0245194fa3
28 changed files with 1345 additions and 400 deletions

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

View File

@ -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 */

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -247,9 +247,10 @@ export function BlogPage({ username, token, initialPostId, onSignOut }: BlogPage
<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>

View File

@ -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
/** 服务器媒体 idtype 为 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>
)

View File

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

View File

@ -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') },
],
},
]

View File

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

View File

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

View File

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

27
Server/Cargo.lock generated
View File

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

View File

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

View File

@ -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());
}
}

View File

@ -6,6 +6,7 @@
mod auth;
mod folders;
mod media;
mod monitor;
mod photos;
mod posts;

484
Server/src/media.rs Normal file
View 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"));
}
}

View File

@ -4,17 +4,19 @@ 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;
@ -22,7 +24,7 @@ use crate::routes::extract::AuthUser;
use crate::{
monitor::Stats,
state::AppState,
weather::{Located, WeatherOutcome},
weather::WeatherOutcome,
};
pub fn router(state: AppState) -> Router {
@ -53,6 +55,12 @@ 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));
@ -69,6 +77,7 @@ 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)
@ -143,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!({
@ -324,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")]
@ -418,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,
}
}
/* ════════════════════════════════════════════════════════════════
+
*/
@ -440,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
@ -457,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),

View File

@ -9,6 +9,7 @@ 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;
@ -32,6 +33,8 @@ pub struct AppState {
pub folders: FolderRegistry,
/// 特殊文件夹图片仓库(按文件夹隔离的磁盘目录)。
pub folder_photos: FolderStore,
/// 特殊文件夹媒体仓库(图片 + 视频)。
pub folder_media: FolderMediaStore,
/// 个人博客文章仓库(按用户隔离的磁盘目录)。
pub posts: PostStore,
}
@ -50,6 +53,7 @@ impl AppState {
photos: PhotoStore::from_env(),
folders: FolderRegistry::from_env(),
folder_photos: FolderStore::from_env(),
folder_media: FolderMediaStore::from_env(),
posts: PostStore::from_env(),
}
}

View File

@ -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) 和风实时天气(成功才计数)

View File

@ -72,9 +72,10 @@
- **背景**:软木板 / 黑板 / 白墙 / 牛皮纸,工具栏循环切换。
- **上传**:按钮选择 / 拖入画布 / 剪贴板粘贴(`FileReader` 读 Base64 → 传服务器);
满 10 张前端拦下、服务器 409 兜底。
- **特殊文件夹视频(规划)**:特殊文件夹(`#<文件夹名>`)将支持上传图片或视频;视频上传后先显示
「AI 正在压缩视频...」占位卡,服务器用 `ffmpeg` 压缩成 3M 带宽友好的 MP4 后自动变为
播放器。个人 `#photo` 暂不开放视频上传。不提供视频下载按钮。
- **特殊文件夹视频(未实现,规划中)**:当前代码和线上能力仍是图片照片墙;特殊文件夹
`#<文件夹名>`)后续将支持上传图片或视频。视频上传后先显示
「thinking」占位卡服务器用 `ffmpeg` 压缩成 3M 带宽友好的 MP4 后自动变为
播放器。个人 `#photo` 暂不开放视频上传。不提供显式视频下载按钮。
- **历史**:撤销/重做 Ctrl+Z / Ctrl+Y最多 30 步。
- **持久化**:图片存服务器;**排版/装饰**操作后防抖 1s 自动写 `localStorage`key
`photo-wall-state``{ bgIndex, decorations, photoLayout }``photoLayout` 按服务器
@ -98,6 +99,6 @@
`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`,后端改动需全量重部署。

View File

@ -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 定位);**等站点上了 HTTPSGPS 会自动开始生效,无需改代码**。前端定位彻底失败时,
> 后端再用 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` 会内置此 KeyJS Key 本就暴露在前端,靠高德控制台
**「安全域名」白名单**约束,不是机密)。
| 变量 | 必填 | 说明 |
|------|------|------|
| `VITE_AMAP_JS_KEY` | 否 | 高德 **Web端(JS API)** Key |
| `VITE_AMAP_JS_CODE` | 否 | 高德 **安全密钥(securityJsCode)**JSAPI 2.0 起与 Key 成对必需,缺它定位报 `INVALID_USER_SCODE` |
### 后端
后端启动时读取(缺和风 JWT 时接口返回 `not_configured`,前端静默不展示;
`AMAP_KEY` 仅 IP 兜底路径需要,前端总能定位时可不配
缺高德 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` |

View File

@ -275,8 +275,31 @@ ssh root@8.130.143.54 "ffmpeg -version | head -1"
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 带宽播放。不提供视频下载入口。
用于适配 3M 带宽播放。不提供显式视频下载入口;`controlsList="nodownload"` 只是 UI 降噪,
不是防下载安全边界。
**写博客2026-06-27后端改动**
个人博客(登录后默认页,`#post/<id>` 文章路由,详见 [blog.md](../../blog.md))引入了后端

View File

@ -4,6 +4,8 @@
**Goal:** Add video upload/playback to special-folder photo walls, with asynchronous server-side ffmpeg compression for low-bandwidth playback.
**Current status:** This is an implementation plan only. The current codebase does not yet include `Server/src/media.rs`, `/api/web/folder/{name}/media*`, `Client/src/api/folderMedia.ts`, or video rendering in `PhotoWallPage`.
**Architecture:** Special folders move from photo-only endpoints to media endpoints. A new backend media module owns media ids, metadata files, multipart streaming, ffmpeg command construction, and one-at-a-time transcode coordination; `routes/web.rs` only exposes HTTP handlers. The React photo wall keeps personal `#photo` image behavior unchanged, while special folders use a new media API and render images, processing videos, ready videos, and failed videos in the same draggable wall.
**Tech Stack:** Rust 2024, axum 0.8.9 with `multipart` feature, tokio, serde/serde_json, ffmpeg runtime dependency, React 19, TypeScript 6, Vite 8.
@ -15,7 +17,7 @@
- Supported inputs: images `jpg/png/gif/webp`; videos at least `mp4/mov/m4v/webm/avi/mkv`.
- Single original video max: 500MB. Single image max remains 8MiB.
- Server transcodes videos to MP4/H.264/AAC for 3M bandwidth: 720p class, 24fps, video maxrate 900k, bufsize 1800k, audio 96k, `+faststart`.
- No video download UI.
- No explicit video download UI. Use `controlsList="nodownload"` on `<video>`, but treat it as UI cleanup only, not a security boundary.
- Video upload does not limit duration.
- Transcode concurrency inside one Rust process is 1.
- Special folder item limit remains `MAX_ITEMS`: images + videos + decorations together.
@ -215,6 +217,8 @@ mod tests {
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"));
}
@ -597,7 +601,7 @@ pub fn ffmpeg_args(input: &Path, output: &Path) -> Vec<String> {
"-i".to_string(),
input.to_string_lossy().to_string(),
"-vf".to_string(),
"scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(ih,iw),min(1280,ih),-2)',fps=24".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(),
@ -689,6 +693,8 @@ mod tests {
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"));
}
@ -741,6 +747,7 @@ git commit -m "feat(media): special-folder media store and ffmpeg transcode mode
**Files:**
- Modify: `Server/src/routes/web.rs`
- Modify: `Server/src/folders.rs`
- Test: compile via `cargo test`
**Interfaces:**
@ -998,7 +1005,62 @@ out.write_all(&chunk).await
out.flush().await
```
- [ ] **Step 5: Run backend tests**
- [ ] **Step 5: Migrate shared wall state API to mediaLayout**
The frontend task will save `mediaLayout`, but the current backend state model only accepts
`photoLayout`. Update both `Server/src/folders.rs` and `Server/src/routes/web.rs` so the shared
folder wall state supports the new key while preserving old saved state.
Required behavior:
- `PUT /api/web/folder/{name}/state` accepts either `mediaLayout` or old `photoLayout`.
- `GET /api/web/folder/{name}/state` returns `mediaLayout`.
- During one compatibility release, `GET` may also return `photoLayout` with the same value so older
frontend builds keep working.
- Existing `wall-state.json` files that contain `photo_layout`/`photoLayout` must still load.
Implementation direction:
```rust
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct WallState {
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_serializing_if = "std::collections::HashMap::is_empty")]
pub photo_layout: std::collections::HashMap<String, PhotoLayout>,
}
```
When saving, normalize the layout once:
```rust
#[derive(Deserialize)]
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: Option<std::collections::HashMap<String, serde_json::Value>>,
}
let layout_values = body
.media_layout
.or(body.photo_layout)
.unwrap_or_default();
```
Then save `media_layout` and, for the compatibility window, mirror it into `photo_layout`.
Also add or update backend tests that deserialize:
- old disk shape with `photo_layout`,
- old API shape with `photoLayout`,
- new API shape with `mediaLayout`.
- [ ] **Step 6: Run backend tests**
Run:
@ -1008,7 +1070,7 @@ cd Server && cargo test
Expected: all backend tests pass.
- [ ] **Step 6: Smoke-test routes locally without ffmpeg**
- [ ] **Step 7: Smoke-test routes locally without ffmpeg**
Run:
@ -1025,10 +1087,10 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/web/folder/fa
Expected: `401` because media endpoints require `Authorization: Bearer`.
- [ ] **Step 7: Commit**
- [ ] **Step 8: Commit**
```bash
git add Server/src/routes/web.rs
git add Server/src/routes/web.rs Server/src/folders.rs
git commit -m "feat(web): special-folder media endpoints"
```
@ -1594,7 +1656,7 @@ In `renderInner`, add a `video` case after `photo`:
{el.mediaStatus === 'processing' && (
<div className="pw-video-state">
<div className="pw-video-spinner" />
<strong>AI 正在压缩视频...</strong>
<strong>thinking</strong>
<span>完成后会自动变成播放器</span>
</div>
)}
@ -1605,7 +1667,7 @@ In `renderInner`, add a `video` case after `photo`:
</div>
)}
{el.mediaStatus === 'ready' && el.src && (
<video src={el.src} controls preload="metadata" />
<video src={el.src} controls controlsList="nodownload" preload="metadata" />
)}
</div>
{editingId === el.id ? (
@ -1797,8 +1859,8 @@ Edit `docs/photo-wall.md`. In the “功能” section, add this bullet after
```markdown
- **特殊文件夹视频**:特殊文件夹(`#<文件夹名>`)支持上传图片或视频;视频上传后先显示
AI 正在压缩视频...」占位卡,服务器用 `ffmpeg` 压缩成 3M 带宽友好的 MP4 后自动变为
播放器。个人 `#photo` 暂不开放视频上传。不提供视频下载按钮。
thinking」占位卡,服务器用 `ffmpeg` 压缩成 3M 带宽友好的 MP4 后自动变为
播放器。个人 `#photo` 暂不开放视频上传。不提供显式视频下载按钮。
```
In the “线上访问” section, add:
@ -1840,7 +1902,8 @@ ssh root@8.130.143.54 "apt-get update && apt-get install -y ffmpeg"
```
转码目标固定为 720p 档、24fps、视频约 900kbps、音频 96kbps、MP4 faststart
用于适配 3M 带宽播放。不提供视频下载入口。
用于适配 3M 带宽播放。不提供显式视频下载入口;`controlsList="nodownload"` 只是 UI 降噪,
不是防下载安全边界。
```
- [ ] **Step 4: Run docs search for stale wording**
@ -1886,7 +1949,7 @@ http://localhost:5173/#<registered-folder>
Expected manual checks:
- Login succeeds for a whitelisted user.
- Uploading `/tmp/pw-test-video.mp4` immediately creates an “AI 正在压缩视频...” card.
- Uploading `/tmp/pw-test-video.mp4` immediately creates a “thinking” card.
- The card becomes a video player after transcode.
- Playback starts only after clicking play.
- Drag, resize, caption edit, and delete work.
@ -1944,7 +2007,7 @@ Spec coverage:
- Multipart upload: Tasks 2 and 3 use `Multipart` and `FormData`.
- ffmpeg compression target: Task 1 builds command and tests 24fps, 900k, 1800k, 96k, `+faststart`.
- Async processing card: Task 4 creates processing video elements and polls status.
- No download UI: Task 4 uses `<video controls>` without a download button; docs state no download entry.
- No explicit download UI: Task 4 uses `<video controls controlsList="nodownload">`; docs state this is UI cleanup, not a security boundary.
- 500MB max and 8MiB image limit: Tasks 1-2 enforce constants while streaming.
- Concurrency 1: Task 1 `Semaphore::new(1)`.
- `mediaLayout` compatibility: Task 4 reads old `photoLayout` and writes `mediaLayout` plus temporary compatibility `photoLayout`.

View 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_-]`、长度 164既作目录名安全
又避免与 `#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,29 +134,31 @@ 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` 统计的性能(目录文件数大时遍历开销)——当前规模可接受,必要时缓存。
- 管理员常量的位置与日后可配置化的留口。

View File

@ -3,6 +3,8 @@
> 在特殊文件夹共享照片墙(`#<文件夹名>`)里支持视频作为墙上媒体元素。
> 视频上传后先显示处理中占位卡,服务器用 `ffmpeg` 压缩为低带宽 MP4完成后在墙上播放。
> 本设计只开放给特殊文件夹;个人 `#photo` 暂不开放视频入口。
>
> 状态:规划中,当前代码尚未实现 `/media*` 接口、视频上传、转码和视频卡片渲染。
前置阅读:
- [photo-wall.md](../../photo-wall.md)
@ -14,9 +16,9 @@
目标:
- 特殊文件夹支持图片和视频混合展示。
- 视频像照片一样可以上传、拖拽、缩放、置顶/置底、删除。
- 视频上传后不阻塞页面等待转码;墙上立即出现“AI 正在压缩视频...”风格的处理中卡片。
- 视频上传后不阻塞页面等待转码;墙上立即出现“thinking”风格的处理中卡片。
- 服务器将常见视频格式统一压缩为适合 3M 带宽播放的 MP4。
- 不提供视频下载入口,不添加下载按钮。
- 不提供显式视频下载入口,不添加下载按钮;这不是防下载安全边界
非目标:
- 个人 `#photo` 视频上传。
@ -30,7 +32,7 @@
后端把特殊文件夹存储从“图片列表”升级为“媒体列表”。图片可以同步保存并立即进入 `ready` 状态。视频上传后先写入临时文件,创建状态文件,后台调用 `ffmpeg` 转码;转码完成后只保留压缩后的 `.mp4` 和状态文件,删除原始临时文件。
`ffmpeg -movflags +faststart` 只用于优化浏览器在线播放体验:把 MP4 索引移动到文件开头,让 `<video>` 不必等完整文件下载完才开始播放。UI 不提供下载功能
`ffmpeg -movflags +faststart` 只用于优化浏览器在线播放体验:把 MP4 索引移动到文件开头,让 `<video>` 不必等完整文件下载完才开始播放。UI 不提供显式下载按钮;浏览器原生视频控件仍需加 `controlsList="nodownload"` 降低下载入口暴露,但这不构成防下载或防复制安全边界
## 3. 媒体模型
@ -128,12 +130,15 @@ interface FolderMediaItem {
```bash
ffmpeg -y -i input \
-vf "scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(ih,iw),min(1280,ih),-2)',fps=24" \
-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。
- 上传请求只负责接收文件、创建状态、启动后台任务,然后立即返回。
@ -146,7 +151,7 @@ ffmpeg -y -i input \
渲染规则:
- `image + ready`:沿用当前照片卡片。
- `video + processing`:显示软木板风格占位卡,文案为“AI 正在压缩视频...”。
- `video + processing`:显示软木板风格占位卡,文案为“thinking”。
- `video + ready`:显示带控件的 `<video controls preload="metadata">`,不自动播放。
- `video + failed`:显示“视频处理失败”,允许右键删除。
@ -154,7 +159,8 @@ ffmpeg -y -i input \
- 支持拖拽、缩放、旋转、置顶/置底、删除。
- 双击编辑说明文字,逻辑与照片 caption 一致。
- 用户主动点击才播放,避免多个视频同时消耗 3M 带宽。
- 不显示下载按钮。
- 不显示显式下载按钮;`<video>` 使用 `controlsList="nodownload"`。该属性只是 UI 降噪,
不能当成安全控制。
轮询:
- 上传视频返回 `processing` 后,前端创建本地元素并开始轮询 `/status`
@ -186,9 +192,12 @@ interface WallState {
```
兼容策略:
- 读取状态时,如果存在 `mediaLayout`,优先使用。
- 如果只有旧的 `photoLayout`,把它当作 `mediaLayout` 读取。
- 前端读取状态时,如果存在 `mediaLayout`,优先使用。
- 如果只有旧的 `photoLayout`前端把它当作 `mediaLayout` 读取。
- 保存状态时写 `mediaLayout`;可以同时保留 `photoLayout` 一段时间以兼容旧前端,但新前端不再依赖它。
- 后端 state 接口必须同步迁移:`PUT /api/web/folder/{name}/state` 需要接受
`mediaLayout` 和旧 `photoLayout``GET` 返回 `mediaLayout`,并在兼容期同时返回
`photoLayout`。仅改前端类型不够。
- 已有图片布局不丢失;新视频布局按同一媒体 id 保存。
## 9. 删除与清理
@ -210,13 +219,16 @@ interface WallState {
- 特殊文件夹权限:未注册 404、无权 403、有权通过。
- 删除会清理成品、状态和临时文件。
- ffmpeg 命令构造包含 720p、24fps、900k、96k、`+faststart`。
- ffmpeg 缩放测试覆盖横屏、竖屏、正方形/近正方形和小尺寸输入,确认不超过目标尺寸且不放大。
前端:
- 特殊文件夹图片上传和显示仍可用。
- 上传视频后立即出现“AI 正在压缩视频...”占位。
- 上传视频后立即出现“thinking”占位。
- 轮询到 `ready` 后替换为播放器。
- `failed` 状态显示失败卡并可删除。
- 视频拖拽、缩放、删除、caption、保存布局不破坏图片和装饰。
- `<video>` 渲染包含 `controlsList="nodownload"`,且产品文案只承诺不提供下载入口,
不承诺防下载。
端到端:
- 在本地安装 `ffmpeg` 后上传一个小视频,确认转码完成、播放不卡、刷新后布局保留。