feat(photo-wall): 修复4个问题 + 特殊文件夹(共享公共相册)

**照片墙修复:**
- 右键菜单 z-index 40→9999,不再被元素遮挡
- 图片放大后强制重建 DOM(img key 绑定 w/h)
- 右键菜单添加滑条调节旋转步长(1°-90°)和缩放步长(照片 px / 其他 scale)
- Delete 键可删除右键菜单选中的元素(便签也能删)

**特殊文件夹功能(按设计文档实现):**
- 后端:FolderRegistry 解析 special_folders.txt 注册表 + FolderStore 按文件夹存图
- 后端:5 个接口(存在性检查/列表/上传/获取/删除),白名单 + 管理员鉴权
- 前端:App.tsx 按 hash 路由到特殊文件夹,复用 PhotoWallPage
- 前端:文件夹模式无张数上限,localStorage 按文件夹隔离
- 前端:管理员配额警告条(超 1GiB 时显示)
- 前端:403 无权访问时退回起始页

Co-Authored-By: Claude <noreply@anthropic.com>
@
This commit is contained in:
cc 2026-06-24 22:19:29 +08:00
parent fbac6de977
commit 8d89cee451
8 changed files with 674 additions and 114 deletions

View File

@ -6,38 +6,50 @@ import { StartPage } from './components/StartPage'
import { PhotoWallPage } from './components/PhotoWallPage'
import { useAuth } from './hooks/useAuth'
import { checkGate } from './api/gate'
import { checkFolder } from './api/folders'
// 固定入口 hash#photo 进照片墙(公开路由,不走后端 gate登录成功即进
// 固定入口 hash#photo 进个人照片墙(公开路由,不走后端 gate登录成功即进
const PHOTO_HASH = 'photo'
function App() {
// 登录态从 localStorage/sessionStorage 恢复:已登录直接进博客页。
const auth = useAuth()
// 是否显示登录页。入口 hash 是否合法由后端判定,故初值为 false等校验通过再开。
const [showLogin, setShowLogin] = useState(false)
// 当前是否走照片墙入口(地址栏 #photo。登录后据此决定进照片墙而非博客。
// 当前是否走个人照片墙入口(地址栏 #photo
const [wantsPhoto, setWantsPhoto] = useState(
() => window.location.hash.replace(/^#/, '') === PHOTO_HASH,
)
// 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名
const [wantsFolder, setWantsFolder] = useState<string | null>(null)
const signOut = auth.signOut
// 监听地址栏 hash
// - #photo 是写死的公开入口,直接放行到(去掉服务器负载栏的)登录页,不查后端。
// - 其余 hash 发后端校验,命中合法开发者才放行到登录页(合法值只存后端)。
// - #photo → 个人照片墙(公开入口,直接放行到登录页)
// - 其余 hash → 先查是否是特殊文件夹 → 否则查 gate → 否则起始页
useEffect(() => {
let alive = true
const check = async () => {
const hash = window.location.hash.replace(/^#/, '')
const photo = hash === PHOTO_HASH
if (alive) setWantsPhoto(photo)
if (photo) {
if (alive) setShowLogin(false) // 照片墙入口不走 gate 登录态
return
if (alive) {
setWantsPhoto(photo)
setWantsFolder(null)
setShowLogin(false)
}
const ok = hash ? await checkGate(hash) : false
// 防竞态:异步返回时若组件已卸载或 hash 又变了,丢弃这次结果。
if (alive && hash === window.location.hash.replace(/^#/, '')) {
setShowLogin(ok)
if (photo) return
if (hash) {
// 先查是否是已注册的特殊文件夹
const isFolder = await checkFolder(hash)
if (!alive || hash !== window.location.hash.replace(/^#/, '')) return
if (isFolder) {
setWantsFolder(hash)
return
}
// 不是文件夹,查 gate
const ok = await checkGate(hash)
if (alive && hash === window.location.hash.replace(/^#/, '')) {
setShowLogin(ok)
}
}
}
check()
@ -48,10 +60,19 @@ function App() {
}
}, [])
// 回到导航页:清掉登录态,并抹掉地址栏里的密钥(防止留在历史里被看到)。
// 回到导航页:清掉登录态,并抹掉地址栏 hash
const goStart = () => {
signOut()
setShowLogin(false)
setWantsFolder(null)
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname + window.location.search)
}
}
// 403 无权访问特殊文件夹时,也退回起始页
const onFolderForbidden = () => {
setWantsFolder(null)
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname + window.location.search)
}
@ -59,12 +80,23 @@ function App() {
let view
if (auth.username) {
// 已登录:照片墙入口 → 照片墙,否则博客页。
view = wantsPhoto
? <PhotoWallPage onExit={goStart} token={auth.token ?? ''} />
: <BlogPage username={auth.username} onSignOut={goStart} />
} else if (wantsPhoto) {
// #photo 未登录:复用登录页,但去掉顶部服务器负载栏。
if (wantsPhoto) {
view = <PhotoWallPage onExit={goStart} token={auth.token ?? ''} />
} else if (wantsFolder) {
view = (
<PhotoWallPage
onExit={goStart}
token={auth.token ?? ''}
folderName={wantsFolder}
isAdmin={auth.username === 'cc'}
onForbidden={onFolderForbidden}
/>
)
} else {
view = <BlogPage username={auth.username} onSignOut={goStart} />
}
} else if (wantsPhoto || wantsFolder) {
// 照片墙 / 特殊文件夹未登录:复用登录页,去掉顶部服务器负载栏
view = <LoginPage onSignIn={auth.signIn} onBack={goStart} hideStats />
} else if (showLogin) {
view = <LoginPage onSignIn={auth.signIn} onBack={goStart} />

98
Client/src/api/folders.ts Normal file
View File

@ -0,0 +1,98 @@
// 特殊文件夹图片接口。基址与 photos.ts 一致。
const API_BASE =
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080'
function authHeaders(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` }
}
/** 检查文件夹是否已注册(登录前,无鉴权) */
export async function checkFolder(name: string): Promise<boolean> {
try {
const res = await fetch(`${API_BASE}/api/web/folder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) return false
return ((await res.json()) as { ok: boolean }).ok
} catch {
return false
}
}
export interface FolderPhotoItem {
id: string
}
export interface FolderPhotoList {
items: FolderPhotoItem[]
totalBytes: number
overQuota: boolean
}
/** 上传失败的可区分原因 */
export type UploadError = 'too-large' | 'bad-type' | 'failed'
/** 列出文件夹内所有图片,含配额信息 */
export async function listFolderPhotos(
token: string,
folder: string,
): Promise<FolderPhotoList> {
const res = await fetch(`${API_BASE}/api/web/folder/${folder}/photos`, {
headers: authHeaders(token),
})
if (res.status === 403) throw new Error('forbidden')
if (!res.ok) throw new Error(`list failed: ${res.status}`)
return (await res.json()) as FolderPhotoList
}
/** 上传图片到文件夹,返回服务器分配的 id */
export async function uploadFolderPhoto(
token: string,
folder: string,
dataUrl: string,
): Promise<string> {
const res = await fetch(`${API_BASE}/api/web/folder/${folder}/photos`, {
method: 'POST',
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
body: JSON.stringify({ dataUrl }),
})
if (res.ok) return ((await res.json()) as { id: string }).id
if (res.status === 403) throw new Error('forbidden')
const reason: UploadError =
res.status === 413
? 'too-large'
: res.status === 415
? 'bad-type'
: 'failed'
throw new Error(reason)
}
/** 删除文件夹内图片 */
export async function deleteFolderPhoto(
token: string,
folder: string,
id: string,
): Promise<void> {
const res = await fetch(`${API_BASE}/api/web/folder/${folder}/photos/${id}`, {
method: 'DELETE',
headers: authHeaders(token),
})
if (res.status === 403) throw new Error('forbidden')
if (!res.ok && res.status !== 404) throw new Error(`delete failed: ${res.status}`)
}
/** 获取文件夹内图片的 objectURL调用方负责释放 */
export async function fetchFolderPhotoUrl(
token: string,
folder: string,
id: string,
): Promise<string> {
const res = await fetch(`${API_BASE}/api/web/folder/${folder}/photos/${id}`, {
headers: authHeaders(token),
})
if (res.status === 403) throw new Error('forbidden')
if (!res.ok) throw new Error(`get failed: ${res.status}`)
return URL.createObjectURL(await res.blob())
}

View File

@ -9,21 +9,32 @@ import {
type PointerEvent as ReactPointerEvent,
} from 'react'
import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos'
import {
listFolderPhotos,
uploadFolderPhoto,
deleteFolderPhoto,
fetchFolderPhotoUrl,
} from '../api/folders'
/*
Photo Wall
/便/ localStorage PNG
docs v1.0 React
#photo#<文件夹名>
DOM transformGPU React
commit setState
*/
interface PhotoWallPageProps {
/** 退出照片墙(清登录态 + 抹掉地址栏 #photo,回到导航页) */
/** 退出照片墙(清登录态 + 抹掉地址栏 #hash,回到导航页) */
onExit: () => void
/** 当前会话 JWT供后续服务端图片接口使用Task 6 正式消费) */
/** 当前会话 JWT */
token: string
/** 特殊文件夹名:设置后走文件夹接口(共享相册),不设则走个人照片墙 */
folderName?: string
/** 当前用户是否该文件夹的管理员(仅 folderName 模式下有意义) */
isAdmin?: boolean
/** 403 无权访问时回调 */
onForbidden?: () => void
}
type ElType = 'photo' | 'note' | 'tape' | 'sticker' | 'stamp' | 'doodle'
@ -55,7 +66,6 @@ interface El {
path?: string
}
const STORAGE_KEY = 'photo-wall-state'
const HISTORY_LIMIT = 30
const H2C_SRC = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'
@ -83,7 +93,6 @@ const randInt = (a: number, b: number) => Math.floor(rand(a, b + 1))
const pick = <T,>(arr: T[]): T => arr[randInt(0, arr.length - 1)]
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
// #rrggbb 调亮/调暗(便签折角取底色更深 15%)。
function shade(hex: string, amt: number) {
if (hex[0] !== '#' || hex.length < 7) return hex
const ch = (i: number) =>
@ -114,40 +123,47 @@ interface SavedState {
photoLayout: Record<string, PhotoLayout>
}
function loadInitial() {
function loadInitial(storageKey: string) {
let decorations: El[] = []
let photoLayout: Record<string, PhotoLayout> = {}
let bgIndex = 0
try {
const raw = localStorage.getItem(STORAGE_KEY)
const raw = localStorage.getItem(storageKey)
if (raw) {
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 {
/* 损坏的存档当作空墙 */
}
} 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 }
}
export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
const initial = useMemo(() => loadInitial(), [])
/** 人类可读的字节大小 */
function fmtBytes(n: number) {
if (n >= 1024 * 1024 * 1024) return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`
return `${n} B`
}
export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden }: PhotoWallPageProps) {
const storageKey = folderName ? `photo-wall-state:folder:${folderName}` : 'photo-wall-state'
const isPersonal = !folderName
const maxPhotos = isPersonal ? 10 : 0 // 0 = unlimited
const initial = useMemo(() => loadInitial(storageKey), [])
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)
@ -159,6 +175,16 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
const [dragHint, setDragHint] = useState(false)
const [saveLabel, setSaveLabel] = useState('已保存')
const [toast, setToast] = useState<string | null>(null)
// 特殊文件夹配额信息
const [quotaBytes, setQuotaBytes] = useState(0)
const [overQuota, setOverQuota] = useState(false)
// 右键菜单滑条:旋转步长 / 缩放步长
const [rotStep, setRotStep] = useState(15)
const [zoomStepPx, setZoomStepPx] = useState(30)
const [zoomStepScale, setZoomStepScale] = useState(0.25)
// 加载中 / 403 状态
const [loading, setLoading] = useState(!isPersonal)
const [forbidden, setForbidden] = useState(false)
const wallRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef(new Map<number, HTMLDivElement>())
@ -166,16 +192,13 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
const fileRef = useRef<HTMLInputElement>(null)
const idRef = useRef(initial.nextId)
const zRef = useRef(initial.maxZ)
const elsRef = useRef(els) // 给拖拽起点取快照用(避免闭包旧值)
const elsRef = useRef(els)
const undoRef = useRef<string[]>([])
const redoRef = useRef<string[]>([])
const doodleRef = useRef(false)
const toastTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
// 拖拽起点取快照需要最新 elsref 在 effect 里同步,避免渲染期写 ref。
useEffect(() => {
elsRef.current = els
})
useEffect(() => { elsRef.current = els })
const showToast = useCallback((msg: string) => {
setToast(msg)
@ -183,7 +206,6 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
toastTimer.current = setTimeout(() => setToast(null), 1800)
}, [])
// 标记“有未保存改动”,由下方防抖 effect 在 1s 后落盘。
const markDirty = useCallback(() => setSaveLabel('保存中…'), [])
/* ── 历史栈 ──────────────────────────────────────────────── */
@ -193,7 +215,6 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
redoRef.current.length = 0
}, [])
// 记一步历史并改状态。离散操作(增删改旋转)都走这里。
const mutate = useCallback(
(updater: (prev: El[]) => El[]) => {
markDirty()
@ -222,15 +243,11 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
})
}, [markDirty])
/* 1s
mutate/undo/redo//
effect */
/* ── 自动保存(防抖 1s────────────────────────────────────── */
useEffect(() => {
const t = setTimeout(() => {
try {
const decorations = els.filter((e) => e.type !== 'photo')
// 从已知排版起步而非清空:未加载完/取图失败的照片其排版得以保留,
// 已加载或移动过的照片在下面覆盖各自的 key删除时 removeEl 已从 layoutRef 删键)。
const photoLayout: Record<string, PhotoLayout> = { ...layoutRef.current }
for (const e of els) {
if (e.type === 'photo' && e.photoId) {
@ -241,7 +258,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
}
}
layoutRef.current = photoLayout
localStorage.setItem(STORAGE_KEY, JSON.stringify({ bgIndex, decorations, photoLayout }))
localStorage.setItem(storageKey, JSON.stringify({ bgIndex, decorations, photoLayout }))
setSaveLabel('已保存')
} catch {
setSaveLabel('保存失败')
@ -249,9 +266,9 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
}
}, 1000)
return () => clearTimeout(t)
}, [els, bgIndex, showToast])
}, [els, bgIndex, showToast, storageKey])
/* ── 懒加载 html2canvas(导出用,非阻塞)──────────────────── */
/* ── 懒加载 html2canvas ──────────────────────────────────── */
useEffect(() => {
if (document.querySelector(`script[src="${H2C_SRC}"]`)) return
const s = document.createElement('script')
@ -279,31 +296,41 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
[mutate],
)
// 登录后从服务器拉本用户图片:取字节为 objectURL套用本地排版或自动散落。
/* ── 图片加载(个人墙 / 文件夹统一处理)────────────────────── */
useEffect(() => {
if (!token) return
let alive = true
;(async () => {
try {
// 重新加载前清掉旧会话的照片,避免重复堆叠与 objectURL 泄漏
for (const u of objUrls.current) URL.revokeObjectURL(u)
objUrls.current = []
setEls((prev) => prev.filter((e) => e.type !== 'photo'))
const { items } = await listPhotos(token)
const listFn = isPersonal
? () => listPhotos(token).then(r => ({ items: r.items, max: r.max, totalBytes: 0, overQuota: false }))
: () => listFolderPhotos(token, folderName!)
const { items, totalBytes, overQuota: over } = await listFn()
if (!alive) return
if (totalBytes !== undefined) {
setQuotaBytes(totalBytes)
setOverQuota(over ?? false)
}
setLoading(false)
for (const item of items) {
let url: string
try {
url = await fetchPhotoObjectUrl(token, item.id)
url = isPersonal
? await fetchPhotoObjectUrl(token, item.id)
: await fetchFolderPhotoUrl(token, folderName!, item.id)
} catch {
continue
}
if (!alive) {
URL.revokeObjectURL(url)
return
}
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 })
@ -311,6 +338,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
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'
@ -332,16 +360,20 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
if (layout) zRef.current = Math.max(zRef.current, layout.z)
setEls((prev) => [...prev, base])
}
} catch {
if (alive) showToast('图片加载失败(请重新登录或检查网络)')
} catch (err) {
if (!alive) return
if (err instanceof Error && err.message === 'forbidden') {
setForbidden(true)
onForbidden?.()
} else {
if (!isPersonal) setLoading(false)
showToast('图片加载失败(请重新登录或检查网络)')
}
}
})()
return () => {
alive = false
}
// 仅在挂载/换 token 时跑一次
return () => { alive = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
}, [token, folderName])
// 卸载时释放所有 objectURL
useEffect(() => {
@ -377,7 +409,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
})
}, [addEl, spawnPos])
/* ── 图片上传(按钮 / 拖入 / 粘贴)─────────────────────────── */
/* ── 图片上传 ─────────────────────────────────────────────── */
const handleFiles = useCallback(
(files: FileList | File[]) => {
const list = Array.from(files)
@ -392,13 +424,19 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
const reader = new FileReader()
reader.onload = (ev) => {
const src = ev.target?.result as string
// 满 10 张直接拦下
const count = elsRef.current.filter((e) => e.type === 'photo').length
if (count >= 10) {
showToast('每位用户最多 10 张照片')
return
// 个人墙有张数上限
if (maxPhotos > 0) {
const count = elsRef.current.filter((e) => e.type === 'photo').length
if (count >= maxPhotos) {
showToast(`每位用户最多 ${maxPhotos} 张照片`)
return
}
}
uploadPhoto(token, src)
const uploadFn = isPersonal
? (t: string, d: string) => uploadPhoto(t, d)
: (t: string, d: string) => uploadFolderPhoto(t, folderName!, d)
uploadFn(token, src)
.then((id) => {
const img = new Image()
img.onload = () => {
@ -416,9 +454,13 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
img.src = src
})
.catch((err: Error) => {
if (err.message === 'forbidden') {
onForbidden?.()
return
}
const reason = err.message as UploadError
showToast(
reason === 'full' ? '每位用户最多 10 张照片'
reason === 'full' ? `每位用户最多 ${maxPhotos} 张照片`
: reason === 'too-large' ? '图片过大(上限 8MB'
: reason === 'bad-type' ? '不支持的图片格式'
: '上传失败',
@ -428,7 +470,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
reader.readAsDataURL(file)
})
},
[addEl, spawnPos, showToast, token],
[addEl, spawnPos, showToast, token, isPersonal, folderName, maxPhotos, onForbidden],
)
useEffect(() => {
@ -446,8 +488,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
return () => window.removeEventListener('paste', onPaste)
}, [handleFiles])
/* Pointer Events +
DOM setState commit */
/* ── 拖拽 ──────────────────────────────────────────────────── */
const onElPointerDown = useCallback(
(e: ReactPointerEvent, el: El) => {
if (e.button === 2 || doodleRef.current) return
@ -469,7 +510,6 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
node.classList.add('pw-dragging')
const snapshot = JSON.stringify(elsRef.current)
// 触摸长按 = 右键菜单
let longPress: ReturnType<typeof setTimeout> | undefined
if (e.pointerType === 'touch') {
longPress = setTimeout(() => {
@ -505,7 +545,6 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
redoRef.current.length = 0
setEls((prev) => prev.map((p) => (p.id === el.id ? { ...p, x: curX, y: curY, z: newZ } : p)))
} else {
// 纯点击:仅把层级提到最前(不记历史)
setEls((prev) => prev.map((p) => (p.id === el.id ? { ...p, z: newZ } : p)))
}
}
@ -542,10 +581,14 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
mutate((p) => p.map((e) => (e.id === id ? { ...e, rot: deg } : e)))
const setScale = (id: number, s: number) =>
mutate((p) => p.map((e) => (e.id === id ? { ...e, scale: s } : e)))
const removeEl = (id: number) => {
const removeEl = useCallback((id: number) => {
const target = elsRef.current.find((e) => e.id === id)
if (target?.type === 'photo' && target.photoId) {
deletePhoto(token, target.photoId).catch(() => {})
if (isPersonal) {
deletePhoto(token, target.photoId).catch(() => {})
} else {
deleteFolderPhoto(token, folderName!, target.photoId).catch(() => {})
}
delete layoutRef.current[target.photoId]
if (target.src?.startsWith('blob:')) {
URL.revokeObjectURL(target.src)
@ -555,9 +598,9 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
mutate((p) => p.filter((e) => e.id !== id))
nodeRefs.current.delete(id)
setMenu(null)
}
}, [mutate, token, isPersonal, folderName])
// 菜单贴边自动翻转,避免超出视口
// 菜单贴边自动翻转
useLayoutEffect(() => {
if (!menu || !menuRef.current) return
const m = menuRef.current
@ -571,6 +614,15 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
m.style.top = Math.max(4, py) + 'px'
}, [menu])
// 初始化菜单滑条值
useEffect(() => {
if (menu) {
setRotStep(15)
setZoomStepPx(30)
setZoomStepScale(0.25)
}
}, [menu?.id])
// 点空白处关闭菜单/下拉
useEffect(() => {
const onDown = (e: globalThis.PointerEvent) => {
@ -582,7 +634,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
return () => window.removeEventListener('pointerdown', onDown)
}, [])
/* ── 双击编辑(照片标注 / 便签)──────────────────────────── */
/* ── 双击编辑 ────────────────────────────────────────────── */
const startEdit = (el: El) => {
setEditText(el.type === 'photo' ? el.caption ?? '' : el.text ?? '')
setEditingId(el.id)
@ -597,7 +649,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
setEditingId(null)
}
/* ── 手绘箭头:进入模式后在画布上拖动绘制 ──────────────────── */
/* ── 手绘箭头 ────────────────────────────────────────────── */
const startDoodle = () => {
doodleRef.current = true
setDoodleMode(true)
@ -613,10 +665,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
const sy = e.clientY - r.top
let ex = sx
let ey = sy
const move = (ev: PointerEvent) => {
ex = ev.clientX - r.left
ey = ev.clientY - r.top
}
const move = (ev: PointerEvent) => { ex = ev.clientX - r.left; ey = ev.clientY - r.top }
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
@ -646,17 +695,13 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
}
/* ── 拖入图片 ────────────────────────────────────────────── */
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
setDragHint(true)
}
const onDragOver = (e: React.DragEvent) => { e.preventDefault(); setDragHint(true) }
const onDragLeave = (e: React.DragEvent) => {
if (wallRef.current?.contains(e.relatedTarget as Node)) return
setDragHint(false)
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragHint(false)
e.preventDefault(); setDragHint(false)
if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files)
}
@ -673,10 +718,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
const exportImage = () => {
type H2C = (el: HTMLElement, opts: Record<string, unknown>) => Promise<HTMLCanvasElement>
const h2c = (window as unknown as { html2canvas?: H2C }).html2canvas
if (!h2c || !wallRef.current) {
showToast('导出库加载中,请稍候重试')
return
}
if (!h2c || !wallRef.current) { showToast('导出库加载中,请稍候重试'); return }
setMenu(null)
showToast('正在生成图片…')
h2c(wallRef.current, { backgroundColor: null, scale: window.devicePixelRatio || 1, useCORS: true })
@ -702,11 +744,12 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
const k = e.key.toLowerCase()
if (mod && k === 'z' && !e.shiftKey) { e.preventDefault(); undo() }
else if (mod && (k === 'y' || (k === 'z' && e.shiftKey))) { e.preventDefault(); redo() }
else if (e.key === 'Delete' && menu) { e.preventDefault(); removeEl(menu.id) }
else if (e.key === 'Escape') { setMenu(null); doodleRef.current = false; setDoodleMode(false) }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [undo, redo])
}, [undo, redo, menu, removeEl])
/* ── 渲染单个元素 ────────────────────────────────────────── */
const setNodeRef = (id: number) => (n: HTMLDivElement | null) => {
@ -720,7 +763,7 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
return (
<>
<div className="pw-imgwrap" style={{ height: el.h }}>
<img src={el.src} alt="" />
<img key={`${el.id}-${el.w}-${el.h}`} src={el.src} alt="" />
</div>
{editingId === el.id ? (
<input
@ -822,12 +865,13 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
}
const menuEl = menu ? els.find((e) => e.id === menu.id) : null
const isMenuPhoto = menuEl?.type === 'photo'
return (
<div className="pw-root">
{/* ── 工具栏 ── */}
<div className="pw-toolbar">
<span className="pw-logo">📸 Photo Wall</span>
<span className="pw-logo">{folderName ? `📁 ${folderName}` : '📸 Photo Wall'}</span>
<button className="pw-tbtn" onClick={() => fileRef.current?.click()}></button>
<button className="pw-tbtn" onClick={makeNote}>便</button>
@ -884,6 +928,13 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
<button className="pw-tbtn pw-danger" onClick={onExit}>退</button>
</div>
{/* ── 管理员配额警告 ── */}
{isAdmin && overQuota && (
<div className="pw-quota-bar">
1GB {fmtBytes(quotaBytes)}
</div>
)}
{/* ── 画布 ── */}
<div
ref={wallRef}
@ -896,6 +947,12 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
onDrop={onDrop}
onContextMenu={(e) => { if (e.target === wallRef.current) e.preventDefault() }}
>
{loading && (
<div className="pw-drophint" style={{ opacity: 1 }}></div>
)}
{forbidden && (
<div className="pw-drophint" style={{ opacity: 1, color: '#E74C3C' }}>访</div>
)}
{els.map((el) => (
<div
key={el.id}
@ -920,20 +977,50 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
{/* ── 右键菜单 ── */}
{menu && menuEl && (
<div ref={menuRef} className="pw-menu pw-pop" style={{ left: menu.x, top: menu.y, minWidth: 170 }}>
<div ref={menuRef} className="pw-menu pw-pop" style={{ left: menu.x, top: menu.y, minWidth: 200 }}>
{menuEl.type !== 'doodle' && (
<>
<div className="pw-item" onClick={() => { rotate(menu.id, 15); setMenu(null) }}> +15°</div>
<div className="pw-item" onClick={() => { rotate(menu.id, -15); setMenu(null) }}> -15°</div>
{menuEl.type === 'photo' ? (
{/* 旋转步长滑条 */}
<div className="pw-slider-row">
<span className="pw-preset-label"></span>
<input
type="range"
min={1} max={90} value={rotStep}
onChange={(e) => setRotStep(Number(e.target.value))}
/>
<span className="pw-preset-val">{rotStep}°</span>
</div>
<div className="pw-item" onClick={() => { rotate(menu.id, rotStep); setMenu(null) }}> +{rotStep}°</div>
<div className="pw-item" onClick={() => { rotate(menu.id, -rotStep); setMenu(null) }}> -{rotStep}°</div>
{/* 缩放步长滑条 */}
{isMenuPhoto ? (
<>
<div className="pw-item" onClick={() => { resizePhoto(menu.id, 30); setMenu(null) }}></div>
<div className="pw-item" onClick={() => { resizePhoto(menu.id, -30); setMenu(null) }}></div>
<div className="pw-slider-row">
<span className="pw-preset-label"></span>
<input
type="range"
min={10} max={100} step={5} value={zoomStepPx}
onChange={(e) => setZoomStepPx(Number(e.target.value))}
/>
<span className="pw-preset-val">{zoomStepPx}px</span>
</div>
<div className="pw-item" onClick={() => { resizePhoto(menu.id, zoomStepPx); setMenu(null) }}></div>
<div className="pw-item" onClick={() => { resizePhoto(menu.id, -zoomStepPx); setMenu(null) }}></div>
</>
) : (
<>
<div className="pw-item" onClick={() => { scaleEl(menu.id, 0.25); setMenu(null) }}></div>
<div className="pw-item" onClick={() => { scaleEl(menu.id, -0.25); setMenu(null) }}></div>
<div className="pw-slider-row">
<span className="pw-preset-label"></span>
<input
type="range"
min={0.05} max={1} step={0.05} value={zoomStepScale}
onChange={(e) => setZoomStepScale(Number(e.target.value))}
/>
<span className="pw-preset-val">{zoomStepScale.toFixed(2)}×</span>
</div>
<div className="pw-item" onClick={() => { scaleEl(menu.id, zoomStepScale); setMenu(null) }}></div>
<div className="pw-item" onClick={() => { scaleEl(menu.id, -zoomStepScale); setMenu(null) }}></div>
</>
)}
<div className="pw-sep" />

View File

@ -369,12 +369,21 @@ textarea,
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.pw-menu { position: fixed; z-index: 40; transform-origin: top left; animation: pwMenuIn 0.12s ease; }
.pw-menu { position: fixed; z-index: 9999; transform-origin: top left; animation: pwMenuIn 0.12s ease; }
@keyframes pwMenuIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
/* 右键菜单内的滑条行 */
.pw-slider-row { display: flex; align-items: center; gap: 8px; padding: 6px 12px; }
.pw-slider-row .pw-preset-label { min-width: 56px; font-size: 13px; color: #555; }
.pw-slider-row input[type="range"] { flex: 1; height: 4px; accent-color: #3498DB; }
.pw-slider-row .pw-preset-val { min-width: 42px; text-align: right; font-size: 12px; color: #888; font-variant-numeric: tabular-nums; }
/* 管理员配额警告条 */
.pw-quota-bar { position: sticky; top: 44px; z-index: 30; background: #FDEBD0; color: #7D6608; text-align: center; padding: 6px 16px; font-size: 13px; border-bottom: 1px solid #F5B041; }
/* ── 画布与四种背景 ─────────────────────────────────────────── */
.pw-wall {
position: absolute;

192
Server/src/folders.rs Normal file
View File

@ -0,0 +1,192 @@
//! 特殊文件夹:共享公共相册,按文件夹白名单控制访问。
//! 图片存储于 SPECIAL_DIR/<文件夹名>/,每图随机 hex 文件名,无张数上限。
//! 软配额 1 GiB——超额不拦上传仅在列表接口返回 overQuota 标志。
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use crate::photos::{self, SaveError, valid_id};
/// 管理员用户名(写死)
pub const ADMIN_USER: &str = "cc";
/// 软配额阈值1 GiB
pub const QUOTA_BYTES: u64 = 1024 * 1024 * 1024;
/* ════════════════════════════════════════════════════════════════
special_folders.txt ` ,`
*/
#[derive(Clone)]
pub struct FolderRegistry {
folders: Arc<HashMap<String, HashSet<String>>>,
}
impl FolderRegistry {
/// 从 `SPECIAL_FOLDERS_FILE` 载入,默认 `special_folders.txt`。
pub fn from_env() -> Self {
let path = std::env::var("SPECIAL_FOLDERS_FILE")
.unwrap_or_else(|_| "special_folders.txt".to_string());
Self::load(&path)
}
fn load(path: &str) -> Self {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(_) => {
eprintln!("[folders] 未找到 {path},特殊文件夹功能不可用");
return Self { folders: Arc::new(HashMap::new()) };
}
};
let mut map: HashMap<String, HashSet<String>> = HashMap::new();
for line in text.lines() {
let content = line.split('#').next().unwrap_or("").trim();
if content.is_empty() { continue; }
let mut parts = content.split_whitespace();
let name = match parts.next() {
Some(n) => n.to_string(),
None => continue,
};
let users_str = parts.next().unwrap_or("");
let users: HashSet<String> = users_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// 名字校验复用 sanitize_user同字符集白名单
if !name.is_empty() && photos::sanitize_user(&name).is_some() {
eprintln!("[folders] 注册文件夹: {name} -> {:?}", users);
map.insert(name, users);
} else if !name.is_empty() {
eprintln!("[folders] 跳过非法文件夹名: {name}");
}
}
eprintln!("[folders] 共注册 {} 个特殊文件夹", map.len());
Self { folders: Arc::new(map) }
}
/// 该文件夹名是否已注册
pub fn exists(&self, name: &str) -> bool {
self.folders.contains_key(name)
}
/// 用户是否有权访问该文件夹(在名单内 或 是管理员)
pub fn allows(&self, folder: &str, user: &str) -> bool {
if user == ADMIN_USER { return true; }
self.folders.get(folder)
.map(|users| users.contains(user))
.unwrap_or(false)
}
/// 仅判断注册(不关心用户),用于区分 404 vs 403
pub fn is_registered(&self, name: &str) -> bool {
self.folders.contains_key(name)
}
}
/* ════════════════════════════════════════════════════════════════
SPECIAL_DIR/<>/
*/
#[derive(Clone)]
pub struct FolderStore {
base: PathBuf,
}
impl FolderStore {
pub fn from_env() -> Self {
let base = std::env::var("SPECIAL_DIR")
.unwrap_or_else(|_| "special-folders".to_string());
Self { base: base.into() }
}
pub fn new(base: impl Into<PathBuf>) -> Self {
Self { base: base.into() }
}
fn folder_dir(&self, folder: &str) -> Option<PathBuf> {
let safe = photos::sanitize_user(folder)?;
Some(self.base.join(safe))
}
/// 列出图片 id 并统计目录总字节 → (items, totalBytes)
pub fn list(&self, folder: &str) -> (Vec<String>, u64) {
let dir = match self.folder_dir(folder) {
Some(d) => d,
None => return (vec![], 0),
};
let mut items = Vec::new();
let mut total: u64 = 0;
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
if let Ok(meta) = entry.metadata() {
total += meta.len();
}
if let Some(name) = entry.file_name().to_str() {
if valid_id(name) {
items.push(name.to_string());
}
}
}
}
items.sort();
(items, total)
}
/// 保存图片(无张数上限),返回分配的 id
pub fn save(&self, folder: &str, data_url: &str) -> Result<String, SaveError> {
let dir = self.folder_dir(folder).ok_or(SaveError::BadUser)?;
let (ext, bytes) = photos::parse_data_url(data_url)?;
std::fs::create_dir_all(&dir).map_err(|_| SaveError::Io)?;
let id = format!("{}.{}", new_stem(), ext);
std::fs::write(dir.join(&id), &bytes).map_err(|_| SaveError::Io)?;
Ok(id)
}
/// 读取图片 → (Content-Type, bytes)
pub fn read(&self, folder: &str, id: &str) -> Option<(&'static str, Vec<u8>)> {
if !valid_id(id) { return None; }
let dir = self.folder_dir(folder)?;
let bytes = std::fs::read(dir.join(id)).ok()?;
let ext = id.rsplit_once('.').map(|(_, e)| e)?;
Some((mime_for_ext(ext), bytes))
}
/// 删除图片,成功返回 true
pub fn delete(&self, folder: &str, id: &str) -> bool {
if !valid_id(id) { return false; }
match self.folder_dir(folder) {
Some(dir) => std::fs::remove_file(dir.join(id)).is_ok(),
None => false,
}
}
}
/* ── 复用 photos.rs 的内部工具 ─────────────────────────────────── */
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
/// 生成 16 位 hex 文件名干(与 photos.rs 同逻辑)
fn new_stem() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let digest = Sha256::digest(format!("{nanos}-{n}").as_bytes());
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
}
/// 扩展名 → MIME 类型(与 photos.rs 同逻辑)
fn mime_for_ext(ext: &str) -> &'static str {
match ext {
"jpg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "application/octet-stream",
}
}

View File

@ -5,6 +5,7 @@
//! - `GET /api/client/health` 给未来 .NET 客户端(不挂 CORS
mod auth;
mod folders;
mod monitor;
mod photos;
mod routes;

View File

@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::photos::{SaveError, MAX_PHOTOS};
use crate::folders::{FolderRegistry, QUOTA_BYTES};
use crate::photos::{SaveError, MAX_BYTES, MAX_PHOTOS};
use crate::routes::extract::AuthUser;
use crate::{
@ -44,12 +45,20 @@ pub fn router(state: AppState) -> Router {
.route("/api/web/photos/{id}", get(get_photo).delete(delete_photo))
.layer(DefaultBodyLimit::max(16 * 1024 * 1024));
// 特殊文件夹路由无张数上限body 上限同照片接口)
let folder_routes = Router::new()
.route("/api/web/folder", post(check_exists))
.route("/api/web/folder/{name}/photos", get(list_folder_photos).post(upload_folder_photo))
.route("/api/web/folder/{name}/photos/{id}", get(get_folder_photo).delete(delete_folder_photo))
.layer(DefaultBodyLimit::max(16 * 1024 * 1024));
Router::new()
.route("/api/web/stats", get(stats))
.route("/api/web/gate", post(gate))
.route("/api/web/login", post(login))
.route("/api/web/weather", get(weather))
.merge(photo_routes)
.merge(folder_routes)
.layer(cors)
.with_state(state)
}
@ -264,3 +273,128 @@ async fn delete_photo(
StatusCode::NOT_FOUND
}
}
/* ════════════════════════════════════════════════════════════════
*/
/// 校验文件夹访问权限:未注册 → 404无权 → 403
fn check_access(registry: &FolderRegistry, folder: &str, user: &str) -> Result<(), StatusCode> {
if !registry.is_registered(folder) {
return Err(StatusCode::NOT_FOUND);
}
if !registry.allows(folder, user) {
return Err(StatusCode::FORBIDDEN);
}
Ok(())
}
#[derive(Deserialize)]
struct FolderCheckRequest {
name: String,
}
#[derive(Serialize)]
struct FolderCheckResponse {
ok: bool,
}
/// POST /api/web/folder — 检查文件夹名是否已注册(登录前,无鉴权)
async fn check_exists(
State(state): State<AppState>,
Json(req): Json<FolderCheckRequest>,
) -> Json<FolderCheckResponse> {
Json(FolderCheckResponse {
ok: state.folders.exists(&req.name),
})
}
#[derive(Serialize)]
struct FolderPhotoList {
items: Vec<FolderPhotoItem>,
#[serde(skip_serializing_if = "Option::is_none")]
max: Option<usize>,
total_bytes: u64,
over_quota: bool,
}
#[derive(Serialize)]
struct FolderPhotoItem {
id: String,
}
/// GET /api/web/folder/{name}/photos
async fn list_folder_photos(
AuthUser(user): AuthUser,
State(state): State<AppState>,
AxPath(name): AxPath<String>,
) -> Result<Json<FolderPhotoList>, StatusCode> {
check_access(&state.folders, &name, &user)?;
let (items, total_bytes) = state.folder_photos.list(&name);
Ok(Json(FolderPhotoList {
items: items.into_iter().map(|id| FolderPhotoItem { id }).collect(),
max: None,
total_bytes,
over_quota: total_bytes > QUOTA_BYTES,
}))
}
#[derive(Deserialize)]
struct FolderUploadRequest {
#[serde(rename = "dataUrl")]
data_url: String,
}
#[derive(Serialize)]
struct FolderUploadResponse {
id: String,
}
/// POST /api/web/folder/{name}/photos
async fn upload_folder_photo(
AuthUser(user): AuthUser,
State(state): State<AppState>,
AxPath(name): AxPath<String>,
Json(req): Json<FolderUploadRequest>,
) -> Result<(StatusCode, Json<FolderUploadResponse>), StatusCode> {
check_access(&state.folders, &name, &user)?;
if req.data_url.len() > MAX_BYTES * 2 {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
match state.folder_photos.save(&name, &req.data_url) {
Ok(id) => Ok((StatusCode::CREATED, Json(FolderUploadResponse { id }))),
Err(SaveError::TooLarge) => Err(StatusCode::PAYLOAD_TOO_LARGE),
Err(SaveError::BadType) => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE),
Err(SaveError::BadUser) => Err(StatusCode::BAD_REQUEST),
Err(SaveError::Io) => Err(StatusCode::INTERNAL_SERVER_ERROR),
Err(SaveError::Full) => Err(StatusCode::INSUFFICIENT_STORAGE),
}
}
/// GET /api/web/folder/{name}/photos/{id}
async fn get_folder_photo(
AuthUser(user): AuthUser,
State(state): State<AppState>,
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_photos.read(&name, &id) {
Some((mime, bytes)) => ([(header::CONTENT_TYPE, mime)], bytes).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
/// DELETE /api/web/folder/{name}/photos/{id}
async fn delete_folder_photo(
AuthUser(user): AuthUser,
State(state): State<AppState>,
AxPath((name, id)): AxPath<(String, String)>,
) -> StatusCode {
if check_access(&state.folders, &name, &user).is_err() {
return StatusCode::NOT_FOUND;
}
if state.folder_photos.delete(&name, &id) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}

View File

@ -8,6 +8,7 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use crate::auth::{self, DevGate, UserStore};
use crate::folders::{FolderRegistry, FolderStore};
use crate::monitor::Stats;
use crate::photos::PhotoStore;
use crate::weather::WeatherService;
@ -24,6 +25,10 @@ pub struct AppState {
pub weather: Arc<WeatherService>,
/// 照片墙图片仓库(按用户隔离的磁盘目录)。
pub photos: PhotoStore,
/// 特殊文件夹注册表(白名单)。
pub folders: FolderRegistry,
/// 特殊文件夹图片仓库(按文件夹隔离的磁盘目录)。
pub folder_photos: FolderStore,
}
impl AppState {
@ -36,6 +41,8 @@ impl AppState {
gate: Arc::new(DevGate::new(pool)),
weather: Arc::new(WeatherService::from_env()),
photos: PhotoStore::from_env(),
folders: FolderRegistry::from_env(),
folder_photos: FolderStore::from_env(),
}
}
}