Compare commits
10 Commits
2f861d3e6a
...
7611c60e6c
| Author | SHA1 | Date | |
|---|---|---|---|
| 7611c60e6c | |||
| 806b474599 | |||
| 519c0ae3a9 | |||
| df85df3d2e | |||
| cfd2af1fab | |||
| 97b413dd50 | |||
| 8b581649a6 | |||
| 4ec8d72b66 | |||
| 21322a4339 | |||
| 7242c7d8f5 |
27
AGENTS.md
27
AGENTS.md
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
public/images/xiaotiaoxiaogou_01.webp
Normal file
BIN
public/images/xiaotiaoxiaogou_01.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@ -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", "未登录,正在跳转..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
|
||||
66
src/components/empty_state.rs
Normal file
66
src/components/empty_state.rs
Normal 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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,8 @@ pub mod admin_layout;
|
||||
pub mod admin_skeleton;
|
||||
/// 评论相关组件。
|
||||
pub mod comments;
|
||||
/// 空状态组件(无数据时的插画配图 + 文案)。
|
||||
pub mod empty_state;
|
||||
/// 页脚组件。
|
||||
pub mod footer;
|
||||
/// 表单控件组件。
|
||||
|
||||
@ -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 },
|
||||
}
|
||||
|
||||
@ -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" } }
|
||||
|
||||
@ -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}" }
|
||||
|
||||
@ -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;
|
||||
|
||||
183
src/db/pool.rs
183
src/db/pool.rs
@ -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")); // 点
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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",
|
||||
"文章删除后保留的时长,到期后自动彻底清除(1–365)"
|
||||
}
|
||||
}
|
||||
// 数字输入 + 步进按钮 + 单位后缀
|
||||
div { class: "flex items-center gap-3",
|
||||
div { class: "flex items-center rounded-lg border border-gray-300 dark:border-[#444] bg-white dark:bg-[#1d1e20] overflow-hidden",
|
||||
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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: "这里会收集你写下的每一篇文字。",
|
||||
}
|
||||
}
|
||||
// 仅在有文章时渲染分页导航,避免越界页码下出现孤立的空分页。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user