@
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 {
|
export interface FolderPhotoList {
|
||||||
items: FolderPhotoItem[]
|
items: FolderPhotoItem[]
|
||||||
|
max?: number // 总数上限(从服务器 max 字段)
|
||||||
totalBytes: number
|
totalBytes: number
|
||||||
overQuota: boolean
|
overQuota: boolean
|
||||||
}
|
}
|
||||||
@ -61,7 +62,9 @@ export async function uploadFolderPhoto(
|
|||||||
if (res.ok) return ((await res.json()) as { id: string }).id
|
if (res.ok) return ((await res.json()) as { id: string }).id
|
||||||
if (res.status === 403) throw new Error('forbidden')
|
if (res.status === 403) throw new Error('forbidden')
|
||||||
const reason: UploadError =
|
const reason: UploadError =
|
||||||
res.status === 413
|
res.status === 409
|
||||||
|
? 'full'
|
||||||
|
: res.status === 413
|
||||||
? 'too-large'
|
? 'too-large'
|
||||||
: res.status === 415
|
: res.status === 415
|
||||||
? 'bad-type'
|
? 'bad-type'
|
||||||
@ -96,3 +99,39 @@ export async function fetchFolderPhotoUrl(
|
|||||||
if (!res.ok) throw new Error(`get failed: ${res.status}`)
|
if (!res.ok) throw new Error(`get failed: ${res.status}`)
|
||||||
return URL.createObjectURL(await res.blob())
|
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,
|
uploadFolderPhoto,
|
||||||
deleteFolderPhoto,
|
deleteFolderPhoto,
|
||||||
fetchFolderPhotoUrl,
|
fetchFolderPhotoUrl,
|
||||||
|
fetchFolderState,
|
||||||
|
saveFolderState,
|
||||||
} from '../api/folders'
|
} from '../api/folders'
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
@ -175,9 +177,11 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
|||||||
const [dragHint, setDragHint] = useState(false)
|
const [dragHint, setDragHint] = useState(false)
|
||||||
const [saveLabel, setSaveLabel] = useState('已保存')
|
const [saveLabel, setSaveLabel] = useState('已保存')
|
||||||
const [toast, setToast] = useState<string | null>(null)
|
const [toast, setToast] = useState<string | null>(null)
|
||||||
// 特殊文件夹配额信息
|
// 特殊文件夹配额 + 总数限制
|
||||||
const [quotaBytes, setQuotaBytes] = useState(0)
|
const [quotaBytes, setQuotaBytes] = useState(0)
|
||||||
const [overQuota, setOverQuota] = useState(false)
|
const [overQuota, setOverQuota] = useState(false)
|
||||||
|
const [maxItems, setMaxItems] = useState(0)
|
||||||
|
const [itemCount, setItemCount] = useState(0)
|
||||||
// 右键菜单滑条:旋转步长 / 缩放步长
|
// 右键菜单滑条:旋转步长 / 缩放步长
|
||||||
const [rotStep, setRotStep] = useState(15)
|
const [rotStep, setRotStep] = useState(15)
|
||||||
const [zoomStepPx, setZoomStepPx] = useState(30)
|
const [zoomStepPx, setZoomStepPx] = useState(30)
|
||||||
@ -259,7 +263,24 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
|||||||
}
|
}
|
||||||
layoutRef.current = photoLayout
|
layoutRef.current = photoLayout
|
||||||
localStorage.setItem(storageKey, JSON.stringify({ bgIndex, decorations, 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('已保存')
|
setSaveLabel('已保存')
|
||||||
|
setItemCount(els.length)
|
||||||
|
}).catch((e) => {
|
||||||
|
if (e instanceof Error && e.message === 'full') {
|
||||||
|
showToast('已达 100 条上限,无法保存更多装饰')
|
||||||
|
}
|
||||||
|
setSaveLabel('已保存(本地)')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSaveLabel('已保存')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSaveLabel('保存失败')
|
setSaveLabel('保存失败')
|
||||||
showToast('保存失败:存储空间可能已满')
|
showToast('保存失败:存储空间可能已满')
|
||||||
@ -290,10 +311,15 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
|||||||
|
|
||||||
const addEl = useCallback(
|
const addEl = useCallback(
|
||||||
(partial: Omit<El, 'id' | 'z'>) => {
|
(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 }
|
const el: El = { ...partial, id: idRef.current++, z: ++zRef.current }
|
||||||
mutate((prev) => [...prev, el])
|
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 }))
|
? () => listPhotos(token).then(r => ({ items: r.items, max: r.max, totalBytes: 0, overQuota: false }))
|
||||||
: () => listFolderPhotos(token, folderName!)
|
: () => listFolderPhotos(token, folderName!)
|
||||||
|
|
||||||
const { items, totalBytes, overQuota: over } = await listFn()
|
const { items, max, totalBytes, overQuota: over } = await listFn()
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
|
if (!isPersonal) {
|
||||||
|
setMaxItems(max ?? 100)
|
||||||
|
}
|
||||||
if (totalBytes !== undefined) {
|
if (totalBytes !== undefined) {
|
||||||
setQuotaBytes(totalBytes)
|
setQuotaBytes(totalBytes)
|
||||||
setOverQuota(over ?? false)
|
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)
|
if (layout) zRef.current = Math.max(zRef.current, layout.z)
|
||||||
setEls((prev) => [...prev, base])
|
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) {
|
} catch (err) {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
if (err instanceof Error && err.message === 'forbidden') {
|
if (err instanceof Error && err.message === 'forbidden') {
|
||||||
@ -432,6 +523,11 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 特殊文件夹:总数上限(图片 + 装饰)
|
||||||
|
if (!isPersonal && maxItems > 0 && elsRef.current.length >= maxItems) {
|
||||||
|
showToast(`已达上限(${maxItems} 条)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
const uploadFn = isPersonal
|
const uploadFn = isPersonal
|
||||||
? (t: string, d: string) => uploadPhoto(t, d)
|
? (t: string, d: string) => uploadPhoto(t, d)
|
||||||
: (t: string, d: string) => uploadFolderPhoto(t, folderName!, 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">
|
<div className="pw-toolbar">
|
||||||
<span className="pw-logo">{folderName ? `📁 ${folderName}` : '📸 Photo Wall'}</span>
|
<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={() => fileRef.current?.click()}>上传照片</button>
|
||||||
<button className="pw-tbtn" onClick={makeNote}>添加便签</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";
|
pub const ADMIN_USER: &str = "cc";
|
||||||
/// 软配额阈值(1 GiB)
|
/// 软配额阈值(1 GiB)
|
||||||
pub const QUOTA_BYTES: u64 = 1024 * 1024 * 1024;
|
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,每行 `文件夹名 用户,用户`
|
文件夹注册表:读 special_folders.txt,每行 `文件夹名 用户,用户`
|
||||||
@ -160,6 +213,39 @@ impl FolderStore {
|
|||||||
None => false,
|
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 的内部工具 ─────────────────────────────────── */
|
/* ── 复用 photos.rs 的内部工具 ─────────────────────────────────── */
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{ConnectInfo, Query, State},
|
extract::{ConnectInfo, Query, State},
|
||||||
http::{HeaderMap, Method, StatusCode, header},
|
http::{HeaderMap, Method, StatusCode, header},
|
||||||
routing::{get, post},
|
routing::{get, post, put},
|
||||||
};
|
};
|
||||||
use axum::extract::{DefaultBodyLimit, Path as AxPath};
|
use axum::extract::{DefaultBodyLimit, Path as AxPath};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
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::photos::{SaveError, MAX_BYTES, MAX_PHOTOS};
|
||||||
use crate::routes::extract::AuthUser;
|
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))
|
.route("/api/web/folder/{name}/photos/{id}", get(get_folder_photo).delete(delete_folder_photo))
|
||||||
.layer(DefaultBodyLimit::max(16 * 1024 * 1024));
|
.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()
|
Router::new()
|
||||||
.route("/api/web/stats", get(stats))
|
.route("/api/web/stats", get(stats))
|
||||||
.route("/api/web/gate", post(gate))
|
.route("/api/web/gate", post(gate))
|
||||||
@ -59,6 +63,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/web/weather", get(weather))
|
.route("/api/web/weather", get(weather))
|
||||||
.merge(photo_routes)
|
.merge(photo_routes)
|
||||||
.merge(folder_routes)
|
.merge(folder_routes)
|
||||||
|
.merge(state_routes)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
@ -334,7 +339,7 @@ async fn list_folder_photos(
|
|||||||
let (items, total_bytes) = state.folder_photos.list(&name);
|
let (items, total_bytes) = state.folder_photos.list(&name);
|
||||||
Ok(Json(FolderPhotoList {
|
Ok(Json(FolderPhotoList {
|
||||||
items: items.into_iter().map(|id| FolderPhotoItem { id }).collect(),
|
items: items.into_iter().map(|id| FolderPhotoItem { id }).collect(),
|
||||||
max: None,
|
max: Some(MAX_ITEMS),
|
||||||
total_bytes,
|
total_bytes,
|
||||||
over_quota: total_bytes > QUOTA_BYTES,
|
over_quota: total_bytes > QUOTA_BYTES,
|
||||||
}))
|
}))
|
||||||
@ -361,6 +366,10 @@ async fn upload_folder_photo(
|
|||||||
if req.data_url.len() > MAX_BYTES * 2 {
|
if req.data_url.len() > MAX_BYTES * 2 {
|
||||||
return Err(StatusCode::PAYLOAD_TOO_LARGE);
|
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) {
|
match state.folder_photos.save(&name, &req.data_url) {
|
||||||
Ok(id) => Ok((StatusCode::CREATED, Json(FolderUploadResponse { id }))),
|
Ok(id) => Ok((StatusCode::CREATED, Json(FolderUploadResponse { id }))),
|
||||||
Err(SaveError::TooLarge) => Err(StatusCode::PAYLOAD_TOO_LARGE),
|
Err(SaveError::TooLarge) => Err(StatusCode::PAYLOAD_TOO_LARGE),
|
||||||
@ -401,3 +410,58 @@ async fn delete_folder_photo(
|
|||||||
StatusCode::NOT_FOUND
|
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