@
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:
parent
0e6c774ed6
commit
3248146c46
@ -27,6 +27,7 @@ export interface FolderPhotoItem {
|
||||
|
||||
export interface FolderPhotoList {
|
||||
items: FolderPhotoItem[]
|
||||
max?: number // 总数上限(从服务器 max 字段)
|
||||
totalBytes: number
|
||||
overQuota: boolean
|
||||
}
|
||||
@ -61,7 +62,9 @@ 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
|
||||
res.status === 409
|
||||
? 'full'
|
||||
: res.status === 413
|
||||
? 'too-large'
|
||||
: res.status === 415
|
||||
? 'bad-type'
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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 }))
|
||||
// 特殊文件夹:同步保存到服务器
|
||||
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>
|
||||
|
||||
|
||||
@ -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 的内部工具 ─────────────────────────────────── */
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user