@
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:
parent
fbac6de977
commit
8d89cee451
@ -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
98
Client/src/api/folders.ts
Normal 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())
|
||||
}
|
||||
@ -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 = <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)
|
||||
|
||||
// 拖拽起点取快照需要最新 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<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" />
|
||||
|
||||
@ -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
192
Server/src/folders.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
//! - `GET /api/client/health` 给未来 .NET 客户端(不挂 CORS)
|
||||
|
||||
mod auth;
|
||||
mod folders;
|
||||
mod monitor;
|
||||
mod photos;
|
||||
mod routes;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user