feat(folder): 特殊文件夹装饰/排版存服务器 + 100条上限

- 后端:WallState 存储模型 + GET/PUT /api/web/folder/{name}/state
- 后端:上传时检查照片+装饰总数 ≤ 100(409 超限拒绝)
- 后端:列表接口返回 max_items 字段
- 前端:特殊文件夹从服务器加载/保存装饰与排版(共享视图)
- 前端:工具栏显示条数计数器,满额变红
- 前端:添加装饰 / 上传图片前客户端检查上限

Co-Authored-By: Claude <noreply@anthropic.com>
@
This commit is contained in:
cc 2026-06-24 23:07:08 +08:00
parent 0e6c774ed6
commit 3248146c46
4 changed files with 306 additions and 12 deletions

View File

@ -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<string, unknown>[]
photoLayout: Record<string, Record<string, unknown>>
}
/** 读取文件夹的共享照片墙状态;无存档返回 null */
export async function fetchFolderState(
token: string,
folder: string,
): Promise<WallState | null> {
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<void> {
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}`)
}

View File

@ -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<string | null>(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<string, unknown>[],
photoLayout: photoLayout as unknown as Record<string, Record<string, unknown>>,
}).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<El, 'id' | 'z'>) => {
// 特殊文件夹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<string, unknown>) => ({
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<string, unknown>
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
{/* ── 工具栏 ── */}
<div className="pw-toolbar">
<span className="pw-logo">{folderName ? `📁 ${folderName}` : '📸 Photo Wall'}</span>
{!isPersonal && (
<span className="pw-count" style={{
fontSize: 13, color: els.length >= maxItems ? '#E74C3C' : '#888',
fontWeight: els.length >= maxItems ? 700 : 400,
marginRight: 8,
}}>
{els.length}/{maxItems}
</span>
)}
<button className="pw-tbtn" onClick={() => fileRef.current?.click()}></button>
<button className="pw-tbtn" onClick={makeNote}>便</button>

View File

@ -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<Decoration>,
pub photo_layout: std::collections::HashMap<String, PhotoLayout>,
}
#[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<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub h: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fix_color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fix_rot: Option<f64>,
}
#[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<WallState> {
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 的内部工具 ─────────────────────────────────── */

View File

@ -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<AppState>,
AxPath(name): AxPath<String>,
) -> Result<Json<WallState>, 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_json::Value>,
#[serde(rename = "photoLayout")]
photo_layout: std::collections::HashMap<String, serde_json::Value>,
}
/// PUT /api/web/folder/{name}/state
async fn save_folder_state(
AuthUser(user): AuthUser,
State(state): State<AppState>,
AxPath(name): AxPath<String>,
Json(body): Json<StateSaveRequest>,
) -> Result<StatusCode, StatusCode> {
check_access(&state.folders, &name, &user)?;
// 从 JSON Value 转换装饰列表
let decorations: Vec<crate::folders::Decoration> = body.decorations
.into_iter()
.filter_map(|v| serde_json::from_value(v).ok())
.collect();
let photo_layout: std::collections::HashMap<String, crate::folders::PhotoLayout> = 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),
}
}