From a9905b4fa73fba4ae79416db604a2a0a758b5811 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 24 Jun 2026 21:22:58 +0800 Subject: [PATCH] feat(photo-wall): load/upload/delete photos via server, split local layout Co-Authored-By: Claude Opus 4.8 --- Client/src/components/PhotoWallPage.tsx | 186 ++++++++++++++++++++---- 1 file changed, 156 insertions(+), 30 deletions(-) diff --git a/Client/src/components/PhotoWallPage.tsx b/Client/src/components/PhotoWallPage.tsx index db0c144..fd48512 100644 --- a/Client/src/components/PhotoWallPage.tsx +++ b/Client/src/components/PhotoWallPage.tsx @@ -8,6 +8,7 @@ import { type CSSProperties, type PointerEvent as ReactPointerEvent, } from 'react' +import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos' /* ════════════════════════════════════════════════════════════════ 照片墙 Photo Wall —— 互动式墙面照片展示。 @@ -46,6 +47,8 @@ interface El { // note text?: string color?: string + /** 服务器图片 id(仅 type==='photo'),也是 localStorage 排版的 key */ + photoId?: string // sticker bg?: string // doodle @@ -94,34 +97,58 @@ function transformOf(el: El, extraScale = 1) { return `translate(${el.x}px, ${el.y}px) rotate(${el.rot}deg) scale(${el.scale * extraScale})` } +/** 每张照片的本地排版覆盖(按服务器图片 id 存浏览器) */ +interface PhotoLayout { + x: number + y: number + rot: number + scale: number + z: number + w: number + caption: string +} + interface SavedState { bgIndex: number - els: El[] + decorations: El[] + photoLayout: Record } function loadInitial() { - let els: El[] = [] + let decorations: El[] = [] + let photoLayout: Record = {} let bgIndex = 0 try { const raw = localStorage.getItem(STORAGE_KEY) if (raw) { - const data = JSON.parse(raw) as Partial - if (Array.isArray(data.els)) els = data.els + const data = JSON.parse(raw) as Partial & { els?: El[] } if (typeof data.bgIndex === 'number') bgIndex = data.bgIndex + if (Array.isArray(data.decorations)) { + decorations = data.decorations + } else if (Array.isArray(data.els)) { + // 旧结构(照片带 base64 存本地):只保留装饰,丢弃旧照片。 + decorations = data.els.filter((e) => e.type !== 'photo') + } + if (data.photoLayout && typeof data.photoLayout === 'object') { + photoLayout = data.photoLayout + } } } catch { /* 损坏的存档当作空墙 */ } - const nextId = els.reduce((m, e) => Math.max(m, e.id), 0) + 1 - const maxZ = Math.max(10, ...els.map((e) => e.z || 10)) - return { els, bgIndex, nextId, maxZ } + 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 } } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) { +export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) { const initial = useMemo(() => loadInitial(), []) - const [els, setEls] = useState(initial.els) + const [els, setEls] = useState(initial.decorations) + // 每张图的本地排版(按服务器图片 id)。runtime 维护,保存时连同装饰一起落盘。 + const layoutRef = useRef>(initial.photoLayout) + // 本组件创建的所有 objectURL,卸载时统一释放。 + const objUrls = useRef([]) const [bgIndex, setBgIndex] = useState(initial.bgIndex) const [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null) const [editingId, setEditingId] = useState(null) @@ -201,7 +228,18 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) { useEffect(() => { const t = setTimeout(() => { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ bgIndex, els })) + const decorations = els.filter((e) => e.type !== 'photo') + const photoLayout: Record = {} + for (const e of els) { + if (e.type === 'photo' && e.photoId) { + photoLayout[e.photoId] = { + 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(STORAGE_KEY, JSON.stringify({ bgIndex, decorations, photoLayout })) setSaveLabel('已保存') } catch { setSaveLabel('保存失败') @@ -239,18 +277,72 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) { [mutate], ) - const makePhoto = useCallback( - (src: string, w: number, h: number) => { - const pos = spawnPos(40) - const roll = Math.random() - const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2' - addEl({ - type: 'photo', src, w, h, x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, - caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3), - }) - }, - [addEl, spawnPos], - ) + // 登录后从服务器拉本用户图片:取字节为 objectURL,套用本地排版或自动散落。 + useEffect(() => { + if (!token) return + let alive = true + ;(async () => { + try { + const { items } = await listPhotos(token) + for (const item of items) { + let url: string + try { + url = await fetchPhotoObjectUrl(token, item.id) + } catch { + continue + } + if (!alive) { + URL.revokeObjectURL(url) + return + } + 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 + }) + if (!alive) return + const ratio = dims.h / dims.w + const roll = Math.random() + const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2' + const pos = spawnPos(40) + const base: El = layout + ? { + id: idRef.current++, type: 'photo', src: url, photoId: item.id, + 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), + 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), + } + if (layout) zRef.current = Math.max(zRef.current, layout.z) + setEls((prev) => [...prev, base]) + } + } catch { + if (alive) showToast('图片加载失败(请重新登录或检查网络)') + } + })() + return () => { + alive = false + } + // 仅在挂载/换 token 时跑一次 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]) + + // 卸载时释放所有 objectURL + useEffect(() => { + return () => { + for (const u of objUrls.current) URL.revokeObjectURL(u) + } + }, []) const makeNote = useCallback(() => { const pos = spawnPos(20) @@ -294,18 +386,43 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) { const reader = new FileReader() reader.onload = (ev) => { const src = ev.target?.result as string - const img = new Image() - img.onload = () => { - const w = randInt(140, 200) - const h = Math.round(w * (img.height / img.width)) - makePhoto(src, w, h) + // 满 10 张直接拦下 + const count = elsRef.current.filter((e) => e.type === 'photo').length + if (count >= 10) { + showToast('每位用户最多 10 张照片') + return } - img.src = src + uploadPhoto(token, src) + .then((id) => { + const img = new Image() + img.onload = () => { + const w = randInt(140, 200) + const h = Math.round(w * (img.height / img.width)) + const pos = spawnPos(40) + 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, + x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, + caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3), + }) + } + img.src = src + }) + .catch((err: Error) => { + const reason = err.message as UploadError + showToast( + reason === 'full' ? '每位用户最多 10 张照片' + : reason === 'too-large' ? '图片过大(上限 8MB)' + : reason === 'bad-type' ? '不支持的图片格式' + : '上传失败', + ) + }) } reader.readAsDataURL(file) }) }, - [makePhoto, showToast], + [addEl, spawnPos, showToast, token], ) useEffect(() => { @@ -416,6 +533,15 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) { const recolorNote = (id: number, color: string) => mutate((p) => p.map((e) => (e.id === id ? { ...e, color } : e))) const removeEl = (id: number) => { + const target = elsRef.current.find((e) => e.id === id) + if (target?.type === 'photo' && target.photoId) { + deletePhoto(token, target.photoId).catch(() => {}) + delete layoutRef.current[target.photoId] + if (target.src?.startsWith('blob:')) { + URL.revokeObjectURL(target.src) + objUrls.current = objUrls.current.filter((u) => u !== target.src) + } + } mutate((p) => p.filter((e) => e.id !== id)) nodeRefs.current.delete(id) setMenu(null)