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 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<string, PhotoLayout>
}
function loadInitial() {
let els: El[] = []
let decorations: El[] = []
let photoLayout: Record<string, PhotoLayout> = {}
let bgIndex = 0
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const data = JSON.parse(raw) as Partial<SavedState>
if (Array.isArray(data.els)) els = data.els
const data = JSON.parse(raw) as Partial<SavedState> & { 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<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 [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null)
const [editingId, setEditingId] = useState<number | null>(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<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('已保存')
} 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)