feat: update tag and timeline share panel copy for clarity and conciseness
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s

style: enhance global CSS for better responsiveness of terminal chips and navigation pills

test: remove inline subscription test and add maintenance mode access code test

feat: implement media library picker dialog for selecting images from the media library

feat: add media URL controls for uploading and managing media assets

feat: add migration for music_enabled and maintenance_mode settings in site settings

feat: implement maintenance mode functionality with access control

feat: create maintenance page with access code input and error handling

chore: add TypeScript declaration for QR code module
This commit is contained in:
2026-04-02 23:05:49 +08:00
parent 6a50dd478c
commit 9665c933b5
94 changed files with 5266 additions and 1612 deletions

View File

@@ -4,6 +4,7 @@
use axum::http::HeaderMap;
use loco_rs::prelude::*;
use sha2::{Digest, Sha256};
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
@@ -89,6 +90,12 @@ pub struct SiteSettingsPayload {
pub tech_stack: Option<Vec<String>>,
#[serde(default, alias = "musicPlaylist")]
pub music_playlist: Option<Vec<MusicTrackPayload>>,
#[serde(default, alias = "musicEnabled")]
pub music_enabled: Option<bool>,
#[serde(default, alias = "maintenanceModeEnabled")]
pub maintenance_mode_enabled: Option<bool>,
#[serde(default, alias = "maintenanceAccessCode")]
pub maintenance_access_code: Option<String>,
#[serde(default, alias = "aiEnabled")]
pub ai_enabled: Option<bool>,
#[serde(default, alias = "paragraphCommentsEnabled")]
@@ -199,6 +206,7 @@ pub struct PublicSiteSettingsResponse {
pub location: Option<String>,
pub tech_stack: Option<serde_json::Value>,
pub music_playlist: Option<serde_json::Value>,
pub music_enabled: bool,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
pub comment_verification_mode: String,
@@ -217,6 +225,31 @@ pub struct PublicSiteSettingsResponse {
pub seo_wechat_share_qr_enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceAccessTokenPayload {
#[serde(default, alias = "accessToken")]
pub access_token: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceVerifyPayload {
#[serde(default)]
pub code: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct MaintenanceAccessStatusResponse {
pub maintenance_mode_enabled: bool,
pub access_granted: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct MaintenanceVerifyResponse {
pub maintenance_mode_enabled: bool,
pub access_granted: bool,
pub access_token: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct HomeCategorySummary {
pub id: i32,
@@ -252,6 +285,51 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
value.map(|item| item.clamp(min, max))
}
fn maintenance_mode_enabled(model: &Model) -> bool {
model.maintenance_mode_enabled.unwrap_or(false)
}
fn maintenance_access_code(model: &Model) -> Option<String> {
normalize_optional_string(model.maintenance_access_code.clone())
}
fn maintenance_access_token_from_secret(secret: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"termi-maintenance-access:v1:");
hasher.update(secret.as_bytes());
let digest = hasher.finalize();
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn validate_maintenance_access_token(model: &Model, token: Option<&str>) -> bool {
let Some(candidate) = token.and_then(|item| {
let trimmed = item.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}) else {
return false;
};
let Some(secret) = maintenance_access_code(model) else {
return false;
};
candidate == maintenance_access_token_from_secret(&secret)
}
fn verify_maintenance_access_code(model: &Model, code: Option<&str>) -> Option<String> {
let candidate = code.and_then(|item| {
let trimmed = item.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})?;
let secret = maintenance_access_code(model)?;
(candidate == secret).then(|| maintenance_access_token_from_secret(&secret))
}
fn normalize_notification_channel_type(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let normalized = item.trim().to_ascii_lowercase();
@@ -272,7 +350,7 @@ pub(crate) fn default_subscription_popup_title() -> String {
}
pub(crate) fn default_subscription_popup_description() -> String {
"有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订".to_string()
"有新内容时及时提醒你;如果愿意,也可以再留一个邮箱备份".to_string()
}
pub(crate) fn default_subscription_popup_delay_seconds() -> i32 {
@@ -555,6 +633,15 @@ impl SiteSettingsPayload {
if let Some(music_playlist) = self.music_playlist {
item.music_playlist = Some(serde_json::json!(normalize_music_playlist(music_playlist)));
}
if let Some(music_enabled) = self.music_enabled {
item.music_enabled = Some(music_enabled);
}
if let Some(maintenance_mode_enabled) = self.maintenance_mode_enabled {
item.maintenance_mode_enabled = Some(maintenance_mode_enabled);
}
if self.maintenance_access_code.is_some() {
item.maintenance_access_code = normalize_optional_string(self.maintenance_access_code);
}
if let Some(ai_enabled) = self.ai_enabled {
item.ai_enabled = Some(ai_enabled);
}
@@ -752,10 +839,10 @@ fn default_payload() -> SiteSettingsPayload {
site_name: Some("InitCool".to_string()),
site_short_name: Some("Termi".to_string()),
site_url: Some("https://init.cool".to_string()),
site_title: Some("InitCool - 终端风格的内容平台".to_string()),
site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活".to_string()),
hero_title: Some("欢迎来到我的极客终端博客".to_string()),
hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()),
site_title: Some("InitCool · 技术笔记与内容档案".to_string()),
site_description: Some("围绕开发实践、产品观察与长期积累整理的中文内容站".to_string()),
hero_title: Some("欢迎来到 InitCool".to_string()),
hero_subtitle: Some("记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。".to_string()),
owner_name: Some("InitCool".to_string()),
owner_title: Some("Rust / Go / Python Developer · Builder @ init.cool".to_string()),
owner_bio: Some(
@@ -813,6 +900,9 @@ fn default_payload() -> SiteSettingsPayload {
description: Some("节奏更明显一点,适合切换阅读状态。".to_string()),
},
]),
music_enabled: Some(true),
maintenance_mode_enabled: Some(false),
maintenance_access_code: None,
ai_enabled: Some(false),
paragraph_comments_enabled: Some(true),
comment_verification_mode: Some(
@@ -923,6 +1013,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
location: model.location,
tech_stack: model.tech_stack,
music_playlist: model.music_playlist,
music_enabled: model.music_enabled.unwrap_or(true),
ai_enabled: model.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
comment_verification_mode: comment_verification_mode.as_str().to_string(),
@@ -1019,6 +1110,50 @@ pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
format::json(public_response(load_current(&ctx).await?))
}
#[debug_handler]
pub async fn maintenance_status(
State(ctx): State<AppContext>,
Json(params): Json<MaintenanceAccessTokenPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
let access_granted = if enabled {
validate_maintenance_access_token(&current, params.access_token.as_deref())
} else {
true
};
format::json(MaintenanceAccessStatusResponse {
maintenance_mode_enabled: enabled,
access_granted,
})
}
#[debug_handler]
pub async fn maintenance_verify(
State(ctx): State<AppContext>,
Json(params): Json<MaintenanceVerifyPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
if !enabled {
return format::json(MaintenanceVerifyResponse {
maintenance_mode_enabled: false,
access_granted: true,
access_token: None,
});
}
let access_token = verify_maintenance_access_code(&current, params.code.as_deref());
format::json(MaintenanceVerifyResponse {
maintenance_mode_enabled: true,
access_granted: access_token.is_some(),
access_token,
})
}
#[debug_handler]
pub async fn update(
headers: HeaderMap,
@@ -1039,6 +1174,8 @@ pub fn routes() -> Routes {
Routes::new()
.prefix("api/site_settings/")
.add("home", get(home))
.add("maintenance/status", post(maintenance_status))
.add("maintenance/verify", post(maintenance_verify))
.add("/", get(show))
.add("/", put(update))
.add("/", patch(update))