Compare commits
No commits in common. "055329107ee03f76ebe733c1feaef1a17a9beec1" and "10ef52bedee69bf34af6c7f73220df9c0ed7bac7" have entirely different histories.
055329107e
...
10ef52bede
11
Makefile
11
Makefile
@ -1,4 +1,4 @@
|
|||||||
.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental highlight-css test
|
.PHONY: dev build build-linux css css-watch clean build-editor highlight-css test
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@$(MAKE) build-editor
|
@$(MAKE) build-editor
|
||||||
@ -21,13 +21,7 @@ build-editor:
|
|||||||
@cd libs/tiptap-editor && npm ci --include=dev && npm run build
|
@cd libs/tiptap-editor && npm ci --include=dev && npm run build
|
||||||
@echo "Tiptap editor built."
|
@echo "Tiptap editor built."
|
||||||
|
|
||||||
# dev 用的增量构建:跳过 npm ci(假设 node_modules 已存在),仅 vite build。
|
dev:
|
||||||
# 与 build-editor 分开,避免每次 make dev 都重装依赖。
|
|
||||||
build-editor-incremental:
|
|
||||||
@cd libs/tiptap-editor && npm run build
|
|
||||||
|
|
||||||
dev: build-editor-incremental
|
|
||||||
@echo "Building Tiptap editor (incremental)..."
|
|
||||||
@echo "Starting tailwindcss watch and dx serve..."
|
@echo "Starting tailwindcss watch and dx serve..."
|
||||||
@tailwindcss -i input.css -o public/style.css --watch & \
|
@tailwindcss -i input.css -o public/style.css --watch & \
|
||||||
TAILWIND_PID=$$!; \
|
TAILWIND_PID=$$!; \
|
||||||
@ -46,4 +40,5 @@ test:
|
|||||||
clean:
|
clean:
|
||||||
@cargo clean
|
@cargo clean
|
||||||
@rm -f public/style.css public/highlight.css
|
@rm -f public/style.css public/highlight.css
|
||||||
|
@rm -rf public/tiptap
|
||||||
@rm -rf uploads/.cache
|
@rm -rf uploads/.cache
|
||||||
|
|||||||
@ -1,479 +0,0 @@
|
|||||||
# 编辑器图片上传占位符与失败提示设计
|
|
||||||
|
|
||||||
## 背景与目标
|
|
||||||
|
|
||||||
文章编辑器(`libs/tiptap-editor/`)已有图片上传功能(见 `2025-06-05-image-upload-design.md`),支持粘贴、拖拽、斜杠命令三种入口上传到 `/api/upload`。但当前上传过程存在两个体验缺陷:
|
|
||||||
|
|
||||||
1. **上传中无反馈**:图片仅在成功后才插入编辑器,用户在等待期间看不到任何"正在上传"的占位符,不知道发生了什么。
|
|
||||||
2. **上传失败完全静默**:三个上传入口(`FileHandler.onPaste`/`onDrop`、`SlashCommand`)失败时只有 `console.error`,用户在编辑器里什么都看不到。更糟的是,`write.rs` 的 fetch 还丢弃了服务端返回的中文错误(如"文件超过大小限制"),只拼成 `'Upload failed: 413'`。
|
|
||||||
|
|
||||||
本设计的目标:
|
|
||||||
- 上传过程中在编辑器内显示**占位符**(本地预览图 + loading 遮罩)
|
|
||||||
- 上传失败时把占位符变成**错误态卡片**(含服务端错误文案 + 重试/移除按钮),同时在页面顶部显示**可堆叠的失败提示**
|
|
||||||
- 保存文章时,若有未完成或失败的占位符,**阻止保存**
|
|
||||||
|
|
||||||
## 关键决策
|
|
||||||
|
|
||||||
| 决策点 | 选择 |
|
|
||||||
|--------|------|
|
|
||||||
| 占位符内容 | 本地预览图(blob URL)+ 半透明遮罩 + spinner + "上传中…" |
|
|
||||||
| 失败呈现 | 占位符变错误态(灰化 + 红色图标 + 服务端错误文案 + 重试/移除按钮)+ 顶部多条堆叠提示 |
|
|
||||||
| 重试机制 | 无限重试,File 对象保留在节点上直到成功或移除 |
|
|
||||||
| 顶部提示形态 | 静态多条堆叠,手动关闭(×) |
|
|
||||||
| 保存处理 | 有 loading/error 占位符时阻止保存,顶部提示 |
|
|
||||||
| 架构 | JS 主导(自定义 Image 节点视图)+ Rust 轮询全局变量获取上传状态 |
|
|
||||||
|
|
||||||
## 技术约束(探索结论)
|
|
||||||
|
|
||||||
- Tiptap Image 扩展(v3.25.0)**无原生上传状态**:`addAttributes` 只有 `src/alt/title/width/height`,没有 `uploading`/`uploadId` 之类。需要自定义扩展或 NodeView。
|
|
||||||
- 项目无 JS→Rust 事件通道:现有 eval 桥接是单向的(Rust eval 调 JS 方法读返回值,或 JS 写全局变量 Rust 轮询)。没有 wasm-bindgen 导出机制。失败提示要落到 Rust 的 Dioxus signal,必须新增一个轮询通道。
|
|
||||||
- 现有无 toast 组件:所有提示都是 signal 驱动的静态内联块(`AlertBox` 或 write.rs 自有的红/绿条)。
|
|
||||||
|
|
||||||
## 详细设计
|
|
||||||
|
|
||||||
### 架构总览
|
|
||||||
|
|
||||||
```
|
|
||||||
用户操作 (粘贴/拖拽/斜杠)
|
|
||||||
↓
|
|
||||||
uploadCoordinator (index.ts, 实例级单例)
|
|
||||||
├─ 生成 uploadId, 创建 blob URL
|
|
||||||
├─ 插入占位符节点 (image, data-upload-state=uploading)
|
|
||||||
├─ 发起 onImageUpload(file) ← write.rs 注入的 fetch /api/upload
|
|
||||||
│
|
|
||||||
├─ 成功: updateImageNode(uploadId, {src:url, 清除上传属性}) + revokeObjectURL
|
|
||||||
└─ 失败: updateImageNode(uploadId, {data-upload-state=error, data-error-msg})
|
|
||||||
+ notifyRust(推 event 到 window.__tiptap_uploads)
|
|
||||||
|
|
||||||
NodeView (upload-image.ts)
|
|
||||||
├─ 渲染三种态 (uploading/error/done)
|
|
||||||
└─ 按钮点击转发给 coordinator: onRetry(uploadId) / onRemove(uploadId)
|
|
||||||
|
|
||||||
Rust (write.rs)
|
|
||||||
├─ 轮询 window.__tiptap_uploads (500ms)
|
|
||||||
│ ├─ 消费 events → upload_errors signal
|
|
||||||
│ └─ 读 counts → uploads_in_flight signal
|
|
||||||
├─ 顶部渲染 upload_errors (多条堆叠 + ×关闭)
|
|
||||||
└─ on_submit 检查 uploads_in_flight + 扫描 markdown 兜底 → 阻止保存
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1. JS 侧:自定义 Image 扩展与 NodeView
|
|
||||||
|
|
||||||
**新文件 `libs/tiptap-editor/src/upload-image.ts`**
|
|
||||||
|
|
||||||
基于 `@tiptap/extension-image` 派生的扩展,覆盖三件事:
|
|
||||||
|
|
||||||
#### 1.1 自定义属性
|
|
||||||
|
|
||||||
继承父类的 `src/alt/title/width/height`,新增三个上传状态属性:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(), // 继承 src/alt/title/width/height
|
|
||||||
'data-upload-state': { default: null, parseHTML: el => el.getAttribute('data-upload-state') },
|
|
||||||
'data-upload-id': { default: null, parseHTML: el => el.getAttribute('data-upload-id') },
|
|
||||||
'data-error-msg': { default: null, parseHTML: el => el.getAttribute('data-error-msg') },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`data-upload-state` 取值:`null`(已完成/正常图片)| `"uploading"` | `"error"`。
|
|
||||||
|
|
||||||
#### 1.2 自定义 NodeView(`addNodeView`)
|
|
||||||
|
|
||||||
NodeView 根据 `data-upload-state` 渲染三种 UI。**NodeView 只负责渲染和转发按钮点击,不直接发起上传**——上传逻辑集中在 coordinator(见 §2)。
|
|
||||||
|
|
||||||
- **`null`(已完成)**:普通 `<img>`,透传 `src`/`alt`/`width`。与原生行为一致。
|
|
||||||
- **`"uploading"`**:容器内放 `<img src="blob:url">`(本地预览)+ 绝对定位遮罩(半透明黑底 + 居中 spinner + "上传中…" 文字)。
|
|
||||||
- **`"error"`**:`<img>`(`opacity-50` 灰化)+ 遮罩(红色 ⚠ 图标 + `data-error-msg` 文案 + 两个按钮:重试 / 移除)。
|
|
||||||
|
|
||||||
NodeView 持有对 `editor` 的引用。按钮点击时:
|
|
||||||
- "重试" → 读取当前节点的 `data-upload-id`,调用 `this.options.onRetry(uploadId)`
|
|
||||||
- "移除" → 读取 `data-upload-id`,调用 `this.options.onRemove(uploadId)`
|
|
||||||
|
|
||||||
`onRetry`/`onRemove` 回调由 `index.ts` 注入(实际调用 coordinator)。
|
|
||||||
|
|
||||||
NodeView 需正确实现 Tiptap NodeView 接口:`update`(节点属性变化时重新渲染遮罩状态)、`ignoreMutation`(遮罩内的按钮点击不应被 ProseMirror 当作编辑)、`destroy`(清理 DOM)。
|
|
||||||
|
|
||||||
**属性变化重渲染机制**:当 coordinator 调用 `updateNode` 更新 `data-upload-state` 等属性时,ProseMirror 派发事务,NodeView 的 `update(node)` 被调用。NodeView 在 `update` 里比较新旧节点的 `data-upload-state`,若变化则重新渲染对应的遮罩 UI(uploading→error、error→uploading、任意→done)。这是占位符状态切换的驱动机制——NodeView 本身不持有状态,纯由节点属性驱动。
|
|
||||||
|
|
||||||
#### 1.3 Markdown 序列化
|
|
||||||
|
|
||||||
`@tiptap/markdown` 默认会丢掉非标准属性。占位符节点序列化后会变成 `` 或 `![]()`。**这是可接受的**——保存拦截(见 §4)保证脏内容不进数据库,且编辑器内只对最终态(`data-upload-state=null`)的图片关心序列化正确性,此时节点属性与原生 Image 一致。
|
|
||||||
|
|
||||||
### 2. JS 侧:上传协调器(`index.ts`)
|
|
||||||
|
|
||||||
`TiptapEditorInstance` 新增一个实例级的 `uploadCoordinator`,统一管理三个上传入口(粘贴/拖拽/斜杠命令),替换现有分散在 `FileHandler.onPaste`/`onDrop` 和 `SlashCommand` 里的 `.then/.catch`。
|
|
||||||
|
|
||||||
#### 2.1 协调器职责与状态
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface UploadEntry {
|
|
||||||
file: File
|
|
||||||
blobUrl: string
|
|
||||||
fileName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
class UploadCoordinator {
|
|
||||||
private pending = new Map<string, UploadEntry>() // uploadId → {file, blobUrl, fileName}
|
|
||||||
constructor(
|
|
||||||
private editor: Editor,
|
|
||||||
private onImageUpload: (file: File) => Promise<string>,
|
|
||||||
private notifyRust: (event: UploadEvent) => void,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.2 公共方法
|
|
||||||
|
|
||||||
**`insertUploading(file: File, pos?: number): void`** — 首次上传入口(粘贴/拖拽/斜杠都调它):
|
|
||||||
```typescript
|
|
||||||
const uploadId = crypto.randomUUID()
|
|
||||||
const blobUrl = URL.createObjectURL(file)
|
|
||||||
this.pending.set(uploadId, { file, blobUrl, fileName: file.name })
|
|
||||||
|
|
||||||
// 插入占位符节点
|
|
||||||
editor.chain().focus()
|
|
||||||
.insertContentAt(pos ?? editor.state.selection.head, {
|
|
||||||
type: 'image',
|
|
||||||
attrs: { src: blobUrl, 'data-upload-state': 'uploading', 'data-upload-id': uploadId }
|
|
||||||
}).run()
|
|
||||||
|
|
||||||
this.runUpload(uploadId)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`retryUpload(uploadId: string): void`** — NodeView"重试"按钮调用:
|
|
||||||
```typescript
|
|
||||||
const entry = this.pending.get(uploadId)
|
|
||||||
if (!entry) return
|
|
||||||
// 节点先转回 uploading
|
|
||||||
this.updateNode(uploadId, { 'data-upload-state': 'uploading', 'data-error-msg': null })
|
|
||||||
this.runUpload(uploadId)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`removeUpload(uploadId: string): void`** — NodeView"移除"按钮调用:
|
|
||||||
```typescript
|
|
||||||
const entry = this.pending.get(uploadId)
|
|
||||||
if (!entry) return
|
|
||||||
// 删除节点(按 data-upload-id 定位)
|
|
||||||
this.removeNodeByUploadId(uploadId)
|
|
||||||
URL.revokeObjectURL(entry.blobUrl)
|
|
||||||
this.pending.delete(uploadId)
|
|
||||||
this.notifyRust({ kind: 'removed', uploadId })
|
|
||||||
```
|
|
||||||
|
|
||||||
**`removeUploadByUploadId(uploadId: string): boolean`** — Rust 侧"×关闭"提示时调用(通过 eval)。逻辑与 `removeUpload` 相同(删节点 + revoke + pending.delete + notifyRust),只是入口不同:`removeUpload` 由 NodeView 内部按钮触发,`removeUploadByUploadId` 由 Rust eval 从外部触发。返回是否成功删除(供 Rust 判断是否要清顶部提示)。
|
|
||||||
|
|
||||||
#### 2.3 私有方法
|
|
||||||
|
|
||||||
**`private async runUpload(uploadId): Promise<void>`** — 核心上传逻辑:
|
|
||||||
```typescript
|
|
||||||
const entry = this.pending.get(uploadId)
|
|
||||||
if (!entry) return
|
|
||||||
try {
|
|
||||||
const url = await this.onImageUpload(entry.file)
|
|
||||||
// 成功:替换 src + 清除上传属性
|
|
||||||
this.updateNode(uploadId, {
|
|
||||||
src: url,
|
|
||||||
'data-upload-state': null,
|
|
||||||
'data-upload-id': null,
|
|
||||||
'data-error-msg': null,
|
|
||||||
})
|
|
||||||
URL.revokeObjectURL(entry.blobUrl)
|
|
||||||
this.pending.delete(uploadId)
|
|
||||||
this.notifyRust({ kind: 'success', uploadId, fileName: entry.fileName })
|
|
||||||
} catch (err) {
|
|
||||||
const msg = this.extractErrorMessage(err)
|
|
||||||
this.updateNode(uploadId, {
|
|
||||||
'data-upload-state': 'error',
|
|
||||||
'data-error-msg': msg,
|
|
||||||
})
|
|
||||||
this.notifyRust({ kind: 'error', uploadId, fileName: entry.fileName, errorMsg: msg })
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`private updateNode(uploadId, attrs): void`** — 按 `data-upload-id` 定位节点并更新属性。上传完成时光标早已移走,不能依赖选区,必须遍历文档回查:
|
|
||||||
```typescript
|
|
||||||
let targetPos: number | null = null
|
|
||||||
editor.state.doc.descendants((node, pos) => {
|
|
||||||
if (node.type.name === 'image' && node.attrs['data-upload-id'] === uploadId) {
|
|
||||||
targetPos = pos
|
|
||||||
return false // 停止遍历
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
if (targetPos !== null) {
|
|
||||||
const tr = editor.state.tr.setNodeMarkup(targetPos, undefined, { ...node.attrs, ...attrs })
|
|
||||||
editor.view.dispatch(tr)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`private removeNodeByUploadId(uploadId): void`** — 类似 `updateNode` 定位后用 `tr.delete(pos, pos + nodeSize)`。
|
|
||||||
|
|
||||||
**`private extractErrorMessage(err): string`** — 从错误对象提取服务端中文消息:
|
|
||||||
- 若 err 是 Error 且 message 以 `"Upload failed: "` 开头(`write.rs` 的旧格式),尝试回退到通用提示
|
|
||||||
- 否则直接用 message
|
|
||||||
- **注意**:见 §5 的 `write.rs` fetch 改造,改造后 err.message 会直接是服务端中文(如"文件超过大小限制"),此处只需透传
|
|
||||||
|
|
||||||
#### 2.4 三个上传入口的改造
|
|
||||||
|
|
||||||
| 入口 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `FileHandler.onPaste` | 改为 `coordinator.insertUploading(file)`(无 pos,插入选区) |
|
|
||||||
| `FileHandler.onDrop` | 改为 `coordinator.insertUploading(file, pos)`(用 onDrop 给的 pos) |
|
|
||||||
| `SlashCommand` 上传图片命令 | 改为 `coordinator.insertUploading(file)` |
|
|
||||||
|
|
||||||
三处原本的 `.then(setImage).catch(console.error)` 全部删除,统一走 coordinator。
|
|
||||||
|
|
||||||
### 3. JS→Rust 状态通道
|
|
||||||
|
|
||||||
#### 3.1 全局对象结构
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
window.__tiptap_uploads = {
|
|
||||||
// 新事件队列:Rust 消费后清空
|
|
||||||
events: [
|
|
||||||
{ kind: 'error', uploadId: 'uuid1', fileName: 'cat.png', errorMsg: '文件超过大小限制', ts: 1719... },
|
|
||||||
{ kind: 'success', uploadId: 'uuid2', fileName: 'dog.png', ts: 1719... },
|
|
||||||
{ kind: 'removed', uploadId: 'uuid1', ts: 1719... },
|
|
||||||
],
|
|
||||||
// 实时计数(始终反映当前编辑器内占位符状态)
|
|
||||||
counts: { uploading: 2, error: 1 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `events`:追加型队列。coordinator 每次 `runUpload` 成功/失败、`removeUpload` 时追加一个 event。Rust 轮询时读取并清空(`u.events = []`)。
|
|
||||||
- `counts`:coordinator 在每次状态变化后重新计算(遍历 `editor.state.doc` 统计 `data-upload-state` 为 `uploading`/`error` 的节点数),写入 `counts`。Rust 每次轮询直接读当前值。
|
|
||||||
|
|
||||||
#### 3.2 `notifyRust(event)` 实现
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private notifyRust(event: UploadEvent) {
|
|
||||||
if (!window.__tiptap_uploads) {
|
|
||||||
window.__tiptap_uploads = { events: [], counts: { uploading: 0, error: 0 } }
|
|
||||||
}
|
|
||||||
window.__tiptap_uploads.events.push({ ...event, ts: Date.now() })
|
|
||||||
// 重新计算 counts
|
|
||||||
let uploading = 0, error = 0
|
|
||||||
this.editor.state.doc.descendants((node) => {
|
|
||||||
const state = node.attrs['data-upload-state']
|
|
||||||
if (state === 'uploading') uploading++
|
|
||||||
else if (state === 'error') error++
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
window.__tiptap_uploads.counts = { uploading, error }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Rust 侧:轮询消费与渲染(`write.rs`)
|
|
||||||
|
|
||||||
#### 4.1 新增 signal
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Clone, Copy, Default)]
|
|
||||||
struct UploadsInFlight { uploading: u32, error: u32 }
|
|
||||||
|
|
||||||
// 当前进行中的上传计数(保存拦截用)
|
|
||||||
let mut uploads_in_flight = use_signal(UploadsInFlight::default);
|
|
||||||
|
|
||||||
// 顶部堆叠的失败提示(用户手动关闭)
|
|
||||||
struct UploadErrorEntry { id: String, file_name: String, message: String }
|
|
||||||
let mut upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.2 轮询 effect
|
|
||||||
|
|
||||||
复用现有 `spawn_local` 轮询模式(参考 `write.rs:210-238` 的 `__tiptap_ready` 轮询)。新增独立 `use_future`,500ms 间隔:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use_future(move || async move {
|
|
||||||
let mut seen_error_ids: HashSet<String> = HashSet::new();
|
|
||||||
loop {
|
|
||||||
sleep(500ms);
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
let snapshot = js_sys::eval(r#"
|
|
||||||
(function() {
|
|
||||||
var u = window.__tiptap_uploads;
|
|
||||||
if (!u) return null;
|
|
||||||
var events = u.events || [];
|
|
||||||
u.events = [];
|
|
||||||
return JSON.stringify({ events: events, counts: u.counts || {uploading:0,error:0} });
|
|
||||||
})()
|
|
||||||
"#).ok().and_then(|v| v.as_string());
|
|
||||||
|
|
||||||
if let Some(json) = snapshot {
|
|
||||||
if let Ok(parsed) = serde_json::from_str::<UploadSnapshot>(&json) {
|
|
||||||
// 1. 消费 events
|
|
||||||
for ev in parsed.events {
|
|
||||||
match ev.kind.as_str() {
|
|
||||||
"error" => {
|
|
||||||
if !seen_error_ids.contains(&ev.uploadId) {
|
|
||||||
seen_error_ids.insert(ev.uploadId.clone());
|
|
||||||
upload_errors.write().push(UploadErrorEntry {
|
|
||||||
id: ev.uploadId,
|
|
||||||
file_name: ev.fileName,
|
|
||||||
message: ev.errorMsg,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"success" | "removed" => {
|
|
||||||
// 该 id 不再是失败态,从顶部提示移除
|
|
||||||
seen_error_ids.remove(&ev.uploadId);
|
|
||||||
upload_errors.write().retain(|e| e.id != ev.uploadId);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. 更新 counts
|
|
||||||
uploads_in_flight.set(UploadsInFlight {
|
|
||||||
uploading: parsed.counts.uploading,
|
|
||||||
error: parsed.counts.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**counts 同步的重要性**:当用户在编辑器内点"移除"删掉失败占位符,coordinator 发 `removed` event + 重算 counts。Rust 消费 `removed` event 时从 `upload_errors` 移除对应条目——**保证编辑器内卡片和顶部提示同步**,不会出现"占位符删了但顶部提示还在"的孤儿状态。
|
|
||||||
|
|
||||||
注:`success` event 在 Rust 侧也会走 `seen_error_ids.remove + upload_errors.retain` 分支,但 success 的 id 从未进过 `seen_error_ids`/`upload_errors`(只有 error 才进),所以这是无害的多余操作,保留统一处理逻辑即可。
|
|
||||||
|
|
||||||
#### 4.3 顶部提示渲染
|
|
||||||
|
|
||||||
在 `write.rs` 现有 `load_error()`/`error()`/`success()` 提示区附近(约 line 453-469),新增上传错误区:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
for err in upload_errors.read().iter() {
|
|
||||||
div {
|
|
||||||
class: "flex-shrink-0 flex items-center justify-between px-4 py-2
|
|
||||||
bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400
|
|
||||||
rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2",
|
|
||||||
span { "图片上传失败: {err.file_name} — {err.message}" }
|
|
||||||
button {
|
|
||||||
class: "ml-3 text-red-400 hover:text-red-600 cursor-pointer",
|
|
||||||
onclick: move |_| {
|
|
||||||
// 关闭提示,同时删除编辑器内的失败占位符(避免孤儿)
|
|
||||||
let _ = js_sys::eval(&format!(
|
|
||||||
"(function(){{var e=window.TiptapEditor&&window.TiptapEditor._instances&&window.TiptapEditor._instances.get('tiptap-editor');if(e&&e.removeUploadByUploadId){{e.removeUploadByUploadId({:?});}}}})()",
|
|
||||||
err.id
|
|
||||||
));
|
|
||||||
upload_errors.write().retain(|e| e.id != err.id);
|
|
||||||
},
|
|
||||||
"×"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**×关闭同时删除失败占位符**:用户点×的语义是"清掉这个失败",保留编辑器内的红色卡片会变成孤儿(顶部无提示了但编辑器里还挂着)。通过 eval 调 `removeUploadByUploadId` 删除占位符 + revoke blob URL + 发 `removed` event。
|
|
||||||
|
|
||||||
#### 4.4 保存拦截(双重防护)
|
|
||||||
|
|
||||||
**第一道:counts 检查**(主提示)
|
|
||||||
|
|
||||||
`on_submit`(约 line 247)开头,读 markdown 前加检查:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let in_flight = uploads_in_flight.read();
|
|
||||||
if in_flight.uploading > 0 || in_flight.error > 0 {
|
|
||||||
let msg = if in_flight.uploading > 0 {
|
|
||||||
format!("有 {} 张图片正在上传,请等待完成后再保存", in_flight.uploading)
|
|
||||||
} else {
|
|
||||||
format!("有 {} 张图片上传失败,请移除或重试后再保存", in_flight.error)
|
|
||||||
};
|
|
||||||
error.set(Some(msg));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
drop(in_flight);
|
|
||||||
```
|
|
||||||
|
|
||||||
**第二道:markdown 兜底扫描**(防御性)
|
|
||||||
|
|
||||||
500ms 轮询有窗口期(用户刚传完、counts 还没更新就点保存)。拿到 markdown 后扫描是否含残留的占位符标记:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 拿到 md 后
|
|
||||||
if md.contains("blob:") || md.contains("data-upload-state") {
|
|
||||||
error.set(Some("检测到未完成上传的图片,请处理后保存".to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
这两道防护共同保证:脏内容(blob URL 或带上传状态的节点)不会写入数据库。
|
|
||||||
|
|
||||||
### 5. `write.rs` fetch 改造(透传服务端错误)
|
|
||||||
|
|
||||||
当前 `onImageUpload` 的 fetch 在非 2xx 时丢弃了服务端的中文错误:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 当前(丢弃错误体)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Upload failed: ' + response.status);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
改为读取错误响应体:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 改造后
|
|
||||||
if (!response.ok) {
|
|
||||||
// 读取服务端返回的中文错误({"success":false,"error":"文件超过大小限制"})
|
|
||||||
// 服务端所有失败路径都返回此 JSON 格式(见 upload.rs),解析可靠
|
|
||||||
const data = await response.json().catch(() => null);
|
|
||||||
if (data && data.error) {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
// 响应体不是 JSON(极端情况,如反向代理错误页),退回状态码
|
|
||||||
throw new Error('上传失败: ' + response.status);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
改造后,coordinator 的 `extractErrorMessage` 直接透传即可拿到"文件超过大小限制"等中文消息。
|
|
||||||
|
|
||||||
## 实现边界与清单
|
|
||||||
|
|
||||||
### JS 侧(`libs/tiptap-editor/src/`)
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `upload-image.ts` | **新建**:自定义 Image 扩展(继承父类属性 + 三个上传属性 + NodeView) |
|
|
||||||
| `index.ts` | 替换 `Image.configure(...)` 为自定义扩展;新增 `UploadCoordinator` 类;`FileHandler.onPaste/onDrop`、`SlashCommand` 统一走 `coordinator.insertUploading`;实现 `notifyRust` + `removeUploadByUploadId` 暴露给 Rust |
|
|
||||||
| `slash-command.ts` | 上传图片命令的 `.then/.catch` 改为 `coordinator.insertUploading(file)` |
|
|
||||||
| `style.css` | 新增 NodeView 三种态的样式(遮罩、spinner、错误卡片、重试/移除按钮) |
|
|
||||||
|
|
||||||
### Rust 侧(`src/pages/admin/write.rs`)
|
|
||||||
|
|
||||||
| 改动点 | 说明 |
|
|
||||||
|--------|------|
|
|
||||||
| 新增 signal | `uploads_in_flight`、`upload_errors` |
|
|
||||||
| 新增轮询 effect | 500ms 消费 `window.__tiptap_uploads` |
|
|
||||||
| 顶部提示渲染 | 多条堆叠 + ×关闭(同时删占位符) |
|
|
||||||
| `on_submit` 拦截 | counts 检查 + markdown 兜底扫描 |
|
|
||||||
| `onImageUpload` fetch 改造 | 读取非 2xx 响应体的 `error` 字段 |
|
|
||||||
|
|
||||||
### 不做的事
|
|
||||||
|
|
||||||
- 不引入 wasm-bindgen 导出机制(保持 eval 桥接一致性)
|
|
||||||
- 不做 toast/自动消失提示(保持与现有静态提示风格一致)
|
|
||||||
- 不引入图片编辑/裁剪能力
|
|
||||||
- 不改动服务端 `upload.rs`(它的错误响应格式已满足需求,只是前端没读)
|
|
||||||
- 不处理编辑模式(`WriteEdit`)下的旧文章占位符回填——旧文章的图片都是 `data-upload-state=null` 的正常图片,不涉及上传态
|
|
||||||
|
|
||||||
## 验收标准
|
|
||||||
|
|
||||||
- [ ] 粘贴/拖拽/斜杠命令上传图片时,编辑器立即显示本地预览图 + "上传中…"遮罩
|
|
||||||
- [ ] 上传成功后,遮罩消失,图片 src 替换为服务端 URL,无光标跳动
|
|
||||||
- [ ] 上传失败时,占位符变红,显示服务端中文错误(如"文件超过大小限制")
|
|
||||||
- [ ] 失败占位符上有"重试"和"移除"按钮,点重试用原文件重新上传,点移除删除节点
|
|
||||||
- [ ] 上传失败时页面顶部出现堆叠提示,显示文件名 + 错误原因 + ×关闭
|
|
||||||
- [ ] 多张图片同时失败时,顶部提示多条堆叠,逐条可关闭
|
|
||||||
- [ ] 在编辑器内移除失败占位符,顶部对应提示同步消失
|
|
||||||
- [ ] 点顶部×关闭,编辑器内对应失败占位符同步删除
|
|
||||||
- [ ] 有 uploading 占位符时点保存,被阻止并提示"有 N 张图片正在上传"
|
|
||||||
- [ ] 有 error 占位符时点保存,被阻止并提示"有 N 张图片上传失败"
|
|
||||||
- [ ] markdown 兜底扫描:即使轮询窗口期漏判,blob: 残留也能被拦下
|
|
||||||
- [ ] 上传超大文件(>5MB)时错误提示为"文件超过大小限制"而非"Upload failed: 413"
|
|
||||||
@ -84,10 +84,7 @@ class TiptapEditorInstance {
|
|||||||
Image.configure({ allowBase64: true }),
|
Image.configure({ allowBase64: true }),
|
||||||
TaskList,
|
TaskList,
|
||||||
TaskItem.configure({ nested: true }),
|
TaskItem.configure({ nested: true }),
|
||||||
// 把宿主注入的图片上传回调透传给斜杠命令扩展,使 /上传图片 命令可用。
|
SlashCommand,
|
||||||
SlashCommand.configure({
|
|
||||||
onImageUpload: this.options.onImageUpload,
|
|
||||||
}),
|
|
||||||
FileHandler.configure({
|
FileHandler.configure({
|
||||||
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||||
onPaste: (editor, files) => {
|
onPaste: (editor, files) => {
|
||||||
|
|||||||
@ -9,37 +9,7 @@ interface CommandItem {
|
|||||||
command: (props: { editor: any; range: Range }) => void
|
command: (props: { editor: any; range: Range }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const COMMANDS: CommandItem[] = [
|
||||||
* 斜杠命令扩展的选项。
|
|
||||||
*
|
|
||||||
* `onImageUpload` 由宿主注入(参见 index.ts),用于把用户选择的图片文件
|
|
||||||
* 上传到服务端并返回可访问的 URL。未提供时"上传图片"命令会被隐藏,
|
|
||||||
* 只保留"图片链接"(手动填 URL)。
|
|
||||||
*/
|
|
||||||
export interface SlashCommandOptions {
|
|
||||||
onImageUpload?: (file: File) => Promise<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
const SlashCommandPluginKey = new PluginKey('slashCommand')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 斜杠命令扩展。
|
|
||||||
*
|
|
||||||
* `onImageUpload` 通过 `addOptions` 注入,"上传图片"命令据此决定是否出现。
|
|
||||||
*/
|
|
||||||
export const SlashCommand = Extension.create<SlashCommandOptions>({
|
|
||||||
name: 'slashCommand',
|
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
onImageUpload: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
// 依据是否提供上传回调,决定可用命令集。
|
|
||||||
const uploadFn = this.options.onImageUpload
|
|
||||||
const COMMANDS: CommandItem[] = [
|
|
||||||
{
|
{
|
||||||
title: '标题 1',
|
title: '标题 1',
|
||||||
description: '大标题',
|
description: '大标题',
|
||||||
@ -120,42 +90,9 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
|
|||||||
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
|
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
|
||||||
|
|
||||||
// 图片相关命令:上传命令仅在上传回调可用时才出现。
|
|
||||||
if (uploadFn) {
|
|
||||||
COMMANDS.push({
|
|
||||||
title: '上传图片',
|
|
||||||
description: '从本地选择并上传图片',
|
|
||||||
icon: '📤',
|
|
||||||
command: ({ editor, range }) => {
|
|
||||||
// 必须先删掉 /命令 文本,文件选择对话框会阻塞,关闭后 range 可能失效。
|
|
||||||
editor.chain().focus().deleteRange(range).run()
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.type = 'file'
|
|
||||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp'
|
|
||||||
input.addEventListener('change', () => {
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
uploadFn(file)
|
|
||||||
.then((url) => {
|
|
||||||
editor.chain().focus().setImage({ src: url }).run()
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
console.error('[SlashCommand] Upload failed:', msg)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// click() 会立即触发原生文件选择器;回调在用户选择文件后异步执行。
|
|
||||||
input.click()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
COMMANDS.push(
|
|
||||||
{
|
{
|
||||||
title: '图片链接',
|
title: '图片',
|
||||||
description: '通过 URL 插入图片',
|
description: '插入图片',
|
||||||
icon: '🖼',
|
icon: '🖼',
|
||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
const url = window.prompt('输入图片 URL')
|
const url = window.prompt('输入图片 URL')
|
||||||
@ -175,51 +112,9 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
]
|
||||||
|
|
||||||
return [
|
const SlashCommandPluginKey = new PluginKey('slashCommand')
|
||||||
Suggestion<CommandItem>({
|
|
||||||
pluginKey: SlashCommandPluginKey,
|
|
||||||
editor: this.editor,
|
|
||||||
char: '/',
|
|
||||||
items: ({ query }) => {
|
|
||||||
return COMMANDS.filter(
|
|
||||||
(item) =>
|
|
||||||
item.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
||||||
item.description.toLowerCase().includes(query.toLowerCase())
|
|
||||||
)
|
|
||||||
},
|
|
||||||
render() {
|
|
||||||
let popup: ReturnType<typeof createPopup> | null = null
|
|
||||||
|
|
||||||
return {
|
|
||||||
onStart(props) {
|
|
||||||
popup = createPopup(props)
|
|
||||||
},
|
|
||||||
onUpdate(props) {
|
|
||||||
if (!popup) return
|
|
||||||
popup.updateItems(props.items)
|
|
||||||
popup.updatePosition()
|
|
||||||
},
|
|
||||||
onKeyDown(props) {
|
|
||||||
if (!popup) return false
|
|
||||||
return popup.onKeyDown(props)
|
|
||||||
},
|
|
||||||
onExit() {
|
|
||||||
if (popup) {
|
|
||||||
popup.destroy()
|
|
||||||
popup = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
command: ({ editor, range, props: item }) => {
|
|
||||||
item.command({ editor, range })
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function createPopup(props: SuggestionProps<CommandItem>) {
|
function createPopup(props: SuggestionProps<CommandItem>) {
|
||||||
const component = document.createElement('div')
|
const component = document.createElement('div')
|
||||||
@ -327,3 +222,51 @@ function createPopup(props: SuggestionProps<CommandItem>) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SlashCommand = Extension.create({
|
||||||
|
name: 'slashCommand',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion<CommandItem>({
|
||||||
|
pluginKey: SlashCommandPluginKey,
|
||||||
|
editor: this.editor,
|
||||||
|
char: '/',
|
||||||
|
items: ({ query }) => {
|
||||||
|
return COMMANDS.filter(
|
||||||
|
(item) =>
|
||||||
|
item.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
item.description.toLowerCase().includes(query.toLowerCase())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
let popup: ReturnType<typeof createPopup> | null = null
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart(props) {
|
||||||
|
popup = createPopup(props)
|
||||||
|
},
|
||||||
|
onUpdate(props) {
|
||||||
|
if (!popup) return
|
||||||
|
popup.updateItems(props.items)
|
||||||
|
popup.updatePosition()
|
||||||
|
},
|
||||||
|
onKeyDown(props) {
|
||||||
|
if (!popup) return false
|
||||||
|
return popup.onKeyDown(props)
|
||||||
|
},
|
||||||
|
onExit() {
|
||||||
|
if (popup) {
|
||||||
|
popup.destroy()
|
||||||
|
popup = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
command: ({ editor, range, props: item }) => {
|
||||||
|
item.command({ editor, range })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@ -78,15 +78,22 @@ pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 将 UTC 时间格式化为相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。
|
/// 将 UTC 时间格式化为相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。
|
||||||
///
|
|
||||||
/// 分档规则与前端 `crate::hooks::comment_storage::relative_label_from_millis` 完全一致,
|
|
||||||
/// 通过共享分档函数保证服务端预渲染与前端实时计算的口径统一。
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
|
pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let delta_millis = now.signed_duration_since(dt).num_milliseconds();
|
let diff = now.signed_duration_since(dt);
|
||||||
let iso = dt.to_rfc3339();
|
|
||||||
crate::hooks::comment_storage::relative_label_from_millis(delta_millis, &iso).0
|
if diff.num_seconds() < 60 {
|
||||||
|
"刚刚".to_string()
|
||||||
|
} else if diff.num_minutes() < 60 {
|
||||||
|
format!("{} 分钟前", diff.num_minutes())
|
||||||
|
} else if diff.num_hours() < 24 {
|
||||||
|
format!("{} 小时前", diff.num_hours())
|
||||||
|
} else if diff.num_days() < 30 {
|
||||||
|
format!("{} 天前", diff.num_days())
|
||||||
|
} else {
|
||||||
|
dt.format("%Y-%m-%d").to_string()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 校验评论作者昵称:非空且不超过 50 字符。
|
/// 校验评论作者昵称:非空且不超过 50 字符。
|
||||||
|
|||||||
@ -5,9 +5,7 @@
|
|||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::hooks::comment_storage::{
|
use crate::hooks::comment_storage::{render_pending_content, PendingComment};
|
||||||
format_relative_time_iso, render_pending_content, PendingComment,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 待审核评论项组件。
|
/// 待审核评论项组件。
|
||||||
///
|
///
|
||||||
@ -15,7 +13,7 @@ use crate::hooks::comment_storage::{
|
|||||||
/// - `comment`:待审核评论数据
|
/// - `comment`:待审核评论数据
|
||||||
/// - `post_id`:所属文章 ID(当前未使用,保留用于未来扩展)
|
/// - `post_id`:所属文章 ID(当前未使用,保留用于未来扩展)
|
||||||
///
|
///
|
||||||
/// 展示内容包括:作者头像/链接、基于创建时间动态计算的相对时间、审核中徽章、Markdown 渲染内容。
|
/// 展示内容包括:作者头像/链接、"刚刚"时间占位、审核中徽章、Markdown 渲染内容。
|
||||||
/// 深度最大展示 6 层缩进,孤儿评论深度会被修正为 0。
|
/// 深度最大展示 6 层缩进,孤儿评论深度会被修正为 0。
|
||||||
#[component]
|
#[component]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
@ -29,8 +27,6 @@ pub fn PendingCommentItem(comment: PendingComment, post_id: i32) -> Element {
|
|||||||
|
|
||||||
let indent = depth.min(6) * 24;
|
let indent = depth.min(6) * 24;
|
||||||
let content_html = render_pending_content(&comment.content_md);
|
let content_html = render_pending_content(&comment.content_md);
|
||||||
// 基于创建时间实时计算相对时间,避免"刚刚"永久显示。
|
|
||||||
let relative_time = format_relative_time_iso(&comment.created_at);
|
|
||||||
|
|
||||||
// 作者名展示为链接或普通文本
|
// 作者名展示为链接或普通文本
|
||||||
let author_element = match &comment.author_url {
|
let author_element = match &comment.author_url {
|
||||||
@ -70,8 +66,7 @@ pub fn PendingCommentItem(comment: PendingComment, post_id: i32) -> Element {
|
|||||||
span { class: "text-paper-tertiary", "·" }
|
span { class: "text-paper-tertiary", "·" }
|
||||||
span {
|
span {
|
||||||
class: "text-paper-tertiary",
|
class: "text-paper-tertiary",
|
||||||
title: "{comment.created_at}",
|
"刚刚"
|
||||||
"{relative_time}"
|
|
||||||
}
|
}
|
||||||
span {
|
span {
|
||||||
class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||||
|
|||||||
@ -235,59 +235,6 @@ fn load_all_pending() -> PendingMap {
|
|||||||
serde_json::from_str(&json).unwrap_or_default()
|
serde_json::from_str(&json).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相对时间分档:根据"距现在的毫秒数"返回 (相对文本, 绝对日期 YYYY-MM-DD)。
|
|
||||||
///
|
|
||||||
/// 分档规则与服务端 `format_relative_time` 完全一致,前端在展示待审核评论时复用,
|
|
||||||
/// 保证两类评论的时间展示口径统一。返回绝对日期用于 `title` 悬浮提示。
|
|
||||||
///
|
|
||||||
/// - `delta_millis`:目标时间与"现在"的差值(毫秒)。正值表示过去,负值表示未来(兜底按刚刚处理)。
|
|
||||||
/// - `created_iso`:评论的 RFC3339 创建时间,用于兜底生成绝对日期。
|
|
||||||
pub fn relative_label_from_millis(delta_millis: i64, created_iso: &str) -> (String, String) {
|
|
||||||
let seconds = delta_millis / 1000;
|
|
||||||
|
|
||||||
let label = if seconds < 60 {
|
|
||||||
"刚刚".to_string()
|
|
||||||
} else {
|
|
||||||
let minutes = seconds / 60;
|
|
||||||
if minutes < 60 {
|
|
||||||
format!("{} 分钟前", minutes)
|
|
||||||
} else {
|
|
||||||
let hours = minutes / 60;
|
|
||||||
if hours < 24 {
|
|
||||||
format!("{} 小时前", hours)
|
|
||||||
} else {
|
|
||||||
let days = hours / 24;
|
|
||||||
if days < 30 {
|
|
||||||
format!("{} 天前", days)
|
|
||||||
} else {
|
|
||||||
// 超过 30 天直接显示日期,下方 absolute 复用
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 绝对日期:优先解析 ISO;解析失败时退化为空串,避免组件报错。
|
|
||||||
let absolute = DateTime::parse_from_rfc3339(created_iso)
|
|
||||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let label = if label.is_empty() { absolute.clone() } else { label };
|
|
||||||
(label, absolute)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 前端友好的相对时间格式化:返回相对文本,用于展示待审核评论的创建时间。
|
|
||||||
///
|
|
||||||
/// 这是 `relative_label_from_millis` 的薄封装,仅返回相对文本。
|
|
||||||
pub fn format_relative_time_iso(created_iso: &str) -> String {
|
|
||||||
// 解析失败时退化为 "刚刚",避免组件崩溃。
|
|
||||||
let Ok(dt) = DateTime::parse_from_rfc3339(created_iso) else {
|
|
||||||
return "刚刚".to_string();
|
|
||||||
};
|
|
||||||
let delta_millis = now_millis() - dt.timestamp_millis();
|
|
||||||
relative_label_from_millis(delta_millis, created_iso).0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HTML 转义辅助函数。
|
/// HTML 转义辅助函数。
|
||||||
pub(crate) fn escape_html(input: &str) -> String {
|
pub(crate) fn escape_html(input: &str) -> String {
|
||||||
input
|
input
|
||||||
@ -303,76 +250,3 @@ pub fn render_pending_content(md: &str) -> String {
|
|||||||
let escaped = escape_html(md);
|
let escaped = escape_html(md);
|
||||||
escaped.replace('\n', "<br>")
|
escaped.replace('\n', "<br>")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const ISO: &str = "2026-06-22T05:43:57.565+00:00";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_label_just_now_under_60s() {
|
|
||||||
let (label, _) = relative_label_from_millis(0, ISO);
|
|
||||||
assert_eq!(label, "刚刚");
|
|
||||||
let (label, _) = relative_label_from_millis(59_999, ISO);
|
|
||||||
assert_eq!(label, "刚刚");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_label_minutes() {
|
|
||||||
let (label, _) = relative_label_from_millis(60_000, ISO);
|
|
||||||
assert_eq!(label, "1 分钟前");
|
|
||||||
let (label, _) = relative_label_from_millis(5 * 60_000, ISO);
|
|
||||||
assert_eq!(label, "5 分钟前");
|
|
||||||
let (label, _) = relative_label_from_millis(59 * 60_000, ISO);
|
|
||||||
assert_eq!(label, "59 分钟前");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_label_hours() {
|
|
||||||
let (label, _) = relative_label_from_millis(60 * 60_000, ISO);
|
|
||||||
assert_eq!(label, "1 小时前");
|
|
||||||
let (label, _) = relative_label_from_millis(3 * 3600_000, ISO);
|
|
||||||
assert_eq!(label, "3 小时前");
|
|
||||||
let (label, _) = relative_label_from_millis(23 * 3600_000, ISO);
|
|
||||||
assert_eq!(label, "23 小时前");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_label_days() {
|
|
||||||
let (label, _) = relative_label_from_millis(24 * 3600_000, ISO);
|
|
||||||
assert_eq!(label, "1 天前");
|
|
||||||
let (label, _) = relative_label_from_millis(7 * 24 * 3600_000, ISO);
|
|
||||||
assert_eq!(label, "7 天前");
|
|
||||||
let (label, _) = relative_label_from_millis(29 * 24 * 3600_000, ISO);
|
|
||||||
assert_eq!(label, "29 天前");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_label_falls_back_to_date_over_30_days() {
|
|
||||||
let (label, absolute) = relative_label_from_millis(60 * 24 * 3600_000, ISO);
|
|
||||||
assert_eq!(label, "2026-06-22");
|
|
||||||
assert_eq!(absolute, "2026-06-22");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_label_future_falls_back_to_just_now() {
|
|
||||||
// 未来时间差为负,秒数 < 60,归为"刚刚"。
|
|
||||||
let (label, _) = relative_label_from_millis(-5_000, ISO);
|
|
||||||
assert_eq!(label, "刚刚");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_label_invalid_iso_still_returns_absolute_empty() {
|
|
||||||
// 无法解析时 absolute 为空,但分档逻辑仍按 delta 决定。
|
|
||||||
let (label, absolute) = relative_label_from_millis(0, "not-a-date");
|
|
||||||
assert_eq!(label, "刚刚");
|
|
||||||
assert_eq!(absolute, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_relative_time_iso_invalid_iso_falls_back() {
|
|
||||||
// 解析失败退化为"刚刚",不 panic。
|
|
||||||
assert_eq!(format_relative_time_iso("not-a-date"), "刚刚");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user