From 3248146c464d39cbe8f244ff3ee6c9f32c2762d9 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 24 Jun 2026 23:07:08 +0800 Subject: [PATCH] =?UTF-8?q?@=20feat(folder):=20=E7=89=B9=E6=AE=8A=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E8=A3=85=E9=A5=B0/=E6=8E=92=E7=89=88?= =?UTF-8?q?=E5=AD=98=E6=9C=8D=E5=8A=A1=E5=99=A8=20+=20100=E6=9D=A1?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:WallState 存储模型 + GET/PUT /api/web/folder/{name}/state - 后端:上传时检查照片+装饰总数 ≤ 100(409 超限拒绝) - 后端:列表接口返回 max_items 字段 - 前端:特殊文件夹从服务器加载/保存装饰与排版(共享视图) - 前端:工具栏显示条数计数器,满额变红 - 前端:添加装饰 / 上传图片前客户端检查上限 Co-Authored-By: Claude @ --- Client/src/api/folders.ts | 49 ++++++++-- Client/src/components/PhotoWallPage.tsx | 113 +++++++++++++++++++++++- Server/src/folders.rs | 86 ++++++++++++++++++ Server/src/routes/web.rs | 70 ++++++++++++++- 4 files changed, 306 insertions(+), 12 deletions(-) diff --git a/Client/src/api/folders.ts b/Client/src/api/folders.ts index bf681da..1cfdee5 100644 --- a/Client/src/api/folders.ts +++ b/Client/src/api/folders.ts @@ -27,6 +27,7 @@ export interface FolderPhotoItem { export interface FolderPhotoList { items: FolderPhotoItem[] + max?: number // 总数上限(从服务器 max 字段) totalBytes: number overQuota: boolean } @@ -61,11 +62,13 @@ export async function uploadFolderPhoto( 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' + res.status === 409 + ? 'full' + : res.status === 413 + ? 'too-large' + : res.status === 415 + ? 'bad-type' + : 'failed' throw new Error(reason) } @@ -96,3 +99,39 @@ export async function fetchFolderPhotoUrl( if (!res.ok) throw new Error(`get failed: ${res.status}`) return URL.createObjectURL(await res.blob()) } + +/* ── 照片墙状态(装饰 + 排版,服务器共享)────────────────────── */ + +export interface WallState { + bgIndex: number + decorations: Record[] + photoLayout: Record> +} + +/** 读取文件夹的共享照片墙状态;无存档返回 null */ +export async function fetchFolderState( + token: string, + folder: string, +): Promise { + const res = await fetch(`${API_BASE}/api/web/folder/${folder}/state`, { + headers: authHeaders(token), + }) + if (res.status === 404) return null + if (!res.ok) throw new Error(`get state failed: ${res.status}`) + return (await res.json()) as WallState +} + +/** 保存文件夹的共享照片墙状态(409 = 超 100 条上限) */ +export async function saveFolderState( + token: string, + folder: string, + state: WallState, +): Promise { + const res = await fetch(`${API_BASE}/api/web/folder/${folder}/state`, { + method: 'PUT', + headers: { ...authHeaders(token), 'Content-Type': 'application/json' }, + body: JSON.stringify(state), + }) + if (res.status === 409) throw new Error('full') + if (!res.ok) throw new Error(`save state failed: ${res.status}`) +} diff --git a/Client/src/components/PhotoWallPage.tsx b/Client/src/components/PhotoWallPage.tsx index edcf394..4f48b68 100644 --- a/Client/src/components/PhotoWallPage.tsx +++ b/Client/src/components/PhotoWallPage.tsx @@ -14,6 +14,8 @@ import { uploadFolderPhoto, deleteFolderPhoto, fetchFolderPhotoUrl, + fetchFolderState, + saveFolderState, } from '../api/folders' /* ════════════════════════════════════════════════════════════════ @@ -175,9 +177,11 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden 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 [maxItems, setMaxItems] = useState(0) + const [itemCount, setItemCount] = useState(0) // 右键菜单滑条:旋转步长 / 缩放步长 const [rotStep, setRotStep] = useState(15) const [zoomStepPx, setZoomStepPx] = useState(30) @@ -259,7 +263,24 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden } layoutRef.current = photoLayout localStorage.setItem(storageKey, JSON.stringify({ bgIndex, decorations, photoLayout })) - setSaveLabel('已保存') + // 特殊文件夹:同步保存到服务器 + if (!isPersonal && folderName) { + saveFolderState(token, folderName, { + bgIndex, + decorations: decorations as unknown as Record[], + photoLayout: photoLayout as unknown as Record>, + }).then(() => { + setSaveLabel('已保存') + setItemCount(els.length) + }).catch((e) => { + if (e instanceof Error && e.message === 'full') { + showToast('已达 100 条上限,无法保存更多装饰') + } + setSaveLabel('已保存(本地)') + }) + } else { + setSaveLabel('已保存') + } } catch { setSaveLabel('保存失败') showToast('保存失败:存储空间可能已满') @@ -290,10 +311,15 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden const addEl = useCallback( (partial: Omit) => { + // 特殊文件夹:100 条上限 + if (!isPersonal && maxItems > 0 && elsRef.current.length >= maxItems) { + showToast(`已达上限(${maxItems} 条)`) + return + } const el: El = { ...partial, id: idRef.current++, z: ++zRef.current } mutate((prev) => [...prev, el]) }, - [mutate], + [mutate, isPersonal, maxItems, showToast], ) /* ── 图片加载(个人墙 / 文件夹统一处理)────────────────────── */ @@ -310,8 +336,11 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden ? () => listPhotos(token).then(r => ({ items: r.items, max: r.max, totalBytes: 0, overQuota: false })) : () => listFolderPhotos(token, folderName!) - const { items, totalBytes, overQuota: over } = await listFn() + const { items, max, totalBytes, overQuota: over } = await listFn() if (!alive) return + if (!isPersonal) { + setMaxItems(max ?? 100) + } if (totalBytes !== undefined) { setQuotaBytes(totalBytes) setOverQuota(over ?? false) @@ -360,6 +389,68 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden if (layout) zRef.current = Math.max(zRef.current, layout.z) setEls((prev) => [...prev, base]) } + + // 特殊文件夹:加载服务器端共享状态(装饰 + 排版) + if (!isPersonal && folderName) { + try { + const serverState = await fetchFolderState(token, folderName) + if (!alive) return + if (serverState) { + // 恢复背景 + if (typeof serverState.bgIndex === 'number') setBgIndex(serverState.bgIndex) + // 恢复装饰(便签/胶带/贴纸/印章/手绘) + const decos: El[] = Array.isArray(serverState.decorations) + ? serverState.decorations + .map((d: Record) => ({ + id: Number(d.id) || idRef.current++, + type: (d.type as ElType) || 'note', + x: Number(d.x) || 0, + y: Number(d.y) || 0, + rot: Number(d.rot) || 0, + scale: Number(d.scale) || 1, + z: Number(d.z) || 0, + w: d.w != null ? Number(d.w) : undefined, + h: d.h != null ? Number(d.h) : undefined, + text: d.text as string | undefined, + color: d.color as string | undefined, + bg: d.bg as string | undefined, + caption: d.caption as string | undefined, + path: d.path as string | undefined, + fix: d.fix as El['fix'], + fixColor: d.fix_color as string | undefined, + fixRot: d.fix_rot != null ? Number(d.fix_rot) : undefined, + } as El)) + .filter((e: El) => e.type && ['note', 'tape', 'sticker', 'stamp', 'doodle'].includes(e.type)) + : [] + if (decos.length > 0) { + const maxId = decos.reduce((m: number, d: El) => Math.max(m, d.id), 0) + idRef.current = Math.max(idRef.current, maxId + 1) + const maxZ = decos.reduce((m: number, d: El) => Math.max(m, d.z), 0) + zRef.current = Math.max(zRef.current, maxZ) + setEls((prev) => [...prev, ...decos]) + } + // 恢复排版(服务器端覆盖本地默认排版) + if (serverState.photoLayout && typeof serverState.photoLayout === 'object') { + for (const [photoId, pl] of Object.entries(serverState.photoLayout)) { + if (pl && typeof pl === 'object') { + const p = pl as Record + layoutRef.current[photoId] = { + x: Number(p.x) || 0, + y: Number(p.y) || 0, + rot: Number(p.rot) || 0, + scale: Number(p.scale) || 1, + z: Number(p.z) || 0, + w: Number(p.w) || 160, + caption: String(p.caption ?? ''), + } + } + } + } + } + } catch { + // 状态加载失败不影响图片展示 + } + } } catch (err) { if (!alive) return if (err instanceof Error && err.message === 'forbidden') { @@ -432,6 +523,11 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden return } } + // 特殊文件夹:总数上限(图片 + 装饰) + if (!isPersonal && maxItems > 0 && elsRef.current.length >= maxItems) { + showToast(`已达上限(${maxItems} 条)`) + return + } const uploadFn = isPersonal ? (t: string, d: string) => uploadPhoto(t, d) : (t: string, d: string) => uploadFolderPhoto(t, folderName!, d) @@ -872,6 +968,15 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden {/* ── 工具栏 ── */}
{folderName ? `📁 ${folderName}` : '📸 Photo Wall'} + {!isPersonal && ( + = maxItems ? '#E74C3C' : '#888', + fontWeight: els.length >= maxItems ? 700 : 400, + marginRight: 8, + }}> + {els.length}/{maxItems} + + )} diff --git a/Server/src/folders.rs b/Server/src/folders.rs index b4c9776..06878a9 100644 --- a/Server/src/folders.rs +++ b/Server/src/folders.rs @@ -12,6 +12,59 @@ use crate::photos::{self, SaveError, valid_id}; pub const ADMIN_USER: &str = "cc"; /// 软配额阈值(1 GiB) pub const QUOTA_BYTES: u64 = 1024 * 1024 * 1024; +/// 每文件夹元素上限(图片 + 装饰) +pub const MAX_ITEMS: usize = 100; + +/// 服务器端照片墙状态(装饰 + 排版,不含图片字节) +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct WallState { + pub bg_index: usize, + pub decorations: Vec, + pub photo_layout: std::collections::HashMap, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct Decoration { + pub id: u64, + #[serde(rename = "type")] + pub el_type: String, + pub x: f64, + pub y: f64, + pub rot: f64, + pub scale: f64, + pub z: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub w: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub h: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caption: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fix_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fix_rot: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct PhotoLayout { + pub x: f64, + pub y: f64, + pub rot: f64, + pub scale: f64, + pub z: i64, + pub w: f64, + pub caption: String, +} /* ════════════════════════════════════════════════════════════════ 文件夹注册表:读 special_folders.txt,每行 `文件夹名 用户,用户` @@ -160,6 +213,39 @@ impl FolderStore { None => false, } } + + /// 统计图片数量(不含 state 文件) + pub fn count_photos(&self, folder: &str) -> usize { + self.list(folder).0.len() + } + + /// 统计总元素数:图片 + 装饰 + pub fn count_items(&self, folder: &str) -> usize { + let photos = self.count_photos(folder); + let decos = self.load_state(folder).map(|s| s.decorations.len()).unwrap_or(0); + photos + decos + } + + /// 读取照片墙状态(装饰 + 排版) + pub fn load_state(&self, folder: &str) -> Option { + let dir = self.folder_dir(folder)?; + let path = dir.join("wall-state.json"); + let bytes = std::fs::read(&path).ok()?; + serde_json::from_slice(&bytes).ok() + } + + /// 保存照片墙状态。校验装饰数 + 图片数 ≤ MAX_ITEMS,超限返回 false。 + pub fn save_state(&self, folder: &str, state: &WallState) -> Result<(), &'static str> { + let dir = self.folder_dir(folder).ok_or("bad folder")?; + let photos = self.count_photos(folder); + if photos + state.decorations.len() > MAX_ITEMS { + return Err("items limit exceeded"); + } + std::fs::create_dir_all(&dir).map_err(|_| "io error")?; + let json = serde_json::to_vec_pretty(state).map_err(|_| "serialize error")?; + std::fs::write(dir.join("wall-state.json"), &json).map_err(|_| "io error")?; + Ok(()) + } } /* ── 复用 photos.rs 的内部工具 ─────────────────────────────────── */ diff --git a/Server/src/routes/web.rs b/Server/src/routes/web.rs index 37fd2f0..1d7a804 100644 --- a/Server/src/routes/web.rs +++ b/Server/src/routes/web.rs @@ -6,7 +6,7 @@ use axum::{ Json, Router, extract::{ConnectInfo, Query, State}, http::{HeaderMap, Method, StatusCode, header}, - routing::{get, post}, + routing::{get, post, put}, }; use axum::extract::{DefaultBodyLimit, Path as AxPath}; use axum::response::{IntoResponse, Response}; @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tower_http::cors::{AllowOrigin, CorsLayer}; -use crate::folders::{FolderRegistry, QUOTA_BYTES}; +use crate::folders::{FolderRegistry, WallState, QUOTA_BYTES, MAX_ITEMS}; use crate::photos::{SaveError, MAX_BYTES, MAX_PHOTOS}; use crate::routes::extract::AuthUser; @@ -52,6 +52,10 @@ pub fn router(state: AppState) -> Router { .route("/api/web/folder/{name}/photos/{id}", get(get_folder_photo).delete(delete_folder_photo)) .layer(DefaultBodyLimit::max(16 * 1024 * 1024)); + // 照片墙状态路由(body 较小,用默认 2MB 即可) + let state_routes = Router::new() + .route("/api/web/folder/{name}/state", get(get_folder_state).put(save_folder_state)); + Router::new() .route("/api/web/stats", get(stats)) .route("/api/web/gate", post(gate)) @@ -59,6 +63,7 @@ pub fn router(state: AppState) -> Router { .route("/api/web/weather", get(weather)) .merge(photo_routes) .merge(folder_routes) + .merge(state_routes) .layer(cors) .with_state(state) } @@ -334,7 +339,7 @@ async fn list_folder_photos( let (items, total_bytes) = state.folder_photos.list(&name); Ok(Json(FolderPhotoList { items: items.into_iter().map(|id| FolderPhotoItem { id }).collect(), - max: None, + max: Some(MAX_ITEMS), total_bytes, over_quota: total_bytes > QUOTA_BYTES, })) @@ -361,6 +366,10 @@ async fn upload_folder_photo( if req.data_url.len() > MAX_BYTES * 2 { return Err(StatusCode::PAYLOAD_TOO_LARGE); } + // 100 条总数上限检查 + if state.folder_photos.count_items(&name) >= MAX_ITEMS { + return Err(StatusCode::CONFLICT); + } 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), @@ -401,3 +410,58 @@ async fn delete_folder_photo( StatusCode::NOT_FOUND } } + +/* ════════════════════════════════════════════════════════════════ + 照片墙状态接口(装饰 + 排版,服务器共享) + ════════════════════════════════════════════════════════════════ */ + +/// GET /api/web/folder/{name}/state +async fn get_folder_state( + AuthUser(user): AuthUser, + State(state): State, + AxPath(name): AxPath, +) -> Result, StatusCode> { + check_access(&state.folders, &name, &user)?; + state.folder_photos + .load_state(&name) + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + +#[derive(Deserialize)] +struct StateSaveRequest { + #[serde(rename = "bgIndex")] + bg_index: usize, + decorations: Vec, + #[serde(rename = "photoLayout")] + photo_layout: std::collections::HashMap, +} + +/// PUT /api/web/folder/{name}/state +async fn save_folder_state( + AuthUser(user): AuthUser, + State(state): State, + AxPath(name): AxPath, + Json(body): Json, +) -> Result { + check_access(&state.folders, &name, &user)?; + // 从 JSON Value 转换装饰列表 + let decorations: Vec = body.decorations + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + let photo_layout: std::collections::HashMap = body.photo_layout + .into_iter() + .filter_map(|(k, v)| serde_json::from_value(v).ok().map(|pl| (k, pl))) + .collect(); + let wall_state = WallState { + bg_index: body.bg_index, + decorations, + photo_layout, + }; + match state.folder_photos.save_state(&name, &wall_state) { + Ok(()) => Ok(StatusCode::NO_CONTENT), + Err("items limit exceeded") => Err(StatusCode::CONFLICT), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +}