Compare commits
7 Commits
fce16288b5
...
ec2f3e313e
| Author | SHA1 | Date | |
|---|---|---|---|
| ec2f3e313e | |||
| 59d5b9222a | |||
| 358dff152d | |||
| c10134c32e | |||
| 04737300e6 | |||
| efa41b42c2 | |||
| f9d23d1eed |
168
AGENTS.md
168
AGENTS.md
@ -3,25 +3,16 @@
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Dev server — runs tailwindcss watch + dx serve (needs PostgreSQL running)
|
||||
make dev
|
||||
|
||||
# Production build — builds editor, compiles CSS, then dx build --release
|
||||
make build
|
||||
|
||||
# Just CSS
|
||||
make css # one-shot
|
||||
make css-watch # watch mode
|
||||
|
||||
# Rust only (no CSS/editor)
|
||||
cargo build
|
||||
cargo clippy
|
||||
cargo test
|
||||
dx check # Dioxus type-check
|
||||
dx serve # Dev server without Tailwind watch
|
||||
make dev # tailwindcss watch + dx serve (needs PostgreSQL)
|
||||
make build # build-editor → highlight-css → tailwindcss → dx build --release
|
||||
make build-linux # same as build but targets x86_64-unknown-linux-musl
|
||||
make css # one-shot CSS
|
||||
make css-watch # watch mode
|
||||
make test # cargo test
|
||||
make clean # cargo clean + rm public/style.css
|
||||
```
|
||||
|
||||
**Command order matters for production**: `make build` internally runs `build-editor` → `tailwindcss` → `dx build --release`. Do not run `dx build --release` alone if you need the editor or CSS.
|
||||
**Build order matters**: `make build` runs `build-editor` → `highlight-css` (`cargo run --bin generate_highlight_css`) → `tailwindcss --minify` → `dx build --release`. Do not run `dx build --release` alone.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@ -32,104 +23,139 @@ dx serve # Dev server without Tailwind watch
|
||||
|
||||
## Environment
|
||||
|
||||
Create `.env` (not committed — see `.env.example`):
|
||||
Create `.env` (not committed):
|
||||
|
||||
```
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil
|
||||
RUST_LOG=info
|
||||
```
|
||||
|
||||
Optional tuning via env vars (all have sane defaults):
|
||||
|
||||
```
|
||||
WEBP_QUALITY=85.0 # 0.0–100.0, clamped
|
||||
WEBP_METHOD=2 # 0–6, clamped
|
||||
RATE_LIMIT_STRICT_PER_SEC=1
|
||||
RATE_LIMIT_STRICT_BURST=5
|
||||
RATE_LIMIT_UPLOAD_PER_SEC=2
|
||||
RATE_LIMIT_UPLOAD_BURST=15
|
||||
RATE_LIMIT_IMAGE_PER_SEC=10
|
||||
RATE_LIMIT_IMAGE_BURST=50
|
||||
```
|
||||
|
||||
Run migrations before first dev server start:
|
||||
|
||||
```bash
|
||||
./migrate.sh # preferred: auto-creates DB, runs all migrations in order
|
||||
# or manually:
|
||||
psql $DATABASE_URL -f migrations/001_init.sql
|
||||
./migrate.sh # auto-creates DB, runs migrations/ in order
|
||||
```
|
||||
|
||||
## Architecture: Conditional Compilation
|
||||
|
||||
This is a Dioxus 0.7 fullstack project with **two independent gates**. Getting this wrong is the most common source of compilation errors.
|
||||
Dioxus 0.7 fullstack project with **two independent gates** — the most common source of compilation errors.
|
||||
|
||||
| Gate | Applies to | Used for |
|
||||
|------|-----------|----------|
|
||||
| `#[cfg(feature = "server")]` | Server binary only | Database, env loading, background tasks, server functions body |
|
||||
| `#[cfg(target_arch = "wasm32")]` | WASM frontend only | localStorage, DOM APIs, cookie manipulation, web_sys calls |
|
||||
| `#[cfg(feature = "server")]` | Server binary only | DB, env loading, background tasks, server function bodies, highlight, WebP, caching |
|
||||
| `#[cfg(target_arch = "wasm32")]` | WASM frontend only | localStorage, DOM APIs, web_sys calls, theme detection |
|
||||
|
||||
**Critical**: `feature = "server"` and `target_arch = "wasm32"` are **mutually exclusive** at build time, but both default features (`web` + `server`) are enabled in `Cargo.toml`. The `dx` CLI handles feature selection during builds.
|
||||
**Critical**: Both default features (`web` + `server`) are enabled in `Cargo.toml`. The `dx` CLI handles feature selection during builds.
|
||||
|
||||
**Stub pattern**: `src/db/mod.rs` provides a `DummyPool` when the `server` feature is disabled so the crate still compiles for WASM. Do not remove this.
|
||||
**Stub pattern**: `src/db/mod.rs` provides a `DummyPool` when `server` feature is disabled — do not remove.
|
||||
|
||||
**Dead code allowances**: Auth utilities (`password.rs`, `session.rs`) carry `#[allow(dead_code)]` because they are imported by `api/auth.rs` but the compiler sees them as unused in WASM builds where server function bodies are stripped.
|
||||
**Dead code allowances**: `src/auth/password.rs` and `src/auth/session.rs` carry `#[allow(dead_code)]` because the compiler sees them as unused in WASM builds where server function bodies are stripped.
|
||||
|
||||
## Server Functions
|
||||
## Dual API Architecture
|
||||
|
||||
Server functions live in `src/api/auth.rs` and use the macro:
|
||||
The server exposes two distinct API patterns:
|
||||
|
||||
1. **Dioxus server functions** (`#[server(Name, "/api")]` in `src/api/`) — auto-routed, callable from both client and server Rust. Spread across `src/api/auth.rs` and `src/api/posts/`.
|
||||
|
||||
2. **Axum routes** (registered in `src/main.rs`) — manual `axum::Router` for endpoints that don't fit the server-function model:
|
||||
- `POST /api/upload` — image upload (multipart, auth-required, rate-limited)
|
||||
- `GET /uploads/{*path}` — image serving with on-the-fly resize/rotate/convert (query params: `w`, `h`, `thumb`, `rotate`, `format`, `quality`)
|
||||
|
||||
## Server Module Structure
|
||||
|
||||
```rust
|
||||
#[server(Name, "/api")]
|
||||
pub async fn fn_name(...) -> Result<..., ServerFnError>
|
||||
```
|
||||
|
||||
These are callable from both client and server Rust code; Dioxus handles HTTP transport automatically.
|
||||
src/api/ — server functions + Axum handlers
|
||||
auth.rs — login, register, session validation
|
||||
markdown.rs — Markdown→HTML rendering (pulldown-cmark + ammonia sanitization)
|
||||
image.rs — image serving with processing pipeline + disk+memory cache
|
||||
upload.rs — image upload, auto-converts to WebP
|
||||
rate_limit.rs — governor-based rate limiting (3 tiers: strict/upload/image)
|
||||
slug.rs — URL slug generation
|
||||
posts/ — CRUD server functions for blog posts
|
||||
src/auth/ — password hashing (Argon2) + session token management
|
||||
src/bin/ — generate_highlight_css (build-time CSS generation)
|
||||
src/cache.rs — moka future-based caches for posts, tags, stats
|
||||
src/components/ — Dioxus UI components
|
||||
src/db/ — PostgreSQL pool (deadpool-postgres, LazyLock global)
|
||||
src/hooks/ — shared Dioxus hooks
|
||||
src/models/ — Post, User, Tag data models
|
||||
src/pages/ — route page components (frontend + admin)
|
||||
src/tasks/ — background tokio tasks (session cleanup)
|
||||
src/theme.rs — light/dark theme with SSR cookie + WASM localStorage
|
||||
src/webp.rs — zenwebp encode/decode (image crate has no WebP)
|
||||
```
|
||||
|
||||
## Tiptap Editor Subproject
|
||||
|
||||
The rich-text editor is a separate Vite project in `libs/tiptap-editor/`.
|
||||
Rich-text editor in `libs/tiptap-editor/`, built as an IIFE library exposing `window.TiptapEditor`.
|
||||
|
||||
- Built as an **IIFE** library exposing `window.TiptapEditor`
|
||||
- Output goes to `public/tiptap/`
|
||||
- `make build` runs `npm install && vite build` inside `libs/tiptap-editor` and renames the output file
|
||||
- The write page (`src/pages/admin/write.rs`) initializes the editor via `js_sys::eval` and polls `window.__tiptap_ready`
|
||||
- Output: `public/tiptap/`
|
||||
- `make build` runs `npm install && npx vite build` inside `libs/tiptap-editor`
|
||||
- `src/pages/admin/write.rs` initializes via `js_sys::eval`, polls `window.__tiptap_ready`
|
||||
|
||||
Do not edit files in `public/tiptap/` directly — they are build artifacts.
|
||||
Do not edit `public/tiptap/` — they are build artifacts.
|
||||
|
||||
## Syntax Highlighting Pipeline
|
||||
|
||||
- `themes/` contains Catppuccin Latte (light) and Mocha (dark) `.tmTheme` files
|
||||
- `syntaxes/` has custom Sublime syntax definitions (Kotlin, Swift)
|
||||
- `src/bin/generate_highlight_css.rs` generates `public/highlight.css` with class-based rules scoped under `.md-content pre code`, with `.dark` prefix for dark mode
|
||||
- `src/highlight.rs` uses syntect at runtime for code block highlighting
|
||||
- All gated behind `#[cfg(feature = "server")]`
|
||||
|
||||
## Auth & Session
|
||||
|
||||
- **Registration**: first user becomes `admin`; subsequent registrations are rejected with `"Registration is closed"`.
|
||||
- **Login**: sets an **HttpOnly** cookie via `FullstackContext::add_response_header` (server-side).
|
||||
- **Session validation**: `get_current_user` reads the `session` cookie from the request headers and queries `sessions` + `users` tables.
|
||||
- **Background cleanup**: `tasks::session_cleanup::run_cleanup()` deletes expired sessions every hour on a tokio task.
|
||||
- **Registration**: first user becomes `admin`; subsequent registrations rejected with `"Registration is closed"`
|
||||
- **Login**: sets an HttpOnly cookie via `FullstackContext::add_response_header`
|
||||
- **Session validation**: `get_current_user` reads `session` cookie, queries `sessions` + `users` tables
|
||||
- **Background cleanup**: `tasks::session_cleanup::run_cleanup()` deletes expired sessions every hour
|
||||
|
||||
## Database
|
||||
## Caching
|
||||
|
||||
- PostgreSQL via `tokio-postgres` + `deadpool-postgres`
|
||||
- Pool is a `LazyLock` global in `src/db/pool.rs`, initialized from `DATABASE_URL`
|
||||
- Max pool size: 10
|
||||
- **Post/tag caches** (`src/cache.rs`): moka future-based, TTL varies by data type (60s–600s). Invalidated on writes.
|
||||
- **Image processing cache** (`src/api/image.rs`): two-tier — in-memory moka cache + disk cache in `uploads/.cache/`. Keyed by path + query params.
|
||||
|
||||
## Testing & Verification
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Standard Rust test suite
|
||||
cargo test
|
||||
|
||||
# Dioxus type-check (catches component/Router issues)
|
||||
dx check
|
||||
|
||||
# Lint
|
||||
cargo clippy
|
||||
cargo test # standard Rust test suite
|
||||
dx check # Dioxus type-check (catches component/Router issues)
|
||||
cargo clippy # lint
|
||||
```
|
||||
|
||||
There are currently no integration tests requiring a database connection.
|
||||
Most tests use `#[cfg(all(test, feature = "server"))]` — they only run when the server feature is active (which is the default). No integration tests requiring a database connection.
|
||||
|
||||
## Build Artifacts to Ignore
|
||||
## Image Processing Constraints
|
||||
|
||||
The following are generated and gitignored:
|
||||
- The `image` crate is configured **without** WebP support (`default-features = false, features = ["jpeg", "png", "gif"]`). Do not add WebP to the image crate features.
|
||||
- All WebP encode/decode goes through `zenwebp` via `src/webp.rs`.
|
||||
- Upload pipeline auto-converts non-GIF/non-WebP images to WebP, keeping original format if WebP is larger.
|
||||
- Image serving supports on-the-fly resize (`w`, `h`), thumbnail (`thumb=WxH`), rotation (90/180/270), and format conversion.
|
||||
|
||||
- `public/style.css` — generated by Tailwind
|
||||
- `public/tiptap/` — generated by Vite from `libs/tiptap-editor/`
|
||||
## Build Artifacts (gitignored)
|
||||
|
||||
- `public/style.css` — Tailwind output
|
||||
- `public/highlight.css` — generated by `generate_highlight_css` binary
|
||||
- `public/tiptap/` — Vite build output
|
||||
- `/dist`, `/.dioxus`, `/target`
|
||||
- `node_modules` (inside `libs/tiptap-editor/`)
|
||||
|
||||
## rust-analyzer
|
||||
|
||||
`rust-analyzer.toml` excludes generated directories (`node_modules`, `public`, `.dioxus`, `.omc`, `dist`). If rust-analyzer shows spurious errors in generated files, check that the exclusion list is respected by your editor.
|
||||
|
||||
## Image Processing
|
||||
|
||||
- **WebP handling**: The `image` crate is configured without WebP support (`default-features = false, features = ["jpeg", "png", "gif"]`). All WebP encode/decode operations go through `zenwebp` via `src/webp.rs`. This ensures consistent WebP handling across the codebase. Do not add WebP to the image crate features.
|
||||
- `uploads/.cache/` — image processing disk cache
|
||||
|
||||
## Notes
|
||||
|
||||
- `rand` + `getrandom` with `js` feature are required for Argon2 salt generation in WASM builds.
|
||||
- The `#[allow(unused_mut, unused_variables)]` on `Write` component is intentional — the `mut` signals are used inside `#[cfg(target_arch = "wasm32")]` blocks that are stripped in server builds.
|
||||
- `#[allow(unused_mut, unused_variables)]` on `Write` component is intentional — `mut` signals are used in `#[cfg(target_arch = "wasm32")]` blocks stripped in server builds.
|
||||
- Server uses incremental rendering with 300s cache (`IncrementalRendererConfig` in `src/main.rs`).
|
||||
|
||||
13
Cargo.lock
generated
13
Cargo.lock
generated
@ -2690,6 +2690,16 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
@ -3137,7 +3147,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fallible-iterator",
|
||||
"hmac",
|
||||
"md-5",
|
||||
"md-5 0.11.0",
|
||||
"memchr",
|
||||
"rand 0.10.1",
|
||||
"sha2 0.11.0",
|
||||
@ -5358,6 +5368,7 @@ dependencies = [
|
||||
"http",
|
||||
"image",
|
||||
"js-sys",
|
||||
"md-5 0.10.6",
|
||||
"moka",
|
||||
"pulldown-cmark",
|
||||
"rand 0.8.6",
|
||||
|
||||
@ -34,6 +34,7 @@ image = { version = "0.25", optional = true, default-features = false, features
|
||||
zenwebp = { version = "0.3", optional = true }
|
||||
moka = { version = "0.12", features = ["future"], optional = true }
|
||||
governor = { version = "0.8", optional = true }
|
||||
md-5 = { version = "0.10", optional = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator"] }
|
||||
@ -69,4 +70,5 @@ server = [
|
||||
"dep:zenwebp",
|
||||
"dep:moka",
|
||||
"dep:governor",
|
||||
"dep:md-5",
|
||||
]
|
||||
|
||||
161
input.css
161
input.css
@ -3,18 +3,20 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* PaperMod Light Theme */
|
||||
--color-paper-theme: #ffffff;
|
||||
--font-serif: 'Source Serif 4', 'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', Georgia, serif;
|
||||
|
||||
--color-paper-theme: #faf9f6;
|
||||
--color-paper-entry: #ffffff;
|
||||
--color-paper-primary: #1e1e1e;
|
||||
--color-paper-secondary: #6c6c6c;
|
||||
--color-paper-tertiary: #d6d6d6;
|
||||
--color-paper-content: #1f1f1f;
|
||||
--color-paper-code-block: #1c1d21;
|
||||
--color-paper-code-bg: #f5f5f5;
|
||||
--color-paper-border: #eeeeee;
|
||||
|
||||
/* PaperMod sizing */
|
||||
--color-paper-primary: #1c1917;
|
||||
--color-paper-secondary: #78716c;
|
||||
--color-paper-tertiary: #d6d3d1;
|
||||
--color-paper-content: #292524;
|
||||
--color-paper-code-block: #1c1917;
|
||||
--color-paper-code-bg: #f5f4f0;
|
||||
--color-paper-border: #e7e5e0;
|
||||
--color-paper-accent: #5c7a5e;
|
||||
--color-paper-accent-soft: #e8f0e8;
|
||||
|
||||
--radius-paper: 8px;
|
||||
--gap-paper: 24px;
|
||||
--content-gap-paper: 20px;
|
||||
@ -26,33 +28,36 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
background-color: var(--color-paper-theme);
|
||||
color: var(--color-paper-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-paper-theme: #1d1e20;
|
||||
--color-paper-entry: #2e2e33;
|
||||
--color-paper-primary: #dadadb;
|
||||
--color-paper-secondary: #9b9c9d;
|
||||
--color-paper-tertiary: #414244;
|
||||
--color-paper-content: #c4c4c5;
|
||||
--color-paper-code-block: #2e2e33;
|
||||
--color-paper-code-bg: #37383e;
|
||||
--color-paper-border: #333333;
|
||||
--color-paper-theme: #141416;
|
||||
--color-paper-entry: #1e1f22;
|
||||
--color-paper-primary: #e7e5df;
|
||||
--color-paper-secondary: #8c8981;
|
||||
--color-paper-tertiary: #3a3a3d;
|
||||
--color-paper-content: #d4d2cc;
|
||||
--color-paper-code-block: #1e1f22;
|
||||
--color-paper-code-bg: #28292d;
|
||||
--color-paper-border: #2a2b2e;
|
||||
--color-paper-accent: #7da97f;
|
||||
--color-paper-accent-soft: #1e2e1e;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Post Single Layout */
|
||||
.post-single {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Post Header */
|
||||
.post-header {
|
||||
margin: 24px auto var(--content-gap-paper) auto;
|
||||
}
|
||||
@ -62,12 +67,14 @@
|
||||
line-height: 1.2;
|
||||
color: var(--color-paper-primary);
|
||||
word-break: break-word;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.post-description {
|
||||
margin-top: 10px;
|
||||
color: var(--color-paper-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
@ -91,7 +98,6 @@
|
||||
color: var(--color-paper-primary);
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -112,14 +118,12 @@
|
||||
text-underline-offset: 0.2rem;
|
||||
}
|
||||
|
||||
/* Draft Badge */
|
||||
.entry-hint {
|
||||
display: inline-flex;
|
||||
margin-left: 0.5rem;
|
||||
color: var(--color-paper-secondary);
|
||||
}
|
||||
|
||||
/* TOC */
|
||||
details.toc {
|
||||
margin-bottom: var(--content-gap-paper);
|
||||
background: var(--color-paper-code-bg);
|
||||
@ -174,7 +178,6 @@
|
||||
margin-inline-start: var(--gap-paper);
|
||||
}
|
||||
|
||||
/* TOC Animation */
|
||||
details {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
@ -191,7 +194,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Entry Cover */
|
||||
.entry-cover {
|
||||
margin-bottom: var(--content-gap-paper);
|
||||
}
|
||||
@ -202,23 +204,23 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Post Content */
|
||||
.post-content {
|
||||
color: var(--color-paper-content);
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
/* Markdown Content */
|
||||
.md-content h1 {
|
||||
margin: 40px auto 32px;
|
||||
font-size: 40px;
|
||||
color: var(--color-paper-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.md-content h2 {
|
||||
margin: 32px auto 24px;
|
||||
font-size: 32px;
|
||||
color: var(--color-paper-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.md-content h3 {
|
||||
@ -248,16 +250,16 @@
|
||||
.md-content a:not(.anchor) {
|
||||
text-underline-offset: 0.3rem;
|
||||
text-decoration: underline;
|
||||
color: var(--color-paper-primary);
|
||||
color: var(--color-paper-accent);
|
||||
}
|
||||
|
||||
.md-content a:not(.anchor):hover {
|
||||
color: var(--color-paper-secondary);
|
||||
color: #4a6249;
|
||||
}
|
||||
|
||||
.md-content p {
|
||||
margin-bottom: var(--content-gap-paper);
|
||||
line-height: 1.8;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.md-content p:last-child {
|
||||
@ -325,7 +327,7 @@
|
||||
|
||||
.md-content-img-zoomable {
|
||||
cursor: zoom-in;
|
||||
transition: opacity 0.2s ease;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.md-content-img-zoomable:hover {
|
||||
@ -337,7 +339,6 @@
|
||||
border-radius: var(--radius-paper);
|
||||
}
|
||||
|
||||
/* Code Blocks */
|
||||
.md-content code {
|
||||
padding: 0.2rem 0.3rem;
|
||||
font-size: 0.85em;
|
||||
@ -366,12 +367,10 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Reset display for syntax-highlighted spans to prevent Tailwind .block conflict */
|
||||
.md-content pre code span {
|
||||
display: inline !important;
|
||||
}
|
||||
|
||||
/* Copy Code Button */
|
||||
.copy-code {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@ -386,11 +385,11 @@
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
transition: background 0.2s ease;
|
||||
transition: background 0.2s ease-out;
|
||||
}
|
||||
|
||||
.copy-code:hover {
|
||||
background: rgba(100, 100, 100, 0.9);
|
||||
background: rgba(92, 122, 94, 0.9);
|
||||
}
|
||||
|
||||
pre:hover .copy-code,
|
||||
@ -398,7 +397,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Heading Anchors */
|
||||
.anchor {
|
||||
display: none;
|
||||
text-decoration: none;
|
||||
@ -423,12 +421,10 @@
|
||||
color: var(--color-paper-primary) !important;
|
||||
}
|
||||
|
||||
/* Post Footer */
|
||||
.post-footer {
|
||||
margin-top: var(--content-gap-paper);
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.post-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -452,15 +448,15 @@
|
||||
border-radius: var(--radius-paper);
|
||||
border: 1px solid var(--color-paper-border);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.post-tags a:hover {
|
||||
background: var(--color-paper-border);
|
||||
color: var(--color-paper-primary);
|
||||
background: var(--color-paper-accent-soft);
|
||||
border-color: var(--color-paper-accent);
|
||||
color: var(--color-paper-accent);
|
||||
}
|
||||
|
||||
/* Post Navigation */
|
||||
.paginav {
|
||||
display: flex;
|
||||
border-radius: var(--radius-paper);
|
||||
@ -477,7 +473,7 @@
|
||||
padding: 0.8rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-paper-primary);
|
||||
transition: background 0.2s ease;
|
||||
transition: background 0.2s ease-out;
|
||||
}
|
||||
|
||||
.paginav a:hover {
|
||||
@ -491,7 +487,6 @@
|
||||
|
||||
.paginav .title {
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-paper-secondary);
|
||||
}
|
||||
@ -501,7 +496,6 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Back to Home */
|
||||
.back-to-home {
|
||||
margin-top: calc(var(--content-gap-paper) * 1.5);
|
||||
padding-top: var(--content-gap-paper);
|
||||
@ -527,17 +521,16 @@
|
||||
text-underline-offset: 0.3rem;
|
||||
}
|
||||
|
||||
/* Image Viewer Lightbox */
|
||||
.image-viewer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
animation: fadeIn 0.2s ease;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.image-viewer-content {
|
||||
@ -568,24 +561,23 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
transition: background 0.2s ease-out;
|
||||
}
|
||||
|
||||
.image-viewer-close:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Markdown Image Lightbox */
|
||||
.md-image-lightbox-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
animation: fadeIn 0.2s ease;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.md-image-lightbox-content {
|
||||
@ -616,14 +608,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
transition: background 0.2s ease-out;
|
||||
}
|
||||
|
||||
.md-image-lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Post Card Cover Thumbnail */
|
||||
.post-card-cover {
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-paper);
|
||||
@ -633,12 +624,60 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
.post-card-cover:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
a.accent-link {
|
||||
color: var(--color-paper-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease-out;
|
||||
}
|
||||
a.accent-link:hover {
|
||||
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;
|
||||
}
|
||||
.post-card-accent::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--color-paper-accent);
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
.post-card-accent:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@ -646,7 +685,6 @@
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media screen and (max-width: 768px) {
|
||||
.post-title {
|
||||
font-size: 32px;
|
||||
@ -686,7 +724,7 @@
|
||||
@keyframes pageEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@ -698,7 +736,6 @@
|
||||
animation: pageEnter 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
|
||||
57
migrations/004_comments.sql
Normal file
57
migrations/004_comments.sql
Normal file
@ -0,0 +1,57 @@
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
post_id INT NOT NULL REFERENCES posts(id) ON DELETE RESTRICT,
|
||||
parent_id BIGINT REFERENCES comments(id) ON DELETE SET NULL,
|
||||
depth INT NOT NULL DEFAULT 0,
|
||||
author_name VARCHAR(50) NOT NULL,
|
||||
author_email VARCHAR(255) NOT NULL,
|
||||
author_url VARCHAR(500),
|
||||
content_md TEXT NOT NULL,
|
||||
content_html TEXT,
|
||||
content_hash VARCHAR(64),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(500),
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT comments_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'spam', 'trash')),
|
||||
CONSTRAINT comments_depth_check
|
||||
CHECK (depth >= 0 AND depth <= 20),
|
||||
CONSTRAINT comments_content_not_empty
|
||||
CHECK (length(trim(content_md)) >= 1),
|
||||
CONSTRAINT comments_name_not_empty
|
||||
CHECK (length(trim(author_name)) >= 1)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_post_approved
|
||||
ON comments(post_id, created_at) WHERE status = 'approved' AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_top_level
|
||||
ON comments(post_id, created_at)
|
||||
WHERE parent_id IS NULL AND status = 'approved' AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_pending
|
||||
ON comments(created_at DESC) WHERE status = 'pending' AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_admin_list
|
||||
ON comments(status, created_at DESC) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_parent
|
||||
ON comments(parent_id) WHERE parent_id IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_comments_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_comments_updated_at
|
||||
BEFORE UPDATE ON comments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_comments_updated_at();
|
||||
@ -326,6 +326,22 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
|
||||
Ok(CurrentUserResponse { user })
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_current_admin_user() -> Result<User, AppError> {
|
||||
let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
|
||||
|
||||
let user = get_user_by_token(&token)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.ok_or(AppError::Unauthorized("会话已过期"))?;
|
||||
|
||||
if user.role != UserRole::Admin {
|
||||
return Err(AppError::Forbidden("权限不足"));
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
225
src/api/comments/create.rs
Normal file
225
src/api/comments/create.rs
Normal file
@ -0,0 +1,225 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::api::comments::types::*;
|
||||
|
||||
#[server(CreateComment, "/api")]
|
||||
pub async fn create_comment(
|
||||
post_id: i32,
|
||||
parent_id: Option<i64>,
|
||||
author_name: String,
|
||||
author_email: String,
|
||||
author_url: Option<String>,
|
||||
content_md: String,
|
||||
) -> Result<CommentResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::comments::helpers::{
|
||||
validate_comment_name, validate_comment_email, validate_comment_url,
|
||||
validate_comment_content, compute_content_hash,
|
||||
};
|
||||
|
||||
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||
let parts = ctx.parts_mut();
|
||||
let ip = crate::api::rate_limit::get_client_ip(&parts.headers);
|
||||
if let Err(msg) = crate::api::rate_limit::check_comment_limit(&ip) {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: msg,
|
||||
error_code: Some("rate_limited".into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = validate_comment_name(&author_name) {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: e,
|
||||
error_code: Some("invalid_input".into()),
|
||||
});
|
||||
}
|
||||
if let Err(e) = validate_comment_email(&author_email) {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: e,
|
||||
error_code: Some("invalid_input".into()),
|
||||
});
|
||||
}
|
||||
if let Some(ref url) = author_url {
|
||||
if let Err(e) = validate_comment_url(url) {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: e,
|
||||
error_code: Some("invalid_input".into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Err(e) = validate_comment_content(&content_md) {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: e,
|
||||
error_code: Some("invalid_input".into()),
|
||||
});
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let post_row = client
|
||||
.query_opt(
|
||||
"SELECT status, deleted_at FROM posts WHERE id = $1",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
match post_row {
|
||||
None => {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: "文章不存在".to_string(),
|
||||
error_code: Some("post_not_found".into()),
|
||||
});
|
||||
}
|
||||
Some(row) => {
|
||||
let status: String = row.get("status");
|
||||
let deleted_at: Option<chrono::DateTime<chrono::Utc>> = row.get("deleted_at");
|
||||
if status != "published" || deleted_at.is_some() {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: "文章不存在".to_string(),
|
||||
error_code: Some("post_not_found".into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut depth: i32 = 0;
|
||||
if let Some(pid) = parent_id {
|
||||
let parent_row = client
|
||||
.query_opt(
|
||||
"SELECT post_id, status, depth FROM comments WHERE id = $1 AND deleted_at IS NULL",
|
||||
&[&pid],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
match parent_row {
|
||||
None => {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: "父评论不存在".to_string(),
|
||||
error_code: Some("parent_not_found".into()),
|
||||
});
|
||||
}
|
||||
Some(row) => {
|
||||
let parent_post_id: i32 = row.get("post_id");
|
||||
let parent_status: String = row.get("status");
|
||||
let parent_depth: i32 = row.get("depth");
|
||||
|
||||
if parent_post_id != post_id {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: "父评论不存在".to_string(),
|
||||
error_code: Some("parent_not_found".into()),
|
||||
});
|
||||
}
|
||||
if parent_status != "approved" {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: "父评论未通过审核".to_string(),
|
||||
error_code: Some("parent_not_approved".into()),
|
||||
});
|
||||
}
|
||||
|
||||
depth = parent_depth + 1;
|
||||
if depth > 20 {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: "评论嵌套层级过深".to_string(),
|
||||
error_code: Some("too_deep".into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content_hash = compute_content_hash(
|
||||
post_id,
|
||||
parent_id,
|
||||
&author_name,
|
||||
&content_md,
|
||||
);
|
||||
|
||||
let dup: Option<i64> = client
|
||||
.query_opt(
|
||||
"SELECT id FROM comments WHERE post_id = $1 AND content_hash = $2 AND created_at > NOW() - INTERVAL '5 minutes'",
|
||||
&[&post_id, &content_hash],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.map(|r| r.get(0));
|
||||
|
||||
if dup.is_some() {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: "请勿重复提交".to_string(),
|
||||
error_code: Some("duplicate".into()),
|
||||
});
|
||||
}
|
||||
|
||||
let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md);
|
||||
|
||||
let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||
let parts = ctx.parts_mut();
|
||||
Some(crate::api::rate_limit::get_client_ip(&parts.headers))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||
let parts = ctx.parts_mut();
|
||||
parts.headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
client
|
||||
.query_one(
|
||||
"INSERT INTO comments \
|
||||
(post_id, parent_id, depth, author_name, author_email, author_url, \
|
||||
content_md, content_html, content_hash, status, ip_address, user_agent) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11) \
|
||||
RETURNING id",
|
||||
&[
|
||||
&post_id,
|
||||
&parent_id,
|
||||
&depth,
|
||||
&author_name.trim(),
|
||||
&author_email.trim(),
|
||||
&author_url.as_ref().map(|u| u.trim()).filter(|u| !u.is_empty()),
|
||||
&content_md,
|
||||
&content_html,
|
||||
&content_hash,
|
||||
&ip_address,
|
||||
&user_agent,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
cache::invalidate_comments_by_post(post_id).await;
|
||||
cache::invalidate_comment_count(post_id).await;
|
||||
|
||||
Ok(CommentResponse {
|
||||
success: true,
|
||||
message: "评论已提交,等待审核".to_string(),
|
||||
error_code: None,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
316
src/api/comments/helpers.rs
Normal file
316
src/api/comments/helpers.rs
Normal file
@ -0,0 +1,316 @@
|
||||
#![allow(clippy::unused_unit, deprecated, unused_imports)]
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::comment::{AdminComment, CommentStatus, PublicComment};
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn md5_hash(input: &str) -> String {
|
||||
use md5::Digest;
|
||||
let hash = md5::Md5::digest(input.as_bytes());
|
||||
hex::encode(hash)
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn gravatar_url(email: &str) -> String {
|
||||
let hash = md5_hash(&email.trim().to_lowercase());
|
||||
format!("https://cravatar.cn/avatar/{}?d=mp&s=80", hash)
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment {
|
||||
let email: String = row.get("author_email");
|
||||
let created_at_dt: chrono::DateTime<chrono::Utc> = row.get("created_at");
|
||||
let created_at_iso = created_at_dt.to_rfc3339();
|
||||
let created_at_relative = format_relative_time(created_at_dt);
|
||||
|
||||
PublicComment {
|
||||
id: row.get("id"),
|
||||
parent_id: row.get("parent_id"),
|
||||
depth: row.get("depth"),
|
||||
author_name: row.get("author_name"),
|
||||
author_url: row.get("author_url"),
|
||||
avatar_url: gravatar_url(&email),
|
||||
content_html: row.get("content_html"),
|
||||
created_at: created_at_relative,
|
||||
created_at_iso,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment {
|
||||
let status_str: String = row.get("status");
|
||||
|
||||
AdminComment {
|
||||
id: row.get("id"),
|
||||
post_id: row.get("post_id"),
|
||||
post_title: row.get("post_title"),
|
||||
post_slug: row.get("post_slug"),
|
||||
parent_id: row.get("parent_id"),
|
||||
depth: row.get("depth"),
|
||||
author_name: row.get("author_name"),
|
||||
author_email: row.get("author_email"),
|
||||
author_url: row.get("author_url"),
|
||||
content_md: row.get("content_md"),
|
||||
status: CommentStatus::from_str(&status_str),
|
||||
created_at: row.get("created_at"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
let diff = now.signed_duration_since(dt);
|
||||
|
||||
if diff.num_seconds() < 60 {
|
||||
"刚刚".to_string()
|
||||
} else if diff.num_minutes() < 60 {
|
||||
format!("{} 分钟前", diff.num_minutes())
|
||||
} else if diff.num_hours() < 24 {
|
||||
format!("{} 小时前", diff.num_hours())
|
||||
} else if diff.num_days() < 30 {
|
||||
format!("{} 天前", diff.num_days())
|
||||
} else {
|
||||
dt.format("%Y-%m-%d").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn validate_comment_name(name: &str) -> Result<(), String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("请输入昵称".to_string());
|
||||
}
|
||||
if trimmed.len() > 50 {
|
||||
return Err("昵称长度不能超过 50 个字符".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn validate_comment_email(email: &str) -> Result<(), String> {
|
||||
let re =
|
||||
regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
|
||||
if !re.is_match(email.trim()) {
|
||||
return Err("邮箱格式不正确".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn validate_comment_url(url: &str) -> Result<(), String> {
|
||||
if url.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err("网址必须以 http:// 或 https:// 开头".to_string());
|
||||
}
|
||||
if url.len() > 200 {
|
||||
return Err("网址长度不能超过 200 个字符".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn validate_comment_content(content: &str) -> Result<(), String> {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("请输入评论内容".to_string());
|
||||
}
|
||||
if trimmed.len() > 10000 {
|
||||
return Err("评论内容不能超过 10000 个字符".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compute_content_hash(
|
||||
post_id: i32,
|
||||
parent_id: Option<i64>,
|
||||
name: &str,
|
||||
content: &str,
|
||||
) -> String {
|
||||
use sha2::Digest;
|
||||
let input = format!(
|
||||
"{}:{}:{}:{}",
|
||||
post_id,
|
||||
parent_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
name.trim(),
|
||||
content.trim()
|
||||
);
|
||||
let hash = sha2::Sha256::digest(input.as_bytes());
|
||||
hex::encode(hash)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn md5_hash_known_value() {
|
||||
assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn md5_hash_empty() {
|
||||
assert_eq!(md5_hash(""), "d41d8cd98f00b204e9800998ecf8427e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gravatar_url_format() {
|
||||
let url = gravatar_url("test@example.com");
|
||||
assert!(url.starts_with("https://cravatar.cn/avatar/"));
|
||||
assert!(url.contains("?d=mp&s=80"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gravatar_url_normalizes_email() {
|
||||
let url1 = gravatar_url("Test@Example.com");
|
||||
let url2 = gravatar_url("test@example.com");
|
||||
assert_eq!(url1, url2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gravatar_url_trims_whitespace() {
|
||||
let url1 = gravatar_url(" test@example.com ");
|
||||
let url2 = gravatar_url("test@example.com");
|
||||
assert_eq!(url1, url2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_just_now() {
|
||||
let now = chrono::Utc::now();
|
||||
assert_eq!(format_relative_time(now), "刚刚");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_minutes() {
|
||||
let dt = chrono::Utc::now() - chrono::Duration::minutes(5);
|
||||
assert_eq!(format_relative_time(dt), "5 分钟前");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_hours() {
|
||||
let dt = chrono::Utc::now() - chrono::Duration::hours(3);
|
||||
assert_eq!(format_relative_time(dt), "3 小时前");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_days() {
|
||||
let dt = chrono::Utc::now() - chrono::Duration::days(7);
|
||||
assert_eq!(format_relative_time(dt), "7 天前");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_old_date() {
|
||||
let dt = chrono::Utc::now() - chrono::Duration::days(60);
|
||||
let result = format_relative_time(dt);
|
||||
assert!(result.contains('-'));
|
||||
assert_eq!(result.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_name_valid() {
|
||||
assert!(validate_comment_name("Alice").is_ok());
|
||||
assert!(validate_comment_name("张三").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_name_empty() {
|
||||
assert!(validate_comment_name("").is_err());
|
||||
assert!(validate_comment_name(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_name_too_long() {
|
||||
assert!(validate_comment_name(&"a".repeat(51)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_name_max_length() {
|
||||
assert!(validate_comment_name(&"a".repeat(50)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_email_valid() {
|
||||
assert!(validate_comment_email("user@example.com").is_ok());
|
||||
assert!(validate_comment_email("a.b+c@domain.co").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_email_invalid() {
|
||||
assert!(validate_comment_email("notanemail").is_err());
|
||||
assert!(validate_comment_email("@domain.com").is_err());
|
||||
assert!(validate_comment_email("user@").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_url_valid() {
|
||||
assert!(validate_comment_url("http://example.com").is_ok());
|
||||
assert!(validate_comment_url("https://example.com/path").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_url_empty_is_ok() {
|
||||
assert!(validate_comment_url("").is_ok());
|
||||
assert!(validate_comment_url(" ").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_url_invalid_scheme() {
|
||||
assert!(validate_comment_url("ftp://example.com").is_err());
|
||||
assert!(validate_comment_url("javascript:alert(1)").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_url_too_long() {
|
||||
let long_url = format!("https://example.com/{}", "a".repeat(200));
|
||||
assert!(validate_comment_url(&long_url).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_content_valid() {
|
||||
assert!(validate_comment_content("Hello world").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_content_empty() {
|
||||
assert!(validate_comment_content("").is_err());
|
||||
assert!(validate_comment_content(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_content_too_long() {
|
||||
assert!(validate_comment_content(&"a".repeat(10001)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_content_max_length() {
|
||||
assert!(validate_comment_content(&"a".repeat(10000)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_content_hash_deterministic() {
|
||||
let h1 = compute_content_hash(1, None, "Alice", "Hello");
|
||||
let h2 = compute_content_hash(1, None, "Alice", "Hello");
|
||||
assert_eq!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_content_hash_different_inputs() {
|
||||
let h1 = compute_content_hash(1, None, "Alice", "Hello");
|
||||
let h2 = compute_content_hash(2, None, "Alice", "Hello");
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_content_hash_trims_whitespace() {
|
||||
let h1 = compute_content_hash(1, None, "Alice", "Hello");
|
||||
let h2 = compute_content_hash(1, None, " Alice ", " Hello ");
|
||||
assert_eq!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_content_hash_64_hex_chars() {
|
||||
let h = compute_content_hash(1, None, "Alice", "Hello");
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
}
|
||||
166
src/api/comments/list.rs
Normal file
166
src/api/comments/list.rs
Normal file
@ -0,0 +1,166 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::api::comments::types::*;
|
||||
|
||||
#[server(GetPendingComments, "/api")]
|
||||
pub async fn get_pending_comments(
|
||||
page: i32,
|
||||
) -> Result<PendingCommentsResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::comments::helpers::row_to_admin_comment;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
let page = page.max(1);
|
||||
let per_page: i64 = 20;
|
||||
let offset: i64 = (page as i64 - 1) * per_page;
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let total: i64 = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM comments WHERE status = 'pending' AND deleted_at IS NULL",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \
|
||||
c.author_url, c.content_md, c.status, c.created_at, \
|
||||
p.title as post_title, p.slug as post_slug \
|
||||
FROM comments c JOIN posts p ON c.post_id = p.id \
|
||||
WHERE c.status = 'pending' AND c.deleted_at IS NULL \
|
||||
ORDER BY c.created_at DESC LIMIT $1 OFFSET $2",
|
||||
&[&per_page, &offset],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
let comments = rows.iter().map(row_to_admin_comment).collect();
|
||||
|
||||
Ok(PendingCommentsResponse { comments, total })
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[server(GetPendingCount, "/api")]
|
||||
pub async fn get_pending_count() -> Result<PendingCountResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
if let Some(cached) = cache::get_pending_count().await {
|
||||
return Ok(PendingCountResponse { count: cached });
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let count: i64 = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM comments WHERE status = 'pending' AND deleted_at IS NULL",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
|
||||
cache::set_pending_count(count).await;
|
||||
|
||||
Ok(PendingCountResponse { count })
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[server(GetAllComments, "/api")]
|
||||
pub async fn get_all_comments(
|
||||
page: i32,
|
||||
status: Option<String>,
|
||||
) -> Result<AllCommentsResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::comments::helpers::row_to_admin_comment;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
let page = page.max(1);
|
||||
let per_page: i64 = 20;
|
||||
let offset: i64 = (page as i64 - 1) * per_page;
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let (total, rows) = match status.as_deref() {
|
||||
Some(s) if !s.is_empty() => {
|
||||
let total: i64 = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM comments WHERE status = $1 AND deleted_at IS NULL",
|
||||
&[&s],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \
|
||||
c.author_url, c.content_md, c.status, c.created_at, \
|
||||
p.title as post_title, p.slug as post_slug \
|
||||
FROM comments c JOIN posts p ON c.post_id = p.id \
|
||||
WHERE c.status = $1 AND c.deleted_at IS NULL \
|
||||
ORDER BY c.created_at DESC LIMIT $2 OFFSET $3",
|
||||
&[&s, &per_page, &offset],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
(total, rows)
|
||||
}
|
||||
_ => {
|
||||
let total: i64 = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM comments WHERE deleted_at IS NULL",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \
|
||||
c.author_url, c.content_md, c.status, c.created_at, \
|
||||
p.title as post_title, p.slug as post_slug \
|
||||
FROM comments c JOIN posts p ON c.post_id = p.id \
|
||||
WHERE c.deleted_at IS NULL \
|
||||
ORDER BY c.created_at DESC LIMIT $1 OFFSET $2",
|
||||
&[&per_page, &offset],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
(total, rows)
|
||||
}
|
||||
};
|
||||
|
||||
let comments = rows.iter().map(row_to_admin_comment).collect();
|
||||
|
||||
Ok(AllCommentsResponse { comments, total })
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
206
src/api/comments/markdown.rs
Normal file
206
src/api/comments/markdown.rs
Normal file
@ -0,0 +1,206 @@
|
||||
#![allow(clippy::unused_unit, deprecated, unused_imports)]
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn clean_comment_html(input: &str) -> String {
|
||||
let mut builder = ammonia::Builder::default();
|
||||
builder
|
||||
.rm_tags(["img", "details", "summary"])
|
||||
.add_generic_attributes(&[
|
||||
"class",
|
||||
"title",
|
||||
"aria-hidden",
|
||||
"aria-label",
|
||||
"role",
|
||||
"accesskey",
|
||||
])
|
||||
.url_relative(ammonia::UrlRelative::PassThrough)
|
||||
.add_tag_attributes("a", &["class", "aria-hidden", "aria-label"])
|
||||
.add_tag_attributes("span", &["class"])
|
||||
.link_rel(Some("nofollow noopener"));
|
||||
|
||||
builder.clean(input).to_string()
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn render_comment_markdown(md: &str) -> String {
|
||||
use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, TagEnd};
|
||||
|
||||
let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
|
||||
let parser = pulldown_cmark::Parser::new_ext(md, opts);
|
||||
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
let mut in_codeblock = false;
|
||||
let mut code_lang: Option<String> = None;
|
||||
let mut code_buffer = String::new();
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { .. }) => {
|
||||
events.push(Event::Start(Tag::Strong));
|
||||
}
|
||||
Event::End(TagEnd::Heading(_)) => {
|
||||
events.push(Event::End(TagEnd::Strong));
|
||||
}
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
in_codeblock = true;
|
||||
code_lang = match kind {
|
||||
CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
|
||||
Some(lang.to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
code_buffer.clear();
|
||||
}
|
||||
Event::Text(text) if in_codeblock => {
|
||||
code_buffer.push_str(&text);
|
||||
}
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
let html = if let Some(ref lang) = code_lang {
|
||||
let highlighted =
|
||||
crate::highlight::server::highlight_code(&code_buffer, Some(lang));
|
||||
format!("<pre><code>{}</code></pre>", highlighted)
|
||||
} else {
|
||||
format!("<pre><code>{}</code></pre>", html_escape(&code_buffer))
|
||||
};
|
||||
events.push(Event::Html(html.into()));
|
||||
in_codeblock = false;
|
||||
}
|
||||
_ if !in_codeblock => {
|
||||
events.push(event);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut html = String::new();
|
||||
pulldown_cmark::html::push_html(&mut html, events.into_iter());
|
||||
clean_comment_html(&html)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_comment_heading_converted_to_strong() {
|
||||
let result = render_comment_markdown("## Hello World");
|
||||
assert!(result.contains("<strong>Hello World</strong>"));
|
||||
assert!(!result.contains("<h2>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_heading_all_levels() {
|
||||
for md in &[
|
||||
"# H1", "## H2", "### H3", "#### H4", "##### H5", "###### H6",
|
||||
] {
|
||||
let result = render_comment_markdown(md);
|
||||
assert!(result.contains("<strong>"), "heading not converted for: {}", md);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_paragraph() {
|
||||
let result = render_comment_markdown("Hello **world**");
|
||||
assert!(result.contains("<strong>world</strong>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_code_block_with_language() {
|
||||
let result = render_comment_markdown("```rust\nfn main() {}\n```");
|
||||
assert!(result.contains("<pre><code>"));
|
||||
assert!(result.contains("main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_code_block_without_language() {
|
||||
let result = render_comment_markdown("```\nplain text\n```");
|
||||
assert!(result.contains("<pre><code>"));
|
||||
assert!(result.contains("plain text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_code_block_without_language_escapes_html() {
|
||||
let result = render_comment_markdown("```\n<div>alert('xss')</div>\n```");
|
||||
assert!(result.contains("<div>"));
|
||||
assert!(!result.contains("<div>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_strips_script() {
|
||||
let result = render_comment_markdown("<script>alert('xss')</script>");
|
||||
assert!(!result.contains("script"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_no_img_tags() {
|
||||
let result = render_comment_markdown("");
|
||||
assert!(!result.contains("<img"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_link_has_nofollow() {
|
||||
let result = render_comment_markdown("[link](https://example.com)");
|
||||
assert!(result.contains("nofollow"));
|
||||
assert!(result.contains("noopener"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_no_id_attribute() {
|
||||
let result = render_comment_markdown("<div id=\"test\">text</div>");
|
||||
assert!(!result.contains("id="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_table() {
|
||||
let result = render_comment_markdown("| a | b |\n|---|---|\n| 1 | 2 |");
|
||||
assert!(result.contains("<table>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_strikethrough() {
|
||||
let result = render_comment_markdown("~~deleted~~");
|
||||
assert!(result.contains("<del>deleted</del>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_inline_code() {
|
||||
let result = render_comment_markdown("Use `println!` to print");
|
||||
assert!(result.contains("<code>println!</code>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_comment_html_removes_details_summary() {
|
||||
let result = clean_comment_html("<details><summary>Click</summary><p>Content</p></details>");
|
||||
assert!(!result.contains("details"));
|
||||
assert!(!result.contains("summary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_comment_html_removes_data_uri() {
|
||||
let result =
|
||||
clean_comment_html("<a href=\"data:text/html,<script>alert(1)</script>\">click</a>");
|
||||
assert!(!result.contains("data:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_empty() {
|
||||
let result = render_comment_markdown("");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_comment_heading_with_inline_code() {
|
||||
let result = render_comment_markdown("## Using `foo()`");
|
||||
assert!(result.contains("<strong>"));
|
||||
assert!(result.contains("<code>foo()</code>"));
|
||||
assert!(!result.contains("<h2>"));
|
||||
}
|
||||
}
|
||||
18
src/api/comments/mod.rs
Normal file
18
src/api/comments/mod.rs
Normal file
@ -0,0 +1,18 @@
|
||||
#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)]
|
||||
|
||||
mod types;
|
||||
mod helpers;
|
||||
mod markdown;
|
||||
mod create;
|
||||
mod read;
|
||||
mod update;
|
||||
mod list;
|
||||
|
||||
pub use types::*;
|
||||
pub use create::create_comment;
|
||||
pub use read::{get_comments, get_comment_count};
|
||||
pub use update::{approve_comment, spam_comment, trash_comment, batch_update_comment_status};
|
||||
pub use list::{get_pending_comments, get_pending_count, get_all_comments};
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub use markdown::render_comment_markdown;
|
||||
78
src/api/comments/read.rs
Normal file
78
src/api/comments/read.rs
Normal file
@ -0,0 +1,78 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::api::comments::types::*;
|
||||
|
||||
#[server(GetComments, "/api")]
|
||||
pub async fn get_comments(
|
||||
post_id: i32,
|
||||
) -> Result<CommentTreeResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::comments::helpers::row_to_public_comment;
|
||||
use crate::api::error::AppError;
|
||||
|
||||
if let Some(cached) = cache::get_comments_by_post(post_id).await {
|
||||
let count = cached.len() as i64;
|
||||
return Ok(CommentTreeResponse {
|
||||
comments: cached,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT id, parent_id, depth, author_name, author_email, author_url, content_html, created_at \
|
||||
FROM comments \
|
||||
WHERE post_id = $1 AND status = 'approved' AND deleted_at IS NULL \
|
||||
ORDER BY id ASC",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
let comments: Vec<_> = rows.iter().map(row_to_public_comment).collect();
|
||||
let count = comments.len() as i64;
|
||||
|
||||
cache::set_comments_by_post(post_id, comments.clone()).await;
|
||||
|
||||
Ok(CommentTreeResponse { comments, count })
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[server(GetCommentCount, "/api")]
|
||||
pub async fn get_comment_count(
|
||||
post_id: i32,
|
||||
) -> Result<CommentCountResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
|
||||
if let Some(cached) = cache::get_comment_count(post_id).await {
|
||||
return Ok(CommentCountResponse { count: cached });
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let count: i64 = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM comments WHERE post_id = $1 AND status = 'approved' AND deleted_at IS NULL",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
|
||||
cache::set_comment_count(post_id, count).await;
|
||||
|
||||
Ok(CommentCountResponse { count })
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
55
src/api/comments/types.rs
Normal file
55
src/api/comments/types.rs
Normal file
@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::models::comment::{AdminComment, PublicComment};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CreateCommentRequest {
|
||||
pub post_id: i32,
|
||||
pub parent_id: Option<i64>,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub author_url: Option<String>,
|
||||
pub content_md: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommentResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommentTreeResponse {
|
||||
pub comments: Vec<PublicComment>,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommentCountResponse {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingCommentsResponse {
|
||||
pub comments: Vec<AdminComment>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AllCommentsResponse {
|
||||
pub comments: Vec<AdminComment>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingCountResponse {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchStatusResponse {
|
||||
pub success: bool,
|
||||
pub updated_count: i64,
|
||||
pub message: String,
|
||||
}
|
||||
244
src/api/comments/update.rs
Normal file
244
src/api/comments/update.rs
Normal file
@ -0,0 +1,244 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::api::comments::types::*;
|
||||
|
||||
#[server(ApproveComment, "/api")]
|
||||
pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT post_id, status FROM comments WHERE id = $1 AND deleted_at IS NULL",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
let post_id: i32 = match row {
|
||||
Some(r) => r.get("post_id"),
|
||||
None => {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: "评论不存在".to_string(),
|
||||
error_code: Some("not_found".into()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
client
|
||||
.execute(
|
||||
"UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
client
|
||||
.execute(
|
||||
"WITH RECURSIVE ancestors AS ( \
|
||||
SELECT parent_id FROM comments WHERE id = $1 \
|
||||
UNION ALL \
|
||||
SELECT c.parent_id FROM comments c JOIN ancestors a ON c.id = a.parent_id WHERE a.parent_id IS NOT NULL \
|
||||
) \
|
||||
UPDATE comments SET status = 'approved', approved_at = NOW() \
|
||||
WHERE id IN (SELECT parent_id FROM ancestors WHERE parent_id IS NOT NULL) AND status = 'pending'",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
cache::invalidate_comments_by_post(post_id).await;
|
||||
cache::invalidate_comment_count(post_id).await;
|
||||
cache::invalidate_pending_count().await;
|
||||
|
||||
Ok(CommentResponse {
|
||||
success: true,
|
||||
message: "已通过".to_string(),
|
||||
error_code: None,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[server(SpamComment, "/api")]
|
||||
pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT post_id, status FROM comments WHERE id = $1 AND deleted_at IS NULL",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
if let Some(r) = row {
|
||||
let post_id: i32 = r.get("post_id");
|
||||
let old_status: String = r.get("status");
|
||||
|
||||
client
|
||||
.execute(
|
||||
"UPDATE comments SET status = 'spam' WHERE id = $1 AND deleted_at IS NULL",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
if old_status == "approved" {
|
||||
cache::invalidate_comments_by_post(post_id).await;
|
||||
cache::invalidate_comment_count(post_id).await;
|
||||
}
|
||||
cache::invalidate_pending_count().await;
|
||||
}
|
||||
|
||||
Ok(CommentResponse {
|
||||
success: true,
|
||||
message: "已标记为垃圾".to_string(),
|
||||
error_code: None,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[server(TrashComment, "/api")]
|
||||
pub async fn trash_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT post_id FROM comments WHERE id = $1 AND deleted_at IS NULL",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
if let Some(r) = row {
|
||||
let post_id: i32 = r.get("post_id");
|
||||
|
||||
client
|
||||
.execute(
|
||||
"UPDATE comments SET status = 'trash', deleted_at = NOW() WHERE id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
cache::invalidate_comments_by_post(post_id).await;
|
||||
cache::invalidate_comment_count(post_id).await;
|
||||
cache::invalidate_pending_count().await;
|
||||
}
|
||||
|
||||
Ok(CommentResponse {
|
||||
success: true,
|
||||
message: "已删除".to_string(),
|
||||
error_code: None,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[server(BatchUpdateCommentStatus, "/api")]
|
||||
pub async fn batch_update_comment_status(
|
||||
ids: Vec<i64>,
|
||||
status: String,
|
||||
) -> Result<BatchStatusResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
if !matches!(status.as_str(), "approved" | "spam" | "trash") {
|
||||
return Ok(BatchStatusResponse {
|
||||
success: false,
|
||||
updated_count: 0,
|
||||
message: "无效的状态".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let post_ids: Vec<i32> = client
|
||||
.query(
|
||||
"SELECT DISTINCT post_id FROM comments WHERE id = ANY($1)",
|
||||
&[&ids],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.iter()
|
||||
.map(|r| r.get("post_id"))
|
||||
.collect();
|
||||
|
||||
let result = if status == "trash" {
|
||||
client
|
||||
.execute(
|
||||
"UPDATE comments SET status = $1, deleted_at = NOW() WHERE id = ANY($2)",
|
||||
&[&status, &ids],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
} else if status == "approved" {
|
||||
client
|
||||
.execute(
|
||||
"UPDATE comments SET status = $1, approved_at = NOW() WHERE id = ANY($2)",
|
||||
&[&status, &ids],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
} else {
|
||||
client
|
||||
.execute(
|
||||
"UPDATE comments SET status = $1 WHERE id = ANY($2)",
|
||||
&[&status, &ids],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
};
|
||||
|
||||
cache::invalidate_pending_count().await;
|
||||
for pid in post_ids {
|
||||
cache::invalidate_comments_by_post(pid).await;
|
||||
cache::invalidate_comment_count(pid).await;
|
||||
}
|
||||
|
||||
Ok(BatchStatusResponse {
|
||||
success: true,
|
||||
updated_count: result as i64,
|
||||
message: format!("已更新 {} 条评论", result),
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
@ -13,19 +13,19 @@ pub enum AppError {
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl AppError {
|
||||
pub fn db_conn(e: impl std::fmt::Display) -> Self {
|
||||
tracing::error!("DB connection failed: {e}");
|
||||
AppError::DbConn(e.to_string())
|
||||
pub fn db_conn(e: impl std::fmt::Debug) -> Self {
|
||||
tracing::error!("DB connection failed: {e:?}");
|
||||
AppError::DbConn(format!("{e:?}"))
|
||||
}
|
||||
|
||||
pub fn query(e: impl std::fmt::Display) -> Self {
|
||||
tracing::error!("Query failed: {e}");
|
||||
AppError::Query(e.to_string())
|
||||
pub fn query(e: impl std::fmt::Debug) -> Self {
|
||||
tracing::error!("Query failed: {e:?}");
|
||||
AppError::Query(format!("{e:?}"))
|
||||
}
|
||||
|
||||
pub fn tx(e: impl std::fmt::Display) -> Self {
|
||||
tracing::error!("Transaction failed: {e}");
|
||||
AppError::Transaction(e.to_string())
|
||||
pub fn tx(e: impl std::fmt::Debug) -> Self {
|
||||
tracing::error!("Transaction failed: {e:?}");
|
||||
AppError::Transaction(format!("{e:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod comments;
|
||||
pub mod error;
|
||||
pub mod image;
|
||||
pub mod markdown;
|
||||
|
||||
@ -3,25 +3,10 @@ use crate::api::error::AppError;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::post::{Post, PostStatus};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::user::{User, UserRole};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::utils::text::count_words;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub(super) async fn get_current_admin_user() -> Result<User, AppError> {
|
||||
let token = crate::auth::session::get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
|
||||
|
||||
let user = crate::api::auth::get_user_by_token(&token)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.ok_or(AppError::Unauthorized("会话已过期"))?;
|
||||
|
||||
if user.role != UserRole::Admin {
|
||||
return Err(AppError::Forbidden("权限不足"));
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
pub(super) use crate::api::auth::get_current_admin_user;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub(super) async fn row_to_post_list(
|
||||
|
||||
@ -42,6 +42,22 @@ static IMAGE_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(
|
||||
)
|
||||
});
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
static COMMENT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
|
||||
RateLimiter::keyed(
|
||||
Quota::per_second(env_or("RATE_LIMIT_COMMENT_PER_SEC", 1))
|
||||
.allow_burst(env_or("RATE_LIMIT_COMMENT_BURST", 5)),
|
||||
)
|
||||
});
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn check_comment_limit(ip: &str) -> Result<(), String> {
|
||||
COMMENT_LIMITER
|
||||
.check_key(&ip.to_string())
|
||||
.map(|_| ())
|
||||
.map_err(|_| "评论过于频繁,请稍后再试".to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> {
|
||||
IMAGE_LIMITER
|
||||
|
||||
192
src/cache.rs
192
src/cache.rs
@ -5,6 +5,8 @@ use std::sync::LazyLock;
|
||||
#[cfg(feature = "server")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::comment::PublicComment;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::post::{Post, PostStats, Tag};
|
||||
|
||||
@ -22,6 +24,12 @@ const TTL_SINGLE_POST: Duration = Duration::from_secs(600);
|
||||
const TTL_POST_STATS: Duration = Duration::from_secs(60);
|
||||
#[cfg(feature = "server")]
|
||||
const TTL_TAG_POSTS: Duration = Duration::from_secs(120);
|
||||
#[cfg(feature = "server")]
|
||||
const TTL_COMMENTS: Duration = Duration::from_secs(60);
|
||||
#[cfg(feature = "server")]
|
||||
const TTL_COMMENT_COUNT: Duration = Duration::from_secs(60);
|
||||
#[cfg(feature = "server")]
|
||||
const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
|
||||
|
||||
// ============================================================================
|
||||
// Cache Key Types
|
||||
@ -36,6 +44,9 @@ pub enum CacheKey {
|
||||
PostBySlug(String),
|
||||
PostsByTag(String),
|
||||
PostStats,
|
||||
CommentsByPost { post_id: i32 },
|
||||
CommentCount { post_id: i32 },
|
||||
PendingCommentCount,
|
||||
}
|
||||
|
||||
|
||||
@ -96,6 +107,36 @@ static TAG_POSTS_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
|
||||
.build()
|
||||
});
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub type CommentCountCache = Cache<CacheKey, i64>;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
|
||||
Cache::builder()
|
||||
.max_capacity(200)
|
||||
.time_to_live(TTL_COMMENTS)
|
||||
.build()
|
||||
});
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
static COMMENT_COUNT_CACHE: LazyLock<CommentCountCache> = LazyLock::new(|| {
|
||||
Cache::builder()
|
||||
.max_capacity(200)
|
||||
.time_to_live(TTL_COMMENT_COUNT)
|
||||
.build()
|
||||
});
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
static PENDING_COUNT_CACHE: LazyLock<CommentCountCache> = LazyLock::new(|| {
|
||||
Cache::builder()
|
||||
.max_capacity(10)
|
||||
.time_to_live(TTL_PENDING_COUNT)
|
||||
.build()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Public Cache API
|
||||
// ============================================================================
|
||||
@ -210,9 +251,81 @@ pub fn invalidate_all_post_caches() {
|
||||
TAG_POSTS_CACHE.invalidate_all();
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
|
||||
COMMENT_CACHE
|
||||
.get(&CacheKey::CommentsByPost { post_id })
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
|
||||
let _ = COMMENT_CACHE
|
||||
.insert(CacheKey::CommentsByPost { post_id }, comments)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_comment_count(post_id: i32) -> Option<i64> {
|
||||
COMMENT_COUNT_CACHE
|
||||
.get(&CacheKey::CommentCount { post_id })
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn set_comment_count(post_id: i32, count: i64) {
|
||||
let _ = COMMENT_COUNT_CACHE
|
||||
.insert(CacheKey::CommentCount { post_id }, count)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_pending_count() -> Option<i64> {
|
||||
PENDING_COUNT_CACHE
|
||||
.get(&CacheKey::PendingCommentCount)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn set_pending_count(count: i64) {
|
||||
let _ = PENDING_COUNT_CACHE
|
||||
.insert(CacheKey::PendingCommentCount, count)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn invalidate_comments_by_post(post_id: i32) {
|
||||
COMMENT_CACHE
|
||||
.invalidate(&CacheKey::CommentsByPost { post_id })
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn invalidate_comment_count(post_id: i32) {
|
||||
COMMENT_COUNT_CACHE
|
||||
.invalidate(&CacheKey::CommentCount { post_id })
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn invalidate_pending_count() {
|
||||
PENDING_COUNT_CACHE
|
||||
.invalidate(&CacheKey::PendingCommentCount)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
pub async fn invalidate_all_comment_caches() {
|
||||
COMMENT_CACHE.invalidate_all();
|
||||
COMMENT_COUNT_CACHE.invalidate_all();
|
||||
PENDING_COUNT_CACHE.invalidate_all();
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::comment::PublicComment;
|
||||
use crate::models::post::PostStatus;
|
||||
|
||||
#[test]
|
||||
@ -322,4 +435,83 @@ mod tests {
|
||||
let cached_after = get_post_by_slug("invalidation-test").await;
|
||||
assert!(cached_after.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn comment_cache_roundtrip() {
|
||||
let comments = vec![PublicComment {
|
||||
id: 1,
|
||||
parent_id: None,
|
||||
depth: 0,
|
||||
author_name: "Alice".to_string(),
|
||||
author_url: None,
|
||||
avatar_url: "https://example.com/avatar".to_string(),
|
||||
content_html: Some("<p>Hello</p>".to_string()),
|
||||
created_at: "刚刚".to_string(),
|
||||
created_at_iso: "2026-01-01T00:00:00Z".to_string(),
|
||||
}];
|
||||
|
||||
set_comments_by_post(42, comments.clone()).await;
|
||||
let cached = get_comments_by_post(42).await;
|
||||
|
||||
assert!(cached.is_some());
|
||||
assert_eq!(cached.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn comment_count_cache_roundtrip() {
|
||||
set_comment_count(42, 15).await;
|
||||
let cached = get_comment_count(42).await;
|
||||
|
||||
assert!(cached.is_some());
|
||||
assert_eq!(cached.unwrap(), 15);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_count_cache_roundtrip() {
|
||||
set_pending_count(7).await;
|
||||
let cached = get_pending_count().await;
|
||||
|
||||
assert!(cached.is_some());
|
||||
assert_eq!(cached.unwrap(), 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn comment_cache_invalidation() {
|
||||
set_comments_by_post(99, vec![]).await;
|
||||
assert!(get_comments_by_post(99).await.is_some());
|
||||
|
||||
invalidate_comments_by_post(99).await;
|
||||
assert!(get_comments_by_post(99).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn comment_count_invalidation() {
|
||||
set_comment_count(99, 5).await;
|
||||
assert!(get_comment_count(99).await.is_some());
|
||||
|
||||
invalidate_comment_count(99).await;
|
||||
assert!(get_comment_count(99).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_count_invalidation() {
|
||||
set_pending_count(3).await;
|
||||
assert!(get_pending_count().await.is_some());
|
||||
|
||||
invalidate_pending_count().await;
|
||||
assert!(get_pending_count().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidate_all_comment_caches_clears_everything() {
|
||||
set_comments_by_post(1, vec![]).await;
|
||||
set_comment_count(1, 10).await;
|
||||
set_pending_count(5).await;
|
||||
|
||||
invalidate_all_comment_caches().await;
|
||||
|
||||
assert!(get_comments_by_post(1).await.is_none());
|
||||
assert!(get_comment_count(1).await.is_none());
|
||||
assert!(get_pending_count().await.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
60
src/components/comments/actions.rs
Normal file
60
src/components/comments/actions.rs
Normal file
@ -0,0 +1,60 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::comments::{approve_comment, spam_comment, trash_comment};
|
||||
use crate::components::comments::section::CommentContext;
|
||||
|
||||
#[component]
|
||||
pub fn CommentActions(comment_id: i64, post_id: i32) -> Element {
|
||||
let ctx: CommentContext = use_context();
|
||||
let refresh_trigger = ctx.refresh_trigger;
|
||||
let mut busy = use_signal(|| false);
|
||||
|
||||
let _ = post_id;
|
||||
|
||||
rsx! {
|
||||
div { class: "flex items-center gap-1.5",
|
||||
button {
|
||||
class: "text-xs px-2 py-0.5 rounded-full text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors cursor-pointer",
|
||||
disabled: busy(),
|
||||
onclick: move |_| {
|
||||
busy.set(true);
|
||||
let mut refresh_trigger = refresh_trigger;
|
||||
spawn(async move {
|
||||
let _ = approve_comment(comment_id).await;
|
||||
refresh_trigger.set(!refresh_trigger());
|
||||
busy.set(false);
|
||||
});
|
||||
},
|
||||
"通过"
|
||||
}
|
||||
button {
|
||||
class: "text-xs px-2 py-0.5 rounded-full text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors cursor-pointer",
|
||||
disabled: busy(),
|
||||
onclick: move |_| {
|
||||
busy.set(true);
|
||||
let mut refresh_trigger = refresh_trigger;
|
||||
spawn(async move {
|
||||
let _ = spam_comment(comment_id).await;
|
||||
refresh_trigger.set(!refresh_trigger());
|
||||
busy.set(false);
|
||||
});
|
||||
},
|
||||
"垃圾"
|
||||
}
|
||||
button {
|
||||
class: "text-xs px-2 py-0.5 rounded-full text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors cursor-pointer",
|
||||
disabled: busy(),
|
||||
onclick: move |_| {
|
||||
busy.set(true);
|
||||
let mut refresh_trigger = refresh_trigger;
|
||||
spawn(async move {
|
||||
let _ = trash_comment(comment_id).await;
|
||||
refresh_trigger.set(!refresh_trigger());
|
||||
busy.set(false);
|
||||
});
|
||||
},
|
||||
"删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/components/comments/form.rs
Normal file
172
src/components/comments/form.rs
Normal file
@ -0,0 +1,172 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::comments::create_comment;
|
||||
use crate::components::comments::section::CommentContext;
|
||||
use crate::components::forms::{INPUT_CLASS, BUTTON_PRIMARY_CLASS, AlertBox};
|
||||
|
||||
#[component]
|
||||
pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
||||
let ctx: CommentContext = use_context();
|
||||
let mut active_reply = ctx.active_reply;
|
||||
let mut refresh_trigger = ctx.refresh_trigger;
|
||||
let mut author_name = use_signal(String::new);
|
||||
let mut author_email = use_signal(String::new);
|
||||
let mut author_url = use_signal(String::new);
|
||||
let mut content_md = use_signal(String::new);
|
||||
let mut honeypot = use_signal(String::new);
|
||||
let mut submitting = use_signal(|| false);
|
||||
let mut message = use_signal(|| Option::<(String, &'static str)>::None);
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
if active_reply() != Some(pid) {
|
||||
return rsx! {};
|
||||
}
|
||||
}
|
||||
|
||||
let is_reply = parent_id.is_some();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-[#333]" } else { "" },
|
||||
role: "form",
|
||||
aria_label: if is_reply { "回复评论" } else { "发表评论" },
|
||||
|
||||
if let Some((msg, variant)) = message() {
|
||||
div { aria_live: "polite",
|
||||
AlertBox { message: msg, variant }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "space-y-3",
|
||||
if !is_reply {
|
||||
div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"昵称 *"
|
||||
}
|
||||
input {
|
||||
class: INPUT_CLASS,
|
||||
r#type: "text",
|
||||
placeholder: "你的昵称",
|
||||
value: "{author_name}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| author_name.set(e.value()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"邮箱 *"
|
||||
}
|
||||
input {
|
||||
class: INPUT_CLASS,
|
||||
r#type: "email",
|
||||
placeholder: "your@email.com",
|
||||
value: "{author_email}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| author_email.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"网站"
|
||||
}
|
||||
input {
|
||||
class: INPUT_CLASS,
|
||||
r#type: "url",
|
||||
placeholder: "https://example.com(可选)",
|
||||
value: "{author_url}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| author_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
class: "{INPUT_CLASS} min-h-[100px] resize-y",
|
||||
placeholder: "写下你的评论…",
|
||||
value: "{content_md}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| content_md.set(e.value()),
|
||||
}
|
||||
|
||||
p { class: "text-xs text-paper-tertiary",
|
||||
"支持 Markdown 语法"
|
||||
}
|
||||
|
||||
textarea {
|
||||
class: "hidden",
|
||||
aria_hidden: "true",
|
||||
tabindex: "-1",
|
||||
value: "{honeypot}",
|
||||
oninput: move |e| honeypot.set(e.value()),
|
||||
}
|
||||
|
||||
button {
|
||||
class: BUTTON_PRIMARY_CLASS,
|
||||
disabled: submitting(),
|
||||
onclick: move |_| {
|
||||
let post_id = post_id;
|
||||
let parent_id = parent_id;
|
||||
let name = author_name();
|
||||
let email = author_email();
|
||||
let url_val = author_url();
|
||||
let content = content_md();
|
||||
let hp = honeypot();
|
||||
|
||||
if !hp.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() {
|
||||
message.set(Some(("请填写所有必填项".to_string(), "error")));
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.set(true);
|
||||
message.set(None);
|
||||
|
||||
spawn(async move {
|
||||
let result = create_comment(
|
||||
post_id,
|
||||
parent_id,
|
||||
name,
|
||||
email,
|
||||
if url_val.trim().is_empty() { None } else { Some(url_val) },
|
||||
content,
|
||||
).await;
|
||||
|
||||
submitting.set(false);
|
||||
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
if resp.success {
|
||||
content_md.set(String::new());
|
||||
message.set(Some((resp.message, "success")));
|
||||
if parent_id.is_some() {
|
||||
active_reply.set(None);
|
||||
}
|
||||
refresh_trigger.set(!refresh_trigger());
|
||||
} else {
|
||||
message.set(Some((resp.message, "error")));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
message.set(Some(("提交失败,请稍后重试".to_string(), "error")));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
if submitting() {
|
||||
"提交中…"
|
||||
} else if is_reply {
|
||||
"回复"
|
||||
} else {
|
||||
"发表评论"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/components/comments/item.rs
Normal file
105
src/components/comments/item.rs
Normal file
@ -0,0 +1,105 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::context::UserContext;
|
||||
use crate::models::comment::PublicComment;
|
||||
use crate::components::comments::section::CommentContext;
|
||||
use crate::components::comments::form::CommentForm;
|
||||
use crate::components::comments::actions::CommentActions;
|
||||
|
||||
#[component]
|
||||
pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element {
|
||||
let ctx: CommentContext = use_context();
|
||||
let mut active_reply = ctx.active_reply;
|
||||
let refresh_trigger = ctx.refresh_trigger;
|
||||
let user_ctx: UserContext = use_context();
|
||||
|
||||
let depth = if comment.parent_id.is_none() && comment.depth > 0 {
|
||||
0
|
||||
} else {
|
||||
comment.depth
|
||||
};
|
||||
|
||||
let indent = depth.min(6) * 24;
|
||||
|
||||
let is_admin = user_ctx.user.read().as_ref().is_some();
|
||||
let is_replying = active_reply() == Some(comment.id);
|
||||
let show_reply = depth < 20;
|
||||
|
||||
let _ = refresh_trigger;
|
||||
|
||||
let author_element = match &comment.author_url {
|
||||
Some(url) if !url.is_empty() => rsx! {
|
||||
a {
|
||||
href: "{url}",
|
||||
rel: "nofollow noopener",
|
||||
target: "_blank",
|
||||
class: "font-medium text-paper-primary hover:text-paper-accent transition-colors",
|
||||
"{comment.author_name}"
|
||||
}
|
||||
},
|
||||
_ => rsx! {
|
||||
span { class: "font-medium text-paper-primary",
|
||||
"{comment.author_name}"
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "py-4",
|
||||
style: "margin-left: {indent}px",
|
||||
|
||||
div { class: "flex gap-3",
|
||||
img {
|
||||
src: "{comment.avatar_url}",
|
||||
alt: "{comment.author_name} 的头像",
|
||||
loading: "lazy",
|
||||
decoding: "async",
|
||||
class: "w-8 h-8 rounded-full shrink-0 mt-0.5 bg-gray-200 dark:bg-[#2a2a2a]",
|
||||
}
|
||||
|
||||
div { class: "flex-1 min-w-0",
|
||||
div { class: "flex items-center gap-1.5 text-sm mb-1.5 flex-wrap",
|
||||
{author_element}
|
||||
span { class: "text-paper-tertiary", "·" }
|
||||
span {
|
||||
class: "text-paper-tertiary",
|
||||
title: "{comment.created_at_iso}",
|
||||
"{comment.created_at}"
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
class: "prose prose-sm dark:prose-invert max-w-none text-paper-secondary",
|
||||
dangerous_inner_html: comment.content_html.as_deref().unwrap_or(""),
|
||||
}
|
||||
|
||||
div { class: "flex items-center gap-3 mt-2",
|
||||
if show_reply {
|
||||
button {
|
||||
class: "text-xs text-paper-tertiary hover:text-paper-accent transition-colors cursor-pointer",
|
||||
aria_label: "回复 {comment.author_name} 的评论",
|
||||
onclick: move |_| {
|
||||
if is_replying {
|
||||
active_reply.set(None);
|
||||
} else {
|
||||
active_reply.set(Some(comment.id));
|
||||
}
|
||||
},
|
||||
if is_replying { "取消回复" } else { "回复" }
|
||||
}
|
||||
}
|
||||
|
||||
if is_admin {
|
||||
CommentActions { comment_id: comment.id, post_id }
|
||||
}
|
||||
}
|
||||
|
||||
if is_replying {
|
||||
CommentForm { post_id, parent_id: Some(comment.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/components/comments/list.rs
Normal file
15
src/components/comments/list.rs
Normal file
@ -0,0 +1,15 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::models::comment::PublicComment;
|
||||
use crate::components::comments::item::CommentItem;
|
||||
|
||||
#[component]
|
||||
pub fn CommentList(comments: Vec<PublicComment>, post_id: i32) -> Element {
|
||||
rsx! {
|
||||
div { class: "space-y-0 divide-y divide-gray-100 dark:divide-[#2a2a2a]",
|
||||
for comment in comments {
|
||||
CommentItem { comment, post_id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/components/comments/mod.rs
Normal file
5
src/components/comments/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod section;
|
||||
pub mod form;
|
||||
pub mod list;
|
||||
pub mod item;
|
||||
pub mod actions;
|
||||
58
src/components/comments/section.rs
Normal file
58
src/components/comments/section.rs
Normal file
@ -0,0 +1,58 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::comments::{get_comments, CommentTreeResponse};
|
||||
use crate::components::comments::form::CommentForm;
|
||||
use crate::components::comments::list::CommentList;
|
||||
use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CommentContext {
|
||||
pub active_reply: Signal<Option<i64>>,
|
||||
pub refresh_trigger: Signal<bool>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CommentSection(post_id: i32) -> Element {
|
||||
let ctx = use_context_provider(|| CommentContext {
|
||||
active_reply: Signal::new(None),
|
||||
refresh_trigger: Signal::new(false),
|
||||
});
|
||||
|
||||
let comments_resource = use_server_future(move || {
|
||||
let _ = ctx.refresh_trigger;
|
||||
get_comments(post_id)
|
||||
})?;
|
||||
|
||||
let data = comments_resource.read();
|
||||
|
||||
match data.as_ref().map(|r| r.as_ref()) {
|
||||
Some(Ok(CommentTreeResponse { comments, count })) => {
|
||||
let count = *count;
|
||||
rsx! {
|
||||
div { class: "space-y-8",
|
||||
h2 { class: "text-xl font-bold text-paper-primary",
|
||||
"评论区 ({count})"
|
||||
}
|
||||
|
||||
CommentForm { post_id, parent_id: None }
|
||||
|
||||
if comments.is_empty() {
|
||||
p { class: "text-paper-tertiary text-center py-8",
|
||||
"暂无评论,成为第一个评论的人吧!"
|
||||
}
|
||||
} else {
|
||||
CommentList { comments: comments.clone(), post_id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(_)) => {
|
||||
rsx! {
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-8",
|
||||
"评论加载失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
None => rsx! { CommentListSkeleton {} },
|
||||
}
|
||||
}
|
||||
@ -67,7 +67,7 @@ pub fn Footer() -> Element {
|
||||
});
|
||||
|
||||
let btn_class = use_memo(move || {
|
||||
let base = "fixed bottom-16 right-8 z-50 w-10 h-10 rounded-full bg-gray-100 dark:bg-[#2d2e30] shadow-md flex items-center justify-center cursor-pointer transition-all duration-300 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white";
|
||||
let base = "fixed bottom-16 right-8 z-50 w-10 h-10 rounded-full bg-paper-entry border border-paper-border shadow-sm flex items-center justify-center cursor-pointer transition-all duration-300 text-paper-secondary hover:text-paper-accent";
|
||||
if visible() {
|
||||
format!("{} opacity-100 translate-y-0", base)
|
||||
} else {
|
||||
@ -76,9 +76,9 @@ pub fn Footer() -> Element {
|
||||
});
|
||||
|
||||
rsx! {
|
||||
footer { class: "w-full border-t border-gray-200 dark:border-[#333] mt-auto",
|
||||
div { class: "max-w-3xl mx-auto px-6 py-5 flex items-center justify-between text-sm text-gray-400 dark:text-[#9b9c9d]",
|
||||
span { "© 2026 Yggdrasil Blog" }
|
||||
footer { class: "w-full border-t border-paper-border mt-auto",
|
||||
div { class: "max-w-3xl mx-auto px-6 py-5 flex items-center justify-between text-sm text-paper-secondary",
|
||||
span { "© 2026 Yggdrasil" }
|
||||
}
|
||||
}
|
||||
a {
|
||||
|
||||
@ -1,23 +1,26 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600";
|
||||
pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border rounded-lg bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
|
||||
|
||||
pub const BUTTON_PRIMARY_CLASS: &str = "w-full py-2 px-4 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 font-medium rounded-full hover:opacity-80 transition-opacity cursor-pointer";
|
||||
pub const BUTTON_PRIMARY_CLASS: &str = "w-full py-2.5 px-4 bg-paper-accent text-white font-medium rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer";
|
||||
|
||||
#[component]
|
||||
pub fn FormInput(
|
||||
r#type: &'static str,
|
||||
placeholder: &'static str,
|
||||
value: String,
|
||||
disabled: bool,
|
||||
oninput: EventHandler<String>,
|
||||
onkeydown: Option<EventHandler<KeyboardEvent>>,
|
||||
) -> Element {
|
||||
let disabled_class = if disabled { "opacity-60 cursor-not-allowed" } else { "" };
|
||||
rsx! {
|
||||
input {
|
||||
class: "{INPUT_CLASS}",
|
||||
class: "{INPUT_CLASS} {disabled_class}",
|
||||
r#type: "{r#type}",
|
||||
placeholder: "{placeholder}",
|
||||
value: "{value}",
|
||||
disabled,
|
||||
oninput: move |e| oninput.call(e.value()),
|
||||
onkeydown: move |e| {
|
||||
if let Some(ref handler) = onkeydown {
|
||||
@ -31,7 +34,7 @@ pub fn FormInput(
|
||||
#[component]
|
||||
pub fn FormLabel(label: &'static str) -> Element {
|
||||
rsx! {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] mb-1",
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
@ -42,7 +45,7 @@ pub fn AlertBox(message: String, variant: &'static str) -> Element {
|
||||
let (bg_class, text_class) = match variant {
|
||||
"error" => ("bg-red-100 dark:bg-red-900/30", "text-red-700 dark:text-red-300"),
|
||||
"success" => ("bg-green-100 dark:bg-green-900/30", "text-green-700 dark:text-green-300"),
|
||||
_ => ("bg-gray-100 dark:bg-[#333]", "text-gray-700 dark:text-[#9b9c9d]"),
|
||||
_ => ("bg-paper-code-bg", "text-paper-secondary"),
|
||||
};
|
||||
rsx! {
|
||||
div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center",
|
||||
|
||||
@ -29,7 +29,7 @@ pub fn FrontendLayout() -> Element {
|
||||
let nav_items = use_nav_items(route.clone());
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20]",
|
||||
div { class: "min-h-screen flex flex-col bg-paper-theme",
|
||||
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
|
||||
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
|
||||
SuspenseBoundary {
|
||||
|
||||
@ -13,10 +13,10 @@ pub struct NavItemConfig {
|
||||
#[component]
|
||||
pub fn Header(nav_items: Vec<NavItemConfig>, right_content: Element) -> Element {
|
||||
rsx! {
|
||||
header { class: "sticky top-0 z-40 w-full border-b border-gray-200 dark:border-[#333] bg-white/80 dark:bg-[#1d1e20]/80 backdrop-blur-sm",
|
||||
header { class: "sticky top-0 z-40 w-full border-b border-paper-border bg-paper-theme/80 backdrop-blur-sm",
|
||||
nav { class: "max-w-3xl mx-auto px-6 h-[60px] flex items-center justify-between",
|
||||
Link {
|
||||
class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
|
||||
class: "text-2xl font-bold font-serif text-paper-primary hover:text-paper-accent transition-colors duration-200",
|
||||
to: Route::Home {},
|
||||
"Yggdrasil"
|
||||
}
|
||||
@ -39,12 +39,12 @@ pub fn Header(nav_items: Vec<NavItemConfig>, right_content: Element) -> Element
|
||||
|
||||
#[component]
|
||||
fn NavItem(route: Route, label: &'static str, is_active: bool) -> Element {
|
||||
let base_class = "px-3 py-1 text-base rounded-lg transition-colors";
|
||||
let base_class = "px-3 py-1 text-base rounded-lg transition-all duration-200";
|
||||
let class_str = if is_active {
|
||||
format!("{} font-medium text-gray-900 dark:text-[#dadadb] underline underline-offset-[0.3rem] decoration-2 decoration-gray-900 dark:decoration-[#dadadb]", base_class)
|
||||
format!("{} font-medium text-paper-accent underline underline-offset-[0.3rem] decoration-2 decoration-paper-accent", base_class)
|
||||
} else {
|
||||
format!(
|
||||
"{} text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb]",
|
||||
"{} text-paper-secondary hover:text-paper-primary",
|
||||
base_class
|
||||
)
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
pub mod admin_layout;
|
||||
pub mod admin_skeleton;
|
||||
pub mod comments;
|
||||
pub mod footer;
|
||||
pub mod forms;
|
||||
pub mod frontend_layout;
|
||||
|
||||
@ -34,7 +34,7 @@ pub fn PostFooter(post: Post) -> Element {
|
||||
div { class: "back-to-home",
|
||||
Link {
|
||||
to: Route::Home {},
|
||||
"← Back to Home"
|
||||
"← 返回首页"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ pub fn PostCard(post: Post) -> Element {
|
||||
|
||||
rsx! {
|
||||
article {
|
||||
class: "relative mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333] hover:-translate-y-0.5 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-250",
|
||||
class: "relative mb-6 p-6 bg-paper-entry rounded-lg border border-paper-border hover:-translate-y-0.5 hover:border-paper-accent/50 hover:shadow-sm transition-all duration-200",
|
||||
Link {
|
||||
class: "block group",
|
||||
to: Route::PostDetail { slug: post_slug },
|
||||
@ -29,22 +29,22 @@ pub fn PostCard(post: Post) -> Element {
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
class: "text-2xl font-bold leading-tight text-gray-900 dark:text-[#dadadb] group-hover:opacity-80 transition-opacity",
|
||||
class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200",
|
||||
"{post.title}"
|
||||
}
|
||||
div {
|
||||
class: "mt-2 text-sm text-gray-500 dark:text-[#9b9c9d] leading-relaxed line-clamp-2",
|
||||
class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2",
|
||||
"{post.summary.as_deref().unwrap_or_default()}"
|
||||
}
|
||||
div {
|
||||
class: "mt-3 flex items-center gap-3 text-[13px] text-gray-400 dark:text-[#9b9c9d]",
|
||||
class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary",
|
||||
span { "{date_str}" }
|
||||
if !post.tags.is_empty() {
|
||||
span { "·" }
|
||||
for tag in post.tags.clone().into_iter() {
|
||||
span {
|
||||
Link {
|
||||
class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors",
|
||||
class: "hover:text-paper-accent transition-colors duration-200",
|
||||
to: Route::TagDetail { tag: tag.clone() },
|
||||
onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
|
||||
"{tag}"
|
||||
|
||||
@ -4,7 +4,7 @@ use dioxus::prelude::*;
|
||||
pub fn SkeletonBox(class: &'static str, style: Option<&'static str>) -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
class: "bg-gray-200 dark:bg-[#2a2a2a] animate-pulse {class}",
|
||||
class: "bg-paper-tertiary/30 animate-pulse {class}",
|
||||
style: style.unwrap_or(""),
|
||||
}
|
||||
}
|
||||
|
||||
38
src/components/skeletons/comment_skeleton.rs
Normal file
38
src/components/skeletons/comment_skeleton.rs
Normal file
@ -0,0 +1,38 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::components::skeletons::atoms::*;
|
||||
|
||||
#[component]
|
||||
pub fn CommentListSkeleton() -> Element {
|
||||
rsx! {
|
||||
div { class: "animate-pulse space-y-6",
|
||||
div { class: "h-8 w-32 bg-paper-tertiary/30 rounded mb-6" }
|
||||
div { class: "space-y-4 bg-paper-tertiary/30 rounded-lg p-4",
|
||||
div { class: "flex gap-3",
|
||||
div { class: "w-10 h-10 rounded-full bg-paper-tertiary/50 shrink-0" }
|
||||
div { class: "flex-1 space-y-2",
|
||||
SkeletonBox { class: "h-4 w-1/4 rounded" }
|
||||
SkeletonBox { class: "h-3 w-3/4 rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "space-y-4 bg-paper-tertiary/30 rounded-lg p-4 ml-6",
|
||||
div { class: "flex gap-3",
|
||||
div { class: "w-10 h-10 rounded-full bg-paper-tertiary/50 shrink-0" }
|
||||
div { class: "flex-1 space-y-2",
|
||||
SkeletonBox { class: "h-4 w-1/4 rounded" }
|
||||
SkeletonBox { class: "h-3 w-3/4 rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "space-y-4 bg-paper-tertiary/30 rounded-lg p-4",
|
||||
div { class: "flex gap-3",
|
||||
div { class: "w-10 h-10 rounded-full bg-paper-tertiary/50 shrink-0" }
|
||||
div { class: "flex-1 space-y-2",
|
||||
SkeletonBox { class: "h-4 w-1/4 rounded" }
|
||||
SkeletonBox { class: "h-3 w-3/4 rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
pub mod atoms;
|
||||
pub mod archive_skeleton;
|
||||
pub mod comment_skeleton;
|
||||
pub mod delayed_skeleton;
|
||||
pub mod home_skeleton;
|
||||
pub mod post_card_skeleton;
|
||||
|
||||
@ -36,6 +36,10 @@ fn main() {
|
||||
use dioxus::server::{axum, DioxusRouterExt, ServeConfig};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
tokio::spawn(async {
|
||||
tasks::ip_purge::run_purge().await;
|
||||
});
|
||||
|
||||
tokio::spawn(async {
|
||||
tasks::session_cleanup::run_cleanup().await;
|
||||
});
|
||||
|
||||
135
src/models/comment.rs
Normal file
135
src/models/comment.rs
Normal file
@ -0,0 +1,135 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CommentStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Spam,
|
||||
Trash,
|
||||
}
|
||||
|
||||
impl CommentStatus {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"approved" => Self::Approved,
|
||||
"spam" => Self::Spam,
|
||||
"trash" => Self::Trash,
|
||||
_ => Self::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Approved => "approved",
|
||||
Self::Spam => "spam",
|
||||
Self::Trash => "trash",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[allow(dead_code)]
|
||||
pub struct Comment {
|
||||
pub id: i64,
|
||||
pub post_id: i32,
|
||||
pub parent_id: Option<i64>,
|
||||
pub depth: i32,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub author_url: Option<String>,
|
||||
pub content_md: String,
|
||||
pub content_html: Option<String>,
|
||||
pub content_hash: Option<String>,
|
||||
pub status: CommentStatus,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub approved_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PublicComment {
|
||||
pub id: i64,
|
||||
pub parent_id: Option<i64>,
|
||||
pub depth: i32,
|
||||
pub author_name: String,
|
||||
pub author_url: Option<String>,
|
||||
pub avatar_url: String,
|
||||
pub content_html: Option<String>,
|
||||
pub created_at: String,
|
||||
pub created_at_iso: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AdminComment {
|
||||
pub id: i64,
|
||||
pub post_id: i32,
|
||||
pub post_title: String,
|
||||
pub post_slug: String,
|
||||
pub parent_id: Option<i64>,
|
||||
pub depth: i32,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub author_url: Option<String>,
|
||||
pub content_md: String,
|
||||
pub status: CommentStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn comment_status_from_str() {
|
||||
assert_eq!(CommentStatus::from_str("pending"), CommentStatus::Pending);
|
||||
assert_eq!(CommentStatus::from_str("approved"), CommentStatus::Approved);
|
||||
assert_eq!(CommentStatus::from_str("spam"), CommentStatus::Spam);
|
||||
assert_eq!(CommentStatus::from_str("trash"), CommentStatus::Trash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_status_from_str_unknown_defaults_to_pending() {
|
||||
assert_eq!(CommentStatus::from_str("unknown"), CommentStatus::Pending);
|
||||
assert_eq!(CommentStatus::from_str(""), CommentStatus::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_status_as_str() {
|
||||
assert_eq!(CommentStatus::Pending.as_str(), "pending");
|
||||
assert_eq!(CommentStatus::Approved.as_str(), "approved");
|
||||
assert_eq!(CommentStatus::Spam.as_str(), "spam");
|
||||
assert_eq!(CommentStatus::Trash.as_str(), "trash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_status_serde_roundtrip() {
|
||||
let statuses = vec![
|
||||
CommentStatus::Pending,
|
||||
CommentStatus::Approved,
|
||||
CommentStatus::Spam,
|
||||
CommentStatus::Trash,
|
||||
];
|
||||
for status in statuses {
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
let expected = format!("\"{}\"", status.as_str());
|
||||
assert_eq!(json, expected);
|
||||
let deserialized: CommentStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized, status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_status_deserialize_from_lowercase() {
|
||||
let pending: CommentStatus = serde_json::from_str("\"pending\"").unwrap();
|
||||
assert_eq!(pending, CommentStatus::Pending);
|
||||
let approved: CommentStatus = serde_json::from_str("\"approved\"").unwrap();
|
||||
assert_eq!(approved, CommentStatus::Approved);
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
pub mod comment;
|
||||
pub mod post;
|
||||
pub mod user;
|
||||
|
||||
@ -4,14 +4,14 @@ use dioxus::prelude::*;
|
||||
pub fn About() -> Element {
|
||||
rsx! {
|
||||
header { class: "page-header mb-6",
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
|
||||
"关于"
|
||||
}
|
||||
}
|
||||
article { class: "prose dark:prose-invert max-w-none text-gray-800 dark:text-[#c9cacc] leading-relaxed",
|
||||
article { class: "prose dark:prose-invert max-w-none text-paper-content leading-relaxed",
|
||||
p { "Yggdrasil 是一个以文字为主的简约博客系统。" }
|
||||
p { "它使用 Rust + Dioxus 构建,采用 PostgreSQL 作为数据库,支持 Markdown 写作、标签管理和暗色模式。" }
|
||||
h2 { class: "text-xl font-bold text-gray-900 dark:text-[#dadadb] mt-8 mb-4", "技术栈" }
|
||||
h2 { class: "text-xl font-bold text-paper-primary mt-8 mb-4", "技术栈" }
|
||||
ul { class: "list-disc pl-5 space-y-1",
|
||||
li { "Rust + Dioxus 0.7 (全栈 Web 框架)" }
|
||||
li { "PostgreSQL + tokio-postgres (数据库)" }
|
||||
@ -19,7 +19,7 @@ pub fn About() -> Element {
|
||||
li { "Tiptap Editor (富文本编辑器)" }
|
||||
li { "pulldown-cmark (Markdown 渲染)" }
|
||||
}
|
||||
h2 { class: "text-xl font-bold text-gray-900 dark:text-[#dadadb] mt-8 mb-4", "特性" }
|
||||
h2 { class: "text-xl font-bold text-paper-primary mt-8 mb-4", "特性" }
|
||||
ul { class: "list-disc pl-5 space-y-1",
|
||||
li { "Markdown 写作与实时预览" }
|
||||
li { "文章标签与归档" }
|
||||
|
||||
400
src/pages/admin/comments.rs
Normal file
400
src/pages/admin/comments.rs
Normal file
@ -0,0 +1,400 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use dioxus::router::components::Link;
|
||||
|
||||
use crate::api::comments::{
|
||||
approve_comment, batch_update_comment_status, get_all_comments, spam_comment,
|
||||
AllCommentsResponse,
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::comments::trash_comment;
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::models::comment::CommentStatus;
|
||||
use crate::router::Route;
|
||||
|
||||
const COMMENTS_PER_PAGE: i32 = 20;
|
||||
|
||||
#[component]
|
||||
pub fn AdminComments() -> Element {
|
||||
rsx! { AdminCommentsPage { page: 1 } }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AdminCommentsPage(page: i32) -> Element {
|
||||
let current_page = page.max(1);
|
||||
let mut active_filter = use_signal(|| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
web_sys::window()
|
||||
.and_then(|w| w.location().search().ok())
|
||||
.and_then(|s| {
|
||||
let params = s.trim_start_matches('?');
|
||||
for pair in params.split('&') {
|
||||
if let Some(val) = pair.strip_prefix("status=") {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
String::new()
|
||||
});
|
||||
let mut selected_ids: Signal<HashSet<i64>> = use_signal(HashSet::new);
|
||||
let filter_status = move || {
|
||||
let f = active_filter();
|
||||
if f.is_empty() { None } else { Some(f) }
|
||||
};
|
||||
let mut comments_res =
|
||||
use_server_future(move || get_all_comments(current_page, filter_status()))?;
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-6",
|
||||
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
"评论管理"
|
||||
}
|
||||
|
||||
div { class: "flex gap-1 border-b border-gray-200 dark:border-[#333]",
|
||||
for (status, label) in [("", "全部"), ("pending", "待审核"), ("approved", "已通过"), ("spam", "垃圾箱")] {
|
||||
button {
|
||||
class: if active_filter() == status {
|
||||
"px-4 py-2 text-sm font-medium border-b-2 border-gray-900 dark:border-[#dadadb] text-gray-900 dark:text-[#dadadb]"
|
||||
} else {
|
||||
"px-4 py-2 text-sm font-medium text-gray-500 dark:text-[#9b9c9d] hover:text-gray-700 dark:hover:text-[#dadadb] transition-colors"
|
||||
},
|
||||
onclick: move |_| active_filter.set(status.to_string()),
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !selected_ids().is_empty() {
|
||||
{ rsx! {
|
||||
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: "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();
|
||||
spawn(async move {
|
||||
let _ = batch_update_comment_status(ids, "approved".to_string()).await;
|
||||
});
|
||||
selected_ids.set(HashSet::new());
|
||||
comments_res.restart();
|
||||
},
|
||||
"批量通过"
|
||||
}
|
||||
button {
|
||||
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();
|
||||
spawn(async move {
|
||||
let _ = batch_update_comment_status(ids, "spam".to_string()).await;
|
||||
});
|
||||
selected_ids.set(HashSet::new());
|
||||
comments_res.restart();
|
||||
},
|
||||
"批量垃圾"
|
||||
}
|
||||
button {
|
||||
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")]
|
||||
{
|
||||
if web_sys::window()
|
||||
.and_then(|w| w.confirm_with_message("确定要删除这些评论吗?").ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||||
spawn(async move {
|
||||
let _ = batch_update_comment_status(ids, "trash".to_string()).await;
|
||||
});
|
||||
selected_ids.set(HashSet::new());
|
||||
comments_res.restart();
|
||||
}
|
||||
}
|
||||
},
|
||||
"批量删除"
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
{
|
||||
let data = comments_res.read().as_ref().map(|r| match r {
|
||||
Ok(AllCommentsResponse { comments, total }) => Ok((comments.clone(), *total)),
|
||||
Err(e) => Err(e.to_string()),
|
||||
});
|
||||
match data {
|
||||
Some(Ok((comments, total))) => {
|
||||
if comments.is_empty() {
|
||||
rsx! {
|
||||
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
||||
"暂无评论"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let all_selected = comments.iter().all(|c| selected_ids().contains(&c.id));
|
||||
let all_ids: Vec<i64> = comments.iter().map(|c| c.id).collect();
|
||||
rsx! {
|
||||
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
|
||||
div { class: "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: "rounded border-gray-300 dark:border-[#555]",
|
||||
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", "文章" }
|
||||
th { class: "px-4 py-3 font-medium w-20 text-center", "状态" }
|
||||
th { class: "px-4 py-3 font-medium w-28", "日期" }
|
||||
th { class: "px-4 py-3 font-medium w-32 text-right", "操作" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for comment in comments.iter() {
|
||||
CommentRow {
|
||||
key: "{comment.id}",
|
||||
comment: comment.clone(),
|
||||
selected: selected_ids().contains(&comment.id),
|
||||
on_select: {
|
||||
let id = comment.id;
|
||||
move |checked: bool| {
|
||||
let mut s = selected_ids();
|
||||
if checked { s.insert(id); } else { s.remove(&id); }
|
||||
selected_ids.set(s);
|
||||
}
|
||||
},
|
||||
on_approve: {
|
||||
let id = comment.id;
|
||||
move |_| {
|
||||
spawn(async move {
|
||||
let _ = approve_comment(id).await;
|
||||
});
|
||||
comments_res.restart();
|
||||
}
|
||||
},
|
||||
on_spam: {
|
||||
let id = comment.id;
|
||||
move |_| {
|
||||
spawn(async move {
|
||||
let _ = spam_comment(id).await;
|
||||
});
|
||||
comments_res.restart();
|
||||
}
|
||||
},
|
||||
on_trash: {
|
||||
#[allow(unused_variables)]
|
||||
let id = comment.id;
|
||||
move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if web_sys::window()
|
||||
.and_then(|w| w.confirm_with_message("确定要删除这条评论吗?").ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let id = id;
|
||||
spawn(async move {
|
||||
let _ = trash_comment(id).await;
|
||||
});
|
||||
comments_res.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CommentsPagination { current_page, total }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(_e)) => {
|
||||
rsx! {
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||
"加载失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
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: "flex items-center gap-4",
|
||||
div { class: "h-4 w-4 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
div { class: "h-8 w-8 bg-gray-200 dark:bg-[#2a2a2a] rounded-full" }
|
||||
div { class: "h-4 w-32 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
div { class: "h-4 flex-1 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn CommentRow(
|
||||
comment: crate::models::comment::AdminComment,
|
||||
selected: bool,
|
||||
on_select: EventHandler<bool>,
|
||||
on_approve: EventHandler,
|
||||
on_spam: EventHandler,
|
||||
on_trash: EventHandler,
|
||||
) -> Element {
|
||||
let (badge_class, status_label) = match &comment.status {
|
||||
CommentStatus::Pending => ("bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", "待审核"),
|
||||
CommentStatus::Approved => ("bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", "已通过"),
|
||||
CommentStatus::Spam => ("bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", "垃圾"),
|
||||
CommentStatus::Trash => ("bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400", "已删除"),
|
||||
};
|
||||
let date_str = comment.created_at.format("%Y-%m-%d").to_string();
|
||||
let preview = if comment.content_md.len() > 100 {
|
||||
format!("{}...", &comment.content_md[..comment.content_md.ceil_char_boundary(100)])
|
||||
} else {
|
||||
comment.content_md.clone()
|
||||
};
|
||||
|
||||
rsx! {
|
||||
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: "rounded border-gray-300 dark:border-[#555]",
|
||||
checked: selected,
|
||||
onchange: move |e| on_select.call(e.checked()),
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3",
|
||||
div { class: "flex items-center gap-2",
|
||||
div { class: "w-8 h-8 rounded-full bg-gray-200 dark:bg-[#444] flex-shrink-0" }
|
||||
div { class: "min-w-0",
|
||||
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb] truncate",
|
||||
"{comment.author_name}"
|
||||
}
|
||||
div { class: "text-xs text-gray-400 dark:text-[#666] truncate",
|
||||
"{comment.author_email}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 max-w-xs",
|
||||
p { class: "text-sm text-gray-600 dark:text-[#9b9c9d] truncate",
|
||||
"{preview}"
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3",
|
||||
Link {
|
||||
class: "text-sm text-gray-700 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
|
||||
to: Route::PostDetail { slug: comment.post_slug.clone() },
|
||||
"{comment.post_title}"
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 text-center",
|
||||
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {badge_class}",
|
||||
"{status_label}"
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||
"{date_str}"
|
||||
}
|
||||
td { class: "px-4 py-3 text-right",
|
||||
div { class: "flex justify-end gap-2",
|
||||
button {
|
||||
class: "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors",
|
||||
onclick: move |_| on_approve.call(()),
|
||||
"通过"
|
||||
}
|
||||
button {
|
||||
class: "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors",
|
||||
onclick: move |_| on_spam.call(()),
|
||||
"垃圾"
|
||||
}
|
||||
button {
|
||||
class: "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors",
|
||||
onclick: move |_| on_trash.call(()),
|
||||
"删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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", "»" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus::router::components::Link;
|
||||
|
||||
use crate::api::comments::get_pending_count;
|
||||
use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse};
|
||||
use crate::hooks::delayed_loading::use_delayed_loading;
|
||||
use crate::models::post::Post;
|
||||
@ -10,6 +11,7 @@ use crate::router::Route;
|
||||
pub fn Admin() -> Element {
|
||||
let stats_res = use_resource(get_post_stats);
|
||||
let posts_res = use_resource(|| list_posts(1, 5));
|
||||
let pending_res = use_resource(get_pending_count);
|
||||
let show_stats_skeleton = use_delayed_loading(move || stats_res.read().is_none());
|
||||
let show_posts_skeleton = use_delayed_loading(move || posts_res.read().is_none());
|
||||
|
||||
@ -37,6 +39,29 @@ pub fn Admin() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
Link {
|
||||
class: "block rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center hover:border-gray-300 dark:hover:border-[#555] transition-colors",
|
||||
to: Route::AdminComments {},
|
||||
match &*pending_res.read() {
|
||||
Some(Ok(resp)) => {
|
||||
rsx! {
|
||||
div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400",
|
||||
"{resp.count}"
|
||||
}
|
||||
div { class: "text-sm text-gray-500 dark:text-[#9b9c9d] mt-2",
|
||||
"待审核评论"
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
rsx! {
|
||||
div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" }
|
||||
div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded mt-3 animate-pulse" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "grid grid-cols-1 md:grid-cols-2 gap-4",
|
||||
Link {
|
||||
class: "bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity cursor-pointer",
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
pub mod comments;
|
||||
pub mod dashboard;
|
||||
pub mod posts;
|
||||
pub mod write;
|
||||
|
||||
pub use comments::{AdminComments, AdminCommentsPage};
|
||||
pub use dashboard::Admin;
|
||||
pub use posts::{Posts, PostsPage};
|
||||
pub use write::{Write, WriteEdit};
|
||||
|
||||
@ -81,7 +81,7 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
||||
pub fn Archives() -> Element {
|
||||
rsx! {
|
||||
header { class: "page-header mb-6",
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
|
||||
"归档"
|
||||
}
|
||||
}
|
||||
@ -98,9 +98,9 @@ fn ArchivesContent() -> Element {
|
||||
Some(Ok(PostListResponse { posts, total })) => {
|
||||
let grouped = group_posts(posts);
|
||||
rsx! {
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
div { class: "mt-2 text-base text-paper-secondary",
|
||||
"共 "
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{total}" }
|
||||
span { class: "font-medium text-paper-primary", "{total}" }
|
||||
" 篇文章"
|
||||
}
|
||||
for year_group in grouped.iter() {
|
||||
@ -134,14 +134,14 @@ fn YearSection(year_group: YearGroup) -> Element {
|
||||
rsx! {
|
||||
div { class: "archive-year mt-10",
|
||||
h2 {
|
||||
class: "archive-year-header text-2xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
|
||||
class: "archive-year-header text-2xl font-bold text-paper-primary mb-4",
|
||||
id: "{year_group.year}",
|
||||
a {
|
||||
class: "archive-header-link hover:opacity-80 transition-opacity",
|
||||
href: "#{year_group.year}",
|
||||
"{year_group.year}"
|
||||
}
|
||||
sup { class: "archive-count text-sm text-gray-400 dark:text-[#9b9c9d] ml-1", "{total}" }
|
||||
sup { class: "archive-count text-sm text-paper-secondary ml-1", "{total}" }
|
||||
}
|
||||
for month_group in year_group.months.iter() {
|
||||
MonthSection { month_group: month_group.clone(), year: year_group.year.clone() }
|
||||
@ -155,16 +155,16 @@ fn MonthSection(month_group: MonthGroup, year: String) -> Element {
|
||||
let count = month_group.posts.len();
|
||||
|
||||
rsx! {
|
||||
div { class: "archive-month flex flex-col md:flex-row md:items-start py-2.5 border-b border-gray-100 dark:border-[#333]/50",
|
||||
div { class: "archive-month flex flex-col md:flex-row md:items-start py-2.5 border-b border-paper-border/50",
|
||||
h3 {
|
||||
class: "archive-month-header text-lg font-medium text-gray-700 dark:text-[#9b9c9d] md:w-[200px] shrink-0 mt-0 mb-0 py-1.5",
|
||||
class: "archive-month-header text-lg font-medium text-paper-secondary md:w-[200px] shrink-0 mt-0 mb-0 py-1.5",
|
||||
id: "{year}-{month_group.month_en}",
|
||||
a {
|
||||
class: "archive-header-link hover:opacity-80 transition-opacity",
|
||||
href: "#{year}-{month_group.month_en}",
|
||||
"{month_group.month}"
|
||||
}
|
||||
sup { class: "archive-count text-sm text-gray-400 dark:text-[#9b9c9d] ml-1", "{count}" }
|
||||
sup { class: "archive-count text-sm text-paper-secondary ml-1", "{count}" }
|
||||
}
|
||||
div { class: "archive-posts flex-1",
|
||||
for post in month_group.posts.iter() {
|
||||
@ -181,10 +181,10 @@ fn ArchiveEntry(post: Post) -> Element {
|
||||
|
||||
rsx! {
|
||||
div { class: "archive-entry relative py-1.5 my-2.5 group",
|
||||
h3 { class: "archive-entry-title text-base font-normal text-gray-900 dark:text-[#dadadb] m-0",
|
||||
h3 { class: "archive-entry-title text-base font-normal text-paper-primary m-0",
|
||||
"{post.title}"
|
||||
}
|
||||
div { class: "archive-meta text-sm text-gray-400 dark:text-[#9b9c9d] mt-1",
|
||||
div { class: "archive-meta text-sm text-paper-secondary mt-1",
|
||||
"{date_str}"
|
||||
}
|
||||
Link {
|
||||
|
||||
@ -40,7 +40,7 @@ fn HomePosts(current_page: i32) -> Element {
|
||||
PostCard { post: post.clone() }
|
||||
}
|
||||
if posts.is_empty() {
|
||||
div { class: "text-center text-gray-500 dark:text-[#9b9c9d] py-20",
|
||||
div { class: "text-center text-paper-secondary py-20",
|
||||
"暂无文章"
|
||||
}
|
||||
}
|
||||
@ -66,10 +66,10 @@ fn HomePosts(current_page: i32) -> Element {
|
||||
fn HomeInfo() -> Element {
|
||||
rsx! {
|
||||
div { class: "mb-10 text-center",
|
||||
h1 { class: "text-[34px] font-bold leading-tight text-gray-900 dark:text-[#dadadb]",
|
||||
h1 { class: "text-4xl font-bold leading-tight text-paper-primary tracking-tight",
|
||||
"Yggdrasil"
|
||||
}
|
||||
p { class: "mt-3 text-base text-gray-500 dark:text-[#9b9c9d] leading-relaxed",
|
||||
p { class: "mt-3 text-base text-paper-secondary leading-relaxed",
|
||||
"以文字为主的简约博客系统"
|
||||
}
|
||||
}
|
||||
@ -92,7 +92,7 @@ fn Pagination(current_page: i32, total: i64) -> Element {
|
||||
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-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
|
||||
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", "«" }
|
||||
"上一页"
|
||||
@ -100,7 +100,7 @@ fn Pagination(current_page: i32, total: i64) -> Element {
|
||||
}
|
||||
if has_next {
|
||||
Link {
|
||||
class: "ml-auto 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",
|
||||
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", "»" }
|
||||
|
||||
@ -11,10 +11,15 @@ pub fn Login() -> Element {
|
||||
let mut username = use_signal(|| "".to_string());
|
||||
let mut password = use_signal(|| "".to_string());
|
||||
let mut error = use_signal(|| None::<String>);
|
||||
let mut loading = use_signal(|| false);
|
||||
let mut ctx: UserContext = use_context();
|
||||
|
||||
let on_submit = Callback::new(move |_| {
|
||||
if loading() {
|
||||
return;
|
||||
}
|
||||
error.set(None);
|
||||
loading.set(true);
|
||||
|
||||
let username_val = username();
|
||||
let password_val = password();
|
||||
@ -47,13 +52,16 @@ pub fn Login() -> Element {
|
||||
error.set(Some(format!("请求失败: {}", e)));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
let is_loading = loading();
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]",
|
||||
div { class: "w-full max-w-md p-8 bg-white dark:bg-[#2e2e33] rounded-2xl border border-gray-200 dark:border-[#333]",
|
||||
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-[#dadadb] mb-6",
|
||||
div { class: "min-h-screen flex items-center justify-center bg-paper-theme",
|
||||
div { class: "w-full max-w-md p-8 bg-paper-entry rounded-2xl border border-paper-border shadow-sm",
|
||||
h1 { class: "text-2xl font-bold text-center text-paper-primary mb-6",
|
||||
"登录"
|
||||
}
|
||||
|
||||
@ -68,6 +76,7 @@ pub fn Login() -> Element {
|
||||
r#type: "text",
|
||||
placeholder: "用户名或邮箱",
|
||||
value: username(),
|
||||
disabled: is_loading,
|
||||
oninput: move |v: String| username.set(v),
|
||||
onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })),
|
||||
}
|
||||
@ -78,17 +87,20 @@ pub fn Login() -> Element {
|
||||
r#type: "password",
|
||||
placeholder: "密码",
|
||||
value: password(),
|
||||
disabled: is_loading,
|
||||
oninput: move |v: String| password.set(v),
|
||||
onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })),
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "{BUTTON_PRIMARY_CLASS}",
|
||||
class: if is_loading { "opacity-60 cursor-not-allowed" },
|
||||
disabled: is_loading,
|
||||
onclick: move |_| on_submit(()),
|
||||
"登录"
|
||||
if is_loading { "登录中..." } else { "登录" }
|
||||
}
|
||||
Link {
|
||||
class: "block w-full py-2 px-4 text-center text-gray-500 dark:text-[#9b9c9d] hover:text-gray-700 dark:hover:text-[#dadadb] font-medium rounded-lg transition-colors cursor-pointer",
|
||||
class: "block w-full py-2 px-4 text-center text-paper-secondary hover:text-paper-accent font-medium rounded-lg transition-all duration-200 cursor-pointer",
|
||||
to: Route::Register {},
|
||||
"还没有账号?去注册"
|
||||
}
|
||||
|
||||
@ -48,6 +48,17 @@ pub fn PostDetail(slug: String) -> Element {
|
||||
}
|
||||
|
||||
PostFooter { post: post.clone() }
|
||||
|
||||
if post.status == crate::models::post::PostStatus::Published {
|
||||
div { class: "mt-12 border-t border-gray-200 dark:border-[#333] pt-8",
|
||||
SuspenseBoundary {
|
||||
fallback: move |_| rsx! {
|
||||
crate::components::skeletons::comment_skeleton::CommentListSkeleton {}
|
||||
},
|
||||
crate::components::comments::section::CommentSection { post_id: post.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,12 @@ pub fn Register() -> Element {
|
||||
let mut confirm_password = use_signal(|| "".to_string());
|
||||
let mut error = use_signal(|| None::<String>);
|
||||
let mut success = use_signal(|| false);
|
||||
let mut loading = use_signal(|| false);
|
||||
|
||||
let on_submit = move |_| {
|
||||
let on_submit = Callback::new(move |_| {
|
||||
if loading() {
|
||||
return;
|
||||
}
|
||||
error.set(None);
|
||||
success.set(false);
|
||||
|
||||
@ -31,6 +35,8 @@ pub fn Register() -> Element {
|
||||
let email_val = email();
|
||||
let password_val = password();
|
||||
|
||||
loading.set(true);
|
||||
|
||||
spawn(async move {
|
||||
match register(username_val, email_val, password_val).await {
|
||||
Ok(AuthResponse { success: true, .. }) => {
|
||||
@ -47,23 +53,26 @@ pub fn Register() -> Element {
|
||||
error.set(Some(format!("请求失败: {}", e)));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
let is_loading = loading();
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]",
|
||||
div { class: "w-full max-w-md p-8 bg-white dark:bg-[#2e2e33] rounded-2xl border border-gray-200 dark:border-[#333]",
|
||||
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-[#dadadb] mb-2",
|
||||
div { class: "min-h-screen flex items-center justify-center bg-paper-theme",
|
||||
div { class: "w-full max-w-md p-8 bg-paper-entry rounded-2xl border border-paper-border shadow-sm",
|
||||
h1 { class: "text-2xl font-bold text-center text-paper-primary mb-2",
|
||||
"注册"
|
||||
}
|
||||
p { class: "text-sm text-center text-gray-500 dark:text-[#9b9c9d] mb-6",
|
||||
p { class: "text-sm text-center text-paper-secondary mb-6",
|
||||
"首个注册账号将自动成为管理员"
|
||||
}
|
||||
|
||||
if success() {
|
||||
div { class: "mb-4 p-3 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg text-center",
|
||||
"注册成功!"
|
||||
Link { class: "block mt-2 text-gray-700 dark:text-[#dadadb] hover:underline cursor-pointer",
|
||||
Link { class: "block mt-2 text-paper-accent hover:underline cursor-pointer",
|
||||
to: Route::Login {},
|
||||
"去登录"
|
||||
}
|
||||
@ -81,6 +90,7 @@ pub fn Register() -> Element {
|
||||
r#type: "text",
|
||||
placeholder: "3-50 位字符",
|
||||
value: username(),
|
||||
disabled: is_loading,
|
||||
oninput: move |v: String| username.set(v),
|
||||
onkeydown: None,
|
||||
}
|
||||
@ -91,6 +101,7 @@ pub fn Register() -> Element {
|
||||
r#type: "email",
|
||||
placeholder: "your@email.com",
|
||||
value: email(),
|
||||
disabled: is_loading,
|
||||
oninput: move |v: String| email.set(v),
|
||||
onkeydown: None,
|
||||
}
|
||||
@ -101,6 +112,7 @@ pub fn Register() -> Element {
|
||||
r#type: "password",
|
||||
placeholder: "至少 8 位",
|
||||
value: password(),
|
||||
disabled: is_loading,
|
||||
oninput: move |v: String| password.set(v),
|
||||
onkeydown: None,
|
||||
}
|
||||
@ -111,19 +123,22 @@ pub fn Register() -> Element {
|
||||
r#type: "password",
|
||||
placeholder: "再次输入密码",
|
||||
value: confirm_password(),
|
||||
disabled: is_loading,
|
||||
oninput: move |v: String| confirm_password.set(v),
|
||||
onkeydown: None,
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "{BUTTON_PRIMARY_CLASS}",
|
||||
onclick: on_submit,
|
||||
"注册"
|
||||
class: if is_loading { "opacity-60 cursor-not-allowed" },
|
||||
disabled: is_loading,
|
||||
onclick: move |_| on_submit(()),
|
||||
if is_loading { "注册中..." } else { "注册" }
|
||||
}
|
||||
}
|
||||
p { class: "mt-4 text-center text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||
p { class: "mt-4 text-center text-sm text-paper-secondary",
|
||||
"已有账号?"
|
||||
Link { class: "text-gray-700 dark:text-[#dadadb] hover:underline cursor-pointer",
|
||||
Link { class: "text-paper-accent hover:underline cursor-pointer",
|
||||
to: Route::Login {},
|
||||
"去登录"
|
||||
}
|
||||
|
||||
@ -26,14 +26,14 @@ pub fn Search() -> Element {
|
||||
|
||||
rsx! {
|
||||
header { class: "page-header mb-6",
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
|
||||
"搜索"
|
||||
}
|
||||
}
|
||||
div { class: "mb-8",
|
||||
div { class: "flex gap-2",
|
||||
input {
|
||||
class: "flex-1 px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
|
||||
class: "flex-1 px-4 py-2 border border-paper-border rounded-lg bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30",
|
||||
r#type: "text",
|
||||
placeholder: "输入关键词搜索文章...",
|
||||
value: query(),
|
||||
@ -41,7 +41,7 @@ pub fn Search() -> Element {
|
||||
onkeydown: move |e| if e.key() == Key::Enter { on_search() },
|
||||
}
|
||||
button {
|
||||
class: "px-6 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full font-medium hover:opacity-80 transition-opacity",
|
||||
class: "px-6 py-2 bg-paper-accent text-white rounded-full font-medium hover:brightness-110 active:scale-[0.98] transition-all duration-200",
|
||||
onclick: move |_| on_search(),
|
||||
"搜索"
|
||||
}
|
||||
@ -51,7 +51,7 @@ pub fn Search() -> Element {
|
||||
DelayedSkeleton { SearchSkeleton {} }
|
||||
} else if let Some(Ok(PostListResponse { posts, total: _ })) = search_res() {
|
||||
if posts.is_empty() {
|
||||
div { class: "text-center text-gray-500 dark:text-[#9b9c9d] py-20",
|
||||
div { class: "text-center text-paper-secondary py-20",
|
||||
"未找到相关文章"
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -11,7 +11,7 @@ use crate::router::Route;
|
||||
pub fn Tags() -> Element {
|
||||
rsx! {
|
||||
header { class: "page-header mb-6",
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
|
||||
"标签"
|
||||
}
|
||||
}
|
||||
@ -32,21 +32,21 @@ fn TagsContent() -> Element {
|
||||
Some(Ok(tags)) => {
|
||||
let total = tags.iter().map(|t| t.post_count).sum::<i64>();
|
||||
rsx! {
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
div { class: "mt-2 text-base text-paper-secondary",
|
||||
"共 "
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{tags.len()}" }
|
||||
span { class: "font-medium text-paper-primary", "{tags.len()}" }
|
||||
" 个标签,"
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{total}" }
|
||||
span { class: "font-medium text-paper-primary", "{total}" }
|
||||
" 篇文章"
|
||||
}
|
||||
ul { class: "flex flex-wrap gap-4 mt-6",
|
||||
for tag in tags {
|
||||
li {
|
||||
Link {
|
||||
class: "inline-flex items-center px-3 py-1.5 text-base font-medium bg-gray-100 dark:bg-[#2e2e33] text-gray-700 dark:text-[#9b9c9d] rounded-lg hover:bg-gray-200 dark:hover:bg-[#333] transition-colors",
|
||||
class: "inline-flex items-center px-3 py-1.5 text-base font-medium bg-paper-accent-soft text-paper-accent rounded-lg hover:bg-paper-accent hover:text-white transition-all duration-200",
|
||||
to: Route::TagDetail { tag: tag.name.clone() },
|
||||
"{tag.name}"
|
||||
sup { class: "ml-1 text-sm text-gray-500 dark:text-[#9b9c9d]", "{tag.post_count}" }
|
||||
sup { class: "ml-1 text-sm text-paper-secondary", "{tag.post_count}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -72,7 +72,7 @@ fn TagsContent() -> Element {
|
||||
pub fn TagDetail(tag: String) -> Element {
|
||||
rsx! {
|
||||
header { class: "page-header mb-6",
|
||||
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||
h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
|
||||
"{tag}"
|
||||
}
|
||||
}
|
||||
@ -92,9 +92,9 @@ fn TagDetailContent(tag: String) -> Element {
|
||||
match posts_data {
|
||||
Some(Ok((posts, total))) => {
|
||||
rsx! {
|
||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
||||
div { class: "mt-2 text-base text-paper-secondary",
|
||||
"共 "
|
||||
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{total}" }
|
||||
span { class: "font-medium text-paper-primary", "{total}" }
|
||||
" 篇文章"
|
||||
}
|
||||
for post in posts.iter() {
|
||||
|
||||
@ -5,7 +5,7 @@ use crate::components::admin_layout::AdminLayout;
|
||||
use crate::components::frontend_layout::FrontendLayout;
|
||||
use crate::context::UserContext;
|
||||
use crate::pages::about::About;
|
||||
use crate::pages::admin::{Admin, Posts, PostsPage, Write, WriteEdit};
|
||||
use crate::pages::admin::{Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Write, WriteEdit};
|
||||
use crate::pages::archives::Archives;
|
||||
use crate::pages::home::{Home, HomePage};
|
||||
use crate::pages::login::Login;
|
||||
@ -52,6 +52,10 @@ pub enum Route {
|
||||
Posts {},
|
||||
#[route("/posts/:page")]
|
||||
PostsPage { page: i32 },
|
||||
#[route("/comments")]
|
||||
AdminComments {},
|
||||
#[route("/comments/:page")]
|
||||
AdminCommentsPage { page: i32 },
|
||||
#[end_layout]
|
||||
#[end_nest]
|
||||
|
||||
|
||||
25
src/tasks/ip_purge.rs
Normal file
25
src/tasks/ip_purge.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
pub async fn run_purge() {
|
||||
let mut ticker = interval(Duration::from_secs(86400));
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match get_conn().await {
|
||||
Ok(client) => {
|
||||
if let Err(e) = client
|
||||
.execute("UPDATE comments SET ip_address = NULL, user_agent = NULL WHERE created_at < NOW() - INTERVAL '90 days' AND ip_address IS NOT NULL", &[])
|
||||
.await
|
||||
{
|
||||
tracing::error!("IP purge error: {:?}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get DB connection for IP purge: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
#[cfg(feature = "server")]
|
||||
pub mod ip_purge;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod session_cleanup;
|
||||
|
||||
@ -135,7 +135,7 @@ pub fn ThemeToggle() -> Element {
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: "theme-toggle p-2 rounded-full cursor-pointer hover:opacity-80 transition-opacity text-gray-600 dark:text-gray-300",
|
||||
class: "theme-toggle p-2 rounded-full cursor-pointer hover:text-paper-accent transition-colors duration-200 text-paper-secondary",
|
||||
onclick: move |_| theme.set(theme().toggle()),
|
||||
if mounted() && theme() == Theme::Dark {
|
||||
svg {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user