Compare commits

..

24 Commits

Author SHA1 Message Date
xfy
b7afd12538 fix(highlight): 大小写不敏感的语法名称匹配,修复 haskell 等高亮失效
Some checks failed
CI / check (push) Failing after 25m52s
CI / build (push) Has been skipped
2026-06-16 16:20:21 +08:00
xfy
6ce8915e6c fix(editor): 修复 focus() 把源码视图滚动到底部
切换到源码模式时 sourceTextarea.focus() 会触发浏览器将光标
所在行滚动进可视区域,光标默认在内容末尾,导致 textarea 被
拉到底部覆盖已设的滚动位置。改为 focus 后立即恢复 scrollTop。

同时移除调试日志。
2026-06-16 16:13:46 +08:00
xfy
ae6d80c9cc fix(editor): 修复滚动比例同步失效导致切换到底部
原 syncScrollRatio 封装在 display:'none' 之后重复读取源容器
scrollTop,此时浏览器已将其归零,导致比例计算为 0。改为提前
读取比例并直接应用,移除二次封装与调试日志。
2026-06-16 15:58:50 +08:00
xfy
914f168551 fix(editor): 切换视图时按滚动比例同步位置
修复切回富文本时编辑器滚动到底部的问题:移除 commands.focus()
(会强制滚动到光标位置),改用滚动比例跨模式同步,富文本与源码
视图按相同比例定位,保持视觉位置一致。
2026-06-16 15:53:28 +08:00
xfy
480aa92bb4 feat(editor): 新增 Markdown 源码视图切换
在 Tiptap 编辑器右上角增加 </> 悬浮按钮,支持在富文本与
Markdown 源码视图间双向切换,内容自动同步。getMarkdown() 在
源码模式下返回 textarea 内容,使 Rust 端提交逻辑无需改动。
2026-06-16 15:50:09 +08:00
xfy
c6d27f0ae0 feat(highlight): 新增 JSX 与 TSX 语法高亮
新建 syntaxes/JSX.sublime-syntax(纯 JS + JSX)与
syntaxes/TSX.sublime-syntax(TypeScript + JSX),两者共享同一套
JSX 规则:标签名(区分大写组件/小写 HTML 标签)、属性名、属性值
(字符串与 {expr} 表达式)、自闭合 />、闭合标签、嵌套子标签与
JSX 片段 <>...</>。

从 TypeScript.sublime-syntax 移除 tsx 扩展名:此前 tsx 命中的是
没有 JSX 支持的 TS 语法,遮蔽了新建的 TSX 语法。

新增回归测试覆盖 jsx/tsx 的标签名与属性名识别。
2026-06-16 15:45:38 +08:00
xfy
d549f4a476 feat(highlight): 新增 TypeScript 语法高亮
新增 syntaxes/TypeScript.sublime-syntax,覆盖声明/控制流关键字、
interface/type/enum 类型声明、函数声明与箭头函数、装饰器、原始类型
与标准库类型、访问修饰符、模板字符串(含 ${} 插值)、各类数字字面量。

同时修复别名表:移除旧的 ts/typescript/tsx -> js 降级映射。这些别名
在没有 TS 语法时是临时降级方案,但 find_syntax 按扩展名->名字->别名
顺序匹配,别名会抢先于扩展名命中内置 JS,导致新增的 TS 语法被永久
遮蔽(诊断输出为 source js 而非 source ts)。改为映射 typescript -> ts
扩展名,使其正确走自定义 TypeScript 语法。

新增两个回归测试覆盖关键字/类型高亮与别名解析。
2026-06-16 15:35:44 +08:00
xfy
61a001c03b feat(highlight): 新增 Zig 语法高亮
新增 syntaxes/Zig.sublime-syntax,覆盖声明/控制流关键字、函数声明、
内建函数(@import 等)、整数/浮点类型(含任意位宽 i0..i65535)、
标准库类型、字符串(含 {d} 格式化插值与多行字符串)、十六进制/八进制/
二进制数字、注释与文档注释、标签等。

新增两个回归测试覆盖关键字/函数/类型/字符串/数字高亮。
2026-06-16 15:25:11 +08:00
xfy
a9f0e8d16b docs(development): 补充代码高亮开发文档
- DEVELOPMENT.md 增加代码高亮系统的详细开发指南,
  涵盖添加/修复语法、刷新文章、调试流程等
- admin/posts.rs 重构重建逻辑:提取 do_rebuild 闭包消除重复,
  新增「重建全部」按钮用于批量刷新已有文章渲染缓存
2026-06-16 15:19:45 +08:00
xfy
e8be1967db fix(highlight): 语法加载改用编译期绝对路径并增加诊断日志
add_from_folder 之前用相对路径 syntaxes/,依赖运行时工作目录,
在 dx serve 启动的 server 子进程下可能解析失败导致自定义语法
(含 Swift)静默不加载。

改用 CARGO_MANIFEST_DIR 在编译期固化为绝对路径,消除工作目录
依赖;并在 LazyLock 初始化时输出加载路径、语法总数与 Swift 是否
命中,便于排查加载问题。
2026-06-16 14:49:44 +08:00
xfy
603a7f68cb refactor(ui): 提取共享 UI 组件,消除跨页面重复
新增 src/components/ui.rs 作为通用 UI 原子层,封装:

组件:
- <Pagination variant="admin"|"frontend">:合并 posts/comments/trash/home
  四个重复的分页实现,用 variant 区分配色(admin 灰黑 / 前台主题绿)
- <StatusBadge>:统一文章状态、评论状态、回收站剩余天数的徽章外层
- <EmptyState variant="default"|"error">:统一列表空态与加载失败占位

类名常量:
- ADMIN_CARD_CLASS / ADMIN_TABLE_CLASS / ADMIN_ROW_HOVER / CHECKBOX_CLASS
- BTN_SOLID_{GREEN,AMBER,RED}:批量操作实心按钮
- BTN_TEXT_{GREEN,AMBER,RED,ACCENT}:行内文字按钮

清理:
- 删除 input.css 中零引用的 .btn-primary 死代码(22 行)
- 删除 4 个重复的分页组件函数

注意:
- 前台空状态(home/search/archives/tags)保持 text-paper-secondary 配色,
  未强行统一到 admin 的灰色 token,避免改变视觉
- 回收站分页计数单位从"条"修正为"篇"(回收站里是文章)
2026-06-16 14:20:05 +08:00
xfy
188e607ec2 fix(highlight): 补全 Swift 语法高亮
Swift.sublime-syntax 之前是残缺定义,expression 上下文仅包含
空白/字符串/数字,导致关键字、类型、函数等全部无法高亮。

补全声明关键字(import/func/class/let 等)、控制流(if/return/
async/await)、标准库类型(Int/String/Array)、函数声明与调用、
属性(@objc)等上下文,并调整 include 顺序使函数声明名优先于
函数调用识别。

新增两个回归测试覆盖关键字/函数/类型/字符串高亮。
2026-06-16 14:17:17 +08:00
xfy
6986a62bb5 style(ui): 重新设计回收站自动清理设置界面
- 合并为单一折叠卡片,顶部摘要条始终显示当前清理状态
- 用主题绿 toggle 开关取代原生 checkbox
- 保留天数改为数字步进器(−/输入框/+)
- 保存按钮改用鼠尾草绿强调色,与项目设计系统一致
- 新增 dirty/已保存 草稿状态反馈
- 补齐 hover/active/focus-visible 交互细节
2026-06-16 13:38:57 +08:00
xfy
63e352d471 feat: 文章回收站与自动清理 2026-06-16 12:42:11 +08:00
xfy
55bef35d1f refactor: 清理 clippy 提示(unwrap_or 与宏 deprecated 放行) 2026-06-16 12:40:43 +08:00
xfy
4ba4fedb23 feat(ui): 新增 /admin/trash 回收站管理页面与导航 2026-06-16 12:38:41 +08:00
xfy
0ba8f9f835 feat(tasks): 新增回收站自动清理后台任务 2026-06-16 12:34:28 +08:00
xfy
f5e19def1a feat(api): 新增回收站配置读写接口 2026-06-16 12:34:28 +08:00
xfy
b6de75ee97 feat(api): 新增 list_deleted_posts 回收站列表接口 2026-06-16 12:34:28 +08:00
xfy
c4c27a34c5 feat(model): 新增 TrashSettings 配置模型与 clamp 测试 2026-06-16 12:32:37 +08:00
xfy
65e090da27 feat(api): 新增回收站恢复/彻底删除/批量/清空接口 2026-06-16 12:31:55 +08:00
xfy
054d7450f7 feat(model): Post 新增 deleted_at 字段并用 try_get 兼容现有查询 2026-06-16 12:29:37 +08:00
xfy
959cc1fb0e feat(db): 新增 settings 键值表与回收站默认配置 2026-06-16 12:28:29 +08:00
xfy
863ec5ea52 fix(posts): get_post_by_slug 的 SELECT 补上 toc_html 列
row_to_post_full 用 row.get("toc_html") 读取该列,但 get_post_by_slug
的 SQL 漏选了 p.toc_html,导致访问公开文章详情时 panic(invalid column
toc_html)。补上列即可。
2026-06-16 11:34:12 +08:00
35 changed files with 2971 additions and 252 deletions

View File

@ -68,3 +68,93 @@ hey -c 100 -n 100000 http://localhost:8080/
## CI
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`)。删除后重启服务器才会重新渲染。

View File

@ -640,28 +640,6 @@
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 {
position: relative;
}

View File

@ -23,6 +23,10 @@ class TiptapEditorInstance {
private editor: Editor | null = null
private container: HTMLElement
private options: EditorOptions
// 源码模式相关状态
private isSourceMode = false
private sourceTextarea: HTMLTextAreaElement | null = null
private toggleButton: HTMLButtonElement | null = null
constructor(container: HTMLElement, options: EditorOptions = {}) {
this.container = container
@ -35,6 +39,30 @@ class TiptapEditorInstance {
el.className = 'tiptap-editor'
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({
element: el,
extensions: [
@ -114,9 +142,76 @@ class TiptapEditorInstance {
}
getMarkdown(): string {
// 源码模式下直接返回 textarea 内容,确保提交逻辑无需感知视图模式
if (this.isSourceMode && this.sourceTextarea) {
return this.sourceTextarea.value
}
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 {
this.editor?.commands.setContent(content, { emitUpdate: false, contentType: 'markdown' })
}
@ -140,6 +235,10 @@ class TiptapEditorInstance {
destroy(): void {
this.editor?.destroy()
this.editor = null
// 清理源码模式相关引用(容器 innerHTML 已清空DOM 会随之移除)
this.sourceTextarea = null
this.toggleButton = null
this.isSourceMode = false
this.container.innerHTML = ''
}
}

View File

@ -425,3 +425,73 @@
.dark .tiptap-editor ul[data-type="taskList"] li > label input[type="checkbox"] {
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;
}

View 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;

View File

@ -20,6 +20,8 @@ pub mod posts;
pub mod rate_limit;
/// HTML 消毒器。
pub mod sanitizer;
/// 回收站与站点配置接口。
pub mod settings;
/// URL slug 生成与校验。
pub mod slug;
/// 图片上传的 Axum 处理器。

View File

@ -50,6 +50,7 @@ pub(super) async fn row_to_post_list(
published_at: row.get("published_at"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
deleted_at: row.try_get("deleted_at").ok(),
tags,
cover_image: row.get("cover_image"),
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"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
deleted_at: row.try_get("deleted_at").ok(),
tags,
cover_image: row.get("cover_image"),
reading_time: (word_count / 200).max(1),

View File

@ -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() 计算。

View File

@ -16,6 +16,7 @@ mod stats;
mod tags;
mod types;
mod update;
mod trash;
/// 创建新文章。
#[allow(unused_imports)]
@ -25,6 +26,9 @@ pub use delete::delete_post;
/// 获取管理员视角的全部文章分页列表。
#[allow(unused_imports)]
pub use list::list_posts;
/// 获取回收站中已软删除的文章列表。
#[allow(unused_imports)]
pub use list::list_deleted_posts;
/// 获取已发布文章分页列表。
pub use list::{get_posts_by_tag, list_published_posts};
/// 根据 id 获取文章详情。
@ -44,6 +48,9 @@ pub use types::*;
/// 更新指定文章。
#[allow(unused_imports)]
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含目录
#[cfg(feature = "server")]

View File

@ -73,7 +73,7 @@ pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, Server
let row = client
.query_opt(
"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,
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
prev.title as prev_title, prev.slug as prev_slug,

286
src/api/posts/trash.rs Normal file
View 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, &current_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, &current_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
View 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,
})
}

View File

@ -473,6 +473,7 @@ mod tests {
published_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
deleted_at: None,
tags: vec![],
cover_image: None,
reading_time: 1,
@ -520,6 +521,7 @@ mod tests {
published_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
deleted_at: None,
tags: vec![],
cover_image: None,
reading_time: 1,

View File

@ -67,6 +67,11 @@ pub fn AdminLayout() -> Element {
label: "管理文章",
is_active: matches!(route, Route::Posts {}),
},
NavItemConfig {
route: Route::Trash {},
label: "回收站",
is_active: matches!(route, Route::Trash {}) || matches!(route, Route::TrashPage { .. }),
},
];
// 右侧操作区:主题切换 + 登出按钮

View File

@ -29,5 +29,7 @@ pub mod post;
pub mod post_card;
/// 骨架屏组件集合。
pub mod skeletons;
/// 通用 UI 原子组件与类名常量(卡片、按钮、分页、徽章、空状态)。
pub mod ui;
/// 编辑器页面骨架屏组件。
pub mod write_skeleton;

185
src/components/ui.rs Normal file
View 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}" }
}
}

View File

@ -14,10 +14,22 @@ pub mod server {
/// 全局语法集合,懒加载时合并内置语法与 `syntaxes/` 目录下的自定义语法。
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(|| {
let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
if let Err(e) = builder.add_from_folder("syntaxes/", true) {
tracing::warn!("Failed to load custom syntaxes: {:?}", e);
// 使用 CARGO_MANIFEST_DIR 派生的绝对路径,避免运行时工作目录不确定导致加载失败
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) {
return s;
}
// 小写后再匹配一次
// 小写扩展名再匹配一次(部分语言的扩展名习惯小写)
let lower = lang.to_lowercase();
if lower != lang {
if let Some(s) = ss.find_syntax_by_extension(&lower) {
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)] = &[
("rust", "rs"),
("js", "js"),
("javascript", "js"),
("ts", "js"),
("typescript", "js"),
("tsx", "js"),
("typescript", "ts"),
("py", "py"),
("python", "py"),
("rb", "rb"),
@ -153,6 +168,23 @@ mod tests {
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]
fn highlight_code_uppercase_language_falls_back_via_lowercase() {
// 大写语言标识应通过小写回退路径匹配到对应语法。
@ -221,4 +253,127 @@ mod tests {
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
);
}
}

View File

@ -64,6 +64,11 @@ fn main() {
tasks::session_cleanup::run_cleanup().await;
});
// 启动后台定时任务:回收站自动清理
tokio::spawn(async {
tasks::post_purge::run_purge().await;
});
// 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖
let config = ServeConfig::builder().incremental(
dioxus::server::IncrementalRendererConfig::default().invalidate_after(

View File

@ -9,3 +9,5 @@ pub mod comment;
pub mod post;
/// 用户模型、用户角色与可公开用户信息。
pub mod user;
/// 回收站与站点配置模型。
pub mod settings;

View File

@ -61,6 +61,8 @@ pub struct Post {
pub created_at: DateTime<Utc>,
/// 最后更新时间。
pub updated_at: DateTime<Utc>,
/// 软删除时间None 表示未删除。仅回收站查询填充。
pub deleted_at: Option<DateTime<Utc>>,
/// 关联标签列表。
pub tags: Vec<String>,
/// 封面图片 URL。
@ -161,6 +163,7 @@ mod tests {
published_at: None,
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(),
deleted_at: None,
tags: vec![],
cover_image: None,
reading_time: 1,

71
src/models/settings.rs Normal file
View 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);
}
}

View File

@ -15,6 +15,10 @@ use crate::api::comments::{approve_comment, batch_update_comment_status, spam_co
#[cfg(target_arch = "wasm32")]
use crate::api::comments::{get_all_comments, AllCommentsResponse};
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::router::Route;
@ -143,7 +147,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
"已选择 {selected_ids().len()} 条"
}
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 |_| {
let ids: Vec<i64> = selected_ids().iter().copied().collect();
let ids_for_api = ids.clone();
@ -156,7 +160,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
"批量通过"
}
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 |_| {
let ids: Vec<i64> = selected_ids().iter().copied().collect();
let ids_for_api = ids.clone();
@ -169,7 +173,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
"批量垃圾"
}
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 |_| {
#[cfg(target_arch = "wasm32")]
{
@ -195,11 +199,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
{
if error().is_some() {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败"
}
}
rsx! { EmptyState { message: "加载失败", variant: "error" } }
} else if loading() && comments().is_empty() {
rsx! {
DelayedSkeleton {
@ -216,17 +216,13 @@ pub fn AdminCommentsPage(page: i32) -> Element {
}
}
} else if comments().is_empty() {
rsx! {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
"暂无评论"
}
}
rsx! { EmptyState { message: "暂无评论", variant: "default" } }
} else {
let list = comments();
let all_selected = list.iter().all(|c| selected_ids().contains(&c.id));
let all_ids: Vec<i64> = list.iter().map(|c| c.id).collect();
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",
table { class: "w-full text-sm",
thead {
@ -234,7 +230,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
th { class: "px-4 py-3 font-medium w-10",
input {
r#type: "checkbox",
class: "rounded border-gray-300 dark:border-[#555]",
class: "{CHECKBOX_CLASS}",
checked: all_selected,
onchange: {
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_trash: EventHandler,
) -> Element {
let (badge_class, status_label) = 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",
"已删除",
),
let status_label = match &comment.status {
CommentStatus::Pending => "待审核".to_string(),
CommentStatus::Approved => "已通过".to_string(),
CommentStatus::Spam => "垃圾".to_string(),
CommentStatus::Trash => "已删除".to_string(),
};
let date_str = comment.created_at.format("%Y-%m-%d").to_string();
let preview = if comment.content_md.len() > 100 {
@ -359,11 +355,11 @@ fn CommentRow(
};
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",
input {
r#type: "checkbox",
class: "rounded border-gray-300 dark:border-[#555]",
class: "{CHECKBOX_CLASS}",
checked: selected,
onchange: move |e| on_select.call(e.checked()),
}
@ -398,8 +394,15 @@ fn CommentRow(
}
}
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}",
"{status_label}"
StatusBadge {
// 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]",
@ -409,21 +412,21 @@ fn CommentRow(
div { class: "flex justify-end gap-2",
if !matches!(comment.status, CommentStatus::Approved) {
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(()),
"通过"
}
}
if !matches!(comment.status, CommentStatus::Spam) {
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(()),
"垃圾"
}
}
if !matches!(comment.status, CommentStatus::Trash) {
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(()),
"删除"
}
@ -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", "»" }
}
}
}
}
}

View File

@ -12,6 +12,7 @@ use crate::api::comments::get_pending_count;
use crate::api::posts::{get_post_stats, list_posts};
#[cfg(target_arch = "wasm32")]
use crate::api::posts::{PostListResponse, PostStatsResponse};
use crate::components::ui::ADMIN_CARD_CLASS;
use crate::models::post::{Post, PostStats};
use crate::router::Route;
@ -151,7 +152,7 @@ pub fn Admin() -> Element {
#[component]
fn StatCard(value: String, label: String) -> Element {
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]",
"{value}"
}

View File

@ -8,6 +8,8 @@ pub mod comments;
pub mod dashboard;
/// 文章管理列表页面模块。
pub mod posts;
/// 回收站管理页面模块。
pub mod trash;
/// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。
pub mod write;
@ -17,5 +19,7 @@ pub use comments::{AdminComments, AdminCommentsPage};
pub use dashboard::Admin;
/// 文章管理列表组件(带默认分页)。
pub use posts::{Posts, PostsPage};
/// 回收站管理组件(带默认分页)。
pub use trash::{Trash, TrashPage};
/// 文章编辑器组件(新建与编辑模式)。
pub use write::{Write, WriteEdit};

View File

@ -14,6 +14,7 @@ use crate::api::posts::PostListResponse;
use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
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::router::Route;
@ -40,6 +41,32 @@ pub fn PostsPage(page: i32) -> Element {
let mut rebuilding = use_signal(|| false);
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 直接结束加载。
use_effect(move || {
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"
},
disabled: rebuilding(),
onclick: move |_| {
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);
});
},
onclick: move |_| do_rebuild(false),
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",
"重建 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 {
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 {},
@ -135,11 +152,9 @@ pub fn PostsPage(page: i32) -> Element {
if loading() && posts().is_empty() {
DelayedSkeleton { PostsSkeleton {} }
} else if posts().is_empty() {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
"暂无文章"
}
EmptyState { message: "暂无文章", variant: "default" }
} 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",
thead {
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
@ -180,53 +195,19 @@ pub fn PostsPage(page: i32) -> Element {
}
}
}
Pagination { current_page, total: total() }
}
}
}
}
/// 分页导航组件,根据当前页与总数量生成上一页 / 下一页链接。
#[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 {}
} else {
Route::PostsPage { page: current_page - 1 }
},
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: 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", "»" }
}
Pagination {
variant: "admin",
current_page,
total: total(),
per_page: POSTS_PER_PAGE,
prev_route: if current_page - 1 <= 1 {
Route::Posts {}
} else {
Route::PostsPage { page: current_page - 1 }
},
next_route: Route::PostsPage { page: current_page + 1 },
unit: "",
}
}
}
}
@ -236,11 +217,9 @@ fn Pagination(current_page: i32, total: i64) -> Element {
#[component]
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
let date_str = post.formatted_date();
let status_label = post.status_label();
let status_badge_class = post.status_badge_class();
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",
Link {
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",
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {status_badge_class}",
"{status_label}"
StatusBadge {
color_class: post.status_badge_class(),
label: post.status_label().to_string(),
}
}
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 {
"text-xs text-gray-400 cursor-not-allowed"
} else {
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer"
BTN_TEXT_RED
},
disabled: deleting,
onclick: move |_| on_delete.call(post.id),

595
src/pages/admin/trash.rs Normal file
View 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",
"文章删除后保留的时长到期后自动彻底清除1365"
}
}
// 数字输入 + 步进按钮 + 单位后缀
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(()),
"彻底删除"
}
}
}
}
}
}

View File

@ -10,12 +10,12 @@
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::api::posts::{list_published_posts, PostListResponse};
use crate::components::post_card::PostCard;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::home_skeleton::HomeSkeleton;
use crate::components::ui::Pagination;
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)) => {
@ -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", "»" }
}
}
}
}
}

View File

@ -12,7 +12,7 @@ use crate::components::frontend_layout::FrontendLayout;
use crate::context::UserContext;
use crate::pages::about::About;
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::home::{Home, HomePage};
@ -84,6 +84,12 @@ pub enum Route {
/// 评论管理分页
#[route("/comments/:page")]
AdminCommentsPage { page: i32 },
/// 回收站
#[route("/trash")]
Trash {},
/// 回收站分页
#[route("/trash/:page")]
TrashPage { page: i32 },
#[end_layout]
#[end_nest]

View File

@ -8,3 +8,6 @@ pub mod ip_purge;
/// 定时删除已过期会话,避免 `sessions` 表无限增长。
#[cfg(feature = "server")]
pub mod session_cleanup;
/// 定时清理回收站中超过保留期的已删除文章。
#[cfg(feature = "server")]
pub mod post_purge;

74
src/tasks/post_purge.rs Normal file
View 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
View 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

View File

@ -194,6 +194,84 @@ contexts:
expression:
- include: whitespace
- include: comments
- include: attribute
- include: string-literal
- include: floating-point-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
View 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

View 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
View 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