Compare commits
4 Commits
22d05a98b3
...
c6cb401522
| Author | SHA1 | Date | |
|---|---|---|---|
| c6cb401522 | |||
| 110be35c54 | |||
| 5a88531543 | |||
| 5e2365ef7c |
15
AGENTS.md
15
AGENTS.md
@ -14,14 +14,14 @@
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
make dev # 增量构建 4 个 libs (pnpm -r run build) + tailwindcss watch + dx serve (needs PostgreSQL, SSR_CACHE_SECS=0)
|
||||
make dev # 增量构建全部 libs (pnpm -r run build) + tailwindcss watch + dx serve (needs PostgreSQL, SSR_CACHE_SECS=0)
|
||||
make build # pnpm install → build-libs → highlight-css → tailwindcss → doc → dx build --release → restore-webp
|
||||
make build-linux # 客户端 + 服务端分离构建,target x86_64-unknown-linux-musl
|
||||
make build-freebsd # cross-compile FreeBSD x86_64 server binary (clang + lld + sysroot, via cargo, not dx)
|
||||
make freebsd-sysroot # download/extract FreeBSD base.txz → .freebsd-sysroot/ (idempotent)
|
||||
make css # one-shot Tailwind build
|
||||
make css-watch # Tailwind watch mode
|
||||
make test # cargo test + pnpm -r run test (all 4 libs: tiptap-editor / lightbox / yggdrasil-core / codemirror-editor)
|
||||
make test # cargo test + pnpm -r run test (全部 libs: tiptap-editor / lightbox / yggdrasil-core / codemirror-editor / xterm-terminal)
|
||||
make doc # cargo doc (ayu 主题) → 拷贝到 public/doc/,随 build 发布
|
||||
make doc-open # 同 doc,生成后自动用浏览器打开(本地预览,不拷贝)
|
||||
make lint # Biome check (libs) + cargo clippy (严格模式,warning 即失败)
|
||||
@ -29,11 +29,11 @@ make fix # biome format --write (libs) + cargo fix --allow-dirty
|
||||
make clean # cargo clean + rm public/{style.css,highlight.css,doc} + rm -rf uploads/.cache + rm -rf libs/node_modules libs/*/node_modules
|
||||
```
|
||||
|
||||
**Build order matters**: `make build` runs `pnpm install --frozen-lockfile` (in `libs/`) → `build-libs` (`pnpm -r run build`, all 4 libs in parallel) → `highlight-css` (`cargo run --bin generate_highlight_css`) → `tailwindcss --minify` → `doc` → `dx build --release` → `restore-webp`. Do not run `dx build --release` alone.
|
||||
**Build order matters**: `make build` runs `pnpm install --frozen-lockfile` (in `libs/`) → `build-libs` (`pnpm -r run build`, all libs in parallel) → `highlight-css` (`cargo run --bin generate_highlight_css`) → `tailwindcss --minify` → `doc` → `dx build --release` → `restore-webp`. Do not run `dx build --release` alone.
|
||||
|
||||
**`restore-webp` workaround**: dx build 0.7.9 re-encodes `public/*.webp` into VP8L lossless stills (drops animation frames, 7-8× larger), contradicting the "verbatim copy" promise. `restore-webp` overwrites `.webp` in `target/dx/**/web/public/` from source `public/`. SVG/ICO are unaffected. Remove once upstream fixes it.
|
||||
|
||||
**JS workspace lives under `libs/`**: `libs/` is a pnpm workspace — root `libs/package.json` hoists the 4 shared devDeps (`happy-dom`/`typescript`/`vite`/`vitest`) + Biome; `libs/pnpm-workspace.yaml` lists `libs/*` packages; `libs/pnpm-lock.yaml` is the single lockfile. First-time setup: `cd libs && pnpm install`. All Makefile recipes `cd libs && pnpm ...`. Build a single lib: `make build-editor` (≈ `cd libs && pnpm --filter @yggdrasil/tiptap-editor run build`).
|
||||
**JS workspace lives under `libs/`**: `libs/` is a pnpm workspace — root `libs/package.json` hoists the 4 shared devDeps (`happy-dom`/`typescript`/`vite`/`vitest`) + Biome; `libs/pnpm-workspace.yaml` lists `libs/*` packages; `libs/pnpm-lock.yaml` is the single lockfile. First-time setup: `cd libs && pnpm install`. All Makefile recipes `cd libs && pnpm ...`. Build a single lib: `make build-editor` (≈ `cd libs && pnpm --filter @yggdrasil/tiptap-editor run build`). There is also `@yggdrasil/shared` (`libs/shared/`) — a non-IIFE **internal source-shared package** (exports `ThemeName`/`THEME_CHANGE_EVENT`/`prefersReducedMotion`, `main` points at `src/index.ts` with no Vite build) consumed via `workspace:*` by codemirror-editor/lightbox/xterm-terminal/yggdrasil-core and inlined into each lib's IIFE bundle at build time; it has no `public/` artifact.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@ -180,7 +180,7 @@ Readers can execute fenced code blocks in isolated Docker containers; authors ge
|
||||
|
||||
- **Execution layer** (`src/infra/docker.rs`, server-only): `bollard` client over Unix socket (`DOCKER_CLIENT` LazyLock). `run_in_container` creates a read-only-rootfs container (tmpfs `/code`,`/tmp`,`/run`; cpu/memory/pids/ulimits cap; `cap_drop=ALL`; `no-new-privileges`; non-root `1000:1000`; `network_mode` none/bridge), injects source via stdin, waits with timeout, captures stdout/stderr (truncated to `output_bytes`), inspects OOM, force-removes via `ContainerGuard` Drop. `src/infra/runner_config.rs` (`RUNNER_CONFIG`) reads all `CODE_RUNNER_*` env vars; `clamp_limits` AND-merges request overrides × language `allow_network` × global switch.
|
||||
- **API layer** (`src/api/code_runner/`): shared `ExecRequest`/`ExecResult`/`ExecStatus`/`ExecTask` structs (compile on both targets); `progress.rs` = DashMap task registry + `gc_old_tasks`; `languages.rs` = `LANGUAGES` registry + `parse_fence_info`; `execute.rs` = `StartExec`/`GetExecResult` server functions (double rate-limit → whitelist → size check → enqueue → `tokio::spawn` + `RUNNER_SEMAPHORE` concurrency cap → clamp → run_in_container). System errors are sanitized to 「系统暂时不可用」 for anonymous callers; full errors in server logs only.
|
||||
- **Markdown/render layer**: a fenced block ` ```python runnable {...overrides} ` renders to `<pre data-runnable="true" data-lang="python" data-overrides="..." data-source="...">` (sanitizer whitelists these 4 attrs on `<pre>`). `PostContent` (`src/components/post/post_content.rs`) splits `content_html` into `Html`/`Runnable` fragments so each runnable block renders as a real `<CodeRunner>` vdom element (no manual DOM mutation → no hydration conflict). `CodeRunner` component polls `GetExecResult` via WASM-friendly `sleep_ms`.
|
||||
- **Markdown/render layer**: a fenced block ` ```python runnable {...overrides} ` renders to `<pre data-runnable="true" data-lang="python" data-overrides="..." data-source="...">` (sanitizer whitelists these 4 attrs on `<pre>`). `PostContent` (`src/components/post/post_content.rs`) splits `content_html` into `Html`/`Runnable` fragments so each runnable block renders as a real `<CodeRunner>` vdom element (no manual DOM mutation → no hydration conflict). `CodeRunner` component polls `GetExecResult` via WASM-friendly `sleep_ms`. Output is rendered into an xterm.js terminal instance (`src/xterm_bridge.rs` + `libs/xterm-terminal`, mirroring the codemirror bridge pattern) — stdout/stderr streamed via SSE, with full-buffer `writeAll` as a polling fallback.
|
||||
|
||||
**Critical WASM-visibility rule**: `code_runner/execute.rs` is **not** cfg-gated (server functions must be visible to the client), but every server-only `use`/static inside it is individually `#[cfg(feature = "server")]`. The shared `use` of `ExecRequest`/`ExecTask` (used in signatures) stays ungated. `code_runner/languages.rs` and `code_runner/progress.rs` (pure server helpers) are wholly gated. Mirrors the `posts/` module convention.
|
||||
|
||||
@ -190,7 +190,7 @@ Readers can execute fenced code blocks in isolated Docker containers; authors ge
|
||||
|
||||
## Frontend Lib Subprojects
|
||||
|
||||
Four Vite-built IIFE libraries under `libs/`, managed as a **pnpm workspace** (single `libs/pnpm-lock.yaml`, shared `libs/tsconfig.base.json` + `libs/biome.json`, hoisted devDeps). Built artifacts go to `public/<name>/` — **do not edit `public/<name>/` files; they are build artifacts**. Each `build` script is `tsc --noEmit && vite build` (type-check before bundle). Output is IIFE because Dioxus `[web.resource] script` injects bare `<script src>` without `type="module"` support. Registered globally in `Dioxus.toml` `script`/`style` arrays.
|
||||
Five Vite-built IIFE libraries under `libs/` (plus `libs/shared/`, an internal source-shared package with no `public/` artifact — see below), managed as a **pnpm workspace** (single `libs/pnpm-lock.yaml`, shared `libs/tsconfig.base.json` + `libs/biome.json`, hoisted devDeps). Built artifacts go to `public/<name>/` — **do not edit `public/<name>/` files; they are build artifacts**. Each `build` script is `tsc --noEmit && vite build` (type-check before bundle). Output is IIFE because Dioxus `[web.resource] script` injects bare `<script src>` without `type="module"` support. Registered globally in `Dioxus.toml` `script`/`style` arrays.
|
||||
|
||||
| Lib | Output dir | Exposes | Wiring |
|
||||
| ------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@ -198,6 +198,7 @@ Four Vite-built IIFE libraries under `libs/`, managed as a **pnpm workspace** (s
|
||||
| `libs/codemirror-editor/` | `public/codemirror/` (`editor.js`/`.map`, **no CSS**) | `window.CodeMirrorEditor` (object literal `{ create }`) + `window.EditorOptions` (class, survives TS erasure) | `src/codemirror_bridge.rs` mirrors tiptap — `get_module()` uses `Reflect::get` + `unchecked_into` (object literal, NOT a constructor extern). Themes are JS `Extension`s from `@catppuccin/codemirror` (Latte/Mocha), hot-swapped via `Compartment.reconfigure`. |
|
||||
| `libs/lightbox/` | `public/lightbox/` (`lightbox.js`/`.css`/`.map`) | self-initializing IIFE | **Not** wasm-bindgen. `src/components/post/post_content.rs` sets `window.__lightboxSelectors` before load; IIFE tail reads it and self-initializes. Direct fallback call if already loaded. |
|
||||
| `libs/yggdrasil-core/` | `public/yggdrasil-core/` (`yggdrasil-core.js`/`.css`/`.map`) | `window.__initPostContent`, `window.__startThemeTransition` | Designated home for all new core JS — add here, not to `public/js/`. Rust calls entry points via `js_sys::Reflect::get` + `Function::apply` (no `js_sys::eval`), silently no-oping if undefined. Theme reveal uses View Transitions API (`startViewTransition` + `@keyframes tt-expand` `clip-path` expand); falls back to instant switch when VT / `prefers-reduced-motion`. |
|
||||
| `libs/xterm-terminal/` | `public/xterm/` (`terminal.js`/`.css`/`.map`) | `window.XtermTerminal` (object literal `{ create }`) | `src/xterm_bridge.rs` mirrors codemirror — `get_module()` uses `Reflect::get` + `unchecked_into` (object literal, NOT a constructor extern). `TerminalHandle` holds instance + `onReady` closure, Drop → `destroy()`. **Output-only** (no stdin); `write_stdout`/`write_stderr`/`write_all` stream the Code Runner's stdout/stderr into the terminal. `@xterm/xterm` ^6 + `@xterm/addon-fit`; theme hot-swap via `THEME_CHANGE_EVENT`. |
|
||||
|
||||
Run a single lib's tests: `cd libs && pnpm --filter @yggdrasil/<name> test` (Vitest + happy-dom). Watch mode: append `-- test:watch`.
|
||||
|
||||
@ -233,7 +234,7 @@ Admin area at `/admin/system` (menu "系统") with 5 tabs: 数据库状态 / 服
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
make test # cargo test (Rust) + pnpm -r run test in all 4 libs
|
||||
make test # cargo test (Rust) + pnpm -r run test in all libs
|
||||
make lint # Biome check (libs) + cargo clippy --all-targets --all-features -- -D warnings
|
||||
dx check # Dioxus type-check (catches component/Router issues)
|
||||
```
|
||||
|
||||
5
Makefile
5
Makefile
@ -1,4 +1,4 @@
|
||||
.PHONY: dev build build-linux build-freebsd freebsd-sysroot docker docker-amd64 docker-apple docker-multiarch css css-watch clean build-libs build-editor build-codemirror build-lightbox build-core highlight-css test doc doc-open start lint fix restore-webp
|
||||
.PHONY: dev build build-linux build-freebsd freebsd-sysroot docker docker-amd64 docker-apple docker-multiarch css css-watch clean build-libs build-editor build-codemirror build-lightbox build-core build-xterm highlight-css test doc doc-open start lint fix restore-webp
|
||||
|
||||
build:
|
||||
@cd libs && pnpm install --frozen-lockfile
|
||||
@ -82,7 +82,7 @@ restore-webp:
|
||||
highlight-css:
|
||||
@cargo run --bin generate_highlight_css
|
||||
|
||||
# 并行构建全部 4 个 libs/ 子项目(pnpm -r 拓扑顺序,无相互依赖则并发)。
|
||||
# 并行构建全部 libs/ 子项目(pnpm -r 拓扑顺序,无相互依赖则并发)。
|
||||
# 依赖安装由调用方负责(build/build-linux 用 pnpm install --frozen-lockfile,
|
||||
# dev 假设 node_modules 已存在)。
|
||||
build-libs:
|
||||
@ -93,6 +93,7 @@ build-editor: ; @cd libs && pnpm --filter @yggdrasil/tiptap-editor run build
|
||||
build-codemirror: ; @cd libs && pnpm --filter @yggdrasil/codemirror-editor run build
|
||||
build-lightbox: ; @cd libs && pnpm --filter @yggdrasil/lightbox run build
|
||||
build-core: ; @cd libs && pnpm --filter @yggdrasil/core run build
|
||||
build-xterm: ; @cd libs && pnpm --filter @yggdrasil/xterm-terminal run build
|
||||
|
||||
dev: build-libs highlight-css
|
||||
@echo "Cleaning static/..."
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
//! 所有 Dioxus server function 均注册在 `/api` 路径下,供前端与服务端调用。
|
||||
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与缓存失效。
|
||||
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
#![allow(clippy::unused_unit, deprecated, unused_imports)]
|
||||
|
||||
mod check;
|
||||
mod create;
|
||||
@ -19,10 +19,8 @@ pub use check::check_pending_status;
|
||||
/// 创建一条新评论。
|
||||
pub use create::create_comment;
|
||||
/// 获取全部评论分页列表。
|
||||
#[allow(unused_imports)]
|
||||
pub use list::get_all_comments;
|
||||
/// 获取待审核评论总数。
|
||||
#[allow(unused_imports)]
|
||||
pub use list::get_pending_count;
|
||||
/// 获取指定文章的已审核评论列表。
|
||||
pub use read::get_comments;
|
||||
@ -35,5 +33,4 @@ pub use update::batch_update_comment_status;
|
||||
/// 将指定评论标记为垃圾评论。
|
||||
pub use update::spam_comment;
|
||||
/// 将指定评论移入回收站。
|
||||
#[allow(unused_imports)]
|
||||
pub use update::trash_comment;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
//! 所有 Dioxus server function 均注册在 `/api` 路径下,供前端与服务端调用。
|
||||
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与缓存失效。
|
||||
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
#![allow(clippy::unused_unit, deprecated, unused_imports)]
|
||||
|
||||
mod create;
|
||||
mod delete;
|
||||
@ -19,20 +19,16 @@ mod types;
|
||||
mod update;
|
||||
|
||||
/// 创建新文章。
|
||||
#[allow(unused_imports)]
|
||||
pub use create::create_post;
|
||||
/// 删除指定文章。
|
||||
pub use delete::delete_post;
|
||||
/// 获取回收站中已软删除的文章列表。
|
||||
#[allow(unused_imports)]
|
||||
pub use list::list_deleted_posts;
|
||||
/// 获取管理员视角的全部文章分页列表。
|
||||
#[allow(unused_imports)]
|
||||
pub use list::list_posts;
|
||||
/// 获取已发布文章分页列表。
|
||||
pub use list::{get_posts_by_tag, list_published_posts};
|
||||
/// 根据 id 获取文章详情。
|
||||
#[allow(unused_imports)]
|
||||
pub use read::{get_post_by_id, get_post_by_slug};
|
||||
/// 重新渲染文章的 Markdown HTML 与目录。
|
||||
pub use rebuild::rebuild_content_html;
|
||||
@ -41,15 +37,12 @@ pub use rebuild::rebuild_post_content_html;
|
||||
/// 全文搜索已发布文章。
|
||||
pub use search::search_posts;
|
||||
/// 获取文章统计信息。
|
||||
#[allow(unused_imports)]
|
||||
pub use stats::get_post_stats;
|
||||
/// 获取全部标签及其文章数量。
|
||||
pub use tags::list_tags;
|
||||
/// 恢复已删除文章。
|
||||
#[allow(unused_imports)]
|
||||
pub use trash::{batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post};
|
||||
/// 文章 API 的请求与响应数据结构。
|
||||
pub use types::*;
|
||||
/// 更新指定文章。
|
||||
#[allow(unused_imports)]
|
||||
pub use update::update_post;
|
||||
|
||||
392
src/main.rs
392
src/main.rs
@ -23,6 +23,10 @@ pub mod infra;
|
||||
// highlight 模块仅在服务端构建时编译
|
||||
#[cfg(feature = "server")]
|
||||
mod highlight;
|
||||
// middleware:Axum 中间件与启动期纯函数(cache-control / admin 守卫 / 压缩层),
|
||||
// server-only。从 main.rs 抽出以便独立测试,路由组装处以 crate::middleware::xxx 调用。
|
||||
#[cfg(feature = "server")]
|
||||
mod middleware;
|
||||
mod hooks;
|
||||
mod models;
|
||||
mod pages;
|
||||
@ -46,230 +50,6 @@ mod utils;
|
||||
mod webp;
|
||||
mod xterm_bridge;
|
||||
|
||||
/// 压缩算法配置。
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct CompressionAlgorithms {
|
||||
gzip: bool,
|
||||
brotli: bool,
|
||||
deflate: bool,
|
||||
zstd: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl CompressionAlgorithms {
|
||||
fn all_enabled() -> Self {
|
||||
Self {
|
||||
gzip: true,
|
||||
brotli: true,
|
||||
deflate: true,
|
||||
zstd: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
!self.gzip && !self.brotli && !self.deflate && !self.zstd
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 COMPRESSION_ALGORITHMS 环境变量值。
|
||||
/// ""、"none"、"off" 返回 None;"all" 或未识别到任何算法时启用全部。
|
||||
#[cfg(feature = "server")]
|
||||
fn parse_compression_algorithms(env: &str) -> Option<CompressionAlgorithms> {
|
||||
let env = env.trim();
|
||||
if env.is_empty() || env.eq_ignore_ascii_case("none") || env.eq_ignore_ascii_case("off") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut all = false;
|
||||
let mut gzip = false;
|
||||
let mut brotli = false;
|
||||
let mut deflate = false;
|
||||
let mut zstd = false;
|
||||
|
||||
for part in env.split(',') {
|
||||
match part.trim().to_lowercase().as_str() {
|
||||
"all" => all = true,
|
||||
"gzip" => gzip = true,
|
||||
"brotli" | "br" => brotli = true,
|
||||
"deflate" => deflate = true,
|
||||
"zstd" => zstd = true,
|
||||
other => tracing::warn!(
|
||||
"Unknown compression algorithm in COMPRESSION_ALGORITHMS: '{}'",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if all {
|
||||
return Some(CompressionAlgorithms::all_enabled());
|
||||
}
|
||||
|
||||
let algorithms = CompressionAlgorithms {
|
||||
gzip,
|
||||
brotli,
|
||||
deflate,
|
||||
zstd,
|
||||
};
|
||||
if algorithms.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(algorithms)
|
||||
}
|
||||
|
||||
/// 根据 COMPRESSION_ALGORITHMS 环境变量构造 CompressionLayer。
|
||||
/// 未设置或设置为 "all" 时启用全部算法;设置为 ""、"none" 或 "off" 时禁用。
|
||||
///
|
||||
/// CompressionLayer 使用 tower-http 的 `DefaultPredicate`,开箱即用即:
|
||||
/// - 跳过 `image/*` content-type(WebP/PNG/JPEG/GIF 等已是压缩格式,再压浪费 CPU,
|
||||
/// 唯一例外是 `image/svg+xml`,作为 XML 文本可被压缩);
|
||||
/// - 跳过 gRPC 与 `text/event-stream`(SSE);
|
||||
/// - 跳过小于 32 字节的响应。
|
||||
///
|
||||
/// 因此无需在此处对图片响应做额外的 content-type 过滤。另:图片实际挂在
|
||||
/// `static_routes`(无中间件),根本不经此层,详见下方路由 merge 处。
|
||||
#[cfg(feature = "server")]
|
||||
fn compression_layer_from_env() -> Option<tower_http::compression::CompressionLayer> {
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
let env = std::env::var("COMPRESSION_ALGORITHMS").unwrap_or_else(|_| "all".to_string());
|
||||
let algorithms = parse_compression_algorithms(&env)?;
|
||||
|
||||
Some(
|
||||
CompressionLayer::new()
|
||||
.gzip(algorithms.gzip)
|
||||
.br(algorithms.brotli)
|
||||
.deflate(algorithms.deflate)
|
||||
.zstd(algorithms.zstd),
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据请求路径和方法决定公开页面的 Cache-Control 头。
|
||||
/// 返回 None 表示不添加缓存头(保留现有行为或避免覆盖)。
|
||||
#[cfg(feature = "server")]
|
||||
fn cache_control_for_path(
|
||||
path: &str,
|
||||
method: &axum::http::Method,
|
||||
) -> Option<axum::http::HeaderValue> {
|
||||
use axum::http::{HeaderValue, Method};
|
||||
|
||||
// 只对 GET/HEAD 请求添加缓存头
|
||||
if *method != Method::GET && *method != Method::HEAD {
|
||||
return None;
|
||||
}
|
||||
|
||||
// API 接口:不缓存(可能涉及认证、写操作)
|
||||
if path.starts_with("/api") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 管理后台和认证页面:不缓存
|
||||
if path.starts_with("/admin") || path == "/login" || path == "/register" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 静态资源:长期缓存(Dioxus/WASM 资源通常带内容哈希)
|
||||
if path.starts_with("/_dioxus/")
|
||||
|| path.starts_with("/wasm/")
|
||||
|| path.ends_with(".wasm")
|
||||
|| path.ends_with(".js")
|
||||
|| path == "/style.css"
|
||||
|| path == "/highlight.css"
|
||||
{
|
||||
return Some(HeaderValue::from_static(
|
||||
"public, max-age=31536000, immutable",
|
||||
));
|
||||
}
|
||||
|
||||
// 公开页面:5 分钟新鲜期,过期后 1 小时内可提供过期内容并后台重新验证
|
||||
Some(HeaderValue::from_static(
|
||||
"public, max-age=300, stale-while-revalidate=3600",
|
||||
))
|
||||
}
|
||||
|
||||
/// Axum 中间件:为公开页面和静态资源附加 Cache-Control 头。
|
||||
#[cfg(feature = "server")]
|
||||
async fn add_cache_control(
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::header;
|
||||
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
let cache_value = cache_control_for_path(&path, &method);
|
||||
|
||||
let mut response = next.run(req).await;
|
||||
|
||||
if let Some(value) = cache_value {
|
||||
// 仅当响应尚未设置 Cache-Control 时才添加,避免覆盖已有策略
|
||||
response
|
||||
.headers_mut()
|
||||
.entry(header::CACHE_CONTROL)
|
||||
.or_insert(value);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Axum 中间件:`/admin*` 的 SSR 层认证守卫。
|
||||
///
|
||||
/// 未登录访问后台时,服务端**直接 302 跳转 `/login`**,根本不进入 Dioxus
|
||||
/// SSR 渲染器。此前后台鉴权完全在客户端 WASM 完成(SSR 渲染骨架屏 → WASM
|
||||
/// 下载/编译 → hydrate → 异步 `get_current_user()` → 客户端 `navigator.push`),
|
||||
/// 整条链串行,未登录用户首屏要"空白好久"才跳登录。
|
||||
///
|
||||
/// - 只匹配 `/admin*`,其它路径(`/login`、公开页、`/api/*`)直接放行。
|
||||
/// - 复用 `get_user_by_token`:命中内存缓存 + 校验 `session_generation`
|
||||
/// (封禁/降级后旧 session 立即失效),与客户端鉴权同一套语义。
|
||||
/// - DB 错误时 **fail-open**(放行进入 SSR):避免数据库抖动把已登录的
|
||||
/// 管理员也踢到登录页;客户端 `AdminLayout` 仍有兜底校验。
|
||||
/// - `/admin` 与 `/login` 本就不进 `cache_control_for_path` 缓存,302 不会被缓存。
|
||||
#[cfg(feature = "server")]
|
||||
async fn admin_guard(
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use crate::models::user::UserRole;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::Response;
|
||||
|
||||
let path = req.uri().path().to_string();
|
||||
if !path.starts_with("/admin") {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// 从 Cookie 头读 session token(与 export.rs / upload.rs 同款手法)。
|
||||
let cookie = req
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let token = crate::auth::session::parse_session_token(cookie);
|
||||
|
||||
let is_admin = match token {
|
||||
Some(t) => match crate::api::auth::get_user_by_token(t).await {
|
||||
Ok(Some(user)) => user.role == UserRole::Admin,
|
||||
// Err(DB 抖动)/ Ok(None)(token 无效):fail-open,交给客户端兜底。
|
||||
_ => true,
|
||||
},
|
||||
// 无 token:明确未登录,拦截。
|
||||
None => false,
|
||||
};
|
||||
|
||||
if is_admin {
|
||||
next.run(req).await
|
||||
} else {
|
||||
Response::builder()
|
||||
.status(StatusCode::FOUND)
|
||||
.header(header::LOCATION, "/login")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// 程序入口
|
||||
fn main() {
|
||||
// server feature:启动服务端
|
||||
@ -540,9 +320,9 @@ fn main() {
|
||||
// layer 顺序:后加的最外层先执行。CSRF 最外层先拦截非法来源。
|
||||
let mut app_routes = dioxus_app
|
||||
.layer(axum::middleware::from_fn(ssr_generation_middleware))
|
||||
.layer(axum::middleware::from_fn(add_cache_control))
|
||||
.layer(axum::middleware::from_fn(crate::middleware::add_cache_control))
|
||||
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
|
||||
if let Some(layer) = compression_layer_from_env() {
|
||||
if let Some(layer) = crate::middleware::compression_layer_from_env() {
|
||||
app_routes = app_routes.layer(layer);
|
||||
}
|
||||
let app_routes = app_routes.layer(TimeoutLayer::with_status_code(
|
||||
@ -551,7 +331,9 @@ fn main() {
|
||||
));
|
||||
// admin_guard 置于最外层(最后添加 = 最先执行):未登录的 /admin* 请求
|
||||
// 在 CSRF / cache / SSR 渲染之前就被 302 短路,零渲染开销。
|
||||
let app_routes = app_routes.layer(axum::middleware::from_fn(admin_guard));
|
||||
let app_routes = app_routes.layer(axum::middleware::from_fn(
|
||||
crate::middleware::admin_guard,
|
||||
));
|
||||
|
||||
// 静态资源路由:图片文件服务。
|
||||
// 注意:`dioxus::server::serve()` 接管了 listener 与 `into_make_service`
|
||||
@ -598,159 +380,3 @@ fn main() {
|
||||
dioxus::launch(AppRouter);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod tests {
|
||||
use super::{cache_control_for_path, parse_compression_algorithms, CompressionAlgorithms};
|
||||
use axum::http::Method;
|
||||
|
||||
fn cache_value(path: &str, method: Method) -> Option<String> {
|
||||
cache_control_for_path(path, &method).map(|v| v.to_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_page_is_cached() {
|
||||
assert_eq!(
|
||||
cache_value("/", Method::GET),
|
||||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/post/hello-world", Method::GET),
|
||||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/tags/rust", Method::GET),
|
||||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_assets_are_cached_long_term() {
|
||||
assert_eq!(
|
||||
cache_value("/style.css", Method::GET),
|
||||
Some("public, max-age=31536000, immutable".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/highlight.css", Method::GET),
|
||||
Some("public, max-age=31536000, immutable".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/wasm/app.wasm", Method::GET),
|
||||
Some("public, max-age=31536000, immutable".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/_dioxus/assets/main.js", Method::GET),
|
||||
Some("public, max-age=31536000, immutable".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_and_admin_and_auth_are_not_cached() {
|
||||
assert_eq!(cache_value("/api/posts", Method::GET), None);
|
||||
assert_eq!(cache_value("/admin", Method::GET), None);
|
||||
assert_eq!(cache_value("/admin/posts", Method::GET), None);
|
||||
assert_eq!(cache_value("/login", Method::GET), None);
|
||||
assert_eq!(cache_value("/register", Method::GET), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_get_requests_are_not_cached() {
|
||||
assert_eq!(cache_value("/", Method::POST), None);
|
||||
assert_eq!(cache_value("/post/hello-world", Method::POST), None);
|
||||
assert_eq!(cache_value("/style.css", Method::POST), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_requests_are_cached_like_get() {
|
||||
assert_eq!(
|
||||
cache_value("/", Method::HEAD),
|
||||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_all_enables_everything() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("all"),
|
||||
Some(CompressionAlgorithms::all_enabled())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_default_env_is_all() {
|
||||
// 模拟未设置环境变量时的默认值
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("all"),
|
||||
Some(CompressionAlgorithms::all_enabled())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_empty_none_off_disable() {
|
||||
assert_eq!(parse_compression_algorithms(""), None);
|
||||
assert_eq!(parse_compression_algorithms("none"), None);
|
||||
assert_eq!(parse_compression_algorithms("NONE"), None);
|
||||
assert_eq!(parse_compression_algorithms("off"), None);
|
||||
assert_eq!(parse_compression_algorithms("OFF"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_single_algorithm() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("gzip"),
|
||||
Some(CompressionAlgorithms {
|
||||
gzip: true,
|
||||
brotli: false,
|
||||
deflate: false,
|
||||
zstd: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("br"),
|
||||
Some(CompressionAlgorithms {
|
||||
gzip: false,
|
||||
brotli: true,
|
||||
deflate: false,
|
||||
zstd: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_multiple_algorithms() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("gzip, zstd"),
|
||||
Some(CompressionAlgorithms {
|
||||
gzip: true,
|
||||
brotli: false,
|
||||
deflate: false,
|
||||
zstd: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_case_insensitive_and_whitespace_tolerant() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("GZIP, Brotli, Deflate, Zstd"),
|
||||
Some(CompressionAlgorithms::all_enabled())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_compression_algorithms(" gzip , br , deflate , zstd "),
|
||||
Some(CompressionAlgorithms::all_enabled())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_unknown_algorithms_are_ignored() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("gzip, unknown, lz4"),
|
||||
Some(CompressionAlgorithms {
|
||||
gzip: true,
|
||||
brotli: false,
|
||||
deflate: false,
|
||||
zstd: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
382
src/middleware.rs
Normal file
382
src/middleware.rs
Normal file
@ -0,0 +1,382 @@
|
||||
//! Axum 中间件与启动期纯函数。
|
||||
//!
|
||||
//! 从 `main.rs` 抽出的、可独立测试的服务端 HTTP 中间件(cache-control、admin
|
||||
//! 守卫)与压缩层构造逻辑。整体 server-only——WASM 构建不会编译本模块。
|
||||
//!
|
||||
//! 这些函数此前散落在 `main.rs`,既无法单独测试也使入口职责过载。迁移后
|
||||
//! `main.rs` 的路由组装以全路径 `crate::middleware::xxx` 引用,语义不变。
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
/// 压缩算法配置。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct CompressionAlgorithms {
|
||||
gzip: bool,
|
||||
brotli: bool,
|
||||
deflate: bool,
|
||||
zstd: bool,
|
||||
}
|
||||
|
||||
impl CompressionAlgorithms {
|
||||
fn all_enabled() -> Self {
|
||||
Self {
|
||||
gzip: true,
|
||||
brotli: true,
|
||||
deflate: true,
|
||||
zstd: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
!self.gzip && !self.brotli && !self.deflate && !self.zstd
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 COMPRESSION_ALGORITHMS 环境变量值。
|
||||
/// ""、"none"、"off" 返回 None;"all" 或未识别到任何算法时启用全部。
|
||||
pub(crate) fn parse_compression_algorithms(env: &str) -> Option<CompressionAlgorithms> {
|
||||
let env = env.trim();
|
||||
if env.is_empty() || env.eq_ignore_ascii_case("none") || env.eq_ignore_ascii_case("off") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut all = false;
|
||||
let mut gzip = false;
|
||||
let mut brotli = false;
|
||||
let mut deflate = false;
|
||||
let mut zstd = false;
|
||||
|
||||
for part in env.split(',') {
|
||||
match part.trim().to_lowercase().as_str() {
|
||||
"all" => all = true,
|
||||
"gzip" => gzip = true,
|
||||
"brotli" | "br" => brotli = true,
|
||||
"deflate" => deflate = true,
|
||||
"zstd" => zstd = true,
|
||||
other => tracing::warn!(
|
||||
"Unknown compression algorithm in COMPRESSION_ALGORITHMS: '{}'",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if all {
|
||||
return Some(CompressionAlgorithms::all_enabled());
|
||||
}
|
||||
|
||||
let algorithms = CompressionAlgorithms {
|
||||
gzip,
|
||||
brotli,
|
||||
deflate,
|
||||
zstd,
|
||||
};
|
||||
if algorithms.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(algorithms)
|
||||
}
|
||||
|
||||
/// 根据 COMPRESSION_ALGORITHMS 环境变量构造 CompressionLayer。
|
||||
/// 未设置或设置为 "all" 时启用全部算法;设置为 ""、"none" 或 "off" 时禁用。
|
||||
///
|
||||
/// CompressionLayer 使用 tower-http 的 `DefaultPredicate`,开箱即用即:
|
||||
/// - 跳过 `image/*` content-type(WebP/PNG/JPEG/GIF 等已是压缩格式,再压浪费 CPU,
|
||||
/// 唯一例外是 `image/svg+xml`,作为 XML 文本可被压缩);
|
||||
/// - 跳过 gRPC 与 `text/event-stream`(SSE);
|
||||
/// - 跳过小于 32 字节的响应。
|
||||
///
|
||||
/// 因此无需在此处对图片响应做额外的 content-type 过滤。另:图片实际挂在
|
||||
/// `static_routes`(无中间件),根本不经此层,详见 main.rs 路由 merge 处。
|
||||
pub(crate) fn compression_layer_from_env() -> Option<tower_http::compression::CompressionLayer> {
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
let env = std::env::var("COMPRESSION_ALGORITHMS").unwrap_or_else(|_| "all".to_string());
|
||||
let algorithms = parse_compression_algorithms(&env)?;
|
||||
|
||||
Some(
|
||||
CompressionLayer::new()
|
||||
.gzip(algorithms.gzip)
|
||||
.br(algorithms.brotli)
|
||||
.deflate(algorithms.deflate)
|
||||
.zstd(algorithms.zstd),
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据请求路径和方法决定公开页面的 Cache-Control 头。
|
||||
/// 返回 None 表示不添加缓存头(保留现有行为或避免覆盖)。
|
||||
pub(crate) fn cache_control_for_path(
|
||||
path: &str,
|
||||
method: &axum::http::Method,
|
||||
) -> Option<axum::http::HeaderValue> {
|
||||
use axum::http::{HeaderValue, Method};
|
||||
|
||||
// 只对 GET/HEAD 请求添加缓存头
|
||||
if *method != Method::GET && *method != Method::HEAD {
|
||||
return None;
|
||||
}
|
||||
|
||||
// API 接口:不缓存(可能涉及认证、写操作)
|
||||
if path.starts_with("/api") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 管理后台和认证页面:不缓存
|
||||
if path.starts_with("/admin") || path == "/login" || path == "/register" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 静态资源:长期缓存(Dioxus/WASM 资源通常带内容哈希)
|
||||
if path.starts_with("/_dioxus/")
|
||||
|| path.starts_with("/wasm/")
|
||||
|| path.ends_with(".wasm")
|
||||
|| path.ends_with(".js")
|
||||
|| path == "/style.css"
|
||||
|| path == "/highlight.css"
|
||||
{
|
||||
return Some(HeaderValue::from_static(
|
||||
"public, max-age=31536000, immutable",
|
||||
));
|
||||
}
|
||||
|
||||
// 公开页面:5 分钟新鲜期,过期后 1 小时内可提供过期内容并后台重新验证
|
||||
Some(HeaderValue::from_static(
|
||||
"public, max-age=300, stale-while-revalidate=3600",
|
||||
))
|
||||
}
|
||||
|
||||
/// Axum 中间件:为公开页面和静态资源附加 Cache-Control 头。
|
||||
pub(crate) async fn add_cache_control(
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::header;
|
||||
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
let cache_value = cache_control_for_path(&path, &method);
|
||||
|
||||
let mut response = next.run(req).await;
|
||||
|
||||
if let Some(value) = cache_value {
|
||||
// 仅当响应尚未设置 Cache-Control 时才添加,避免覆盖已有策略
|
||||
response
|
||||
.headers_mut()
|
||||
.entry(header::CACHE_CONTROL)
|
||||
.or_insert(value);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Axum 中间件:`/admin*` 的 SSR 层认证守卫。
|
||||
///
|
||||
/// 未登录访问后台时,服务端**直接 302 跳转 `/login`**,根本不进入 Dioxus
|
||||
/// SSR 渲染器。此前后台鉴权完全在客户端 WASM 完成(SSR 渲染骨架屏 → WASM
|
||||
/// 下载/编译 → hydrate → 异步 `get_current_user()` → 客户端 `navigator.push`),
|
||||
/// 整条链串行,未登录用户首屏要"空白好久"才跳登录。
|
||||
///
|
||||
/// - 只匹配 `/admin*`,其它路径(`/login`、公开页、`/api/*`)直接放行。
|
||||
/// - 复用 `get_user_by_token`:命中内存缓存 + 校验 `session_generation`
|
||||
/// (封禁/降级后旧 session 立即失效),与客户端鉴权同一套语义。
|
||||
/// - DB 错误时 **fail-open**(放行进入 SSR):避免数据库抖动把已登录的
|
||||
/// 管理员也踢到登录页;客户端 `AdminLayout` 仍有兜底校验。
|
||||
/// - `/admin` 与 `/login` 本就不进 `cache_control_for_path` 缓存,302 不会被缓存。
|
||||
pub(crate) async fn admin_guard(
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use crate::models::user::UserRole;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::Response;
|
||||
|
||||
let path = req.uri().path().to_string();
|
||||
if !path.starts_with("/admin") {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// 从 Cookie 头读 session token(与 export.rs / upload.rs 同款手法)。
|
||||
let cookie = req
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let token = crate::auth::session::parse_session_token(cookie);
|
||||
|
||||
let is_admin = match token {
|
||||
Some(t) => match crate::api::auth::get_user_by_token(t).await {
|
||||
Ok(Some(user)) => user.role == UserRole::Admin,
|
||||
// Err(DB 抖动)/ Ok(None)(token 无效):fail-open,交给客户端兜底。
|
||||
_ => true,
|
||||
},
|
||||
// 无 token:明确未登录,拦截。
|
||||
None => false,
|
||||
};
|
||||
|
||||
if is_admin {
|
||||
next.run(req).await
|
||||
} else {
|
||||
Response::builder()
|
||||
.status(StatusCode::FOUND)
|
||||
.header(header::LOCATION, "/login")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cache_control_for_path, parse_compression_algorithms, CompressionAlgorithms};
|
||||
use axum::http::Method;
|
||||
|
||||
fn cache_value(path: &str, method: Method) -> Option<String> {
|
||||
cache_control_for_path(path, &method).map(|v| v.to_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_page_is_cached() {
|
||||
assert_eq!(
|
||||
cache_value("/", Method::GET),
|
||||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/post/hello-world", Method::GET),
|
||||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/tags/rust", Method::GET),
|
||||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_assets_are_cached_long_term() {
|
||||
assert_eq!(
|
||||
cache_value("/style.css", Method::GET),
|
||||
Some("public, max-age=31536000, immutable".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/highlight.css", Method::GET),
|
||||
Some("public, max-age=31536000, immutable".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/wasm/app.wasm", Method::GET),
|
||||
Some("public, max-age=31536000, immutable".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_value("/_dioxus/assets/main.js", Method::GET),
|
||||
Some("public, max-age=31536000, immutable".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_and_admin_and_auth_are_not_cached() {
|
||||
assert_eq!(cache_value("/api/posts", Method::GET), None);
|
||||
assert_eq!(cache_value("/admin", Method::GET), None);
|
||||
assert_eq!(cache_value("/admin/posts", Method::GET), None);
|
||||
assert_eq!(cache_value("/login", Method::GET), None);
|
||||
assert_eq!(cache_value("/register", Method::GET), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_get_requests_are_not_cached() {
|
||||
assert_eq!(cache_value("/", Method::POST), None);
|
||||
assert_eq!(cache_value("/post/hello-world", Method::POST), None);
|
||||
assert_eq!(cache_value("/style.css", Method::POST), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_requests_are_cached_like_get() {
|
||||
assert_eq!(
|
||||
cache_value("/", Method::HEAD),
|
||||
Some("public, max-age=300, stale-while-revalidate=3600".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_all_enables_everything() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("all"),
|
||||
Some(CompressionAlgorithms::all_enabled())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_default_env_is_all() {
|
||||
// 模拟未设置环境变量时的默认值
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("all"),
|
||||
Some(CompressionAlgorithms::all_enabled())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_empty_none_off_disable() {
|
||||
assert_eq!(parse_compression_algorithms(""), None);
|
||||
assert_eq!(parse_compression_algorithms("none"), None);
|
||||
assert_eq!(parse_compression_algorithms("NONE"), None);
|
||||
assert_eq!(parse_compression_algorithms("off"), None);
|
||||
assert_eq!(parse_compression_algorithms("OFF"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_single_algorithm() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("gzip"),
|
||||
Some(CompressionAlgorithms {
|
||||
gzip: true,
|
||||
brotli: false,
|
||||
deflate: false,
|
||||
zstd: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("br"),
|
||||
Some(CompressionAlgorithms {
|
||||
gzip: false,
|
||||
brotli: true,
|
||||
deflate: false,
|
||||
zstd: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_multiple_algorithms() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("gzip, zstd"),
|
||||
Some(CompressionAlgorithms {
|
||||
gzip: true,
|
||||
brotli: false,
|
||||
deflate: false,
|
||||
zstd: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_case_insensitive_and_whitespace_tolerant() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("GZIP, Brotli, Deflate, Zstd"),
|
||||
Some(CompressionAlgorithms::all_enabled())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_compression_algorithms(" gzip , br , deflate , zstd "),
|
||||
Some(CompressionAlgorithms::all_enabled())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_unknown_algorithms_are_ignored() {
|
||||
assert_eq!(
|
||||
parse_compression_algorithms("gzip, unknown, lz4"),
|
||||
Some(CompressionAlgorithms {
|
||||
gzip: true,
|
||||
brotli: false,
|
||||
deflate: false,
|
||||
zstd: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
443
src/pages/admin/system/backup.rs
Normal file
443
src/pages/admin/system/backup.rs
Normal file
@ -0,0 +1,443 @@
|
||||
//! 备份恢复 tab。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::ui::{BTN_OUTLINE, BTN_TEXT_AMBER, BTN_TEXT_RED, LoadingButton};
|
||||
|
||||
use super::format_bytes;
|
||||
|
||||
/// 备份恢复 tab:备份按钮 + 进度轮询 + 备份列表(下载/恢复/删除)。
|
||||
#[allow(non_snake_case)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||
pub(super) fn BackupTab() -> Element {
|
||||
use crate::api::database::backup::BackupInfo;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::backup::{
|
||||
create_backup, delete_backup, list_backups, restore_backup,
|
||||
};
|
||||
use crate::api::database::tasks::TaskProgress;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::tasks::{get_task_progress, TaskStatus};
|
||||
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
|
||||
|
||||
// backups/active_task_id 仅在闭包内的重绑定副本上 .set()(如 backups_f),
|
||||
// 外层绑定本身不改值,故无需 mut。
|
||||
let backups = use_signal(Vec::<BackupInfo>::new);
|
||||
let mut loading = use_signal(|| false);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
// 当前进行中的任务(备份/恢复)id + 进度
|
||||
let active_task_id: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut active_progress = use_signal(|| Option::<TaskProgress>::None);
|
||||
let mut busy = use_signal(|| false);
|
||||
|
||||
// 刷新备份列表
|
||||
let mut refresh_list = move || {
|
||||
loading.set(true);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut backups = backups;
|
||||
let mut error = error;
|
||||
spawn(async move {
|
||||
match list_backups().await {
|
||||
Ok(list) => {
|
||||
backups.set(list);
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
loading.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
use_effect(move || {
|
||||
refresh_list();
|
||||
});
|
||||
|
||||
// 任务进度轮询:active_task_id 存在时每 1.5s 拉取进度,Done/Failed 后停止 + 刷新列表。
|
||||
//
|
||||
// 同样用长生命周期 loop + 循环内读 active_task_id() 的模式。原先在挂载时把
|
||||
// active_task_id 快照进 _task_id_for_poll(彼时为 None),use_future 只跑一次
|
||||
// 即 return;用户点"创建备份"后 create_backup 返回 task id 并设置信号,但
|
||||
// future 已结束 → 轮询永不启动,busy 永远为 true(用户报告的 bug)。
|
||||
use_future(move || {
|
||||
let mut active_task_id = active_task_id;
|
||||
let mut active_progress = active_progress;
|
||||
let mut backups_f = backups;
|
||||
let mut busy_f = busy;
|
||||
async move {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
loop {
|
||||
let tid = match active_task_id() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
// 空闲:短 yield,最多 200ms 后响应新任务。
|
||||
crate::utils::time::sleep_ms(200).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// 有任务在途:进入 1.5s 轮询,直到 Done/Failed/出错。
|
||||
loop {
|
||||
crate::utils::time::sleep_ms(1500).await;
|
||||
match get_task_progress(tid.clone()).await {
|
||||
Ok(p) => {
|
||||
let done =
|
||||
p.status == TaskStatus::Done || p.status == TaskStatus::Failed;
|
||||
active_progress.set(Some(p));
|
||||
if done {
|
||||
// 刷新列表(备份完成后新文件出现)并清理任务态
|
||||
if let Ok(list) = list_backups().await {
|
||||
backups_f.set(list);
|
||||
}
|
||||
active_task_id.set(None);
|
||||
busy_f.set(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
active_task_id.set(None);
|
||||
busy_f.set(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 内层 loop 退出后回到外层,继续等待下一个任务或空闲。
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let _ = (active_task_id, active_progress, backups_f, busy_f);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let current_backups = backups.read().clone();
|
||||
let current_error = error.read().clone();
|
||||
let current_progress = active_progress.read().clone();
|
||||
let is_busy = busy();
|
||||
// 预格式化备份行(避免在 rsx for 循环体内 let / 格式化)。
|
||||
// 每行:(filename, mode, size_str, dl_url)
|
||||
let backup_rows: Vec<(String, String, String, String)> = current_backups
|
||||
.iter()
|
||||
.map(|b| {
|
||||
(
|
||||
b.filename.clone(),
|
||||
b.mode.clone(),
|
||||
format_bytes(b.size_bytes as i64),
|
||||
format!("/api/database/backups/{}", urlencode_dl(&b.filename)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-4",
|
||||
// 操作栏
|
||||
div { class: "flex items-center gap-3",
|
||||
LoadingButton {
|
||||
label: "创建备份".to_string(),
|
||||
loading: is_busy,
|
||||
variant: "sm",
|
||||
onclick: move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
busy.set(true);
|
||||
active_progress.set(None);
|
||||
let mut active_task_id = active_task_id;
|
||||
spawn(async move {
|
||||
match create_backup().await {
|
||||
Ok(id) => active_task_id.set(Some(id)),
|
||||
Err(e) => {
|
||||
error.set(Some(e.to_string()));
|
||||
busy.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "{BTN_OUTLINE}",
|
||||
disabled: loading() || is_busy,
|
||||
onclick: move |_| refresh_list(),
|
||||
"刷新列表"
|
||||
}
|
||||
}
|
||||
|
||||
// 进度
|
||||
if let Some(p) = current_progress {
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
div { class: "flex items-center justify-between mb-2",
|
||||
span { class: "text-sm font-medium text-paper-primary", "{p.stage}" }
|
||||
span { class: "text-sm text-paper-secondary", "{p.percent}%" }
|
||||
}
|
||||
div { class: "w-full bg-paper-entry rounded-full h-2 overflow-hidden",
|
||||
div {
|
||||
class: "bg-paper-accent h-full transition-all",
|
||||
style: "width: {p.percent}%",
|
||||
}
|
||||
}
|
||||
if let Some(detail) = p.detail {
|
||||
p { class: "text-xs text-paper-secondary mt-2", "{detail}" }
|
||||
}
|
||||
if let Some(err) = p.error {
|
||||
p { class: "text-xs text-red-600 dark:text-red-400 mt-2",
|
||||
"错误:{err}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 错误
|
||||
if let Some(err) = current_error {
|
||||
div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-700 dark:text-red-300",
|
||||
"{err}"
|
||||
}
|
||||
}
|
||||
|
||||
// 备份列表
|
||||
if !current_backups.is_empty() {
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "overflow-x-auto",
|
||||
table { class: "w-full text-sm",
|
||||
thead {
|
||||
tr { class: "border-b border-paper-border text-left text-paper-secondary",
|
||||
th { class: "px-4 py-2 font-medium", "文件名" }
|
||||
th { class: "px-4 py-2 font-medium", "模式" }
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"大小"
|
||||
}
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for (fname, mode, size_str, dl_url) in backup_rows.iter() {
|
||||
BackupRow {
|
||||
key: "{fname}",
|
||||
filename: fname.clone(),
|
||||
mode: mode.clone(),
|
||||
size_str: size_str.clone(),
|
||||
dl_url: dl_url.clone(),
|
||||
busy: is_busy,
|
||||
// 恢复:确认已在 BackupRow 的 Popover 内完成,
|
||||
// 这里直接发起 restore_backup 并交由轮询 use_future 接管。
|
||||
// pending_restore signal + 确认 use_future 链路已移除
|
||||
//(原生 confirm 是阻塞式才需要那套间接机制)。
|
||||
on_restore: move |f: String| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut busy = busy;
|
||||
let mut active_progress = active_progress;
|
||||
let mut active_task_id = active_task_id;
|
||||
let mut error = error;
|
||||
spawn(async move {
|
||||
busy.set(true);
|
||||
active_progress.set(None);
|
||||
match restore_backup(f, true).await {
|
||||
Ok(id) => active_task_id.set(Some(id)),
|
||||
Err(e) => {
|
||||
error.set(Some(e.to_string()));
|
||||
busy.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
// 删除:确认已在 BackupRow 的 Popover 内完成,
|
||||
// 直接执行 delete_backup + 刷新列表。
|
||||
on_delete: move |fname_del: String| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut backups = backups;
|
||||
spawn(async move {
|
||||
let _ = delete_backup(fname_del).await;
|
||||
if let Ok(list) = list_backups().await {
|
||||
backups.set(list);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !loading() {
|
||||
div { class: "text-paper-secondary text-sm py-4", "暂无备份文件" }
|
||||
}
|
||||
|
||||
p { class: "text-xs text-paper-secondary",
|
||||
"备份优先用 pg_dump(含 schema),不可用时回退纯 SQL(仅数据)。"
|
||||
"恢复仅接受本系统生成的备份,且会覆盖现有数据。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 下载链接用的 URL 编码(wasm32 才编码,server 端原样返回——rsx 构造 dl_url 时两端都调)。
|
||||
/// 自包含实现,不跨文件依赖 export.rs 的 urlencode。
|
||||
fn urlencode_dl(s: &str) -> String {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
b' ' => out.push('+'),
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
/// 备份列表单行(抽取为子组件:各自 scope 内 let/clone 不冲突)。
|
||||
///
|
||||
/// 删除/恢复不再用浏览器原生 confirm(),改用 [`Popover`](crate::components::ui::Popover) 确认框(`position:fixed`
|
||||
/// 逃出表格 `overflow-hidden`)。点击按钮读 `MouseEvent::client_coordinates()` 作为
|
||||
/// popover 锚点,`confirm` 按钮回调父组件的 `on_delete`/`on_restore`。
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
struct BackupRowProps {
|
||||
filename: String,
|
||||
mode: String,
|
||||
size_str: String,
|
||||
dl_url: String,
|
||||
busy: bool,
|
||||
on_restore: Callback<String>,
|
||||
on_delete: Callback<String>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||
fn BackupRow(props: BackupRowProps) -> Element {
|
||||
use crate::components::ui::Popover;
|
||||
use crate::components::ui::BTN_DANGER_OUTLINE;
|
||||
|
||||
// Callback 是 Copy,直接复用;filename 需 clone(确认框闭包各取一份)。
|
||||
let on_restore = props.on_restore;
|
||||
let on_delete = props.on_delete;
|
||||
let fname_for_restore = props.filename.clone();
|
||||
let fname_for_delete = props.filename.clone();
|
||||
|
||||
// Popover 状态:哪个动作的确认框打开 + 锚点坐标。None = 都关闭。
|
||||
// 用一个 String("delete"/"restore") 而非两个 bool,避免同时开两个 popover。
|
||||
let mut open_action = use_signal(|| Option::<String>::None);
|
||||
// 锚点坐标:按钮点击的视口坐标(client_coordinates)。
|
||||
let mut anchor_x = use_signal(|| 0i32);
|
||||
let mut anchor_y = use_signal(|| 0i32);
|
||||
|
||||
rsx! {
|
||||
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
|
||||
td { class: "px-4 py-2 font-mono text-xs text-paper-primary", "{props.filename}" }
|
||||
td { class: "px-4 py-2 text-paper-secondary", "{props.mode}" }
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary", "{props.size_str}" }
|
||||
td { class: "px-4 py-2 text-right whitespace-nowrap",
|
||||
a {
|
||||
class: "text-xs text-paper-accent hover:underline mr-3",
|
||||
href: "{props.dl_url}",
|
||||
download: "",
|
||||
"下载"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_TEXT_AMBER} mr-3 disabled:opacity-50",
|
||||
disabled: props.busy,
|
||||
// 点击记录坐标并打开恢复确认 popover。client_coordinates 两端编译。
|
||||
onclick: move |e| {
|
||||
let c = e.client_coordinates();
|
||||
anchor_x.set(c.x as i32);
|
||||
anchor_y.set(c.y as i32);
|
||||
open_action.set(Some("restore".to_string()));
|
||||
},
|
||||
"恢复"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_TEXT_RED} disabled:opacity-50",
|
||||
disabled: props.busy,
|
||||
onclick: move |e| {
|
||||
let c = e.client_coordinates();
|
||||
anchor_x.set(c.x as i32);
|
||||
anchor_y.set(c.y as i32);
|
||||
open_action.set(Some("delete".to_string()));
|
||||
},
|
||||
"删除"
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复确认 popover
|
||||
Popover {
|
||||
open: open_action().as_deref() == Some("restore"),
|
||||
anchor_x: anchor_x(),
|
||||
anchor_y: anchor_y(),
|
||||
placement: "bottom",
|
||||
on_close: move |_| open_action.set(None),
|
||||
div { class: "w-64 space-y-3",
|
||||
p { class: "text-sm text-paper-primary leading-relaxed",
|
||||
"恢复将覆盖现有数据,确认恢复 "
|
||||
span { class: "font-mono text-xs break-all", "{props.filename}" }
|
||||
"?"
|
||||
}
|
||||
p { class: "text-xs text-paper-secondary",
|
||||
"仅本系统生成的备份可恢复。"
|
||||
}
|
||||
div { class: "flex justify-end gap-2 pt-1",
|
||||
button {
|
||||
class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
|
||||
onclick: move |_| open_action.set(None),
|
||||
"取消"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_DANGER_OUTLINE}",
|
||||
onclick: move |_| {
|
||||
open_action.set(None);
|
||||
on_restore.call(fname_for_restore.clone());
|
||||
},
|
||||
"确认恢复"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除确认 popover
|
||||
Popover {
|
||||
open: open_action().as_deref() == Some("delete"),
|
||||
anchor_x: anchor_x(),
|
||||
anchor_y: anchor_y(),
|
||||
placement: "bottom",
|
||||
on_close: move |_| open_action.set(None),
|
||||
div { class: "w-64 space-y-3",
|
||||
p { class: "text-sm text-paper-primary",
|
||||
"确认删除 "
|
||||
span { class: "font-mono text-xs break-all", "{props.filename}" }
|
||||
"?"
|
||||
}
|
||||
div { class: "flex justify-end gap-2 pt-1",
|
||||
button {
|
||||
class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
|
||||
onclick: move |_| open_action.set(None),
|
||||
"取消"
|
||||
}
|
||||
button {
|
||||
class: "{BTN_DANGER_OUTLINE}",
|
||||
onclick: move |_| {
|
||||
open_action.set(None);
|
||||
on_delete.call(fname_for_delete.clone());
|
||||
},
|
||||
"确认删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
371
src/pages/admin/system/db_status.rs
Normal file
371
src/pages/admin/system/db_status.rs
Normal file
@ -0,0 +1,371 @@
|
||||
//! 数据库状态 tab。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::skeletons::atoms::SkeletonBox;
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::components::ui::LoadingButton;
|
||||
|
||||
use super::format_bytes;
|
||||
|
||||
/// 数据库状态 tab:概览卡片 + 表清单 + 索引 Top + 活跃连接。
|
||||
/// 手动刷新按钮 + 自动刷新开关(1s/2s/5s/30s/手动,默认手动)。
|
||||
#[allow(non_snake_case)]
|
||||
// status/error/loading 在 spawn/onclick 闭包里 .set(),仅 WASM 前端真正用到;
|
||||
// server 构建里这些 set 调用都在被剥离的 #[cfg(wasm32)] 块内,故 allow unused_mut。
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||
pub(super) fn DbStatusTab() -> Element {
|
||||
use crate::api::database::status::DbStatus;
|
||||
// get_db_status 只在 WASM 前端调用,server 构建时该 server function 的客户端桩不需要导入。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::status::get_db_status;
|
||||
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
|
||||
|
||||
// Signal 是 Copy,可在多个 spawn/effect 中捕获同一副本;set 走内部可变(&self)。
|
||||
let mut status = use_signal(|| Option::<DbStatus>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
// 自动刷新间隔(秒);None = 手动。DB 查询有成本,最低 1s。
|
||||
let mut refresh_interval: Signal<Option<u32>> = use_signal(|| None);
|
||||
|
||||
// 数据加载:WASM 前端 spawn 请求,SSR 直接结束加载。
|
||||
// 因 Signal 是 Copy,每次 spawn 各自捕获副本即可,无需共享闭包。
|
||||
let mut load_once = move || {
|
||||
loading.set(true);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let t0 = crate::utils::time::now_millis();
|
||||
web_sys::console::log_1(&format!("[DEBUG-db] fetch start at {t0}").into());
|
||||
spawn(async move {
|
||||
match get_db_status().await {
|
||||
Ok(s) => {
|
||||
let t1 = crate::utils::time::now_millis();
|
||||
web_sys::console::log_1(
|
||||
&format!("[DEBUG-db] fetch done at {t1} (Δ={}ms)", t1 - t0).into(),
|
||||
);
|
||||
status.set(Some(s));
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
loading.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 首次加载
|
||||
use_effect(move || {
|
||||
load_once();
|
||||
});
|
||||
|
||||
// 自动刷新:使用官方推荐模式——一个永不重建的长生命周期 loop,在每次循环
|
||||
// 内部读取 refresh_interval 的当前值,自然响应间隔切换。
|
||||
// 旧做法在闭包同步体内读 status/loading/error signal,导致这些 signal 每次
|
||||
// .set() 后都触发 use_future 重建,产生多个并发 loop(请求爆炸)。
|
||||
use_future(move || async move {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
loop {
|
||||
// 每次循环读最新 interval(signal 的 Copy 语义,直接调用即可)。
|
||||
let secs = refresh_interval().unwrap_or(0);
|
||||
if secs == 0 {
|
||||
// 手动模式:短暂 yield,让事件循环呼吸,避免忙等;
|
||||
// 用户切换到自动模式后最多等 200ms 即响应。
|
||||
crate::utils::time::sleep_ms(200).await;
|
||||
continue;
|
||||
}
|
||||
crate::utils::time::sleep_ms(secs * 1000).await;
|
||||
// 二次检查:sleep 期间用户可能切回手动。
|
||||
if refresh_interval().is_none() {
|
||||
continue;
|
||||
}
|
||||
loading.set(true);
|
||||
spawn(async move {
|
||||
match get_db_status().await {
|
||||
Ok(s) => {
|
||||
status.set(Some(s));
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let _ = (status, loading, error, refresh_interval);
|
||||
}
|
||||
});
|
||||
|
||||
// Option<DbStatus> 非 Copy,读出来克隆一份供 rsx 消费。
|
||||
let current = status.read().clone();
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-6",
|
||||
// 工具栏:刷新按钮 + 自动刷新开关
|
||||
div { class: "flex items-center justify-between",
|
||||
LoadingButton {
|
||||
label: "刷新".to_string(),
|
||||
loading: loading(),
|
||||
variant: "sm",
|
||||
onclick: move |_| {
|
||||
loading.set(true);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
spawn(async move {
|
||||
match get_db_status().await {
|
||||
Ok(s) => {
|
||||
status.set(Some(s));
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
loading.set(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
div { class: "flex items-center gap-2",
|
||||
span { class: "text-sm text-paper-secondary", "自动刷新" }
|
||||
select {
|
||||
class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary",
|
||||
value: "{refresh_interval().map(|s| s.to_string()).unwrap_or_default()}",
|
||||
onchange: move |e| {
|
||||
let v = e.value();
|
||||
refresh_interval
|
||||
.set(
|
||||
match v.as_str() {
|
||||
"1" => Some(1),
|
||||
"2" => Some(2),
|
||||
"5" => Some(5),
|
||||
"30" => Some(30),
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
},
|
||||
option { value: "", "手动" }
|
||||
option { value: "1", "1s" }
|
||||
option { value: "2", "2s" }
|
||||
option { value: "5", "5s" }
|
||||
option { value: "30", "30s" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = error.read().clone() {
|
||||
div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-sm text-red-700 dark:text-red-300",
|
||||
"加载失败:{err}"
|
||||
}
|
||||
} else if let Some(s) = current {
|
||||
// 概览卡片
|
||||
div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "数据库总大小" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{format_bytes(s.db_size_bytes)}"
|
||||
}
|
||||
}
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "连接数" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{s.total_connections} / {s.max_connections}"
|
||||
}
|
||||
}
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "表数量" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{s.tables.len()}"
|
||||
}
|
||||
}
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "迁移版本" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary truncate",
|
||||
{s.migration_version.clone().unwrap_or_else(|| "—".to_string())}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表清单(小表显示真实行数;大表回退估算,行数前标 ~)
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
|
||||
"表清单(行数:小表为真实值,大表标 ~ 为估算)"
|
||||
}
|
||||
div { class: "overflow-x-auto",
|
||||
table { class: "w-full text-sm",
|
||||
thead {
|
||||
tr { class: "border-b border-paper-border text-left text-paper-secondary",
|
||||
th { class: "px-4 py-2 font-medium", "表名" }
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"行数"
|
||||
}
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"表大小"
|
||||
}
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"索引大小"
|
||||
}
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"总大小"
|
||||
}
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"死元组"
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for t in s.tables.iter() {
|
||||
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
|
||||
td { class: "px-4 py-2 font-mono text-paper-primary",
|
||||
"{t.name}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary",
|
||||
if t.row_count_estimated {
|
||||
"~{t.row_count}"
|
||||
} else {
|
||||
"{t.row_count}"
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary",
|
||||
"{format_bytes(t.table_size_bytes)}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary",
|
||||
"{format_bytes(t.index_size_bytes)}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-primary font-medium",
|
||||
"{format_bytes(t.total_size_bytes)}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary",
|
||||
"{t.dead_tuples}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 索引占用 Top
|
||||
if !s.top_indexes.is_empty() {
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
|
||||
"索引占用 Top 10"
|
||||
}
|
||||
div { class: "overflow-x-auto",
|
||||
table { class: "w-full text-sm",
|
||||
thead {
|
||||
tr { class: "border-b border-paper-border text-left text-paper-secondary",
|
||||
th { class: "px-4 py-2 font-medium", "索引名" }
|
||||
th { class: "px-4 py-2 font-medium", "所属表" }
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"大小"
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for i in s.top_indexes.iter() {
|
||||
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
|
||||
td { class: "px-4 py-2 font-mono text-paper-primary",
|
||||
"{i.name}"
|
||||
}
|
||||
td { class: "px-4 py-2 font-mono text-paper-secondary",
|
||||
"{i.table_name}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary",
|
||||
"{format_bytes(i.size_bytes)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 活跃连接
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
|
||||
"活跃连接({s.active_connections.len()})"
|
||||
}
|
||||
div { class: "overflow-x-auto",
|
||||
table { class: "w-full text-sm",
|
||||
thead {
|
||||
tr { class: "border-b border-paper-border text-left text-paper-secondary",
|
||||
th { class: "px-4 py-2 font-medium", "PID" }
|
||||
th { class: "px-4 py-2 font-medium", "用户" }
|
||||
th { class: "px-4 py-2 font-medium", "状态" }
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"时长(秒)"
|
||||
}
|
||||
th { class: "px-4 py-2 font-medium", "查询" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for c in s.active_connections.iter() {
|
||||
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
|
||||
td { class: "px-4 py-2 text-paper-secondary",
|
||||
"{c.pid}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-paper-secondary",
|
||||
"{c.user}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-paper-secondary",
|
||||
{c.state.clone().unwrap_or_else(|| "—".to_string())}
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary",
|
||||
{
|
||||
c.query_duration_secs
|
||||
.map(|d| format!("{:.1}", d))
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
}
|
||||
td { class: "px-4 py-2 font-mono text-xs text-paper-secondary max-w-md truncate",
|
||||
{c.query.clone().unwrap_or_else(|| "—".to_string())}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if loading() {
|
||||
// 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。
|
||||
DelayedSkeleton {
|
||||
div { class: "space-y-4",
|
||||
// 概览卡片骨架
|
||||
div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
|
||||
for _ in 0..4 {
|
||||
div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
|
||||
SkeletonBox { class: "h-3 w-16 rounded" }
|
||||
SkeletonBox { class: "h-6 w-24 rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
// 表清单骨架
|
||||
div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden",
|
||||
div { class: "px-4 py-3 border-b border-paper-border",
|
||||
SkeletonBox { class: "h-4 w-40 rounded" }
|
||||
}
|
||||
for _ in 0..5 {
|
||||
div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0",
|
||||
SkeletonBox { class: "h-4 w-24 rounded" }
|
||||
SkeletonBox { class: "h-4 w-16 rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
div { class: "text-paper-secondary py-8", "暂无数据" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
141
src/pages/admin/system/export.rs
Normal file
141
src/pages/admin/system/export.rs
Normal file
@ -0,0 +1,141 @@
|
||||
//! 数据导出 tab。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::ui::BTN_PRIMARY_SM;
|
||||
|
||||
/// 数据导出 tab:按表/按查询导出 SQL/CSV,走 Axum 流式下载。
|
||||
#[allow(non_snake_case)]
|
||||
pub(super) fn ExportTab() -> Element {
|
||||
use crate::components::ui::ADMIN_CARD_CLASS;
|
||||
// 导出模式:"table" / "query"
|
||||
let mut mode = use_signal(|| "table".to_string());
|
||||
let mut table_name = use_signal(String::new);
|
||||
let mut query = use_signal(String::new);
|
||||
let mut format = use_signal(|| "csv".to_string());
|
||||
let mut include_columns = use_signal(|| true);
|
||||
|
||||
// 触发下载:构造 GET /api/database/export?... URL 并打开
|
||||
let do_export = move || {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let source = if mode().as_str() == "table" {
|
||||
format!("table:{}", table_name.read().trim())
|
||||
} else {
|
||||
format!("query:{}", query.read())
|
||||
};
|
||||
let url = format!(
|
||||
"/api/database/export?source={}&format={}&include_columns={}",
|
||||
urlencode(&source),
|
||||
format(),
|
||||
include_columns(),
|
||||
);
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.open_with_url(&url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-4",
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4 space-y-4",
|
||||
// 模式选择
|
||||
div { class: "flex items-center gap-4",
|
||||
label { class: "flex items-center gap-2 text-sm text-paper-primary",
|
||||
input {
|
||||
r#type: "radio",
|
||||
name: "export-mode",
|
||||
checked: mode() == "table",
|
||||
onchange: move |_| mode.set("table".to_string()),
|
||||
}
|
||||
"按表导出"
|
||||
}
|
||||
label { class: "flex items-center gap-2 text-sm text-paper-primary",
|
||||
input {
|
||||
r#type: "radio",
|
||||
name: "export-mode",
|
||||
checked: mode() == "query",
|
||||
onchange: move |_| mode.set("query".to_string()),
|
||||
}
|
||||
"按查询导出"
|
||||
}
|
||||
}
|
||||
|
||||
// 表名输入
|
||||
if mode().as_str() == "table" {
|
||||
div {
|
||||
label { class: "block text-sm text-paper-secondary mb-1", "表名" }
|
||||
input {
|
||||
r#type: "text",
|
||||
class: "w-full px-3 py-2 text-sm border border-paper-border rounded bg-paper-theme text-paper-primary font-mono",
|
||||
placeholder: "如 posts",
|
||||
value: "{table_name}",
|
||||
oninput: move |e| table_name.set(e.value()),
|
||||
}
|
||||
p { class: "text-xs text-paper-secondary mt-1",
|
||||
"仅支持 public schema 下的用户表,表名需为合法标识符"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 查询输入
|
||||
div {
|
||||
label { class: "block text-sm text-paper-secondary mb-1",
|
||||
"SELECT 查询(只读)"
|
||||
}
|
||||
textarea {
|
||||
class: "w-full px-3 py-2 text-sm border border-paper-border rounded bg-paper-theme text-paper-primary font-mono",
|
||||
rows: "4",
|
||||
placeholder: "SELECT id, title FROM posts WHERE published = true",
|
||||
value: "{query}",
|
||||
oninput: move |e| query.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式 + 选项
|
||||
div { class: "flex flex-wrap items-center gap-4",
|
||||
div { class: "flex items-center gap-2",
|
||||
span { class: "text-sm text-paper-secondary", "格式" }
|
||||
select {
|
||||
class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary",
|
||||
value: "{format}",
|
||||
onchange: move |e| format.set(e.value()),
|
||||
option { value: "csv", "CSV" }
|
||||
option { value: "sql", "SQL (INSERT)" }
|
||||
}
|
||||
}
|
||||
label { class: "flex items-center gap-1 text-sm text-paper-secondary",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "mr-1",
|
||||
checked: include_columns(),
|
||||
onchange: move |e| include_columns.set(e.checked()),
|
||||
}
|
||||
"包含列名(CSV 表头 / INSERT 列清单)"
|
||||
}
|
||||
}
|
||||
|
||||
button { class: "{BTN_PRIMARY_SM}", onclick: move |_| do_export(), "导出并下载" }
|
||||
}
|
||||
p { class: "text-xs text-paper-secondary",
|
||||
"导出走流式响应,大表不会占满内存。SQL 格式仅含 INSERT 语句(不含 DDL/schema)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 简易 URL 编码(避免引入新依赖;仅编码导出参数里的特殊字符)。
|
||||
/// 仅在 WASM 前端的导出按钮里用。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn urlencode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
b' ' => out.push('+'),
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
184
src/pages/admin/system/mod.rs
Normal file
184
src/pages/admin/system/mod.rs
Normal file
@ -0,0 +1,184 @@
|
||||
//! 后台系统管理页面(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)。
|
||||
//!
|
||||
//! 用顶部 tab 切换 5 个功能,tab 状态用 `use_signal`(不深链 / 不走分页路由)。
|
||||
//! 各 tab 拆分为独立子模块,状态完全独立、互不共享——切换 tab 时父组件用 `key`
|
||||
//! 强制卸载旧 tab 组件,其内部 signal 随之销毁。
|
||||
|
||||
mod backup;
|
||||
mod db_status;
|
||||
mod export;
|
||||
mod server_status;
|
||||
mod sql_console;
|
||||
|
||||
use backup::BackupTab;
|
||||
use db_status::DbStatusTab;
|
||||
use dioxus::prelude::*;
|
||||
use export::ExportTab;
|
||||
use server_status::ServerStatusTab;
|
||||
use sql_console::SqlConsoleTab;
|
||||
|
||||
use crate::components::ui::FilterTabs;
|
||||
|
||||
/// 系统管理的 5 个功能 tab。
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum SystemTab {
|
||||
/// 数据库运行状态(表/连接/死元组/迁移版本)。
|
||||
DbStatus,
|
||||
/// 服务器状态(应用内 + 主机层 CPU/内存/磁盘)。
|
||||
ServerStatus,
|
||||
/// SQL 控制台(全读写 + 护栏)。
|
||||
SqlConsole,
|
||||
/// 数据导出(SQL/CSV 流式下载)。
|
||||
Export,
|
||||
/// 备份恢复(pg_dump + 任务进度)。
|
||||
Backup,
|
||||
}
|
||||
|
||||
impl SystemTab {
|
||||
/// 变体 → 稳定字符串 key(用于与基于 String 的 `FilterTabs` 组件桥接)。
|
||||
/// 改这些 key 会破坏潜在的持久化/调试场景,见 `from_str` 的反向映射。
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SystemTab::DbStatus => "db_status",
|
||||
SystemTab::ServerStatus => "server_status",
|
||||
SystemTab::SqlConsole => "sql_console",
|
||||
SystemTab::Export => "export",
|
||||
SystemTab::Backup => "backup",
|
||||
}
|
||||
}
|
||||
|
||||
/// 字符串 key → 变体。未知/空串返回 Err(调用方 fallback 到默认 tab)。
|
||||
/// 与 `as_str` 严格对应;大小写敏感。
|
||||
fn from_str(s: &str) -> Result<SystemTab, &'static str> {
|
||||
match s {
|
||||
"db_status" => Ok(SystemTab::DbStatus),
|
||||
"server_status" => Ok(SystemTab::ServerStatus),
|
||||
"sql_console" => Ok(SystemTab::SqlConsole),
|
||||
"export" => Ok(SystemTab::Export),
|
||||
"backup" => Ok(SystemTab::Backup),
|
||||
_ => Err("unknown tab key"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统管理入口组件。
|
||||
#[component]
|
||||
pub fn System() -> Element {
|
||||
// tab 状态:默认进第一个 tab(数据库状态)。用 signal 而非 URL query——
|
||||
// tab 切换无需深链/书签,避免新增路由变体。
|
||||
let mut active_tab = use_signal(|| SystemTab::DbStatus);
|
||||
|
||||
rsx! {
|
||||
div { class: "w-full max-w-7xl mx-auto space-y-6",
|
||||
// 页面标题
|
||||
div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-[var(--color-paper-border)] mb-6",
|
||||
div {
|
||||
h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
|
||||
"系统面板"
|
||||
}
|
||||
p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
|
||||
"数据库与服务器诊断"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部 tab 切换栏:复用公共 FilterTabs 组件(String API,经 as_str/from_str 桥接枚举)。
|
||||
// 视觉与评论页一致:平滑滑动指示条 + 选中文字 text-paper-primary。
|
||||
FilterTabs {
|
||||
items: vec![
|
||||
("db_status", "数据库状态"),
|
||||
("server_status", "服务器状态"),
|
||||
("sql_console", "SQL 控制台"),
|
||||
("export", "数据导出"),
|
||||
("backup", "备份恢复"),
|
||||
],
|
||||
active_value: active_tab().as_str().to_string(),
|
||||
on_change: move |v: String| {
|
||||
// 未知 key fallback 到默认 tab,保证状态始终有效。
|
||||
active_tab.set(SystemTab::from_str(&v).unwrap_or(SystemTab::DbStatus));
|
||||
},
|
||||
}
|
||||
|
||||
// tab 内容
|
||||
// key 保证切换 tab 时 Dioxus 完全卸载旧组件、重新挂载新组件,
|
||||
// 避免 hook slot 复用导致 DelayedSkeleton 的 visible 信号残留为 true。
|
||||
div { key: "{active_tab().as_str()}",
|
||||
match active_tab() {
|
||||
SystemTab::DbStatus => rsx! {
|
||||
DbStatusTab {}
|
||||
},
|
||||
SystemTab::ServerStatus => rsx! {
|
||||
ServerStatusTab {}
|
||||
},
|
||||
SystemTab::SqlConsole => rsx! {
|
||||
SqlConsoleTab {}
|
||||
},
|
||||
SystemTab::Export => rsx! {
|
||||
ExportTab {}
|
||||
},
|
||||
SystemTab::Backup => rsx! {
|
||||
BackupTab {}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节数 → 人类可读(如 1.2 MB)。db_status / server_status / backup 三个 tab 共用。
|
||||
pub(super) fn format_bytes(bytes: i64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
||||
let mut size = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while size.abs() >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{} {}", bytes, UNITS[0])
|
||||
} else {
|
||||
format!("{:.2} {}", size, UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SystemTab;
|
||||
|
||||
#[test]
|
||||
fn as_str_roundtrips_all_variants() {
|
||||
// 每个变体经 as_str -> from_str 必须还原为自身。
|
||||
for tab in [
|
||||
SystemTab::DbStatus,
|
||||
SystemTab::ServerStatus,
|
||||
SystemTab::SqlConsole,
|
||||
SystemTab::Export,
|
||||
SystemTab::Backup,
|
||||
] {
|
||||
let s = tab.as_str();
|
||||
assert_eq!(
|
||||
SystemTab::from_str(s),
|
||||
Ok(tab),
|
||||
"roundtrip failed for {tab:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_str_returns_stable_keys() {
|
||||
// 字符串 key 必须稳定(改 key 会破坏 URL/调试/未来持久化),锁定之。
|
||||
assert_eq!(SystemTab::DbStatus.as_str(), "db_status");
|
||||
assert_eq!(SystemTab::ServerStatus.as_str(), "server_status");
|
||||
assert_eq!(SystemTab::SqlConsole.as_str(), "sql_console");
|
||||
assert_eq!(SystemTab::Export.as_str(), "export");
|
||||
assert_eq!(SystemTab::Backup.as_str(), "backup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_rejects_unknown_and_empty() {
|
||||
assert!(SystemTab::from_str("nonsense").is_err());
|
||||
assert!(SystemTab::from_str("").is_err());
|
||||
// 大小写敏感:不接受大写变体,避免歧义。
|
||||
assert!(SystemTab::from_str("DbStatus").is_err());
|
||||
}
|
||||
}
|
||||
339
src/pages/admin/system/server_status.rs
Normal file
339
src/pages/admin/system/server_status.rs
Normal file
@ -0,0 +1,339 @@
|
||||
//! 服务器状态 tab。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::skeletons::atoms::SkeletonBox;
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::components::ui::LoadingButton;
|
||||
|
||||
use super::format_bytes;
|
||||
|
||||
/// 秒数 → 人类可读运行时间(如 1d 2h 3m)。
|
||||
fn format_uptime(secs: u64) -> String {
|
||||
let d = secs / 86400;
|
||||
let h = (secs % 86400) / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
if d > 0 {
|
||||
format!("{d}d {h}h {m}m")
|
||||
} else if h > 0 {
|
||||
format!("{h}h {m}m")
|
||||
} else if m > 0 {
|
||||
format!("{m}m")
|
||||
} else {
|
||||
format!("{secs}s")
|
||||
}
|
||||
}
|
||||
/// 服务器状态 tab:应用内指标(连接池/会话/缓存命中率)+ 主机层(CPU/内存/磁盘)。
|
||||
/// 手动刷新 + 自动刷新开关(500ms/1s/2s/5s/手动,默认手动)。
|
||||
/// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。
|
||||
#[allow(non_snake_case)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||
pub(super) fn ServerStatusTab() -> Element {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::system_status::get_server_status;
|
||||
use crate::api::database::system_status::ServerStatus;
|
||||
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
|
||||
|
||||
let mut status = use_signal(|| Option::<ServerStatus>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
// 自动刷新间隔(毫秒);None = 手动。主机层后台采样,前端可高频轮询。
|
||||
let mut refresh_ms: Signal<Option<u32>> = use_signal(|| None);
|
||||
|
||||
let mut load_once = move || {
|
||||
loading.set(true);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
spawn(async move {
|
||||
match get_server_status().await {
|
||||
Ok(s) => {
|
||||
status.set(Some(s));
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
loading.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
use_effect(move || {
|
||||
load_once();
|
||||
});
|
||||
|
||||
// 自动刷新:同 DbStatusTab,采用官方推荐的单一长生命周期 loop 模式。
|
||||
// 闭包体内不读任何 signal(避免隐式依赖追踪导致重建),loop 内部实时读取。
|
||||
use_future(move || async move {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
loop {
|
||||
let ms = refresh_ms().unwrap_or(0);
|
||||
if ms == 0 {
|
||||
crate::utils::time::sleep_ms(200).await;
|
||||
continue;
|
||||
}
|
||||
crate::utils::time::sleep_ms(ms).await;
|
||||
if refresh_ms().is_none() {
|
||||
continue;
|
||||
}
|
||||
loading.set(true);
|
||||
spawn(async move {
|
||||
match get_server_status().await {
|
||||
Ok(s) => {
|
||||
status.set(Some(s));
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let _ = (status, loading, error, refresh_ms);
|
||||
}
|
||||
});
|
||||
|
||||
let current = status.read().clone();
|
||||
// rsx 不支持格式说明符({:.1}),也不允许在 for 循环体内 let,故预格式化所有展示值。
|
||||
let cpu_pct = current
|
||||
.as_ref()
|
||||
.map(|s| format!("{:.1}%", s.host.cpu_usage))
|
||||
.unwrap_or_default();
|
||||
let load_1 = current
|
||||
.as_ref()
|
||||
.map(|s| format!("{:.2}", s.host.load_avg_1))
|
||||
.unwrap_or_default();
|
||||
// 缓存表预格式化:把每行需要展示的值都算好字符串,避免在 rsx 里做格式化。
|
||||
let cache_rows: Vec<(String, u64, u64, u64, String)> = current
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
s.caches
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c.name.clone(),
|
||||
c.entry_count,
|
||||
c.hits,
|
||||
c.misses,
|
||||
format!("{:.1}%", c.hit_rate * 100.0),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-6",
|
||||
div { class: "flex items-center justify-between",
|
||||
LoadingButton {
|
||||
label: "刷新".to_string(),
|
||||
loading: loading(),
|
||||
variant: "sm",
|
||||
onclick: move |_| {
|
||||
loading.set(true);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
spawn(async move {
|
||||
match get_server_status().await {
|
||||
Ok(s) => {
|
||||
status.set(Some(s));
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
loading.set(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
div { class: "flex items-center gap-2",
|
||||
span { class: "text-sm text-paper-secondary", "自动刷新" }
|
||||
select {
|
||||
class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary",
|
||||
value: "{refresh_ms().map(|s| s.to_string()).unwrap_or_default()}",
|
||||
onchange: move |e| {
|
||||
let v = e.value();
|
||||
refresh_ms
|
||||
.set(
|
||||
match v.as_str() {
|
||||
"500" => Some(500),
|
||||
"1000" => Some(1000),
|
||||
"2000" => Some(2000),
|
||||
"5000" => Some(5000),
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
},
|
||||
option { value: "", "手动" }
|
||||
option { value: "500", "500ms" }
|
||||
option { value: "1000", "1s" }
|
||||
option { value: "2000", "2s" }
|
||||
option { value: "5000", "5s" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = error.read().clone() {
|
||||
div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-sm text-red-700 dark:text-red-300",
|
||||
"加载失败:{err}"
|
||||
}
|
||||
} else if let Some(s) = current {
|
||||
// 应用内指标卡片
|
||||
div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "运行时间" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{format_uptime(s.uptime_secs)}"
|
||||
}
|
||||
}
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "DB 连接池" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{s.pool_size} / {s.pool_max_size}"
|
||||
}
|
||||
p { class: "text-xs text-paper-secondary",
|
||||
"空闲 {s.pool_available} · 等待 {s.pool_waiting}"
|
||||
}
|
||||
}
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "活跃会话" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{s.active_sessions}"
|
||||
}
|
||||
}
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "CPU" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{cpu_pct}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主机层指标卡片
|
||||
div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "内存" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{format_bytes(s.host.used_memory as i64)} / {format_bytes(s.host.total_memory as i64)}"
|
||||
}
|
||||
}
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "磁盘" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{format_bytes((s.host.disk_total - s.host.disk_available) as i64)} / {format_bytes(s.host.disk_total as i64)}"
|
||||
}
|
||||
}
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "Load (1m)" }
|
||||
p { class: "mt-1 text-lg font-semibold text-paper-primary",
|
||||
"{load_1}"
|
||||
}
|
||||
}
|
||||
div { class: "{ADMIN_CARD_CLASS} p-4",
|
||||
p { class: "text-xs text-paper-secondary", "系统" }
|
||||
p { class: "mt-1 text-sm font-medium text-paper-primary truncate",
|
||||
"{s.host.os_name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存命中率表
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
|
||||
"缓存命中率"
|
||||
}
|
||||
div { class: "overflow-x-auto",
|
||||
table { class: "w-full text-sm",
|
||||
thead {
|
||||
tr { class: "border-b border-paper-border text-left text-paper-secondary",
|
||||
th { class: "px-4 py-2 font-medium", "缓存" }
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"条目"
|
||||
}
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"命中"
|
||||
}
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"未命中"
|
||||
}
|
||||
th { class: "px-4 py-2 font-medium text-right",
|
||||
"命中率"
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for (name, entry_count, hits, misses, rate_pct) in cache_rows.iter() {
|
||||
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
|
||||
td { class: "px-4 py-2 text-paper-primary",
|
||||
"{name}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary",
|
||||
"{entry_count}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary",
|
||||
"{hits}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-secondary",
|
||||
"{misses}"
|
||||
}
|
||||
td { class: "px-4 py-2 text-right text-paper-primary font-medium",
|
||||
"{rate_pct}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if loading() {
|
||||
// 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。
|
||||
DelayedSkeleton {
|
||||
div { class: "space-y-4",
|
||||
// 应用内指标卡片骨架
|
||||
div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
|
||||
for _ in 0..4 {
|
||||
div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
|
||||
SkeletonBox { class: "h-3 w-16 rounded" }
|
||||
SkeletonBox { class: "h-6 w-24 rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
// 主机层指标卡片骨架
|
||||
div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
|
||||
for _ in 0..4 {
|
||||
div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
|
||||
SkeletonBox { class: "h-3 w-16 rounded" }
|
||||
SkeletonBox { class: "h-6 w-24 rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
// 缓存命中率表骨架
|
||||
div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden",
|
||||
div { class: "px-4 py-3 border-b border-paper-border",
|
||||
SkeletonBox { class: "h-4 w-24 rounded" }
|
||||
}
|
||||
for _ in 0..4 {
|
||||
div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0",
|
||||
SkeletonBox { class: "h-4 w-20 rounded" }
|
||||
SkeletonBox { class: "h-4 w-12 rounded" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
div { class: "text-paper-secondary py-8", "暂无数据" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
364
src/pages/admin/system/sql_console.rs
Normal file
364
src/pages/admin/system/sql_console.rs
Normal file
@ -0,0 +1,364 @@
|
||||
//! SQL 控制台 tab。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::ui::LoadingButton;
|
||||
|
||||
/// SQL 控制台 tab:CodeMirror 编辑器(SQL 高亮/补全/Vim)+ 4 道护栏 + 结果表 + EXPLAIN。
|
||||
///
|
||||
/// 护栏 4(前端二次确认):提交写操作前弹窗确认。
|
||||
#[allow(non_snake_case)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||
pub(super) fn SqlConsoleTab() -> Element {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::schema::get_db_schema;
|
||||
use crate::api::database::sql_console::SqlResult;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::codemirror_bridge;
|
||||
use crate::components::sql_result_table::SqlResultTable;
|
||||
use crate::components::ui::ADMIN_TABLE_CLASS;
|
||||
// use_resolved_theme 两种构建都用:resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。
|
||||
use crate::theme::use_resolved_theme;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::theme::ResolvedTheme;
|
||||
|
||||
let resolved = use_resolved_theme();
|
||||
let sql_text = use_signal(String::new);
|
||||
let mut result = use_signal(|| Option::<SqlResult>::None);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut running = use_signal(|| false);
|
||||
// 选项 toggles
|
||||
let mut with_explain = use_signal(|| false);
|
||||
let mut allow_multi = use_signal(|| false);
|
||||
let mut confirm_dangerous = use_signal(|| false);
|
||||
// resolved/sql_text 仅在 wasm32 块内使用;server 构建时显式引用避免 unused 警告。
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = (&resolved, &sql_text);
|
||||
|
||||
// CodeMirror 实例句柄(仅 WASM)。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> = use_signal(|| None);
|
||||
|
||||
// 执行 SQL 的核心逻辑:抽成独立闭包,按钮 onclick 与 Ctrl+Enter 快捷键共用。
|
||||
// 所有捕获都是 Copy 的 Signal,故每次调用读取最新值,闭包本身是 Fn 可重复调用。
|
||||
// 用两个独立闭包(execute_for_editor / run_sql)避免 move 单一所有权冲突——
|
||||
// 它们捕获相同的 Copy signal,行为完全等价。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut execute_for_editor = move || {
|
||||
running.set(true);
|
||||
error.set(None);
|
||||
let sql = sql_text.read().clone();
|
||||
let opts = ExecuteSqlOpts {
|
||||
allow_multi: allow_multi(),
|
||||
confirm_dangerous: confirm_dangerous(),
|
||||
with_explain: with_explain(),
|
||||
};
|
||||
// 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
|
||||
let lower = sql.to_lowercase();
|
||||
let looks_write = [
|
||||
"update ",
|
||||
"delete ",
|
||||
"insert ",
|
||||
"alter ",
|
||||
"drop ",
|
||||
"truncate ",
|
||||
"create ",
|
||||
]
|
||||
.iter()
|
||||
.any(|k| lower.contains(k));
|
||||
if looks_write && !confirm_dangerous() {
|
||||
// 简单提示;真正的高危放行靠 confirm_dangerous 开关
|
||||
let confirmed = web_sys::window().and_then(|w| {
|
||||
w.confirm_with_message(
|
||||
"这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。",
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
if confirmed != Some(true) {
|
||||
running.set(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
spawn(async move {
|
||||
match execute_sql(sql, opts).await {
|
||||
Ok(r) => result.set(Some(r)),
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
running.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 捕获当前 scope id:Ctrl+Enter 从 CodeMirror 的 JS 事件处理中触发时,
|
||||
// dioxus scope stack 为空,spawn() 内部 current_scope_id().unwrap() 会 panic。
|
||||
// 在 effect 里用 Runtime::in_scope 重建 scope 上下文,保证 spawn 找得到 origin。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let scope_id = dioxus::core::Runtime::current()
|
||||
.try_current_scope_id()
|
||||
.unwrap_or(dioxus::core::ScopeId::ROOT);
|
||||
|
||||
// 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use_effect(move || {
|
||||
if editor_handle.read().is_some() {
|
||||
return;
|
||||
}
|
||||
let mut text = sql_text;
|
||||
let on_change = Closure::new(move |v: String| {
|
||||
text.set(v);
|
||||
});
|
||||
let on_ready = Closure::new(|| {});
|
||||
// Ctrl/Cmd+Enter 触发执行(与按钮共用同一套资源/护栏逻辑)。
|
||||
// 从 CodeMirror JS 事件触发时无 dioxus scope,必须用 in_scope 重建。
|
||||
// 刻意用闭包 `|| execute_for_editor()` 而非直接传 `execute_for_editor`:
|
||||
// 后者会 move 闭包值进 in_scope,导致第二次快捷键触发时 use-after-move
|
||||
// (Closure::new 要求 Fn 可重复调用)。闭包包装走借用调用,每次都读最新值。
|
||||
#[allow(clippy::redundant_closure)]
|
||||
let on_run_shortcut = Closure::new(move || {
|
||||
dioxus::core::Runtime::current().in_scope(scope_id, || execute_for_editor());
|
||||
});
|
||||
|
||||
let theme_name = if resolved() == ResolvedTheme::Dark {
|
||||
"dark"
|
||||
} else {
|
||||
"light"
|
||||
};
|
||||
let opts = codemirror_bridge::EditorOptions::new();
|
||||
opts.set_language("sql");
|
||||
opts.set_theme(theme_name);
|
||||
opts.set_vim(true);
|
||||
opts.set_on_change(&on_change);
|
||||
opts.set_on_ready(&on_ready);
|
||||
opts.set_on_run_shortcut(&on_run_shortcut);
|
||||
|
||||
if let Ok(Some(inst)) = codemirror_bridge::get_module().create("sql-editor", &opts) {
|
||||
let handle = codemirror_bridge::EditorHandle::new(
|
||||
inst,
|
||||
on_change,
|
||||
on_ready,
|
||||
on_run_shortcut,
|
||||
);
|
||||
editor_handle.set(Some(handle));
|
||||
}
|
||||
|
||||
// 异步拉取 schema 注入补全
|
||||
spawn(async move {
|
||||
if let Ok(schema) = get_db_schema().await {
|
||||
// SqlSchema 是 serde 类型:先用 serde_wasm_bindgen 转 JsValue,
|
||||
// 再传给 CodeMirror(extern set_schema 只接受 &JsValue)。
|
||||
if let Ok(js) = serde_wasm_bindgen::to_value(&schema) {
|
||||
if let Some(h) = editor_handle.read().as_ref() {
|
||||
h.instance().set_schema(&js);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
|
||||
// resolved 是 theme + system_dark 的派生 memo,任一变化都自动触发此 effect。
|
||||
use_effect(move || {
|
||||
let r = resolved();
|
||||
if let Some(h) = editor_handle.read().as_ref() {
|
||||
h.instance().set_theme(if r == ResolvedTheme::Dark {
|
||||
"dark"
|
||||
} else {
|
||||
"light"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 执行 SQL:按钮 onclick 用的闭包。wasm 下复制 execute_for_editor 的逻辑,
|
||||
// server 下仅复位 running(无网络层)。两处闭包捕获相同的 Copy signal。
|
||||
let mut run_sql = move || {
|
||||
running.set(true);
|
||||
error.set(None);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let sql = sql_text.read().clone();
|
||||
let opts = ExecuteSqlOpts {
|
||||
allow_multi: allow_multi(),
|
||||
confirm_dangerous: confirm_dangerous(),
|
||||
with_explain: with_explain(),
|
||||
};
|
||||
// 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
|
||||
let lower = sql.to_lowercase();
|
||||
let looks_write = [
|
||||
"update ",
|
||||
"delete ",
|
||||
"insert ",
|
||||
"alter ",
|
||||
"drop ",
|
||||
"truncate ",
|
||||
"create ",
|
||||
]
|
||||
.iter()
|
||||
.any(|k| lower.contains(k));
|
||||
if looks_write && !confirm_dangerous() {
|
||||
// 简单提示;真正的高危放行靠 confirm_dangerous 开关
|
||||
let confirmed = web_sys::window().and_then(|w| {
|
||||
w.confirm_with_message(
|
||||
"这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。",
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
if confirmed != Some(true) {
|
||||
running.set(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
spawn(async move {
|
||||
match execute_sql(sql, opts).await {
|
||||
Ok(r) => result.set(Some(r)),
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
running.set(false);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
running.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
let current_result = result.read().clone();
|
||||
let current_error = error.read().clone();
|
||||
let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0);
|
||||
let affected = current_result
|
||||
.as_ref()
|
||||
.map(|r| r.affected_rows)
|
||||
.unwrap_or(0);
|
||||
let stmt_type = current_result
|
||||
.as_ref()
|
||||
.map(|r| r.statement_type.clone())
|
||||
.unwrap_or_default();
|
||||
let truncated = current_result
|
||||
.as_ref()
|
||||
.map(|r| r.truncated)
|
||||
.unwrap_or(false);
|
||||
let explain = current_result.as_ref().and_then(|r| r.explain.clone());
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-5",
|
||||
// 编辑器卡片:标题栏 + CodeMirror 一体化。
|
||||
div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]",
|
||||
// 标题栏
|
||||
div { class: "flex justify-between items-center px-4 py-2.5 border-b border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]",
|
||||
div { class: "flex items-center gap-2",
|
||||
span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
|
||||
span { class: "font-mono text-sm font-semibold text-[var(--color-paper-primary)]",
|
||||
"SQL"
|
||||
}
|
||||
}
|
||||
span { class: "text-xs text-[var(--color-paper-tertiary)] font-mono",
|
||||
"⌘↵ 执行"
|
||||
}
|
||||
}
|
||||
// CodeMirror 容器:用 flex 让 .cm-editor(flex:1) 填满整个高度,
|
||||
// 避免编辑器塌缩到内容高度、底部透出容器背景造成上下色差。
|
||||
div {
|
||||
id: "sql-editor",
|
||||
style: "min-height: 280px; display: flex; flex-direction: column",
|
||||
}
|
||||
}
|
||||
|
||||
// 工具条:执行按钮 + 普通/危险选项分层
|
||||
div { class: "flex flex-wrap items-center gap-x-4 gap-y-3",
|
||||
LoadingButton {
|
||||
label: "执行".to_string(),
|
||||
loading: running(),
|
||||
onclick: move |_| run_sql(),
|
||||
}
|
||||
// 普通选项
|
||||
label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "rounded border-[var(--color-paper-border)]",
|
||||
checked: with_explain(),
|
||||
onchange: move |e| with_explain.set(e.checked()),
|
||||
}
|
||||
"EXPLAIN"
|
||||
}
|
||||
label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "rounded border-[var(--color-paper-border)]",
|
||||
checked: allow_multi(),
|
||||
onchange: move |e| allow_multi.set(e.checked()),
|
||||
}
|
||||
"允许多语句"
|
||||
}
|
||||
// 危险选项:视觉分隔 + 红色语义
|
||||
span { class: "w-px h-4 bg-[var(--color-paper-border)] mx-1" }
|
||||
label { class: "flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 cursor-pointer",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "rounded border-red-300 dark:border-red-700",
|
||||
checked: confirm_dangerous(),
|
||||
onchange: move |e| confirm_dangerous.set(e.checked()),
|
||||
}
|
||||
"我了解后果(DROP/TRUNCATE/ALTER)"
|
||||
}
|
||||
}
|
||||
|
||||
// 错误
|
||||
if let Some(err) = current_error {
|
||||
div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-2xl p-3 text-sm text-red-700 dark:text-red-300",
|
||||
"{err}"
|
||||
}
|
||||
}
|
||||
|
||||
// 结果摘要(徽章式)
|
||||
if current_result.is_some() {
|
||||
div { class: "flex flex-wrap items-center gap-2",
|
||||
if !stmt_type.is_empty() {
|
||||
span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium",
|
||||
"{stmt_type}"
|
||||
}
|
||||
}
|
||||
if affected > 0 {
|
||||
span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium",
|
||||
"影响 {affected} 行"
|
||||
}
|
||||
}
|
||||
span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium",
|
||||
"{elapsed}ms"
|
||||
}
|
||||
if truncated {
|
||||
span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs font-medium",
|
||||
"结果超过 500 行,已截断"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 结果表格
|
||||
if let Some(res) = ¤t_result {
|
||||
if !res.rows.is_empty() {
|
||||
SqlResultTable { result: res.clone() }
|
||||
} else if res.statement_type.to_uppercase().contains("SELECT") {
|
||||
// 有结果但无行:SELECT 返回空集的友好提示
|
||||
div { class: "{ADMIN_TABLE_CLASS} px-4 py-8 text-center text-sm text-[var(--color-paper-tertiary)]",
|
||||
"查询成功,无返回行"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EXPLAIN 输出
|
||||
if let Some(explain) = explain {
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "px-4 py-2 border-b border-paper-border text-sm font-medium text-paper-primary",
|
||||
"执行计划"
|
||||
}
|
||||
pre { class: "p-4 text-xs font-mono text-paper-secondary overflow-x-auto whitespace-pre m-0",
|
||||
"{explain}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user