Compare commits
No commits in common. "99d33b54d25cb0b171090417cf0ba72fe9ecc0f3" and "de4bfafb723f22d7599d1669bffdd6c288081aea" have entirely different histories.
99d33b54d2
...
de4bfafb72
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3445,7 +3445,6 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
"postgres-protocol",
|
"postgres-protocol",
|
||||||
"uuid",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -14,7 +14,7 @@ required-features = ["server"]
|
|||||||
dioxus = { version = "0.7.9", features = ["fullstack", "router"] }
|
dioxus = { version = "0.7.9", features = ["fullstack", "router"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "fs", "time", "sync"], optional = true }
|
tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "fs", "time", "sync"], optional = true }
|
||||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1"], optional = true }
|
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"], optional = true }
|
||||||
deadpool-postgres = { version = "0.14", optional = true }
|
deadpool-postgres = { version = "0.14", optional = true }
|
||||||
argon2 = { version = "0.5", optional = true }
|
argon2 = { version = "0.5", optional = true }
|
||||||
uuid = { version = "1", features = ["v4"], optional = true }
|
uuid = { version = "1", features = ["v4"], optional = true }
|
||||||
@ -69,7 +69,7 @@ mimalloc = { version = "0.1", optional = true }
|
|||||||
katex-rs = { version = "0.2", optional = true }
|
katex-rs = { version = "0.2", optional = true }
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "Clipboard", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] }
|
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
|
|||||||
@ -1,184 +0,0 @@
|
|||||||
# 素材管理(Assets)设计规格
|
|
||||||
|
|
||||||
> 状态:已确认(2026-07-24 grilling 访谈结论)
|
|
||||||
> 范围:后台 `/admin/assets` 素材库 + 封面上传联动。编辑器(Tiptap)正文选择器**不在**本期范围。
|
|
||||||
|
|
||||||
## 1. 背景与动机
|
|
||||||
|
|
||||||
当前图片上传链路(`POST /api/upload` → `uploads/YYYY/MM/DD/`)只有文件没有数据层:
|
|
||||||
|
|
||||||
- 图片仅以 URL 字符串形式被 `posts.content_html` / `cover_image` 引用,**引用关系不可查**;
|
|
||||||
- 编辑器里上传后又删掉的图、被替换的封面图,文件永久残留磁盘,**无孤儿治理**;
|
|
||||||
- 删除文件无护栏,误删即线上文章 404(且 SSR 缓存会延迟暴露问题)。
|
|
||||||
|
|
||||||
本功能建立 `assets` 注册表 + `asset_refs` 引用表,实现:可视化管理、引用状态可见、删除保护、孤儿一键清理、封面图复用。
|
|
||||||
|
|
||||||
设计取舍来自 2026 年 DAM/CMS 趋势调研的过滤结论:只采纳「孤儿治理」「扁平无文件夹」「上传即元数据(alt)」三条;AI 语义搜索、标签体系、RBAC 可视、回收站均不采纳(单人博客量级不匹配)。
|
|
||||||
|
|
||||||
## 2. 目标 / 非目标
|
|
||||||
|
|
||||||
### 目标
|
|
||||||
|
|
||||||
1. 所有经 `/api/upload` 上传的图片自动登记入 `assets` 表(编辑器正文图与封面图共用该端点,全覆盖)。
|
|
||||||
2. 文章保存时同步 `asset_refs`,引用关系精确可查(复刻 `sync_tags` 模式)。
|
|
||||||
3. `/admin/assets` 管理页:网格浏览、搜索、引用状态筛选、单张删除、alt 编辑、复制 URL。
|
|
||||||
4. 删除保护:引用中禁删;孤儿(含 7 天保护窗)可一键批量清理。
|
|
||||||
5. 存量回填:素材页「重建索引」手动按钮,幂等可重跑。
|
|
||||||
6. 封面联动:`CoverUploader` 支持「从素材库选择」。
|
|
||||||
|
|
||||||
### 非目标(明确不做)
|
|
||||||
|
|
||||||
- Tiptap 正文「从素材库插入」选择器(后续版本候选)
|
|
||||||
- 标签体系 / 自由描述字段
|
|
||||||
- 回收站 / 软删除
|
|
||||||
- 外链图片(非 `/uploads/` 路径)管理
|
|
||||||
- 多选 checkbox 批量操作
|
|
||||||
- AI 打标 / 语义搜索
|
|
||||||
|
|
||||||
## 3. 数据模型
|
|
||||||
|
|
||||||
### 3.1 migration `015_assets.sql`
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS assets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
path TEXT NOT NULL UNIQUE, -- 相对路径 "2026/07/24/153000.<uuid>.webp"
|
|
||||||
filename TEXT NOT NULL, -- 原始文件名(客户端提供,仅展示用)
|
|
||||||
mime TEXT NOT NULL, -- 落盘后的实际 MIME
|
|
||||||
size_bytes BIGINT NOT NULL,
|
|
||||||
width INTEGER NOT NULL,
|
|
||||||
height INTEGER NOT NULL,
|
|
||||||
alt TEXT, -- 管理性 alt,仅作默认值/备注,不回写已有文章
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_created_at ON assets (created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS asset_refs (
|
|
||||||
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
|
||||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (asset_id, post_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_asset_refs_post ON asset_refs (post_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
同步在 `src/db/migrate.rs` 的 `MIGRATIONS` 数组注册(编译测试守卫文件/数组一致性)。
|
|
||||||
|
|
||||||
注意:`posts.id` 为 `INTEGER`(见 001/002 迁移),`asset_refs.post_id` 类型必须对齐。
|
|
||||||
|
|
||||||
### 3.2 一致性语义
|
|
||||||
|
|
||||||
- **磁盘是字节唯一存储,DB 是元数据注册表**。两者可能不一致(手动删文件、回填前上传),以「重建索引」自愈:
|
|
||||||
- 扫 `uploads/`(跳过 `.cache`/`.trash` 等点目录)→ upsert assets(存在则更新 size/width/height,**不覆盖** alt);
|
|
||||||
- 全表扫 posts(含回收站?——见 §7 边界)→ 重建 asset_refs;
|
|
||||||
- DB 中文件已消失的行连同 refs 级联删除。
|
|
||||||
- 新上传增量入库;重建只是兜底,不是常态路径。
|
|
||||||
|
|
||||||
## 4. 写入路径改造
|
|
||||||
|
|
||||||
### 4.1 `upload_image`(`src/api/upload.rs`)
|
|
||||||
|
|
||||||
文件落盘成功后 INSERT assets:
|
|
||||||
|
|
||||||
```text
|
|
||||||
顺序:写文件 → 读 header 得 width/height(上传时已做过 check_upload_dimensions,复用其读取结果)
|
|
||||||
→ INSERT assets(path, filename, mime, size, w, h)
|
|
||||||
失败补偿:INSERT 失败 → 尝试 tokio::fs::remove_file 清理已落盘文件,返回 500
|
|
||||||
filename:取 multipart field 的 file_name()(客户端原始名),缺失时退化为落盘文件名
|
|
||||||
```
|
|
||||||
|
|
||||||
不改动的部分:限流、鉴权、MIME/magic-bytes/尺寸校验、WebP 转码策略全部保持原样。
|
|
||||||
|
|
||||||
### 4.2 `sync_asset_refs`(`src/api/posts/helpers.rs`,镜像 `sync_tags`)
|
|
||||||
|
|
||||||
文章 create/update 事务内调用:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub(super) async fn sync_asset_refs(
|
|
||||||
tx: &deadpool_postgres::Transaction<'_>,
|
|
||||||
post_id: i32,
|
|
||||||
content_html: &str,
|
|
||||||
cover_image: Option<&str>,
|
|
||||||
) -> Result<(), AppError>
|
|
||||||
```
|
|
||||||
|
|
||||||
逻辑:
|
|
||||||
|
|
||||||
1. 正则提取 `content_html` 中全部 `/uploads/<rel>` 路径(去 query),加上 `cover_image`(若为 `/uploads/` 路径);
|
|
||||||
2. `DELETE FROM asset_refs WHERE post_id = $1`;
|
|
||||||
3. `INSERT INTO asset_refs SELECT id, $1 FROM assets WHERE path = ANY($2)`(仅匹配已登记资产;未登记的路径——如回填前的旧图——静默跳过,由重建索引兜底)。
|
|
||||||
|
|
||||||
调用点:`create.rs` / `update.rs` 中与 `sync_tags` 相同位置(同事务)。
|
|
||||||
|
|
||||||
正则复用/对齐 `markdown.rs` 的 `IMG_RE` 思路,但需匹配任意位置的 `/uploads/` 出现(含 blur-img 的 `data-src`),建议 `r#"/uploads/([0-9]{4}/[0-9]{2}/[0-9]{2}/[^"?#\s]+)"#`。
|
|
||||||
|
|
||||||
## 5. Server Functions(`src/api/assets/`)
|
|
||||||
|
|
||||||
全部遵循现有约定:`#[server(..., "/api")]`、`get_current_admin_user().await?` 守卫、`AppError` 错误映射、写操作返回后失效对应缓存。
|
|
||||||
|
|
||||||
| 函数 | 参数 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `list_assets` | `filter: AssetFilter`(All/Used/Orphan)、`query: String`、`sort: AssetSort`(CreatedDesc/SizeDesc)、`page: i32` | 每页 60。返回 `(Vec<AssetDto>, total)`。`AssetDto` 含 `ref_count` 与 `refs: Vec<(post_id, title)>`(仅详情需要时可拆第二个函数) |
|
|
||||||
| `update_asset_alt` | `id: Uuid, alt: String` | 更新 alt + updated_at |
|
|
||||||
| `delete_asset` | `id: Uuid` | **引用中返回 `Ok(success:false, 引用文章列表)`**(业务拒绝走 Ok,遵循仓库约定);孤儿执行:删文件 → 删 DB 行(refs 级联)→ 清 `uploads/.cache` 中该路径的派生文件 → `IMAGE_DIMENSIONS_CACHE` invalidate |
|
|
||||||
| `purge_orphan_assets` | 无 | 清理 `无引用 AND created_at < now() - interval '7 days'`。先 SELECT 出清单(事务外逐项删文件,容忍单项失败并记录),返回 `(deleted_count, freed_bytes, failures)` |
|
|
||||||
| `rebuild_assets_index` | 无 | 全量重建(§3.2)。批处理 + 进度返回,交互复刻 `rebuild_content_html` |
|
|
||||||
|
|
||||||
缓存失效注意:删除素材后,引用它的 SSR 页面理论上不存在(引用中禁删);孤儿图无页面引用,无需 `ssr_cache` 失效。
|
|
||||||
|
|
||||||
## 6. 管理页 `/admin/assets`
|
|
||||||
|
|
||||||
### 6.1 路由与导航
|
|
||||||
|
|
||||||
- `src/router.rs` admin nest 下新增 `#[route("/assets")] Assets {}` 与分页路由(对齐 comments 的 `/comments/:page` 模式);
|
|
||||||
- `AdminLayout` 导航加「素材」入口,图标用 Feather 风格线框(image 图标),与现有导航一致。
|
|
||||||
|
|
||||||
### 6.2 页面结构
|
|
||||||
|
|
||||||
- **顶栏**:搜索框(filename/alt LIKE)+ 筛选 tabs(全部 / 引用中 / 孤儿,显示各计数)+ 排序(最新/最大)+ 「清理孤儿」按钮 + 「重建索引」按钮;
|
|
||||||
- **网格**:卡片 = 缩略图(`/uploads/<path>?thumb=300x300`,`serve_image` 现成)+ filename + 尺寸/大小 + 引用徽标(`被 N 篇引用` / `孤儿`);
|
|
||||||
- **卡片操作**:复制 URL、编辑 alt(inline 或 modal)、查看引用(列出文章标题,点击跳 `/admin/write/:id`)、删除;
|
|
||||||
- **删除交互**:引用中 → 按钮禁用 + tooltip 列引用文章;孤儿 → 确认框 → 硬删除;
|
|
||||||
- **「清理孤儿」**:按钮文本带数量与总大小(`清理 23 张孤儿图(45.2 MB)`),确认框说明 7 天保护窗规则;
|
|
||||||
- 设计语言遵循 `yggdrasil-ui-design-taste`:2rem 圆角卡片、无感阴影、组件挂载式路由动画;用户文案中文。
|
|
||||||
|
|
||||||
## 7. 封面联动(`CoverUploader`)
|
|
||||||
|
|
||||||
- 空态与预览态加「从素材库选择」入口;
|
|
||||||
- 点击弹出 modal:素材网格(复用管理页的 list_assets,默认 All 筛选、最新排序),支持搜索,单击选中回填 `cover_image`;
|
|
||||||
- modal 内保留「上传新图」按钮(复用现有 `spawn_cover_upload` 闭包);
|
|
||||||
- 纯 Dioxus 组件,不触碰 Tiptap/bridge。
|
|
||||||
|
|
||||||
## 8. 边界 case
|
|
||||||
|
|
||||||
| case | 处理 |
|
|
||||||
|---|---|
|
|
||||||
| 未保存草稿的图被当孤儿清理 | 7 天保护窗:`purge_orphan_assets` 只清 `created_at < now()-7d`;列表页「孤儿」筛选展示全部但清理按钮有窗口说明 |
|
|
||||||
| 回收站文章的引用 | refs 照常在保存时建立;**回收站文章的引用同样阻止删除**(purge 文章时 refs 级联删,图变孤儿可被清)。语义自洽,无需特判 |
|
|
||||||
| 上传成功但 DB INSERT 失败 | 补偿删文件,返回 500(见 §4.1) |
|
|
||||||
| 外链图(非 /uploads/) | 不入库、不追踪,正文/封面外链保持现状 |
|
|
||||||
| 重建时文件已消失 | DB 行级联删除 refs 后删行 |
|
|
||||||
| 重建时 DB 已有行 | upsert 更新技术字段,**保留 alt** |
|
|
||||||
| `sync_asset_refs` 时图未登记(回填前旧文重存) | 静默跳过,重建索引兜底 |
|
|
||||||
| 删除素材的缓存清理 | `uploads/.cache/<key>` 派生文件 + `IMAGE_DIMENSIONS_CACHE`;无需 SSR 失效(孤儿无页面引用) |
|
|
||||||
|
|
||||||
## 9. 实现阶段(tracer bullets)
|
|
||||||
|
|
||||||
1. **数据层**:015 迁移 + models/asset.rs + `upload_image` 入库 + `sync_asset_refs` 接入 create/update → 验证:上传一张图,DB 有行;保存文章,refs 正确
|
|
||||||
2. **管理页只读**:`list_assets` + 网格/搜索/筛选/排序/分页 + 导航入口 → 验证:页面可用
|
|
||||||
3. **删除与清理**:`delete_asset` + `purge_orphan_assets` + 缓存清理 + 确认交互 → 验证:引用中禁删;孤儿删除后文件/DB/缓存三清
|
|
||||||
4. **重建索引**:`rebuild_assets_index` + 批处理进度 → 验证:存量图全部入库,refs 正确,幂等重跑无副作用
|
|
||||||
5. **封面联动**:选择器 modal → 验证:选图回填封面
|
|
||||||
|
|
||||||
每阶段独立可交付、可提交(`feat(assets): ...`)。
|
|
||||||
|
|
||||||
## 10. 验收标准
|
|
||||||
|
|
||||||
- [ ] 编辑器上传图片 → `assets` 表出现对应行(尺寸/大小/MIME 正确)
|
|
||||||
- [ ] 保存含图文章 → `asset_refs` 精确反映引用;改文删图后 refs 同步移除
|
|
||||||
- [ ] 引用中的素材删除被拦截并列出引用文章
|
|
||||||
- [ ] 孤儿单删后:文件消失、DB 行消失、`.cache` 派生物消失
|
|
||||||
- [ ] 「清理孤儿」只清 7 天前的无引用图,返回数量与释放字节数
|
|
||||||
- [ ] 「重建索引」后存量图全部可见,重跑结果不变(幂等)
|
|
||||||
- [ ] 封面可从素材库选择回填,也可在 modal 内上传新图
|
|
||||||
- [ ] `cargo test --features server` 全绿;新增迁移通过文件/数组一致性编译测试
|
|
||||||
@ -376,39 +376,18 @@ function gotoIndex(rawIndex: number): void {
|
|||||||
// 150ms 后换图淡入
|
// 150ms 后换图淡入
|
||||||
const swap = (): void => {
|
const swap = (): void => {
|
||||||
if (!state) return; // 切换中可能已关闭
|
if (!state) return; // 切换中可能已关闭
|
||||||
// 换图后必须按新图真实尺寸重算几何,否则新图沿用第一张的
|
|
||||||
// target/scale,宽高比不同的图会被压扁/拉伸。
|
|
||||||
// 布局盒直接设为 target 尺寸(宽高比 = 新图),transform 归位 scale(1,1);
|
|
||||||
// baseW/H 同步为 target 尺寸,关闭/滚动关闭的飞回动画以它为 scale 基准。
|
|
||||||
const applyGeometry = (): void => {
|
|
||||||
if (!state) return;
|
|
||||||
const naturalW = s.img.naturalWidth || 1;
|
|
||||||
const naturalH = s.img.naturalHeight || 1;
|
|
||||||
const target = fitCentered(naturalW, naturalH, window.innerWidth, window.innerHeight);
|
|
||||||
s.target = target;
|
|
||||||
s.baseW = target.w;
|
|
||||||
s.baseH = target.h;
|
|
||||||
// 几何跳变不播动画(transition 只作用于随后的 opacity 淡入)。
|
|
||||||
s.img.style.transition = 'none';
|
|
||||||
s.img.style.width = `${target.w}px`;
|
|
||||||
s.img.style.height = `${target.h}px`;
|
|
||||||
s.img.style.transform = transformFor(target, target.w, target.h);
|
|
||||||
};
|
|
||||||
const fade = (): void => {
|
const fade = (): void => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
s.img.style.transition = 'opacity 150ms ease-out';
|
s.img.style.transition = 'opacity 150ms ease-out';
|
||||||
s.img.style.opacity = '1';
|
s.img.style.opacity = '1';
|
||||||
};
|
};
|
||||||
const onReady = (): void => {
|
|
||||||
applyGeometry();
|
|
||||||
fade();
|
|
||||||
};
|
|
||||||
// 先换 src 再判 complete:换之前判的是旧图(必命中),会按旧图尺寸算几何。
|
|
||||||
s.img.src = origSrc;
|
|
||||||
if (s.img.complete && s.img.naturalWidth) {
|
if (s.img.complete && s.img.naturalWidth) {
|
||||||
onReady(); // 缓存命中,新图尺寸同步可用
|
// 缓存命中,直接淡入
|
||||||
|
s.img.src = origSrc;
|
||||||
|
fade();
|
||||||
} else {
|
} else {
|
||||||
s.img.addEventListener('load', onReady, { once: true });
|
s.img.addEventListener('load', fade, { once: true });
|
||||||
|
s.img.src = origSrc;
|
||||||
}
|
}
|
||||||
s.caption.textContent = altText;
|
s.caption.textContent = altText;
|
||||||
s.caption.style.display = altText ? '' : 'none';
|
s.caption.style.display = altText ? '' : 'none';
|
||||||
@ -527,28 +506,18 @@ window.__initLightbox = (selectors: string | string[]): void => {
|
|||||||
initLazyLoad(node);
|
initLazyLoad(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 幂等守卫:SPA 页面(如 /admin/assets)数据刷新后会重复调用 __initLightbox,
|
|
||||||
// 而 Dioxus keyed diff 可能复用同一批 DOM 节点——无守卫会叠加 click 监听,
|
|
||||||
// 点一次连续触发多次 openLightbox(飞行动画重启/闪烁)。
|
|
||||||
// initLazyLoad 已有 data-blur-init 守卫,这里给 click 绑定补同款。
|
|
||||||
const bindClick = (node: HTMLElement, handler: (e: MouseEvent) => void): void => {
|
|
||||||
if (node.getAttribute('data-lb-bound')) return;
|
|
||||||
node.setAttribute('data-lb-bound', 'true');
|
|
||||||
node.addEventListener('click', handler);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 正文图:带 index。for..of + const 天然捕获每次迭代的 idx,
|
// 正文图:带 index。for..of + const 天然捕获每次迭代的 idx,
|
||||||
// 无需旧 IIFE 包装(旧 var 循环闭包必须立即执行函数固定变量)。
|
// 无需旧 IIFE 包装(旧 var 循环闭包必须立即执行函数固定变量)。
|
||||||
const gallery = collected.gallery;
|
const gallery = collected.gallery;
|
||||||
gallery.forEach((node, idx) => {
|
gallery.forEach((node, idx) => {
|
||||||
bindClick(node, (e: MouseEvent) => {
|
node.addEventListener('click', (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openLightbox(node, gallery, idx);
|
openLightbox(node, gallery, idx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// 单张图(封面):index = null,gallery 传空数组表示单张
|
// 单张图(封面):index = null,gallery 传空数组表示单张
|
||||||
for (const node of collected.singles) {
|
for (const node of collected.singles) {
|
||||||
bindClick(node, (e: MouseEvent) => {
|
node.addEventListener('click', (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openLightbox(node, [], null);
|
openLightbox(node, [], null);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -63,13 +63,6 @@ function getCounter(): HTMLElement | null {
|
|||||||
return document.querySelector('.lightbox-counter');
|
return document.querySelector('.lightbox-counter');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** stub 灯箱图的 natural 尺寸与 complete(happy-dom 不加载真实图片)。 */
|
|
||||||
function stubNatural(img: HTMLImageElement, w: number, h: number): void {
|
|
||||||
Object.defineProperty(img, 'naturalWidth', { configurable: true, value: w });
|
|
||||||
Object.defineProperty(img, 'naturalHeight', { configurable: true, value: h });
|
|
||||||
Object.defineProperty(img, 'complete', { configurable: true, value: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模拟元素 click(真实事件派发,触发 addEventListener('click'))。 */
|
/** 模拟元素 click(真实事件派发,触发 addEventListener('click'))。 */
|
||||||
function clickEl(el: Element): void {
|
function clickEl(el: Element): void {
|
||||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
@ -242,52 +235,6 @@ describe('lightbox 黑盒行为', () => {
|
|||||||
const fullB = imgs[1].querySelector('.blur-img-full');
|
const fullB = imgs[1].querySelector('.blur-img-full');
|
||||||
expect(document.activeElement).toBe(fullB);
|
expect(document.activeElement).toBe(fullB);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('切换后按新图宽高比重算几何(不沿用第一张的 target/scale)', () => {
|
|
||||||
const imgs = [makeGalleryImage('/a.webp', 'A'), makeGalleryImage('/b.webp', 'B')];
|
|
||||||
mountRoot(imgs);
|
|
||||||
window.__initLightbox('.post-content');
|
|
||||||
|
|
||||||
clickEl(imgs[0]);
|
|
||||||
const lbImg = getLightboxImg()!;
|
|
||||||
// happy-dom 不真实加载图片:手动 stub 第 1 张 natural 尺寸并派发 load,
|
|
||||||
// 让 openLightbox 的 start() 走完(state.target/baseW 按第 1 张建立)。
|
|
||||||
stubNatural(lbImg, 1000, 500);
|
|
||||||
lbImg.dispatchEvent(new Event('load'));
|
|
||||||
vi.advanceTimersByTime(50); // double-rAF 在 fake timers 下同步推进
|
|
||||||
|
|
||||||
// 切到第 2 张前,先把 natural stub 成新图尺寸(竖图),模拟缓存命中
|
|
||||||
pressKey('ArrowRight');
|
|
||||||
stubNatural(lbImg, 500, 1000);
|
|
||||||
vi.advanceTimersByTime(200); // 150ms 淡出后 swap 同步执行
|
|
||||||
|
|
||||||
// 几何必须按 500x1000 重算:布局盒 = fitCentered 目标尺寸(宽高比 0.5),
|
|
||||||
// transform 归位 scale(1,1)。修复前这里仍是第 1 张的 width/height/scale。
|
|
||||||
const w = parseFloat(lbImg.style.width);
|
|
||||||
const h = parseFloat(lbImg.style.height);
|
|
||||||
expect(w).toBeGreaterThan(0);
|
|
||||||
expect(w / h).toBeCloseTo(0.5, 2);
|
|
||||||
expect(lbImg.style.transform).toContain('scale(1,1)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('重复初始化幂等(SPA 数据刷新场景)', () => {
|
|
||||||
it('同一批节点重复 __initLightbox,点击只创建一次 overlay', () => {
|
|
||||||
const img = makeGalleryImage('/a.webp', 'A');
|
|
||||||
mountRoot([img]);
|
|
||||||
window.__initLightbox('.post-content');
|
|
||||||
// 模拟 /admin/assets 刷新后 Dioxus 复用 DOM 节点导致的重复初始化
|
|
||||||
window.__initLightbox('.post-content');
|
|
||||||
|
|
||||||
const appendSpy = vi.spyOn(document.body, 'appendChild');
|
|
||||||
clickEl(img);
|
|
||||||
// 无守卫时两个 click 监听先后触发 openLightbox → overlay 被 append 两次
|
|
||||||
const overlayAppends = appendSpy.mock.calls.filter(
|
|
||||||
(c) => c[0] instanceof HTMLElement && c[0].classList.contains('lightbox-overlay'),
|
|
||||||
);
|
|
||||||
expect(overlayAppends).toHaveLength(1);
|
|
||||||
appendSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('单张图不参与图集切换', () => {
|
describe('单张图不参与图集切换', () => {
|
||||||
|
|||||||
@ -12,11 +12,8 @@
|
|||||||
|
|
||||||
/* 灯箱图片:绝对定位,尺寸/位置由 JS 的 transform 控制。
|
/* 灯箱图片:绝对定位,尺寸/位置由 JS 的 transform 控制。
|
||||||
transform-origin 为 top left,配合 translate+scale 实现原地缩放。
|
transform-origin 为 top left,配合 translate+scale 实现原地缩放。
|
||||||
显式 width/height 由 JS 设置(打开时 = 缩略图 rect,图集切换后 = 居中目标
|
显式 width/height 由 JS 设置为原图尺寸,确保布局尺寸确定,
|
||||||
尺寸),确保布局尺寸确定,transform 只做视觉缩放,不改变占位。
|
transform 只做视觉缩放,不改变占位。 */
|
||||||
注意不可加 object-fit: contain:transformFor 的非均匀 scale 依赖「位图填满
|
|
||||||
布局盒」这一前提才能恰好抵消失真(素材网格的缩略图容器恒为正方形,与原图
|
|
||||||
宽高比不同,contain 的 letterbox 会导致非均匀 scale 把图压扁)。 */
|
|
||||||
.lightbox-img {
|
.lightbox-img {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -24,6 +21,7 @@
|
|||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
|
object-fit: contain;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
-- 素材(图片)注册表与文章引用关联。
|
|
||||||
-- assets:uploads/ 下每张已登记图片的元数据;磁盘是字节唯一存储,本表是注册表。
|
|
||||||
-- asset_refs:哪篇文章引用了哪张素材,文章保存时由 sync_asset_refs 全量重建(sync_tags 模式)。
|
|
||||||
-- id 由应用层生成(uuid crate),SQL 侧不设默认值,避免依赖 PG13+ 的 gen_random_uuid()。
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS assets (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
path TEXT NOT NULL UNIQUE, -- 相对路径 "2026/07/24/153000.<uuid>.webp"
|
|
||||||
filename TEXT NOT NULL, -- 原始文件名(客户端提供,仅展示用)
|
|
||||||
mime TEXT NOT NULL, -- 落盘后的实际 MIME(转码后可能变为 image/webp)
|
|
||||||
size_bytes BIGINT NOT NULL,
|
|
||||||
width INTEGER NOT NULL,
|
|
||||||
height INTEGER NOT NULL,
|
|
||||||
alt TEXT, -- 管理性 alt:仅作默认值/备注,不回写已有文章 HTML
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_created_at ON assets (created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS asset_refs (
|
|
||||||
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
|
||||||
post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (asset_id, post_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_asset_refs_post ON asset_refs (post_id);
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
//! 素材删除与孤儿清理接口。
|
|
||||||
//!
|
|
||||||
//! 删除保护:被任何文章(含回收站文章)引用的素材禁删,返回引用列表;
|
|
||||||
//! 孤儿素材硬删除(文件 + DB 行 + 派生缓存)。一键清理仅作用于
|
|
||||||
//! 无引用且超过 7 天保护窗的素材(保护未保存草稿的引用)。
|
|
||||||
//! Dioxus server function,注册在 `/api` 路径下,仅 admin 可用。
|
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
use super::types::{AssetOpResponse, PurgeOrphansResponse};
|
|
||||||
|
|
||||||
/// 更新素材 alt(管理性备注,不回写已有文章 HTML)。
|
|
||||||
#[server(UpdateAssetAlt, "/api")]
|
|
||||||
pub async fn update_asset_alt(id: String, alt: String) -> Result<AssetOpResponse, ServerFnError> {
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
{
|
|
||||||
use crate::api::auth::get_current_admin_user;
|
|
||||||
use crate::api::error::AppError;
|
|
||||||
use crate::db::pool::get_conn;
|
|
||||||
|
|
||||||
let _admin = get_current_admin_user().await?;
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
|
||||||
|
|
||||||
// id 在边界处从 String 解析为 Uuid(非法 id 属业务错误,不走 500)。
|
|
||||||
let asset_uuid = match uuid::Uuid::parse_str(&id) {
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let alt = alt.trim().to_string();
|
|
||||||
let updated = client
|
|
||||||
.execute(
|
|
||||||
"UPDATE assets SET alt = NULLIF($2, ''), updated_at = NOW() WHERE id = $1",
|
|
||||||
&[&asset_uuid, &alt],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
if updated == 0 {
|
|
||||||
return Ok(AssetOpResponse::err("素材不存在".to_string()));
|
|
||||||
}
|
|
||||||
Ok(AssetOpResponse::ok("alt 已更新".to_string()))
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除单张素材。
|
|
||||||
///
|
|
||||||
/// 引用中 → `Ok(success:false)` + 引用文章列表(业务拒绝不走 Err);
|
|
||||||
/// 孤儿 → 物理删除文件、DB 行(refs 级联)与派生缓存。
|
|
||||||
#[server(DeleteAsset, "/api")]
|
|
||||||
pub async fn delete_asset(id: String) -> Result<AssetOpResponse, ServerFnError> {
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
{
|
|
||||||
use crate::api::auth::get_current_admin_user;
|
|
||||||
use crate::api::error::AppError;
|
|
||||||
use crate::db::pool::get_conn;
|
|
||||||
use crate::models::asset::AssetRef;
|
|
||||||
|
|
||||||
let _admin = get_current_admin_user().await?;
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
|
||||||
|
|
||||||
let asset_uuid = match uuid::Uuid::parse_str(&id) {
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let row = client
|
|
||||||
.query_opt(
|
|
||||||
"SELECT id AS id, path, filename FROM assets WHERE id = $1",
|
|
||||||
&[&asset_uuid],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
let Some(row) = row else {
|
|
||||||
return Ok(AssetOpResponse::err("素材不存在".to_string()));
|
|
||||||
};
|
|
||||||
let path: String = row.get("path");
|
|
||||||
|
|
||||||
// 引用检查:含回收站文章(其 purge 时 refs 级联删,图自然变孤儿)。
|
|
||||||
let ref_rows = client
|
|
||||||
.query(
|
|
||||||
"SELECT p.id, p.title FROM asset_refs r JOIN posts p ON p.id = r.post_id \
|
|
||||||
WHERE r.asset_id = $1 ORDER BY p.id",
|
|
||||||
&[&asset_uuid],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
if !ref_rows.is_empty() {
|
|
||||||
let refs: Vec<AssetRef> = ref_rows
|
|
||||||
.iter()
|
|
||||||
.map(|r| AssetRef {
|
|
||||||
post_id: r.get(0),
|
|
||||||
title: r.get(1),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
return Ok(AssetOpResponse {
|
|
||||||
success: false,
|
|
||||||
message: format!("该素材正被 {} 篇文章引用,无法删除", refs.len()),
|
|
||||||
refs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 孤儿:先删文件(NotFound 容忍——磁盘与 DB 可能已不一致),再删 DB 行。
|
|
||||||
let file_path = format!("uploads/{}", path);
|
|
||||||
if let Err(e) = tokio::fs::remove_file(&file_path).await {
|
|
||||||
if e.kind() != std::io::ErrorKind::NotFound {
|
|
||||||
tracing::warn!("Remove asset file failed ({}): {}", file_path, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client
|
|
||||||
.execute("DELETE FROM assets WHERE id = $1", &[&asset_uuid])
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
crate::api::image::invalidate_asset_caches(&path).await;
|
|
||||||
|
|
||||||
Ok(AssetOpResponse::ok("已删除".to_string()))
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一键清理孤儿素材:无引用且 created_at 早于 7 天保护窗。
|
|
||||||
///
|
|
||||||
/// 逐项删文件(容忍单项失败),最后批量删 DB 行。
|
|
||||||
/// 返回清理数量、释放字节数与文件删除失败数。
|
|
||||||
#[server(PurgeOrphanAssets, "/api")]
|
|
||||||
pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ServerFnError> {
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
{
|
|
||||||
use crate::api::auth::get_current_admin_user;
|
|
||||||
use crate::api::error::AppError;
|
|
||||||
use crate::db::pool::get_conn;
|
|
||||||
|
|
||||||
let _admin = get_current_admin_user().await?;
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
|
||||||
|
|
||||||
let rows = client
|
|
||||||
.query(
|
|
||||||
"SELECT a.id AS id, a.path, a.size_bytes FROM assets a \
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
|
|
||||||
AND a.created_at < NOW() - make_interval(days => $1)",
|
|
||||||
&[&super::list::PURGE_GRACE_DAYS],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
if rows.is_empty() {
|
|
||||||
return Ok(PurgeOrphansResponse {
|
|
||||||
success: true,
|
|
||||||
message: "没有可清理的孤儿素材".to_string(),
|
|
||||||
deleted_count: 0,
|
|
||||||
freed_bytes: 0,
|
|
||||||
failures: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
|
|
||||||
let mut freed_bytes: i64 = 0;
|
|
||||||
let mut failures: i64 = 0;
|
|
||||||
for row in &rows {
|
|
||||||
let id: uuid::Uuid = row.get("id");
|
|
||||||
let path: String = row.get("path");
|
|
||||||
freed_bytes += row.get::<_, i64>("size_bytes");
|
|
||||||
let file_path = format!("uploads/{}", path);
|
|
||||||
if let Err(e) = tokio::fs::remove_file(&file_path).await {
|
|
||||||
// NotFound 可容忍(文件可能已被手动删);其他错误计入 failures,
|
|
||||||
// DB 行照删——残留文件由重建索引的反向语义兜底。
|
|
||||||
if e.kind() != std::io::ErrorKind::NotFound {
|
|
||||||
tracing::warn!("Purge: remove file failed ({}): {}", file_path, e);
|
|
||||||
failures += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::api::image::invalidate_asset_caches(&path).await;
|
|
||||||
ids.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let deleted = client
|
|
||||||
.execute("DELETE FROM assets WHERE id = ANY($1)", &[&ids])
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
Ok(PurgeOrphansResponse {
|
|
||||||
success: true,
|
|
||||||
message: format!("已清理 {} 张孤儿素材", deleted),
|
|
||||||
deleted_count: deleted as i64,
|
|
||||||
freed_bytes,
|
|
||||||
failures,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
//! 素材分页列表接口。
|
|
||||||
//!
|
|
||||||
//! 按引用状态(全部/引用中/孤儿)筛选、按文件名/alt 搜索、按时间或大小排序。
|
|
||||||
//! 同时返回各筛选维度计数与可清理孤儿统计,供 tabs 与「清理孤儿」按钮展示。
|
|
||||||
//! Dioxus server function,注册在 `/api` 路径下,仅 admin 可用。
|
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
use super::types::AssetListResponse;
|
|
||||||
use crate::models::asset::{AssetFilter, AssetSort};
|
|
||||||
|
|
||||||
/// 每页素材数(网格 6 列 x 10 行)。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
const PER_PAGE: i64 = 60;
|
|
||||||
|
|
||||||
/// 孤儿清理保护窗:上传不满 7 天的无引用素材不可一键清理
|
|
||||||
/// (保护尚未首次保存的草稿引用)。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub(crate) const PURGE_GRACE_DAYS: i32 = 7;
|
|
||||||
|
|
||||||
/// 获取素材分页列表。
|
|
||||||
#[server(ListAssets, "/api")]
|
|
||||||
pub async fn list_assets(
|
|
||||||
filter: AssetFilter,
|
|
||||||
query: String,
|
|
||||||
sort: AssetSort,
|
|
||||||
page: i32,
|
|
||||||
) -> Result<AssetListResponse, ServerFnError> {
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
{
|
|
||||||
use crate::api::auth::get_current_admin_user;
|
|
||||||
use crate::api::error::AppError;
|
|
||||||
use crate::db::pool::get_conn;
|
|
||||||
use crate::models::asset::{Asset, AssetDto, AssetRef};
|
|
||||||
|
|
||||||
let _admin = get_current_admin_user().await?;
|
|
||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
|
||||||
|
|
||||||
let page = page.max(1);
|
|
||||||
let offset: i64 = (page as i64 - 1) * PER_PAGE;
|
|
||||||
let query = query.trim().to_string();
|
|
||||||
|
|
||||||
// 筛选/搜索条件统一拼进 WHERE;参数按出现顺序编号。
|
|
||||||
// 引用状态用 EXISTS 子查询,搜索用 ILIKE 转义通配符。
|
|
||||||
let mut conditions: Vec<String> = Vec::new();
|
|
||||||
let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
|
|
||||||
match filter {
|
|
||||||
AssetFilter::Used => {
|
|
||||||
conditions.push(
|
|
||||||
"EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
AssetFilter::Orphan => {
|
|
||||||
conditions.push(
|
|
||||||
"NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
AssetFilter::All => {}
|
|
||||||
}
|
|
||||||
if !query.is_empty() {
|
|
||||||
params.push(&query);
|
|
||||||
conditions.push(format!(
|
|
||||||
"(a.filename ILIKE '%' || ${} || '%' OR a.alt ILIKE '%' || ${} || '%')",
|
|
||||||
params.len(),
|
|
||||||
params.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let where_clause = if conditions.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", conditions.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
let order_clause = match sort {
|
|
||||||
AssetSort::CreatedDesc => "a.created_at DESC, a.id",
|
|
||||||
AssetSort::SizeDesc => "a.size_bytes DESC, a.id",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 列表查询:ref_count 用相关子查询一次带出。
|
|
||||||
// const 不能取引用(内联后借临时值会垂悬),绑定到局部变量再进参数列表。
|
|
||||||
let per_page = PER_PAGE;
|
|
||||||
params.push(&per_page);
|
|
||||||
let limit_idx = params.len();
|
|
||||||
params.push(&offset);
|
|
||||||
let offset_idx = params.len();
|
|
||||||
let rows = client
|
|
||||||
.query(
|
|
||||||
&format!(
|
|
||||||
"SELECT a.id AS id, a.path, a.filename, a.mime, a.size_bytes, \
|
|
||||||
a.width, a.height, a.alt, a.created_at, \
|
|
||||||
(SELECT COUNT(*) FROM asset_refs r WHERE r.asset_id = a.id) AS ref_count \
|
|
||||||
FROM assets a {where_clause} \
|
|
||||||
ORDER BY {order_clause} LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
|
||||||
),
|
|
||||||
¶ms,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
let total: i64 = client
|
|
||||||
.query_one(
|
|
||||||
&format!("SELECT COUNT(*) FROM assets a {where_clause}"),
|
|
||||||
¶ms[..params.len() - 2],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?
|
|
||||||
.get(0);
|
|
||||||
|
|
||||||
// 汇总计数:tabs 与「清理孤儿」按钮徽标。不受筛选/搜索影响,始终全局。
|
|
||||||
let summary = client
|
|
||||||
.query_one(
|
|
||||||
"SELECT \
|
|
||||||
COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)), \
|
|
||||||
COUNT(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)), \
|
|
||||||
COUNT(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
|
|
||||||
AND a.created_at < NOW() - make_interval(days => $1)), \
|
|
||||||
COALESCE(SUM(a.size_bytes) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
|
|
||||||
AND a.created_at < NOW() - make_interval(days => $1)), 0)::bigint \
|
|
||||||
FROM assets a",
|
|
||||||
&[&PURGE_GRACE_DAYS],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
// 本页素材的引用文章(第二查询,避免 JOIN fan-out 与分页错位)。
|
|
||||||
let ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.get::<_, uuid::Uuid>("id")).collect();
|
|
||||||
let mut refs_map: std::collections::HashMap<String, Vec<AssetRef>> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
if !ids.is_empty() {
|
|
||||||
let ref_rows = client
|
|
||||||
.query(
|
|
||||||
"SELECT r.asset_id AS asset_id, p.id AS post_id, p.title \
|
|
||||||
FROM asset_refs r JOIN posts p ON p.id = r.post_id \
|
|
||||||
WHERE r.asset_id = ANY($1) \
|
|
||||||
ORDER BY p.id",
|
|
||||||
&[&ids],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
for rr in ref_rows {
|
|
||||||
let asset_key: String = rr.get::<_, uuid::Uuid>("asset_id").to_string();
|
|
||||||
refs_map.entry(asset_key).or_default().push(AssetRef {
|
|
||||||
post_id: rr.get("post_id"),
|
|
||||||
title: rr.get("title"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let assets = rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| {
|
|
||||||
let id: String = row.get::<_, uuid::Uuid>("id").to_string();
|
|
||||||
let refs = refs_map.remove(&id).unwrap_or_default();
|
|
||||||
AssetDto {
|
|
||||||
asset: Asset {
|
|
||||||
id,
|
|
||||||
path: row.get("path"),
|
|
||||||
filename: row.get("filename"),
|
|
||||||
mime: row.get("mime"),
|
|
||||||
size_bytes: row.get("size_bytes"),
|
|
||||||
width: row.get("width"),
|
|
||||||
height: row.get("height"),
|
|
||||||
alt: row.get("alt"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
},
|
|
||||||
ref_count: row.get("ref_count"),
|
|
||||||
refs,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(AssetListResponse {
|
|
||||||
assets,
|
|
||||||
total,
|
|
||||||
used_count: summary.get(0),
|
|
||||||
orphan_count: summary.get(1),
|
|
||||||
purgeable_count: summary.get(2),
|
|
||||||
purgeable_bytes: summary.get(3),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
//! 素材管理 API 模块。
|
|
||||||
//!
|
|
||||||
//! 管理 `uploads/` 图片的注册表(assets 表)与引用关系(asset_refs 表):
|
|
||||||
//! 分页列表、删除保护、孤儿清理、全量重建索引。
|
|
||||||
//! 全部为 Dioxus server function,仅 admin 可用。
|
|
||||||
// 与 posts / comments 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
|
|
||||||
#![allow(clippy::unused_unit, deprecated, unused_imports)]
|
|
||||||
|
|
||||||
/// 素材删除与孤儿清理。
|
|
||||||
pub mod delete;
|
|
||||||
/// 素材分页列表。
|
|
||||||
pub mod list;
|
|
||||||
/// 素材索引全量重建。
|
|
||||||
pub mod rebuild;
|
|
||||||
/// 请求与响应数据结构。
|
|
||||||
pub mod types;
|
|
||||||
|
|
||||||
pub use delete::{delete_asset, purge_orphan_assets, update_asset_alt};
|
|
||||||
pub use list::list_assets;
|
|
||||||
pub use rebuild::rebuild_assets_index;
|
|
||||||
pub use types::{AssetListResponse, AssetOpResponse, PurgeOrphansResponse, RebuildAssetsResponse};
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
//! 素材索引全量重建接口。
|
|
||||||
//!
|
|
||||||
//! 以磁盘为准自愈 DB 与文件系统的不一致:
|
|
||||||
//! 1. 扫 `uploads/`(跳过 `.cache` 等点目录)→ upsert assets(技术字段变化才更新,保留 alt);
|
|
||||||
//! 2. 删除文件已消失的 DB 行(refs 级联);
|
|
||||||
//! 3. 全表扫 posts(含回收站)重建 asset_refs。
|
|
||||||
//!
|
|
||||||
//! 幂等:重跑结果相同(技术字段无变化时 updated 为 0,alt 不被覆盖)。
|
|
||||||
//! 幂等性由「手动触发」语义承载,非常态路径。Dioxus server function,仅 admin 可用。
|
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
use super::types::RebuildAssetsResponse;
|
|
||||||
|
|
||||||
/// 可登记的图片扩展名(与 upload.rs 的 ALLOWED_MIME_TYPES 对应)。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
const IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"];
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
/// 扫描到的磁盘文件信息(spawn_blocking 产物)。
|
|
||||||
struct ScannedFile {
|
|
||||||
/// 相对路径 "2026/07/24/x.webp"。
|
|
||||||
rel_path: String,
|
|
||||||
filename: String,
|
|
||||||
mime: &'static str,
|
|
||||||
size_bytes: i64,
|
|
||||||
width: i32,
|
|
||||||
height: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
/// 递归收集 dir 下的图片文件(跳过以 `.` 开头的目录/文件)。
|
|
||||||
fn walk_images(dir: &std::path::Path, base: &std::path::Path, out: &mut Vec<ScannedFile>) {
|
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let name = entry.file_name();
|
|
||||||
let Some(name_str) = name.to_str() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if name_str.starts_with('.') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
walk_images(&path, base, out);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let ext = name_str.rsplit('.').next().unwrap_or("");
|
|
||||||
if !IMAGE_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(rel) = path.strip_prefix(base) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let rel_path = rel.to_string_lossy().replace('\\', "/");
|
|
||||||
// 尺寸读 header(命中 IMAGE_DIMENSIONS_CACHE 时零 IO);读不到则跳过该文件。
|
|
||||||
let Some((w, h)) = crate::api::image::get_image_dimensions(&rel_path) else {
|
|
||||||
tracing::warn!("Rebuild: skip unreadable image {}", rel_path);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let size_bytes = entry.metadata().map(|m| m.len() as i64).unwrap_or(0);
|
|
||||||
let mime = match ext.to_ascii_lowercase().as_str() {
|
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
|
||||||
"png" => "image/png",
|
|
||||||
"gif" => "image/gif",
|
|
||||||
_ => "image/webp",
|
|
||||||
};
|
|
||||||
out.push(ScannedFile {
|
|
||||||
rel_path,
|
|
||||||
filename: name_str.to_string(),
|
|
||||||
mime,
|
|
||||||
size_bytes,
|
|
||||||
width: w as i32,
|
|
||||||
height: h as i32,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 全量重建素材索引。
|
|
||||||
#[server(RebuildAssetsIndex, "/api")]
|
|
||||||
pub async fn rebuild_assets_index() -> Result<RebuildAssetsResponse, ServerFnError> {
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
{
|
|
||||||
use crate::api::auth::get_current_admin_user;
|
|
||||||
use crate::api::error::AppError;
|
|
||||||
use crate::db::pool::get_conn;
|
|
||||||
|
|
||||||
let _admin = get_current_admin_user().await?;
|
|
||||||
|
|
||||||
// 磁盘扫描 + header 尺寸读取是 IO 密集同步操作,移到阻塞线程池。
|
|
||||||
let scanned = tokio::task::spawn_blocking(|| {
|
|
||||||
let base = std::path::Path::new("uploads");
|
|
||||||
let mut files = Vec::new();
|
|
||||||
walk_images(base, base, &mut files);
|
|
||||||
files
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::Internal("素材扫描任务失败"))?;
|
|
||||||
|
|
||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
|
||||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
|
||||||
|
|
||||||
// 1. upsert assets。xmax = 0 判别新插入(PG 系统列:新行 xmax 为 0)。
|
|
||||||
// ON CONFLICT 仅当技术字段实际变化时才更新(IS DISTINCT FROM),
|
|
||||||
// 保证幂等重跑 updated = 0 且不覆盖 alt。
|
|
||||||
let mut inserted: i64 = 0;
|
|
||||||
let mut updated: i64 = 0;
|
|
||||||
for f in &scanned {
|
|
||||||
// DO UPDATE 的 WHERE 不满足时不返回行(技术字段无变化),用 query_opt 区分三种结果。
|
|
||||||
let asset_id = uuid::Uuid::new_v4();
|
|
||||||
let row = tx
|
|
||||||
.query_opt(
|
|
||||||
"INSERT INTO assets (id, path, filename, mime, size_bytes, width, height) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
|
||||||
ON CONFLICT (path) DO UPDATE SET \
|
|
||||||
filename = EXCLUDED.filename, \
|
|
||||||
mime = EXCLUDED.mime, \
|
|
||||||
size_bytes = EXCLUDED.size_bytes, \
|
|
||||||
width = EXCLUDED.width, \
|
|
||||||
height = EXCLUDED.height, \
|
|
||||||
updated_at = NOW() \
|
|
||||||
WHERE assets.size_bytes IS DISTINCT FROM EXCLUDED.size_bytes \
|
|
||||||
OR assets.width IS DISTINCT FROM EXCLUDED.width \
|
|
||||||
OR assets.height IS DISTINCT FROM EXCLUDED.height \
|
|
||||||
OR assets.mime IS DISTINCT FROM EXCLUDED.mime \
|
|
||||||
OR assets.filename IS DISTINCT FROM EXCLUDED.filename \
|
|
||||||
RETURNING (xmax = 0) AS was_inserted",
|
|
||||||
&[
|
|
||||||
&asset_id,
|
|
||||||
&f.rel_path,
|
|
||||||
&f.filename,
|
|
||||||
&f.mime,
|
|
||||||
&f.size_bytes,
|
|
||||||
&f.width,
|
|
||||||
&f.height,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::tx)?;
|
|
||||||
match row {
|
|
||||||
Some(r) if r.get::<_, bool>("was_inserted") => inserted += 1,
|
|
||||||
Some(_) => updated += 1,
|
|
||||||
None => {} // 技术字段无变化,幂等跳过
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 删除文件已消失的 DB 行(refs 级联删)。
|
|
||||||
let paths: Vec<String> = scanned.iter().map(|f| f.rel_path.clone()).collect();
|
|
||||||
let removed = tx
|
|
||||||
.execute(
|
|
||||||
"DELETE FROM assets WHERE NOT (path = ANY($1))",
|
|
||||||
&[&paths],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::tx)?;
|
|
||||||
|
|
||||||
// 3. 重建 asset_refs:全表扫 posts(含回收站——回收站文章的引用同样阻止删除)。
|
|
||||||
let post_rows = tx
|
|
||||||
.query("SELECT id, content_html, cover_image FROM posts", &[])
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
tx.execute("DELETE FROM asset_refs", &[])
|
|
||||||
.await
|
|
||||||
.map_err(AppError::tx)?;
|
|
||||||
let mut ref_count: i64 = 0;
|
|
||||||
for pr in &post_rows {
|
|
||||||
let post_id: i32 = pr.get("id");
|
|
||||||
let content_html: Option<String> = pr.get("content_html");
|
|
||||||
let cover_image: Option<String> = pr.get("cover_image");
|
|
||||||
let found = crate::api::posts::helpers::extract_asset_paths(
|
|
||||||
content_html.as_deref().unwrap_or(""),
|
|
||||||
cover_image.as_deref(),
|
|
||||||
);
|
|
||||||
if found.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let n = tx
|
|
||||||
.execute(
|
|
||||||
"INSERT INTO asset_refs (asset_id, post_id) \
|
|
||||||
SELECT id, $1 FROM assets WHERE path = ANY($2) \
|
|
||||||
ON CONFLICT DO NOTHING",
|
|
||||||
&[&post_id, &found],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::tx)?;
|
|
||||||
ref_count += n as i64;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().await.map_err(AppError::tx)?;
|
|
||||||
|
|
||||||
let scanned_count = scanned.len() as i64;
|
|
||||||
Ok(RebuildAssetsResponse {
|
|
||||||
success: true,
|
|
||||||
message: format!(
|
|
||||||
"重建完成:扫描 {} 个文件,新增 {},更新 {},移除 {}",
|
|
||||||
scanned_count, inserted, updated, removed
|
|
||||||
),
|
|
||||||
scanned: scanned_count,
|
|
||||||
inserted,
|
|
||||||
updated,
|
|
||||||
removed: removed as i64,
|
|
||||||
ref_count,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
//! 素材管理 API 的请求与响应数据结构。
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::models::asset::AssetDto;
|
|
||||||
|
|
||||||
/// 素材分页列表响应。
|
|
||||||
///
|
|
||||||
/// 附带各筛选维度的计数(tabs 展示)与可清理孤儿的统计(清理按钮展示),
|
|
||||||
/// 避免前端为徽标数字额外发请求。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AssetListResponse {
|
|
||||||
pub assets: Vec<AssetDto>,
|
|
||||||
/// 当前筛选条件下的总数(分页用)。
|
|
||||||
pub total: i64,
|
|
||||||
/// 全部被引用素材数(「引用中」tab)。
|
|
||||||
pub used_count: i64,
|
|
||||||
/// 全部无引用素材数(「孤儿」tab,含 7 天保护窗内的)。
|
|
||||||
pub orphan_count: i64,
|
|
||||||
/// 可一键清理的孤儿数(无引用且 created_at 早于 7 天前)。
|
|
||||||
pub purgeable_count: i64,
|
|
||||||
/// 可清理孤儿的总字节数。
|
|
||||||
pub purgeable_bytes: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 通用素材操作响应(删除/清理/重建共用)。
|
|
||||||
///
|
|
||||||
/// 业务拒绝(如引用中禁删)走 `Ok(success:false)`,遵循仓库约定不走 Err。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AssetOpResponse {
|
|
||||||
pub success: bool,
|
|
||||||
pub message: String,
|
|
||||||
/// 删除被拦截时的引用文章列表(post_id, title)。
|
|
||||||
pub refs: Vec<crate::models::asset::AssetRef>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AssetOpResponse {
|
|
||||||
pub fn ok(message: String) -> Self {
|
|
||||||
Self {
|
|
||||||
success: true,
|
|
||||||
message,
|
|
||||||
refs: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn err(message: String) -> Self {
|
|
||||||
Self {
|
|
||||||
success: false,
|
|
||||||
message,
|
|
||||||
refs: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一键清理孤儿的结果。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PurgeOrphansResponse {
|
|
||||||
pub success: bool,
|
|
||||||
pub message: String,
|
|
||||||
pub deleted_count: i64,
|
|
||||||
pub freed_bytes: i64,
|
|
||||||
/// 删文件失败但 DB 行已删的素材数(文件可能已不存在,属可容忍不一致)。
|
|
||||||
pub failures: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重建索引的结果。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct RebuildAssetsResponse {
|
|
||||||
pub success: bool,
|
|
||||||
pub message: String,
|
|
||||||
/// 磁盘扫描到的图片文件数。
|
|
||||||
pub scanned: i64,
|
|
||||||
/// 新登记进 assets 的数量。
|
|
||||||
pub inserted: i64,
|
|
||||||
/// 已存在并更新技术字段的数量。
|
|
||||||
pub updated: i64,
|
|
||||||
/// 文件已消失而删除的 DB 行数。
|
|
||||||
pub removed: i64,
|
|
||||||
/// 重建后的引用关联总数。
|
|
||||||
pub ref_count: i64,
|
|
||||||
}
|
|
||||||
@ -321,15 +321,15 @@ fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// 仅读取 header 校验上传图片的尺寸/像素是否超限,并返回 (width, height)。
|
/// 仅读取 header 校验上传图片的尺寸/像素是否超限。
|
||||||
///
|
///
|
||||||
|
/// 与 `check_image_dimensions` 的区别:
|
||||||
/// - 输入是原始字节 + MIME,内部按 MIME 分发只解析 header 拿尺寸(不解码像素)
|
/// - 输入是原始字节 + MIME,内部按 MIME 分发只解析 header 拿尺寸(不解码像素)
|
||||||
/// - 返回带友好中文提示的 `&'static str`(供上传接口直接回给用户)
|
/// - 返回带友好中文提示的 `&'static str`(供上传接口直接回给用户)
|
||||||
///
|
///
|
||||||
/// 上传入口三种格式在此统一拦截至尺寸上限;WebP 走 `zenwebp` header,
|
/// 上传入口三种格式在此统一拦截至尺寸上限;WebP 走 `zenwebp` header,
|
||||||
/// JPEG/PNG/GIF 走 `image` crate 的 `into_dimensions`(均只读 header)。
|
/// JPEG/PNG/GIF 走 `image` crate 的 `into_dimensions`(均只读 header)。
|
||||||
/// 尺寸随结果返回,供 `upload_image` 校验通过后直接写入 assets 表,避免二次解析。
|
pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'static str> {
|
||||||
pub(crate) fn upload_dimensions(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
|
|
||||||
let dims = read_dimensions_by_mime(data, mime_type)?;
|
let dims = read_dimensions_by_mime(data, mime_type)?;
|
||||||
let (width, height) = dims;
|
let (width, height) = dims;
|
||||||
if width == 0 || height == 0 {
|
if width == 0 || height == 0 {
|
||||||
@ -350,7 +350,7 @@ pub(crate) fn upload_dimensions(data: &[u8], mime_type: &str) -> Result<(u32, u3
|
|||||||
);
|
);
|
||||||
return Err("图片尺寸过大,请压缩后再上传");
|
return Err("图片尺寸过大,请压缩后再上传");
|
||||||
}
|
}
|
||||||
Ok(dims)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -534,22 +534,6 @@ use axum::http::HeaderMap;
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
const CACHE_DIR: &str = "uploads/.cache";
|
const CACHE_DIR: &str = "uploads/.cache";
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
/// 素材删除时清理其派生缓存。
|
|
||||||
///
|
|
||||||
/// - 内存处理缓存 `IMAGE_CACHE`:key 形如 `{path}|w=..|thumb=..`,按前缀批量失效;
|
|
||||||
/// - 尺寸缓存 `IMAGE_DIMENSIONS_CACHE`:key 即相对路径,直接失效。
|
|
||||||
///
|
|
||||||
/// 磁盘派生缓存(`uploads/.cache/cache_<sha256(key)>`)文件名是整 key 的哈希,
|
|
||||||
/// 无法按路径前缀枚举;这些死条目由 `image_cache_cleanup` 后台任务
|
|
||||||
/// 按容量/年龄回收,不在删除路径上处理。
|
|
||||||
pub async fn invalidate_asset_caches(rel_path: &str) {
|
|
||||||
let prefix = format!("{}|", rel_path);
|
|
||||||
// moka 的 invalidate_entries_if 是同步谓词求值,返回 Result(仅谓词 panic 时 Err)。
|
|
||||||
let _ = IMAGE_CACHE.invalidate_entries_if(move |k, _| k.starts_with(&prefix));
|
|
||||||
IMAGE_DIMENSIONS_CACHE.invalidate(rel_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn disk_cache_base(cache_key: &str) -> String {
|
fn disk_cache_base(cache_key: &str) -> String {
|
||||||
// 使用 SHA-256 生成稳定的磁盘缓存文件名,避免进程重启后 DefaultHasher 随机 seed
|
// 使用 SHA-256 生成稳定的磁盘缓存文件名,避免进程重启后 DefaultHasher 随机 seed
|
||||||
@ -788,7 +772,7 @@ mod tests {
|
|||||||
assert_eq!(dims, None);
|
assert_eq!(dims, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— upload_dimensions:统一上传尺寸/像素上限校验 ——
|
// —— check_upload_dimensions:统一上传尺寸/像素上限校验 ——
|
||||||
|
|
||||||
/// 构造指定尺寸的 PNG 字节(内存占用 = 尺寸,仅用于 header 校验测试)。
|
/// 构造指定尺寸的 PNG 字节(内存占用 = 尺寸,仅用于 header 校验测试)。
|
||||||
fn make_png_bytes(w: u32, h: u32) -> Vec<u8> {
|
fn make_png_bytes(w: u32, h: u32) -> Vec<u8> {
|
||||||
@ -799,54 +783,54 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upload_dimensions_accepts_small_png() {
|
fn check_upload_dimensions_accepts_small_png() {
|
||||||
let data = make_png_bytes(100, 100);
|
let data = make_png_bytes(100, 100);
|
||||||
assert!(upload_dimensions(&data, "image/png").is_ok());
|
assert!(check_upload_dimensions(&data, "image/png").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upload_dimensions_accepts_boundary_png() {
|
fn check_upload_dimensions_accepts_boundary_png() {
|
||||||
// 用 7000×7000(≈49M 像素):单边 7000 < 默认 8192 上限,且总像素 < 默认 50M 上限,应放行。
|
// 用 7000×7000(≈49M 像素):单边 7000 < 默认 8192 上限,且总像素 < 默认 50M 上限,应放行。
|
||||||
// 注意不能直接用 *MAX_IMAGE_DIMENSION 做正方形边——8192²≈67M 会触发像素上限拒绝。
|
// 注意不能直接用 *MAX_IMAGE_DIMENSION 做正方形边——8192²≈67M 会触发像素上限拒绝。
|
||||||
let data = make_png_bytes(7000, 7000);
|
let data = make_png_bytes(7000, 7000);
|
||||||
assert!(upload_dimensions(&data, "image/png").is_ok());
|
assert!(check_upload_dimensions(&data, "image/png").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upload_dimensions_rejects_oversized_width() {
|
fn check_upload_dimensions_rejects_oversized_width() {
|
||||||
// 单边超限:(上限+1)×1,像素远低于上限,但单边越界
|
// 单边超限:(上限+1)×1,像素远低于上限,但单边越界
|
||||||
let data = make_png_bytes(*MAX_IMAGE_DIMENSION + 1, 1);
|
let data = make_png_bytes(*MAX_IMAGE_DIMENSION + 1, 1);
|
||||||
let err = upload_dimensions(&data, "image/png").unwrap_err();
|
let err = check_upload_dimensions(&data, "image/png").unwrap_err();
|
||||||
assert!(err.contains("尺寸过大"));
|
assert!(err.contains("尺寸过大"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upload_dimensions_rejects_oversized_height() {
|
fn check_upload_dimensions_rejects_oversized_height() {
|
||||||
let data = make_png_bytes(1, *MAX_IMAGE_DIMENSION + 1);
|
let data = make_png_bytes(1, *MAX_IMAGE_DIMENSION + 1);
|
||||||
let err = upload_dimensions(&data, "image/png").unwrap_err();
|
let err = check_upload_dimensions(&data, "image/png").unwrap_err();
|
||||||
assert!(err.contains("尺寸过大"));
|
assert!(err.contains("尺寸过大"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upload_dimensions_accepts_small_webp() {
|
fn check_upload_dimensions_accepts_small_webp() {
|
||||||
let img = image::DynamicImage::new_rgb8(64, 48);
|
let img = image::DynamicImage::new_rgb8(64, 48);
|
||||||
let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
|
let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
|
||||||
assert!(upload_dimensions(&webp_bytes, "image/webp").is_ok());
|
assert!(check_upload_dimensions(&webp_bytes, "image/webp").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upload_dimensions_accepts_gif() {
|
fn check_upload_dimensions_accepts_gif() {
|
||||||
// image crate 默认启用 gif feature,into_dimensions 可读 GIF header
|
// image crate 默认启用 gif feature,into_dimensions 可读 GIF header
|
||||||
let img = image::DynamicImage::new_rgb8(32, 32);
|
let img = image::DynamicImage::new_rgb8(32, 32);
|
||||||
let mut buf = std::io::Cursor::new(Vec::new());
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
img.write_to(&mut buf, image::ImageFormat::Gif).unwrap();
|
img.write_to(&mut buf, image::ImageFormat::Gif).unwrap();
|
||||||
assert!(upload_dimensions(&buf.into_inner(), "image/gif").is_ok());
|
assert!(check_upload_dimensions(&buf.into_inner(), "image/gif").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upload_dimensions_rejects_corrupt_bytes() {
|
fn check_upload_dimensions_rejects_corrupt_bytes() {
|
||||||
// 非 magic bytes,与现有损坏文件校验文案对齐
|
// 非 magic bytes,与现有损坏文件校验文案对齐
|
||||||
let err = upload_dimensions(b"not an image at all", "image/png").unwrap_err();
|
let err = check_upload_dimensions(b"not an image at all", "image/png").unwrap_err();
|
||||||
assert_eq!(err, "图片文件损坏或格式不正确");
|
assert_eq!(err, "图片文件损坏或格式不正确");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
/// 认证相关的 Dioxus server function。
|
/// 认证相关的 Dioxus server function。
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
/// 素材管理(assets/asset_refs)的 Dioxus server function。
|
|
||||||
pub mod assets;
|
|
||||||
/// 代码运行接口与数据结构。
|
/// 代码运行接口与数据结构。
|
||||||
pub mod code_runner;
|
pub mod code_runner;
|
||||||
/// 评论相关接口。
|
/// 评论相关接口。
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use super::helpers::{clean_tags, get_current_admin_user, sync_asset_refs, sync_tags};
|
use super::helpers::{clean_tags, get_current_admin_user, sync_tags};
|
||||||
use super::types::CreatePostResponse;
|
use super::types::CreatePostResponse;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::api::error::AppError;
|
use crate::api::error::AppError;
|
||||||
@ -129,9 +129,6 @@ pub async fn create_post(
|
|||||||
let tags_cleaned = clean_tags(&tags);
|
let tags_cleaned = clean_tags(&tags);
|
||||||
sync_tags(&tx, post_id, &tags_cleaned).await?;
|
sync_tags(&tx, post_id, &tags_cleaned).await?;
|
||||||
|
|
||||||
// 同步素材引用关联(asset_refs):内部自带 DELETE 再重建。
|
|
||||||
sync_asset_refs(&tx, post_id, &content_html, cover_image.as_deref()).await?;
|
|
||||||
|
|
||||||
tx.commit().await.map_err(AppError::tx)?;
|
tx.commit().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
// 写入成功后按粒度失效相关缓存。
|
// 写入成功后按粒度失效相关缓存。
|
||||||
|
|||||||
@ -203,73 +203,9 @@ pub(super) fn clean_tags(tags: &[String]) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 匹配 HTML/Markdown 中出现的本地上传图片路径,捕获组为相对路径
|
|
||||||
/// (如 `2026/07/24/153000.<uuid>.webp`,不含 /uploads/ 前缀与 query)。
|
|
||||||
/// 覆盖 blur-img 双层结构的 src 与 data-src。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
static ASSET_PATH_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
|
|
||||||
regex::Regex::new(r#"/uploads/(\d{4}/\d{2}/\d{2}/[^\"'\s?#\)]+)"#)
|
|
||||||
.expect("ASSET_PATH_RE 正则模式应在编译期通过校验")
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 从文章 HTML 与封面 URL 中提取全部引用的本地上传图片相对路径(去重)。
|
|
||||||
///
|
|
||||||
/// 外链图(非 /uploads/ 路径)与无法识别的路径自然被忽略。
|
|
||||||
/// pub(crate):重建素材索引(api::assets::rebuild)复用同一提取逻辑。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub(crate) fn extract_asset_paths(content_html: &str, cover_image: Option<&str>) -> Vec<String> {
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
let mut paths: Vec<String> = ASSET_PATH_RE
|
|
||||||
.captures_iter(content_html)
|
|
||||||
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
|
|
||||||
.filter(|p| seen.insert(p.clone()))
|
|
||||||
.collect();
|
|
||||||
if let Some(cover) = cover_image {
|
|
||||||
if let Some(rel) = cover
|
|
||||||
.strip_prefix("/uploads/")
|
|
||||||
.map(|p| p.split('?').next().unwrap_or(p))
|
|
||||||
{
|
|
||||||
if seen.insert(rel.to_string()) {
|
|
||||||
paths.push(rel.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
paths
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在事务中同步文章的素材引用关联(asset_refs)。
|
|
||||||
///
|
|
||||||
/// 语义镜像 [`sync_tags`]:调用方需在事务内先删除旧关联(本函数自带 DELETE),
|
|
||||||
/// 再按 content_html + cover_image 中出现的 /uploads/ 路径重建。
|
|
||||||
/// 未登记到 assets 表的路径(如回填前的旧图)静默跳过,由重建索引兜底。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub(super) async fn sync_asset_refs(
|
|
||||||
tx: &deadpool_postgres::Transaction<'_>,
|
|
||||||
post_id: i32,
|
|
||||||
content_html: &str,
|
|
||||||
cover_image: Option<&str>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
tx.execute("DELETE FROM asset_refs WHERE post_id = $1", &[&post_id])
|
|
||||||
.await
|
|
||||||
.map_err(AppError::tx)?;
|
|
||||||
|
|
||||||
let paths = extract_asset_paths(content_html, cover_image);
|
|
||||||
if !paths.is_empty() {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO asset_refs (asset_id, post_id) \
|
|
||||||
SELECT id, $1 FROM assets WHERE path = ANY($2) \
|
|
||||||
ON CONFLICT DO NOTHING",
|
|
||||||
&[&post_id, &paths],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::tx)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, feature = "server"))]
|
#[cfg(all(test, feature = "server"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{clean_tags, extract_asset_paths};
|
use super::clean_tags;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_tags_trims_whitespace() {
|
fn clean_tags_trims_whitespace() {
|
||||||
@ -313,43 +249,4 @@ mod tests {
|
|||||||
vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()]
|
vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— extract_asset_paths ——
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_asset_paths_from_blur_img_html() {
|
|
||||||
// blur-img 双层结构:src 带 ?w=20,data-src 带 ?w=800,同一张图只应提取一次。
|
|
||||||
let html = r#"<span class="blur-img"><img class="blur-img-placeholder" src="/uploads/2026/07/24/a.webp?w=20"><img class="blur-img-full" data-src="/uploads/2026/07/24/a.webp?w=800"></span>"#;
|
|
||||||
assert_eq!(
|
|
||||||
extract_asset_paths(html, None),
|
|
||||||
vec!["2026/07/24/a.webp".to_string()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_asset_paths_multiple_and_cover() {
|
|
||||||
let html = r#"<p><img src="/uploads/2026/07/24/a.webp"></p><p><img src="/uploads/2026/06/01/b.png?w=800"></p><p><img src="https://cdn.example.com/x.webp"></p>"#;
|
|
||||||
let paths = extract_asset_paths(html, Some("/uploads/2026/07/24/cover.jpg?w=600"));
|
|
||||||
assert_eq!(paths.len(), 3);
|
|
||||||
assert!(paths.contains(&"2026/07/24/a.webp".to_string()));
|
|
||||||
assert!(paths.contains(&"2026/06/01/b.png".to_string()));
|
|
||||||
assert!(paths.contains(&"2026/07/24/cover.jpg".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_asset_paths_cover_dedup_and_external() {
|
|
||||||
// 封面与正文同图时去重;外链封面不产生路径。
|
|
||||||
let html = r#"<img src="/uploads/2026/07/24/a.webp">"#;
|
|
||||||
assert_eq!(
|
|
||||||
extract_asset_paths(html, Some("/uploads/2026/07/24/a.webp")),
|
|
||||||
vec!["2026/07/24/a.webp".to_string()]
|
|
||||||
);
|
|
||||||
assert!(extract_asset_paths(html, Some("https://example.com/c.webp")).len() == 1);
|
|
||||||
assert!(extract_asset_paths(html, None).len() == 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_asset_paths_empty() {
|
|
||||||
assert!(extract_asset_paths("<p>no image</p>", None).is_empty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
mod create;
|
mod create;
|
||||||
mod delete;
|
mod delete;
|
||||||
pub(crate) mod helpers;
|
mod helpers;
|
||||||
mod list;
|
mod list;
|
||||||
mod read;
|
mod read;
|
||||||
mod rebuild;
|
mod rebuild;
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use super::helpers::{clean_tags, get_current_admin_user, sync_asset_refs, sync_tags};
|
use super::helpers::{clean_tags, get_current_admin_user, sync_tags};
|
||||||
use super::types::CreatePostResponse;
|
use super::types::CreatePostResponse;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::api::error::AppError;
|
use crate::api::error::AppError;
|
||||||
@ -183,9 +183,6 @@ pub async fn update_post(
|
|||||||
|
|
||||||
sync_tags(&tx, post_id, &tags_cleaned).await?;
|
sync_tags(&tx, post_id, &tags_cleaned).await?;
|
||||||
|
|
||||||
// 同步素材引用关联(asset_refs):内部自带 DELETE 再重建。
|
|
||||||
sync_asset_refs(&tx, post_id, &content_html, cover_image.as_deref()).await?;
|
|
||||||
|
|
||||||
tx.commit().await.map_err(AppError::tx)?;
|
tx.commit().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
// 失效文章列表、标签、当前 slug 与统计缓存。
|
// 失效文章列表、标签、当前 slug 与统计缓存。
|
||||||
|
|||||||
@ -134,9 +134,6 @@ pub async fn upload_image(
|
|||||||
return Err(upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型"));
|
return Err(upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原始文件名(客户端提供,仅作 assets 表展示字段);需在 bytes() 消耗 field 前取出。
|
|
||||||
let original_filename = field.file_name().map(|s| s.to_string());
|
|
||||||
|
|
||||||
// 5. Read file data
|
// 5. Read file data
|
||||||
let data = match field.bytes().await {
|
let data = match field.bytes().await {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
@ -161,13 +158,12 @@ pub async fn upload_image(
|
|||||||
return Err(upload_error(StatusCode::BAD_REQUEST, "文件类型与内容不符"));
|
return Err(upload_error(StatusCode::BAD_REQUEST, "文件类型与内容不符"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅读 header 统一校验尺寸/像素上限,并拿回 (w, h) 供 assets 表登记,避免二次解析。
|
// 仅读 header 统一校验尺寸/像素上限。三种格式走同一路径:
|
||||||
|
// JPEG/PNG/GIF 用 image crate 的 into_dimensions,WebP 用 zenwebp header。
|
||||||
// 超限直接拒绝,避免大图走 decode 后被静默降级(原 fallback 存原图)。
|
// 超限直接拒绝,避免大图走 decode 后被静默降级(原 fallback 存原图)。
|
||||||
let (img_width, img_height) =
|
if let Err(msg) = crate::api::image::check_upload_dimensions(&data, mime_type.as_str()) {
|
||||||
match crate::api::image::upload_dimensions(&data, mime_type.as_str()) {
|
return Err(upload_error(StatusCode::BAD_REQUEST, msg));
|
||||||
Ok(dims) => dims,
|
}
|
||||||
Err(msg) => return Err(upload_error(StatusCode::BAD_REQUEST, msg)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_gif = mime_type.as_str() == "image/gif";
|
let is_gif = mime_type.as_str() == "image/gif";
|
||||||
let is_webp = mime_type.as_str() == "image/webp";
|
let is_webp = mime_type.as_str() == "image/webp";
|
||||||
@ -295,48 +291,6 @@ pub async fn upload_image(
|
|||||||
|
|
||||||
tracing::info!("Image uploaded: {} ({} bytes)", file_path, final_data.len());
|
tracing::info!("Image uploaded: {} ({} bytes)", file_path, final_data.len());
|
||||||
|
|
||||||
// 登记 assets 注册表。失败时补偿删除已落盘文件,避免产生未登记的孤儿文件。
|
|
||||||
let rel_path = format!("{}/{}", date, file_name);
|
|
||||||
let final_mime = match final_ext.as_str() {
|
|
||||||
"jpg" => "image/jpeg",
|
|
||||||
"png" => "image/png",
|
|
||||||
"gif" => "image/gif",
|
|
||||||
_ => "image/webp",
|
|
||||||
};
|
|
||||||
let registered: Result<(), String> = async {
|
|
||||||
let client = crate::db::pool::get_conn()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
// id 用 Uuid 类型直连 uuid 列(with-uuid-1 桥接),避免 String→uuid 序列化失败。
|
|
||||||
let asset_id = uuid::Uuid::new_v4();
|
|
||||||
client
|
|
||||||
.execute(
|
|
||||||
"INSERT INTO assets (id, path, filename, mime, size_bytes, width, height)\
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
|
||||||
&[
|
|
||||||
&asset_id,
|
|
||||||
&rel_path,
|
|
||||||
&original_filename.unwrap_or_else(|| file_name.clone()),
|
|
||||||
&final_mime,
|
|
||||||
&(final_data.len() as i64),
|
|
||||||
&(img_width as i32),
|
|
||||||
&(img_height as i32),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
if let Err(e) = registered {
|
|
||||||
tracing::error!("Register asset failed ({}), removing uploaded file", e);
|
|
||||||
let _ = tokio::fs::remove_file(&file_path).await;
|
|
||||||
return Err(upload_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"文件保存失败",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"url": url_path
|
"url": url_path
|
||||||
|
|||||||
@ -44,7 +44,6 @@ pub fn AdminLayout() -> Element {
|
|||||||
(Route::Admin {}, "仪表盘"),
|
(Route::Admin {}, "仪表盘"),
|
||||||
(Route::Write {}, "写文章"),
|
(Route::Write {}, "写文章"),
|
||||||
(Route::Posts {}, "管理文章"),
|
(Route::Posts {}, "管理文章"),
|
||||||
(Route::Assets {}, "素材"),
|
|
||||||
(Route::Runner {}, "试运行"),
|
(Route::Runner {}, "试运行"),
|
||||||
(Route::System {}, "系统"),
|
(Route::System {}, "系统"),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -12,8 +12,6 @@ use dioxus::prelude::*;
|
|||||||
|
|
||||||
use crate::components::code_runner::CodeRunner;
|
use crate::components::code_runner::CodeRunner;
|
||||||
use crate::infra::runner_config::ResourceLimits;
|
use crate::infra::runner_config::ResourceLimits;
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::utils::js::invoke_optional_global;
|
|
||||||
|
|
||||||
/// 内容片段:普通 HTML 文本,或一个可运行代码块。
|
/// 内容片段:普通 HTML 文本,或一个可运行代码块。
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
@ -94,6 +92,27 @@ fn decode_html_entities(s: &str) -> String {
|
|||||||
.replace("&", "&")
|
.replace("&", "&")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取 `window` 上的可选全局函数并调用;函数未定义/为 null 时静默跳过。
|
||||||
|
///
|
||||||
|
/// 替代 `js_sys::eval("if(window.__x) window.__x(...)")` 字符串拼贴模式:用
|
||||||
|
/// `Reflect::get` 取属性 + `Function::apply` 调用,无字符串求值,与 `tiptap_bridge`
|
||||||
|
/// 的类型化 extern 风格一致。
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn invoke_optional_global(window: &web_sys::Window, name: &str, args: &[wasm_bindgen::JsValue]) {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
if let Ok(fn_val) = js_sys::Reflect::get(window, &name.into()) {
|
||||||
|
if !fn_val.is_undefined() && !fn_val.is_null() {
|
||||||
|
let arr = js_sys::Array::new();
|
||||||
|
for a in args {
|
||||||
|
arr.push(a);
|
||||||
|
}
|
||||||
|
let _ = fn_val
|
||||||
|
.unchecked_into::<js_sys::Function>()
|
||||||
|
.apply(window, &arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 文章内容组件。
|
/// 文章内容组件。
|
||||||
///
|
///
|
||||||
/// Props:
|
/// Props:
|
||||||
|
|||||||
@ -60,7 +60,6 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
"014",
|
"014",
|
||||||
include_str!("../../migrations/014_drop_ineffective_trgm_index.sql"),
|
include_str!("../../migrations/014_drop_ineffective_trgm_index.sql"),
|
||||||
),
|
),
|
||||||
("015", include_str!("../../migrations/015_assets.sql")),
|
|
||||||
// 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。
|
// 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
//! 素材(图片)模型。
|
|
||||||
//!
|
|
||||||
//! `assets` 表是 `uploads/` 目录的元数据注册表:磁盘是字节唯一存储,
|
|
||||||
//! 本表承载路径、尺寸、alt 等管理性字段。`asset_refs` 记录文章引用关系。
|
|
||||||
//! 这些结构体通过 serde 在服务端与客户端之间共享序列化。
|
|
||||||
//!
|
|
||||||
//! id 以 String 承载(SQL 侧 `id::text` 读出、`$1::uuid` 写入),
|
|
||||||
//! 避免把 server-only 的 uuid crate 引入 WASM 前端构建。
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// 素材记录(对应 assets 表一行)。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct Asset {
|
|
||||||
pub id: String,
|
|
||||||
/// 相对路径,如 "2026/07/24/153000.<uuid>.webp"(不含 /uploads/ 前缀)。
|
|
||||||
pub path: String,
|
|
||||||
pub filename: String,
|
|
||||||
pub mime: String,
|
|
||||||
pub size_bytes: i64,
|
|
||||||
pub width: i32,
|
|
||||||
pub height: i32,
|
|
||||||
pub alt: Option<String>,
|
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 引用该素材的一篇文章(素材详情/删除拦截时列出)。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct AssetRef {
|
|
||||||
pub post_id: i32,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列表页 DTO:素材本体 + 引用计数 + 引用文章列表。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct AssetDto {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub asset: Asset,
|
|
||||||
pub ref_count: i64,
|
|
||||||
pub refs: Vec<AssetRef>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列表筛选:按引用状态。
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
|
|
||||||
pub enum AssetFilter {
|
|
||||||
#[default]
|
|
||||||
All,
|
|
||||||
Used,
|
|
||||||
Orphan,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列表排序。
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
|
|
||||||
pub enum AssetSort {
|
|
||||||
#[default]
|
|
||||||
CreatedDesc,
|
|
||||||
SizeDesc,
|
|
||||||
}
|
|
||||||
@ -3,8 +3,6 @@
|
|||||||
//! 定义博客系统使用的核心领域模型,包括文章(Post)、用户(User)与评论(Comment)。
|
//! 定义博客系统使用的核心领域模型,包括文章(Post)、用户(User)与评论(Comment)。
|
||||||
//! 这些结构体通过 serde 在服务端与客户端之间共享序列化。
|
//! 这些结构体通过 serde 在服务端与客户端之间共享序列化。
|
||||||
|
|
||||||
/// 素材(图片)模型:assets 注册表与引用关联的 serde DTO。
|
|
||||||
pub mod asset;
|
|
||||||
/// 评论模型及其状态枚举。
|
/// 评论模型及其状态枚举。
|
||||||
pub mod comment;
|
pub mod comment;
|
||||||
/// 文章模型、文章状态、标签与统计信息。
|
/// 文章模型、文章状态、标签与统计信息。
|
||||||
|
|||||||
@ -1,171 +0,0 @@
|
|||||||
//! 素材选择 modal(封面上「从素材库选择」)。
|
|
||||||
//!
|
|
||||||
//! 网格展示素材库(默认最新排序,支持文件名/alt 搜索),单击选中回填封面 URL。
|
|
||||||
//! 内嵌「上传新图」入口(复用 `upload_image_file`),上传成功即选中。
|
|
||||||
//! 纯 Dioxus 组件,不触碰 Tiptap;数据加载仅在 WASM 前端发生。
|
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::api::assets::list_assets;
|
|
||||||
use crate::models::asset::AssetDto;
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::models::asset::{AssetFilter, AssetSort};
|
|
||||||
|
|
||||||
/// 素材选择 modal。
|
|
||||||
///
|
|
||||||
/// - `visible`:显隐控制(父组件持有,选中/点遮罩/× 都会置 false)。
|
|
||||||
/// - `on_select`:选中回填,参数为 `/uploads/<path>` URL。
|
|
||||||
/// - `cover_uploading`:modal 内上传新图时置位,供父页面拦截保存(与 CoverUploader 语义一致)。
|
|
||||||
#[component]
|
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
|
||||||
pub fn AssetPickerModal(
|
|
||||||
mut visible: Signal<bool>,
|
|
||||||
on_select: EventHandler<String>,
|
|
||||||
cover_uploading: Signal<bool>,
|
|
||||||
) -> Element {
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut assets: Signal<Vec<AssetDto>> = use_signal(Vec::new);
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut loading = use_signal(|| false);
|
|
||||||
let mut query = use_signal(String::new);
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut error = use_signal(|| None::<String>);
|
|
||||||
|
|
||||||
// 打开时与搜索词变化时加载第一页(最新 60 张,封面复用场景足够)。
|
|
||||||
use_effect(move || {
|
|
||||||
let open = visible();
|
|
||||||
let q = query();
|
|
||||||
if open {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
spawn(async move {
|
|
||||||
loading.set(true);
|
|
||||||
match list_assets(AssetFilter::All, q, AssetSort::CreatedDesc, 1).await {
|
|
||||||
Ok(resp) => {
|
|
||||||
assets.set(resp.assets);
|
|
||||||
error.set(None);
|
|
||||||
}
|
|
||||||
Err(e) => error.set(Some(e.to_string())),
|
|
||||||
}
|
|
||||||
loading.set(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if !visible() {
|
|
||||||
return rsx! {};
|
|
||||||
}
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
// 遮罩:点击关闭
|
|
||||||
div { class: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-6",
|
|
||||||
onclick: move |_| visible.set(false),
|
|
||||||
// 面板:阻止点击穿透到遮罩
|
|
||||||
div { class: "w-full max-w-3xl max-h-[80vh] flex flex-col rounded-[2rem] bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)] shadow-xl overflow-hidden",
|
|
||||||
onclick: move |evt| evt.stop_propagation(),
|
|
||||||
|
|
||||||
// 头部:标题 + 搜索 + 上传 + 关闭
|
|
||||||
div { class: "flex items-center gap-3 px-6 py-4 border-b border-[var(--color-paper-border)]",
|
|
||||||
h2 { class: "text-lg font-bold text-[var(--color-paper-primary)] shrink-0",
|
|
||||||
"选择封面图"
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
class: "flex-1 min-w-0 text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-4 py-2 shadow-sm transition-all",
|
|
||||||
r#type: "search",
|
|
||||||
placeholder: "搜索文件名 / alt",
|
|
||||||
value: "{query}",
|
|
||||||
oninput: move |evt| query.set(evt.value()),
|
|
||||||
}
|
|
||||||
// 上传新图:成功后直接选中
|
|
||||||
label { class: "shrink-0 text-sm font-medium px-4 py-2 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity cursor-pointer",
|
|
||||||
"上传新图"
|
|
||||||
input {
|
|
||||||
r#type: "file",
|
|
||||||
accept: "image/jpeg,image/png,image/gif,image/webp",
|
|
||||||
class: "hidden",
|
|
||||||
onchange: move |evt| {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
use dioxus::html::HasFileData;
|
|
||||||
use dioxus::web::WebFileExt;
|
|
||||||
if let Some(file) = evt.files().into_iter().next() {
|
|
||||||
if let Some(web_file) = file.get_web_file() {
|
|
||||||
cover_uploading.set(true);
|
|
||||||
spawn(async move {
|
|
||||||
match crate::tiptap_bridge::upload_image_file(
|
|
||||||
web_file,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(url) => {
|
|
||||||
on_select.call(url);
|
|
||||||
visible.set(false);
|
|
||||||
}
|
|
||||||
// 失败留在 modal 内提示,不关闭。
|
|
||||||
Err(msg) => error.set(Some(msg)),
|
|
||||||
}
|
|
||||||
cover_uploading.set(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "shrink-0 w-8 h-8 flex items-center justify-center rounded-full text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer",
|
|
||||||
aria_label: "关闭",
|
|
||||||
onclick: move |_| visible.set(false),
|
|
||||||
"×"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 网格内容区
|
|
||||||
div { class: "flex-1 overflow-y-auto p-4",
|
|
||||||
if let Some(err) = error() {
|
|
||||||
div { class: "py-12 text-center text-sm text-red-500", "加载失败:{err}" }
|
|
||||||
} else if loading() && assets.read().is_empty() {
|
|
||||||
div { class: "py-12 text-center text-sm text-[var(--color-paper-secondary)]",
|
|
||||||
"加载中..."
|
|
||||||
}
|
|
||||||
} else if assets.read().is_empty() {
|
|
||||||
div { class: "py-12 text-center text-sm text-[var(--color-paper-secondary)]",
|
|
||||||
"素材库为空,点击「上传新图」添加"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
div { class: "grid grid-cols-3 sm:grid-cols-4 gap-3",
|
|
||||||
for asset in assets.read().iter() {
|
|
||||||
{
|
|
||||||
let url = format!("/uploads/{}", asset.asset.path);
|
|
||||||
let thumb = format!("{}?thumb=300x300", url);
|
|
||||||
rsx! {
|
|
||||||
button {
|
|
||||||
key: "{asset.asset.id}",
|
|
||||||
class: "group relative aspect-square rounded-2xl overflow-hidden border border-[var(--color-paper-border)] hover:border-[var(--color-paper-primary)] hover:shadow-md transition-all cursor-pointer",
|
|
||||||
title: "{asset.asset.filename}",
|
|
||||||
onclick: {
|
|
||||||
let url = url.clone();
|
|
||||||
move |_| {
|
|
||||||
on_select.call(url.clone());
|
|
||||||
visible.set(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
img {
|
|
||||||
class: "w-full h-full object-cover",
|
|
||||||
src: "{thumb}",
|
|
||||||
alt: asset.asset.alt.clone().unwrap_or_else(|| {
|
|
||||||
asset.asset.filename.clone()
|
|
||||||
}),
|
|
||||||
loading: "lazy",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,522 +0,0 @@
|
|||||||
//! 素材管理页面。
|
|
||||||
//!
|
|
||||||
//! 网格浏览 `uploads/` 已登记图片:搜索(文件名/alt)、引用状态筛选
|
|
||||||
//! (全部/引用中/孤儿)、排序(最新/最大)、客户端分页。
|
|
||||||
//! 缩略图直接复用 `serve_image` 的动态处理(`?thumb=300x300`),零额外成本。
|
|
||||||
//!
|
|
||||||
//! 缩略图采用与前台正文图一致的 `.blur-img` 双层结构(`?w=20` 占位 + `data-src`
|
|
||||||
//! 展示层),点击由全局注入的 `lightbox.js` 接管为灯箱预览(图集模式,当前页内
|
|
||||||
//! 左右切换,灯箱加载原图 = `data-src` 去 query)。数据异步到达/刷新后由
|
|
||||||
//! `use_effect` 调 `__initLightbox` 绑定;TS 端有 `data-lb-bound` 守卫,重复绑定幂等。
|
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
// server fn 仅在 WASM 前端调用(全部包在 cfg(wasm32) 块内),server SSR 只编译类型。
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::api::assets::{
|
|
||||||
delete_asset, list_assets, purge_orphan_assets, rebuild_assets_index, update_asset_alt,
|
|
||||||
};
|
|
||||||
use crate::api::assets::AssetListResponse;
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::api::assets::{PurgeOrphansResponse, RebuildAssetsResponse};
|
|
||||||
use crate::components::empty_state::EmptyState;
|
|
||||||
use crate::components::ui::{FilterTabs, Pagination};
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::utils::js::invoke_optional_global;
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::models::asset::{AssetFilter, AssetSort};
|
|
||||||
|
|
||||||
/// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。
|
|
||||||
const ASSETS_PER_PAGE: i32 = 60;
|
|
||||||
|
|
||||||
/// 格式化字节数为可读字符串(B/KB/MB/GB)。
|
|
||||||
fn format_bytes(bytes: i64) -> String {
|
|
||||||
const KB: f64 = 1024.0;
|
|
||||||
const MB: f64 = KB * 1024.0;
|
|
||||||
const GB: f64 = MB * 1024.0;
|
|
||||||
let b = bytes as f64;
|
|
||||||
if b >= GB {
|
|
||||||
format!("{:.1} GB", b / GB)
|
|
||||||
} else if b >= MB {
|
|
||||||
format!("{:.1} MB", b / MB)
|
|
||||||
} else if b >= KB {
|
|
||||||
format!("{:.1} KB", b / KB)
|
|
||||||
} else {
|
|
||||||
format!("{} B", bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 素材管理入口组件。
|
|
||||||
// 交互逻辑全部 cfg(wasm32) 门控,server SSR 编译时一批绑定未使用,按 CoverUploader 惯例放行。
|
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
|
||||||
#[component]
|
|
||||||
pub fn Assets() -> Element {
|
|
||||||
// 筛选/搜索/排序/分页状态:全部客户端驱动(单路由 + signal,对齐「管理文章」模式)。
|
|
||||||
let mut filter = use_signal(|| "all".to_string());
|
|
||||||
let mut query = use_signal(String::new);
|
|
||||||
let mut sort = use_signal(|| "created".to_string());
|
|
||||||
let mut page = use_signal(|| 1_i32);
|
|
||||||
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut data: Signal<Option<AssetListResponse>> = use_signal(|| None);
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut loading: Signal<bool> = use_signal(|| true);
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut error: Signal<Option<String>> = use_signal(|| None);
|
|
||||||
|
|
||||||
// 操作结果横幅(删除/清理/alt 编辑的反馈)。
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut op_message: Signal<Option<String>> = use_signal(|| None);
|
|
||||||
// 待二次确认的删除目标(素材 id)与一键清理确认态。
|
|
||||||
let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
|
|
||||||
let mut purge_confirm = use_signal(|| false);
|
|
||||||
// 重建索引进行中状态。
|
|
||||||
let mut rebuilding = use_signal(|| false);
|
|
||||||
// alt 内联编辑:目标素材 id + 输入框值。
|
|
||||||
let mut editing_alt: Signal<Option<String>> = use_signal(|| None);
|
|
||||||
let mut alt_input = use_signal(String::new);
|
|
||||||
// 重载触发器:操作成功后 +1 让 effect 重新请求。
|
|
||||||
let mut reload = use_signal(|| 0_i32);
|
|
||||||
|
|
||||||
// 数据加载:任一查询条件或 reload 变化时重新请求。筛选/搜索/排序变化时重置到第 1 页。
|
|
||||||
use_effect(move || {
|
|
||||||
let f = filter();
|
|
||||||
let q = query();
|
|
||||||
let s = sort();
|
|
||||||
let p = page();
|
|
||||||
let _ = reload();
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
let filter_enum = match f.as_str() {
|
|
||||||
"used" => AssetFilter::Used,
|
|
||||||
"orphan" => AssetFilter::Orphan,
|
|
||||||
_ => AssetFilter::All,
|
|
||||||
};
|
|
||||||
let sort_enum = if s == "size" {
|
|
||||||
AssetSort::SizeDesc
|
|
||||||
} else {
|
|
||||||
AssetSort::CreatedDesc
|
|
||||||
};
|
|
||||||
spawn(async move {
|
|
||||||
loading.set(true);
|
|
||||||
error.set(None);
|
|
||||||
match list_assets(filter_enum, q, sort_enum, p).await {
|
|
||||||
Ok(resp) => data.set(Some(resp)),
|
|
||||||
Err(e) => error.set(Some(e.to_string())),
|
|
||||||
}
|
|
||||||
loading.set(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 灯箱初始化:lightbox.js 由 Dioxus.toml 全局注入。网格随数据异步渲染,
|
|
||||||
// 需在数据到达(DOM 提交后)调 __initLightbox 绑定;筛选/翻页/刷新重建节点后
|
|
||||||
// 重跑此 effect 重新绑定。TS 端 data-lb-bound 守卫保证重复绑定幂等。
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use_effect(move || {
|
|
||||||
// 订阅 data:取数/刷新后网格重渲染,effect 在 DOM 更新后运行。
|
|
||||||
if data.read().is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let window =
|
|
||||||
web_sys::window().expect("assets use_effect 仅在 WASM 浏览器上下文执行:无 window");
|
|
||||||
// 双保险契约(同 PostContent):先设全局配置,lightbox.js 若尚未加载完,
|
|
||||||
// 其 IIFE 尾部读到配置自启动;已加载则下方直接调用兜底。
|
|
||||||
let selectors = js_sys::Array::of1(&".assets-lightbox".into());
|
|
||||||
let selectors_val = js_sys::Object::from(selectors).into();
|
|
||||||
let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
|
|
||||||
invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = data.read();
|
|
||||||
let (assets, total, used_count, orphan_count, purgeable_count, purgeable_bytes) =
|
|
||||||
match resp.as_ref() {
|
|
||||||
Some(r) => (
|
|
||||||
r.assets.clone(),
|
|
||||||
r.total,
|
|
||||||
r.used_count,
|
|
||||||
r.orphan_count,
|
|
||||||
r.purgeable_count,
|
|
||||||
r.purgeable_bytes,
|
|
||||||
),
|
|
||||||
None => (Vec::new(), 0, 0, 0, 0, 0),
|
|
||||||
};
|
|
||||||
let all_count = used_count + orphan_count;
|
|
||||||
drop(resp);
|
|
||||||
|
|
||||||
// Dioxus 格式化段不支持内联 if 块表达式,条件 class 提前算好。
|
|
||||||
let sort_btn_base = "text-xs font-mono tracking-widest uppercase cursor-pointer px-3 py-2 rounded-full border transition-colors";
|
|
||||||
let sort_active = "border-[var(--color-paper-primary)] text-[var(--color-paper-primary)]";
|
|
||||||
let sort_idle = "border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)]";
|
|
||||||
let sort_created_class = if sort() == "created" {
|
|
||||||
format!("{sort_btn_base} {sort_active}")
|
|
||||||
} else {
|
|
||||||
format!("{sort_btn_base} {sort_idle}")
|
|
||||||
};
|
|
||||||
let sort_size_class = if sort() == "size" {
|
|
||||||
format!("{sort_btn_base} {sort_active}")
|
|
||||||
} else {
|
|
||||||
format!("{sort_btn_base} {sort_idle}")
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div {
|
|
||||||
h1 { class: "text-3xl font-extrabold tracking-tight mb-2", "素材管理" }
|
|
||||||
p { class: "text-sm text-[var(--color-paper-secondary)] mb-8",
|
|
||||||
"管理文章编辑器上传的图片。共 {all_count} 张,引用中 {used_count} 张,孤儿 {orphan_count} 张。"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 顶栏:筛选 tabs + 搜索 + 排序
|
|
||||||
div { class: "flex flex-wrap items-end justify-between gap-4",
|
|
||||||
FilterTabs {
|
|
||||||
items: vec![("all", "全部"), ("used", "引用中"), ("orphan", "孤儿")],
|
|
||||||
active_value: filter(),
|
|
||||||
on_change: move |v: String| {
|
|
||||||
filter.set(v);
|
|
||||||
page.set(1);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
div { class: "flex items-center gap-3 pb-1",
|
|
||||||
// 重建索引:以磁盘为准全量自愈(存量回填/不一致修复)。
|
|
||||||
button {
|
|
||||||
class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:border-[var(--color-paper-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
|
|
||||||
disabled: rebuilding(),
|
|
||||||
title: "扫描 uploads/ 全量文件,同步素材注册表与文章引用(幂等,可随时重跑)",
|
|
||||||
onclick: move |_| {
|
|
||||||
rebuilding.set(true);
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
spawn(async move {
|
|
||||||
match rebuild_assets_index().await {
|
|
||||||
Ok(RebuildAssetsResponse { message, .. }) => {
|
|
||||||
op_message.set(Some(message));
|
|
||||||
reload.set(reload() + 1);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
op_message.set(Some(format!("重建失败:{e}")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rebuilding.set(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
if rebuilding() {
|
|
||||||
"重建中..."
|
|
||||||
} else {
|
|
||||||
"重建索引"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
class: "w-56 text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-4 py-2 shadow-sm transition-all",
|
|
||||||
r#type: "search",
|
|
||||||
placeholder: "搜索文件名 / alt",
|
|
||||||
value: "{query}",
|
|
||||||
oninput: move |evt| {
|
|
||||||
query.set(evt.value());
|
|
||||||
page.set(1);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "{sort_created_class}",
|
|
||||||
onclick: move |_| {
|
|
||||||
sort.set("created".to_string());
|
|
||||||
page.set(1);
|
|
||||||
},
|
|
||||||
"最新"
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "{sort_size_class}",
|
|
||||||
onclick: move |_| {
|
|
||||||
sort.set("size".to_string());
|
|
||||||
page.set(1);
|
|
||||||
},
|
|
||||||
"最大"
|
|
||||||
}
|
|
||||||
// 一键清理孤儿:仅 7 天保护窗外的无引用素材;两步确认。
|
|
||||||
if purgeable_count > 0 {
|
|
||||||
if purge_confirm() {
|
|
||||||
button {
|
|
||||||
class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors",
|
|
||||||
onclick: move |_| {
|
|
||||||
purge_confirm.set(false);
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
spawn(async move {
|
|
||||||
match purge_orphan_assets().await {
|
|
||||||
Ok(PurgeOrphansResponse {
|
|
||||||
deleted_count,
|
|
||||||
freed_bytes,
|
|
||||||
failures,
|
|
||||||
..
|
|
||||||
}) => {
|
|
||||||
let mut msg = format!(
|
|
||||||
"已清理 {} 张孤儿素材,释放 {}",
|
|
||||||
deleted_count,
|
|
||||||
format_bytes(freed_bytes)
|
|
||||||
);
|
|
||||||
if failures > 0 {
|
|
||||||
msg.push_str(&format!(
|
|
||||||
"({} 个文件删除失败)",
|
|
||||||
failures
|
|
||||||
));
|
|
||||||
}
|
|
||||||
op_message.set(Some(msg));
|
|
||||||
reload.set(reload() + 1);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
op_message.set(Some(format!("清理失败:{e}")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
"确认清理 {purgeable_count} 张({format_bytes(purgeable_bytes)})"
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "text-xs cursor-pointer px-3 py-2 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
|
|
||||||
onclick: move |_| purge_confirm.set(false),
|
|
||||||
"取消"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
button {
|
|
||||||
class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full border border-amber-500/50 text-amber-600 dark:text-amber-400 hover:bg-amber-500/10 transition-colors",
|
|
||||||
title: "仅清理无引用且上传超过 7 天的素材(保护未保存的草稿)",
|
|
||||||
onclick: move |_| purge_confirm.set(true),
|
|
||||||
"清理孤儿({purgeable_count} 张 · {format_bytes(purgeable_bytes)})"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 操作结果横幅
|
|
||||||
if let Some(msg) = op_message() {
|
|
||||||
div { class: "mt-4 flex items-center justify-between gap-4 rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] px-4 py-3 text-sm text-[var(--color-paper-primary)] shadow-sm",
|
|
||||||
span { "{msg}" }
|
|
||||||
button {
|
|
||||||
class: "text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] cursor-pointer",
|
|
||||||
onclick: move |_| op_message.set(None),
|
|
||||||
"×"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内容区
|
|
||||||
if let Some(err) = error() {
|
|
||||||
div { class: "mt-8 text-sm text-red-500", "加载失败:{err}" }
|
|
||||||
} else if loading() && assets.is_empty() {
|
|
||||||
div { class: "mt-8 text-sm text-[var(--color-paper-secondary)]", "加载中..." }
|
|
||||||
} else if assets.is_empty() {
|
|
||||||
EmptyState {
|
|
||||||
title: "暂无素材".to_string(),
|
|
||||||
description: "在编辑器中上传图片后会自动出现在这里".to_string(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 网格:缩略图卡片(assets-lightbox 为 __initLightbox 的根选择器)
|
|
||||||
div { class: "assets-lightbox grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-2",
|
|
||||||
for asset in assets.iter() {
|
|
||||||
{
|
|
||||||
let a = &asset.asset;
|
|
||||||
let thumb = format!("/uploads/{}?thumb=300x300", a.path);
|
|
||||||
let placeholder = format!("/uploads/{}?w=20", a.path);
|
|
||||||
let img_alt = a.alt.clone().unwrap_or_else(|| a.filename.clone());
|
|
||||||
let is_orphan = asset.ref_count == 0;
|
|
||||||
let badge_class = if is_orphan {
|
|
||||||
"absolute top-2 left-2 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-amber-500/80 text-white"
|
|
||||||
} else {
|
|
||||||
"absolute top-2 left-2 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-black/50 text-white"
|
|
||||||
};
|
|
||||||
rsx! {
|
|
||||||
div {
|
|
||||||
key: "{a.id}",
|
|
||||||
class: "group relative rounded-3xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] shadow-sm hover:shadow-md transition-all",
|
|
||||||
// blur-img 双层结构(对齐前台正文图):?w=20 模糊占位 +
|
|
||||||
// data-src 展示层(IO 懒加载);点击由 lightbox.js 接管为灯箱
|
|
||||||
// (图集模式,原图 = data-src 去 query)。不加 lightbox-single。
|
|
||||||
div { class: "blur-img aspect-square m-0 cursor-pointer bg-[var(--color-paper-theme)]",
|
|
||||||
img {
|
|
||||||
class: "blur-img-placeholder",
|
|
||||||
src: "{placeholder}",
|
|
||||||
alt: "",
|
|
||||||
}
|
|
||||||
img {
|
|
||||||
class: "blur-img-full",
|
|
||||||
"data-src": "{thumb}",
|
|
||||||
alt: "{img_alt}",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 引用徽标
|
|
||||||
span {
|
|
||||||
class: "{badge_class}",
|
|
||||||
if is_orphan {
|
|
||||||
"孤儿"
|
|
||||||
} else {
|
|
||||||
"被 {asset.ref_count} 篇引用"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div { class: "p-3",
|
|
||||||
p { class: "text-xs font-medium truncate text-[var(--color-paper-primary)]",
|
|
||||||
title: "{a.filename}",
|
|
||||||
"{a.filename}"
|
|
||||||
}
|
|
||||||
p { class: "text-[10px] font-mono text-[var(--color-paper-tertiary)] mt-0.5",
|
|
||||||
"{a.width}×{a.height} · {format_bytes(a.size_bytes)}"
|
|
||||||
}
|
|
||||||
if let Some(alt_text) = &a.alt {
|
|
||||||
p { class: "text-[10px] truncate text-[var(--color-paper-secondary)] mt-0.5",
|
|
||||||
title: "{alt_text}",
|
|
||||||
"alt: {alt_text}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 操作区:确认删除 / alt 编辑 / 常规三按钮 三态互斥
|
|
||||||
if confirm_delete().as_deref() == Some(a.id.as_str()) {
|
|
||||||
div { class: "flex items-center gap-2 mt-2",
|
|
||||||
button {
|
|
||||||
class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors",
|
|
||||||
onclick: {
|
|
||||||
let id = a.id.clone();
|
|
||||||
move |_| {
|
|
||||||
confirm_delete.set(None);
|
|
||||||
let id = id.clone();
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
spawn(async move {
|
|
||||||
match delete_asset(id).await {
|
|
||||||
Ok(resp) => {
|
|
||||||
op_message.set(Some(resp.message));
|
|
||||||
if resp.success {
|
|
||||||
reload.set(reload() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => op_message
|
|
||||||
.set(Some(format!("删除失败:{e}"))),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"确认删除"
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "text-[10px] cursor-pointer px-2 py-1 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
|
|
||||||
onclick: move |_| confirm_delete.set(None),
|
|
||||||
"取消"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if editing_alt().as_deref() == Some(a.id.as_str()) {
|
|
||||||
div { class: "flex items-center gap-1 mt-2",
|
|
||||||
input {
|
|
||||||
class: "flex-1 min-w-0 text-[10px] bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-full px-2 py-1 transition-all",
|
|
||||||
r#type: "text",
|
|
||||||
placeholder: "alt 文本",
|
|
||||||
value: "{alt_input}",
|
|
||||||
oninput: move |evt| alt_input.set(evt.value()),
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity",
|
|
||||||
onclick: {
|
|
||||||
let id = a.id.clone();
|
|
||||||
move |_| {
|
|
||||||
let id = id.clone();
|
|
||||||
let alt = alt_input();
|
|
||||||
editing_alt.set(None);
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
spawn(async move {
|
|
||||||
match update_asset_alt(id, alt).await {
|
|
||||||
Ok(resp) => {
|
|
||||||
op_message.set(Some(resp.message));
|
|
||||||
if resp.success {
|
|
||||||
reload.set(reload() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => op_message
|
|
||||||
.set(Some(format!("保存失败:{e}"))),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"存"
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "text-[10px] cursor-pointer px-2 py-1 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
|
|
||||||
onclick: move |_| editing_alt.set(None),
|
|
||||||
"×"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
div { class: "flex items-center gap-2 mt-2 opacity-0 group-hover:opacity-100 transition-opacity",
|
|
||||||
button {
|
|
||||||
class: "text-[10px] cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
|
|
||||||
title: "复制图片 URL",
|
|
||||||
onclick: {
|
|
||||||
let url = format!("/uploads/{}", a.path);
|
|
||||||
move |_| {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
let _ = window
|
|
||||||
.navigator()
|
|
||||||
.clipboard()
|
|
||||||
.write_text(&url);
|
|
||||||
op_message.set(Some(format!("已复制 {url}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"复制"
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "text-[10px] cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
|
|
||||||
title: "编辑 alt",
|
|
||||||
onclick: {
|
|
||||||
let id = a.id.clone();
|
|
||||||
let current_alt = a.alt.clone().unwrap_or_default();
|
|
||||||
move |_| {
|
|
||||||
alt_input.set(current_alt.clone());
|
|
||||||
editing_alt.set(Some(id.clone()));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"alt"
|
|
||||||
}
|
|
||||||
if asset.ref_count > 0 {
|
|
||||||
{
|
|
||||||
let refs_tip = asset
|
|
||||||
.refs
|
|
||||||
.iter()
|
|
||||||
.map(|r| r.title.clone())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("、");
|
|
||||||
rsx! {
|
|
||||||
span {
|
|
||||||
class: "text-[10px] text-[var(--color-paper-tertiary)] cursor-not-allowed",
|
|
||||||
title: "被引用:{refs_tip}",
|
|
||||||
"删除"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
button {
|
|
||||||
class: "text-[10px] cursor-pointer text-red-500/70 hover:text-red-500 transition-colors",
|
|
||||||
onclick: {
|
|
||||||
let id = a.id.clone();
|
|
||||||
move |_| confirm_delete.set(Some(id.clone()))
|
|
||||||
},
|
|
||||||
"删除"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Pagination {
|
|
||||||
variant: "admin",
|
|
||||||
current_page: page(),
|
|
||||||
total,
|
|
||||||
per_page: ASSETS_PER_PAGE,
|
|
||||||
unit: "张",
|
|
||||||
on_prev: move |_| page.set((page() - 1).max(1)),
|
|
||||||
on_next: move |_| page.set(page() + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,10 +2,6 @@
|
|||||||
//!
|
//!
|
||||||
//! 汇总并重新导出后台管理相关的页面组件,供路由与其他模块使用。
|
//! 汇总并重新导出后台管理相关的页面组件,供路由与其他模块使用。
|
||||||
|
|
||||||
/// 素材选择 modal(封面上「从素材库选择」)。
|
|
||||||
pub mod asset_picker;
|
|
||||||
/// 素材管理页面模块。
|
|
||||||
pub mod assets;
|
|
||||||
/// 评论管理页面模块。
|
/// 评论管理页面模块。
|
||||||
pub mod comments;
|
pub mod comments;
|
||||||
/// 管理后台仪表盘页面模块。
|
/// 管理后台仪表盘页面模块。
|
||||||
@ -21,8 +17,6 @@ pub mod system;
|
|||||||
/// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。
|
/// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。
|
||||||
pub mod write;
|
pub mod write;
|
||||||
|
|
||||||
/// 素材管理页面组件。
|
|
||||||
pub use assets::Assets;
|
|
||||||
/// 评论管理入口组件(带默认分页)。
|
/// 评论管理入口组件(带默认分页)。
|
||||||
pub use comments::{AdminComments, AdminCommentsPage};
|
pub use comments::{AdminComments, AdminCommentsPage};
|
||||||
/// 管理后台仪表盘组件。
|
/// 管理后台仪表盘组件。
|
||||||
|
|||||||
@ -20,7 +20,6 @@ use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle
|
|||||||
use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM};
|
use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM};
|
||||||
use crate::components::write_skeleton::WriteSkeleton;
|
use crate::components::write_skeleton::WriteSkeleton;
|
||||||
use crate::models::post::Post;
|
use crate::models::post::Post;
|
||||||
use crate::pages::admin::asset_picker::AssetPickerModal;
|
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight};
|
use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight};
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@ -607,8 +606,6 @@ fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) ->
|
|||||||
let mut cover_drag_active = use_signal(|| false);
|
let mut cover_drag_active = use_signal(|| false);
|
||||||
// 封面 URL 输入框的临时值(确认前不直接写入 cover_image,避免半截 URL 触发预览加载)。
|
// 封面 URL 输入框的临时值(确认前不直接写入 cover_image,避免半截 URL 触发预览加载)。
|
||||||
let mut cover_url_input = use_signal(|| "".to_string());
|
let mut cover_url_input = use_signal(|| "".to_string());
|
||||||
// 素材选择 modal 显隐。
|
|
||||||
let mut picker_visible = use_signal(|| false);
|
|
||||||
|
|
||||||
// 封面图上传:spawn 一个 async 调用 upload_image_file。
|
// 封面图上传:spawn 一个 async 调用 upload_image_file。
|
||||||
// 三条入口(file input / drop / paste)收敛成拿到 web_sys::File 后统一调用此闭包。
|
// 三条入口(file input / drop / paste)收敛成拿到 web_sys::File 后统一调用此闭包。
|
||||||
@ -780,19 +777,9 @@ fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) ->
|
|||||||
span { class: "text-sm font-medium text-[var(--color-paper-secondary)] shrink-0",
|
span { class: "text-sm font-medium text-[var(--color-paper-secondary)] shrink-0",
|
||||||
"拖拽 / 粘贴 / 点击上传"
|
"拖拽 / 粘贴 / 点击上传"
|
||||||
}
|
}
|
||||||
// 从素材库选择:阻止 label 默认 file 触发,打开选择 modal。
|
|
||||||
span {
|
|
||||||
class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors ml-auto shrink-0",
|
|
||||||
onclick: move |evt| {
|
|
||||||
evt.prevent_default();
|
|
||||||
evt.stop_propagation();
|
|
||||||
picker_visible.set(true);
|
|
||||||
},
|
|
||||||
"素材库"
|
|
||||||
}
|
|
||||||
// URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。
|
// URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。
|
||||||
span {
|
span {
|
||||||
class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
|
class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors ml-auto shrink-0",
|
||||||
onclick: move |evt| {
|
onclick: move |evt| {
|
||||||
evt.prevent_default();
|
evt.prevent_default();
|
||||||
evt.stop_propagation();
|
evt.stop_propagation();
|
||||||
@ -877,15 +864,5 @@ fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 素材选择 modal:选中回填封面 URL(上传新图成功同样回填)。
|
|
||||||
AssetPickerModal {
|
|
||||||
visible: picker_visible,
|
|
||||||
cover_uploading,
|
|
||||||
on_select: move |url: String| {
|
|
||||||
cover_image.set(url);
|
|
||||||
cover_error.set(None);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ use crate::components::frontend_layout::FrontendLayout;
|
|||||||
use crate::context::UserContext;
|
use crate::context::UserContext;
|
||||||
use crate::pages::about::About;
|
use crate::pages::about::About;
|
||||||
use crate::pages::admin::{
|
use crate::pages::admin::{
|
||||||
Admin, AdminComments, AdminCommentsPage, Assets, Posts, Runner, System, Write, WriteEdit,
|
Admin, AdminComments, AdminCommentsPage, Posts, Runner, System, Write, WriteEdit,
|
||||||
};
|
};
|
||||||
use crate::pages::archives::Archives;
|
use crate::pages::archives::Archives;
|
||||||
use crate::pages::home::{Home, HomePage};
|
use crate::pages::home::{Home, HomePage};
|
||||||
@ -77,9 +77,6 @@ pub enum Route {
|
|||||||
/// 文章管理(列表 + 回收站,客户端 tab 切换,单一路由)
|
/// 文章管理(列表 + 回收站,客户端 tab 切换,单一路由)
|
||||||
#[route("/posts")]
|
#[route("/posts")]
|
||||||
Posts {},
|
Posts {},
|
||||||
/// 素材管理(上传图片注册表:浏览/搜索/删除/孤儿清理)
|
|
||||||
#[route("/assets")]
|
|
||||||
Assets {},
|
|
||||||
/// 评论管理
|
/// 评论管理
|
||||||
#[route("/comments")]
|
#[route("/comments")]
|
||||||
AdminComments {},
|
AdminComments {},
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
//! WASM 端 JS 互操作小工具(调用 `window` 上的可选全局函数)。
|
|
||||||
//!
|
|
||||||
//! 仅在 `target_arch = "wasm32"` 下编译;服务端 SSR 路径不参与。
|
|
||||||
|
|
||||||
#![cfg(target_arch = "wasm32")]
|
|
||||||
|
|
||||||
/// 读取 `window` 上的可选全局函数并调用;函数未定义/为 null 时静默跳过。
|
|
||||||
///
|
|
||||||
/// 替代 `js_sys::eval("if(window.__x) window.__x(...)")` 字符串拼贴模式:用
|
|
||||||
/// `Reflect::get` 取属性 + `Function::apply` 调用,无字符串求值,与 `tiptap_bridge`
|
|
||||||
/// 的类型化 extern 风格一致。
|
|
||||||
///
|
|
||||||
/// 用于调用各 IIFE 库暴露的 `window.__init*` 入口(lightbox / mermaid / 锚点拦截等),
|
|
||||||
/// 这些库由 `Dioxus.toml` 全局 `<script src>` 注入,加载时机不保证早于调用点。
|
|
||||||
pub fn invoke_optional_global(window: &web_sys::Window, name: &str, args: &[wasm_bindgen::JsValue]) {
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
if let Ok(fn_val) = js_sys::Reflect::get(window, &name.into()) {
|
|
||||||
if !fn_val.is_undefined() && !fn_val.is_null() {
|
|
||||||
let arr = js_sys::Array::new();
|
|
||||||
for a in args {
|
|
||||||
arr.push(a);
|
|
||||||
}
|
|
||||||
let _ = fn_val
|
|
||||||
.unchecked_into::<js_sys::Function>()
|
|
||||||
.apply(window, &arr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
//!
|
//!
|
||||||
//! - `comment_storage`:评论草稿 localStorage 持久化(WASM 端)。
|
//! - `comment_storage`:评论草稿 localStorage 持久化(WASM 端)。
|
||||||
//! - `html`:HTML 转义(两端通用)。
|
//! - `html`:HTML 转义(两端通用)。
|
||||||
//! - `js`:WASM 端调用 `window.__init*` 可选全局函数(仅 wasm32)。
|
|
||||||
//! - `text`:Markdown / 纯文本处理(仅 `server` feature)。
|
//! - `text`:Markdown / 纯文本处理(仅 `server` feature)。
|
||||||
//! - `time`:跨平台时间/睡眠工具(WASM 与原生异步版本)。
|
//! - `time`:跨平台时间/睡眠工具(WASM 与原生异步版本)。
|
||||||
|
|
||||||
@ -10,9 +9,6 @@
|
|||||||
pub mod comment_storage;
|
pub mod comment_storage;
|
||||||
/// HTML 转义工具(前端后端通用)。
|
/// HTML 转义工具(前端后端通用)。
|
||||||
pub mod html;
|
pub mod html;
|
||||||
/// WASM 端 JS 全局函数调用工具(仅 wasm32 编译)。
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub mod js;
|
|
||||||
/// Markdown / 纯文本处理工具。
|
/// Markdown / 纯文本处理工具。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod text;
|
pub mod text;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user