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:
parent
9685336903
commit
a9905b4fa7
@ -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
|
||||||
const roll = Math.random()
|
let alive = true
|
||||||
const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
|
;(async () => {
|
||||||
addEl({
|
try {
|
||||||
type: 'photo', src, w, h, x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1,
|
const { items } = await listPhotos(token)
|
||||||
caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
|
for (const item of items) {
|
||||||
})
|
let url: string
|
||||||
},
|
try {
|
||||||
[addEl, spawnPos],
|
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 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
|
||||||
const img = new Image()
|
// 满 10 张直接拦下
|
||||||
img.onload = () => {
|
const count = elsRef.current.filter((e) => e.type === 'photo').length
|
||||||
const w = randInt(140, 200)
|
if (count >= 10) {
|
||||||
const h = Math.round(w * (img.height / img.width))
|
showToast('每位用户最多 10 张照片')
|
||||||
makePhoto(src, w, h)
|
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)
|
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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user