Compare commits
No commits in common. "b7afd12538ed7f5318690c4616b38332c44c7b01" and "c60e4e08d79f4dc2c3f08897475e0ad1dba03b16" have entirely different histories.
b7afd12538
...
c60e4e08d7
@ -68,93 +68,3 @@ 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`)。删除后重启服务器才会重新渲染。
|
||||
|
||||
|
||||
22
input.css
22
input.css
@ -640,6 +640,28 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@ -23,10 +23,6 @@ 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
|
||||
@ -39,30 +35,6 @@ 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: [
|
||||
@ -142,76 +114,9 @@ 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' })
|
||||
}
|
||||
@ -235,10 +140,6 @@ class TiptapEditorInstance {
|
||||
destroy(): void {
|
||||
this.editor?.destroy()
|
||||
this.editor = null
|
||||
// 清理源码模式相关引用(容器 innerHTML 已清空,DOM 会随之移除)
|
||||
this.sourceTextarea = null
|
||||
this.toggleButton = null
|
||||
this.isSourceMode = false
|
||||
this.container.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
@ -425,73 +425,3 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
-- 回收站与站点配置键值表。
|
||||
-- 采用简单键值结构而非列式配置,便于后续扩展更多设置项。
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 回填回收站默认配置:自动清理默认关闭,保留期 30 天。
|
||||
-- ON CONFLICT 保证重复执行迁移安全。
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('trash_auto_purge_enabled', 'false'),
|
||||
('trash_retention_days', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@ -20,8 +20,6 @@ pub mod posts;
|
||||
pub mod rate_limit;
|
||||
/// HTML 消毒器。
|
||||
pub mod sanitizer;
|
||||
/// 回收站与站点配置接口。
|
||||
pub mod settings;
|
||||
/// URL slug 生成与校验。
|
||||
pub mod slug;
|
||||
/// 图片上传的 Axum 处理器。
|
||||
|
||||
@ -50,7 +50,6 @@ 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),
|
||||
@ -144,7 +143,6 @@ 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),
|
||||
|
||||
@ -171,65 +171,6 @@ 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() 计算。
|
||||
|
||||
@ -16,7 +16,6 @@ mod stats;
|
||||
mod tags;
|
||||
mod types;
|
||||
mod update;
|
||||
mod trash;
|
||||
|
||||
/// 创建新文章。
|
||||
#[allow(unused_imports)]
|
||||
@ -26,9 +25,6 @@ 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 获取文章详情。
|
||||
@ -48,9 +44,6 @@ 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")]
|
||||
|
||||
@ -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.toc_html,
|
||||
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,
|
||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
|
||||
prev.title as prev_title, prev.slug as prev_slug,
|
||||
|
||||
@ -1,286 +0,0 @@
|
||||
//! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。
|
||||
//!
|
||||
//! 所有接口需要 admin 权限,操作后清空全部文章相关缓存。
|
||||
//! Dioxus server function,注册在 `/api` 路径下。
|
||||
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use super::helpers::get_current_admin_user;
|
||||
use super::types::CreatePostResponse;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::slug::ensure_unique_slug;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
/// 恢复一篇已删除的文章(将 deleted_at 置空)。
|
||||
///
|
||||
/// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。
|
||||
#[server(RestorePost, "/api")]
|
||||
pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
// 读取待恢复文章的当前 slug 与是否确已删除。
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(CreatePostResponse {
|
||||
success: false,
|
||||
message: "文章不在回收站".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
});
|
||||
};
|
||||
|
||||
let current_slug: String = row.get("slug");
|
||||
|
||||
// 恢复时确保 slug 在未删除文章中唯一(自动加后缀)。
|
||||
let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(post_id)).await?;
|
||||
|
||||
// 置空 deleted_at,并更新 slug(可能已加后缀)。
|
||||
let result = client
|
||||
.execute(
|
||||
"UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
|
||||
&[&new_slug, &post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
if result == 0 {
|
||||
return Ok(CreatePostResponse {
|
||||
success: false,
|
||||
message: "文章不在回收站".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
});
|
||||
}
|
||||
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
success: true,
|
||||
message: "恢复成功".to_string(),
|
||||
post_id: Some(post_id),
|
||||
slug: Some(new_slug),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(CreatePostResponse {
|
||||
success: false,
|
||||
message: "server only".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 彻底删除一篇已删除的文章(物理删除,不可恢复)。
|
||||
///
|
||||
/// 注意:仅删除数据库记录,不删除已上传的图片文件。
|
||||
/// post_tags 关联因外键 ON DELETE CASCADE 自动清理。
|
||||
#[server(PurgePost, "/api")]
|
||||
pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let result = client
|
||||
.execute(
|
||||
"DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
if result == 0 {
|
||||
return Ok(CreatePostResponse {
|
||||
success: false,
|
||||
message: "文章不在回收站".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
});
|
||||
}
|
||||
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
success: true,
|
||||
message: "彻底删除成功".to_string(),
|
||||
post_id: Some(post_id),
|
||||
slug: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(CreatePostResponse {
|
||||
success: false,
|
||||
message: "server only".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量恢复文章。
|
||||
#[server(BatchRestorePosts, "/api")]
|
||||
pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
if post_ids.is_empty() {
|
||||
return Ok(CreatePostResponse {
|
||||
success: true,
|
||||
message: "无操作".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
});
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
// 逐条恢复,slug 冲突时自动加后缀。
|
||||
let mut restored = 0u64;
|
||||
for id in &post_ids {
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
if let Some(row) = row {
|
||||
let current_slug: String = row.get("slug");
|
||||
let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(*id)).await?;
|
||||
let n = client
|
||||
.execute(
|
||||
"UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
|
||||
&[&new_slug, &id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
restored += n;
|
||||
}
|
||||
}
|
||||
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
success: true,
|
||||
message: format!("已恢复 {restored} 篇"),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(CreatePostResponse {
|
||||
success: false,
|
||||
message: "server only".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量彻底删除文章。
|
||||
#[server(BatchPurgePosts, "/api")]
|
||||
pub async fn batch_purge_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
if post_ids.is_empty() {
|
||||
return Ok(CreatePostResponse {
|
||||
success: true,
|
||||
message: "无操作".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
});
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let total = post_ids.len() as i64;
|
||||
let result = client
|
||||
.execute(
|
||||
"DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL",
|
||||
&[&post_ids],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
success: true,
|
||||
message: format!("已彻底删除 {result}/{total} 篇"),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(CreatePostResponse {
|
||||
success: false,
|
||||
message: "server only".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空回收站:彻底删除所有已软删除的文章。
|
||||
#[server(EmptyTrash, "/api")]
|
||||
pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let result = client
|
||||
.execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[])
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
success: true,
|
||||
message: format!("已清空回收站({result} 篇)"),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(CreatePostResponse {
|
||||
success: false,
|
||||
message: "server only".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
//! 回收站配置接口:读取与更新自动清理设置。
|
||||
//!
|
||||
//! 所有接口需要 admin 权限。配置持久化到 settings 键值表。
|
||||
//! Dioxus server function,注册在 `/api` 路径下。
|
||||
|
||||
// 与 posts 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::models::settings::TrashSettings;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
/// 读取回收站配置。
|
||||
///
|
||||
/// settings 表缺失键时回退到默认值,保证向后兼容。
|
||||
#[server(GetTrashSettings, "/api")]
|
||||
pub async fn get_trash_settings() -> Result<TrashSettings, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let enabled: bool = client
|
||||
.query_opt(
|
||||
"SELECT value FROM settings WHERE key = 'trash_auto_purge_enabled'",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.and_then(|r| r.get::<_, String>("value").parse().ok())
|
||||
.unwrap_or(crate::models::settings::DEFAULT_AUTO_PURGE_ENABLED);
|
||||
|
||||
let days: i32 = client
|
||||
.query_opt(
|
||||
"SELECT value FROM settings WHERE key = 'trash_retention_days'",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.and_then(|r| r.get::<_, String>("value").parse().ok())
|
||||
.unwrap_or(crate::models::settings::DEFAULT_RETENTION_DAYS);
|
||||
|
||||
Ok(TrashSettings {
|
||||
auto_purge_enabled: enabled,
|
||||
retention_days: TrashSettings::clamp_retention(days),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(TrashSettings::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新回收站配置。
|
||||
///
|
||||
/// retention_days 会被 clamp 到合法范围后写入。
|
||||
#[server(UpdateTrashSettings, "/api")]
|
||||
pub async fn update_trash_settings(
|
||||
auto_purge_enabled: bool,
|
||||
retention_days: i32,
|
||||
) -> Result<TrashSettings, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
let retention_days = TrashSettings::clamp_retention(retention_days);
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
// UPSERT 两个键。
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES ('trash_auto_purge_enabled', $1, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
|
||||
&[&auto_purge_enabled.to_string()],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES ('trash_retention_days', $1, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
|
||||
&[&retention_days.to_string()],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
tracing::info!(
|
||||
"Trash settings updated: auto_purge={}, retention_days={}",
|
||||
auto_purge_enabled,
|
||||
retention_days
|
||||
);
|
||||
}
|
||||
|
||||
Ok(TrashSettings {
|
||||
auto_purge_enabled,
|
||||
retention_days,
|
||||
})
|
||||
}
|
||||
@ -473,7 +473,6 @@ 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,
|
||||
@ -521,7 +520,6 @@ 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,
|
||||
|
||||
@ -67,11 +67,6 @@ 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 { .. }),
|
||||
},
|
||||
];
|
||||
|
||||
// 右侧操作区:主题切换 + 登出按钮
|
||||
|
||||
@ -29,7 +29,5 @@ pub mod post;
|
||||
pub mod post_card;
|
||||
/// 骨架屏组件集合。
|
||||
pub mod skeletons;
|
||||
/// 通用 UI 原子组件与类名常量(卡片、按钮、分页、徽章、空状态)。
|
||||
pub mod ui;
|
||||
/// 编辑器页面骨架屏组件。
|
||||
pub mod write_skeleton;
|
||||
|
||||
@ -1,185 +0,0 @@
|
||||
//! 通用 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}" }
|
||||
}
|
||||
}
|
||||
175
src/highlight.rs
175
src/highlight.rs
@ -14,22 +14,10 @@ pub mod server {
|
||||
/// 全局语法集合,懒加载时合并内置语法与 `syntaxes/` 目录下的自定义语法。
|
||||
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(|| {
|
||||
let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
|
||||
// 使用 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),
|
||||
if let Err(e) = builder.add_from_folder("syntaxes/", true) {
|
||||
tracing::warn!("Failed to load custom syntaxes: {:?}", e);
|
||||
}
|
||||
let built = builder.build();
|
||||
tracing::info!(
|
||||
"SyntaxSet built: {} syntaxes, swift={:?}",
|
||||
built.syntaxes().len(),
|
||||
built
|
||||
.find_syntax_by_extension("swift")
|
||||
.map(|s| &s.name)
|
||||
);
|
||||
built
|
||||
builder.build()
|
||||
});
|
||||
|
||||
/// 根据语言标识查找对应的语法定义。
|
||||
@ -48,27 +36,24 @@ 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;
|
||||
}
|
||||
}
|
||||
// 大小写不敏感的语法名称匹配(syntect 的语法名通常首字母大写,如 Haskell)
|
||||
if let Some(s) = ss
|
||||
.syntaxes()
|
||||
.iter()
|
||||
.find(|s| s.name.eq_ignore_ascii_case(lang))
|
||||
{
|
||||
return s;
|
||||
if let Some(s) = ss.find_syntax_by_name(&lower) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
// 常用语言别名映射表
|
||||
let aliases: &[(&str, &str)] = &[
|
||||
("rust", "rs"),
|
||||
("js", "js"),
|
||||
("javascript", "js"),
|
||||
("typescript", "ts"),
|
||||
("ts", "js"),
|
||||
("typescript", "js"),
|
||||
("tsx", "js"),
|
||||
("py", "py"),
|
||||
("python", "py"),
|
||||
("rb", "rb"),
|
||||
@ -168,23 +153,6 @@ 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() {
|
||||
// 大写语言标识应通过小写回退路径匹配到对应语法。
|
||||
@ -253,127 +221,4 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,11 +64,6 @@ 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(
|
||||
|
||||
@ -9,5 +9,3 @@ pub mod comment;
|
||||
pub mod post;
|
||||
/// 用户模型、用户角色与可公开用户信息。
|
||||
pub mod user;
|
||||
/// 回收站与站点配置模型。
|
||||
pub mod settings;
|
||||
|
||||
@ -61,8 +61,6 @@ 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。
|
||||
@ -163,7 +161,6 @@ 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,
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
//! 回收站与站点配置模型。
|
||||
|
||||
/// 默认保留天数(天)。
|
||||
pub const DEFAULT_RETENTION_DAYS: i32 = 30;
|
||||
/// 默认不启用自动清理。
|
||||
pub const DEFAULT_AUTO_PURGE_ENABLED: bool = false;
|
||||
/// 保留天数下限(天)。
|
||||
pub const MIN_RETENTION_DAYS: i32 = 1;
|
||||
/// 保留天数上限(天)。防止误填超大值导致永不清理。
|
||||
pub const MAX_RETENTION_DAYS: i32 = 365;
|
||||
|
||||
/// 回收站配置。
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TrashSettings {
|
||||
/// 是否启用自动定时清理。
|
||||
pub auto_purge_enabled: bool,
|
||||
/// 已删除文章保留天数,超过后被后台任务物理删除。
|
||||
pub retention_days: i32,
|
||||
}
|
||||
|
||||
impl Default for TrashSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_purge_enabled: DEFAULT_AUTO_PURGE_ENABLED,
|
||||
retention_days: DEFAULT_RETENTION_DAYS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrashSettings {
|
||||
/// 将保留天数钳制到合法范围 [MIN, MAX]。
|
||||
pub fn clamp_retention(days: i32) -> i32 {
|
||||
days.clamp(MIN_RETENTION_DAYS, MAX_RETENTION_DAYS)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_disabled_30_days() {
|
||||
let s = TrashSettings::default();
|
||||
assert!(!s.auto_purge_enabled);
|
||||
assert_eq!(s.retention_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_retention_keeps_valid() {
|
||||
assert_eq!(TrashSettings::clamp_retention(7), 7);
|
||||
assert_eq!(TrashSettings::clamp_retention(30), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_retention_clamps_below_min() {
|
||||
assert_eq!(TrashSettings::clamp_retention(0), MIN_RETENTION_DAYS);
|
||||
assert_eq!(TrashSettings::clamp_retention(-5), MIN_RETENTION_DAYS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_retention_clamps_above_max() {
|
||||
assert_eq!(TrashSettings::clamp_retention(366), MAX_RETENTION_DAYS);
|
||||
assert_eq!(TrashSettings::clamp_retention(i32::MAX), MAX_RETENTION_DAYS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_retention_boundary() {
|
||||
assert_eq!(TrashSettings::clamp_retention(MIN_RETENTION_DAYS), MIN_RETENTION_DAYS);
|
||||
assert_eq!(TrashSettings::clamp_retention(MAX_RETENTION_DAYS), MAX_RETENTION_DAYS);
|
||||
}
|
||||
}
|
||||
@ -15,10 +15,6 @@ 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;
|
||||
|
||||
@ -147,7 +143,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
||||
"已选择 {selected_ids().len()} 条"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_SOLID_GREEN}",
|
||||
class: "px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition-colors",
|
||||
onclick: move |_| {
|
||||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||||
let ids_for_api = ids.clone();
|
||||
@ -160,7 +156,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
||||
"批量通过"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_SOLID_AMBER}",
|
||||
class: "px-3 py-1.5 text-xs font-medium bg-amber-600 text-white rounded hover:bg-amber-700 transition-colors",
|
||||
onclick: move |_| {
|
||||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||||
let ids_for_api = ids.clone();
|
||||
@ -173,7 +169,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
||||
"批量垃圾"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_SOLID_RED}",
|
||||
class: "px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded hover:bg-red-700 transition-colors",
|
||||
onclick: move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@ -199,7 +195,11 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
||||
|
||||
{
|
||||
if error().is_some() {
|
||||
rsx! { EmptyState { message: "加载失败", variant: "error" } }
|
||||
rsx! {
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||
"加载失败"
|
||||
}
|
||||
}
|
||||
} else if loading() && comments().is_empty() {
|
||||
rsx! {
|
||||
DelayedSkeleton {
|
||||
@ -216,13 +216,17 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
||||
}
|
||||
}
|
||||
} else if comments().is_empty() {
|
||||
rsx! { EmptyState { message: "暂无评论", variant: "default" } }
|
||||
rsx! {
|
||||
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
||||
"暂无评论"
|
||||
}
|
||||
}
|
||||
} 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: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
|
||||
div { class: "overflow-x-auto",
|
||||
table { class: "w-full text-sm",
|
||||
thead {
|
||||
@ -230,7 +234,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
||||
th { class: "px-4 py-3 font-medium w-10",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "{CHECKBOX_CLASS}",
|
||||
class: "rounded border-gray-300 dark:border-[#555]",
|
||||
checked: all_selected,
|
||||
onchange: {
|
||||
move |_| {
|
||||
@ -308,19 +312,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
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: "条",
|
||||
}
|
||||
CommentsPagination { current_page, total: total() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -338,11 +330,23 @@ fn CommentRow(
|
||||
on_spam: EventHandler,
|
||||
on_trash: EventHandler,
|
||||
) -> Element {
|
||||
let status_label = match &comment.status {
|
||||
CommentStatus::Pending => "待审核".to_string(),
|
||||
CommentStatus::Approved => "已通过".to_string(),
|
||||
CommentStatus::Spam => "垃圾".to_string(),
|
||||
CommentStatus::Trash => "已删除".to_string(),
|
||||
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 date_str = comment.created_at.format("%Y-%m-%d").to_string();
|
||||
let preview = if comment.content_md.len() > 100 {
|
||||
@ -355,11 +359,11 @@ fn CommentRow(
|
||||
};
|
||||
|
||||
rsx! {
|
||||
tr { class: "{ADMIN_ROW_HOVER}",
|
||||
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
|
||||
td { class: "px-4 py-3",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "{CHECKBOX_CLASS}",
|
||||
class: "rounded border-gray-300 dark:border-[#555]",
|
||||
checked: selected,
|
||||
onchange: move |e| on_select.call(e.checked()),
|
||||
}
|
||||
@ -394,15 +398,8 @@ fn CommentRow(
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 text-center",
|
||||
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,
|
||||
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap {badge_class}",
|
||||
"{status_label}"
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||
@ -412,21 +409,21 @@ fn CommentRow(
|
||||
div { class: "flex justify-end gap-2",
|
||||
if !matches!(comment.status, CommentStatus::Approved) {
|
||||
button {
|
||||
class: "{BTN_TEXT_GREEN}",
|
||||
class: "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors cursor-pointer",
|
||||
onclick: move |_| on_approve.call(()),
|
||||
"通过"
|
||||
}
|
||||
}
|
||||
if !matches!(comment.status, CommentStatus::Spam) {
|
||||
button {
|
||||
class: "{BTN_TEXT_AMBER}",
|
||||
class: "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors cursor-pointer",
|
||||
onclick: move |_| on_spam.call(()),
|
||||
"垃圾"
|
||||
}
|
||||
}
|
||||
if !matches!(comment.status, CommentStatus::Trash) {
|
||||
button {
|
||||
class: "{BTN_TEXT_RED}",
|
||||
class: "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer",
|
||||
onclick: move |_| on_trash.call(()),
|
||||
"删除"
|
||||
}
|
||||
@ -437,3 +434,56 @@ fn CommentRow(
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论分页导航组件。
|
||||
#[component]
|
||||
fn CommentsPagination(current_page: i32, total: i64) -> Element {
|
||||
let has_prev = current_page > 1;
|
||||
let total_pages =
|
||||
((total + COMMENTS_PER_PAGE as i64 - 1) / COMMENTS_PER_PAGE as i64).max(1) as i32;
|
||||
let has_next = current_page < total_pages;
|
||||
|
||||
let prev_route = if current_page - 1 <= 1 {
|
||||
Route::AdminComments {}
|
||||
} else {
|
||||
Route::AdminCommentsPage {
|
||||
page: current_page - 1,
|
||||
}
|
||||
};
|
||||
let next_route = Route::AdminCommentsPage {
|
||||
page: current_page + 1,
|
||||
};
|
||||
|
||||
rsx! {
|
||||
nav { class: "flex mt-6 justify-between",
|
||||
if has_prev {
|
||||
Link {
|
||||
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
|
||||
to: prev_route,
|
||||
span { class: "mr-1", "«" }
|
||||
"上一页"
|
||||
}
|
||||
} else {
|
||||
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
|
||||
span { class: "mr-1", "«" }
|
||||
"上一页"
|
||||
}
|
||||
}
|
||||
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
|
||||
"{current_page} / {total_pages} 页 (共 {total} 条)"
|
||||
}
|
||||
if has_next {
|
||||
Link {
|
||||
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
|
||||
to: next_route,
|
||||
"下一页"
|
||||
span { class: "ml-1", "»" }
|
||||
}
|
||||
} else {
|
||||
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
|
||||
"下一页"
|
||||
span { class: "ml-1", "»" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ 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;
|
||||
|
||||
@ -152,7 +151,7 @@ pub fn Admin() -> Element {
|
||||
#[component]
|
||||
fn StatCard(value: String, label: String) -> Element {
|
||||
rsx! {
|
||||
div { class: "{ADMIN_CARD_CLASS} p-6 text-center",
|
||||
div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center",
|
||||
div { class: "text-3xl font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
"{value}"
|
||||
}
|
||||
|
||||
@ -8,8 +8,6 @@ pub mod comments;
|
||||
pub mod dashboard;
|
||||
/// 文章管理列表页面模块。
|
||||
pub mod posts;
|
||||
/// 回收站管理页面模块。
|
||||
pub mod trash;
|
||||
/// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。
|
||||
pub mod write;
|
||||
|
||||
@ -19,7 +17,5 @@ pub use comments::{AdminComments, AdminCommentsPage};
|
||||
pub use dashboard::Admin;
|
||||
/// 文章管理列表组件(带默认分页)。
|
||||
pub use posts::{Posts, PostsPage};
|
||||
/// 回收站管理组件(带默认分页)。
|
||||
pub use trash::{Trash, TrashPage};
|
||||
/// 文章编辑器组件(新建与编辑模式)。
|
||||
pub use write::{Write, WriteEdit};
|
||||
|
||||
@ -14,7 +14,6 @@ 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;
|
||||
|
||||
@ -41,32 +40,6 @@ 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;
|
||||
@ -113,28 +86,38 @@ 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 |_| do_rebuild(false),
|
||||
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);
|
||||
});
|
||||
},
|
||||
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 {},
|
||||
@ -152,9 +135,11 @@ pub fn PostsPage(page: i32) -> Element {
|
||||
if loading() && posts().is_empty() {
|
||||
DelayedSkeleton { PostsSkeleton {} }
|
||||
} else if posts().is_empty() {
|
||||
EmptyState { message: "暂无文章", variant: "default" }
|
||||
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
||||
"暂无文章"
|
||||
}
|
||||
} else {
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
|
||||
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]",
|
||||
@ -195,19 +180,53 @@ pub fn PostsPage(page: i32) -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
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: "篇",
|
||||
}
|
||||
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", "»" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -217,9 +236,11 @@ pub fn PostsPage(page: i32) -> 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: "{ADMIN_ROW_HOVER}",
|
||||
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
|
||||
td { class: "px-4 py-3",
|
||||
Link {
|
||||
class: "text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
|
||||
@ -228,9 +249,8 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 text-center",
|
||||
StatusBadge {
|
||||
color_class: post.status_badge_class(),
|
||||
label: post.status_label().to_string(),
|
||||
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {status_badge_class}",
|
||||
"{status_label}"
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]",
|
||||
@ -247,7 +267,7 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element
|
||||
class: if deleting {
|
||||
"text-xs text-gray-400 cursor-not-allowed"
|
||||
} else {
|
||||
BTN_TEXT_RED
|
||||
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer"
|
||||
},
|
||||
disabled: deleting,
|
||||
onclick: move |_| on_delete.call(post.id),
|
||||
|
||||
@ -1,595 +0,0 @@
|
||||
//! 回收站管理页面。
|
||||
//!
|
||||
//! 展示已软删除文章,支持恢复、彻底删除、批量操作、一键清空,
|
||||
//! 以及自动清理配置(启用开关 + 保留天数)。
|
||||
//! 数据加载与操作仅在 WASM 前端通过 Dioxus server functions 交互。
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
// 操作类 server function 在 SSR 与 WASM 均需可见(spawn 闭包需类型检查),
|
||||
// 但部分仅用于 WASM 代码路径,SSR 下触发 unused imports,按项目惯例放行。
|
||||
#[allow(unused_imports)]
|
||||
use crate::api::posts::{
|
||||
batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post,
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::posts::{list_deleted_posts, PostListResponse};
|
||||
#[allow(unused_imports)]
|
||||
use crate::api::settings::{get_trash_settings, update_trash_settings};
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::components::ui::{
|
||||
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN,
|
||||
BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS,
|
||||
};
|
||||
use crate::models::post::Post;
|
||||
use crate::models::settings::TrashSettings;
|
||||
use crate::router::Route;
|
||||
|
||||
/// 每页展示的回收站文章数量。
|
||||
const TRASH_PER_PAGE: i32 = 20;
|
||||
|
||||
/// 回收站入口组件,默认展示第 1 页。
|
||||
#[component]
|
||||
pub fn Trash() -> Element {
|
||||
rsx! { TrashPage { page: 1 } }
|
||||
}
|
||||
|
||||
/// 回收站分页组件。
|
||||
///
|
||||
/// 支持单条/批量恢复与彻底删除、一键清空,以及内联自动清理配置。
|
||||
#[allow(unused_mut, unused_variables)]
|
||||
#[component]
|
||||
pub fn TrashPage(page: i32) -> Element {
|
||||
let current_page = page.max(1);
|
||||
let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new);
|
||||
let mut posts: Signal<Vec<Post>> = use_signal(Vec::new);
|
||||
let mut total: Signal<i64> = use_signal(|| 0);
|
||||
#[allow(unused_mut)]
|
||||
let mut loading: Signal<bool> = use_signal(|| false);
|
||||
#[allow(unused_mut)]
|
||||
let mut error: Signal<Option<String>> = use_signal(|| None);
|
||||
// 自动清理配置(含本地草稿态用于表单输入)。
|
||||
let mut settings: Signal<TrashSettings> = use_signal(TrashSettings::default);
|
||||
let mut settings_draft_days: Signal<String> = use_signal(|| "30".to_string());
|
||||
let mut settings_draft_enabled: Signal<bool> = use_signal(|| false);
|
||||
let mut settings_panel_open: Signal<bool> = use_signal(|| false);
|
||||
let mut saving_settings: Signal<bool> = use_signal(|| false);
|
||||
// 保存成功后的短暂反馈标记(用户再次编辑时清除)。
|
||||
let mut just_saved: Signal<bool> = use_signal(|| false);
|
||||
// 配置只加载一次的标记,避免每次翻页 effect 重复拉取。
|
||||
let mut settings_loaded: Signal<bool> = use_signal(|| false);
|
||||
|
||||
// 加载回收站列表;配置仅首次加载。
|
||||
use_effect(move || {
|
||||
let _ = current_page;
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let page = current_page;
|
||||
spawn(async move {
|
||||
match list_deleted_posts(page, TRASH_PER_PAGE).await {
|
||||
Ok(PostListResponse { posts: list, total: t }) => {
|
||||
posts.set(list);
|
||||
total.set(t);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
if !settings_loaded() {
|
||||
settings_loaded.set(true);
|
||||
spawn(async move {
|
||||
if let Ok(s) = get_trash_settings().await {
|
||||
settings_draft_days.set(s.retention_days.to_string());
|
||||
settings_draft_enabled.set(s.auto_purge_enabled);
|
||||
settings.set(s);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
loading.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 本地移除一篇文章(乐观更新)。
|
||||
let mut remove_post = move |id: i32| {
|
||||
posts.with_mut(|list| list.retain(|p| p.id != id));
|
||||
total.with_mut(|t| *t = t.saturating_sub(1));
|
||||
selected_ids.with_mut(|s| {
|
||||
s.remove(&id);
|
||||
});
|
||||
};
|
||||
|
||||
// 草稿相对已保存配置是否存在差异:控制保存按钮可用性与“未保存”提示。
|
||||
let dirty = settings_draft_enabled() != settings().auto_purge_enabled
|
||||
|| settings_draft_days()
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.ok()
|
||||
.map(|d| d != settings().retention_days)
|
||||
.unwrap_or(true);
|
||||
// 折叠箭头旋转类(展开时翻转 180°)。
|
||||
let chevron_rotate = if settings_panel_open() {
|
||||
"rotate-180"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-6",
|
||||
// 页面标题
|
||||
div { class: "flex items-center gap-3",
|
||||
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]", "回收站" }
|
||||
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||
"共 {total()} 篇"
|
||||
}
|
||||
}
|
||||
|
||||
// 自动清理配置卡片:可折叠的设置面板,顶部始终显示当前状态摘要
|
||||
div {
|
||||
class: "rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden bg-white dark:bg-[#2e2e33]",
|
||||
// 顶部可点击摘要条:状态指示灯 + 标题 + 展开箭头
|
||||
button {
|
||||
class: "w-full flex items-center gap-3 px-5 py-4 text-left cursor-pointer hover:bg-gray-50 dark:hover:bg-[#34343a] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40",
|
||||
onclick: move |_| {
|
||||
settings_panel_open.set(!settings_panel_open());
|
||||
just_saved.set(false);
|
||||
},
|
||||
// 状态指示灯
|
||||
{let dot_class = if settings().auto_purge_enabled {
|
||||
"w-2 h-2 rounded-full bg-[#5c7a5e] dark:bg-[#7da97f] shadow-[0_0_0_3px_rgba(92,122,94,0.15)]"
|
||||
} else {
|
||||
"w-2 h-2 rounded-full bg-gray-300 dark:bg-[#666]"
|
||||
};
|
||||
rsx! {
|
||||
div { class: "w-2 flex-shrink-0 flex items-center justify-center",
|
||||
div { class: "{dot_class}" }
|
||||
}
|
||||
}}
|
||||
// 标题 + 当前状态描述
|
||||
div { class: "flex-1 min-w-0",
|
||||
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb]",
|
||||
"自动清理"
|
||||
}
|
||||
div { class: "text-xs text-gray-500 dark:text-[#9b9c9d] mt-0.5 truncate",
|
||||
if settings().auto_purge_enabled {
|
||||
"已开启 · 超过 {settings().retention_days} 天的文章将被自动删除"
|
||||
} else {
|
||||
"已关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
// 展开箭头(旋转动画)
|
||||
svg {
|
||||
class: "w-4 h-4 text-gray-400 dark:text-[#9b9c9d] transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
|
||||
view_box: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
stroke_width: "2",
|
||||
path { stroke_linecap: "round", stroke_linejoin: "round", d: "M19 9l-7 7-7-7" }
|
||||
}
|
||||
}
|
||||
|
||||
// 设置面板(可折叠)
|
||||
if settings_panel_open() {
|
||||
div { class: "border-t border-gray-100 dark:border-[#3a3a3e] p-5 space-y-6",
|
||||
// 开关行:启用自动清理
|
||||
div {
|
||||
class: "flex items-center justify-between gap-4",
|
||||
div { class: "min-w-0",
|
||||
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb]",
|
||||
"启用自动清理"
|
||||
}
|
||||
div { class: "text-xs text-gray-500 dark:text-[#9b9c9d] mt-1",
|
||||
"后台任务定期彻底删除超过保留期的文章"
|
||||
}
|
||||
}
|
||||
// 自定义开关(toggle switch)—— 取代原生 checkbox
|
||||
button {
|
||||
role: "switch",
|
||||
aria_checked: "{settings_draft_enabled()}",
|
||||
class: if settings_draft_enabled() {
|
||||
"relative w-11 h-6 flex-shrink-0 rounded-full bg-[#5c7a5e] dark:bg-[#7da97f] cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40"
|
||||
} else {
|
||||
"relative w-11 h-6 flex-shrink-0 rounded-full bg-gray-300 dark:bg-[#555] cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40"
|
||||
},
|
||||
onclick: move |_| {
|
||||
settings_draft_enabled.set(!settings_draft_enabled());
|
||||
just_saved.set(false);
|
||||
},
|
||||
// 滑块圆点
|
||||
span {
|
||||
class: if settings_draft_enabled() {
|
||||
"absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200 translate-x-5"
|
||||
} else {
|
||||
"absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200"
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保留天数行
|
||||
div { class: "space-y-3",
|
||||
div { class: "min-w-0",
|
||||
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb]",
|
||||
"保留天数"
|
||||
}
|
||||
div { class: "text-xs text-gray-500 dark:text-[#9b9c9d] mt-1",
|
||||
"文章删除后保留的时长,到期后自动彻底清除(1–365)"
|
||||
}
|
||||
}
|
||||
// 数字输入 + 步进按钮 + 单位后缀
|
||||
div { class: "flex items-center gap-3",
|
||||
div { class: "flex items-center rounded-lg border border-gray-300 dark:border-[#444] bg-white dark:bg-[#1d1e20] overflow-hidden",
|
||||
// 减号
|
||||
button {
|
||||
class: "w-9 h-9 flex items-center justify-center text-sm text-gray-500 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] hover:bg-gray-50 dark:hover:bg-[#2a2a2a] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40",
|
||||
r#type: "button",
|
||||
aria_label: "减少保留天数",
|
||||
onclick: move |_| {
|
||||
let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
|
||||
let next = cur.saturating_sub(1).max(1);
|
||||
settings_draft_days.set(next.to_string());
|
||||
just_saved.set(false);
|
||||
},
|
||||
"−"
|
||||
}
|
||||
// 数字输入(无边框,衔接步进按钮)
|
||||
input {
|
||||
r#type: "number",
|
||||
min: "1",
|
||||
max: "365",
|
||||
class: "w-14 h-9 px-1 text-center text-sm tabular-nums text-gray-900 dark:text-[#dadadb] bg-transparent border-0 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
|
||||
value: "{settings_draft_days()}",
|
||||
oninput: move |e| {
|
||||
settings_draft_days.set(e.value());
|
||||
just_saved.set(false);
|
||||
},
|
||||
}
|
||||
// 加号
|
||||
button {
|
||||
class: "w-9 h-9 flex items-center justify-center text-sm text-gray-500 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] hover:bg-gray-50 dark:hover:bg-[#2a2a2a] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40",
|
||||
r#type: "button",
|
||||
aria_label: "增加保留天数",
|
||||
onclick: move |_| {
|
||||
let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
|
||||
let next = cur.saturating_add(1).min(365);
|
||||
settings_draft_days.set(next.to_string());
|
||||
just_saved.set(false);
|
||||
},
|
||||
"+"
|
||||
}
|
||||
}
|
||||
span { class: "text-xs text-gray-400 dark:text-[#666]", "天" }
|
||||
}
|
||||
}
|
||||
|
||||
// 底部操作行:未保存提示 + 保存按钮
|
||||
div { class: "flex items-center justify-between gap-4 pt-1",
|
||||
// 草稿状态提示
|
||||
if just_saved() {
|
||||
span { class: "inline-flex items-center gap-1.5 text-xs text-[#5c7a5e] dark:text-[#7da97f]",
|
||||
svg { class: "w-3.5 h-3.5", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2.5",
|
||||
path { stroke_linecap: "round", stroke_linejoin: "round", d: "M5 13l4 4L19 7" }
|
||||
}
|
||||
"已保存"
|
||||
}
|
||||
} else if dirty {
|
||||
span { class: "text-xs text-gray-400 dark:text-[#666]",
|
||||
"有未保存的更改"
|
||||
}
|
||||
} else {
|
||||
span { class: "text-xs text-transparent select-none", "·" }
|
||||
}
|
||||
// 保存按钮:启用主题色,禁用/保存中态灰化
|
||||
button {
|
||||
class: if saving_settings() {
|
||||
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] bg-gray-100 dark:bg-[#2a2a2a] rounded-full"
|
||||
} else if just_saved() {
|
||||
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] bg-gray-100 dark:bg-[#2a2a2a] rounded-full"
|
||||
} else {
|
||||
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-white bg-[#5c7a5e] dark:bg-[#7da97f] dark:text-[#1d1e20] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40"
|
||||
},
|
||||
disabled: saving_settings() || just_saved() || !dirty,
|
||||
onclick: move |_| {
|
||||
let days: i32 = settings_draft_days().parse().unwrap_or(30);
|
||||
let enabled = settings_draft_enabled();
|
||||
saving_settings.set(true);
|
||||
spawn(async move {
|
||||
if let Ok(s) = update_trash_settings(enabled, days).await {
|
||||
settings.set(s);
|
||||
just_saved.set(true);
|
||||
}
|
||||
saving_settings.set(false);
|
||||
});
|
||||
},
|
||||
if saving_settings() { "保存中…" } else { "保存设置" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量操作栏(选中时显示)
|
||||
if !selected_ids().is_empty() {
|
||||
div { class: "flex items-center gap-3 p-3 bg-gray-50 dark:bg-[#2a2a2a] rounded-lg",
|
||||
span { class: "text-sm text-gray-600 dark:text-[#9b9c9d]",
|
||||
"已选择 {selected_ids().len()} 条"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_SOLID_GREEN}",
|
||||
onclick: move |_| {
|
||||
let ids: Vec<i32> = selected_ids().iter().copied().collect();
|
||||
spawn(async move {
|
||||
let _ = batch_restore_posts(ids).await;
|
||||
});
|
||||
for id in selected_ids() { remove_post(id); }
|
||||
selected_ids.set(HashSet::new());
|
||||
},
|
||||
"批量恢复"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_SOLID_RED}",
|
||||
onclick: move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if web_sys::window()
|
||||
.and_then(|w| w.confirm_with_message("确定要彻底删除选中的文章吗?此操作不可恢复。").ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let ids: Vec<i32> = selected_ids().iter().copied().collect();
|
||||
spawn(async move {
|
||||
let _ = batch_purge_posts(ids).await;
|
||||
});
|
||||
for id in selected_ids() { remove_post(id); }
|
||||
selected_ids.set(HashSet::new());
|
||||
}
|
||||
}
|
||||
},
|
||||
"批量彻底删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主内容:错误 / 加载骨架 / 空态 / 列表
|
||||
{
|
||||
if error().is_some() {
|
||||
rsx! { EmptyState { message: "加载失败", variant: "error" } }
|
||||
} else if loading() && posts().is_empty() {
|
||||
rsx! {
|
||||
DelayedSkeleton {
|
||||
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] p-6 space-y-4",
|
||||
for _ in 0..5 {
|
||||
div { class: "h-10 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if posts().is_empty() {
|
||||
rsx! { EmptyState { message: "回收站为空", variant: "default" } }
|
||||
} else {
|
||||
let list = posts();
|
||||
let all_selected = list.iter().all(|p| selected_ids().contains(&p.id));
|
||||
let all_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||||
rsx! {
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "overflow-x-auto",
|
||||
table { class: "w-full text-sm",
|
||||
thead {
|
||||
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
|
||||
th { class: "px-4 py-3 font-medium w-10",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "{CHECKBOX_CLASS}",
|
||||
checked: all_selected,
|
||||
onchange: {
|
||||
move |_| {
|
||||
let mut s = selected_ids();
|
||||
if all_selected {
|
||||
for id in &all_ids { s.remove(id); }
|
||||
} else {
|
||||
for id in &all_ids { s.insert(*id); }
|
||||
}
|
||||
selected_ids.set(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
th { class: "px-4 py-3 font-medium", "标题" }
|
||||
th { class: "px-4 py-3 font-medium", "原状态" }
|
||||
th { class: "px-4 py-3 font-medium w-28", "删除时间" }
|
||||
th { class: "px-4 py-3 font-medium w-24 text-center", "剩余" }
|
||||
th { class: "px-4 py-3 font-medium w-32 text-right", "操作" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for post in list.iter() {
|
||||
TrashRow {
|
||||
key: "{post.id}",
|
||||
post: post.clone(),
|
||||
retention_days: settings().retention_days,
|
||||
selected: selected_ids().contains(&post.id),
|
||||
on_select: {
|
||||
let id = post.id;
|
||||
move |checked: bool| {
|
||||
let mut s = selected_ids();
|
||||
if checked { s.insert(id); } else { s.remove(&id); }
|
||||
selected_ids.set(s);
|
||||
}
|
||||
},
|
||||
on_restore: {
|
||||
let id = post.id;
|
||||
move |_| {
|
||||
spawn(async move {
|
||||
let _ = restore_post(id).await;
|
||||
});
|
||||
remove_post(id);
|
||||
}
|
||||
},
|
||||
on_purge: {
|
||||
let id = post.id;
|
||||
move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if web_sys::window()
|
||||
.and_then(|w| w.confirm_with_message("确定要彻底删除这篇文章吗?此操作不可恢复。").ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
spawn(async move {
|
||||
let _ = purge_post(id).await;
|
||||
});
|
||||
remove_post(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 底部:清空回收站 + 分页
|
||||
div { class: "flex items-center justify-between mt-4",
|
||||
button {
|
||||
class: "px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-900/50 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer",
|
||||
onclick: move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if web_sys::window()
|
||||
.and_then(|w| w.confirm_with_message("确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。").ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
spawn(async move {
|
||||
let _ = empty_trash().await;
|
||||
});
|
||||
posts.set(Vec::new());
|
||||
total.set(0);
|
||||
selected_ids.set(HashSet::new());
|
||||
}
|
||||
}
|
||||
},
|
||||
"清空回收站"
|
||||
}
|
||||
}
|
||||
Pagination {
|
||||
variant: "admin",
|
||||
current_page,
|
||||
total: total(),
|
||||
per_page: TRASH_PER_PAGE,
|
||||
prev_route: if current_page - 1 <= 1 {
|
||||
Route::Trash {}
|
||||
} else {
|
||||
Route::TrashPage { page: current_page - 1 }
|
||||
},
|
||||
next_route: Route::TrashPage { page: current_page + 1 },
|
||||
unit: "篇",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算剩余天数(保留期 - 已删除天数)。
|
||||
///
|
||||
/// 返回 (剩余天数, 是否已过期)。基于客户端时钟计算,轻微漂移可接受。
|
||||
fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(deleted_at) = post.deleted_at {
|
||||
let now_ms = js_sys::Date::now() as i64; // 毫秒
|
||||
let deleted_ms = deleted_at.timestamp_millis();
|
||||
let elapsed_days = (now_ms - deleted_ms) / 86_400_000;
|
||||
let remaining = retention_days as i64 - elapsed_days;
|
||||
(remaining, remaining <= 0)
|
||||
} else {
|
||||
(retention_days as i64, false)
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let _ = post;
|
||||
(retention_days as i64, false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回收站表格行组件。
|
||||
#[component]
|
||||
fn TrashRow(
|
||||
post: Post,
|
||||
retention_days: i32,
|
||||
selected: bool,
|
||||
on_select: EventHandler<bool>,
|
||||
on_restore: EventHandler,
|
||||
on_purge: EventHandler,
|
||||
) -> Element {
|
||||
let (remaining, expired) = remaining_days(&post, retention_days);
|
||||
// 剩余天数徽章配色:>7 天中性,≤7 天鼠尾草绿(主题色),≤0/过期琥珀色。
|
||||
let badge_class = if expired {
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
} else if remaining <= 7 {
|
||||
"bg-[#e8f0e8] text-[#5c7a5e] dark:bg-[#1e2e1e] dark:text-[#7da97f]"
|
||||
} else {
|
||||
"bg-gray-100 text-gray-600 dark:bg-[#333] dark:text-[#9b9c9d]"
|
||||
};
|
||||
let badge_text = if expired { "待清理".to_string() } else { format!("{remaining}天") };
|
||||
let deleted_str = post
|
||||
.deleted_at
|
||||
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
rsx! {
|
||||
tr { class: "{ADMIN_ROW_HOVER}",
|
||||
td { class: "px-4 py-3",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "{CHECKBOX_CLASS}",
|
||||
checked: selected,
|
||||
onchange: move |e| on_select.call(e.checked()),
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3",
|
||||
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb] truncate max-w-xs",
|
||||
"{post.title}"
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3",
|
||||
StatusBadge {
|
||||
color_class: post.status_badge_class(),
|
||||
label: post.status_label().to_string(),
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||
"{deleted_str}"
|
||||
}
|
||||
td { class: "px-4 py-3 text-center",
|
||||
StatusBadge {
|
||||
color_class: badge_class,
|
||||
label: badge_text,
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 text-right",
|
||||
div { class: "flex justify-end gap-2",
|
||||
button {
|
||||
class: "{BTN_TEXT_ACCENT}",
|
||||
onclick: move |_| on_restore.call(()),
|
||||
"恢复"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_TEXT_RED}",
|
||||
onclick: move |_| on_purge.call(()),
|
||||
"彻底删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,12 +10,12 @@
|
||||
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
|
||||
|
||||
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,20 +70,7 @@ fn HomePosts(current_page: i32) -> Element {
|
||||
}
|
||||
}
|
||||
// 在列表底部渲染分页导航。
|
||||
// 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: "篇",
|
||||
}
|
||||
Pagination { current_page, total }
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
@ -116,3 +103,42 @@ fn HomeInfo() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页导航组件。
|
||||
///
|
||||
/// 根据当前页码与文章总数计算总页数,并渲染上一页/下一页链接。
|
||||
/// 第一页的上一页链接固定指向 `Route::Home`,避免生成 `/page/1`。
|
||||
#[component]
|
||||
fn Pagination(current_page: i32, total: i64) -> Element {
|
||||
let has_prev = current_page > 1;
|
||||
// 向上取整计算总页数,至少为 1 页。
|
||||
let total_pages = ((total + POSTS_PER_PAGE as i64 - 1) / POSTS_PER_PAGE as i64).max(1) as i32;
|
||||
let has_next = current_page < total_pages;
|
||||
let prev = current_page - 1;
|
||||
// 当上一页为第 1 页时,使用 `/` 路由而非 `/page/1`。
|
||||
let prev_route = if prev <= 1 {
|
||||
Route::Home {}
|
||||
} else {
|
||||
Route::HomePage { page: prev }
|
||||
};
|
||||
|
||||
rsx! {
|
||||
nav { class: "flex mt-10 mb-6 justify-between",
|
||||
if has_prev {
|
||||
Link {
|
||||
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
|
||||
to: prev_route,
|
||||
span { class: "mr-1", "«" }
|
||||
"上一页"
|
||||
}
|
||||
}
|
||||
if has_next {
|
||||
Link {
|
||||
class: "ml-auto inline-flex items-center px-4 py-2 text-sm text-white bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
|
||||
to: Route::HomePage { page: current_page + 1 },
|
||||
"下一页"
|
||||
span { class: "ml-1", "»" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ use crate::components::frontend_layout::FrontendLayout;
|
||||
use crate::context::UserContext;
|
||||
use crate::pages::about::About;
|
||||
use crate::pages::admin::{
|
||||
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Trash, TrashPage, Write, WriteEdit,
|
||||
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Write, WriteEdit,
|
||||
};
|
||||
use crate::pages::archives::Archives;
|
||||
use crate::pages::home::{Home, HomePage};
|
||||
@ -84,12 +84,6 @@ pub enum Route {
|
||||
/// 评论管理分页
|
||||
#[route("/comments/:page")]
|
||||
AdminCommentsPage { page: i32 },
|
||||
/// 回收站
|
||||
#[route("/trash")]
|
||||
Trash {},
|
||||
/// 回收站分页
|
||||
#[route("/trash/:page")]
|
||||
TrashPage { page: i32 },
|
||||
#[end_layout]
|
||||
#[end_nest]
|
||||
|
||||
|
||||
@ -8,6 +8,3 @@ pub mod ip_purge;
|
||||
/// 定时删除已过期会话,避免 `sessions` 表无限增长。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod session_cleanup;
|
||||
/// 定时清理回收站中超过保留期的已删除文章。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod post_purge;
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
//! 回收站自动清理后台任务。
|
||||
//!
|
||||
//! 仅在 `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)
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
%YAML 1.2
|
||||
---
|
||||
# https://react.dev/learn/writing-markup-with-jsx
|
||||
# JavaScript + JSX. Pure JS (no TypeScript types) with JSX tag support.
|
||||
|
||||
name: JSX
|
||||
scope: source.jsx
|
||||
|
||||
file_extensions:
|
||||
- jsx
|
||||
|
||||
variables:
|
||||
identifier: '[A-Za-z_$][A-Za-z0-9_$]*'
|
||||
line_break: '(?:\x0D\x0A?|\x0A)'
|
||||
|
||||
contexts:
|
||||
main:
|
||||
- include: whitespace
|
||||
- include: comments
|
||||
- include: decorator
|
||||
- include: jsx
|
||||
- include: strings
|
||||
- include: numbers
|
||||
- include: declaration-keywords
|
||||
- include: control-keywords
|
||||
- include: constants
|
||||
- include: function-calls
|
||||
- include: property-access
|
||||
- include: punctuation
|
||||
- include: identifiers
|
||||
|
||||
whitespace:
|
||||
- match: '[\t ]+'
|
||||
|
||||
comments:
|
||||
- match: '/\*'
|
||||
scope: punctuation.definition.comment.jsx
|
||||
push:
|
||||
- meta_scope: comment.block.jsx
|
||||
- match: '\*/'
|
||||
scope: punctuation.definition.comment.jsx
|
||||
pop: 1
|
||||
- match: '//'
|
||||
scope: punctuation.definition.comment.jsx
|
||||
push:
|
||||
- meta_scope: comment.line.double-slash.jsx
|
||||
- match: '{{line_break}}'
|
||||
pop: 1
|
||||
|
||||
decorator:
|
||||
- match: '@{{identifier}}(?:\.{{identifier}})*'
|
||||
scope: entity.other.attribute-name.jsx
|
||||
|
||||
strings:
|
||||
- match: '`'
|
||||
scope: punctuation.definition.string.begin.jsx
|
||||
push:
|
||||
- meta_scope: string.quoted.other.jsx
|
||||
- match: '\$\{'
|
||||
scope: punctuation.section.interpolation.begin.jsx
|
||||
push:
|
||||
- clear_scopes: 1
|
||||
- meta_scope: meta.interpolation.jsx
|
||||
- include: main
|
||||
- match: '\}'
|
||||
scope: punctuation.section.interpolation.end.jsx
|
||||
pop: 1
|
||||
- match: '\\[nrt`\\$]'
|
||||
scope: constant.character.escape.jsx
|
||||
- match: '`'
|
||||
scope: punctuation.definition.string.end.jsx
|
||||
pop: 1
|
||||
- match: '"'
|
||||
scope: punctuation.definition.string.begin.jsx
|
||||
push:
|
||||
- meta_scope: string.quoted.double.jsx
|
||||
- match: '\\[nrt"\\]'
|
||||
scope: constant.character.escape.jsx
|
||||
- match: '"'
|
||||
scope: punctuation.definition.string.end.jsx
|
||||
pop: 1
|
||||
- match: '{{line_break}}'
|
||||
scope: invalid.illegal.unclosed-string.jsx
|
||||
pop: 1
|
||||
- match: "'"
|
||||
scope: punctuation.definition.string.begin.jsx
|
||||
push:
|
||||
- meta_scope: string.quoted.single.jsx
|
||||
- match: "\\\\[nrt'\\\\]"
|
||||
scope: constant.character.escape.jsx
|
||||
- match: "'"
|
||||
scope: punctuation.definition.string.end.jsx
|
||||
pop: 1
|
||||
|
||||
numbers:
|
||||
- match: '\b0[xX][0-9A-Fa-f_]+'
|
||||
scope: constant.numeric.integer.hexadecimal.jsx
|
||||
- match: '\b0[bB][01_]+'
|
||||
scope: constant.numeric.integer.binary.jsx
|
||||
- match: '\b0[oO][0-7_]+'
|
||||
scope: constant.numeric.integer.octal.jsx
|
||||
- match: '\b[0-9][0-9_]*(?:\.[0-9][0-9_]*)?(?:[eE][-+]?[0-9]+)?\b'
|
||||
scope: constant.numeric.decimal.jsx
|
||||
|
||||
declaration-keywords:
|
||||
- match: '\b(?:var|let|const|function|class|import|export|from|as|default|extends|implements)\b'
|
||||
scope: keyword.declaration.jsx
|
||||
|
||||
control-keywords:
|
||||
- match: '\b(?:if|else|for|while|do|switch|case|break|continue|return|throw|try|catch|finally|await|yield|new|delete|typeof|instanceof|in|of)\b'
|
||||
scope: keyword.control.jsx
|
||||
- match: '\b(?:async|await)\b'
|
||||
scope: keyword.control.async.jsx
|
||||
- match: '\b(?:true|false|null|undefined|this|super)\b'
|
||||
scope: constant.language.jsx
|
||||
|
||||
constants:
|
||||
- match: '\b(?:console|Math|JSON|Object|Number|String|Boolean|Symbol|window|document|globalThis|process|React)\b'
|
||||
scope: support.class.jsx
|
||||
- match: '\b[A-Z][A-Z0-9_]+\b'
|
||||
scope: variable.other.constant.jsx
|
||||
|
||||
function-calls:
|
||||
- match: '\b({{identifier}})\s*(?=\()'
|
||||
captures:
|
||||
1: variable.function.jsx
|
||||
|
||||
property-access:
|
||||
- match: '\.({{identifier}})\s*(?=\()'
|
||||
captures:
|
||||
1: variable.function.jsx
|
||||
- match: '\.({{identifier}})\b'
|
||||
captures:
|
||||
1: variable.other.property.jsx
|
||||
|
||||
punctuation:
|
||||
- match: '[\(\)\{\}\[\];,]'
|
||||
scope: punctuation.jsx
|
||||
- match: '=>'
|
||||
scope: storage.type.function.arrow.jsx
|
||||
- match: '[-+*/%=<>!&|?~^]+'
|
||||
scope: keyword.operator.jsx
|
||||
|
||||
identifiers:
|
||||
- match: '\b[A-Z][A-Za-z0-9_$]*\b'
|
||||
scope: entity.name.type.jsx
|
||||
- match: '{{identifier}}'
|
||||
scope: variable.jsx
|
||||
|
||||
# ============ JSX 规则 ============
|
||||
jsx:
|
||||
- match: '(<)((?:[A-Z][A-Za-z0-9_$]*|[a-z][A-Za-z0-9-]*))'
|
||||
captures:
|
||||
1: punctuation.definition.tag.begin.jsx
|
||||
2: entity.name.tag.jsx
|
||||
push:
|
||||
- meta_scope: meta.tag.jsx
|
||||
- include: jsx-attributes
|
||||
- match: '/>'
|
||||
scope: punctuation.definition.tag.end.jsx
|
||||
pop: 1
|
||||
- match: '>'
|
||||
scope: punctuation.definition.tag.end.jsx
|
||||
pop: jsx-body
|
||||
- match: '(</)((?:[A-Z][A-Za-z0-9_$]*|[a-z][A-Za-z0-9-]*))\s*(>)'
|
||||
scope: meta.tag.jsx
|
||||
captures:
|
||||
1: punctuation.definition.tag.begin.jsx
|
||||
2: entity.name.tag.jsx
|
||||
3: punctuation.definition.tag.end.jsx
|
||||
- match: '(<)(>)'
|
||||
captures:
|
||||
1: punctuation.definition.tag.begin.jsx
|
||||
2: punctuation.definition.tag.end.jsx
|
||||
push: jsx-body
|
||||
|
||||
jsx-attributes:
|
||||
- match: '\b([A-Za-z_][A-Za-z0-9_-]*)\s*(=)'
|
||||
captures:
|
||||
1: entity.other.attribute-name.jsx
|
||||
2: keyword.operator.assignment.jsx
|
||||
push:
|
||||
- match: '"'
|
||||
scope: punctuation.definition.string.begin.jsx
|
||||
push:
|
||||
- meta_scope: string.quoted.double.jsx
|
||||
- match: '"'
|
||||
scope: punctuation.definition.string.end.jsx
|
||||
pop: 1
|
||||
- match: "'"
|
||||
scope: punctuation.definition.string.begin.jsx
|
||||
push:
|
||||
- meta_scope: string.quoted.single.jsx
|
||||
- match: "'"
|
||||
scope: punctuation.definition.string.end.jsx
|
||||
pop: 1
|
||||
- match: '\{'
|
||||
scope: punctuation.section.embedded.begin.jsx
|
||||
push:
|
||||
- clear_scopes: 1
|
||||
- meta_scope: meta.embedded.expression.jsx
|
||||
- include: main
|
||||
- match: '\}'
|
||||
scope: punctuation.section.embedded.end.jsx
|
||||
pop: 1
|
||||
- match: '(?=\S)'
|
||||
pop: 1
|
||||
- include: whitespace
|
||||
- match: '\b[A-Za-z_][A-Za-z0-9_-]*\b'
|
||||
scope: entity.other.attribute-name.jsx
|
||||
|
||||
jsx-body:
|
||||
- meta_include_prototype: false
|
||||
- match: '(?=</)'
|
||||
pop: 1
|
||||
- include: jsx-text-interpolation
|
||||
- include: jsx
|
||||
|
||||
jsx-text-interpolation:
|
||||
- match: '\{'
|
||||
scope: punctuation.section.embedded.begin.jsx
|
||||
push:
|
||||
- meta_scope: meta.embedded.expression.jsx
|
||||
- include: main
|
||||
- match: '\}'
|
||||
scope: punctuation.section.embedded.end.jsx
|
||||
pop: 1
|
||||
@ -194,84 +194,6 @@ 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
|
||||
|
||||
|
||||
@ -1,270 +0,0 @@
|
||||
%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
|
||||
@ -1,221 +0,0 @@
|
||||
%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
|
||||
@ -1,191 +0,0 @@
|
||||
%YAML 1.2
|
||||
---
|
||||
# https://ziglang.org/documentation/master/
|
||||
# Sublime Text syntax definition for the Zig programming language.
|
||||
|
||||
name: Zig
|
||||
scope: source.zig
|
||||
|
||||
file_extensions:
|
||||
- zig
|
||||
- zon
|
||||
|
||||
variables:
|
||||
identifier: '[A-Za-z_][A-Za-z0-9_]*'
|
||||
line_break: '(?:\x0D\x0A?|\x0A)'
|
||||
|
||||
contexts:
|
||||
main:
|
||||
- include: whitespace
|
||||
- include: comments
|
||||
- include: strings
|
||||
- include: numbers
|
||||
- include: builtin-functions
|
||||
- include: function-declaration
|
||||
- include: keywords
|
||||
- include: types
|
||||
- include: constants
|
||||
- include: labels
|
||||
- include: operators
|
||||
- include: punctuation
|
||||
- include: identifiers
|
||||
|
||||
whitespace:
|
||||
- match: '[\t ]+'
|
||||
|
||||
comments:
|
||||
# Zig 只有行注释 //,支持文档注释 ///
|
||||
- match: '//[!/]'
|
||||
scope: punctuation.definition.comment.zig
|
||||
push:
|
||||
- meta_scope: comment.line.documentation.zig
|
||||
- match: '{{line_break}}'
|
||||
pop: 1
|
||||
- match: '//'
|
||||
scope: punctuation.definition.comment.zig
|
||||
push:
|
||||
- meta_scope: comment.line.double-slash.zig
|
||||
- match: '{{line_break}}'
|
||||
pop: 1
|
||||
|
||||
strings:
|
||||
# 多行字符串:每行以 \ 开头,行内容(去掉 \)原样拼接
|
||||
- match: '\\$'
|
||||
scope: punctuation.definition.string.begin.zig
|
||||
push: multiline-string-body
|
||||
# 字符串字面量 "...",支持转义和内联 {name} 格式化
|
||||
- match: '"'
|
||||
scope: punctuation.definition.string.begin.zig
|
||||
push:
|
||||
- meta_scope: string.quoted.double.zig
|
||||
- match: "\\\\[nrt\"'\\\\]"
|
||||
scope: constant.character.escape.zig
|
||||
- match: "\\\\x[0-9A-Fa-f]{2}"
|
||||
scope: constant.character.escape.hex.zig
|
||||
- match: "\\\\u\\{[0-9A-Fa-f]{1,6}\\}"
|
||||
scope: constant.character.escape.unicode.zig
|
||||
- match: '\{[^\}"]*\}'
|
||||
scope: meta.interpolation.zig
|
||||
- match: '"'
|
||||
scope: punctuation.definition.string.end.zig
|
||||
pop: 1
|
||||
- match: '{{line_break}}'
|
||||
scope: invalid.illegal.unclosed-string.zig
|
||||
pop: 1
|
||||
# 单引号字符字面量
|
||||
- match: "'"
|
||||
scope: punctuation.definition.string.begin.zig
|
||||
push:
|
||||
- meta_scope: string.quoted.single.zig
|
||||
- match: "\\\\[nrt\"'\\\\]"
|
||||
scope: constant.character.escape.zig
|
||||
- match: "'"
|
||||
scope: punctuation.definition.string.end.zig
|
||||
pop: 1
|
||||
|
||||
multiline-string-body:
|
||||
- meta_scope: string.unquoted.zig
|
||||
- match: '[^\s\\][^\n]*'
|
||||
- match: '\\$'
|
||||
- match: '{{line_break}}'
|
||||
- match: '(?=\S)'
|
||||
pop: 1
|
||||
|
||||
numbers:
|
||||
# 浮点:必须含小数点和指数,如 1.0e10
|
||||
- match: '\b(0x[0-9A-Fa-f_]+\.[0-9A-Fa-f_]+(?:[pP][-+]?[0-9]+))'
|
||||
scope: constant.numeric.float.hexadecimal.zig
|
||||
- match: '\b(0b[01_]+\.[01_]+(?:[pP][-+]?[0-9]+))'
|
||||
scope: constant.numeric.float.binary.zig
|
||||
- match: '\b([0-9][0-9_]*\.[0-9][0-9_]*(?:[eE][-+]?[0-9]+))'
|
||||
scope: constant.numeric.float.decimal.zig
|
||||
# 整数:十六进制/八进制/二进制/十进制,可带类型后缀
|
||||
- match: '\b0x[0-9A-Fa-f_]+'
|
||||
scope: constant.numeric.integer.hexadecimal.zig
|
||||
- match: '\b0o[0-7_]+'
|
||||
scope: constant.numeric.integer.octal.zig
|
||||
- match: '\b0b[01_]+'
|
||||
scope: constant.numeric.integer.binary.zig
|
||||
- match: '\b[0-9][0-9_]*'
|
||||
scope: constant.numeric.integer.decimal.zig
|
||||
|
||||
# 内建函数:以 @ 开头,如 @import @as @intCast
|
||||
builtin-functions:
|
||||
- match: '@{{identifier}}'
|
||||
scope: support.function.builtin.zig
|
||||
|
||||
# 函数声明:pub fn name(...) 块的识别
|
||||
function-declaration:
|
||||
- match: '\b(fn)\s+({{identifier}})'
|
||||
captures:
|
||||
1: keyword.declaration.function.zig
|
||||
2: entity.name.function.zig
|
||||
|
||||
keywords:
|
||||
# 声明类关键字
|
||||
- match: '\b(?:const|var|fn|pub|test|comptime|inline|noinline|extern|export|packed|align|linksection|threadlocal|addrspace)\b'
|
||||
scope: keyword.declaration.zig
|
||||
# 控制流
|
||||
- match: '\b(?:if|else|while|for|switch|break|continue|return|defer|errdefer|try|catch|orelse|unreachable|resume|suspend|nosuspend|await|async|const)\b'
|
||||
scope: keyword.control.zig
|
||||
# 布尔与 null
|
||||
- match: '\b(?:true|false|null|undefined)\b'
|
||||
scope: constant.language.zig
|
||||
# 类型相关关键字
|
||||
- match: '\b(?:struct|enum|union|opaque|error|anyerror|anytype|anyframe|anyopaque|void|noreturn|type|bool)\b'
|
||||
scope: keyword.other.type.zig
|
||||
# 操作符关键字
|
||||
- match: '\b(?:and|or|not)\b'
|
||||
scope: keyword.operator.logical.zig
|
||||
# 特殊关键字
|
||||
- match: '\b(?:usingnamespace|callconv|volatile|allowzero|unreachable)\b'
|
||||
scope: keyword.other.zig
|
||||
|
||||
types:
|
||||
# 整数类型:i32 u8 isize usize 以及任意位宽 i0..i65535
|
||||
- match: '\b[iu](?:sz|[0-9]{1,5})\b'
|
||||
scope: support.type.primitive.zig
|
||||
# 浮点类型
|
||||
- match: '\bf(?:16|32|64|80|128)\b'
|
||||
scope: support.type.primitive.zig
|
||||
# 常用标准库类型
|
||||
- match: '\b(?:std|mem|fmt|io|fs|os|debug|builtin|testing|hash|math|sort|meta|Thread|Allocator|ArrayList|StringHashMap|AutoHashMap|File|Dir|Reader|Writer|Buffer)\b'
|
||||
scope: support.type.zig
|
||||
|
||||
constants:
|
||||
# 编译期常量与枚举值风格的大写标识符
|
||||
- match: '\b[A-Z][A-Z0-9_]+\b'
|
||||
scope: variable.other.constant.zig
|
||||
|
||||
# 标签:用于 break :label / continue :label
|
||||
labels:
|
||||
- match: '(?:break|continue)\s+(:{{identifier}})'
|
||||
captures:
|
||||
1: entity.name.label.zig
|
||||
- match: '(:{{identifier}})\s*(?=\{)'
|
||||
captures:
|
||||
1: entity.name.label.zig
|
||||
|
||||
operators:
|
||||
- match: '<<=|>>=|<<|>>'
|
||||
scope: keyword.operator.bitwise.zig
|
||||
- match: '==|!=|<=|>=|<|>'
|
||||
scope: keyword.operator.comparison.zig
|
||||
- match: '&&|\|\||!'
|
||||
scope: keyword.operator.logical.zig
|
||||
- match: '\+\+|\*\*|[-+*/%]'
|
||||
scope: keyword.operator.arithmetic.zig
|
||||
- match: '&=|\|=|\^=|<<=|>>=|[-+*/%&|^]='
|
||||
scope: keyword.operator.assignment.zig
|
||||
- match: '[-=]>|\.|\?|%'
|
||||
scope: keyword.operator.zig
|
||||
|
||||
punctuation:
|
||||
- match: '[\(\)\{\}\[\];,]'
|
||||
scope: punctuation.zig
|
||||
|
||||
identifiers:
|
||||
- match: '\b[A-Z][A-Za-z0-9_]*\b'
|
||||
scope: entity.name.type.zig
|
||||
- match: '{{identifier}}'
|
||||
scope: variable.zig
|
||||
Loading…
x
Reference in New Issue
Block a user