diff --git a/Client/src/App.tsx b/Client/src/App.tsx index 0e81055..0238f03 100644 --- a/Client/src/App.tsx +++ b/Client/src/App.tsx @@ -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(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 - ? - : - } else if (wantsPhoto) { - // #photo 未登录:复用登录页,但去掉顶部服务器负载栏。 + if (wantsPhoto) { + view = + } else if (wantsFolder) { + view = ( + + ) + } else { + view = + } + } else if (wantsPhoto || wantsFolder) { + // 照片墙 / 特殊文件夹未登录:复用登录页,去掉顶部服务器负载栏 view = } else if (showLogin) { view = diff --git a/Client/src/api/folders.ts b/Client/src/api/folders.ts new file mode 100644 index 0000000..bf681da --- /dev/null +++ b/Client/src/api/folders.ts @@ -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 { + return { Authorization: `Bearer ${token}` } +} + +/** 检查文件夹是否已注册(登录前,无鉴权) */ +export async function checkFolder(name: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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()) +} diff --git a/Client/src/components/PhotoWallPage.tsx b/Client/src/components/PhotoWallPage.tsx index 5cad78b..edcf394 100644 --- a/Client/src/components/PhotoWallPage.tsx +++ b/Client/src/components/PhotoWallPage.tsx @@ -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 的 transform(GPU 合成),不触发 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 = (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 } -function loadInitial() { +function loadInitial(storageKey: string) { let decorations: El[] = [] let photoLayout: Record = {} let bgIndex = 0 try { - const raw = localStorage.getItem(STORAGE_KEY) + const raw = localStorage.getItem(storageKey) if (raw) { 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 { - /* 损坏的存档当作空墙 */ - } + } 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(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) @@ -159,6 +175,16 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) { const [dragHint, setDragHint] = useState(false) const [saveLabel, setSaveLabel] = useState('已保存') const [toast, setToast] = useState(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(null) const nodeRefs = useRef(new Map()) @@ -166,16 +192,13 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) { const fileRef = useRef(null) const idRef = useRef(initial.nextId) const zRef = useRef(initial.maxZ) - const elsRef = useRef(els) // 给拖拽起点取快照用(避免闭包旧值) + const elsRef = useRef(els) const undoRef = useRef([]) const redoRef = useRef([]) const doodleRef = useRef(false) const toastTimer = useRef | undefined>(undefined) - // 拖拽起点取快照需要最新 els;ref 在 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 = { ...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 | 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) => Promise 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 ( <>
- +
{editingId === el.id ? ( e.id === menu.id) : null + const isMenuPhoto = menuEl?.type === 'photo' return (
{/* ── 工具栏 ── */}
- 📸 Photo Wall + {folderName ? `📁 ${folderName}` : '📸 Photo Wall'} @@ -884,6 +928,13 @@ export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
+ {/* ── 管理员配额警告 ── */} + {isAdmin && overQuota && ( +
+ 该相册已超 1GB(当前 {fmtBytes(quotaBytes)}),请清理 +
+ )} + {/* ── 画布 ── */}
{ if (e.target === wallRef.current) e.preventDefault() }} > + {loading && ( +
加载中…
+ )} + {forbidden && ( +
无权访问该相册
+ )} {els.map((el) => (
+
{menuEl.type !== 'doodle' && ( <> -
{ rotate(menu.id, 15); setMenu(null) }}>旋转 +15°
-
{ rotate(menu.id, -15); setMenu(null) }}>旋转 -15°
- {menuEl.type === 'photo' ? ( + {/* 旋转步长滑条 */} +
+ 旋转步长 + setRotStep(Number(e.target.value))} + /> + {rotStep}° +
+
{ rotate(menu.id, rotStep); setMenu(null) }}>旋转 +{rotStep}°
+
{ rotate(menu.id, -rotStep); setMenu(null) }}>旋转 -{rotStep}°
+ + {/* 缩放步长滑条 */} + {isMenuPhoto ? ( <> -
{ resizePhoto(menu.id, 30); setMenu(null) }}>放大
-
{ resizePhoto(menu.id, -30); setMenu(null) }}>缩小
+
+ 缩放步长 + setZoomStepPx(Number(e.target.value))} + /> + {zoomStepPx}px +
+
{ resizePhoto(menu.id, zoomStepPx); setMenu(null) }}>放大
+
{ resizePhoto(menu.id, -zoomStepPx); setMenu(null) }}>缩小
) : ( <> -
{ scaleEl(menu.id, 0.25); setMenu(null) }}>放大
-
{ scaleEl(menu.id, -0.25); setMenu(null) }}>缩小
+
+ 缩放步长 + setZoomStepScale(Number(e.target.value))} + /> + {zoomStepScale.toFixed(2)}× +
+
{ scaleEl(menu.id, zoomStepScale); setMenu(null) }}>放大
+
{ scaleEl(menu.id, -zoomStepScale); setMenu(null) }}>缩小
)}
diff --git a/Client/src/index.css b/Client/src/index.css index 6fbb908..f50bc7a 100644 --- a/Client/src/index.css +++ b/Client/src/index.css @@ -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; diff --git a/Server/src/folders.rs b/Server/src/folders.rs new file mode 100644 index 0000000..b4c9776 --- /dev/null +++ b/Server/src/folders.rs @@ -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>>, +} + +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> = 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 = 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) -> Self { + Self { base: base.into() } + } + + fn folder_dir(&self, folder: &str) -> Option { + let safe = photos::sanitize_user(folder)?; + Some(self.base.join(safe)) + } + + /// 列出图片 id 并统计目录总字节 → (items, totalBytes) + pub fn list(&self, folder: &str) -> (Vec, 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 { + 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)> { + 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", + } +} diff --git a/Server/src/main.rs b/Server/src/main.rs index af77245..d4df461 100644 --- a/Server/src/main.rs +++ b/Server/src/main.rs @@ -5,6 +5,7 @@ //! - `GET /api/client/health` 给未来 .NET 客户端(不挂 CORS) mod auth; +mod folders; mod monitor; mod photos; mod routes; diff --git a/Server/src/routes/web.rs b/Server/src/routes/web.rs index d546997..7d85811 100644 --- a/Server/src/routes/web.rs +++ b/Server/src/routes/web.rs @@ -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, + Json(req): Json, +) -> Json { + Json(FolderCheckResponse { + ok: state.folders.exists(&req.name), + }) +} + +#[derive(Serialize)] +struct FolderPhotoList { + items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + max: Option, + 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, + AxPath(name): AxPath, +) -> Result, 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, + AxPath(name): AxPath, + Json(req): Json, +) -> Result<(StatusCode, Json), 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, + 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, + 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 + } +} diff --git a/Server/src/state.rs b/Server/src/state.rs index 728aff1..a3a2b15 100644 --- a/Server/src/state.rs +++ b/Server/src/state.rs @@ -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, /// 照片墙图片仓库(按用户隔离的磁盘目录)。 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(), } } }