feat(photo-wall): load/upload/delete photos via server, split local layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cc 2026-06-24 21:22:58 +08:00
parent 9685336903
commit a9905b4fa7

View File

@ -8,6 +8,7 @@ import {
type CSSProperties, type CSSProperties,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from 'react' } from 'react'
import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos'
/* /*
Photo Wall Photo Wall
@ -46,6 +47,8 @@ interface El {
// note // note
text?: string text?: string
color?: string color?: string
/** 服务器图片 id仅 type==='photo'),也是 localStorage 排版的 key */
photoId?: string
// sticker // sticker
bg?: string bg?: string
// doodle // 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})` 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 { interface SavedState {
bgIndex: number bgIndex: number
els: El[] decorations: El[]
photoLayout: Record<string, PhotoLayout>
} }
function loadInitial() { function loadInitial() {
let els: El[] = [] let decorations: El[] = []
let photoLayout: Record<string, PhotoLayout> = {}
let bgIndex = 0 let bgIndex = 0
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (raw) { if (raw) {
const data = JSON.parse(raw) as Partial<SavedState> const data = JSON.parse(raw) as Partial<SavedState> & { els?: El[] }
if (Array.isArray(data.els)) els = data.els
if (typeof data.bgIndex === 'number') bgIndex = data.bgIndex 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 { } catch {
/* 损坏的存档当作空墙 */ /* 损坏的存档当作空墙 */
} }
const nextId = els.reduce((m, e) => Math.max(m, e.id), 0) + 1 const nextId = decorations.reduce((m, e) => Math.max(m, e.id), 0) + 1
const maxZ = Math.max(10, ...els.map((e) => e.z || 10)) const maxZ = Math.max(10, ...decorations.map((e) => e.z || 10))
return { els, bgIndex, nextId, maxZ } return { decorations, photoLayout, bgIndex, nextId, maxZ }
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
const initial = useMemo(() => loadInitial(), []) const initial = useMemo(() => loadInitial(), [])
const [els, setEls] = useState<El[]>(initial.els) const [els, setEls] = useState<El[]>(initial.decorations)
// 每张图的本地排版(按服务器图片 id。runtime 维护,保存时连同装饰一起落盘。
const layoutRef = useRef<Record<string, PhotoLayout>>(initial.photoLayout)
// 本组件创建的所有 objectURL卸载时统一释放。
const objUrls = useRef<string[]>([])
const [bgIndex, setBgIndex] = useState(initial.bgIndex) const [bgIndex, setBgIndex] = useState(initial.bgIndex)
const [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null) const [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null)
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
@ -201,7 +228,18 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ bgIndex, els })) const decorations = els.filter((e) => e.type !== 'photo')
const photoLayout: Record<string, PhotoLayout> = {}
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('已保存') setSaveLabel('已保存')
} catch { } catch {
setSaveLabel('保存失败') setSaveLabel('保存失败')
@ -239,18 +277,72 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
[mutate], [mutate],
) )
const makePhoto = useCallback( // 登录后从服务器拉本用户图片:取字节为 objectURL套用本地排版或自动散落。
(src: string, w: number, h: number) => { useEffect(() => {
const pos = spawnPos(40) 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 roll = Math.random()
const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2' const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
addEl({ const pos = spawnPos(40)
type: 'photo', src, w, h, x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, 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), caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
}) }
}, if (layout) zRef.current = Math.max(zRef.current, layout.z)
[addEl, spawnPos], 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 makeNote = useCallback(() => {
const pos = spawnPos(20) const pos = spawnPos(20)
@ -294,18 +386,43 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
const reader = new FileReader() const reader = new FileReader()
reader.onload = (ev) => { reader.onload = (ev) => {
const src = ev.target?.result as string const src = ev.target?.result as string
// 满 10 张直接拦下
const count = elsRef.current.filter((e) => e.type === 'photo').length
if (count >= 10) {
showToast('每位用户最多 10 张照片')
return
}
uploadPhoto(token, src)
.then((id) => {
const img = new Image() const img = new Image()
img.onload = () => { img.onload = () => {
const w = randInt(140, 200) const w = randInt(140, 200)
const h = Math.round(w * (img.height / img.width)) const h = Math.round(w * (img.height / img.width))
makePhoto(src, w, h) 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 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) reader.readAsDataURL(file)
}) })
}, },
[makePhoto, showToast], [addEl, spawnPos, showToast, token],
) )
useEffect(() => { useEffect(() => {
@ -416,6 +533,15 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
const recolorNote = (id: number, color: string) => const recolorNote = (id: number, color: string) =>
mutate((p) => p.map((e) => (e.id === id ? { ...e, color } : e))) mutate((p) => p.map((e) => (e.id === id ? { ...e, color } : e)))
const removeEl = (id: number) => { 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)) mutate((p) => p.filter((e) => e.id !== id))
nodeRefs.current.delete(id) nodeRefs.current.delete(id)
setMenu(null) setMenu(null)