Compare commits

...

11 Commits

Author SHA1 Message Date
xfy
99d33b54d2 fix(lightbox): 图集切换不重算几何导致图片压扁
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
gotoIndex 换图时只替换 src,target/baseW/baseH/transform 全部沿用
第一张图的几何,宽高比不同的图被套用错误的 scale(显示宽高比 =
自身宽高比 × 首图宽高比)。同时 .lightbox-img 的 object-fit: contain
与 transformFor 依赖的「位图填满布局盒」前提冲突,素材网格的方形
缩略图容器下首图即被压扁。

修复:
- swap() 在新图加载后按 naturalWidth/Height 重算 fitCentered 目标,
  布局盒设为 target 尺寸、transform 归位 scale(1,1),baseW/H 同步
  供关闭飞回动画复用
- 先换 src 再判 complete,避免用旧图的 complete 误判缓存命中
- 移除 .lightbox-img 的 object-fit: contain,恢复位图填满布局盒

新增回归测试:切换后几何必须按新图宽高比重算(红绿验证通过)
2026-07-24 18:15:11 +08:00
xfy
051ca947e3 feat(assets): 素材网格接入灯箱预览
- 缩略图改为与前台正文图一致的 .blur-img 双层结构(?w=20 占位 +
  ?thumb=300x300 展示层 data-src),点击由全局注入的 lightbox.js 接管;
  图集模式(当前页内 ←/→ 切换),灯箱加载原图(data-src 去 query)。
- 数据异步到达/筛选/翻页/刷新后由 use_effect 调 __initLightbox 重新绑定
  (双保险契约同 PostContent:__lightboxSelectors + 直接调用兜底)。
- lightbox.ts 补 data-lb-bound 幂等守卫:Dioxus keyed diff 复用节点时
  重复初始化会叠加 click 监听(点一次开两次);附回归测试。
- invoke_optional_global 提取为 src/utils/js.rs 共享,post_content 复用。

浏览器实测:47 张绑定齐全,原图打开/图集切换/Esc 关闭正常,
筛选重建与重建索引重取后重绑幂等(单次点击仅 append 一次 overlay)。
2026-07-24 17:39:34 +08:00
xfy
fa40f4bd95 fix(assets): uuid 参数序列化失败导致上传 500
tokio-postgres 将 $1::uuid 的参数推断为 uuid 类型,String 无法
序列化(缺类型桥接),INSERT assets 报 error serializing parameter 0,
补偿删文件后返回 500。list/delete/purge/rebuild 的 $1::uuid 与
ANY($1::uuid[]) 全是同一颗未引爆的雷。

正路:tokio-postgres 启用 with-uuid-1,SQL 侧去掉全部 ::uuid cast,
Rust 侧全程 Uuid 类型,仅在 serde DTO 边界(WASM 共享模型)转 String;
外部传入的 id 在边界 parse_str,非法 id 走业务错误而非 500。
2026-07-24 17:04:35 +08:00
xfy
8f75a64bfd fix(assets): SUM(size_bytes) 显式转 bigint 修复列表 500
Postgres 的 SUM(bigint) 返回 NUMERIC,tokio-postgres 反序列化为
i64 时类型不匹配,row.get(3) 直接 panic(release 的 panic=abort
下会崩掉整个进程)。COALESCE(..., 0)::bigint 修正。
2026-07-24 16:50:30 +08:00
xfy
45d70e1dc6 refactor(assets): 清理 lint,对齐项目惯例
- api/assets/mod.rs 按 posts/comments 惯例加
  #![allow(clippy::unused_unit, deprecated, unused_imports)]
  (#[server] 宏固有噪音)
- 页面交互导入按 target_arch = wasm32 门控;组件级放行非 WASM 未用绑定
- 合并 check_upload_dimensions 进 upload_dimensions(单函数返回尺寸,
  消除死代码),测试同步更名
2026-07-24 16:44:47 +08:00
xfy
5612f16425 feat(assets): 封面「从素材库选择」联动
- AssetPickerModal:素材网格(最新 60 + 搜索)单击回填封面 URL,
  内嵌「上传新图」(复用 upload_image_file,成功即选中,
  失败留在 modal 内提示),上传期间置位 cover_uploading 拦截保存
- CoverUploader 空态加「素材库」入口(与「输入链接」并列)
2026-07-24 16:32:43 +08:00
xfy
0c47e87d30 feat(assets): 全量重建索引
- rebuild_assets_index:扫 uploads/(跳过点目录)→ upsert assets
  (xmax=0 判别新增;技术字段 IS DISTINCT FROM 才更新,保留 alt,
  幂等重跑 updated=0)→ 删文件已消失的行 → 全表扫 posts 重建 refs
- 磁盘扫描与 header 尺寸读取移入 spawn_blocking
- extract_asset_paths 提升 pub(crate) 供重建复用同一提取逻辑
- 前端「重建索引」按钮(进行中禁点,结果进横幅)
2026-07-24 16:27:02 +08:00
xfy
799455da43 feat(assets): 删除保护、孤儿清理与 alt 编辑
- delete_asset:引用中业务拒绝(Ok(success:false) + 引用文章列表),
  孤儿硬删除(文件 + DB 行级联 refs + 派生缓存)
- purge_orphan_assets:一键清理无引用且超过 7 天保护窗的素材,
  容忍单项文件删除失败,返回数量/释放字节/失败数
- update_asset_alt:管理性 alt 编辑
- invalidate_asset_caches:IMAGE_CACHE 前缀批量失效 + 尺寸缓存失效;
  磁盘派生缓存(哈希命名不可枚举)由既有清理任务回收
- 前端:卡片操作区三态(复制/alt/删除 ↔ alt 编辑 ↔ 确认删除),
  顶栏「清理孤儿」两步确认(带数量与总大小),操作结果横幅
2026-07-24 16:23:00 +08:00
xfy
99b96e7a0c feat(assets): 素材管理只读列表页
- list_assets:引用状态筛选 + filename/alt 搜索 + 时间/大小排序 +
  分页(60/页),一次响应带齐 tabs 计数与可清理孤儿统计
- /admin/assets 网格页:?thumb=300x300 缩略图卡片 + 引用徽标,
  单路由客户端分页(对齐管理文章模式),导航加「素材」入口
2026-07-24 16:15:19 +08:00
xfy
0c811efd4d feat(assets): 素材注册表数据层
- migration 015:assets 元数据注册表 + asset_refs 引用关联表
- upload_image 落盘后登记 assets(失败补偿删文件);尺寸校验复用
  upload_dimensions 一次解析同时返回 (w,h)
- sync_asset_refs 镜像 sync_tags:文章 create/update 事务内
  解析 content_html + cover_image 的 /uploads/ 路径重建引用
- Asset/AssetDto/AssetFilter/AssetSort 共享模型,id 以 String 承载
  (SQL 侧 ::uuid cast,避免 uuid crate 进 WASM 构建)
2026-07-24 16:07:07 +08:00
xfy
adde92e15c docs(assets): 素材管理功能设计规格
grilling 访谈确认的结论落盘:assets/asset_refs 双表、
sync_tags 模式引用同步、引用中禁删 + 孤儿 7 天窗一键清理、
手动重建索引回填、封面联动(编辑器正文选择器不做)。
2026-07-24 15:26:56 +08:00
31 changed files with 2023 additions and 63 deletions

1
Cargo.lock generated
View File

@ -3445,6 +3445,7 @@ dependencies = [
"chrono", "chrono",
"fallible-iterator", "fallible-iterator",
"postgres-protocol", "postgres-protocol",
"uuid",
] ]
[[package]] [[package]]

View File

@ -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"], optional = true } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1"], 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", "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", "Clipboard", "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"

View File

@ -0,0 +1,184 @@
# 素材管理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、编辑 altinline 或 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` 全绿;新增迁移通过文件/数组一致性编译测试

View File

@ -376,18 +376,39 @@ 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';
}; };
if (s.img.complete && s.img.naturalWidth) { const onReady = (): void => {
// 缓存命中,直接淡入 applyGeometry();
s.img.src = origSrc;
fade(); fade();
};
// 先换 src 再判 complete换之前判的是旧图必命中会按旧图尺寸算几何。
s.img.src = origSrc;
if (s.img.complete && s.img.naturalWidth) {
onReady(); // 缓存命中,新图尺寸同步可用
} else { } else {
s.img.addEventListener('load', fade, { once: true }); s.img.addEventListener('load', onReady, { 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';
@ -506,18 +527,28 @@ 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) => {
node.addEventListener('click', (e: MouseEvent) => { bindClick(node, (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
openLightbox(node, gallery, idx); openLightbox(node, gallery, idx);
}); });
}); });
// 单张图封面index = nullgallery 传空数组表示单张 // 单张图封面index = nullgallery 传空数组表示单张
for (const node of collected.singles) { for (const node of collected.singles) {
node.addEventListener('click', (e: MouseEvent) => { bindClick(node, (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
openLightbox(node, [], null); openLightbox(node, [], null);
}); });

View File

@ -63,6 +63,13 @@ function getCounter(): HTMLElement | null {
return document.querySelector('.lightbox-counter'); return document.querySelector('.lightbox-counter');
} }
/** stub 灯箱图的 natural 尺寸与 completehappy-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 }));
@ -235,6 +242,52 @@ 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('单张图不参与图集切换', () => {

View File

@ -12,8 +12,11 @@
/* 灯箱图片绝对定位尺寸/位置由 JS transform 控制 /* 灯箱图片绝对定位尺寸/位置由 JS transform 控制
transform-origin top left配合 translate+scale 实现原地缩放 transform-origin top left配合 translate+scale 实现原地缩放
显式 width/height JS 设置为原图尺寸确保布局尺寸确定 显式 width/height JS 设置打开时 = 缩略图 rect图集切换后 = 居中目标
transform 只做视觉缩放不改变占位 */ 尺寸确保布局尺寸确定transform 只做视觉缩放不改变占位
注意不可加 object-fit: containtransformFor 的非均匀 scale 依赖位图填满
布局盒这一前提才能恰好抵消失真素材网格的缩略图容器恒为正方形与原图
宽高比不同contain letterbox 会导致非均匀 scale 把图压扁 */
.lightbox-img { .lightbox-img {
position: absolute; position: absolute;
left: 0; left: 0;
@ -21,7 +24,6 @@
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;
} }

27
migrations/015_assets.sql Normal file
View File

@ -0,0 +1,27 @@
-- 素材(图片)注册表与文章引用关联。
-- assetsuploads/ 下每张已登记图片的元数据;磁盘是字节唯一存储,本表是注册表。
-- asset_refs哪篇文章引用了哪张素材文章保存时由 sync_asset_refs 全量重建sync_tags 模式)。
-- id 由应用层生成uuid crateSQL 侧不设默认值,避免依赖 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);

194
src/api/assets/delete.rs Normal file
View File

@ -0,0 +1,194 @@
//! 素材删除与孤儿清理接口。
//!
//! 删除保护:被任何文章(含回收站文章)引用的素材禁删,返回引用列表;
//! 孤儿素材硬删除(文件 + 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!()
}

184
src/api/assets/list.rs Normal file
View File

@ -0,0 +1,184 @@
//! 素材分页列表接口。
//!
//! 按引用状态(全部/引用中/孤儿)筛选、按文件名/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}"
),
&params,
)
.await
.map_err(AppError::query)?;
let total: i64 = client
.query_one(
&format!("SELECT COUNT(*) FROM assets a {where_clause}"),
&params[..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!()
}

21
src/api/assets/mod.rs Normal file
View File

@ -0,0 +1,21 @@
//! 素材管理 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};

209
src/api/assets/rebuild.rs Normal file
View File

@ -0,0 +1,209 @@
//! 素材索引全量重建接口。
//!
//! 以磁盘为准自愈 DB 与文件系统的不一致:
//! 1. 扫 `uploads/`(跳过 `.cache` 等点目录)→ upsert assets技术字段变化才更新保留 alt
//! 2. 删除文件已消失的 DB 行refs 级联);
//! 3. 全表扫 posts含回收站重建 asset_refs。
//!
//! 幂等:重跑结果相同(技术字段无变化时 updated 为 0alt 不被覆盖)。
//! 幂等性由「手动触发」语义承载非常态路径。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!()
}

81
src/api/assets/types.rs Normal file
View File

@ -0,0 +1,81 @@
//! 素材管理 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,
}

View File

@ -321,15 +321,15 @@ fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 仅读取 header 校验上传图片的尺寸/像素是否超限 /// 仅读取 header 校验上传图片的尺寸/像素是否超限,并返回 (width, height)
/// ///
/// 与 `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)。
pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'static str> { /// 尺寸随结果返回,供 `upload_image` 校验通过后直接写入 assets 表,避免二次解析。
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 fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'sta
); );
return Err("图片尺寸过大,请压缩后再上传"); return Err("图片尺寸过大,请压缩后再上传");
} }
Ok(()) Ok(dims)
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -534,6 +534,22 @@ 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
@ -772,7 +788,7 @@ mod tests {
assert_eq!(dims, None); assert_eq!(dims, None);
} }
// —— check_upload_dimensions:统一上传尺寸/像素上限校验 —— // —— 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> {
@ -783,54 +799,54 @@ mod tests {
} }
#[test] #[test]
fn check_upload_dimensions_accepts_small_png() { fn upload_dimensions_accepts_small_png() {
let data = make_png_bytes(100, 100); let data = make_png_bytes(100, 100);
assert!(check_upload_dimensions(&data, "image/png").is_ok()); assert!(upload_dimensions(&data, "image/png").is_ok());
} }
#[test] #[test]
fn check_upload_dimensions_accepts_boundary_png() { fn 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!(check_upload_dimensions(&data, "image/png").is_ok()); assert!(upload_dimensions(&data, "image/png").is_ok());
} }
#[test] #[test]
fn check_upload_dimensions_rejects_oversized_width() { fn 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 = check_upload_dimensions(&data, "image/png").unwrap_err(); let err = upload_dimensions(&data, "image/png").unwrap_err();
assert!(err.contains("尺寸过大")); assert!(err.contains("尺寸过大"));
} }
#[test] #[test]
fn check_upload_dimensions_rejects_oversized_height() { fn 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 = check_upload_dimensions(&data, "image/png").unwrap_err(); let err = upload_dimensions(&data, "image/png").unwrap_err();
assert!(err.contains("尺寸过大")); assert!(err.contains("尺寸过大"));
} }
#[test] #[test]
fn check_upload_dimensions_accepts_small_webp() { fn 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!(check_upload_dimensions(&webp_bytes, "image/webp").is_ok()); assert!(upload_dimensions(&webp_bytes, "image/webp").is_ok());
} }
#[test] #[test]
fn check_upload_dimensions_accepts_gif() { fn 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!(check_upload_dimensions(&buf.into_inner(), "image/gif").is_ok()); assert!(upload_dimensions(&buf.into_inner(), "image/gif").is_ok());
} }
#[test] #[test]
fn check_upload_dimensions_rejects_corrupt_bytes() { fn upload_dimensions_rejects_corrupt_bytes() {
// 非 magic bytes,与现有损坏文件校验文案对齐 // 非 magic bytes,与现有损坏文件校验文案对齐
let err = check_upload_dimensions(b"not an image at all", "image/png").unwrap_err(); let err = upload_dimensions(b"not an image at all", "image/png").unwrap_err();
assert_eq!(err, "图片文件损坏或格式不正确"); assert_eq!(err, "图片文件损坏或格式不正确");
} }

View File

@ -6,6 +6,8 @@
/// 认证相关的 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;
/// 评论相关接口。 /// 评论相关接口。

View File

@ -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_tags}; use super::helpers::{clean_tags, get_current_admin_user, sync_asset_refs, 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,6 +129,9 @@ 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)?;
// 写入成功后按粒度失效相关缓存。 // 写入成功后按粒度失效相关缓存。

View File

@ -203,9 +203,73 @@ 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; use super::{clean_tags, extract_asset_paths};
#[test] #[test]
fn clean_tags_trims_whitespace() { fn clean_tags_trims_whitespace() {
@ -249,4 +313,43 @@ 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=20data-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());
}
} }

View File

@ -7,7 +7,7 @@
mod create; mod create;
mod delete; mod delete;
mod helpers; pub(crate) mod helpers;
mod list; mod list;
mod read; mod read;
mod rebuild; mod rebuild;

View File

@ -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_tags}; use super::helpers::{clean_tags, get_current_admin_user, sync_asset_refs, 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,6 +183,9 @@ 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 与统计缓存。

View File

@ -134,6 +134,9 @@ 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,
@ -158,12 +161,13 @@ pub async fn upload_image(
return Err(upload_error(StatusCode::BAD_REQUEST, "文件类型与内容不符")); return Err(upload_error(StatusCode::BAD_REQUEST, "文件类型与内容不符"));
} }
// 仅读 header 统一校验尺寸/像素上限。三种格式走同一路径: // 仅读 header 统一校验尺寸/像素上限,并拿回 (w, h) 供 assets 表登记,避免二次解析。
// JPEG/PNG/GIF 用 image crate 的 into_dimensions,WebP 用 zenwebp header。
// 超限直接拒绝,避免大图走 decode 后被静默降级(原 fallback 存原图)。 // 超限直接拒绝,避免大图走 decode 后被静默降级(原 fallback 存原图)。
if let Err(msg) = crate::api::image::check_upload_dimensions(&data, mime_type.as_str()) { let (img_width, img_height) =
return Err(upload_error(StatusCode::BAD_REQUEST, msg)); match crate::api::image::upload_dimensions(&data, mime_type.as_str()) {
} 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";
@ -291,6 +295,48 @@ 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

View File

@ -44,6 +44,7 @@ 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 {}, "系统"),
]; ];

View File

@ -12,6 +12,8 @@ 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)]
@ -92,27 +94,6 @@ fn decode_html_entities(s: &str) -> String {
.replace("&amp;", "&") .replace("&amp;", "&")
} }
/// 读取 `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

View File

@ -60,6 +60,7 @@ 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 文件。
]; ];

58
src/models/asset.rs Normal file
View File

@ -0,0 +1,58 @@
//! 素材(图片)模型。
//!
//! `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,
}

View File

@ -3,6 +3,8 @@
//! 定义博客系统使用的核心领域模型包括文章Post、用户User与评论Comment //! 定义博客系统使用的核心领域模型包括文章Post、用户User与评论Comment
//! 这些结构体通过 serde 在服务端与客户端之间共享序列化。 //! 这些结构体通过 serde 在服务端与客户端之间共享序列化。
/// 素材图片模型assets 注册表与引用关联的 serde DTO。
pub mod asset;
/// 评论模型及其状态枚举。 /// 评论模型及其状态枚举。
pub mod comment; pub mod comment;
/// 文章模型、文章状态、标签与统计信息。 /// 文章模型、文章状态、标签与统计信息。

View File

@ -0,0 +1,171 @@
//! 素材选择 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",
}
}
}
}
}
}
}
}
}
}
}
}

522
src/pages/admin/assets.rs Normal file
View File

@ -0,0 +1,522 @@
//! 素材管理页面。
//!
//! 网格浏览 `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),
}
}
}
}
}

View File

@ -2,6 +2,10 @@
//! //!
//! 汇总并重新导出后台管理相关的页面组件,供路由与其他模块使用。 //! 汇总并重新导出后台管理相关的页面组件,供路由与其他模块使用。
/// 素材选择 modal封面上「从素材库选择」
pub mod asset_picker;
/// 素材管理页面模块。
pub mod assets;
/// 评论管理页面模块。 /// 评论管理页面模块。
pub mod comments; pub mod comments;
/// 管理后台仪表盘页面模块。 /// 管理后台仪表盘页面模块。
@ -17,6 +21,8 @@ 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};
/// 管理后台仪表盘组件。 /// 管理后台仪表盘组件。

View File

@ -20,6 +20,7 @@ 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")]
@ -606,6 +607,8 @@ 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 后统一调用此闭包。
@ -777,9 +780,19 @@ 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",
"拖拽 / 粘贴 / 点击上传" "拖拽 / 粘贴 / 点击上传"
} }
// URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式 // 从素材库选择:阻止 label 默认 file 触发,打开选择 modal
span { span {
class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors ml-auto 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| {
evt.prevent_default();
evt.stop_propagation();
picker_visible.set(true);
},
"素材库"
}
// URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。
span {
class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
onclick: move |evt| { onclick: move |evt| {
evt.prevent_default(); evt.prevent_default();
evt.stop_propagation(); evt.stop_propagation();
@ -864,5 +877,15 @@ 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);
},
}
} }
} }

View File

@ -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, Posts, Runner, System, Write, WriteEdit, Admin, AdminComments, AdminCommentsPage, Assets, 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,6 +77,9 @@ pub enum Route {
/// 文章管理(列表 + 回收站,客户端 tab 切换,单一路由) /// 文章管理(列表 + 回收站,客户端 tab 切换,单一路由)
#[route("/posts")] #[route("/posts")]
Posts {}, Posts {},
/// 素材管理(上传图片注册表:浏览/搜索/删除/孤儿清理)
#[route("/assets")]
Assets {},
/// 评论管理 /// 评论管理
#[route("/comments")] #[route("/comments")]
AdminComments {}, AdminComments {},

28
src/utils/js.rs Normal file
View File

@ -0,0 +1,28 @@
//! 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);
}
}
}

View File

@ -2,6 +2,7 @@
//! //!
//! - `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 与原生异步版本)。
@ -9,6 +10,9 @@
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;