diff --git a/Client/src/api/folderMedia.ts b/Client/src/api/folderMedia.ts new file mode 100644 index 0000000..639ae78 --- /dev/null +++ b/Client/src/api/folderMedia.ts @@ -0,0 +1,97 @@ +const API_BASE = + (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080' + +function authHeaders(token: string): Record { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`) +} diff --git a/Client/src/api/folders.ts b/Client/src/api/folders.ts index b57849c..c25a925 100644 --- a/Client/src/api/folders.ts +++ b/Client/src/api/folders.ts @@ -105,7 +105,8 @@ export async function fetchFolderPhotoUrl( export interface WallState { bgIndex: number decorations: Record[] - photoLayout: Record> + mediaLayout?: Record> + photoLayout?: Record> } /** 读取文件夹的共享照片墙状态;无存档返回 null */ diff --git a/Client/src/api/weather.ts b/Client/src/api/weather.ts index dc581f4..3ee9b68 100644 --- a/Client/src/api/weather.ts +++ b/Client/src/api/weather.ts @@ -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 { - 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 { + 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 diff --git a/Client/src/assets/sides/1.png b/Client/src/assets/sides/1.png index e7e07f9..513b40b 100644 Binary files a/Client/src/assets/sides/1.png and b/Client/src/assets/sides/1.png differ diff --git a/Client/src/assets/sides/2.jpg b/Client/src/assets/sides/2.jpg index 2b0a428..aa7b83a 100644 Binary files a/Client/src/assets/sides/2.jpg and b/Client/src/assets/sides/2.jpg differ diff --git a/Client/src/assets/sides/Aria.jpg b/Client/src/assets/sides/Aria.jpg index 6723e8e..2e65476 100644 Binary files a/Client/src/assets/sides/Aria.jpg and b/Client/src/assets/sides/Aria.jpg differ diff --git a/Client/src/assets/sides/sunna.jpg b/Client/src/assets/sides/sunna.jpg index 4392701..8f21177 100644 Binary files a/Client/src/assets/sides/sunna.jpg and b/Client/src/assets/sides/sunna.jpg differ diff --git a/Client/src/components/BlogPage.tsx b/Client/src/components/BlogPage.tsx index ab54329..ab0381b 100644 --- a/Client/src/components/BlogPage.tsx +++ b/Client/src/components/BlogPage.tsx @@ -247,9 +247,10 @@ export function BlogPage({ username, token, initialPostId, onSignOut }: BlogPage
-
+
© {new Date().getFullYear()} cc built with React · Rust · 🌈 + 苏ICP备2026046305号-1
diff --git a/Client/src/components/PhotoWallPage.tsx b/Client/src/components/PhotoWallPage.tsx index ecc78de..6d499f1 100644 --- a/Client/src/components/PhotoWallPage.tsx +++ b/Client/src/components/PhotoWallPage.tsx @@ -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 + mediaLayout: Record + photoLayout?: Record } function loadInitial(storageKey: string) { let decorations: El[] = [] - let photoLayout: Record = {} + let mediaLayout: Record = {} 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(initial.decorations) - const layoutRef = useRef>(initial.photoLayout) + const layoutRef = useRef>(initial.mediaLayout) const objUrls = useRef([]) + const pollTimers = useRef(new Map>()) const [bgIndex, setBgIndex] = useState(initial.bgIndex) const [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null) const [editingId, setEditingId] = useState(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 = { ...layoutRef.current } + const decorations = els.filter((e) => e.type !== 'photo' && e.type !== 'video') + const mediaLayout: Record = { ...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[], - photoLayout: photoLayout as unknown as Record>, + mediaLayout: mediaLayout as unknown as Record>, + photoLayout: mediaLayout as unknown as Record>, }).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 - 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 ( + <> +
+ {el.mediaStatus === 'processing' && ( +
+
+ thinking + 完成后会自动变成播放器 +
+ )} + {el.mediaStatus === 'failed' && ( +
+ 视频处理失败 + 右键删除后可重新上传 +
+ )} + {el.mediaStatus === 'ready' && el.src && ( +
+ {editingId === el.id ? ( + setEditText(e.target.value)} + onBlur={() => commitEdit(el.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); commitEdit(el.id) } + e.stopPropagation() + }} + /> + ) : ( +
{el.caption}
+ )} +
+ + ) 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 = { - 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 (
@@ -1004,7 +1170,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden {els.length}/{maxItems} )} - +
@@ -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)}
))} - {dragHint &&
拖入图片即可添加到照片墙
} + {dragHint &&
{isPersonal ? '拖入图片即可添加到照片墙' : '拖入图片或视频即可添加到照片墙'}
}
{/* ── 右键菜单 ── */} @@ -1198,7 +1364,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden {toast &&
{toast}
} - { if (e.target.files) handleFiles(e.target.files); e.target.value = '' }} />
) diff --git a/Client/src/components/StartPage.tsx b/Client/src/components/StartPage.tsx index 46f50ef..32476eb 100644 --- a/Client/src/components/StartPage.tsx +++ b/Client/src/components/StartPage.tsx @@ -147,6 +147,10 @@ export function StartPage() { {/* 底部分类抽屉:按类型分组的快捷入口,悬停某类时菜单向上抽出 */} + + + 苏ICP备2026046305号-1 +
) } diff --git a/Client/src/components/start/CategoryDock.tsx b/Client/src/components/start/CategoryDock.tsx index df9e180..2b6bfb5 100644 --- a/Client/src/components/start/CategoryDock.tsx +++ b/Client/src/components/start/CategoryDock.tsx @@ -41,24 +41,12 @@ const ZhihuIcon = ( ) -const XIcon = ( - - - -) - const YoutubeIcon = ( ) -const SteamIcon = ( - - - -) - // 无可靠 simple-icons path 的站点:用字标,跟其余图标同色同风格。 const Wordmark = (text: string) => ( @@ -99,15 +87,6 @@ const DevGlyph = ( ) -const GameGlyph = ( - - - - - - -) - // 分类顺序即从左到右的展示顺序。每类的 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') }, ], }, ] diff --git a/Client/src/hooks/useWeather.ts b/Client/src/hooks/useWeather.ts index 4367495..c5ad5a2 100644 --- a/Client/src/hooks/useWeather.ts +++ b/Client/src/hooks/useWeather.ts @@ -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 }) diff --git a/Client/src/index.css b/Client/src/index.css index 6796254..181622d 100644 --- a/Client/src/index.css +++ b/Client/src/index.css @@ -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; diff --git a/Client/src/lib/amap.ts b/Client/src/lib/amap.ts deleted file mode 100644 index 9678a26..0000000 --- a/Client/src/lib/amap.ts +++ /dev/null @@ -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) => AMapGeolocation -} - -declare global { - interface Window { - AMap?: AMapNamespace - _AMapSecurityConfig?: { securityJsCode: string } - } -} - -let sdkPromise: Promise | null = null - -/** 按需注入高德 JSAPI 脚本(单例)。安全密钥须在脚本加载前挂到 window。 */ -function loadSdk(): Promise { - 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 { - if (!KEY) return null - - let AMap: AMapNamespace - try { - AMap = await loadSdk() - } catch { - return null - } - - return new Promise((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) - } - }) - } - }) - }) - }) -} diff --git a/Server/Cargo.lock b/Server/Cargo.lock index 057ba00..6985198 100644 --- a/Server/Cargo.lock +++ b/Server/Cargo.lock @@ -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" diff --git a/Server/Cargo.toml b/Server/Cargo.toml index c63fe13..70e7688 100644 --- a/Server/Cargo.toml +++ b/Server/Cargo.toml @@ -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" diff --git a/Server/src/folders.rs b/Server/src/folders.rs index 06878a9..9f233e0 100644 --- a/Server/src/folders.rs +++ b/Server/src/folders.rs @@ -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, + #[serde(default, alias = "photoLayout", alias = "photo_layout")] + pub media_layout: std::collections::HashMap, + #[serde(default, skip_deserializing, skip_serializing_if = "std::collections::HashMap::is_empty")] pub photo_layout: std::collections::HashMap, } @@ -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()); + } +} diff --git a/Server/src/main.rs b/Server/src/main.rs index 28c7808..8625e23 100644 --- a/Server/src/main.rs +++ b/Server/src/main.rs @@ -6,6 +6,7 @@ mod auth; mod folders; +mod media; mod monitor; mod photos; mod posts; diff --git a/Server/src/media.rs b/Server/src/media.rs new file mode 100644 index 0000000..63c7390 --- /dev/null +++ b/Server/src/media.rs @@ -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, +} + +#[derive(Clone)] +pub struct FolderMediaStore { + base: PathBuf, + transcodes: Arc, +} + +#[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) -> Self { + Self { + base: base.into(), + transcodes: Arc::new(Semaphore::new(1)), + } + } + + fn folder_dir(&self, folder: &str) -> Option { + let safe = photos::sanitize_user(folder)?; + Some(self.base.join(safe)) + } + + fn media_dir(&self, folder: &str) -> Option { + Some(self.folder_dir(folder)?.join("media")) + } + + fn tmp_dir(&self, folder: &str) -> Option { + Some(self.folder_dir(folder)?.join(".tmp")) + } + + pub fn list(&self, folder: &str) -> (Vec, 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::(&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 { + 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 { + 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 { + 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)> { + 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 { + 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 { + 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 { + 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 { + 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) { + let Ok(bytes) = std::fs::read(path) else { + return; + }; + let Ok(mut meta) = serde_json::from_slice::(&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")); + } +} diff --git a/Server/src/routes/web.rs b/Server/src/routes/web.rs index e7e6713..769ef86 100644 --- a/Server/src/routes/web.rs +++ b/Server/src/routes/web.rs @@ -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, - lat: Option, - city: Option, -} - /// 天气 + 定位: -/// - 前端带 `lon/lat`(AMap.Geolocation 定位结果)→ 直接用,只调和风。 -/// - 未带 → 高德按访问者 IP 定位取城市作兜底。 +/// 后端只按访问者 IP 定位取城市;定位失败则返回业务失败态,前端静默不显示。 /// /// 内部自带每日限流(高德 300 / 和风 900,北京时间 0 点归零)与短期缓存。 async fn weather( State(state): State, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, - Query(params): Query, ) -> Json { 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, #[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, + #[serde(skip_serializing_if = "Option::is_none")] + max: Option, + total_bytes: u64, + over_quota: bool, +} + +async fn list_folder_media( + AuthUser(user): AuthUser, + State(state): State, + AxPath(name): AxPath, +) -> Result, 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, + AxPath(name): AxPath, + multipart: Multipart, +) -> Result<(StatusCode, Json), 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, + 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, + AxPath((name, id)): AxPath<(String, String)>, +) -> Result, 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, + 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), 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(rename = "mediaLayout")] + media_layout: Option>, #[serde(rename = "photoLayout")] - photo_layout: std::collections::HashMap, + photo_layout: Option>, } /// 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 = body.photo_layout + let layout_values = body.media_layout.or(body.photo_layout).unwrap_or_default(); + let media_layout: std::collections::HashMap = 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), diff --git a/Server/src/state.rs b/Server/src/state.rs index 2938f73..3c35952 100644 --- a/Server/src/state.rs +++ b/Server/src/state.rs @@ -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(), } } diff --git a/Server/src/weather.rs b/Server/src/weather.rs index b670b5e..c9d706e 100644 --- a/Server/src/weather.rs +++ b/Server/src/weather.rs @@ -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) -> 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) -> 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) 和风实时天气(成功才计数) diff --git a/docs/photo-wall.md b/docs/photo-wall.md index a76a65e..0c88bce 100644 --- a/docs/photo-wall.md +++ b/docs/photo-wall.md @@ -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`,后端改动需全量重部署。 diff --git a/docs/startpage-weather-location.md b/docs/startpage-weather-location.md index d1a3149..7bb2b0f 100644 --- a/docs/startpage-weather-location.md +++ b/docs/startpage-weather-location.md @@ -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` | diff --git a/docs/superpowers/plans/2026-06-18-deploy.md b/docs/superpowers/plans/2026-06-18-deploy.md index e40b41f..c169a15 100644 --- a/docs/superpowers/plans/2026-06-18-deploy.md +++ b/docs/superpowers/plans/2026-06-18-deploy.md @@ -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/` 文章路由,详见 [blog.md](../../blog.md))引入了后端 diff --git a/docs/superpowers/plans/2026-06-29-special-folder-video-media.md b/docs/superpowers/plans/2026-06-29-special-folder-video-media.md index 0e26c37..f2ba45a 100644 --- a/docs/superpowers/plans/2026-06-29-special-folder-video-media.md +++ b/docs/superpowers/plans/2026-06-29-special-folder-video-media.md @@ -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 `