Compare commits

...

10 Commits

Author SHA1 Message Date
xfy
7611c60e6c feat(write): 保存按钮改用鼠尾草绿主色
Some checks failed
CI / check (push) Failing after 5m21s
CI / build (push) Has been skipped
write 页的「保存」主按钮原为石墨黑 bg-paper-primary,与 dashboard
「写文章」等主操作按钮颜色不一致。改为 bg-paper-accent 鼠尾草绿,
统一后台所有主操作按钮配色。hover 改用 brightness-110 + active
scale 与其它主按钮交互手感一致。禁用态(saving)保持 paper-tertiary 灰。
2026-06-25 15:14:31 +08:00
xfy
806b474599 feat(theme): 新增冷调玫瑰第二色用于次要按钮
在 input.css 的 @theme 新增 accent-2 / accent-2-soft 主题变量,
用于与鼠尾草绿主色成对的次要操作按钮。

颜色选择经 HSL 分析:色相 342°(冷调玫瑰,区别于暖砖红 9°),
饱和度 28%(低饱和从属于主色),保证视觉为浅色系且不浊。
对比度:亮色 #83495b vs 米白底 6.5:1、暗色 #cca4b0 vs entry 底 7.46:1,
均过 WCAG AA。

新增 BTN_SECONDARY 常量(ghost 描边风格)用于 dashboard「管理文章」
按钮,与「写文章」实心主按钮构成主次层级。两按钮统一用
inline-flex items-center justify-center 保证文字水平垂直居中、等高。
2026-06-25 15:06:45 +08:00
xfy
519c0ae3a9 feat(admin): 后台迁移至 paper-* 主题变量对齐前台
将 /admin 路由下硬编码的 Tailwind gray + hex 配色统一迁移到
input.css 定义的 paper-* 主题变量系统,使后台视觉完全对齐前台:

- 底色:bg-white → bg-paper-theme(米白,与前台一致)
- 卡片/表格:bg-white dark:bg-[#2e2e33] → bg-paper-entry
- 主操作按钮(写文章/保存/分页):bg-gray-900 黑 → bg-paper-accent 鼠尾草绿
- 文字/边框/hover:gray-* + dark:hex → text-paper-*/border-paper-*
- 清理 trash 页散落的 #5c7a5e/#7da97f/#e8f0e8 等 hex,路由到变量
- 骨架占位统一复用 ADMIN_* 常量与 SkeletonBox

保留 green/amber/red 状态语义色(通过/待审/删除)与 toggle 白色滑块。
统一采用 bg-paper-* 工具类语法,与 header/post_card 主流写法一致。

dx check + clippy + 402 tests 全通过。
2026-06-25 14:33:48 +08:00
xfy
df85df3d2e style(empty-state): 配图加圆角,暗色模式降低亮度
- rounded-lg 圆角(= 项目的 --radius-paper 8px)
- dark:brightness-90 滤镜,让彩色配图在暗色主题下退后一些
2026-06-25 13:48:46 +08:00
xfy
cfd2af1fab refactor(empty-state): 内联 SVG 换成 webp 配图
移除手绘的 LineDog 内联 SVG,改用 <img> 引用
public/images/xiaotiaoxiaogou_01.webp 原始插画。
2026-06-25 13:43:23 +08:00
xfy
97b413dd50 docs: 同步 AGENTS.md 与当前代码
修正与代码不符的过时描述:
- server functions 分布补全 comments/ 与 settings.rs(原仅列 auth + posts)
- rate_limit tiers 从 3 个更正为 5 个(strict/upload/image/comment/unknown)
- Rust 测试数 392 → 402

补全 lightbox 子项目文档(新增独立章节,说明其经 Dioxus.toml
全局注入而非 wasm-bindgen 桥接的差异),并在 Build Artifacts
与 make test 描述中补上 lightbox 产物与测试(23 tests)。
2026-06-25 13:39:28 +08:00
xfy
8b581649a6 feat(home): 用「线条小狗」插画替换首页空状态
原空状态仅有一行「暂无文章」文案。新增 EmptyState 组件:
- 内联单色 SVG 配图(双手持相机,取景器内两只小狗),由 webp 重绘为线条画
- 描边用 currentColor 适配明暗主题,屏幕区填 accent-soft 作焦点
- 标题改「还没有文章」,副文案呼应站点定位
- 保留可选 action 字段供后台上下文复用

颜色、圆角、入场动画均沿用项目 Forest 调色板与既有视觉语言。
2026-06-25 13:37:07 +08:00
xfy
4ec8d72b66 feat(db): 启动期自动创建目标数据库
把 scripts/migrate.sh 里 CREATE DATABASE 的逻辑内置进二进制启动流程,
全新部署不再需要手动建库即可首次启动。

- src/db/pool.rs: 新增 ensure_database(),连接 postgres 维护库做
  CREATE DATABASE IF NOT EXISTS 等价逻辑(SELECT EXISTS + CREATE)。
  复用 MIGRATE_STARTUP_TIMEOUT_SECS 窗口应对 DB 起得慢;目标库名经
  is_simple_ident 校验后才拼到 SQL,不合法的名字跳过让正常路径报错。
  tokio_postgres::Config 不 Clone,故逐字段拷贝到新 Config。
- src/main.rs: 在 get_conn_for_startup() 之前调用 ensure_database(),
  失败走统一的 tracing::error! + exit(1) 路径。
- src/db/migrate.rs: 文档注释补明目标库存在性由 ensure_database 上游保证。

新增 2 个 is_simple_ident 单测(392 → 402 tests)。
2026-06-25 11:46:50 +08:00
xfy
21322a4339 fix(card): 提升覆盖层 z-index 使封面图可点击跳转
封面完整图 .blur-img-full 在全局 CSS 有 z-index:1 (input.css:611),
盖在原 z-0 的覆盖层 Link 之上,导致 hover 封面命中 <img> 而非 <a>:
光标非 pointer 且点击不跳转。

将覆盖层提到 z-[2](>封面图 1,<标签 10),命中 <a> 后光标与点击一并解决,
标签 stop_propagation 与 hover 缩放不受影响。
2026-06-25 11:10:43 +08:00
xfy
7242c7d8f5 chore: 将迁移脚本 migrate.sh 移至 scripts/ 目录 2026-06-25 10:33:32 +08:00
20 changed files with 418 additions and 121 deletions

View File

@ -8,7 +8,7 @@ make build # build-editor → highlight-css → tailwindcss → dx build
make build-linux # same as build but targets x86_64-unknown-linux-musl
make css # one-shot CSS
make css-watch # watch mode
make test # cargo test
make test # cargo test + vitest (tiptap-editor + lightbox libs)
make clean # cargo clean + rm public/style.css
```
@ -88,7 +88,7 @@ Dioxus 0.7 fullstack project with **two independent gates** — the most common
The server exposes two distinct API patterns:
1. **Dioxus server functions** (`#[server(Name, "/api")]` in `src/api/`) — auto-routed, callable from both client and server Rust. Spread across `src/api/auth.rs` and `src/api/posts/`.
1. **Dioxus server functions** (`#[server(Name, "/api")]` in `src/api/`) — auto-routed, callable from both client and server Rust. Spread across `src/api/auth.rs`, `src/api/posts/`, `src/api/comments/`, and `src/api/settings.rs`.
2. **Axum routes** (registered in `src/main.rs`) — manual `axum::Router` for endpoints that don't fit the server-function model:
- `POST /api/upload` — image upload (multipart, auth-required, rate-limited)
@ -99,10 +99,12 @@ The server exposes two distinct API patterns:
```
src/api/ — server functions + Axum handlers
auth.rs — login, register, session validation
comments/ — comment CRUD + approval/spam/trash server functions
markdown.rs — Markdown→HTML rendering (pulldown-cmark + ammonia sanitization)
image.rs — image serving with processing pipeline + disk+memory cache
upload.rs — image upload, auto-converts to WebP
rate_limit.rs — governor-based rate limiting (3 tiers: strict/upload/image)
rate_limit.rs — governor-based rate limiting (5 tiers: strict/upload/image/comment/unknown)
settings.rs — site settings server functions (trash retention, etc.)
slug.rs — URL slug generation
posts/ — CRUD server functions for blog posts
src/auth/ — password hashing (Argon2) + session token management
@ -129,6 +131,16 @@ Rich-text editor in `libs/tiptap-editor/`, built as an IIFE library exposing `wi
Do not edit `public/tiptap/` — they are build artifacts.
## Lightbox Subproject
Image lightbox (click-to-zoom) in `libs/lightbox/`, built as an IIFE library. Unlike the Tiptap editor, it is **not** wired through wasm-bindgen; the built `lightbox.js` is injected globally via `Dioxus.toml` (`script = ["/lightbox/lightbox.js"]`), and `src/components/post/post_content.rs` sets `window.__lightboxSelectors` (e.g. `['.post-content', '.entry-cover']`) before the script loads. The IIFE tail reads this config and self-initializes; `post_content.rs` also calls it directly as a fallback if the script was already loaded.
- Output: `public/lightbox/` (`lightbox.js`, `lightbox.css`, `lightbox.js.map`)
- `make build` runs `npm ci --include=dev && npm run build` inside `libs/lightbox`; the `build` script is `tsc --noEmit && vite build`
- Unit tests: `npm test` (Vitest, 23 tests), covering `geometry` math and `lightbox` rendering/lifecycle
Do not edit `public/lightbox/` — they are build artifacts.
## Syntax Highlighting Pipeline
- `themes/` contains Catppuccin Latte (light) and Mocha (dark) `.tmTheme` files
@ -152,12 +164,12 @@ Do not edit `public/tiptap/` — they are build artifacts.
## Testing
```bash
make test # cargo test (Rust, 392 tests) + vitest (editor lib, 46 tests)
make test # cargo test (Rust, 402 tests) + vitest (tiptap-editor 46 tests, lightbox 23 tests)
dx check # Dioxus type-check (catches component/Router issues)
cargo clippy # lint
```
Most tests use `#[cfg(all(test, feature = "server"))]` — they only run when the server feature is active (which is the default). No integration tests requiring a database connection.
Most tests use `#[cfg(all(test, feature = "server"))]` — they only run when the server feature is active (which is the default). No integration tests requiring a database connection. The two `libs/` subprojects (tiptap-editor, lightbox) run their own vitest suites.
## Image Processing Constraints
@ -170,9 +182,10 @@ Most tests use `#[cfg(all(test, feature = "server"))]` — they only run when th
- `public/style.css` — Tailwind output
- `public/highlight.css` — generated by `generate_highlight_css` binary
- `public/tiptap/` — Vite build output
- `public/tiptap/` — Vite build output (editor)
- `public/lightbox/` — Vite build output (lightbox)
- `/dist`, `/.dioxus`, `/target`
- `node_modules` (inside `libs/tiptap-editor/`)
- `node_modules` (inside `libs/tiptap-editor/` and `libs/lightbox/`)
- `uploads/.cache/` — image processing disk cache
## Notes

View File

@ -16,6 +16,8 @@
--color-paper-border: #e7e5e0;
--color-paper-accent: #5c7a5e;
--color-paper-accent-soft: #e8f0e8;
--color-paper-accent-2: #83495b;
--color-paper-accent-2-soft: #f3e8eb;
--radius-paper: 8px;
--gap-paper: 24px;
@ -50,6 +52,8 @@
--color-paper-border: #2a2b2e;
--color-paper-accent: #7da97f;
--color-paper-accent-soft: #1e2e1e;
--color-paper-accent-2: #cca4b0;
--color-paper-accent-2-soft: #331f25;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -79,7 +79,7 @@ pub fn AdminLayout() -> Element {
div { class: "flex items-center gap-3",
ThemeToggle {}
button {
class: "text-sm text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] transition-colors",
class: "text-sm text-paper-secondary hover:text-paper-primary transition-colors",
onclick: move |_| {
spawn(async move {
let _ = logout().await;
@ -103,9 +103,9 @@ pub fn AdminLayout() -> Element {
// Write 路由:页面固定高度,不滚动,由编辑器内部处理滚动
let root_class = if is_write_route {
"h-dvh flex flex-col overflow-hidden bg-white dark:bg-[#1d1e20]"
"h-dvh flex flex-col overflow-hidden bg-paper-theme"
} else {
"min-h-screen flex flex-col bg-white dark:bg-[#1d1e20]"
"min-h-screen flex flex-col bg-paper-theme"
};
// 根据校验状态与用户状态渲染真实布局、跳转提示或骨架屏
@ -125,7 +125,7 @@ pub fn AdminLayout() -> Element {
rsx! {
div { class: "{root_class}",
div { class: "flex-1 flex items-center justify-center",
p { class: "text-gray-600 dark:text-[#9b9c9d]", "未登录,正在跳转..." }
p { class: "text-paper-secondary", "未登录,正在跳转..." }
}
}
}

View File

@ -17,7 +17,7 @@ pub fn AdminDashboardSkeleton() -> Element {
// 统计卡片骨架
div { class: "grid grid-cols-1 md:grid-cols-3 gap-6",
for _ in 0..3 {
div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center space-y-3",
div { class: "rounded-xl bg-paper-entry border border-paper-border p-6 text-center space-y-3",
SkeletonBox { class: "h-9 w-16 mx-auto rounded" }
SkeletonBox { class: "h-4 w-20 mx-auto rounded" }
}
@ -35,7 +35,7 @@ pub fn AdminDashboardSkeleton() -> Element {
SkeletonBox { class: "h-6 w-24 rounded" }
div { class: "space-y-0",
for _ in 0..5 {
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]",
div { class: "flex justify-between items-center py-3 border-b border-paper-border",
SkeletonBox { class: "h-4 w-[45%] rounded" }
SkeletonBox { class: "h-3 w-20 rounded" }
}

View File

@ -0,0 +1,66 @@
//! 空状态组件。
//!
//! 当列表(首页、标签、搜索等)无数据时展示:插画配图 + 标题 + 副文案,
//! 可选行动按钮。视觉语言沿用项目 Forest 调色板(鼠尾草绿强调色)与
//! Source Serif 4 衬线标题,留白克制,与首页 HomeInfo 标题区风格一致。
//!
//! 配图为「线条小狗」插画(`public/images/xiaotiaoxiaogou_01.webp`
//! 通过 `<img>` 引用绝对路径,由 Dioxus 的静态资源服务直接返回。
use dioxus::prelude::*;
use dioxus::router::components::Link;
/// 空状态行动按钮。
#[derive(Props, Clone, PartialEq)]
pub struct EmptyStateAction {
/// 按钮文案。
pub label: &'static str,
/// 跳转目标路由。
pub to: crate::router::Route,
}
/// 空状态组件。
///
/// 默认渲染「线条小狗」配图;提供 `title` / `description` / `action` 可覆盖默认文案。
/// 所有元素垂直居中,配图下方留白,与首页 `HomeInfo` 的居中布局对齐。
///
/// Props 由 `#[component]` 宏自动生成(`title` / `description` / `action` 均为可选)。
#[component]
pub fn EmptyState(
/// 主标题(通常为「还没有文章」之类)。
#[props(default)] title: Option<&'static str>,
/// 副文案,说明当前状态或引导用户。
#[props(default)] description: Option<&'static str>,
/// 可选的行动按钮。
#[props(default)] action: Option<EmptyStateAction>,
) -> Element {
rsx! {
div { class: "flex flex-col items-center justify-center text-center py-20 px-4 page-enter",
// 配图:线条小狗(双手持相机,取景器内两只小狗)。
img {
class: "w-48 h-auto rounded-lg select-none dark:brightness-90",
src: "/images/xiaotiaoxiaogou_01.webp",
alt: "线条小狗插画",
draggable: "false",
}
// 主标题:衬线字体,与首页 H1 风格呼应但更轻量。
h2 { class: "mt-8 text-2xl font-bold tracking-tight text-paper-primary",
{title.unwrap_or("还没有文章")}
}
// 副文案:次要色,限宽保证可读性。
if let Some(desc) = description {
p { class: "mt-3 text-sm leading-relaxed text-paper-secondary max-w-md",
{desc}
}
}
// 行动按钮:药丸形,与搜索页主按钮一致。
if let Some(act) = action {
Link {
class: "mt-8 inline-flex items-center px-6 py-2 bg-paper-accent text-white rounded-full font-medium text-sm hover:brightness-110 active:scale-[0.98] transition-all duration-200",
to: act.to,
{act.label}
}
}
}
}
}

View File

@ -11,6 +11,8 @@ pub mod admin_layout;
pub mod admin_skeleton;
/// 评论相关组件。
pub mod comments;
/// 空状态组件(无数据时的插画配图 + 文案)。
pub mod empty_state;
/// 页脚组件。
pub mod footer;
/// 表单控件组件。

View File

@ -79,9 +79,12 @@ pub fn PostCard(post: PostListItem) -> Element {
}
}
}
// 覆盖层链接铺满卡片承担整卡跳转。z-0 位于标签 (z-10) 之下。
// 覆盖层链接:铺满卡片,承担整卡跳转。
// z-[2] 高于封面完整图 (.blur-img-full z-index:1, input.css:611)
// 使鼠标 hover 封面时命中本 <a> 而非 <img>:光标变 pointer 且点击可跳转;
// 仍低于标签 (z-10)标签独立点击stop_propagation不受影响。
Link {
class: "absolute inset-0 z-0",
class: "absolute inset-0 z-[2]",
aria_label: "post link to {post.title}",
to: Route::PostDetail { slug: post_slug },
}

View File

@ -5,6 +5,7 @@
use dioxus::prelude::*;
use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::ui::ADMIN_TABLE_CLASS;
/// 后台文章管理列表骨架屏组件。
///
@ -12,10 +13,10 @@ use crate::components::skeletons::atoms::SkeletonBox;
#[component]
pub fn PostsSkeleton() -> Element {
rsx! {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
div { class: "{ADMIN_TABLE_CLASS}",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-gray-200 dark:border-[#333]",
tr { class: "border-b border-paper-border",
th { class: "px-4 py-3", SkeletonBox { class: "h-3 w-10" } }
th { class: "px-4 py-3 w-24", SkeletonBox { class: "h-3 w-10 mx-auto" } }
th { class: "px-4 py-3 w-32", SkeletonBox { class: "h-3 w-10" } }
@ -24,7 +25,7 @@ pub fn PostsSkeleton() -> Element {
}
tbody {
for _ in 0..10 {
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0",
tr { class: "border-b border-paper-border last:border-0",
td { class: "px-4 py-3", SkeletonBox { class: "h-4 w-1/3" } }
td { class: "px-4 py-3", SkeletonBox { class: "h-5 w-14 mx-auto rounded" } }
td { class: "px-4 py-3", SkeletonBox { class: "h-4 w-20" } }

View File

@ -17,18 +17,18 @@ use crate::router::Route;
/// Admin 卡片容器:白底圆角描边,亮/暗双模式。用于 stat 卡片、面板等。
pub const ADMIN_CARD_CLASS: &str =
"bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333]";
"bg-paper-entry rounded-xl border border-paper-border";
/// Admin 表格容器:在卡片基础上加 `overflow-hidden`,圆角裁剪表格。
pub const ADMIN_TABLE_CLASS: &str =
"bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden";
"bg-paper-entry rounded-xl border border-paper-border overflow-hidden";
/// Admin 表格行 hover 态:底部分割线 + 悬停背景。
pub const ADMIN_ROW_HOVER: &str =
"border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors";
"border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors";
/// 列表复选框统一样式(全选表头 + 行内)。
pub const CHECKBOX_CLASS: &str = "rounded border-gray-300 dark:border-[#555]";
pub const CHECKBOX_CLASS: &str = "rounded border-paper-border";
/// 状态徽章外层:小号圆角胶囊。
pub const BADGE_BASE: &str =
@ -56,7 +56,15 @@ pub const BTN_TEXT_AMBER: &str = "text-xs text-amber-600 hover:text-amber-800 da
pub const BTN_TEXT_RED: &str =
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
/// 主题绿(鼠尾草)文字小按钮(行内恢复)。
pub const BTN_TEXT_ACCENT: &str = "text-xs text-[#5c7a5e] hover:text-[#3d5a3f] dark:text-[#7da97f] dark:hover:text-[#9dc79f] transition-colors cursor-pointer";
pub const BTN_TEXT_ACCENT: &str = "text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer";
// --- 次要按钮冷调玫瑰第二色ghost 描边风格,从属于主色鼠尾草绿) ---
/// 次要按钮:冷调玫瑰描边 ghost 风格。
/// 用于与主操作按钮paper-accent 实心)成对的次操作,如「管理文章」「重建内容」。
/// 亮色文字 #83495b vs 米白底 6.5:1、暗色 #cca4b0 vs entry 底 7.46:1均过 WCAG AA。
pub const BTN_SECONDARY: &str =
"px-6 py-3 rounded-full text-sm font-medium text-center text-paper-accent-2 border border-paper-accent-2/40 hover:border-paper-accent-2 hover:bg-paper-accent-2-soft transition-all cursor-pointer";
// ===========================================================================
// 组件
@ -95,7 +103,7 @@ pub fn Pagination(
let is_admin = variant == "admin";
let (link_class, link_extra_next) = if is_admin {
(
"inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
"inline-flex items-center px-4 py-2 text-sm text-paper-theme bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
"",
)
} else {
@ -105,7 +113,7 @@ pub fn Pagination(
)
};
let disabled_class =
"inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed";
"inline-flex items-center px-4 py-2 text-sm text-paper-secondary bg-paper-tertiary rounded-full cursor-not-allowed";
// admin 首尾页渲染禁用态frontend 首尾页直接不渲染。
rsx! {
@ -126,7 +134,7 @@ pub fn Pagination(
// admin 显示页码计数。
if is_admin {
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
span { class: "text-sm text-paper-secondary self-center",
"{current_page} / {total_pages} 页 (共 {total} {unit})"
}
}
@ -177,7 +185,7 @@ pub fn StatusBadge(color_class: &'static str, label: String) -> Element {
pub fn EmptyState(message: &'static str, variant: &'static str) -> Element {
let class = match variant {
"error" => "text-center text-red-500 dark:text-red-400 py-20",
_ => "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
_ => "text-center py-20 text-paper-secondary",
};
rsx! {
div { class: "{class}", "{message}" }

View File

@ -7,6 +7,10 @@
//! - 每个迁移在独立事务里执行,失败自动回滚,版本行不会写入。
//! - 咨询锁(`pg_advisory_lock`)保证多实例启动时只有一个进程执行迁移。
//!
//! 前置条件:目标数据库的存在性由 [`crate::db::pool::ensure_database`] 在本模块
//! 被调用前保证(它先连 `postgres` 维护库做 `CREATE DATABASE IF NOT EXISTS` 等价逻辑)。
//! 本模块只负责 schema不再关心"库不存在"。
//!
//! 仅在 `feature = "server"` 时编译。
use std::collections::HashSet;

View File

@ -11,7 +11,8 @@ use std::sync::LazyLock;
use std::time::Duration;
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime};
use tokio_postgres::NoTls;
use tokio_postgres::config::Host;
use tokio_postgres::{Config, NoTls};
/// 解析 `DATABASE_URL` 并注入 `statement_timeout`,返回配置好的 `tokio_postgres::Config`。
///
@ -190,3 +191,183 @@ pub async fn get_conn_for_startup(
}
}
}
/// 启动期自举:连接 `postgres` 维护库,确保目标数据库存在(不存在则创建)。
///
/// 解决"全新部署没有目标库"的缺口——`get_conn_for_startup` 连的是 `DATABASE_URL`
/// 里指定的目标库,库不存在时只会反复重试到超时退出。本函数在连接池首次被触碰
/// **之前**运行,把 `scripts/migrate.sh` 里那段 `CREATE DATABASE` 逻辑内置进二进制,
/// 让首次启动真正零手动。
///
/// 与 `get_conn_for_startup` 共享同一套语义:
/// - 复用 `MIGRATE_STARTUP_TIMEOUT_SECS` 窗口(默认 30s应对"DB 起得比 app 慢"
/// - 固定 500ms 轮询连接 `postgres` 维护库;连上后 `EXISTS` 查询 + `CREATE` 只跑一次。
/// - 目标库已存在是常态,此时仅一次快速往返。
///
/// 返回 `Result<(), String>`(而非 `MigrateError`),与 `validate_database_url` 的
/// 报错风格一致,便于 `main.rs` 走统一的 `tracing::error!` + `exit(1)` 路径。
///
/// 跳过自动创建(返回 `Ok(())`)的安全场景:
/// - 目标库名无法从 URL/用户名推断;
/// - 目标库名不是简单标识符(含 `-`、引号等),避免拼到 `CREATE DATABASE` 后面
/// 产生 SQL 注入风险——此时把"库不存在"的错误留给后续正常连接路径去报告。
pub async fn ensure_database() -> Result<(), String> {
// 1. 推断目标库名:优先 URL 里的 dbname回退到用户名Postgres 自身的默认行为)。
let pg_cfg = build_pg_config()?;
let db_name = pg_cfg
.get_dbname()
.or_else(|| pg_cfg.get_user())
.map(|s| s.to_string());
let db_name = match db_name {
Some(name) => name,
None => {
tracing::warn!(
"could not determine target database name from DATABASE_URL; \
skipping auto-create (letting normal connect path surface any error)"
);
return Ok(());
}
};
// 2. 标识符安全校验:`CREATE DATABASE` 后面只能跟裸标识符(无法用 $1 参数化),
// 含特殊字符的库名直接跳过,避免注入。
if !is_simple_ident(&db_name) {
tracing::warn!(
"target database name {:?} is not a simple identifier; \
skipping auto-create (letting normal connect path surface any error)",
db_name
);
return Ok(());
}
// 3. 在启动超时窗口内反复尝试连接 `postgres` 维护库。
let timeout_secs = std::env::var("MIGRATE_STARTUP_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(30);
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let retry_interval = Duration::from_millis(500);
let (client, connection) = loop {
let admin_cfg = build_admin_config()?;
match admin_cfg.connect(NoTls).await {
Ok(joined) => break joined,
Err(e) => {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(format!(
"could not connect to 'postgres' maintenance database within {timeout_secs}s: {e}"
));
}
tracing::warn!(
"ensure_database: connect to 'postgres' failed, ~{}s remaining: {e}",
remaining.as_secs()
);
tokio::time::sleep(std::cmp::min(retry_interval, remaining)).await;
}
}
};
// 连接的后台驱动任务:出错时仅记录,连接随即作废。
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::warn!("postgres maintenance connection ended: {e}");
}
});
// 4. 查询目标库是否已存在。
let exists: bool = client
.query_one(
"SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)",
&[&db_name],
)
.await
.map_err(|e| format!("failed to query pg_database: {e}"))?
.get(0);
if exists {
tracing::info!("target database {:?} already exists", db_name);
return Ok(());
}
// 5. 不存在则创建。db_name 已通过 is_simple_ident 校验,可安全拼到 SQL。
tracing::info!("target database {:?} does not exist, creating", db_name);
let stmt = format!("CREATE DATABASE {db_name}");
client
.batch_execute(&stmt)
.await
.map_err(|e| format!("failed to create database {db_name:?}: {e}"))?;
tracing::info!("created database {:?}", db_name);
Ok(())
}
/// 构建 `postgres` 维护库的连接配置:复用目标 URL 的 host/port/user/password
/// 仅把 dbname 换成 `postgres`。
///
/// `tokio_postgres::Config` 未实现 `Clone`,故逐字段拷贝到新的 `Config::new()`。
/// `host()`/`port()` 是 `&mut self` 的借用式 builder故按 host 逐个追加并配对端口。
fn build_admin_config() -> Result<Config, String> {
let src = build_pg_config()?;
let mut dst = Config::new();
if let Some(user) = src.get_user() {
dst.user(user);
}
if let Some(password) = src.get_password() {
dst.password(password);
}
let hosts = src.get_hosts();
let ports = src.get_ports();
for (i, host) in hosts.iter().enumerate() {
// 端口与 host 按 tokio-postgres 内部配对规则取第 i 个端口,缺省回退 5432。
let port = ports.get(i).copied().unwrap_or(5432);
match host {
Host::Tcp(h) => {
dst.host(h);
dst.port(port);
}
Host::Unix(p) => {
dst.host_path(p);
}
};
}
// get_hosts()/get_ports() 为空时退回 libpq 默认localhost:5432与原 URL 解析一致。
dst.dbname("postgres");
Ok(dst)
}
/// 判断字符串是否为 PostgreSQL 简单标识符:`^[A-Za-z_][A-Za-z0-9_]*$`。
///
/// 用于在把目标库名拼进 `CREATE DATABASE <name>` 前做安全校验——SQL 里库名无法参数化,
/// 非简单标识符(如 `my-db`、含引号)一律跳过自动创建。
fn is_simple_ident(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::is_simple_ident;
#[test]
fn simple_ident_accepts_valid_names() {
assert!(is_simple_ident("yggdrasil"));
assert!(is_simple_ident("_ygg"));
assert!(is_simple_ident("db_1"));
assert!(is_simple_ident("YggDrasil09"));
}
#[test]
fn simple_ident_rejects_invalid_names() {
assert!(!is_simple_ident(""));
assert!(!is_simple_ident("my-db")); // 连字符
assert!(!is_simple_ident("9db")); // 数字开头
assert!(!is_simple_ident("db name")); // 空格
assert!(!is_simple_ident("db\"; --")); // 引号 / 注入
assert!(!is_simple_ident("db.name")); // 点
}
}

View File

@ -238,6 +238,15 @@ fn main() {
migrate_rt.block_on(async {
tracing::info!("running database migrations");
// 连接池指向目标库,但目标库可能尚不存在(全新部署)。
// 先连 postgres 维护库确保目标库存在,复用启动超时窗口应对 DB 起得慢。
if let Err(e) = db::pool::ensure_database().await {
tracing::error!("failed to ensure target database exists: {e}");
eprintln!("ERROR: failed to ensure target database exists: {e}");
eprintln!("HINT: verify DATABASE_URL; the role needs CREATEDB (or CREATE privilege on the 'postgres' DB) to auto-create the target database.");
std::process::exit(1);
}
// 启动期用长重试窗口拿连接DB 可能还在初始化docker-compose 无 healthcheck、
// 本机忘启 Postgres 等)。窗口由 MIGRATE_STARTUP_TIMEOUT_SECS 控制,默认 30s。
let mut conn = match db::pool::get_conn_for_startup().await {

View File

@ -14,10 +14,12 @@ use crate::api::comments::trash_comment;
use crate::api::comments::{approve_comment, batch_update_comment_status, spam_comment};
#[cfg(target_arch = "wasm32")]
use crate::api::comments::{get_all_comments, AllCommentsResponse};
use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::ui::{
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_AMBER,
BTN_TEXT_GREEN, BTN_TEXT_RED, BTN_SOLID_AMBER, BTN_SOLID_GREEN, BTN_SOLID_RED, CHECKBOX_CLASS,
EmptyState, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
BTN_TEXT_AMBER, BTN_TEXT_GREEN, BTN_TEXT_RED, BTN_SOLID_AMBER, BTN_SOLID_GREEN, BTN_SOLID_RED,
CHECKBOX_CLASS,
};
use crate::models::comment::{AdminComment, CommentStatus};
use crate::router::Route;
@ -122,17 +124,17 @@ pub fn AdminCommentsPage(page: i32) -> Element {
rsx! {
div { class: "space-y-6",
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]",
h1 { class: "text-2xl font-bold text-paper-primary",
"评论管理"
}
div { class: "flex gap-1 border-b border-gray-200 dark:border-[#333]",
div { class: "flex gap-1 border-b border-paper-border",
for (status, label) in [("", "全部"), ("pending", "待审核"), ("approved", "已通过"), ("spam", "垃圾箱")] {
button {
class: if active_filter() == status {
"px-4 py-2 text-sm font-medium border-b-2 border-gray-900 dark:border-[#dadadb] text-gray-900 dark:text-[#dadadb]"
"px-4 py-2 text-sm font-medium border-b-2 border-paper-accent text-paper-primary"
} else {
"px-4 py-2 text-sm font-medium text-gray-500 dark:text-[#9b9c9d] hover:text-gray-700 dark:hover:text-[#dadadb] transition-colors"
"px-4 py-2 text-sm font-medium text-paper-secondary hover:text-paper-primary transition-colors"
},
onclick: move |_| active_filter.set(status.to_string()),
"{label}"
@ -142,8 +144,8 @@ pub fn AdminCommentsPage(page: i32) -> Element {
if !selected_ids().is_empty() {
{ rsx! {
div { class: "flex items-center gap-3 p-3 bg-gray-50 dark:bg-[#2a2a2a] rounded-lg",
span { class: "text-sm text-gray-600 dark:text-[#9b9c9d]",
div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg",
span { class: "text-sm text-paper-secondary",
"已选择 {selected_ids().len()} 条"
}
button {
@ -203,13 +205,13 @@ pub fn AdminCommentsPage(page: i32) -> Element {
} else if loading() && comments().is_empty() {
rsx! {
DelayedSkeleton {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] p-6 space-y-4",
div { class: "{ADMIN_CARD_CLASS} p-6 space-y-4",
for _ in 0..5 {
div { class: "flex items-center gap-4",
div { class: "h-4 w-4 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-8 w-8 bg-gray-200 dark:bg-[#2a2a2a] rounded-full" }
div { class: "h-4 w-32 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 flex-1 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
SkeletonBox { class: "h-4 w-4 rounded" }
SkeletonBox { class: "h-8 w-8 rounded-full" }
SkeletonBox { class: "h-4 w-32 rounded" }
SkeletonBox { class: "h-4 flex-1 rounded" }
}
}
}
@ -226,7 +228,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
div { class: "overflow-x-auto",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
tr { class: "border-b border-paper-border text-left text-paper-secondary",
th { class: "px-4 py-3 font-medium w-10",
input {
r#type: "checkbox",
@ -372,23 +374,23 @@ fn CommentRow(
alt: "{comment.author_name}",
}
div { class: "min-w-0",
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb] truncate",
div { class: "text-sm font-medium text-paper-primary truncate",
"{comment.author_name}"
}
div { class: "text-xs text-gray-400 dark:text-[#666] truncate",
div { class: "text-xs text-paper-secondary truncate",
"{comment.author_email}"
}
}
}
}
td { class: "px-4 py-3 max-w-xs",
p { class: "text-sm text-gray-600 dark:text-[#9b9c9d] truncate",
p { class: "text-sm text-paper-secondary truncate",
"{preview}"
}
}
td { class: "px-4 py-3",
Link {
class: "text-sm text-gray-700 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
class: "text-sm text-paper-primary hover:text-paper-accent transition-colors",
to: Route::PostDetail { slug: comment.post_slug.clone() },
"{comment.post_title}"
}
@ -405,7 +407,7 @@ fn CommentRow(
label: status_label,
}
}
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
td { class: "px-4 py-3 text-sm text-paper-secondary",
"{date_str}"
}
td { class: "px-4 py-3 text-right",

View File

@ -12,7 +12,8 @@ use crate::api::comments::get_pending_count;
use crate::api::posts::{get_post_stats, list_posts};
#[cfg(target_arch = "wasm32")]
use crate::api::posts::{PostListResponse, PostStatsResponse};
use crate::components::ui::ADMIN_CARD_CLASS;
use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::ui::{BTN_SECONDARY, ADMIN_CARD_CLASS};
use crate::models::post::{PostListItem, PostStats};
use crate::router::Route;
@ -70,9 +71,9 @@ pub fn Admin() -> Element {
None => {
rsx! {
for _ in 0..3 {
div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center space-y-3 animate-pulse",
div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "{ADMIN_CARD_CLASS} p-6 text-center space-y-3 animate-pulse",
SkeletonBox { class: "h-9 w-16 mx-auto rounded" }
SkeletonBox { class: "h-4 w-20 mx-auto rounded" }
}
}
}
@ -81,7 +82,7 @@ pub fn Admin() -> Element {
}
Link {
class: "block rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center hover:border-gray-300 dark:hover:border-[#555] transition-colors",
class: "block {ADMIN_CARD_CLASS} p-6 text-center hover:border-paper-accent transition-colors",
to: Route::AdminComments {},
match pending_count() {
Some(count) => {
@ -89,15 +90,15 @@ pub fn Admin() -> Element {
div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400",
"{count}"
}
div { class: "text-sm text-gray-500 dark:text-[#9b9c9d] mt-2",
div { class: "text-sm text-paper-secondary mt-2",
"待审核评论"
}
}
}
None => {
rsx! {
div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" }
div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded mt-3 animate-pulse" }
SkeletonBox { class: "h-9 w-16 mx-auto rounded" }
SkeletonBox { class: "h-4 w-20 mx-auto rounded mt-3" }
}
}
}
@ -105,19 +106,19 @@ pub fn Admin() -> Element {
div { class: "grid grid-cols-1 md:grid-cols-2 gap-4",
Link {
class: "bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity cursor-pointer",
class: "inline-flex items-center justify-center bg-paper-accent text-paper-theme rounded-full px-6 py-3 text-center font-medium hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
to: Route::Write {},
"写文章"
}
Link {
class: "bg-gray-200 dark:bg-[#333] text-gray-700 dark:text-[#dadadb] rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity cursor-pointer",
class: "inline-flex items-center justify-center {BTN_SECONDARY}",
to: Route::Posts {},
"管理文章"
}
}
div { class: "mb-8",
h2 { class: "text-xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
h2 { class: "text-xl font-bold text-paper-primary mb-4",
"最近文章"
}
match recent_posts() {
@ -134,9 +135,9 @@ pub fn Admin() -> Element {
rsx! {
div { class: "space-y-4 animate-pulse",
for _ in 0..5 {
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]",
div { class: "h-4 w-[45%] bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-3 w-20 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "flex justify-between items-center py-3 border-b border-paper-border",
SkeletonBox { class: "h-4 w-[45%] rounded" }
SkeletonBox { class: "h-3 w-20 rounded" }
}
}
}
@ -153,10 +154,10 @@ pub fn Admin() -> Element {
fn StatCard(value: String, label: String) -> Element {
rsx! {
div { class: "{ADMIN_CARD_CLASS} p-6 text-center",
div { class: "text-3xl font-bold text-gray-900 dark:text-[#dadadb]",
div { class: "text-3xl font-bold text-paper-primary",
"{value}"
}
div { class: "text-sm text-gray-500 dark:text-[#9b9c9d] mt-2",
div { class: "text-sm text-paper-secondary mt-2",
"{label}"
}
}
@ -171,16 +172,16 @@ fn RecentPostItem(post: PostListItem) -> Element {
let status_class = post.status_class();
rsx! {
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]",
div { class: "flex justify-between items-center py-3 border-b border-paper-border",
div { class: "flex items-center gap-3",
span { class: "text-gray-700 dark:text-[#dadadb]",
span { class: "text-paper-primary",
"{post.title}"
}
span { class: "text-xs {status_class}",
"{status_label}"
}
}
span { class: "text-sm text-gray-400 dark:text-[#9b9c9d]",
span { class: "text-sm text-paper-secondary",
"{date_str}"
}
}

View File

@ -101,42 +101,42 @@ pub fn PostsPage(page: i32) -> Element {
rsx! {
div { class: "space-y-6",
div { class: "flex items-center justify-between",
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]",
h1 { class: "text-2xl font-bold text-paper-primary",
"文章管理"
}
div { class: "flex items-center gap-3",
div { class: "group relative",
button {
class: if rebuilding() {
"px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] border border-gray-300 dark:border-[#444]"
"px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border"
} else {
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all"
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all"
},
disabled: rebuilding(),
onclick: move |_| do_rebuild(false),
if rebuilding() { "重建中..." } else { "重建内容" }
}
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-[#1a1a1a] shadow-lg z-50",
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50",
"重建 content_html 为空的文章渲染缓存"
}
}
div { class: "group relative",
button {
class: if rebuilding() {
"px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] border border-gray-300 dark:border-[#444]"
"px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border"
} else {
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all"
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all"
},
disabled: rebuilding(),
onclick: move |_| do_rebuild(true),
if rebuilding() { "重建中..." } else { "重建全部" }
}
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-[#1a1a1a] shadow-lg z-50",
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50",
"重建所有文章的渲染缓存(含已有内容)"
}
}
Link {
class: "px-4 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer",
class: "px-4 py-2 bg-paper-accent text-paper-theme rounded-full text-sm font-medium hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
to: Route::Write {},
"+ 写文章"
}
@ -144,7 +144,7 @@ pub fn PostsPage(page: i32) -> Element {
}
if let Some(msg) = rebuild_result() {
div { class: "text-sm text-gray-600 dark:text-[#9b9c9d] px-1",
div { class: "text-sm text-paper-secondary px-1",
"{msg}"
}
}
@ -157,7 +157,7 @@ pub fn PostsPage(page: i32) -> Element {
div { class: "{ADMIN_TABLE_CLASS}",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
tr { class: "border-b border-paper-border text-left text-paper-secondary",
th { class: "px-4 py-3 font-medium", "标题" }
th { class: "px-4 py-3 font-medium w-24 text-center", "状态" }
th { class: "px-4 py-3 font-medium w-32", "日期" }
@ -222,7 +222,7 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) ->
tr { class: "{ADMIN_ROW_HOVER}",
td { class: "px-4 py-3",
Link {
class: "text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
class: "text-paper-primary hover:text-paper-accent transition-colors cursor-pointer",
to: Route::PostDetail { slug: post.slug.clone() },
"{post.title}"
}
@ -233,19 +233,19 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) ->
label: post.status_label().to_string(),
}
}
td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]",
td { class: "px-4 py-3 text-paper-secondary",
"{date_str}"
}
td { class: "px-4 py-3 text-right",
div { class: "flex justify-end gap-3",
Link {
class: "text-xs text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] transition-colors cursor-pointer",
class: "text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
to: Route::WriteEdit { id: post.id },
"编辑"
}
button {
class: if deleting {
"text-xs text-gray-400 cursor-not-allowed"
"text-xs text-paper-secondary cursor-not-allowed"
} else {
BTN_TEXT_RED
},

View File

@ -18,10 +18,11 @@ use crate::api::posts::{
use crate::api::posts::{list_deleted_posts, PostListResponse};
#[allow(unused_imports)]
use crate::api::settings::{get_trash_settings, update_trash_settings};
use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::ui::{
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN,
BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS,
EmptyState, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS,
};
use crate::models::post::PostListItem;
use crate::models::settings::TrashSettings;
@ -124,27 +125,27 @@ pub fn TrashPage(page: i32) -> Element {
div { class: "space-y-6",
// 页面标题
div { class: "flex items-center gap-3",
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]", "回收站" }
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d]",
h1 { class: "text-2xl font-bold text-paper-primary", "回收站" }
span { class: "text-sm text-paper-secondary",
"共 {total()} 篇"
}
}
// 自动清理配置卡片:可折叠的设置面板,顶部始终显示当前状态摘要
div {
class: "rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden bg-white dark:bg-[#2e2e33]",
class: "rounded-xl border border-paper-border overflow-hidden bg-paper-entry",
// 顶部可点击摘要条:状态指示灯 + 标题 + 展开箭头
button {
class: "w-full flex items-center gap-3 px-5 py-4 text-left cursor-pointer hover:bg-gray-50 dark:hover:bg-[#34343a] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40",
class: "w-full flex items-center gap-3 px-5 py-4 text-left cursor-pointer hover:bg-paper-theme focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40",
onclick: move |_| {
settings_panel_open.set(!settings_panel_open());
just_saved.set(false);
},
// 状态指示灯
{let dot_class = if settings().auto_purge_enabled {
"w-2 h-2 rounded-full bg-[#5c7a5e] dark:bg-[#7da97f] shadow-[0_0_0_3px_rgba(92,122,94,0.15)]"
"w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(92,122,94,0.15)]"
} else {
"w-2 h-2 rounded-full bg-gray-300 dark:bg-[#666]"
"w-2 h-2 rounded-full bg-paper-tertiary"
};
rsx! {
div { class: "w-2 flex-shrink-0 flex items-center justify-center",
@ -153,10 +154,10 @@ pub fn TrashPage(page: i32) -> Element {
}}
// 标题 + 当前状态描述
div { class: "flex-1 min-w-0",
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb]",
div { class: "text-sm font-medium text-paper-primary",
"自动清理"
}
div { class: "text-xs text-gray-500 dark:text-[#9b9c9d] mt-0.5 truncate",
div { class: "text-xs text-paper-secondary mt-0.5 truncate",
if settings().auto_purge_enabled {
"已开启 · 超过 {settings().retention_days} 天的文章将被自动删除"
} else {
@ -166,7 +167,7 @@ pub fn TrashPage(page: i32) -> Element {
}
// 展开箭头(旋转动画)
svg {
class: "w-4 h-4 text-gray-400 dark:text-[#9b9c9d] transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
class: "w-4 h-4 text-paper-secondary transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
@ -177,15 +178,15 @@ pub fn TrashPage(page: i32) -> Element {
// 设置面板(可折叠)
if settings_panel_open() {
div { class: "border-t border-gray-100 dark:border-[#3a3a3e] p-5 space-y-6",
div { class: "border-t border-paper-border p-5 space-y-6",
// 开关行:启用自动清理
div {
class: "flex items-center justify-between gap-4",
div { class: "min-w-0",
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb]",
div { class: "text-sm font-medium text-paper-primary",
"启用自动清理"
}
div { class: "text-xs text-gray-500 dark:text-[#9b9c9d] mt-1",
div { class: "text-xs text-paper-secondary mt-1",
"后台任务定期彻底删除超过保留期的文章"
}
}
@ -194,9 +195,9 @@ pub fn TrashPage(page: i32) -> Element {
role: "switch",
aria_checked: "{settings_draft_enabled()}",
class: if settings_draft_enabled() {
"relative w-11 h-6 flex-shrink-0 rounded-full bg-[#5c7a5e] dark:bg-[#7da97f] cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40"
"relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-accent cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40"
} else {
"relative w-11 h-6 flex-shrink-0 rounded-full bg-gray-300 dark:bg-[#555] cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40"
"relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-tertiary cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40"
},
onclick: move |_| {
settings_draft_enabled.set(!settings_draft_enabled());
@ -216,19 +217,19 @@ pub fn TrashPage(page: i32) -> Element {
// 保留天数行
div { class: "space-y-3",
div { class: "min-w-0",
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb]",
div { class: "text-sm font-medium text-paper-primary",
"保留天数"
}
div { class: "text-xs text-gray-500 dark:text-[#9b9c9d] mt-1",
div { class: "text-xs text-paper-secondary mt-1",
"文章删除后保留的时长到期后自动彻底清除1365"
}
}
// 数字输入 + 步进按钮 + 单位后缀
div { class: "flex items-center gap-3",
div { class: "flex items-center rounded-lg border border-gray-300 dark:border-[#444] bg-white dark:bg-[#1d1e20] overflow-hidden",
div { class: "flex items-center rounded-lg border border-paper-border bg-paper-entry overflow-hidden",
// 减号
button {
class: "w-9 h-9 flex items-center justify-center text-sm text-gray-500 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] hover:bg-gray-50 dark:hover:bg-[#2a2a2a] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40",
class: "w-9 h-9 flex items-center justify-center text-sm text-paper-secondary hover:text-paper-primary hover:bg-paper-theme cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40",
r#type: "button",
aria_label: "减少保留天数",
onclick: move |_| {
@ -244,7 +245,7 @@ pub fn TrashPage(page: i32) -> Element {
r#type: "number",
min: "1",
max: "365",
class: "w-14 h-9 px-1 text-center text-sm tabular-nums text-gray-900 dark:text-[#dadadb] bg-transparent border-0 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
class: "w-14 h-9 px-1 text-center text-sm tabular-nums text-paper-primary bg-transparent border-0 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
value: "{settings_draft_days()}",
oninput: move |e| {
settings_draft_days.set(e.value());
@ -253,7 +254,7 @@ pub fn TrashPage(page: i32) -> Element {
}
// 加号
button {
class: "w-9 h-9 flex items-center justify-center text-sm text-gray-500 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] hover:bg-gray-50 dark:hover:bg-[#2a2a2a] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40",
class: "w-9 h-9 flex items-center justify-center text-sm text-paper-secondary hover:text-paper-primary hover:bg-paper-theme cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40",
r#type: "button",
aria_label: "增加保留天数",
onclick: move |_| {
@ -265,7 +266,7 @@ pub fn TrashPage(page: i32) -> Element {
"+"
}
}
span { class: "text-xs text-gray-400 dark:text-[#666]", "" }
span { class: "text-xs text-paper-secondary", "" }
}
}
@ -273,14 +274,14 @@ pub fn TrashPage(page: i32) -> Element {
div { class: "flex items-center justify-between gap-4 pt-1",
// 草稿状态提示
if just_saved() {
span { class: "inline-flex items-center gap-1.5 text-xs text-[#5c7a5e] dark:text-[#7da97f]",
span { class: "inline-flex items-center gap-1.5 text-xs text-paper-accent",
svg { class: "w-3.5 h-3.5", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2.5",
path { stroke_linecap: "round", stroke_linejoin: "round", d: "M5 13l4 4L19 7" }
}
"已保存"
}
} else if dirty {
span { class: "text-xs text-gray-400 dark:text-[#666]",
span { class: "text-xs text-paper-secondary",
"有未保存的更改"
}
} else {
@ -289,11 +290,11 @@ pub fn TrashPage(page: i32) -> Element {
// 保存按钮:启用主题色,禁用/保存中态灰化
button {
class: if saving_settings() {
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] bg-gray-100 dark:bg-[#2a2a2a] rounded-full"
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-paper-secondary bg-paper-tertiary rounded-full"
} else if just_saved() {
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] bg-gray-100 dark:bg-[#2a2a2a] rounded-full"
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-paper-secondary bg-paper-tertiary rounded-full"
} else {
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-white bg-[#5c7a5e] dark:bg-[#7da97f] dark:text-[#1d1e20] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5c7a5e]/40 dark:focus-visible:ring-[#7da97f]/40"
"inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-paper-theme bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40"
},
disabled: saving_settings() || just_saved() || !dirty,
onclick: move |_| {
@ -317,8 +318,8 @@ pub fn TrashPage(page: i32) -> Element {
// 批量操作栏(选中时显示)
if !selected_ids().is_empty() {
div { class: "flex items-center gap-3 p-3 bg-gray-50 dark:bg-[#2a2a2a] rounded-lg",
span { class: "text-sm text-gray-600 dark:text-[#9b9c9d]",
div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg",
span { class: "text-sm text-paper-secondary",
"已选择 {selected_ids().len()} 条"
}
button {
@ -363,9 +364,9 @@ pub fn TrashPage(page: i32) -> Element {
} else if loading() && posts().is_empty() {
rsx! {
DelayedSkeleton {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] p-6 space-y-4",
div { class: "{ADMIN_CARD_CLASS} p-6 space-y-4",
for _ in 0..5 {
div { class: "h-10 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
SkeletonBox { class: "h-10 rounded" }
}
}
}
@ -381,7 +382,7 @@ pub fn TrashPage(page: i32) -> Element {
div { class: "overflow-x-auto",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
tr { class: "border-b border-paper-border text-left text-paper-secondary",
th { class: "px-4 py-3 font-medium w-10",
input {
r#type: "checkbox",
@ -535,9 +536,9 @@ fn TrashRow(
let badge_class = if expired {
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
} else if remaining <= 7 {
"bg-[#e8f0e8] text-[#5c7a5e] dark:bg-[#1e2e1e] dark:text-[#7da97f]"
"bg-paper-accent-soft text-paper-accent"
} else {
"bg-gray-100 text-gray-600 dark:bg-[#333] dark:text-[#9b9c9d]"
"bg-paper-tertiary text-paper-secondary"
};
let badge_text = if expired { "待清理".to_string() } else { format!("{remaining}") };
let deleted_str = post
@ -556,7 +557,7 @@ fn TrashRow(
}
}
td { class: "px-4 py-3",
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb] truncate max-w-xs",
div { class: "text-sm font-medium text-paper-primary truncate max-w-xs",
"{post.title}"
}
}
@ -566,7 +567,7 @@ fn TrashRow(
label: post.status_label().to_string(),
}
}
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
td { class: "px-4 py-3 text-sm text-paper-secondary",
"{deleted_str}"
}
td { class: "px-4 py-3 text-center",

View File

@ -423,7 +423,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
rsx! {
div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden",
if loading() {
div { class: "absolute inset-0 z-10 bg-white dark:bg-[#1d1e20]",
div { class: "absolute inset-0 z-10 bg-paper-theme",
WriteSkeleton {}
}
}
@ -811,7 +811,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
class: if saving() {
"px-5 py-1.5 text-sm bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)] rounded-xl font-medium cursor-not-allowed"
} else {
"px-5 py-1.5 text-sm bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
"px-5 py-1.5 text-sm bg-[var(--color-paper-accent)] text-[var(--color-paper-theme)] rounded-xl font-medium hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer"
},
disabled: saving(),
onclick: on_submit,

View File

@ -12,6 +12,7 @@
use dioxus::prelude::*;
use crate::api::posts::{list_published_posts, PostListResponse};
use crate::components::empty_state::EmptyState;
use crate::components::post_card::PostCard;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::home_skeleton::HomeSkeleton;
@ -67,8 +68,9 @@ fn HomePosts(current_page: i32) -> Element {
// 注意total > 0 但 posts 为空(如越界页码 /page/9999也不显示空状态
// 避免误导用户以为站点无内容;此时仅靠下方的分页导航引导回有效页。
if total == 0 {
div { class: "text-center text-paper-secondary py-20",
"暂无文章"
EmptyState {
title: "还没有文章",
description: "这里会收集你写下的每一篇文字。",
}
}
// 仅在有文章时渲染分页导航,避免越界页码下出现孤立的空分页。