Compare commits
24 Commits
c60e4e08d7
...
b7afd12538
| Author | SHA1 | Date | |
|---|---|---|---|
| b7afd12538 | |||
| 6ce8915e6c | |||
| ae6d80c9cc | |||
| 914f168551 | |||
| 480aa92bb4 | |||
| c6d27f0ae0 | |||
| d549f4a476 | |||
| 61a001c03b | |||
| a9f0e8d16b | |||
| e8be1967db | |||
| 603a7f68cb | |||
| 188e607ec2 | |||
| 6986a62bb5 | |||
| 63e352d471 | |||
| 55bef35d1f | |||
| 4ba4fedb23 | |||
| 0ba8f9f835 | |||
| f5e19def1a | |||
| b6de75ee97 | |||
| c4c27a34c5 | |||
| 65e090da27 | |||
| 054d7450f7 | |||
| 959cc1fb0e | |||
| 863ec5ea52 |
@ -68,3 +68,93 @@ hey -c 100 -n 100000 http://localhost:8080/
|
|||||||
## CI
|
## CI
|
||||||
|
|
||||||
https://git.rua.plus/api/v1/repos/xfy/yggdrasil/actions/tasks
|
https://git.rua.plus/api/v1/repos/xfy/yggdrasil/actions/tasks
|
||||||
|
|
||||||
|
## 代码高亮(Syntax Highlighting)
|
||||||
|
|
||||||
|
代码高亮基于 [syntect](https://docs.rs/syntect),将代码块渲染成带 CSS class 的 HTML,配合 `public/highlight.css` 的主题规则着色。涉及四个部分:
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| `syntaxes/*.sublime-syntax` | 各语言的语法定义(Sublime Text 格式) |
|
||||||
|
| `themes/*.tmTheme` | Catppuccin Latte(浅)/ Mocha(深)配色主题 |
|
||||||
|
| `src/highlight.rs` | 运行时高亮入口,加载语法集并渲染 HTML |
|
||||||
|
| `src/bin/generate_highlight_css.rs` | 构建期从主题生成 `public/highlight.css` |
|
||||||
|
|
||||||
|
### 渲染时机(关键)
|
||||||
|
|
||||||
|
**文章 HTML 在保存时渲染一次,固化进数据库的 `posts.content_html` 字段,读取时不再重新渲染。** `highlight_code` 只在 `render_markdown_enhanced` 内被调用,而后者只在文章创建/更新(`src/api/posts/create.rs`、`update.rs`)时触发。
|
||||||
|
|
||||||
|
这意味着:**修改语法定义后,已存在的文章不会自动刷新**,必须手动重建(见下文「刷新已有文章」)。
|
||||||
|
|
||||||
|
### 添加 / 修复某个语言的高亮
|
||||||
|
|
||||||
|
1. **编辑语法定义**:修改 `syntaxes/<Lang>.sublime-syntax`(参考同目录 `Kotlin.sublime-syntax` 的完整写法)。核心是 `expression` 上下文——它必须 `include` 所有需要识别的元素:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
expression:
|
||||||
|
- include: whitespace
|
||||||
|
- include: comments
|
||||||
|
- include: string-literal
|
||||||
|
- include: declaration-keywords # 关键字
|
||||||
|
- include: types # 类型
|
||||||
|
- include: function-declaration # 函数声明(须在 declaration-keywords 之前)
|
||||||
|
- include: function-calls
|
||||||
|
- include: types-and-identifiers
|
||||||
|
```
|
||||||
|
|
||||||
|
> **include 顺序很重要**:`declaration-keywords` 的裸关键字匹配会吃掉 `func name` 中的 `func`,导致后续 `function-declaration` 的多 token 匹配失败。让多 token 的规则(如 `func\s+name`)排在单 token 规则之前。
|
||||||
|
|
||||||
|
2. **验证 YAML 合法**(syntect 加载失败只会 `warn`,不会 panic,容易静默丢语法):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import yaml; yaml.safe_load(open('syntaxes/Swift.sublime-syntax')); print('OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **加回归测试**:在 `src/highlight.rs` 的 `tests` 模块里加测试,断言关键字/类型/函数等产出对应的 CSS class:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_swift_keyword_and_func() {
|
||||||
|
let result = highlight_code("func greet() {}", Some("swift"));
|
||||||
|
assert!(result.contains("keyword"));
|
||||||
|
assert!(result.contains("name function") || result.contains("variable function"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **运行测试**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --features server highlight_code_<lang> -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
`--nocapture` 会打印 HTML 输出,方便人眼检查每个 token 的 class 是否正确。
|
||||||
|
|
||||||
|
5. **重新生成 highlight.css**(如果新增了 scope 类型才需要,已有 scope 的颜色规则会自动覆盖):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --features server --bin generate_highlight_css
|
||||||
|
```
|
||||||
|
|
||||||
|
### 刷新已有文章
|
||||||
|
|
||||||
|
修改语法后,用 `/admin/posts` 页面的按钮重建文章 HTML:
|
||||||
|
|
||||||
|
- **重建内容**:仅重建 `content_html` 为空的文章
|
||||||
|
- **重建全部**:重建所有文章(含已有内容)—— 语法/渲染逻辑升级后用这个
|
||||||
|
|
||||||
|
底层调用 `rebuild_content_html(rebuild_all: bool)` server function(`src/api/posts/rebuild.rs`),单批上限 500 篇,渲染异常会被捕获汇总,不会因单篇失败中断整批。
|
||||||
|
|
||||||
|
### 调试「高亮不生效」
|
||||||
|
|
||||||
|
排查顺序(从快到慢):
|
||||||
|
|
||||||
|
1. **先跑测试**:`cargo test --features server highlight_code_<lang> -- --nocapture`。测试直接调用 `highlight_code`,绕过 DB 和缓存,能立刻判断是语法定义问题还是运行时问题。
|
||||||
|
2. **查 DB**:测试通过但页面仍不对,查数据库里该文章的 `content_html` 是否含期望的 class——多数情况是旧 HTML 固化在 DB,需要重建:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT (LENGTH(content_html) - LENGTH(REPLACE(content_html, 'keyword', ''))) / LENGTH('keyword')
|
||||||
|
FROM posts WHERE slug = '<slug>';
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **清 SSR 缓存**:`IncrementalRenderer` 会把渲染结果持久化到 `static/` 目录(如 `static/post/<slug>/index/*.html`)。删除后重启服务器才会重新渲染。
|
||||||
|
|
||||||
|
|||||||
22
input.css
22
input.css
@ -640,28 +640,6 @@
|
|||||||
color: var(--color-paper-primary);
|
color: var(--color-paper-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: white;
|
|
||||||
background: var(--color-paper-accent);
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease-out;
|
|
||||||
}
|
|
||||||
.btn-primary:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
.btn-primary:active {
|
|
||||||
transform: translateY(0) scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-card-accent {
|
.post-card-accent {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,10 @@ class TiptapEditorInstance {
|
|||||||
private editor: Editor | null = null
|
private editor: Editor | null = null
|
||||||
private container: HTMLElement
|
private container: HTMLElement
|
||||||
private options: EditorOptions
|
private options: EditorOptions
|
||||||
|
// 源码模式相关状态
|
||||||
|
private isSourceMode = false
|
||||||
|
private sourceTextarea: HTMLTextAreaElement | null = null
|
||||||
|
private toggleButton: HTMLButtonElement | null = null
|
||||||
|
|
||||||
constructor(container: HTMLElement, options: EditorOptions = {}) {
|
constructor(container: HTMLElement, options: EditorOptions = {}) {
|
||||||
this.container = container
|
this.container = container
|
||||||
@ -35,6 +39,30 @@ class TiptapEditorInstance {
|
|||||||
el.className = 'tiptap-editor'
|
el.className = 'tiptap-editor'
|
||||||
this.container.appendChild(el)
|
this.container.appendChild(el)
|
||||||
|
|
||||||
|
// 源码模式切换按钮:悬浮于编辑器右上角
|
||||||
|
this.toggleButton = document.createElement('button')
|
||||||
|
this.toggleButton.className = 'tiptap-toggle-btn'
|
||||||
|
this.toggleButton.type = 'button'
|
||||||
|
this.toggleButton.title = '切换 Markdown 源码'
|
||||||
|
this.toggleButton.textContent = '</>'
|
||||||
|
this.toggleButton.addEventListener('click', () => this.toggleSource())
|
||||||
|
el.appendChild(this.toggleButton)
|
||||||
|
|
||||||
|
// 源码模式 textarea:初始隐藏,与 ProseMirror 共用同一区域
|
||||||
|
this.sourceTextarea = document.createElement('textarea')
|
||||||
|
this.sourceTextarea.className = 'tiptap-source-textarea'
|
||||||
|
this.sourceTextarea.hidden = true
|
||||||
|
this.sourceTextarea.placeholder = '在此输入 Markdown 源码...'
|
||||||
|
this.sourceTextarea.spellcheck = false
|
||||||
|
this.sourceTextarea.addEventListener('input', () => {
|
||||||
|
// 保持全局缓存与 onUpdate 回调一致
|
||||||
|
window.__tiptap_content = this.sourceTextarea!.value
|
||||||
|
if (this.options.onUpdate) {
|
||||||
|
this.options.onUpdate(this.sourceTextarea!.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
el.appendChild(this.sourceTextarea)
|
||||||
|
|
||||||
this.editor = new Editor({
|
this.editor = new Editor({
|
||||||
element: el,
|
element: el,
|
||||||
extensions: [
|
extensions: [
|
||||||
@ -114,9 +142,76 @@ class TiptapEditorInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMarkdown(): string {
|
getMarkdown(): string {
|
||||||
|
// 源码模式下直接返回 textarea 内容,确保提交逻辑无需感知视图模式
|
||||||
|
if (this.isSourceMode && this.sourceTextarea) {
|
||||||
|
return this.sourceTextarea.value
|
||||||
|
}
|
||||||
return this.editor?.getMarkdown() || ''
|
return this.editor?.getMarkdown() || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在富文本模式与 Markdown 源码模式之间切换。
|
||||||
|
* 切换时同步内容:富文本 → 源码用 getMarkdown 导出;源码 → 富文本用 setMarkdown 回填。
|
||||||
|
*/
|
||||||
|
toggleSource(): void {
|
||||||
|
if (!this.editor || !this.sourceTextarea || !this.toggleButton) return
|
||||||
|
|
||||||
|
// ProseMirror 的实际 DOM 节点(.ProseMirror),用于切换显隐。
|
||||||
|
const proseMirrorDom = this.editor.view.dom
|
||||||
|
if (!this.isSourceMode) {
|
||||||
|
// 富文本 → 源码:导出当前 Markdown 到 textarea
|
||||||
|
this.sourceTextarea.value = this.editor.getMarkdown()
|
||||||
|
// 必须在 display:'none' 之前读取滚动比例——隐藏后 scrollTop 会被浏览器归零
|
||||||
|
const pmRatio = this.getScrollRatio(proseMirrorDom)
|
||||||
|
proseMirrorDom.style.display = 'none'
|
||||||
|
this.sourceTextarea.hidden = false
|
||||||
|
this.applyScrollRatio(this.sourceTextarea, pmRatio)
|
||||||
|
// focus() 会触发浏览器把光标所在行滚动进可视区域,光标默认在内容末尾,
|
||||||
|
// 会把 textarea 拉到底部覆盖上面设好的滚动位置。因此先记录、focus 后再恢复。
|
||||||
|
const scrollTopBeforeFocus = this.sourceTextarea.scrollTop
|
||||||
|
this.sourceTextarea.focus()
|
||||||
|
this.sourceTextarea.scrollTop = scrollTopBeforeFocus
|
||||||
|
this.toggleButton.textContent = '✎'
|
||||||
|
this.toggleButton.title = '切换富文本'
|
||||||
|
this.isSourceMode = true
|
||||||
|
} else {
|
||||||
|
// 源码 → 富文本:把 textarea 内容回填到编辑器
|
||||||
|
const md = this.sourceTextarea.value
|
||||||
|
// 先记录源码视图的滚动比例(setContent 会重建文档,必须在替换前拿到比例)
|
||||||
|
const sourceRatio = this.getScrollRatio(this.sourceTextarea)
|
||||||
|
this.setMarkdown(md)
|
||||||
|
this.sourceTextarea.hidden = true
|
||||||
|
proseMirrorDom.style.display = ''
|
||||||
|
// 等待 DOM 布局更新后,按比例同步富文本视图滚动位置
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.applyScrollRatio(proseMirrorDom, sourceRatio)
|
||||||
|
})
|
||||||
|
this.toggleButton.textContent = '</>'
|
||||||
|
this.toggleButton.title = '切换 Markdown 源码'
|
||||||
|
this.isSourceMode = false
|
||||||
|
// 注意:不调用 editor.commands.focus(),它会强制滚动到光标位置(默认文档末尾),破坏比例同步
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取可滚动容器的滚动比例(0~1)。
|
||||||
|
* 比例 = scrollTop / 可滚动总距离,避免两种模式内容高度不同导致像素无法直接对应。
|
||||||
|
*/
|
||||||
|
private getScrollRatio(el: HTMLElement): number {
|
||||||
|
const max = el.scrollHeight - el.clientHeight
|
||||||
|
if (max <= 0) return 0
|
||||||
|
return el.scrollTop / max
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按比例设置目标容器的滚动位置。
|
||||||
|
*/
|
||||||
|
private applyScrollRatio(el: HTMLElement, ratio: number): void {
|
||||||
|
const max = el.scrollHeight - el.clientHeight
|
||||||
|
if (max <= 0) return
|
||||||
|
el.scrollTop = max * ratio
|
||||||
|
}
|
||||||
|
|
||||||
setMarkdown(content: string): void {
|
setMarkdown(content: string): void {
|
||||||
this.editor?.commands.setContent(content, { emitUpdate: false, contentType: 'markdown' })
|
this.editor?.commands.setContent(content, { emitUpdate: false, contentType: 'markdown' })
|
||||||
}
|
}
|
||||||
@ -140,6 +235,10 @@ class TiptapEditorInstance {
|
|||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.editor?.destroy()
|
this.editor?.destroy()
|
||||||
this.editor = null
|
this.editor = null
|
||||||
|
// 清理源码模式相关引用(容器 innerHTML 已清空,DOM 会随之移除)
|
||||||
|
this.sourceTextarea = null
|
||||||
|
this.toggleButton = null
|
||||||
|
this.isSourceMode = false
|
||||||
this.container.innerHTML = ''
|
this.container.innerHTML = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -425,3 +425,73 @@
|
|||||||
.dark .tiptap-editor ul[data-type="taskList"] li > label input[type="checkbox"] {
|
.dark .tiptap-editor ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||||
accent-color: #58a6ff;
|
accent-color: #58a6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== Source Toggle Button ========== */
|
||||||
|
.tiptap-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
color: #6a737d;
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: opacity 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-toggle-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-toggle-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #9b9c9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-toggle-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Source Textarea ========== */
|
||||||
|
.tiptap-source-textarea {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 24px 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #2c2c2c;
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-source-textarea::placeholder {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-source-textarea {
|
||||||
|
color: #dadadb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-source-textarea::placeholder {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|||||||
14
migrations/006_settings.sql
Normal file
14
migrations/006_settings.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- 回收站与站点配置键值表。
|
||||||
|
-- 采用简单键值结构而非列式配置,便于后续扩展更多设置项。
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 回填回收站默认配置:自动清理默认关闭,保留期 30 天。
|
||||||
|
-- ON CONFLICT 保证重复执行迁移安全。
|
||||||
|
INSERT INTO settings (key, value) VALUES
|
||||||
|
('trash_auto_purge_enabled', 'false'),
|
||||||
|
('trash_retention_days', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@ -20,6 +20,8 @@ pub mod posts;
|
|||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
/// HTML 消毒器。
|
/// HTML 消毒器。
|
||||||
pub mod sanitizer;
|
pub mod sanitizer;
|
||||||
|
/// 回收站与站点配置接口。
|
||||||
|
pub mod settings;
|
||||||
/// URL slug 生成与校验。
|
/// URL slug 生成与校验。
|
||||||
pub mod slug;
|
pub mod slug;
|
||||||
/// 图片上传的 Axum 处理器。
|
/// 图片上传的 Axum 处理器。
|
||||||
|
|||||||
@ -50,6 +50,7 @@ pub(super) async fn row_to_post_list(
|
|||||||
published_at: row.get("published_at"),
|
published_at: row.get("published_at"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
updated_at: row.get("updated_at"),
|
updated_at: row.get("updated_at"),
|
||||||
|
deleted_at: row.try_get("deleted_at").ok(),
|
||||||
tags,
|
tags,
|
||||||
cover_image: row.get("cover_image"),
|
cover_image: row.get("cover_image"),
|
||||||
reading_time: (word_count / 200).max(1),
|
reading_time: (word_count / 200).max(1),
|
||||||
@ -143,6 +144,7 @@ pub(super) async fn row_to_post_full(
|
|||||||
published_at: row.get("published_at"),
|
published_at: row.get("published_at"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
updated_at: row.get("updated_at"),
|
updated_at: row.get("updated_at"),
|
||||||
|
deleted_at: row.try_get("deleted_at").ok(),
|
||||||
tags,
|
tags,
|
||||||
cover_image: row.get("cover_image"),
|
cover_image: row.get("cover_image"),
|
||||||
reading_time: (word_count / 200).max(1),
|
reading_time: (word_count / 200).max(1),
|
||||||
|
|||||||
@ -171,6 +171,65 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, Se
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取回收站中已软删除的文章列表。
|
||||||
|
///
|
||||||
|
/// 需要 admin 权限;按删除时间降序,不走缓存。
|
||||||
|
#[server(ListDeletedPosts, "/api")]
|
||||||
|
pub async fn list_deleted_posts(
|
||||||
|
page: i32,
|
||||||
|
per_page: i32,
|
||||||
|
) -> Result<PostListResponse, ServerFnError> {
|
||||||
|
// 与 list_posts 一致的分页钳制。
|
||||||
|
let (page, per_page) = clamp_pagination(page, per_page);
|
||||||
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
let count_row = client
|
||||||
|
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL", &[])
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
let total: i64 = count_row.get(0);
|
||||||
|
|
||||||
|
let offset = ((page - 1).max(0) as i64) * (per_page as i64);
|
||||||
|
let limit = per_page as i64;
|
||||||
|
let rows = client
|
||||||
|
.query(
|
||||||
|
"SELECT
|
||||||
|
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
|
||||||
|
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, p.deleted_at,
|
||||||
|
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
|
||||||
|
FROM posts p
|
||||||
|
LEFT JOIN post_tags pt ON p.id = pt.post_id
|
||||||
|
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||||
|
WHERE p.deleted_at IS NOT NULL
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY p.deleted_at DESC
|
||||||
|
LIMIT $1 OFFSET $2",
|
||||||
|
&[&limit, &offset],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
let mut posts = Vec::new();
|
||||||
|
for row in &rows {
|
||||||
|
posts.push(row_to_post_list(&client, row).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PostListResponse { posts, total })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
{
|
||||||
|
Ok(PostListResponse {
|
||||||
|
posts: Vec::new(),
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取指定标签下的已发布文章列表。
|
/// 获取指定标签下的已发布文章列表。
|
||||||
///
|
///
|
||||||
/// 优先命中缓存;当前实现返回全部匹配文章,因此 total 用 posts.len() 计算。
|
/// 优先命中缓存;当前实现返回全部匹配文章,因此 total 用 posts.len() 计算。
|
||||||
|
|||||||
@ -16,6 +16,7 @@ mod stats;
|
|||||||
mod tags;
|
mod tags;
|
||||||
mod types;
|
mod types;
|
||||||
mod update;
|
mod update;
|
||||||
|
mod trash;
|
||||||
|
|
||||||
/// 创建新文章。
|
/// 创建新文章。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@ -25,6 +26,9 @@ pub use delete::delete_post;
|
|||||||
/// 获取管理员视角的全部文章分页列表。
|
/// 获取管理员视角的全部文章分页列表。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use list::list_posts;
|
pub use list::list_posts;
|
||||||
|
/// 获取回收站中已软删除的文章列表。
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use list::list_deleted_posts;
|
||||||
/// 获取已发布文章分页列表。
|
/// 获取已发布文章分页列表。
|
||||||
pub use list::{get_posts_by_tag, list_published_posts};
|
pub use list::{get_posts_by_tag, list_published_posts};
|
||||||
/// 根据 id 获取文章详情。
|
/// 根据 id 获取文章详情。
|
||||||
@ -44,6 +48,9 @@ pub use types::*;
|
|||||||
/// 更新指定文章。
|
/// 更新指定文章。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use update::update_post;
|
pub use update::update_post;
|
||||||
|
/// 恢复已删除文章。
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use trash::{batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post};
|
||||||
|
|
||||||
/// 将 Markdown 渲染为增强 HTML(含目录)。
|
/// 将 Markdown 渲染为增强 HTML(含目录)。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
|||||||
@ -73,7 +73,7 @@ pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, Server
|
|||||||
let row = client
|
let row = client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT
|
"SELECT
|
||||||
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
|
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.toc_html,
|
||||||
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
|
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
|
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
|
||||||
prev.title as prev_title, prev.slug as prev_slug,
|
prev.title as prev_title, prev.slug as prev_slug,
|
||||||
|
|||||||
286
src/api/posts/trash.rs
Normal file
286
src/api/posts/trash.rs
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
//! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。
|
||||||
|
//!
|
||||||
|
//! 所有接口需要 admin 权限,操作后清空全部文章相关缓存。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use super::helpers::get_current_admin_user;
|
||||||
|
use super::types::CreatePostResponse;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use crate::api::error::AppError;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use crate::api::slug::ensure_unique_slug;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use crate::db::pool::get_conn;
|
||||||
|
|
||||||
|
/// 恢复一篇已删除的文章(将 deleted_at 置空)。
|
||||||
|
///
|
||||||
|
/// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。
|
||||||
|
#[server(RestorePost, "/api")]
|
||||||
|
pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
|
||||||
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 读取待恢复文章的当前 slug 与是否确已删除。
|
||||||
|
let row = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||||
|
&[&post_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Ok(CreatePostResponse {
|
||||||
|
success: false,
|
||||||
|
message: "文章不在回收站".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_slug: String = row.get("slug");
|
||||||
|
|
||||||
|
// 恢复时确保 slug 在未删除文章中唯一(自动加后缀)。
|
||||||
|
let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(post_id)).await?;
|
||||||
|
|
||||||
|
// 置空 deleted_at,并更新 slug(可能已加后缀)。
|
||||||
|
let result = client
|
||||||
|
.execute(
|
||||||
|
"UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
|
||||||
|
&[&new_slug, &post_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
return Ok(CreatePostResponse {
|
||||||
|
success: false,
|
||||||
|
message: "文章不在回收站".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::cache::invalidate_all_post_caches();
|
||||||
|
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: true,
|
||||||
|
message: "恢复成功".to_string(),
|
||||||
|
post_id: Some(post_id),
|
||||||
|
slug: Some(new_slug),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
{
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: false,
|
||||||
|
message: "server only".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 彻底删除一篇已删除的文章(物理删除,不可恢复)。
|
||||||
|
///
|
||||||
|
/// 注意:仅删除数据库记录,不删除已上传的图片文件。
|
||||||
|
/// post_tags 关联因外键 ON DELETE CASCADE 自动清理。
|
||||||
|
#[server(PurgePost, "/api")]
|
||||||
|
pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
|
||||||
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
let result = client
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||||
|
&[&post_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
return Ok(CreatePostResponse {
|
||||||
|
success: false,
|
||||||
|
message: "文章不在回收站".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::cache::invalidate_all_post_caches();
|
||||||
|
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: true,
|
||||||
|
message: "彻底删除成功".to_string(),
|
||||||
|
post_id: Some(post_id),
|
||||||
|
slug: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
{
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: false,
|
||||||
|
message: "server only".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量恢复文章。
|
||||||
|
#[server(BatchRestorePosts, "/api")]
|
||||||
|
pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse, ServerFnError> {
|
||||||
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
if post_ids.is_empty() {
|
||||||
|
return Ok(CreatePostResponse {
|
||||||
|
success: true,
|
||||||
|
message: "无操作".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 逐条恢复,slug 冲突时自动加后缀。
|
||||||
|
let mut restored = 0u64;
|
||||||
|
for id in &post_ids {
|
||||||
|
let row = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||||
|
&[&id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
if let Some(row) = row {
|
||||||
|
let current_slug: String = row.get("slug");
|
||||||
|
let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(*id)).await?;
|
||||||
|
let n = client
|
||||||
|
.execute(
|
||||||
|
"UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
|
||||||
|
&[&new_slug, &id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
restored += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::cache::invalidate_all_post_caches();
|
||||||
|
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("已恢复 {restored} 篇"),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
{
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: false,
|
||||||
|
message: "server only".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量彻底删除文章。
|
||||||
|
#[server(BatchPurgePosts, "/api")]
|
||||||
|
pub async fn batch_purge_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse, ServerFnError> {
|
||||||
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
if post_ids.is_empty() {
|
||||||
|
return Ok(CreatePostResponse {
|
||||||
|
success: true,
|
||||||
|
message: "无操作".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
let total = post_ids.len() as i64;
|
||||||
|
let result = client
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL",
|
||||||
|
&[&post_ids],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
crate::cache::invalidate_all_post_caches();
|
||||||
|
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("已彻底删除 {result}/{total} 篇"),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
{
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: false,
|
||||||
|
message: "server only".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空回收站:彻底删除所有已软删除的文章。
|
||||||
|
#[server(EmptyTrash, "/api")]
|
||||||
|
pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
|
||||||
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
let result = client
|
||||||
|
.execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[])
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
crate::cache::invalidate_all_post_caches();
|
||||||
|
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("已清空回收站({result} 篇)"),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
{
|
||||||
|
Ok(CreatePostResponse {
|
||||||
|
success: false,
|
||||||
|
message: "server only".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/api/settings.rs
Normal file
108
src/api/settings.rs
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
//! 回收站配置接口:读取与更新自动清理设置。
|
||||||
|
//!
|
||||||
|
//! 所有接口需要 admin 权限。配置持久化到 settings 键值表。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
|
||||||
|
// 与 posts 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
|
||||||
|
#![allow(clippy::unused_unit, deprecated)]
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::models::settings::TrashSettings;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use crate::api::auth::get_current_admin_user;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use crate::api::error::AppError;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use crate::db::pool::get_conn;
|
||||||
|
|
||||||
|
/// 读取回收站配置。
|
||||||
|
///
|
||||||
|
/// settings 表缺失键时回退到默认值,保证向后兼容。
|
||||||
|
#[server(GetTrashSettings, "/api")]
|
||||||
|
pub async fn get_trash_settings() -> Result<TrashSettings, ServerFnError> {
|
||||||
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
let enabled: bool = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT value FROM settings WHERE key = 'trash_auto_purge_enabled'",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?
|
||||||
|
.and_then(|r| r.get::<_, String>("value").parse().ok())
|
||||||
|
.unwrap_or(crate::models::settings::DEFAULT_AUTO_PURGE_ENABLED);
|
||||||
|
|
||||||
|
let days: i32 = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT value FROM settings WHERE key = 'trash_retention_days'",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?
|
||||||
|
.and_then(|r| r.get::<_, String>("value").parse().ok())
|
||||||
|
.unwrap_or(crate::models::settings::DEFAULT_RETENTION_DAYS);
|
||||||
|
|
||||||
|
Ok(TrashSettings {
|
||||||
|
auto_purge_enabled: enabled,
|
||||||
|
retention_days: TrashSettings::clamp_retention(days),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
{
|
||||||
|
Ok(TrashSettings::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新回收站配置。
|
||||||
|
///
|
||||||
|
/// retention_days 会被 clamp 到合法范围后写入。
|
||||||
|
#[server(UpdateTrashSettings, "/api")]
|
||||||
|
pub async fn update_trash_settings(
|
||||||
|
auto_purge_enabled: bool,
|
||||||
|
retention_days: i32,
|
||||||
|
) -> Result<TrashSettings, ServerFnError> {
|
||||||
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
let retention_days = TrashSettings::clamp_retention(retention_days);
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// UPSERT 两个键。
|
||||||
|
client
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO settings (key, value, updated_at) VALUES ('trash_auto_purge_enabled', $1, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
|
||||||
|
&[&auto_purge_enabled.to_string()],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO settings (key, value, updated_at) VALUES ('trash_retention_days', $1, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
|
||||||
|
&[&retention_days.to_string()],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Trash settings updated: auto_purge={}, retention_days={}",
|
||||||
|
auto_purge_enabled,
|
||||||
|
retention_days
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TrashSettings {
|
||||||
|
auto_purge_enabled,
|
||||||
|
retention_days,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -473,6 +473,7 @@ mod tests {
|
|||||||
published_at: None,
|
published_at: None,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
updated_at: chrono::Utc::now(),
|
updated_at: chrono::Utc::now(),
|
||||||
|
deleted_at: None,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
cover_image: None,
|
cover_image: None,
|
||||||
reading_time: 1,
|
reading_time: 1,
|
||||||
@ -520,6 +521,7 @@ mod tests {
|
|||||||
published_at: None,
|
published_at: None,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
updated_at: chrono::Utc::now(),
|
updated_at: chrono::Utc::now(),
|
||||||
|
deleted_at: None,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
cover_image: None,
|
cover_image: None,
|
||||||
reading_time: 1,
|
reading_time: 1,
|
||||||
|
|||||||
@ -67,6 +67,11 @@ pub fn AdminLayout() -> Element {
|
|||||||
label: "管理文章",
|
label: "管理文章",
|
||||||
is_active: matches!(route, Route::Posts {}),
|
is_active: matches!(route, Route::Posts {}),
|
||||||
},
|
},
|
||||||
|
NavItemConfig {
|
||||||
|
route: Route::Trash {},
|
||||||
|
label: "回收站",
|
||||||
|
is_active: matches!(route, Route::Trash {}) || matches!(route, Route::TrashPage { .. }),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 右侧操作区:主题切换 + 登出按钮
|
// 右侧操作区:主题切换 + 登出按钮
|
||||||
|
|||||||
@ -29,5 +29,7 @@ pub mod post;
|
|||||||
pub mod post_card;
|
pub mod post_card;
|
||||||
/// 骨架屏组件集合。
|
/// 骨架屏组件集合。
|
||||||
pub mod skeletons;
|
pub mod skeletons;
|
||||||
|
/// 通用 UI 原子组件与类名常量(卡片、按钮、分页、徽章、空状态)。
|
||||||
|
pub mod ui;
|
||||||
/// 编辑器页面骨架屏组件。
|
/// 编辑器页面骨架屏组件。
|
||||||
pub mod write_skeleton;
|
pub mod write_skeleton;
|
||||||
|
|||||||
185
src/components/ui.rs
Normal file
185
src/components/ui.rs
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
//! 通用 UI 原子组件与类名常量。
|
||||||
|
//!
|
||||||
|
//! 提供跨页面共享的样式常量(卡片、按钮、徽章外层等)与可复用组件
|
||||||
|
//! (分页导航、状态徽章、空状态)。样式常量用于消除散落在各页面的重复
|
||||||
|
//! Tailwind 类字符串;组件用于封装结构固定的 UI 单元。
|
||||||
|
//!
|
||||||
|
//! 与 `forms.rs`(表单控件)并列,本模块聚焦通用展示类原子。
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus::router::components::Link;
|
||||||
|
|
||||||
|
use crate::router::Route;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 样式常量
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// Admin 卡片容器:白底圆角描边,亮/暗双模式。用于 stat 卡片、面板等。
|
||||||
|
pub const ADMIN_CARD_CLASS: &str =
|
||||||
|
"bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333]";
|
||||||
|
|
||||||
|
/// Admin 表格容器:在卡片基础上加 `overflow-hidden`,圆角裁剪表格。
|
||||||
|
pub const ADMIN_TABLE_CLASS: &str =
|
||||||
|
"bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden";
|
||||||
|
|
||||||
|
/// Admin 表格行 hover 态:底部分割线 + 悬停背景。
|
||||||
|
pub const ADMIN_ROW_HOVER: &str =
|
||||||
|
"border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors";
|
||||||
|
|
||||||
|
/// 列表复选框统一样式(全选表头 + 行内)。
|
||||||
|
pub const CHECKBOX_CLASS: &str = "rounded border-gray-300 dark:border-[#555]";
|
||||||
|
|
||||||
|
/// 状态徽章外层:小号圆角胶囊。
|
||||||
|
pub const BADGE_BASE: &str =
|
||||||
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap";
|
||||||
|
|
||||||
|
// --- 实心小按钮(批量操作栏:通过 / 垃圾 / 删除) ---
|
||||||
|
|
||||||
|
/// 绿色实心小按钮(批量通过、批量恢复)。
|
||||||
|
pub const BTN_SOLID_GREEN: &str =
|
||||||
|
"px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition-colors";
|
||||||
|
/// 琥珀色实心小按钮(批量标为垃圾)。
|
||||||
|
pub const BTN_SOLID_AMBER: &str =
|
||||||
|
"px-3 py-1.5 text-xs font-medium bg-amber-600 text-white rounded hover:bg-amber-700 transition-colors";
|
||||||
|
/// 红色实心小按钮(批量删除、批量彻底删除)。
|
||||||
|
pub const BTN_SOLID_RED: &str =
|
||||||
|
"px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded hover:bg-red-700 transition-colors";
|
||||||
|
|
||||||
|
// --- 文字小按钮(表格行内操作:通过 / 垃圾 / 删除 / 恢复) ---
|
||||||
|
|
||||||
|
/// 绿色文字小按钮(行内通过)。
|
||||||
|
pub const BTN_TEXT_GREEN: &str = "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors cursor-pointer";
|
||||||
|
/// 琥珀色文字小按钮(行内标为垃圾)。
|
||||||
|
pub const BTN_TEXT_AMBER: &str = "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors cursor-pointer";
|
||||||
|
/// 红色文字小按钮(行内删除 / 彻底删除)。
|
||||||
|
pub const BTN_TEXT_RED: &str =
|
||||||
|
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
|
||||||
|
/// 主题绿(鼠尾草)文字小按钮(行内恢复)。
|
||||||
|
pub const BTN_TEXT_ACCENT: &str = "text-xs text-[#5c7a5e] hover:text-[#3d5a3f] dark:text-[#7da97f] dark:hover:text-[#9dc79f] transition-colors cursor-pointer";
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 组件
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 分页导航组件。
|
||||||
|
///
|
||||||
|
/// 统一了后台与前台的分页 UI,通过 `variant` 切换配色与展示细节:
|
||||||
|
/// - `"admin"`:灰黑胶囊按钮,显示页码计数(`{当前} / {总} 页 (共 {total} {unit})`),
|
||||||
|
/// 首尾页渲染禁用态。
|
||||||
|
/// - `"frontend"`:主题绿胶囊按钮,不显示计数,首尾页直接不渲染按钮。
|
||||||
|
///
|
||||||
|
/// Props:
|
||||||
|
/// - `variant`:`"admin"` 或 `"frontend"`
|
||||||
|
/// - `current_page`:当前页码(从 1 开始)
|
||||||
|
/// - `total`:数据总条数
|
||||||
|
/// - `per_page`:每页条数,用于计算总页数
|
||||||
|
/// - `prev_route`:点击上一页跳转的目标路由
|
||||||
|
/// - `next_route`:点击下一页跳转的目标路由
|
||||||
|
/// - `unit`:计数单位("篇" / "条"),仅 admin 显示计数时使用
|
||||||
|
#[component]
|
||||||
|
pub fn Pagination(
|
||||||
|
variant: &'static str,
|
||||||
|
current_page: i32,
|
||||||
|
total: i64,
|
||||||
|
per_page: i32,
|
||||||
|
prev_route: Route,
|
||||||
|
next_route: Route,
|
||||||
|
unit: &'static str,
|
||||||
|
) -> Element {
|
||||||
|
let has_prev = current_page > 1;
|
||||||
|
let total_pages = ((total + per_page as i64 - 1) / per_page as i64).max(1) as i32;
|
||||||
|
let has_next = current_page < total_pages;
|
||||||
|
|
||||||
|
// admin 与 frontend 的配色差异。
|
||||||
|
let is_admin = variant == "admin";
|
||||||
|
let (link_class, link_extra_next) = if is_admin {
|
||||||
|
(
|
||||||
|
"inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"inline-flex items-center px-4 py-2 text-sm text-white bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
|
||||||
|
"ml-auto",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let disabled_class =
|
||||||
|
"inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed";
|
||||||
|
|
||||||
|
// admin 首尾页渲染禁用态;frontend 首尾页直接不渲染。
|
||||||
|
rsx! {
|
||||||
|
nav { class: if is_admin { "flex mt-6 justify-between" } else { "flex mt-10 mb-6 justify-between" },
|
||||||
|
if has_prev {
|
||||||
|
Link {
|
||||||
|
class: "{link_class}",
|
||||||
|
to: prev_route,
|
||||||
|
span { class: "mr-1", "«" }
|
||||||
|
"上一页"
|
||||||
|
}
|
||||||
|
} else if is_admin {
|
||||||
|
span { class: "{disabled_class}",
|
||||||
|
span { class: "mr-1", "«" }
|
||||||
|
"上一页"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// admin 显示页码计数。
|
||||||
|
if is_admin {
|
||||||
|
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
|
||||||
|
"{current_page} / {total_pages} 页 (共 {total} {unit})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_next {
|
||||||
|
Link {
|
||||||
|
class: "{link_class} {link_extra_next}",
|
||||||
|
to: next_route,
|
||||||
|
"下一页"
|
||||||
|
span { class: "ml-1", "»" }
|
||||||
|
}
|
||||||
|
} else if is_admin {
|
||||||
|
span { class: "{disabled_class}",
|
||||||
|
"下一页"
|
||||||
|
span { class: "ml-1", "»" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 状态徽章组件。
|
||||||
|
///
|
||||||
|
/// 外层固定 `BADGE_BASE`,颜色类由调用方传入。之所以用 `color_class` prop
|
||||||
|
/// 而非枚举变体,是因为部分场景(如回收站剩余天数)的颜色由动态逻辑决定
|
||||||
|
/// (>7 天中性 / ≤7 天主题绿 / ≤0 琥珀),硬编码 variant 反而不够灵活。
|
||||||
|
///
|
||||||
|
/// Props:
|
||||||
|
/// - `color_class`:背景与文字颜色类(如 `post.status_badge_class()` 的返回值)
|
||||||
|
/// - `label`:徽章文本
|
||||||
|
#[component]
|
||||||
|
pub fn StatusBadge(color_class: &'static str, label: String) -> Element {
|
||||||
|
rsx! {
|
||||||
|
span { class: "{BADGE_BASE} {color_class}",
|
||||||
|
"{label}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空状态 / 错误状态组件。
|
||||||
|
///
|
||||||
|
/// 用于列表页无数据或加载失败时的居中占位提示。
|
||||||
|
///
|
||||||
|
/// Props:
|
||||||
|
/// - `message`:提示文本
|
||||||
|
/// - `variant`:`"default"`(灰色,空状态)或 `"error"`(红色,加载失败)
|
||||||
|
#[component]
|
||||||
|
pub fn EmptyState(message: &'static str, variant: &'static str) -> Element {
|
||||||
|
let class = match variant {
|
||||||
|
"error" => "text-center text-red-500 dark:text-red-400 py-20",
|
||||||
|
_ => "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
||||||
|
};
|
||||||
|
rsx! {
|
||||||
|
div { class: "{class}", "{message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/highlight.rs
173
src/highlight.rs
@ -14,10 +14,22 @@ pub mod server {
|
|||||||
/// 全局语法集合,懒加载时合并内置语法与 `syntaxes/` 目录下的自定义语法。
|
/// 全局语法集合,懒加载时合并内置语法与 `syntaxes/` 目录下的自定义语法。
|
||||||
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(|| {
|
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(|| {
|
||||||
let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
|
let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
|
||||||
if let Err(e) = builder.add_from_folder("syntaxes/", true) {
|
// 使用 CARGO_MANIFEST_DIR 派生的绝对路径,避免运行时工作目录不确定导致加载失败
|
||||||
tracing::warn!("Failed to load custom syntaxes: {:?}", e);
|
let syntaxes_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/syntaxes");
|
||||||
|
tracing::info!("Loading custom syntaxes from: {}", syntaxes_dir);
|
||||||
|
match builder.add_from_folder(syntaxes_dir, true) {
|
||||||
|
Ok(()) => tracing::info!("Custom syntaxes loaded successfully"),
|
||||||
|
Err(e) => tracing::warn!("Failed to load custom syntaxes: {:?}", e),
|
||||||
}
|
}
|
||||||
builder.build()
|
let built = builder.build();
|
||||||
|
tracing::info!(
|
||||||
|
"SyntaxSet built: {} syntaxes, swift={:?}",
|
||||||
|
built.syntaxes().len(),
|
||||||
|
built
|
||||||
|
.find_syntax_by_extension("swift")
|
||||||
|
.map(|s| &s.name)
|
||||||
|
);
|
||||||
|
built
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 根据语言标识查找对应的语法定义。
|
/// 根据语言标识查找对应的语法定义。
|
||||||
@ -36,24 +48,27 @@ pub mod server {
|
|||||||
if let Some(s) = ss.find_syntax_by_name(lang) {
|
if let Some(s) = ss.find_syntax_by_name(lang) {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
// 小写后再匹配一次
|
// 小写扩展名再匹配一次(部分语言的扩展名习惯小写)
|
||||||
let lower = lang.to_lowercase();
|
let lower = lang.to_lowercase();
|
||||||
if lower != lang {
|
if lower != lang {
|
||||||
if let Some(s) = ss.find_syntax_by_extension(&lower) {
|
if let Some(s) = ss.find_syntax_by_extension(&lower) {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
if let Some(s) = ss.find_syntax_by_name(&lower) {
|
|
||||||
return s;
|
|
||||||
}
|
}
|
||||||
|
// 大小写不敏感的语法名称匹配(syntect 的语法名通常首字母大写,如 Haskell)
|
||||||
|
if let Some(s) = ss
|
||||||
|
.syntaxes()
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.eq_ignore_ascii_case(lang))
|
||||||
|
{
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
// 常用语言别名映射表
|
// 常用语言别名映射表
|
||||||
let aliases: &[(&str, &str)] = &[
|
let aliases: &[(&str, &str)] = &[
|
||||||
("rust", "rs"),
|
("rust", "rs"),
|
||||||
("js", "js"),
|
("js", "js"),
|
||||||
("javascript", "js"),
|
("javascript", "js"),
|
||||||
("ts", "js"),
|
("typescript", "ts"),
|
||||||
("typescript", "js"),
|
|
||||||
("tsx", "js"),
|
|
||||||
("py", "py"),
|
("py", "py"),
|
||||||
("python", "py"),
|
("python", "py"),
|
||||||
("rb", "rb"),
|
("rb", "rb"),
|
||||||
@ -153,6 +168,23 @@ mod tests {
|
|||||||
assert!(result.contains(r#"<span class="constant numeric integer decimal rust">1</span>"#));
|
assert!(result.contains(r#"<span class="constant numeric integer decimal rust">1</span>"#));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_haskell_by_full_name() {
|
||||||
|
// Haskell 语法名首字母大写,扩展名为 hs;直接写 "haskell" 应能匹配。
|
||||||
|
let code = "factorial :: Integer -> Integer\nfactorial 0 = 1";
|
||||||
|
let result = highlight_code(code, Some("haskell"));
|
||||||
|
assert!(
|
||||||
|
!result.contains(r#"<span class="text plain">"#),
|
||||||
|
"Haskell 不应回退到纯文本: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("source haskell"),
|
||||||
|
"Haskell 应输出 source haskell: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn highlight_code_uppercase_language_falls_back_via_lowercase() {
|
fn highlight_code_uppercase_language_falls_back_via_lowercase() {
|
||||||
// 大写语言标识应通过小写回退路径匹配到对应语法。
|
// 大写语言标识应通过小写回退路径匹配到对应语法。
|
||||||
@ -221,4 +253,127 @@ mod tests {
|
|||||||
2
|
2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_swift_keyword_and_func() {
|
||||||
|
// Swift 关键字 func/import/let 应生成 declaration/keyword span,而不是纯文本。
|
||||||
|
let code = "import Foundation\nfunc greet(person: String) -> String {\n return \"Hi\"\n}";
|
||||||
|
let result = highlight_code(code, Some("swift"));
|
||||||
|
assert!(
|
||||||
|
result.contains("keyword"),
|
||||||
|
"Swift 输出缺少关键字高亮: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
// 函数名应被识别为函数(声明名 entity name function 或调用 variable function)。
|
||||||
|
assert!(
|
||||||
|
result.contains("name function") || result.contains("variable function"),
|
||||||
|
"Swift func 名缺少函数高亮: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_swift_types_and_strings() {
|
||||||
|
// Swift 标准库类型与字符串字面量都应被识别。
|
||||||
|
let code = "let count: Int = 42\nlet name = \"hello\"";
|
||||||
|
let result = highlight_code(code, Some("swift"));
|
||||||
|
assert!(
|
||||||
|
result.contains("support type") || result.contains("entity name type"),
|
||||||
|
"Swift Int 类型未被识别为类型: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("string"),
|
||||||
|
"Swift 字符串未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_typescript_keywords_and_types() {
|
||||||
|
// TS 关键字 interface/const/=> 与类型 string/number 应被识别。
|
||||||
|
let code = "interface User { name: string; }\nconst x: number = 42;";
|
||||||
|
let result = highlight_code(code, Some("typescript"));
|
||||||
|
assert!(
|
||||||
|
result.contains("keyword"),
|
||||||
|
"TypeScript 关键字未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("support type") || result.contains("entity name type"),
|
||||||
|
"TypeScript 类型未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_jsx_tags_and_attributes() {
|
||||||
|
// JSX 标签名与属性名都应被识别。
|
||||||
|
let code = "const el = <Button title=\"save\" onClick={fn}>OK</Button>;";
|
||||||
|
for lang in &["jsx", "tsx"] {
|
||||||
|
let result = highlight_code(code, Some(lang));
|
||||||
|
assert!(
|
||||||
|
result.contains("entity name tag"),
|
||||||
|
"{lang} JSX 标签名未识别: {result}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("attribute"),
|
||||||
|
"{lang} JSX 属性名未识别: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_typescript_resolves_ts_alias() {
|
||||||
|
// 别名 "ts" 与 "typescript" 输出应一致。
|
||||||
|
let code = "const x: number = 1;";
|
||||||
|
let by_ext = highlight_code(code, Some("ts"));
|
||||||
|
let by_name = highlight_code(code, Some("typescript"));
|
||||||
|
assert_eq!(by_ext, by_name);
|
||||||
|
assert!(by_ext.contains("keyword"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_zig_keywords_and_fn() {
|
||||||
|
// Zig 关键字 const/fn/pub 与内建函数 @import 都应被高亮。
|
||||||
|
let code = "const std = @import(\"std\");\npub fn main() void {}";
|
||||||
|
let result = highlight_code(code, Some("zig"));
|
||||||
|
assert!(
|
||||||
|
result.contains("keyword"),
|
||||||
|
"Zig 关键字未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("name function"),
|
||||||
|
"Zig 函数名未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("builtin") || result.contains("support function"),
|
||||||
|
"Zig 内建函数 @import 未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_zig_types_and_strings() {
|
||||||
|
// Zig 整数类型、字符串字面量与十六进制数字应被识别。
|
||||||
|
let code = "const x: u32 = 0xFF;\nconst s = \"hello\"";
|
||||||
|
let result = highlight_code(code, Some("zig"));
|
||||||
|
assert!(
|
||||||
|
result.contains("support type") || result.contains("keyword"),
|
||||||
|
"Zig u32 类型未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("string"),
|
||||||
|
"Zig 字符串未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("numeric"),
|
||||||
|
"Zig 数字未被识别: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,11 @@ fn main() {
|
|||||||
tasks::session_cleanup::run_cleanup().await;
|
tasks::session_cleanup::run_cleanup().await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 启动后台定时任务:回收站自动清理
|
||||||
|
tokio::spawn(async {
|
||||||
|
tasks::post_purge::run_purge().await;
|
||||||
|
});
|
||||||
|
|
||||||
// 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖
|
// 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖
|
||||||
let config = ServeConfig::builder().incremental(
|
let config = ServeConfig::builder().incremental(
|
||||||
dioxus::server::IncrementalRendererConfig::default().invalidate_after(
|
dioxus::server::IncrementalRendererConfig::default().invalidate_after(
|
||||||
|
|||||||
@ -9,3 +9,5 @@ pub mod comment;
|
|||||||
pub mod post;
|
pub mod post;
|
||||||
/// 用户模型、用户角色与可公开用户信息。
|
/// 用户模型、用户角色与可公开用户信息。
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
/// 回收站与站点配置模型。
|
||||||
|
pub mod settings;
|
||||||
|
|||||||
@ -61,6 +61,8 @@ pub struct Post {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
/// 最后更新时间。
|
/// 最后更新时间。
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
/// 软删除时间,None 表示未删除。仅回收站查询填充。
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
/// 关联标签列表。
|
/// 关联标签列表。
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
/// 封面图片 URL。
|
/// 封面图片 URL。
|
||||||
@ -161,6 +163,7 @@ mod tests {
|
|||||||
published_at: None,
|
published_at: None,
|
||||||
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
||||||
updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
||||||
|
deleted_at: None,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
cover_image: None,
|
cover_image: None,
|
||||||
reading_time: 1,
|
reading_time: 1,
|
||||||
|
|||||||
71
src/models/settings.rs
Normal file
71
src/models/settings.rs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
//! 回收站与站点配置模型。
|
||||||
|
|
||||||
|
/// 默认保留天数(天)。
|
||||||
|
pub const DEFAULT_RETENTION_DAYS: i32 = 30;
|
||||||
|
/// 默认不启用自动清理。
|
||||||
|
pub const DEFAULT_AUTO_PURGE_ENABLED: bool = false;
|
||||||
|
/// 保留天数下限(天)。
|
||||||
|
pub const MIN_RETENTION_DAYS: i32 = 1;
|
||||||
|
/// 保留天数上限(天)。防止误填超大值导致永不清理。
|
||||||
|
pub const MAX_RETENTION_DAYS: i32 = 365;
|
||||||
|
|
||||||
|
/// 回收站配置。
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct TrashSettings {
|
||||||
|
/// 是否启用自动定时清理。
|
||||||
|
pub auto_purge_enabled: bool,
|
||||||
|
/// 已删除文章保留天数,超过后被后台任务物理删除。
|
||||||
|
pub retention_days: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TrashSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
auto_purge_enabled: DEFAULT_AUTO_PURGE_ENABLED,
|
||||||
|
retention_days: DEFAULT_RETENTION_DAYS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrashSettings {
|
||||||
|
/// 将保留天数钳制到合法范围 [MIN, MAX]。
|
||||||
|
pub fn clamp_retention(days: i32) -> i32 {
|
||||||
|
days.clamp(MIN_RETENTION_DAYS, MAX_RETENTION_DAYS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_is_disabled_30_days() {
|
||||||
|
let s = TrashSettings::default();
|
||||||
|
assert!(!s.auto_purge_enabled);
|
||||||
|
assert_eq!(s.retention_days, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamp_retention_keeps_valid() {
|
||||||
|
assert_eq!(TrashSettings::clamp_retention(7), 7);
|
||||||
|
assert_eq!(TrashSettings::clamp_retention(30), 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamp_retention_clamps_below_min() {
|
||||||
|
assert_eq!(TrashSettings::clamp_retention(0), MIN_RETENTION_DAYS);
|
||||||
|
assert_eq!(TrashSettings::clamp_retention(-5), MIN_RETENTION_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamp_retention_clamps_above_max() {
|
||||||
|
assert_eq!(TrashSettings::clamp_retention(366), MAX_RETENTION_DAYS);
|
||||||
|
assert_eq!(TrashSettings::clamp_retention(i32::MAX), MAX_RETENTION_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamp_retention_boundary() {
|
||||||
|
assert_eq!(TrashSettings::clamp_retention(MIN_RETENTION_DAYS), MIN_RETENTION_DAYS);
|
||||||
|
assert_eq!(TrashSettings::clamp_retention(MAX_RETENTION_DAYS), MAX_RETENTION_DAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,10 @@ use crate::api::comments::{approve_comment, batch_update_comment_status, spam_co
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::comments::{get_all_comments, AllCommentsResponse};
|
use crate::api::comments::{get_all_comments, AllCommentsResponse};
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
|
use crate::components::ui::{
|
||||||
|
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_AMBER,
|
||||||
|
BTN_TEXT_GREEN, BTN_TEXT_RED, BTN_SOLID_AMBER, BTN_SOLID_GREEN, BTN_SOLID_RED, CHECKBOX_CLASS,
|
||||||
|
};
|
||||||
use crate::models::comment::{AdminComment, CommentStatus};
|
use crate::models::comment::{AdminComment, CommentStatus};
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
@ -143,7 +147,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
"已选择 {selected_ids().len()} 条"
|
"已选择 {selected_ids().len()} 条"
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
class: "px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition-colors",
|
class: "{BTN_SOLID_GREEN}",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||||||
let ids_for_api = ids.clone();
|
let ids_for_api = ids.clone();
|
||||||
@ -156,7 +160,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
"批量通过"
|
"批量通过"
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
class: "px-3 py-1.5 text-xs font-medium bg-amber-600 text-white rounded hover:bg-amber-700 transition-colors",
|
class: "{BTN_SOLID_AMBER}",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||||||
let ids_for_api = ids.clone();
|
let ids_for_api = ids.clone();
|
||||||
@ -169,7 +173,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
"批量垃圾"
|
"批量垃圾"
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
class: "px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded hover:bg-red-700 transition-colors",
|
class: "{BTN_SOLID_RED}",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
@ -195,11 +199,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
|
|
||||||
{
|
{
|
||||||
if error().is_some() {
|
if error().is_some() {
|
||||||
rsx! {
|
rsx! { EmptyState { message: "加载失败", variant: "error" } }
|
||||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
|
||||||
"加载失败"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if loading() && comments().is_empty() {
|
} else if loading() && comments().is_empty() {
|
||||||
rsx! {
|
rsx! {
|
||||||
DelayedSkeleton {
|
DelayedSkeleton {
|
||||||
@ -216,17 +216,13 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if comments().is_empty() {
|
} else if comments().is_empty() {
|
||||||
rsx! {
|
rsx! { EmptyState { message: "暂无评论", variant: "default" } }
|
||||||
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
|
||||||
"暂无评论"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let list = comments();
|
let list = comments();
|
||||||
let all_selected = list.iter().all(|c| selected_ids().contains(&c.id));
|
let all_selected = list.iter().all(|c| selected_ids().contains(&c.id));
|
||||||
let all_ids: Vec<i64> = list.iter().map(|c| c.id).collect();
|
let all_ids: Vec<i64> = list.iter().map(|c| c.id).collect();
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
|
div { class: "{ADMIN_TABLE_CLASS}",
|
||||||
div { class: "overflow-x-auto",
|
div { class: "overflow-x-auto",
|
||||||
table { class: "w-full text-sm",
|
table { class: "w-full text-sm",
|
||||||
thead {
|
thead {
|
||||||
@ -234,7 +230,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
th { class: "px-4 py-3 font-medium w-10",
|
th { class: "px-4 py-3 font-medium w-10",
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
class: "rounded border-gray-300 dark:border-[#555]",
|
class: "{CHECKBOX_CLASS}",
|
||||||
checked: all_selected,
|
checked: all_selected,
|
||||||
onchange: {
|
onchange: {
|
||||||
move |_| {
|
move |_| {
|
||||||
@ -312,7 +308,19 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CommentsPagination { current_page, total: total() }
|
Pagination {
|
||||||
|
variant: "admin",
|
||||||
|
current_page,
|
||||||
|
total: total(),
|
||||||
|
per_page: COMMENTS_PER_PAGE,
|
||||||
|
prev_route: if current_page - 1 <= 1 {
|
||||||
|
Route::AdminComments {}
|
||||||
|
} else {
|
||||||
|
Route::AdminCommentsPage { page: current_page - 1 }
|
||||||
|
},
|
||||||
|
next_route: Route::AdminCommentsPage { page: current_page + 1 },
|
||||||
|
unit: "条",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -330,23 +338,11 @@ fn CommentRow(
|
|||||||
on_spam: EventHandler,
|
on_spam: EventHandler,
|
||||||
on_trash: EventHandler,
|
on_trash: EventHandler,
|
||||||
) -> Element {
|
) -> Element {
|
||||||
let (badge_class, status_label) = match &comment.status {
|
let status_label = match &comment.status {
|
||||||
CommentStatus::Pending => (
|
CommentStatus::Pending => "待审核".to_string(),
|
||||||
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
CommentStatus::Approved => "已通过".to_string(),
|
||||||
"待审核",
|
CommentStatus::Spam => "垃圾".to_string(),
|
||||||
),
|
CommentStatus::Trash => "已删除".to_string(),
|
||||||
CommentStatus::Approved => (
|
|
||||||
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
|
||||||
"已通过",
|
|
||||||
),
|
|
||||||
CommentStatus::Spam => (
|
|
||||||
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
|
||||||
"垃圾",
|
|
||||||
),
|
|
||||||
CommentStatus::Trash => (
|
|
||||||
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
|
|
||||||
"已删除",
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
let date_str = comment.created_at.format("%Y-%m-%d").to_string();
|
let date_str = comment.created_at.format("%Y-%m-%d").to_string();
|
||||||
let preview = if comment.content_md.len() > 100 {
|
let preview = if comment.content_md.len() > 100 {
|
||||||
@ -359,11 +355,11 @@ fn CommentRow(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
|
tr { class: "{ADMIN_ROW_HOVER}",
|
||||||
td { class: "px-4 py-3",
|
td { class: "px-4 py-3",
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
class: "rounded border-gray-300 dark:border-[#555]",
|
class: "{CHECKBOX_CLASS}",
|
||||||
checked: selected,
|
checked: selected,
|
||||||
onchange: move |e| on_select.call(e.checked()),
|
onchange: move |e| on_select.call(e.checked()),
|
||||||
}
|
}
|
||||||
@ -398,8 +394,15 @@ fn CommentRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { class: "px-4 py-3 text-center",
|
td { class: "px-4 py-3 text-center",
|
||||||
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap {badge_class}",
|
StatusBadge {
|
||||||
"{status_label}"
|
// badge_class 是 &'static str 字面量匹配,转为静态生命周期。
|
||||||
|
color_class: match &comment.status {
|
||||||
|
CommentStatus::Pending => "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||||
|
CommentStatus::Approved => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||||
|
CommentStatus::Spam => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||||
|
CommentStatus::Trash => "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
|
||||||
|
},
|
||||||
|
label: status_label,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
|
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||||
@ -409,21 +412,21 @@ fn CommentRow(
|
|||||||
div { class: "flex justify-end gap-2",
|
div { class: "flex justify-end gap-2",
|
||||||
if !matches!(comment.status, CommentStatus::Approved) {
|
if !matches!(comment.status, CommentStatus::Approved) {
|
||||||
button {
|
button {
|
||||||
class: "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors cursor-pointer",
|
class: "{BTN_TEXT_GREEN}",
|
||||||
onclick: move |_| on_approve.call(()),
|
onclick: move |_| on_approve.call(()),
|
||||||
"通过"
|
"通过"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !matches!(comment.status, CommentStatus::Spam) {
|
if !matches!(comment.status, CommentStatus::Spam) {
|
||||||
button {
|
button {
|
||||||
class: "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors cursor-pointer",
|
class: "{BTN_TEXT_AMBER}",
|
||||||
onclick: move |_| on_spam.call(()),
|
onclick: move |_| on_spam.call(()),
|
||||||
"垃圾"
|
"垃圾"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !matches!(comment.status, CommentStatus::Trash) {
|
if !matches!(comment.status, CommentStatus::Trash) {
|
||||||
button {
|
button {
|
||||||
class: "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer",
|
class: "{BTN_TEXT_RED}",
|
||||||
onclick: move |_| on_trash.call(()),
|
onclick: move |_| on_trash.call(()),
|
||||||
"删除"
|
"删除"
|
||||||
}
|
}
|
||||||
@ -434,56 +437,3 @@ fn CommentRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 评论分页导航组件。
|
|
||||||
#[component]
|
|
||||||
fn CommentsPagination(current_page: i32, total: i64) -> Element {
|
|
||||||
let has_prev = current_page > 1;
|
|
||||||
let total_pages =
|
|
||||||
((total + COMMENTS_PER_PAGE as i64 - 1) / COMMENTS_PER_PAGE as i64).max(1) as i32;
|
|
||||||
let has_next = current_page < total_pages;
|
|
||||||
|
|
||||||
let prev_route = if current_page - 1 <= 1 {
|
|
||||||
Route::AdminComments {}
|
|
||||||
} else {
|
|
||||||
Route::AdminCommentsPage {
|
|
||||||
page: current_page - 1,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let next_route = Route::AdminCommentsPage {
|
|
||||||
page: current_page + 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
nav { class: "flex mt-6 justify-between",
|
|
||||||
if has_prev {
|
|
||||||
Link {
|
|
||||||
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
|
|
||||||
to: prev_route,
|
|
||||||
span { class: "mr-1", "«" }
|
|
||||||
"上一页"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
|
|
||||||
span { class: "mr-1", "«" }
|
|
||||||
"上一页"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
|
|
||||||
"{current_page} / {total_pages} 页 (共 {total} 条)"
|
|
||||||
}
|
|
||||||
if has_next {
|
|
||||||
Link {
|
|
||||||
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
|
|
||||||
to: next_route,
|
|
||||||
"下一页"
|
|
||||||
span { class: "ml-1", "»" }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
|
|
||||||
"下一页"
|
|
||||||
span { class: "ml-1", "»" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ use crate::api::comments::get_pending_count;
|
|||||||
use crate::api::posts::{get_post_stats, list_posts};
|
use crate::api::posts::{get_post_stats, list_posts};
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::posts::{PostListResponse, PostStatsResponse};
|
use crate::api::posts::{PostListResponse, PostStatsResponse};
|
||||||
|
use crate::components::ui::ADMIN_CARD_CLASS;
|
||||||
use crate::models::post::{Post, PostStats};
|
use crate::models::post::{Post, PostStats};
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
@ -151,7 +152,7 @@ pub fn Admin() -> Element {
|
|||||||
#[component]
|
#[component]
|
||||||
fn StatCard(value: String, label: String) -> Element {
|
fn StatCard(value: String, label: String) -> Element {
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center",
|
div { class: "{ADMIN_CARD_CLASS} p-6 text-center",
|
||||||
div { class: "text-3xl font-bold text-gray-900 dark:text-[#dadadb]",
|
div { class: "text-3xl font-bold text-gray-900 dark:text-[#dadadb]",
|
||||||
"{value}"
|
"{value}"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ pub mod comments;
|
|||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
/// 文章管理列表页面模块。
|
/// 文章管理列表页面模块。
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
|
/// 回收站管理页面模块。
|
||||||
|
pub mod trash;
|
||||||
/// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。
|
/// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。
|
||||||
pub mod write;
|
pub mod write;
|
||||||
|
|
||||||
@ -17,5 +19,7 @@ pub use comments::{AdminComments, AdminCommentsPage};
|
|||||||
pub use dashboard::Admin;
|
pub use dashboard::Admin;
|
||||||
/// 文章管理列表组件(带默认分页)。
|
/// 文章管理列表组件(带默认分页)。
|
||||||
pub use posts::{Posts, PostsPage};
|
pub use posts::{Posts, PostsPage};
|
||||||
|
/// 回收站管理组件(带默认分页)。
|
||||||
|
pub use trash::{Trash, TrashPage};
|
||||||
/// 文章编辑器组件(新建与编辑模式)。
|
/// 文章编辑器组件(新建与编辑模式)。
|
||||||
pub use write::{Write, WriteEdit};
|
pub use write::{Write, WriteEdit};
|
||||||
|
|||||||
@ -14,6 +14,7 @@ use crate::api::posts::PostListResponse;
|
|||||||
use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult};
|
use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult};
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
||||||
|
use crate::components::ui::{EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_RED};
|
||||||
use crate::models::post::Post;
|
use crate::models::post::Post;
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
@ -40,6 +41,32 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
let mut rebuilding = use_signal(|| false);
|
let mut rebuilding = use_signal(|| false);
|
||||||
let mut rebuild_result = use_signal(|| Option::<String>::None);
|
let mut rebuild_result = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
// 重建文章渲染缓存:rebuild_all 为 false 时仅重建 content_html 为空的文章,
|
||||||
|
// 为 true 时重建所有文章(用于语法/渲染逻辑升级后批量刷新已有内容)。
|
||||||
|
let mut do_rebuild = move |rebuild_all: bool| {
|
||||||
|
rebuilding.set(true);
|
||||||
|
rebuild_result.set(None);
|
||||||
|
spawn(async move {
|
||||||
|
match rebuild_content_html(rebuild_all).await {
|
||||||
|
Ok(RebuildResult { rebuilt, failed, errors }) => {
|
||||||
|
if failed > 0 {
|
||||||
|
let mut msg = format!("已重建 {rebuilt} 篇,失败 {failed} 篇");
|
||||||
|
if let Some(first) = errors.first() {
|
||||||
|
msg.push_str(&format!("\n{first}"));
|
||||||
|
}
|
||||||
|
rebuild_result.set(Some(msg));
|
||||||
|
} else {
|
||||||
|
rebuild_result.set(Some(format!("已重建 {rebuilt} 篇文章")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
rebuild_result.set(Some(format!("失败: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuilding.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 页码变化时加载分页数据:WASM 前端请求接口,SSR 直接结束加载。
|
// 页码变化时加载分页数据:WASM 前端请求接口,SSR 直接结束加载。
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let _ = current_page;
|
let _ = current_page;
|
||||||
@ -86,38 +113,28 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all"
|
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all"
|
||||||
},
|
},
|
||||||
disabled: rebuilding(),
|
disabled: rebuilding(),
|
||||||
onclick: move |_| {
|
onclick: move |_| do_rebuild(false),
|
||||||
rebuilding.set(true);
|
|
||||||
rebuild_result.set(None);
|
|
||||||
spawn(async move {
|
|
||||||
match rebuild_content_html(false).await {
|
|
||||||
Ok(RebuildResult { rebuilt, failed, errors }) => {
|
|
||||||
if failed > 0 {
|
|
||||||
let mut msg = format!(
|
|
||||||
"已重建 {rebuilt} 篇,失败 {failed} 篇"
|
|
||||||
);
|
|
||||||
if let Some(first) = errors.first() {
|
|
||||||
msg.push_str(&format!("\n{first}"));
|
|
||||||
}
|
|
||||||
rebuild_result.set(Some(msg));
|
|
||||||
} else {
|
|
||||||
rebuild_result
|
|
||||||
.set(Some(format!("已重建 {rebuilt} 篇文章")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
rebuild_result.set(Some(format!("失败: {e}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rebuilding.set(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
if rebuilding() { "重建中..." } else { "重建内容" }
|
if rebuilding() { "重建中..." } else { "重建内容" }
|
||||||
}
|
}
|
||||||
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-[#1a1a1a] shadow-lg z-50",
|
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-[#1a1a1a] shadow-lg z-50",
|
||||||
"重建 content_html 为空的文章渲染缓存"
|
"重建 content_html 为空的文章渲染缓存"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
div { class: "group relative",
|
||||||
|
button {
|
||||||
|
class: if rebuilding() {
|
||||||
|
"px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] border border-gray-300 dark:border-[#444]"
|
||||||
|
} else {
|
||||||
|
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all"
|
||||||
|
},
|
||||||
|
disabled: rebuilding(),
|
||||||
|
onclick: move |_| do_rebuild(true),
|
||||||
|
if rebuilding() { "重建中..." } else { "重建全部" }
|
||||||
|
}
|
||||||
|
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-[#1a1a1a] shadow-lg z-50",
|
||||||
|
"重建所有文章的渲染缓存(含已有内容)"
|
||||||
|
}
|
||||||
|
}
|
||||||
Link {
|
Link {
|
||||||
class: "px-4 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer",
|
class: "px-4 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer",
|
||||||
to: Route::Write {},
|
to: Route::Write {},
|
||||||
@ -135,11 +152,9 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
if loading() && posts().is_empty() {
|
if loading() && posts().is_empty() {
|
||||||
DelayedSkeleton { PostsSkeleton {} }
|
DelayedSkeleton { PostsSkeleton {} }
|
||||||
} else if posts().is_empty() {
|
} else if posts().is_empty() {
|
||||||
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
EmptyState { message: "暂无文章", variant: "default" }
|
||||||
"暂无文章"
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
|
div { class: "{ADMIN_TABLE_CLASS}",
|
||||||
table { class: "w-full text-sm",
|
table { class: "w-full text-sm",
|
||||||
thead {
|
thead {
|
||||||
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
|
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
|
||||||
@ -180,52 +195,18 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Pagination { current_page, total: total() }
|
Pagination {
|
||||||
}
|
variant: "admin",
|
||||||
}
|
current_page,
|
||||||
}
|
total: total(),
|
||||||
}
|
per_page: POSTS_PER_PAGE,
|
||||||
|
prev_route: if current_page - 1 <= 1 {
|
||||||
/// 分页导航组件,根据当前页与总数量生成上一页 / 下一页链接。
|
|
||||||
#[component]
|
|
||||||
fn Pagination(current_page: i32, total: i64) -> Element {
|
|
||||||
let has_prev = current_page > 1;
|
|
||||||
let total_pages = ((total + POSTS_PER_PAGE as i64 - 1) / POSTS_PER_PAGE as i64).max(1) as i32;
|
|
||||||
let has_next = current_page < total_pages;
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
nav { class: "flex mt-6 justify-between",
|
|
||||||
if has_prev {
|
|
||||||
Link {
|
|
||||||
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
|
|
||||||
to: if current_page - 1 <= 1 {
|
|
||||||
Route::Posts {}
|
Route::Posts {}
|
||||||
} else {
|
} else {
|
||||||
Route::PostsPage { page: current_page - 1 }
|
Route::PostsPage { page: current_page - 1 }
|
||||||
},
|
},
|
||||||
span { class: "mr-1", "«" }
|
next_route: Route::PostsPage { page: current_page + 1 },
|
||||||
"上一页"
|
unit: "篇",
|
||||||
}
|
|
||||||
} else {
|
|
||||||
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
|
|
||||||
span { class: "mr-1", "«" }
|
|
||||||
"上一页"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
|
|
||||||
"{current_page} / {total_pages} 页 (共 {total} 篇)"
|
|
||||||
}
|
|
||||||
if has_next {
|
|
||||||
Link {
|
|
||||||
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
|
|
||||||
to: Route::PostsPage { page: current_page + 1 },
|
|
||||||
"下一页"
|
|
||||||
span { class: "ml-1", "»" }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
|
|
||||||
"下一页"
|
|
||||||
span { class: "ml-1", "»" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -236,11 +217,9 @@ fn Pagination(current_page: i32, total: i64) -> Element {
|
|||||||
#[component]
|
#[component]
|
||||||
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
|
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
|
||||||
let date_str = post.formatted_date();
|
let date_str = post.formatted_date();
|
||||||
let status_label = post.status_label();
|
|
||||||
let status_badge_class = post.status_badge_class();
|
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
|
tr { class: "{ADMIN_ROW_HOVER}",
|
||||||
td { class: "px-4 py-3",
|
td { class: "px-4 py-3",
|
||||||
Link {
|
Link {
|
||||||
class: "text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
|
class: "text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
|
||||||
@ -249,8 +228,9 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { class: "px-4 py-3 text-center",
|
td { class: "px-4 py-3 text-center",
|
||||||
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {status_badge_class}",
|
StatusBadge {
|
||||||
"{status_label}"
|
color_class: post.status_badge_class(),
|
||||||
|
label: post.status_label().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]",
|
td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]",
|
||||||
@ -267,7 +247,7 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element
|
|||||||
class: if deleting {
|
class: if deleting {
|
||||||
"text-xs text-gray-400 cursor-not-allowed"
|
"text-xs text-gray-400 cursor-not-allowed"
|
||||||
} else {
|
} else {
|
||||||
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer"
|
BTN_TEXT_RED
|
||||||
},
|
},
|
||||||
disabled: deleting,
|
disabled: deleting,
|
||||||
onclick: move |_| on_delete.call(post.id),
|
onclick: move |_| on_delete.call(post.id),
|
||||||
|
|||||||
595
src/pages/admin/trash.rs
Normal file
595
src/pages/admin/trash.rs
Normal file
@ -0,0 +1,595 @@
|
|||||||
|
//! 回收站管理页面。
|
||||||
|
//!
|
||||||
|
//! 展示已软删除文章,支持恢复、彻底删除、批量操作、一键清空,
|
||||||
|
//! 以及自动清理配置(启用开关 + 保留天数)。
|
||||||
|
//! 数据加载与操作仅在 WASM 前端通过 Dioxus server functions 交互。
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
// 操作类 server function 在 SSR 与 WASM 均需可见(spawn 闭包需类型检查),
|
||||||
|
// 但部分仅用于 WASM 代码路径,SSR 下触发 unused imports,按项目惯例放行。
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use crate::api::posts::{
|
||||||
|
batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post,
|
||||||
|
};
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::api::posts::{list_deleted_posts, PostListResponse};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use crate::api::settings::{get_trash_settings, update_trash_settings};
|
||||||
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
|
use crate::components::ui::{
|
||||||
|
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN,
|
||||||
|
BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS,
|
||||||
|
};
|
||||||
|
use crate::models::post::Post;
|
||||||
|
use crate::models::settings::TrashSettings;
|
||||||
|
use crate::router::Route;
|
||||||
|
|
||||||
|
/// 每页展示的回收站文章数量。
|
||||||
|
const TRASH_PER_PAGE: i32 = 20;
|
||||||
|
|
||||||
|
/// 回收站入口组件,默认展示第 1 页。
|
||||||
|
#[component]
|
||||||
|
pub fn Trash() -> Element {
|
||||||
|
rsx! { TrashPage { page: 1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回收站分页组件。
|
||||||
|
///
|
||||||
|
/// 支持单条/批量恢复与彻底删除、一键清空,以及内联自动清理配置。
|
||||||
|
#[allow(unused_mut, unused_variables)]
|
||||||
|
#[component]
|
||||||
|
pub fn TrashPage(page: i32) -> Element {
|
||||||
|
let current_page = page.max(1);
|
||||||
|
let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new);
|
||||||
|
let mut posts: Signal<Vec<Post>> = use_signal(Vec::new);
|
||||||
|
let mut total: Signal<i64> = use_signal(|| 0);
|
||||||
|
#[allow(unused_mut)]
|
||||||
|
let mut loading: Signal<bool> = use_signal(|| false);
|
||||||
|
#[allow(unused_mut)]
|
||||||
|
let mut error: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
// 自动清理配置(含本地草稿态用于表单输入)。
|
||||||
|
let mut settings: Signal<TrashSettings> = use_signal(TrashSettings::default);
|
||||||
|
let mut settings_draft_days: Signal<String> = use_signal(|| "30".to_string());
|
||||||
|
let mut settings_draft_enabled: Signal<bool> = use_signal(|| false);
|
||||||
|
let mut settings_panel_open: Signal<bool> = use_signal(|| false);
|
||||||
|
let mut saving_settings: Signal<bool> = use_signal(|| false);
|
||||||
|
// 保存成功后的短暂反馈标记(用户再次编辑时清除)。
|
||||||
|
let mut just_saved: Signal<bool> = use_signal(|| false);
|
||||||
|
// 配置只加载一次的标记,避免每次翻页 effect 重复拉取。
|
||||||
|
let mut settings_loaded: Signal<bool> = use_signal(|| false);
|
||||||
|
|
||||||
|
// 加载回收站列表;配置仅首次加载。
|
||||||
|
use_effect(move || {
|
||||||
|
let _ = current_page;
|
||||||
|
loading.set(true);
|
||||||
|
error.set(None);
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let page = current_page;
|
||||||
|
spawn(async move {
|
||||||
|
match list_deleted_posts(page, TRASH_PER_PAGE).await {
|
||||||
|
Ok(PostListResponse { posts: list, total: t }) => {
|
||||||
|
posts.set(list);
|
||||||
|
total.set(t);
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.to_string())),
|
||||||
|
}
|
||||||
|
loading.set(false);
|
||||||
|
});
|
||||||
|
if !settings_loaded() {
|
||||||
|
settings_loaded.set(true);
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(s) = get_trash_settings().await {
|
||||||
|
settings_draft_days.set(s.retention_days.to_string());
|
||||||
|
settings_draft_enabled.set(s.auto_purge_enabled);
|
||||||
|
settings.set(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 本地移除一篇文章(乐观更新)。
|
||||||
|
let mut remove_post = move |id: i32| {
|
||||||
|
posts.with_mut(|list| list.retain(|p| p.id != id));
|
||||||
|
total.with_mut(|t| *t = t.saturating_sub(1));
|
||||||
|
selected_ids.with_mut(|s| {
|
||||||
|
s.remove(&id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 草稿相对已保存配置是否存在差异:控制保存按钮可用性与“未保存”提示。
|
||||||
|
let dirty = settings_draft_enabled() != settings().auto_purge_enabled
|
||||||
|
|| settings_draft_days()
|
||||||
|
.trim()
|
||||||
|
.parse::<i32>()
|
||||||
|
.ok()
|
||||||
|
.map(|d| d != settings().retention_days)
|
||||||
|
.unwrap_or(true);
|
||||||
|
// 折叠箭头旋转类(展开时翻转 180°)。
|
||||||
|
let chevron_rotate = if settings_panel_open() {
|
||||||
|
"rotate-180"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "space-y-6",
|
||||||
|
// 页面标题
|
||||||
|
div { class: "flex items-center gap-3",
|
||||||
|
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]", "回收站" }
|
||||||
|
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||||
|
"共 {total()} 篇"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动清理配置卡片:可折叠的设置面板,顶部始终显示当前状态摘要
|
||||||
|
div {
|
||||||
|
class: "rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden bg-white dark:bg-[#2e2e33]",
|
||||||
|
// 顶部可点击摘要条:状态指示灯 + 标题 + 展开箭头
|
||||||
|
button {
|
||||||
|
class: "w-full flex items-center gap-3 px-5 py-4 text-left cursor-pointer hover:bg-gray-50 dark:hover:bg-[#34343a] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40",
|
||||||
|
onclick: move |_| {
|
||||||
|
settings_panel_open.set(!settings_panel_open());
|
||||||
|
just_saved.set(false);
|
||||||
|
},
|
||||||
|
// 状态指示灯
|
||||||
|
{let dot_class = if settings().auto_purge_enabled {
|
||||||
|
"w-2 h-2 rounded-full bg-[#5c7a5e] dark:bg-[#7da97f] shadow-[0_0_0_3px_rgba(92,122,94,0.15)]"
|
||||||
|
} else {
|
||||||
|
"w-2 h-2 rounded-full bg-gray-300 dark:bg-[#666]"
|
||||||
|
};
|
||||||
|
rsx! {
|
||||||
|
div { class: "w-2 flex-shrink-0 flex items-center justify-center",
|
||||||
|
div { class: "{dot_class}" }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
// 标题 + 当前状态描述
|
||||||
|
div { class: "flex-1 min-w-0",
|
||||||
|
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb]",
|
||||||
|
"自动清理"
|
||||||
|
}
|
||||||
|
div { class: "text-xs text-gray-500 dark:text-[#9b9c9d] mt-0.5 truncate",
|
||||||
|
if settings().auto_purge_enabled {
|
||||||
|
"已开启 · 超过 {settings().retention_days} 天的文章将被自动删除"
|
||||||
|
} else {
|
||||||
|
"已关闭"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 展开箭头(旋转动画)
|
||||||
|
svg {
|
||||||
|
class: "w-4 h-4 text-gray-400 dark:text-[#9b9c9d] transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
|
||||||
|
view_box: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
stroke_width: "2",
|
||||||
|
path { stroke_linecap: "round", stroke_linejoin: "round", d: "M19 9l-7 7-7-7" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置面板(可折叠)
|
||||||
|
if settings_panel_open() {
|
||||||
|
div { class: "border-t border-gray-100 dark:border-[#3a3a3e] p-5 space-y-6",
|
||||||
|
// 开关行:启用自动清理
|
||||||
|
div {
|
||||||
|
class: "flex items-center justify-between gap-4",
|
||||||
|
div { class: "min-w-0",
|
||||||
|
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb]",
|
||||||
|
"启用自动清理"
|
||||||
|
}
|
||||||
|
div { class: "text-xs text-gray-500 dark:text-[#9b9c9d] mt-1",
|
||||||
|
"后台任务定期彻底删除超过保留期的文章"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 自定义开关(toggle switch)—— 取代原生 checkbox
|
||||||
|
button {
|
||||||
|
role: "switch",
|
||||||
|
aria_checked: "{settings_draft_enabled()}",
|
||||||
|
class: if settings_draft_enabled() {
|
||||||
|
"relative w-11 h-6 flex-shrink-0 rounded-full bg-[#5c7a5e] dark:bg-[#7da97f] cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40"
|
||||||
|
} else {
|
||||||
|
"relative w-11 h-6 flex-shrink-0 rounded-full bg-gray-300 dark:bg-[#555] cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40"
|
||||||
|
},
|
||||||
|
onclick: move |_| {
|
||||||
|
settings_draft_enabled.set(!settings_draft_enabled());
|
||||||
|
just_saved.set(false);
|
||||||
|
},
|
||||||
|
// 滑块圆点
|
||||||
|
span {
|
||||||
|
class: if settings_draft_enabled() {
|
||||||
|
"absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200 translate-x-5"
|
||||||
|
} else {
|
||||||
|
"absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留天数行
|
||||||
|
div { class: "space-y-3",
|
||||||
|
div { class: "min-w-0",
|
||||||
|
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb]",
|
||||||
|
"保留天数"
|
||||||
|
}
|
||||||
|
div { class: "text-xs text-gray-500 dark:text-[#9b9c9d] mt-1",
|
||||||
|
"文章删除后保留的时长,到期后自动彻底清除(1–365)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 数字输入 + 步进按钮 + 单位后缀
|
||||||
|
div { class: "flex items-center gap-3",
|
||||||
|
div { class: "flex items-center rounded-lg border border-gray-300 dark:border-[#444] bg-white dark:bg-[#1d1e20] overflow-hidden",
|
||||||
|
// 减号
|
||||||
|
button {
|
||||||
|
class: "w-9 h-9 flex items-center justify-center text-sm text-gray-500 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] hover:bg-gray-50 dark:hover:bg-[#2a2a2a] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40",
|
||||||
|
r#type: "button",
|
||||||
|
aria_label: "减少保留天数",
|
||||||
|
onclick: move |_| {
|
||||||
|
let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
|
||||||
|
let next = cur.saturating_sub(1).max(1);
|
||||||
|
settings_draft_days.set(next.to_string());
|
||||||
|
just_saved.set(false);
|
||||||
|
},
|
||||||
|
"−"
|
||||||
|
}
|
||||||
|
// 数字输入(无边框,衔接步进按钮)
|
||||||
|
input {
|
||||||
|
r#type: "number",
|
||||||
|
min: "1",
|
||||||
|
max: "365",
|
||||||
|
class: "w-14 h-9 px-1 text-center text-sm tabular-nums text-gray-900 dark:text-[#dadadb] bg-transparent border-0 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
|
||||||
|
value: "{settings_draft_days()}",
|
||||||
|
oninput: move |e| {
|
||||||
|
settings_draft_days.set(e.value());
|
||||||
|
just_saved.set(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// 加号
|
||||||
|
button {
|
||||||
|
class: "w-9 h-9 flex items-center justify-center text-sm text-gray-500 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] hover:bg-gray-50 dark:hover:bg-[#2a2a2a] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40",
|
||||||
|
r#type: "button",
|
||||||
|
aria_label: "增加保留天数",
|
||||||
|
onclick: move |_| {
|
||||||
|
let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
|
||||||
|
let next = cur.saturating_add(1).min(365);
|
||||||
|
settings_draft_days.set(next.to_string());
|
||||||
|
just_saved.set(false);
|
||||||
|
},
|
||||||
|
"+"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span { class: "text-xs text-gray-400 dark:text-[#666]", "天" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部操作行:未保存提示 + 保存按钮
|
||||||
|
div { class: "flex items-center justify-between gap-4 pt-1",
|
||||||
|
// 草稿状态提示
|
||||||
|
if just_saved() {
|
||||||
|
span { class: "inline-flex items-center gap-1.5 text-xs text-[#5c7a5e] dark:text-[#7da97f]",
|
||||||
|
svg { class: "w-3.5 h-3.5", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2.5",
|
||||||
|
path { stroke_linecap: "round", stroke_linejoin: "round", d: "M5 13l4 4L19 7" }
|
||||||
|
}
|
||||||
|
"已保存"
|
||||||
|
}
|
||||||
|
} else if dirty {
|
||||||
|
span { class: "text-xs text-gray-400 dark:text-[#666]",
|
||||||
|
"有未保存的更改"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span { class: "text-xs text-transparent select-none", "·" }
|
||||||
|
}
|
||||||
|
// 保存按钮:启用主题色,禁用/保存中态灰化
|
||||||
|
button {
|
||||||
|
class: if saving_settings() {
|
||||||
|
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] bg-gray-100 dark:bg-[#2a2a2a] rounded-full"
|
||||||
|
} else if just_saved() {
|
||||||
|
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] bg-gray-100 dark:bg-[#2a2a2a] rounded-full"
|
||||||
|
} else {
|
||||||
|
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-white bg-[#5c7a5e] dark:bg-[#7da97f] dark:text-[#1d1e20] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40"
|
||||||
|
},
|
||||||
|
disabled: saving_settings() || just_saved() || !dirty,
|
||||||
|
onclick: move |_| {
|
||||||
|
let days: i32 = settings_draft_days().parse().unwrap_or(30);
|
||||||
|
let enabled = settings_draft_enabled();
|
||||||
|
saving_settings.set(true);
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(s) = update_trash_settings(enabled, days).await {
|
||||||
|
settings.set(s);
|
||||||
|
just_saved.set(true);
|
||||||
|
}
|
||||||
|
saving_settings.set(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
if saving_settings() { "保存中…" } else { "保存设置" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量操作栏(选中时显示)
|
||||||
|
if !selected_ids().is_empty() {
|
||||||
|
div { class: "flex items-center gap-3 p-3 bg-gray-50 dark:bg-[#2a2a2a] rounded-lg",
|
||||||
|
span { class: "text-sm text-gray-600 dark:text-[#9b9c9d]",
|
||||||
|
"已选择 {selected_ids().len()} 条"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "{BTN_SOLID_GREEN}",
|
||||||
|
onclick: move |_| {
|
||||||
|
let ids: Vec<i32> = selected_ids().iter().copied().collect();
|
||||||
|
spawn(async move {
|
||||||
|
let _ = batch_restore_posts(ids).await;
|
||||||
|
});
|
||||||
|
for id in selected_ids() { remove_post(id); }
|
||||||
|
selected_ids.set(HashSet::new());
|
||||||
|
},
|
||||||
|
"批量恢复"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "{BTN_SOLID_RED}",
|
||||||
|
onclick: move |_| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if web_sys::window()
|
||||||
|
.and_then(|w| w.confirm_with_message("确定要彻底删除选中的文章吗?此操作不可恢复。").ok())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let ids: Vec<i32> = selected_ids().iter().copied().collect();
|
||||||
|
spawn(async move {
|
||||||
|
let _ = batch_purge_posts(ids).await;
|
||||||
|
});
|
||||||
|
for id in selected_ids() { remove_post(id); }
|
||||||
|
selected_ids.set(HashSet::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"批量彻底删除"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主内容:错误 / 加载骨架 / 空态 / 列表
|
||||||
|
{
|
||||||
|
if error().is_some() {
|
||||||
|
rsx! { EmptyState { message: "加载失败", variant: "error" } }
|
||||||
|
} else if loading() && posts().is_empty() {
|
||||||
|
rsx! {
|
||||||
|
DelayedSkeleton {
|
||||||
|
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] p-6 space-y-4",
|
||||||
|
for _ in 0..5 {
|
||||||
|
div { class: "h-10 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if posts().is_empty() {
|
||||||
|
rsx! { EmptyState { message: "回收站为空", variant: "default" } }
|
||||||
|
} else {
|
||||||
|
let list = posts();
|
||||||
|
let all_selected = list.iter().all(|p| selected_ids().contains(&p.id));
|
||||||
|
let all_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||||||
|
rsx! {
|
||||||
|
div { class: "{ADMIN_TABLE_CLASS}",
|
||||||
|
div { class: "overflow-x-auto",
|
||||||
|
table { class: "w-full text-sm",
|
||||||
|
thead {
|
||||||
|
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
|
||||||
|
th { class: "px-4 py-3 font-medium w-10",
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
class: "{CHECKBOX_CLASS}",
|
||||||
|
checked: all_selected,
|
||||||
|
onchange: {
|
||||||
|
move |_| {
|
||||||
|
let mut s = selected_ids();
|
||||||
|
if all_selected {
|
||||||
|
for id in &all_ids { s.remove(id); }
|
||||||
|
} else {
|
||||||
|
for id in &all_ids { s.insert(*id); }
|
||||||
|
}
|
||||||
|
selected_ids.set(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th { class: "px-4 py-3 font-medium", "标题" }
|
||||||
|
th { class: "px-4 py-3 font-medium", "原状态" }
|
||||||
|
th { class: "px-4 py-3 font-medium w-28", "删除时间" }
|
||||||
|
th { class: "px-4 py-3 font-medium w-24 text-center", "剩余" }
|
||||||
|
th { class: "px-4 py-3 font-medium w-32 text-right", "操作" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for post in list.iter() {
|
||||||
|
TrashRow {
|
||||||
|
key: "{post.id}",
|
||||||
|
post: post.clone(),
|
||||||
|
retention_days: settings().retention_days,
|
||||||
|
selected: selected_ids().contains(&post.id),
|
||||||
|
on_select: {
|
||||||
|
let id = post.id;
|
||||||
|
move |checked: bool| {
|
||||||
|
let mut s = selected_ids();
|
||||||
|
if checked { s.insert(id); } else { s.remove(&id); }
|
||||||
|
selected_ids.set(s);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
on_restore: {
|
||||||
|
let id = post.id;
|
||||||
|
move |_| {
|
||||||
|
spawn(async move {
|
||||||
|
let _ = restore_post(id).await;
|
||||||
|
});
|
||||||
|
remove_post(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
on_purge: {
|
||||||
|
let id = post.id;
|
||||||
|
move |_| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if web_sys::window()
|
||||||
|
.and_then(|w| w.confirm_with_message("确定要彻底删除这篇文章吗?此操作不可恢复。").ok())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
spawn(async move {
|
||||||
|
let _ = purge_post(id).await;
|
||||||
|
});
|
||||||
|
remove_post(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 底部:清空回收站 + 分页
|
||||||
|
div { class: "flex items-center justify-between mt-4",
|
||||||
|
button {
|
||||||
|
class: "px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-900/50 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer",
|
||||||
|
onclick: move |_| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if web_sys::window()
|
||||||
|
.and_then(|w| w.confirm_with_message("确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。").ok())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
spawn(async move {
|
||||||
|
let _ = empty_trash().await;
|
||||||
|
});
|
||||||
|
posts.set(Vec::new());
|
||||||
|
total.set(0);
|
||||||
|
selected_ids.set(HashSet::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"清空回收站"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Pagination {
|
||||||
|
variant: "admin",
|
||||||
|
current_page,
|
||||||
|
total: total(),
|
||||||
|
per_page: TRASH_PER_PAGE,
|
||||||
|
prev_route: if current_page - 1 <= 1 {
|
||||||
|
Route::Trash {}
|
||||||
|
} else {
|
||||||
|
Route::TrashPage { page: current_page - 1 }
|
||||||
|
},
|
||||||
|
next_route: Route::TrashPage { page: current_page + 1 },
|
||||||
|
unit: "篇",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算剩余天数(保留期 - 已删除天数)。
|
||||||
|
///
|
||||||
|
/// 返回 (剩余天数, 是否已过期)。基于客户端时钟计算,轻微漂移可接受。
|
||||||
|
fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(deleted_at) = post.deleted_at {
|
||||||
|
let now_ms = js_sys::Date::now() as i64; // 毫秒
|
||||||
|
let deleted_ms = deleted_at.timestamp_millis();
|
||||||
|
let elapsed_days = (now_ms - deleted_ms) / 86_400_000;
|
||||||
|
let remaining = retention_days as i64 - elapsed_days;
|
||||||
|
(remaining, remaining <= 0)
|
||||||
|
} else {
|
||||||
|
(retention_days as i64, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let _ = post;
|
||||||
|
(retention_days as i64, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回收站表格行组件。
|
||||||
|
#[component]
|
||||||
|
fn TrashRow(
|
||||||
|
post: Post,
|
||||||
|
retention_days: i32,
|
||||||
|
selected: bool,
|
||||||
|
on_select: EventHandler<bool>,
|
||||||
|
on_restore: EventHandler,
|
||||||
|
on_purge: EventHandler,
|
||||||
|
) -> Element {
|
||||||
|
let (remaining, expired) = remaining_days(&post, retention_days);
|
||||||
|
// 剩余天数徽章配色:>7 天中性,≤7 天鼠尾草绿(主题色),≤0/过期琥珀色。
|
||||||
|
let badge_class = if expired {
|
||||||
|
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||||
|
} else if remaining <= 7 {
|
||||||
|
"bg-[#e8f0e8] text-[#5c7a5e] dark:bg-[#1e2e1e] dark:text-[#7da97f]"
|
||||||
|
} else {
|
||||||
|
"bg-gray-100 text-gray-600 dark:bg-[#333] dark:text-[#9b9c9d]"
|
||||||
|
};
|
||||||
|
let badge_text = if expired { "待清理".to_string() } else { format!("{remaining}天") };
|
||||||
|
let deleted_str = post
|
||||||
|
.deleted_at
|
||||||
|
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_else(|| "—".to_string());
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
tr { class: "{ADMIN_ROW_HOVER}",
|
||||||
|
td { class: "px-4 py-3",
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
class: "{CHECKBOX_CLASS}",
|
||||||
|
checked: selected,
|
||||||
|
onchange: move |e| on_select.call(e.checked()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { class: "px-4 py-3",
|
||||||
|
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb] truncate max-w-xs",
|
||||||
|
"{post.title}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { class: "px-4 py-3",
|
||||||
|
StatusBadge {
|
||||||
|
color_class: post.status_badge_class(),
|
||||||
|
label: post.status_label().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||||
|
"{deleted_str}"
|
||||||
|
}
|
||||||
|
td { class: "px-4 py-3 text-center",
|
||||||
|
StatusBadge {
|
||||||
|
color_class: badge_class,
|
||||||
|
label: badge_text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { class: "px-4 py-3 text-right",
|
||||||
|
div { class: "flex justify-end gap-2",
|
||||||
|
button {
|
||||||
|
class: "{BTN_TEXT_ACCENT}",
|
||||||
|
onclick: move |_| on_restore.call(()),
|
||||||
|
"恢复"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "{BTN_TEXT_RED}",
|
||||||
|
onclick: move |_| on_purge.call(()),
|
||||||
|
"彻底删除"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -10,12 +10,12 @@
|
|||||||
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
|
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus::router::components::Link;
|
|
||||||
|
|
||||||
use crate::api::posts::{list_published_posts, PostListResponse};
|
use crate::api::posts::{list_published_posts, PostListResponse};
|
||||||
use crate::components::post_card::PostCard;
|
use crate::components::post_card::PostCard;
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::skeletons::home_skeleton::HomeSkeleton;
|
use crate::components::skeletons::home_skeleton::HomeSkeleton;
|
||||||
|
use crate::components::ui::Pagination;
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
// 每页展示的已发布文章数量,用于分页计算。
|
// 每页展示的已发布文章数量,用于分页计算。
|
||||||
@ -70,7 +70,20 @@ fn HomePosts(current_page: i32) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 在列表底部渲染分页导航。
|
// 在列表底部渲染分页导航。
|
||||||
Pagination { current_page, total }
|
// frontend variant 不渲染页码计数,unit 不显示(仅满足必填 prop)。
|
||||||
|
Pagination {
|
||||||
|
variant: "frontend",
|
||||||
|
current_page,
|
||||||
|
total,
|
||||||
|
per_page: POSTS_PER_PAGE,
|
||||||
|
prev_route: if current_page - 1 <= 1 {
|
||||||
|
Route::Home {}
|
||||||
|
} else {
|
||||||
|
Route::HomePage { page: current_page - 1 }
|
||||||
|
},
|
||||||
|
next_route: Route::HomePage { page: current_page + 1 },
|
||||||
|
unit: "篇",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
@ -103,42 +116,3 @@ fn HomeInfo() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 分页导航组件。
|
|
||||||
///
|
|
||||||
/// 根据当前页码与文章总数计算总页数,并渲染上一页/下一页链接。
|
|
||||||
/// 第一页的上一页链接固定指向 `Route::Home`,避免生成 `/page/1`。
|
|
||||||
#[component]
|
|
||||||
fn Pagination(current_page: i32, total: i64) -> Element {
|
|
||||||
let has_prev = current_page > 1;
|
|
||||||
// 向上取整计算总页数,至少为 1 页。
|
|
||||||
let total_pages = ((total + POSTS_PER_PAGE as i64 - 1) / POSTS_PER_PAGE as i64).max(1) as i32;
|
|
||||||
let has_next = current_page < total_pages;
|
|
||||||
let prev = current_page - 1;
|
|
||||||
// 当上一页为第 1 页时,使用 `/` 路由而非 `/page/1`。
|
|
||||||
let prev_route = if prev <= 1 {
|
|
||||||
Route::Home {}
|
|
||||||
} else {
|
|
||||||
Route::HomePage { page: prev }
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
nav { class: "flex mt-10 mb-6 justify-between",
|
|
||||||
if has_prev {
|
|
||||||
Link {
|
|
||||||
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
|
|
||||||
to: prev_route,
|
|
||||||
span { class: "mr-1", "«" }
|
|
||||||
"上一页"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if has_next {
|
|
||||||
Link {
|
|
||||||
class: "ml-auto inline-flex items-center px-4 py-2 text-sm text-white bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
|
|
||||||
to: Route::HomePage { page: current_page + 1 },
|
|
||||||
"下一页"
|
|
||||||
span { class: "ml-1", "»" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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, PostsPage, Write, WriteEdit,
|
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Trash, TrashPage, Write, WriteEdit,
|
||||||
};
|
};
|
||||||
use crate::pages::archives::Archives;
|
use crate::pages::archives::Archives;
|
||||||
use crate::pages::home::{Home, HomePage};
|
use crate::pages::home::{Home, HomePage};
|
||||||
@ -84,6 +84,12 @@ pub enum Route {
|
|||||||
/// 评论管理分页
|
/// 评论管理分页
|
||||||
#[route("/comments/:page")]
|
#[route("/comments/:page")]
|
||||||
AdminCommentsPage { page: i32 },
|
AdminCommentsPage { page: i32 },
|
||||||
|
/// 回收站
|
||||||
|
#[route("/trash")]
|
||||||
|
Trash {},
|
||||||
|
/// 回收站分页
|
||||||
|
#[route("/trash/:page")]
|
||||||
|
TrashPage { page: i32 },
|
||||||
#[end_layout]
|
#[end_layout]
|
||||||
#[end_nest]
|
#[end_nest]
|
||||||
|
|
||||||
|
|||||||
@ -8,3 +8,6 @@ pub mod ip_purge;
|
|||||||
/// 定时删除已过期会话,避免 `sessions` 表无限增长。
|
/// 定时删除已过期会话,避免 `sessions` 表无限增长。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod session_cleanup;
|
pub mod session_cleanup;
|
||||||
|
/// 定时清理回收站中超过保留期的已删除文章。
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub mod post_purge;
|
||||||
|
|||||||
74
src/tasks/post_purge.rs
Normal file
74
src/tasks/post_purge.rs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
//! 回收站自动清理后台任务。
|
||||||
|
//!
|
||||||
|
//! 仅在 `server` feature 启用时编译,每天运行一次。
|
||||||
|
//! 每次执行前读取 settings 表:若自动清理关闭则跳过,否则按保留天数物理删除过期文章。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::time::interval;
|
||||||
|
|
||||||
|
use crate::db::pool::get_conn;
|
||||||
|
use crate::models::settings::TrashSettings;
|
||||||
|
|
||||||
|
/// 启动回收站自动清理循环,每天触发一次。
|
||||||
|
///
|
||||||
|
/// 每次读取最新配置:若 `trash_auto_purge_enabled` 关闭则 no-op,
|
||||||
|
/// 否则删除 `deleted_at < NOW() - retention_days` 的文章。
|
||||||
|
/// 任何错误只记录日志,不中断循环。
|
||||||
|
pub async fn run_purge() {
|
||||||
|
let mut ticker = interval(Duration::from_secs(86400));
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
match get_conn().await {
|
||||||
|
Ok(client) => {
|
||||||
|
match purge_expired(&client).await {
|
||||||
|
Ok(n) => {
|
||||||
|
if n > 0 {
|
||||||
|
tracing::info!("Post auto-purge: removed {} expired trashed posts", n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("Post auto-purge error: {:?}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("Failed to get DB connection for post purge: {:?}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取配置并删除过期回收站文章,返回删除行数。
|
||||||
|
///
|
||||||
|
/// 抽取为独立函数便于单元测试(虽需 DB,但逻辑集中)。
|
||||||
|
async fn purge_expired(client: &tokio_postgres::Client) -> Result<u64, tokio_postgres::Error> {
|
||||||
|
// 读取配置,缺键时回退默认值。
|
||||||
|
let enabled: bool = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT value FROM settings WHERE key = 'trash_auto_purge_enabled'",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.and_then(|r| r.get::<_, String>("value").parse().ok())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let days: i32 = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT value FROM settings WHERE key = 'trash_retention_days'",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.and_then(|r| r.get::<_, String>("value").parse().ok())
|
||||||
|
.unwrap_or(30);
|
||||||
|
|
||||||
|
let days = TrashSettings::clamp_retention(days);
|
||||||
|
|
||||||
|
let n = client
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM posts WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - make_interval(days => $1)",
|
||||||
|
&[&days],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
227
syntaxes/JSX.sublime-syntax
Normal file
227
syntaxes/JSX.sublime-syntax
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
%YAML 1.2
|
||||||
|
---
|
||||||
|
# https://react.dev/learn/writing-markup-with-jsx
|
||||||
|
# JavaScript + JSX. Pure JS (no TypeScript types) with JSX tag support.
|
||||||
|
|
||||||
|
name: JSX
|
||||||
|
scope: source.jsx
|
||||||
|
|
||||||
|
file_extensions:
|
||||||
|
- jsx
|
||||||
|
|
||||||
|
variables:
|
||||||
|
identifier: '[A-Za-z_$][A-Za-z0-9_$]*'
|
||||||
|
line_break: '(?:\x0D\x0A?|\x0A)'
|
||||||
|
|
||||||
|
contexts:
|
||||||
|
main:
|
||||||
|
- include: whitespace
|
||||||
|
- include: comments
|
||||||
|
- include: decorator
|
||||||
|
- include: jsx
|
||||||
|
- include: strings
|
||||||
|
- include: numbers
|
||||||
|
- include: declaration-keywords
|
||||||
|
- include: control-keywords
|
||||||
|
- include: constants
|
||||||
|
- include: function-calls
|
||||||
|
- include: property-access
|
||||||
|
- include: punctuation
|
||||||
|
- include: identifiers
|
||||||
|
|
||||||
|
whitespace:
|
||||||
|
- match: '[\t ]+'
|
||||||
|
|
||||||
|
comments:
|
||||||
|
- match: '/\*'
|
||||||
|
scope: punctuation.definition.comment.jsx
|
||||||
|
push:
|
||||||
|
- meta_scope: comment.block.jsx
|
||||||
|
- match: '\*/'
|
||||||
|
scope: punctuation.definition.comment.jsx
|
||||||
|
pop: 1
|
||||||
|
- match: '//'
|
||||||
|
scope: punctuation.definition.comment.jsx
|
||||||
|
push:
|
||||||
|
- meta_scope: comment.line.double-slash.jsx
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
decorator:
|
||||||
|
- match: '@{{identifier}}(?:\.{{identifier}})*'
|
||||||
|
scope: entity.other.attribute-name.jsx
|
||||||
|
|
||||||
|
strings:
|
||||||
|
- match: '`'
|
||||||
|
scope: punctuation.definition.string.begin.jsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.other.jsx
|
||||||
|
- match: '\$\{'
|
||||||
|
scope: punctuation.section.interpolation.begin.jsx
|
||||||
|
push:
|
||||||
|
- clear_scopes: 1
|
||||||
|
- meta_scope: meta.interpolation.jsx
|
||||||
|
- include: main
|
||||||
|
- match: '\}'
|
||||||
|
scope: punctuation.section.interpolation.end.jsx
|
||||||
|
pop: 1
|
||||||
|
- match: '\\[nrt`\\$]'
|
||||||
|
scope: constant.character.escape.jsx
|
||||||
|
- match: '`'
|
||||||
|
scope: punctuation.definition.string.end.jsx
|
||||||
|
pop: 1
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.begin.jsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.double.jsx
|
||||||
|
- match: '\\[nrt"\\]'
|
||||||
|
scope: constant.character.escape.jsx
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.end.jsx
|
||||||
|
pop: 1
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
scope: invalid.illegal.unclosed-string.jsx
|
||||||
|
pop: 1
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.begin.jsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.single.jsx
|
||||||
|
- match: "\\\\[nrt'\\\\]"
|
||||||
|
scope: constant.character.escape.jsx
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.end.jsx
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
numbers:
|
||||||
|
- match: '\b0[xX][0-9A-Fa-f_]+'
|
||||||
|
scope: constant.numeric.integer.hexadecimal.jsx
|
||||||
|
- match: '\b0[bB][01_]+'
|
||||||
|
scope: constant.numeric.integer.binary.jsx
|
||||||
|
- match: '\b0[oO][0-7_]+'
|
||||||
|
scope: constant.numeric.integer.octal.jsx
|
||||||
|
- match: '\b[0-9][0-9_]*(?:\.[0-9][0-9_]*)?(?:[eE][-+]?[0-9]+)?\b'
|
||||||
|
scope: constant.numeric.decimal.jsx
|
||||||
|
|
||||||
|
declaration-keywords:
|
||||||
|
- match: '\b(?:var|let|const|function|class|import|export|from|as|default|extends|implements)\b'
|
||||||
|
scope: keyword.declaration.jsx
|
||||||
|
|
||||||
|
control-keywords:
|
||||||
|
- match: '\b(?:if|else|for|while|do|switch|case|break|continue|return|throw|try|catch|finally|await|yield|new|delete|typeof|instanceof|in|of)\b'
|
||||||
|
scope: keyword.control.jsx
|
||||||
|
- match: '\b(?:async|await)\b'
|
||||||
|
scope: keyword.control.async.jsx
|
||||||
|
- match: '\b(?:true|false|null|undefined|this|super)\b'
|
||||||
|
scope: constant.language.jsx
|
||||||
|
|
||||||
|
constants:
|
||||||
|
- match: '\b(?:console|Math|JSON|Object|Number|String|Boolean|Symbol|window|document|globalThis|process|React)\b'
|
||||||
|
scope: support.class.jsx
|
||||||
|
- match: '\b[A-Z][A-Z0-9_]+\b'
|
||||||
|
scope: variable.other.constant.jsx
|
||||||
|
|
||||||
|
function-calls:
|
||||||
|
- match: '\b({{identifier}})\s*(?=\()'
|
||||||
|
captures:
|
||||||
|
1: variable.function.jsx
|
||||||
|
|
||||||
|
property-access:
|
||||||
|
- match: '\.({{identifier}})\s*(?=\()'
|
||||||
|
captures:
|
||||||
|
1: variable.function.jsx
|
||||||
|
- match: '\.({{identifier}})\b'
|
||||||
|
captures:
|
||||||
|
1: variable.other.property.jsx
|
||||||
|
|
||||||
|
punctuation:
|
||||||
|
- match: '[\(\)\{\}\[\];,]'
|
||||||
|
scope: punctuation.jsx
|
||||||
|
- match: '=>'
|
||||||
|
scope: storage.type.function.arrow.jsx
|
||||||
|
- match: '[-+*/%=<>!&|?~^]+'
|
||||||
|
scope: keyword.operator.jsx
|
||||||
|
|
||||||
|
identifiers:
|
||||||
|
- match: '\b[A-Z][A-Za-z0-9_$]*\b'
|
||||||
|
scope: entity.name.type.jsx
|
||||||
|
- match: '{{identifier}}'
|
||||||
|
scope: variable.jsx
|
||||||
|
|
||||||
|
# ============ JSX 规则 ============
|
||||||
|
jsx:
|
||||||
|
- match: '(<)((?:[A-Z][A-Za-z0-9_$]*|[a-z][A-Za-z0-9-]*))'
|
||||||
|
captures:
|
||||||
|
1: punctuation.definition.tag.begin.jsx
|
||||||
|
2: entity.name.tag.jsx
|
||||||
|
push:
|
||||||
|
- meta_scope: meta.tag.jsx
|
||||||
|
- include: jsx-attributes
|
||||||
|
- match: '/>'
|
||||||
|
scope: punctuation.definition.tag.end.jsx
|
||||||
|
pop: 1
|
||||||
|
- match: '>'
|
||||||
|
scope: punctuation.definition.tag.end.jsx
|
||||||
|
pop: jsx-body
|
||||||
|
- match: '(</)((?:[A-Z][A-Za-z0-9_$]*|[a-z][A-Za-z0-9-]*))\s*(>)'
|
||||||
|
scope: meta.tag.jsx
|
||||||
|
captures:
|
||||||
|
1: punctuation.definition.tag.begin.jsx
|
||||||
|
2: entity.name.tag.jsx
|
||||||
|
3: punctuation.definition.tag.end.jsx
|
||||||
|
- match: '(<)(>)'
|
||||||
|
captures:
|
||||||
|
1: punctuation.definition.tag.begin.jsx
|
||||||
|
2: punctuation.definition.tag.end.jsx
|
||||||
|
push: jsx-body
|
||||||
|
|
||||||
|
jsx-attributes:
|
||||||
|
- match: '\b([A-Za-z_][A-Za-z0-9_-]*)\s*(=)'
|
||||||
|
captures:
|
||||||
|
1: entity.other.attribute-name.jsx
|
||||||
|
2: keyword.operator.assignment.jsx
|
||||||
|
push:
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.begin.jsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.double.jsx
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.end.jsx
|
||||||
|
pop: 1
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.begin.jsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.single.jsx
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.end.jsx
|
||||||
|
pop: 1
|
||||||
|
- match: '\{'
|
||||||
|
scope: punctuation.section.embedded.begin.jsx
|
||||||
|
push:
|
||||||
|
- clear_scopes: 1
|
||||||
|
- meta_scope: meta.embedded.expression.jsx
|
||||||
|
- include: main
|
||||||
|
- match: '\}'
|
||||||
|
scope: punctuation.section.embedded.end.jsx
|
||||||
|
pop: 1
|
||||||
|
- match: '(?=\S)'
|
||||||
|
pop: 1
|
||||||
|
- include: whitespace
|
||||||
|
- match: '\b[A-Za-z_][A-Za-z0-9_-]*\b'
|
||||||
|
scope: entity.other.attribute-name.jsx
|
||||||
|
|
||||||
|
jsx-body:
|
||||||
|
- meta_include_prototype: false
|
||||||
|
- match: '(?=</)'
|
||||||
|
pop: 1
|
||||||
|
- include: jsx-text-interpolation
|
||||||
|
- include: jsx
|
||||||
|
|
||||||
|
jsx-text-interpolation:
|
||||||
|
- match: '\{'
|
||||||
|
scope: punctuation.section.embedded.begin.jsx
|
||||||
|
push:
|
||||||
|
- meta_scope: meta.embedded.expression.jsx
|
||||||
|
- include: main
|
||||||
|
- match: '\}'
|
||||||
|
scope: punctuation.section.embedded.end.jsx
|
||||||
|
pop: 1
|
||||||
@ -194,6 +194,84 @@ contexts:
|
|||||||
|
|
||||||
expression:
|
expression:
|
||||||
- include: whitespace
|
- include: whitespace
|
||||||
|
- include: comments
|
||||||
|
- include: attribute
|
||||||
- include: string-literal
|
- include: string-literal
|
||||||
- include: floating-point-literal
|
- include: floating-point-literal
|
||||||
- include: integer-literal
|
- include: integer-literal
|
||||||
|
- include: function-declaration
|
||||||
|
- include: declaration-keywords
|
||||||
|
- include: control-keywords
|
||||||
|
- include: modifiers
|
||||||
|
- include: types
|
||||||
|
- include: constants
|
||||||
|
- include: function-calls
|
||||||
|
- include: types-and-identifiers
|
||||||
|
|
||||||
|
comments:
|
||||||
|
- include: comment
|
||||||
|
- include: multiline-comment
|
||||||
|
|
||||||
|
# 属性:@MainActor、@objc、@available(iOS 14, *)
|
||||||
|
attribute:
|
||||||
|
- match: '@\b{{identifier_head}}{{identifier_character}}*'
|
||||||
|
scope: meta.attribute.swift entity.other.attribute-name.swift
|
||||||
|
|
||||||
|
# 声明类关键字
|
||||||
|
declaration-keywords:
|
||||||
|
- match: '\b(?:import|func|class|struct|enum|protocol|extension|let|var|typealias|init|deinit|subscript|operator|precedencegroup|actor|macro)\b'
|
||||||
|
scope: keyword.declaration.swift
|
||||||
|
- match: '\b(?:associatedtype|fileprivate|internal|private|public|open|static|final|lazy|weak|unowned|dynamic|inout|mutating|nonmutating|convenience|required|optional|override|indirect|borrowing|consuming|distributed|nonisolated|isolated)\b'
|
||||||
|
scope: storage.modifier.swift
|
||||||
|
|
||||||
|
# 控制流关键字
|
||||||
|
control-keywords:
|
||||||
|
- match: '\b(?:if|else|guard|switch|case|default|for|in|while|repeat|do|where|fallthrough|break|continue|return|throw|throws|rethrows|try|catch|defer|as|is|async|await|yield|some|any|each)\b'
|
||||||
|
scope: keyword.control.swift
|
||||||
|
- match: '\b(?:true|false|nil|self|Self|super)\b'
|
||||||
|
scope: constant.language.swift
|
||||||
|
# try?/try!/as?/as!
|
||||||
|
- match: '\b(?:try|as)([?!])'
|
||||||
|
captures:
|
||||||
|
1: keyword.control.swift
|
||||||
|
|
||||||
|
# 单独列出的修饰符(已在 declaration-keywords 内,此处保留占位以便扩展)
|
||||||
|
modifiers: []
|
||||||
|
|
||||||
|
# 内置/标准库常用类型
|
||||||
|
types:
|
||||||
|
- match: '\b(?:Int|UInt|Int8|Int16|Int32|Int64|UInt8|UInt16|UInt32|UInt64|Float|Double|Bool|String|Character|Void|Optional|Array|Dictionary|Set|Range|ClosedRange|Any|Never|Result)\b'
|
||||||
|
scope: support.type.swift
|
||||||
|
|
||||||
|
# 常量与全局函数
|
||||||
|
constants:
|
||||||
|
- match: '\b(?:print|println|debugPrint|fatalError|assert|assertionFailure|precondition|preconditionFailure|min|max|abs|count|append|contains|map|filter|reduce|compactMap|flatMap|sorted|first|last|isEmpty)\b'
|
||||||
|
scope: support.function.swift
|
||||||
|
|
||||||
|
# 函数声明:func name(...) 与 init/subscript
|
||||||
|
function-declaration:
|
||||||
|
- match: '\b(func)\s+({{identifier_head}}{{identifier_character}}*|`[^`]+`|operator)'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.swift
|
||||||
|
2: entity.name.function.swift
|
||||||
|
- match: '\b(init)\b'
|
||||||
|
scope: keyword.declaration.swift entity.name.function.swift
|
||||||
|
- match: '\b(deinit)\b'
|
||||||
|
scope: keyword.declaration.swift
|
||||||
|
- match: '\b(subscript)\b'
|
||||||
|
scope: keyword.declaration.swift
|
||||||
|
|
||||||
|
# 函数调用:name( 或 name <Generic>(
|
||||||
|
function-calls:
|
||||||
|
- match: '\b{{identifier_head}}{{identifier_character}}*(?=\s*(?:<[^(){}\n]*>)?\s*\()'
|
||||||
|
scope: variable.function.swift
|
||||||
|
- match: '\b{{identifier_head}}{{identifier_character}}*(?=\s*\{)'
|
||||||
|
scope: variable.function.swift
|
||||||
|
|
||||||
|
# 普通标识符(大写识别为类型风格,小写为变量)
|
||||||
|
types-and-identifiers:
|
||||||
|
- match: '\b[A-Z]{{identifier_character}}*\b'
|
||||||
|
scope: entity.name.type.swift
|
||||||
|
- match: '\b{{identifier_head}}{{identifier_character}}*\b'
|
||||||
|
scope: variable.swift
|
||||||
|
|
||||||
|
|||||||
270
syntaxes/TSX.sublime-syntax
Normal file
270
syntaxes/TSX.sublime-syntax
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
%YAML 1.2
|
||||||
|
---
|
||||||
|
# https://www.typescriptlang.org/docs/handbook/jsx.html
|
||||||
|
# TypeScript + JSX (TSX). Based on TypeScript syntax but with JSX tag support.
|
||||||
|
|
||||||
|
name: TSX
|
||||||
|
scope: source.tsx
|
||||||
|
|
||||||
|
file_extensions:
|
||||||
|
- tsx
|
||||||
|
|
||||||
|
variables:
|
||||||
|
identifier: '[A-Za-z_$][A-Za-z0-9_$]*'
|
||||||
|
line_break: '(?:\x0D\x0A?|\x0A)'
|
||||||
|
|
||||||
|
contexts:
|
||||||
|
main:
|
||||||
|
- include: whitespace
|
||||||
|
- include: comments
|
||||||
|
- include: decorator
|
||||||
|
- include: jsx
|
||||||
|
- include: strings
|
||||||
|
- include: numbers
|
||||||
|
- include: declaration-keywords
|
||||||
|
- include: control-keywords
|
||||||
|
- include: modifiers
|
||||||
|
- include: type-declaration
|
||||||
|
- include: function-declaration
|
||||||
|
- include: types
|
||||||
|
- include: constants
|
||||||
|
- include: function-calls
|
||||||
|
- include: property-access
|
||||||
|
- include: punctuation
|
||||||
|
- include: identifiers
|
||||||
|
|
||||||
|
whitespace:
|
||||||
|
- match: '[\t ]+'
|
||||||
|
|
||||||
|
comments:
|
||||||
|
- match: '/\*'
|
||||||
|
scope: punctuation.definition.comment.tsx
|
||||||
|
push:
|
||||||
|
- meta_scope: comment.block.tsx
|
||||||
|
- match: '\*/'
|
||||||
|
scope: punctuation.definition.comment.tsx
|
||||||
|
pop: 1
|
||||||
|
- match: '//'
|
||||||
|
scope: punctuation.definition.comment.tsx
|
||||||
|
push:
|
||||||
|
- meta_scope: comment.line.double-slash.tsx
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
decorator:
|
||||||
|
- match: '@{{identifier}}(?:\.{{identifier}})*'
|
||||||
|
scope: entity.other.attribute-name.tsx
|
||||||
|
|
||||||
|
strings:
|
||||||
|
- match: '`'
|
||||||
|
scope: punctuation.definition.string.begin.tsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.other.tsx
|
||||||
|
- match: '\$\{'
|
||||||
|
scope: punctuation.section.interpolation.begin.tsx
|
||||||
|
push:
|
||||||
|
- clear_scopes: 1
|
||||||
|
- meta_scope: meta.interpolation.tsx
|
||||||
|
- include: main
|
||||||
|
- match: '\}'
|
||||||
|
scope: punctuation.section.interpolation.end.tsx
|
||||||
|
pop: 1
|
||||||
|
- match: '\\[nrt`\\$]'
|
||||||
|
scope: constant.character.escape.tsx
|
||||||
|
- match: '`'
|
||||||
|
scope: punctuation.definition.string.end.tsx
|
||||||
|
pop: 1
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.begin.tsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.double.tsx
|
||||||
|
- match: '\\[nrt"\\]'
|
||||||
|
scope: constant.character.escape.tsx
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.end.tsx
|
||||||
|
pop: 1
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
scope: invalid.illegal.unclosed-string.tsx
|
||||||
|
pop: 1
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.begin.tsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.single.tsx
|
||||||
|
- match: "\\\\[nrt'\\\\]"
|
||||||
|
scope: constant.character.escape.tsx
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.end.tsx
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
numbers:
|
||||||
|
- match: '\b0[xX][0-9A-Fa-f_]+'
|
||||||
|
scope: constant.numeric.integer.hexadecimal.tsx
|
||||||
|
- match: '\b0[bB][01_]+'
|
||||||
|
scope: constant.numeric.integer.binary.tsx
|
||||||
|
- match: '\b0[oO][0-7_]+'
|
||||||
|
scope: constant.numeric.integer.octal.tsx
|
||||||
|
- match: '\b[0-9][0-9_]*(?:\.[0-9][0-9_]*)?(?:[eE][-+]?[0-9]+)?\b'
|
||||||
|
scope: constant.numeric.decimal.tsx
|
||||||
|
|
||||||
|
declaration-keywords:
|
||||||
|
- match: '\b(?:var|let|const|function|class|interface|type|enum|namespace|module|import|export|from|as|default)\b'
|
||||||
|
scope: keyword.declaration.tsx
|
||||||
|
- match: '\b(?:new|delete|typeof|instanceof|in|of|keyof|infer|is|readonly|asserts)\b'
|
||||||
|
scope: keyword.operator.type.tsx
|
||||||
|
|
||||||
|
control-keywords:
|
||||||
|
- match: '\b(?:if|else|for|while|do|switch|case|break|continue|return|throw|try|catch|finally|await|yield)\b'
|
||||||
|
scope: keyword.control.tsx
|
||||||
|
- match: '\b(?:async|await)\b'
|
||||||
|
scope: keyword.control.async.tsx
|
||||||
|
- match: '\b(?:true|false|null|undefined|this|super)\b'
|
||||||
|
scope: constant.language.tsx
|
||||||
|
|
||||||
|
modifiers:
|
||||||
|
- match: '\b(?:public|private|protected|static|abstract|readonly|override|declare|get|set)\b'
|
||||||
|
scope: storage.modifier.tsx
|
||||||
|
|
||||||
|
type-declaration:
|
||||||
|
- match: '\b(type)\s+({{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.tsx
|
||||||
|
2: entity.name.type.tsx
|
||||||
|
- match: '\b(interface)\s+({{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.tsx
|
||||||
|
2: entity.name.type.tsx
|
||||||
|
- match: '\b(enum)\s+({{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.tsx
|
||||||
|
2: entity.name.type.tsx
|
||||||
|
|
||||||
|
function-declaration:
|
||||||
|
- match: '\b(function)\s+\*?\s*({{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.tsx
|
||||||
|
2: entity.name.function.tsx
|
||||||
|
|
||||||
|
types:
|
||||||
|
- match: '\b(?:string|number|boolean|void|object|symbol|bigint|never|unknown|any)\b'
|
||||||
|
scope: support.type.primitive.tsx
|
||||||
|
- match: '\b(?:Array|Promise|Map|Set|Date|RegExp|Error|Record|Partial|Required|Readonly|Pick|Omit|ReturnType|Parameters|InstanceType|ReadonlyArray)\b'
|
||||||
|
scope: support.type.tsx
|
||||||
|
|
||||||
|
constants:
|
||||||
|
- match: '\b(?:console|Math|JSON|Object|Number|String|Boolean|Symbol|window|document|globalThis|process)\b'
|
||||||
|
scope: support.class.tsx
|
||||||
|
- match: '\b[A-Z][A-Z0-9_]+\b'
|
||||||
|
scope: variable.other.constant.tsx
|
||||||
|
|
||||||
|
function-calls:
|
||||||
|
- match: '\b({{identifier}})\s*(?=\()'
|
||||||
|
captures:
|
||||||
|
1: variable.function.tsx
|
||||||
|
|
||||||
|
property-access:
|
||||||
|
- match: '\.({{identifier}})\s*(?=\()'
|
||||||
|
captures:
|
||||||
|
1: variable.function.tsx
|
||||||
|
- match: '\.({{identifier}})\b'
|
||||||
|
captures:
|
||||||
|
1: variable.other.property.tsx
|
||||||
|
|
||||||
|
punctuation:
|
||||||
|
- match: '[\(\)\{\}\[\];,]'
|
||||||
|
scope: punctuation.tsx
|
||||||
|
- match: '=>'
|
||||||
|
scope: storage.type.function.arrow.tsx
|
||||||
|
- match: '[-+*/%=<>!&|?~^]+'
|
||||||
|
scope: keyword.operator.tsx
|
||||||
|
|
||||||
|
identifiers:
|
||||||
|
- match: '\b[A-Z][A-Za-z0-9_$]*\b'
|
||||||
|
scope: entity.name.type.tsx
|
||||||
|
- match: '{{identifier}}'
|
||||||
|
scope: variable.tsx
|
||||||
|
|
||||||
|
# ============ JSX 规则 ============
|
||||||
|
jsx:
|
||||||
|
# 自闭合标签 <Component ... /> 或 <div ... />
|
||||||
|
- match: '(<)((?:[A-Z][A-Za-z0-9_$]*|[a-z][A-Za-z0-9-]*))'
|
||||||
|
captures:
|
||||||
|
1: punctuation.definition.tag.begin.tsx
|
||||||
|
2: entity.name.tag.tsx
|
||||||
|
push:
|
||||||
|
- meta_scope: meta.tag.tsx
|
||||||
|
- include: jsx-attributes
|
||||||
|
- match: '/>'
|
||||||
|
scope: punctuation.definition.tag.end.tsx
|
||||||
|
pop: 1
|
||||||
|
- match: '>'
|
||||||
|
scope: punctuation.definition.tag.end.tsx
|
||||||
|
pop: jsx-body
|
||||||
|
# 结束标签 </Component>
|
||||||
|
- match: '(</)((?:[A-Z][A-Za-z0-9_$]*|[a-z][A-Za-z0-9-]*))\s*(>)'
|
||||||
|
scope: meta.tag.tsx
|
||||||
|
captures:
|
||||||
|
1: punctuation.definition.tag.begin.tsx
|
||||||
|
2: entity.name.tag.tsx
|
||||||
|
3: punctuation.definition.tag.end.tsx
|
||||||
|
# JSX 片段开始 <></>
|
||||||
|
- match: '(<)(>)'
|
||||||
|
captures:
|
||||||
|
1: punctuation.definition.tag.begin.tsx
|
||||||
|
2: punctuation.definition.tag.end.tsx
|
||||||
|
push: jsx-body
|
||||||
|
|
||||||
|
# JSX 标签属性
|
||||||
|
jsx-attributes:
|
||||||
|
- match: '\b([A-Za-z_][A-Za-z0-9_-]*)\s*(=)'
|
||||||
|
captures:
|
||||||
|
1: entity.other.attribute-name.tsx
|
||||||
|
2: keyword.operator.assignment.tsx
|
||||||
|
push:
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.begin.tsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.double.tsx
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.end.tsx
|
||||||
|
pop: 1
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.begin.tsx
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.single.tsx
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.end.tsx
|
||||||
|
pop: 1
|
||||||
|
# 属性值里的表达式 {expr}
|
||||||
|
- match: '\{'
|
||||||
|
scope: punctuation.section.embedded.begin.tsx
|
||||||
|
push:
|
||||||
|
- clear_scopes: 1
|
||||||
|
- meta_scope: meta.embedded.expression.tsx
|
||||||
|
- include: main
|
||||||
|
- match: '\}'
|
||||||
|
scope: punctuation.section.embedded.end.tsx
|
||||||
|
pop: 1
|
||||||
|
- match: '(?=\S)'
|
||||||
|
pop: 1
|
||||||
|
- include: whitespace
|
||||||
|
- match: '\b[A-Za-z_][A-Za-z0-9_-]*\b'
|
||||||
|
scope: entity.other.attribute-name.tsx
|
||||||
|
|
||||||
|
# JSX 元素体:标签之间的内容,可嵌套子标签或表达式
|
||||||
|
jsx-body:
|
||||||
|
- meta_include_prototype: false
|
||||||
|
- match: '(?=</)'
|
||||||
|
pop: 1
|
||||||
|
- include: jsx-text-interpolation
|
||||||
|
- include: jsx
|
||||||
|
|
||||||
|
# JSX 体内的文本与 {expr} 表达式
|
||||||
|
jsx-text-interpolation:
|
||||||
|
- match: '\{'
|
||||||
|
scope: punctuation.section.embedded.begin.tsx
|
||||||
|
push:
|
||||||
|
- meta_scope: meta.embedded.expression.tsx
|
||||||
|
- include: main
|
||||||
|
- match: '\}'
|
||||||
|
scope: punctuation.section.embedded.end.tsx
|
||||||
|
pop: 1
|
||||||
221
syntaxes/TypeScript.sublime-syntax
Normal file
221
syntaxes/TypeScript.sublime-syntax
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
%YAML 1.2
|
||||||
|
---
|
||||||
|
# https://www.typescriptlang.org/docs/handbook/intro.html
|
||||||
|
# Sublime Text syntax definition for TypeScript / TSX.
|
||||||
|
|
||||||
|
name: TypeScript
|
||||||
|
scope: source.ts
|
||||||
|
|
||||||
|
file_extensions:
|
||||||
|
- ts
|
||||||
|
- mts
|
||||||
|
- cts
|
||||||
|
|
||||||
|
variables:
|
||||||
|
identifier: '[A-Za-z_$][A-Za-z0-9_$]*'
|
||||||
|
line_break: '(?:\x0D\x0A?|\x0A)'
|
||||||
|
|
||||||
|
contexts:
|
||||||
|
main:
|
||||||
|
- include: whitespace
|
||||||
|
- include: comments
|
||||||
|
- include: decorator
|
||||||
|
- include: strings
|
||||||
|
- include: numbers
|
||||||
|
- include: declaration-keywords
|
||||||
|
- include: control-keywords
|
||||||
|
- include: modifiers
|
||||||
|
- include: type-declaration
|
||||||
|
- include: function-declaration
|
||||||
|
- include: types
|
||||||
|
- include: constants
|
||||||
|
- include: function-calls
|
||||||
|
- include: property-access
|
||||||
|
- include: punctuation
|
||||||
|
- include: identifiers
|
||||||
|
|
||||||
|
whitespace:
|
||||||
|
- match: '[\t ]+'
|
||||||
|
|
||||||
|
comments:
|
||||||
|
- match: '/\*'
|
||||||
|
scope: punctuation.definition.comment.ts
|
||||||
|
push:
|
||||||
|
- meta_scope: comment.block.ts
|
||||||
|
- match: '\*/'
|
||||||
|
scope: punctuation.definition.comment.ts
|
||||||
|
pop: 1
|
||||||
|
- match: '//'
|
||||||
|
scope: punctuation.definition.comment.ts
|
||||||
|
push:
|
||||||
|
- meta_scope: comment.line.double-slash.ts
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
# 装饰器:@Component、@Injectable
|
||||||
|
decorator:
|
||||||
|
- match: '@{{identifier}}(?:\.{{identifier}})*'
|
||||||
|
scope: entity.other.attribute-name.ts
|
||||||
|
push:
|
||||||
|
- match: '\('
|
||||||
|
scope: punctuation.section.arguments.begin.ts
|
||||||
|
push:
|
||||||
|
- match: '\)'
|
||||||
|
scope: punctuation.section.arguments.end.ts
|
||||||
|
pop: 1
|
||||||
|
- include: main
|
||||||
|
- match: '(?=\S)'
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
strings:
|
||||||
|
# 模板字符串 `...${expr}...`
|
||||||
|
- match: '`'
|
||||||
|
scope: punctuation.definition.string.begin.ts
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.other.ts
|
||||||
|
- match: '\$\{'
|
||||||
|
scope: punctuation.section.interpolation.begin.ts
|
||||||
|
push:
|
||||||
|
- clear_scopes: 1
|
||||||
|
- meta_scope: meta.interpolation.ts
|
||||||
|
- include: main
|
||||||
|
- match: '\}'
|
||||||
|
scope: punctuation.section.interpolation.end.ts
|
||||||
|
pop: 1
|
||||||
|
- match: '\\[nrt`\\$]'
|
||||||
|
scope: constant.character.escape.ts
|
||||||
|
- match: '`'
|
||||||
|
scope: punctuation.definition.string.end.ts
|
||||||
|
pop: 1
|
||||||
|
# 双引号字符串
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.begin.ts
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.double.ts
|
||||||
|
- match: '\\[nrt"\\]'
|
||||||
|
scope: constant.character.escape.ts
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.end.ts
|
||||||
|
pop: 1
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
scope: invalid.illegal.unclosed-string.ts
|
||||||
|
pop: 1
|
||||||
|
# 单引号字符串
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.begin.ts
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.single.ts
|
||||||
|
- match: "\\\\[nrt'\\\\]"
|
||||||
|
scope: constant.character.escape.ts
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.end.ts
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
numbers:
|
||||||
|
# 十六进制
|
||||||
|
- match: '\b0[xX][0-9A-Fa-f_]+'
|
||||||
|
scope: constant.numeric.integer.hexadecimal.ts
|
||||||
|
# 二进制
|
||||||
|
- match: '\b0[bB][01_]+'
|
||||||
|
scope: constant.numeric.integer.binary.ts
|
||||||
|
# 八进制
|
||||||
|
- match: '\b0[oO][0-7_]+'
|
||||||
|
scope: constant.numeric.integer.octal.ts
|
||||||
|
# 浮点(含科学计数法)
|
||||||
|
- match: '\b[0-9][0-9_]*(?:\.[0-9][0-9_]*)?(?:[eE][-+]?[0-9]+)?\b'
|
||||||
|
scope: constant.numeric.decimal.ts
|
||||||
|
# BigInt 后缀
|
||||||
|
- match: '\b[0-9][0-9_]*n\b'
|
||||||
|
scope: constant.numeric.integer.decimal.ts
|
||||||
|
|
||||||
|
# 声明类关键字
|
||||||
|
declaration-keywords:
|
||||||
|
- match: '\b(?:var|let|const|function|class|interface|type|enum|namespace|module|import|export|from|as|default)\b'
|
||||||
|
scope: keyword.declaration.ts
|
||||||
|
- match: '\b(?:new|delete|typeof|instanceof|in|of|keyof|infer|is|readonly|asserts)\b'
|
||||||
|
scope: keyword.operator.type.ts
|
||||||
|
|
||||||
|
# 控制流关键字
|
||||||
|
control-keywords:
|
||||||
|
- match: '\b(?:if|else|for|while|do|switch|case|break|continue|return|throw|try|catch|finally|await|yield)\b'
|
||||||
|
scope: keyword.control.ts
|
||||||
|
- match: '\b(?:async|await)\b'
|
||||||
|
scope: keyword.control.async.ts
|
||||||
|
- match: '\b(?:true|false|null|undefined|this|super)\b'
|
||||||
|
scope: constant.language.ts
|
||||||
|
|
||||||
|
# 访问修饰符
|
||||||
|
modifiers:
|
||||||
|
- match: '\b(?:public|private|protected|static|abstract|readonly|override|declare|get|set)\b'
|
||||||
|
scope: storage.modifier.ts
|
||||||
|
|
||||||
|
# type 别名与 interface 的名字识别
|
||||||
|
type-declaration:
|
||||||
|
- match: '\b(type)\s+({{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.ts
|
||||||
|
2: entity.name.type.ts
|
||||||
|
- match: '\b(interface)\s+({{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.ts
|
||||||
|
2: entity.name.type.ts
|
||||||
|
- match: '\b(enum)\s+({{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.ts
|
||||||
|
2: entity.name.type.ts
|
||||||
|
|
||||||
|
# 函数声明:function name( / 方法名(
|
||||||
|
function-declaration:
|
||||||
|
- match: '\b(function)\s+\*?\s*({{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.ts
|
||||||
|
2: entity.name.function.ts
|
||||||
|
- match: '\b({{identifier}})\s*(?=\(|\<)'
|
||||||
|
captures:
|
||||||
|
1: entity.name.function.ts
|
||||||
|
|
||||||
|
# 内置/常用类型
|
||||||
|
types:
|
||||||
|
- match: '\b(?:string|number|boolean|void|object|symbol|bigint|never|unknown|any)\b'
|
||||||
|
scope: support.type.primitive.ts
|
||||||
|
- match: '\b(?:Array|Promise|Map|Set|Date|RegExp|Error|Record|Partial|Required|Readonly|Pick|Omit|ReturnType|Parameters|InstanceType|ReadonlyArray)\b'
|
||||||
|
scope: support.type.ts
|
||||||
|
|
||||||
|
# 常量
|
||||||
|
constants:
|
||||||
|
- match: '\b(?:console|Math|JSON|Object|Number|String|Boolean|Symbol|window|document|globalThis|process)\b'
|
||||||
|
scope: support.class.ts
|
||||||
|
- match: '\b[A-Z][A-Z0-9_]+\b'
|
||||||
|
scope: variable.other.constant.ts
|
||||||
|
|
||||||
|
# 函数调用
|
||||||
|
function-calls:
|
||||||
|
- match: '\b({{identifier}})\s*(?=\()'
|
||||||
|
captures:
|
||||||
|
1: variable.function.ts
|
||||||
|
|
||||||
|
# 属性访问:.method(
|
||||||
|
property-access:
|
||||||
|
- match: '\.({{identifier}})\s*(?=\()'
|
||||||
|
captures:
|
||||||
|
1: variable.function.ts
|
||||||
|
- match: '\.({{identifier}})\b'
|
||||||
|
captures:
|
||||||
|
1: variable.other.property.ts
|
||||||
|
|
||||||
|
punctuation:
|
||||||
|
- match: '[\(\)\{\}\[\];,]'
|
||||||
|
scope: punctuation.ts
|
||||||
|
- match: '=>'
|
||||||
|
scope: storage.type.function.arrow.ts
|
||||||
|
- match: '<(?=[A-Za-z_$])'
|
||||||
|
scope: punctuation.definition.typeparameters.begin.ts
|
||||||
|
- match: '=>|[-+*/%=<>!&|?~^]+'
|
||||||
|
scope: keyword.operator.ts
|
||||||
|
|
||||||
|
# 大写开头的标识符视为类型/类名
|
||||||
|
identifiers:
|
||||||
|
- match: '\b[A-Z][A-Za-z0-9_$]*\b'
|
||||||
|
scope: entity.name.type.ts
|
||||||
|
- match: '{{identifier}}'
|
||||||
|
scope: variable.ts
|
||||||
191
syntaxes/Zig.sublime-syntax
Normal file
191
syntaxes/Zig.sublime-syntax
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
%YAML 1.2
|
||||||
|
---
|
||||||
|
# https://ziglang.org/documentation/master/
|
||||||
|
# Sublime Text syntax definition for the Zig programming language.
|
||||||
|
|
||||||
|
name: Zig
|
||||||
|
scope: source.zig
|
||||||
|
|
||||||
|
file_extensions:
|
||||||
|
- zig
|
||||||
|
- zon
|
||||||
|
|
||||||
|
variables:
|
||||||
|
identifier: '[A-Za-z_][A-Za-z0-9_]*'
|
||||||
|
line_break: '(?:\x0D\x0A?|\x0A)'
|
||||||
|
|
||||||
|
contexts:
|
||||||
|
main:
|
||||||
|
- include: whitespace
|
||||||
|
- include: comments
|
||||||
|
- include: strings
|
||||||
|
- include: numbers
|
||||||
|
- include: builtin-functions
|
||||||
|
- include: function-declaration
|
||||||
|
- include: keywords
|
||||||
|
- include: types
|
||||||
|
- include: constants
|
||||||
|
- include: labels
|
||||||
|
- include: operators
|
||||||
|
- include: punctuation
|
||||||
|
- include: identifiers
|
||||||
|
|
||||||
|
whitespace:
|
||||||
|
- match: '[\t ]+'
|
||||||
|
|
||||||
|
comments:
|
||||||
|
# Zig 只有行注释 //,支持文档注释 ///
|
||||||
|
- match: '//[!/]'
|
||||||
|
scope: punctuation.definition.comment.zig
|
||||||
|
push:
|
||||||
|
- meta_scope: comment.line.documentation.zig
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
pop: 1
|
||||||
|
- match: '//'
|
||||||
|
scope: punctuation.definition.comment.zig
|
||||||
|
push:
|
||||||
|
- meta_scope: comment.line.double-slash.zig
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
strings:
|
||||||
|
# 多行字符串:每行以 \ 开头,行内容(去掉 \)原样拼接
|
||||||
|
- match: '\\$'
|
||||||
|
scope: punctuation.definition.string.begin.zig
|
||||||
|
push: multiline-string-body
|
||||||
|
# 字符串字面量 "...",支持转义和内联 {name} 格式化
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.begin.zig
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.double.zig
|
||||||
|
- match: "\\\\[nrt\"'\\\\]"
|
||||||
|
scope: constant.character.escape.zig
|
||||||
|
- match: "\\\\x[0-9A-Fa-f]{2}"
|
||||||
|
scope: constant.character.escape.hex.zig
|
||||||
|
- match: "\\\\u\\{[0-9A-Fa-f]{1,6}\\}"
|
||||||
|
scope: constant.character.escape.unicode.zig
|
||||||
|
- match: '\{[^\}"]*\}'
|
||||||
|
scope: meta.interpolation.zig
|
||||||
|
- match: '"'
|
||||||
|
scope: punctuation.definition.string.end.zig
|
||||||
|
pop: 1
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
scope: invalid.illegal.unclosed-string.zig
|
||||||
|
pop: 1
|
||||||
|
# 单引号字符字面量
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.begin.zig
|
||||||
|
push:
|
||||||
|
- meta_scope: string.quoted.single.zig
|
||||||
|
- match: "\\\\[nrt\"'\\\\]"
|
||||||
|
scope: constant.character.escape.zig
|
||||||
|
- match: "'"
|
||||||
|
scope: punctuation.definition.string.end.zig
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
multiline-string-body:
|
||||||
|
- meta_scope: string.unquoted.zig
|
||||||
|
- match: '[^\s\\][^\n]*'
|
||||||
|
- match: '\\$'
|
||||||
|
- match: '{{line_break}}'
|
||||||
|
- match: '(?=\S)'
|
||||||
|
pop: 1
|
||||||
|
|
||||||
|
numbers:
|
||||||
|
# 浮点:必须含小数点和指数,如 1.0e10
|
||||||
|
- match: '\b(0x[0-9A-Fa-f_]+\.[0-9A-Fa-f_]+(?:[pP][-+]?[0-9]+))'
|
||||||
|
scope: constant.numeric.float.hexadecimal.zig
|
||||||
|
- match: '\b(0b[01_]+\.[01_]+(?:[pP][-+]?[0-9]+))'
|
||||||
|
scope: constant.numeric.float.binary.zig
|
||||||
|
- match: '\b([0-9][0-9_]*\.[0-9][0-9_]*(?:[eE][-+]?[0-9]+))'
|
||||||
|
scope: constant.numeric.float.decimal.zig
|
||||||
|
# 整数:十六进制/八进制/二进制/十进制,可带类型后缀
|
||||||
|
- match: '\b0x[0-9A-Fa-f_]+'
|
||||||
|
scope: constant.numeric.integer.hexadecimal.zig
|
||||||
|
- match: '\b0o[0-7_]+'
|
||||||
|
scope: constant.numeric.integer.octal.zig
|
||||||
|
- match: '\b0b[01_]+'
|
||||||
|
scope: constant.numeric.integer.binary.zig
|
||||||
|
- match: '\b[0-9][0-9_]*'
|
||||||
|
scope: constant.numeric.integer.decimal.zig
|
||||||
|
|
||||||
|
# 内建函数:以 @ 开头,如 @import @as @intCast
|
||||||
|
builtin-functions:
|
||||||
|
- match: '@{{identifier}}'
|
||||||
|
scope: support.function.builtin.zig
|
||||||
|
|
||||||
|
# 函数声明:pub fn name(...) 块的识别
|
||||||
|
function-declaration:
|
||||||
|
- match: '\b(fn)\s+({{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: keyword.declaration.function.zig
|
||||||
|
2: entity.name.function.zig
|
||||||
|
|
||||||
|
keywords:
|
||||||
|
# 声明类关键字
|
||||||
|
- match: '\b(?:const|var|fn|pub|test|comptime|inline|noinline|extern|export|packed|align|linksection|threadlocal|addrspace)\b'
|
||||||
|
scope: keyword.declaration.zig
|
||||||
|
# 控制流
|
||||||
|
- match: '\b(?:if|else|while|for|switch|break|continue|return|defer|errdefer|try|catch|orelse|unreachable|resume|suspend|nosuspend|await|async|const)\b'
|
||||||
|
scope: keyword.control.zig
|
||||||
|
# 布尔与 null
|
||||||
|
- match: '\b(?:true|false|null|undefined)\b'
|
||||||
|
scope: constant.language.zig
|
||||||
|
# 类型相关关键字
|
||||||
|
- match: '\b(?:struct|enum|union|opaque|error|anyerror|anytype|anyframe|anyopaque|void|noreturn|type|bool)\b'
|
||||||
|
scope: keyword.other.type.zig
|
||||||
|
# 操作符关键字
|
||||||
|
- match: '\b(?:and|or|not)\b'
|
||||||
|
scope: keyword.operator.logical.zig
|
||||||
|
# 特殊关键字
|
||||||
|
- match: '\b(?:usingnamespace|callconv|volatile|allowzero|unreachable)\b'
|
||||||
|
scope: keyword.other.zig
|
||||||
|
|
||||||
|
types:
|
||||||
|
# 整数类型:i32 u8 isize usize 以及任意位宽 i0..i65535
|
||||||
|
- match: '\b[iu](?:sz|[0-9]{1,5})\b'
|
||||||
|
scope: support.type.primitive.zig
|
||||||
|
# 浮点类型
|
||||||
|
- match: '\bf(?:16|32|64|80|128)\b'
|
||||||
|
scope: support.type.primitive.zig
|
||||||
|
# 常用标准库类型
|
||||||
|
- match: '\b(?:std|mem|fmt|io|fs|os|debug|builtin|testing|hash|math|sort|meta|Thread|Allocator|ArrayList|StringHashMap|AutoHashMap|File|Dir|Reader|Writer|Buffer)\b'
|
||||||
|
scope: support.type.zig
|
||||||
|
|
||||||
|
constants:
|
||||||
|
# 编译期常量与枚举值风格的大写标识符
|
||||||
|
- match: '\b[A-Z][A-Z0-9_]+\b'
|
||||||
|
scope: variable.other.constant.zig
|
||||||
|
|
||||||
|
# 标签:用于 break :label / continue :label
|
||||||
|
labels:
|
||||||
|
- match: '(?:break|continue)\s+(:{{identifier}})'
|
||||||
|
captures:
|
||||||
|
1: entity.name.label.zig
|
||||||
|
- match: '(:{{identifier}})\s*(?=\{)'
|
||||||
|
captures:
|
||||||
|
1: entity.name.label.zig
|
||||||
|
|
||||||
|
operators:
|
||||||
|
- match: '<<=|>>=|<<|>>'
|
||||||
|
scope: keyword.operator.bitwise.zig
|
||||||
|
- match: '==|!=|<=|>=|<|>'
|
||||||
|
scope: keyword.operator.comparison.zig
|
||||||
|
- match: '&&|\|\||!'
|
||||||
|
scope: keyword.operator.logical.zig
|
||||||
|
- match: '\+\+|\*\*|[-+*/%]'
|
||||||
|
scope: keyword.operator.arithmetic.zig
|
||||||
|
- match: '&=|\|=|\^=|<<=|>>=|[-+*/%&|^]='
|
||||||
|
scope: keyword.operator.assignment.zig
|
||||||
|
- match: '[-=]>|\.|\?|%'
|
||||||
|
scope: keyword.operator.zig
|
||||||
|
|
||||||
|
punctuation:
|
||||||
|
- match: '[\(\)\{\}\[\];,]'
|
||||||
|
scope: punctuation.zig
|
||||||
|
|
||||||
|
identifiers:
|
||||||
|
- match: '\b[A-Z][A-Za-z0-9_]*\b'
|
||||||
|
scope: entity.name.type.zig
|
||||||
|
- match: '{{identifier}}'
|
||||||
|
scope: variable.zig
|
||||||
Loading…
x
Reference in New Issue
Block a user