优化SQL查询构建,新增NOT条件支持,修复文本转义逻辑,更新主题切换逻辑,改进加载动画,更新依赖项,重构主题管理组件,修复多个小问题。

This commit is contained in:
lsy 2024-12-12 20:18:08 +08:00
parent 507595a269
commit 195f8de576
9 changed files with 353 additions and 298 deletions

View File

@ -199,7 +199,7 @@ impl SafeValue {
SafeValue::Float(f) => Ok(f.to_string()),
SafeValue::Text(s, level) => {
TextValidator::default().validate(s, *level)?;
Ok(format!("{}", s.replace('\'', "''")))
Ok(format!("{}", s))
}
SafeValue::DateTime(dt) => Ok(format!("{}", dt.to_rfc3339())),
}
@ -254,8 +254,7 @@ pub enum Operator {
Lte,
Like,
In,
IsNull,
IsNotNull,
IsNull
}
impl Operator {
@ -269,8 +268,7 @@ impl Operator {
Operator::Lte => "<=",
Operator::Like => "LIKE",
Operator::In => "IN",
Operator::IsNull => "IS NULL",
Operator::IsNotNull => "IS NOT NULL",
Operator::IsNull => "IS NULL"
}
}
}
@ -297,6 +295,7 @@ pub enum WhereClause {
And(Vec<WhereClause>),
Or(Vec<WhereClause>),
Condition(Condition),
Not(Condition)
}
#[derive(Debug, Clone)]
@ -463,6 +462,10 @@ impl QueryBuilder {
WhereClause::Condition(condition) => {
self.build_condition(condition, &mut params, param_index)?
}
WhereClause::Not(condition) => {
let condition_sql = self.build_condition(condition, &mut params, param_index)?;
format!("NOT ({})", condition_sql)
}
};
Ok((sql, params))

View File

@ -77,7 +77,7 @@ impl Database {
db: Arc::new(db),
prefix: Arc::new(database.db_prefix.clone()),
db_type: Arc::new(match database.db_type.to_lowercase().as_str() {
// "postgresql" => DatabaseType::PostgreSQL,
"postgresql" => DatabaseType::PostgreSQL,
"mysql" => DatabaseType::MySQL,
"sqllite" => DatabaseType::SQLite,
_ => return Err("unknown database type".into_custom_error()),

View File

@ -1,7 +1,7 @@
use super::builder::{Condition, Identifier, Operator, SafeValue, ValidationLevel, WhereClause};
use super::DatabaseType;
use crate::common::error::{CustomErrorInto, CustomResult};
use std::fmt::Display;
use std::fmt::{format, Display};
#[derive(Debug, Clone, PartialEq)]
pub enum FieldType {
@ -200,6 +200,7 @@ impl Field {
Err("Invalid IN clause value".into_custom_error())
}
}
Operator::IsNull => Ok(format!("{} IS NULL", field_name)),
Operator::Eq
| Operator::Ne
| Operator::Gt
@ -220,9 +221,26 @@ impl Field {
_ => Err("Unsupported operator for CHECK constraint".into_custom_error()),
}
}
_ => {
Err("Only simple conditions are supported for CHECK constraints"
.into_custom_error())
WhereClause::And(conditions) => {
let conditions: CustomResult<Vec<String>> = conditions
.iter()
.map(|c| Self::build_check_constraint(c))
.collect();
Ok(format!("({})", conditions?.join(" AND ")))
}
WhereClause::Or(conditions) => {
let conditions: CustomResult<Vec<String>> = conditions
.iter()
.map(|c| Self::build_check_constraint(c))
.collect();
Ok(format!("({})", conditions?.join(" OR ")))
}
WhereClause::Not(condition) => {
let inner_condition = WhereClause::Condition(condition.clone());
Ok(format!(
"NOT ({})",
Self::build_check_constraint(&inner_condition)?
))
}
}
}
@ -372,8 +390,62 @@ impl SchemaBuilder {
pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomResult<String> {
let db_prefix = db_prefix.to_string()?;
let mut schema = SchemaBuilder::new();
let user_level = "('contributor', 'administrator')";
let content_state = "('draft', 'published', 'private', 'hidden')";
let role_check = WhereClause::Or(vec![
WhereClause::Condition(Condition::new(
"role".to_string(),
Operator::Eq,
Some(SafeValue::Text(
"'contributor'".to_string(),
ValidationLevel::Raw,
)),
)?),
WhereClause::Condition(Condition::new(
"role".to_string(),
Operator::Eq,
Some(SafeValue::Text(
"'administrator'".to_string(),
ValidationLevel::Raw,
)),
)?),
]);
let content_state_check = WhereClause::Or(vec![
WhereClause::Condition(Condition::new(
"status".to_string(),
Operator::Eq,
Some(SafeValue::Text(
"'published'".to_string(),
ValidationLevel::Raw,
)),
)?),
WhereClause::Condition(Condition::new(
"status".to_string(),
Operator::Eq,
Some(SafeValue::Text(
"'private'".to_string(),
ValidationLevel::Raw,
)),
)?),
WhereClause::Condition(Condition::new(
"status".to_string(),
Operator::Eq,
Some(SafeValue::Text(
"'hidden'".to_string(),
ValidationLevel::Raw,
)),
)?),
]);
let target_type_check = WhereClause::Or(vec![
WhereClause::Condition(Condition::new(
"target_type".to_string(),
Operator::Eq,
Some(SafeValue::Text("'post'".to_string(), ValidationLevel::Raw)),
)?),
WhereClause::Condition(Condition::new(
"target_type".to_string(),
Operator::Eq,
Some(SafeValue::Text("'page'".to_string(), ValidationLevel::Raw)),
)?),
]);
// 用户表
let mut users_table = Table::new(&format!("{}users", db_prefix))?;
@ -405,16 +477,7 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.add_field(Field::new(
"role",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(WhereClause::Condition(Condition::new(
"role".to_string(),
Operator::In,
Some(SafeValue::Text(
user_level.to_string(),
ValidationLevel::Relaxed,
)),
)?)),
FieldConstraint::new().not_null().check(role_check.clone()),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
@ -438,9 +501,7 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.add_field(Field::new(
"last_login_at",
FieldType::Timestamp,
FieldConstraint::new()
.not_null()
.default(SafeValue::Text(
FieldConstraint::new().not_null().default(SafeValue::Text(
"CURRENT_TIMESTAMP".to_string(),
ValidationLevel::Strict,
)),
@ -480,16 +541,7 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.add_field(Field::new(
"status",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(WhereClause::Condition(Condition::new(
"status".to_string(),
Operator::In,
Some(SafeValue::Text(
content_state.to_string(),
ValidationLevel::Standard,
)),
)?)),
FieldConstraint::new().not_null().check(content_state_check.clone()),
ValidationLevel::Strict,
)?);
@ -535,16 +587,7 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.add_field(Field::new(
"status",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(WhereClause::Condition(Condition::new(
"status".to_string(),
Operator::In,
Some(SafeValue::Text(
content_state.to_string(),
ValidationLevel::Standard,
)),
)?)),
FieldConstraint::new().not_null().check(content_state_check.clone()),
ValidationLevel::Strict,
)?)
.add_field(Field::new(
@ -678,47 +721,47 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
metadata_table
.add_field(Field::new(
"id",
FieldType::Integer(true),
FieldType::Integer(true),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"target_type",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(WhereClause::Condition(Condition::new(
"target_type".to_string(),
Operator::In,
Some(SafeValue::Text(
"('post', 'page')".to_string(),
ValidationLevel::Standard,
)),
)?)),
FieldConstraint::new().not_null().check(target_type_check.clone()),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"target_id",
FieldType::Integer(false),
FieldConstraint::new()
.not_null()
.check(WhereClause::Condition(Condition::new(
"(target_type = 'post' AND EXISTS (SELECT 1 FROM posts WHERE id = target_id)) OR \
(target_type = 'page' AND EXISTS (SELECT 1 FROM pages WHERE id = target_id))".to_string(),
Operator::Raw,
None,
)?)),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"meta_key",
FieldType::VarChar(50),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"meta_value",
FieldType::Text,
FieldConstraint::new(),
ValidationLevel::Strict,
)?);
metadata_table.add_index(Index::new(
"fk_metadata_posts",
vec!["target_id".to_string()],
false,
)?);
metadata_table.add_index(Index::new(
"fk_metadata_pages",
vec!["target_id".to_string()],
false,
)?);
metadata_table.add_index(Index::new(
"idx_metadata_target",
vec!["target_type".to_string(), "target_id".to_string()],
@ -735,36 +778,32 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
FieldType::Integer(true),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"target_type",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(WhereClause::Condition(Condition::new(
"target_type".to_string(),
Operator::In,
Some(SafeValue::Text(
"('post', 'page')".to_string(),
ValidationLevel::Standard,
)),
)?)),
FieldConstraint::new().not_null().check(target_type_check.clone()),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"target_id",
FieldType::Integer(false),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"field_key",
FieldType::VarChar(50),
FieldConstraint::new().not_null(),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"field_value",
FieldType::Text,
FieldConstraint::new(),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"field_type",
FieldType::VarChar(20),
FieldConstraint::new().not_null(),
@ -788,37 +827,45 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
FieldType::VarChar(50),
FieldConstraint::new().primary(),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"slug",
FieldType::VarChar(50),
FieldConstraint::new().not_null().unique(),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"type",
FieldType::VarChar(20),
FieldConstraint::new()
.not_null()
.check(WhereClause::Condition(Condition::new(
"type".to_string(),
Operator::In,
Some(SafeValue::Text(
"('tag', 'category')".to_string(),
ValidationLevel::Standard,
)),
)?)),
.check(WhereClause::Or(vec![
WhereClause::Condition(Condition::new(
"type".to_string(),
Operator::Eq,
Some(SafeValue::Text(
"'tag'".to_string(),
ValidationLevel::Raw,
)),
)?),
WhereClause::Condition(Condition::new(
"type".to_string(),
Operator::Eq,
Some(SafeValue::Text(
"'category'".to_string(),
ValidationLevel::Raw,
)),
)?),
])),
ValidationLevel::Strict,
)?).add_field(Field::new(
"parent_id",
)?)
.add_field(Field::new(
"parent_name",
FieldType::VarChar(50),
FieldConstraint::new()
.foreign_key(format!("{}taxonomies", db_prefix), "name".to_string())
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade)
.check(WhereClause::Condition(Condition::new(
"(type = 'category' OR parent_id IS NULL)".to_string(),
Operator::Raw,
None,
)?)),
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?);
@ -836,7 +883,8 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
ValidationLevel::Strict,
)?).add_field(Field::new(
)?)
.add_field(Field::new(
"taxonomy_id",
FieldType::VarChar(50),
FieldConstraint::new()

View File

@ -456,30 +456,75 @@ const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
);
};
const SetupComplete: React.FC = () => (
<StepContainer title="安装完成">
<Flex direction="column" align="center" gap="4">
<Text size="5" weight="medium">
</Text>
<Text size="3">...</Text>
<Box mt="4">
<Flex justify="center">
<Box className="animate-spin rounded-full h-8 w-8 border-b-2 border-current"></Box>
</Flex>
</Box>
</Flex>
</StepContainer>
);
const SetupComplete: React.FC = () => {
useEffect(() => {
// 添加延迟后刷新页面
const timer = setTimeout(() => {
window.location.reload();
}, 3000);
return () => clearTimeout(timer);
}, []);
return (
<StepContainer title="安装完成">
<Flex direction="column" align="center" gap="4">
<Text size="5" weight="medium">
</Text>
<Text size="3">...</Text>
<Box mt="4">
<Flex justify="center">
<Box className="animate-spin rounded-full h-8 w-8 border-b-2 border-current"></Box>
</Flex>
</Box>
</Flex>
</StepContainer>
);
};
export default function SetupPage() {
const [currentStep, setCurrentStep] = useState(1);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// 在客户端组件挂载后更新状态
const initStatus = Number(import.meta.env.VITE_INIT_STATUS ?? 0) + 1;
setCurrentStep(initStatus);
// 标记客户端渲染完成
setIsClient(true);
// 获取初始化状态
const initStatus = Number(import.meta.env.VITE_INIT_STATUS ?? 0);
// 如果已完成初始化,直接刷新页面
if (initStatus >= 3) {
window.location.reload();
return;
}
// 否则设置当前步骤
setCurrentStep(initStatus + 1);
}, []);
// 在服务端渲染时或客户端首次渲染时,返回加载状态
if (!isClient) {
return (
<Theme
grayColor="gray"
accentColor="gray"
radius="medium"
panelBackground="solid"
appearance="inherit"
>
<Box className="min-h-screen w-full">
<Flex justify="center" pt="2">
<Box className="w-20 h-20">
<Echoes />
</Box>
</Flex>
</Box>
</Theme>
);
}
return (
<Theme
grayColor="gray"
@ -490,7 +535,9 @@ export default function SetupPage() {
>
<Box className="min-h-screen w-full">
<Box position="fixed" top="2" right="4">
<ThemeModeToggle />
<Box className="w-10 h-10 flex items-center justify-center [&_button]:w-10 [&_button]:h-10 [&_svg]:w-6 [&_svg]:h-6 [&_button]:text-[--gray-12] [&_button:hover]:text-[--accent-9]">
<ThemeModeToggle />
</Box>
</Box>
<Flex justify="center" pt="2">

View File

@ -7,7 +7,7 @@ import {
} from "@remix-run/react";
import { NotificationProvider } from "hooks/Notification";
import { Theme } from "@radix-ui/themes";
import { ThemeScript } from "hooks/ThemeMode";
import { ThemeScript } from "hooks/themeMode";
import "~/index.css";
@ -70,3 +70,4 @@ export function Layout() {
export default function App() {
return <Layout />;
}

View File

@ -757,7 +757,7 @@ export const ParticleImage = ({
const ctx = canvas.getContext('2d');
if (ctx) {
// 增加一个小的边距以确保<EFBFBD><EFBFBD>
// 增加一个小的边距以确保
const padding = 2; // 添加2像素的距
canvas.width = width + padding * 2;
canvas.height = height + padding * 2;
@ -961,7 +961,7 @@ export const ParticleImage = ({
containerRef.current.removeChild(renderer.domElement);
renderer.dispose();
}
// 清有 GSAP <EFBFBD><EFBFBD><EFBFBD>
// 清有 GSAP
gsap.killTweensOf('*');
// 移除 resize 监听
@ -1043,11 +1043,6 @@ const loadingQueue = {
add(url: string, instanceId: string, isLongConnection = false) {
const key = `${instanceId}:${url}`;
if (!this.items.has(key)) {
console.log('[Queue] Adding:', key,
isLongConnection ? '(long connection)' : '',
'Current processing:', this.currentProcessing,
'Max concurrent:', this.maxConcurrent
);
this.items.set(key, {
isProcessing: false,
@ -1056,7 +1051,7 @@ const loadingQueue = {
lastActiveTime: Date.now()
});
// 连接再直接进入备选队列,而是等待加载完成后再加入
// 连接<EFBFBD><EFBFBD>再直接进入备选队列,而是等待加载完成后再加入
this.processQueue();
return true;
}
@ -1064,32 +1059,15 @@ const loadingQueue = {
},
processQueue() {
console.log('[Queue] Processing queue:', {
availableSlots: this.availableSlots,
currentProcessing: this.currentProcessing,
pendingQueueSize: this.pendingQueue.size,
totalItems: this.items.size
});
if (this.availableSlots > 0) {
let nextKey: string | undefined;
// 优先从备选队列中获取已加载完成的长连接
if (this.pendingQueue.size > 0) {
nextKey = Array.from(this.pendingQueue)[0];
this.pendingQueue.delete(nextKey);
console.log('[Queue] Processing from pending queue:', nextKey);
} else {
// 如果没有待处理的长连接,处理普通请求
const normalItem = Array.from(this.items.entries())
.find(([_, item]) =>
!item.isProcessing &&
!item.isLongConnection
);
if (normalItem) {
nextKey = normalItem[0];
console.log('[Queue] Processing normal request:', nextKey);
}
// 修改这部分逻辑,不再区分长连接和普通请求
const nextItem = Array.from(this.items.entries())
.find(([_, item]) => !item.isProcessing);
if (nextItem) {
nextKey = nextItem[0];
}
if (nextKey) {
@ -1101,17 +1079,12 @@ const loadingQueue = {
startProcessing(key: string) {
const item = this.items.get(key);
if (item && !item.isProcessing) {
console.log('[Queue] Start processing:', key, {
isLongConnection: item.isLongConnection,
isError: key.includes('error')
});
item.isProcessing = true;
// 只有普通请求且不是错误状态时才增加处理数量
if (!item.isLongConnection && !key.includes('error')) {
this.currentProcessing++;
console.log('[Queue] Increased processing count:', this.currentProcessing);
}
}
},
@ -1122,7 +1095,6 @@ const loadingQueue = {
const item = this.items.get(key);
if (item?.isLongConnection) {
this.pendingQueue.add(key);
console.log('[Queue] Added to pending queue:', key);
}
},
@ -1130,27 +1102,16 @@ const loadingQueue = {
const key = `${instanceId}:${url}`;
const item = this.items.get(key);
console.log('[Queue] Removing:', key,
'Is long connection:', item?.isLongConnection,
'Was processing:', item?.isProcessing,
'Has error:', key.includes('error')
);
// 只有普通请求且正在处理时才减少处理数量
if (item?.isProcessing && !item.isLongConnection && !key.includes('error')) {
this.currentProcessing--;
console.log('[Queue] Decreased processing count:', this.currentProcessing);
}
// 确保从队列中移除
this.items.delete(key);
this.pendingQueue.delete(key);
console.log('[Queue] After remove - Processing:', this.currentProcessing,
'Pending queue size:', this.pendingQueue.size,
'Total items:', this.items.size
);
// 如果是错误状态,立即处理下一个请求
if (key.includes('error')) {
this.processQueue();
@ -1166,10 +1127,9 @@ const loadingQueue = {
const key = `${instanceId}:${url}`;
const item = this.items.get(key);
// 错误状态不占用槽位,只在第一次检查时输出日志
// 错误状态的处理保持不变
if (key.includes('error')) {
if (!item?.lastLogTime) {
console.log('[Queue] Can process (error):', key, true);
if (item) {
item.lastLogTime = Date.now();
}
@ -1177,22 +1137,13 @@ const loadingQueue = {
return true;
}
// 长连接在备选队列中时可以处理
if (item?.isLongConnection && this.pendingQueue.has(key)) {
if (!item.lastLogTime || Date.now() - item.lastLogTime > 1000) {
console.log('[Queue] Can process (pending long):', key, true);
item.lastLogTime = Date.now();
}
return true;
}
// 移除长连接的特殊处理,统一处理所有请求
const canProcess = item?.isProcessing || false;
// 只在状态发生变化时或者每秒最多输出一次日志
if (item &&
(item.lastProcessState !== canProcess ||
!item.lastLogTime ||
Date.now() - item.lastLogTime > 1000)) {
console.log('[Queue] Can process (normal):', key, canProcess);
item.lastProcessState = canProcess;
item.lastLogTime = Date.now();
}
@ -1233,7 +1184,6 @@ export const ImageLoader = ({
useEffect(() => {
if (!src) return;
console.log('[Queue] Effect triggered for:', src, 'Instance:', instanceId.current);
setShowImage(false);
setAnimationComplete(false);
setCanShowParticles(false);
@ -1246,7 +1196,6 @@ export const ImageLoader = ({
const timeSinceLastAnimation = now - lastAnimationTime;
if (particleLoadQueue.size === 0) {
console.log('[Queue] Starting immediate animation for:', src, 'Instance:', instanceId.current);
particleLoadQueue.add(src);
setCanShowParticles(true);
lastAnimationTime = now;
@ -1258,12 +1207,10 @@ export const ImageLoader = ({
Math.min(ANIMATION_THRESHOLD, timeSinceLastAnimation)
);
console.log('[Queue] Scheduling delayed animation for:', src, 'Instance:', instanceId.current);
const timer = setTimeout(() => {
const key = `${instanceId.current}:${src}`;
if (!loadingQueue.items.has(key)) return;
console.log('[Queue] Starting delayed animation for:', src, 'Instance:', instanceId.current);
particleLoadQueue.add(src);
setCanShowParticles(true);
lastAnimationTime = Date.now();
@ -1282,7 +1229,6 @@ export const ImageLoader = ({
const cleanup = checkQueue();
return () => {
console.log('[Queue] Cleanup effect for:', src, 'Instance:', instanceId.current);
cleanup?.();
loadingQueue.remove(src, instanceId.current);
};
@ -1393,7 +1339,6 @@ export const ImageLoader = ({
};
img.onerror = () => {
console.log('[Image Loader] Error loading image:', src);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
@ -1429,7 +1374,7 @@ export const ImageLoader = ({
};
}, [src, preloadImage]);
// 添加一个的状来控制粒子动画
// 添加一个<EFBFBD><EFBFBD><EFBFBD>的状来控制粒子动画
const [canShowParticles, setCanShowParticles] = useState(false);
// 添加加载动画组件
@ -1449,25 +1394,20 @@ export const ImageLoader = ({
src={src}
status={status}
onLoad={() => {
console.log('[ParticleImage] onLoad START:', src);
if (imageRef.current) {
// 保持为空
}
console.log('[ParticleImage] onLoad END:', src);
}}
onAnimationComplete={() => {
console.log('[ParticleImage] Animation START:', src);
if (imageRef.current && src) {
setShowImage(true);
requestAnimationFrame(() => {
console.log('[ParticleImage] Setting animation complete:', src);
setAnimationComplete(true);
particleLoadQueue.delete(src);
loadingQueue.remove(src, instanceId.current);
setTimeout(() => {
console.log('[ParticleImage] Fading image:', src);
const img = document.querySelector(`img[src="${imageRef.current?.src}"]`) as HTMLImageElement;
if (img) {
img.style.opacity = '1';
@ -1475,7 +1415,6 @@ export const ImageLoader = ({
}, 50);
});
}
console.log('[ParticleImage] Animation END:', src);
}}
/>
)}

View File

@ -4,89 +4,97 @@ import { Button } from "@radix-ui/themes";
const THEME_KEY = "theme-preference";
// 添加这个脚本来预先设置主题,避免闪烁
// 修改主题脚本,确保在服务端和客户端都能正确初始化
const themeScript = `
(function() {
function getInitialTheme() {
try {
const savedTheme = localStorage.getItem("${THEME_KEY}");
if (savedTheme) {
document.documentElement.className = savedTheme;
return savedTheme;
}
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = isDark ? "dark" : "light";
document.documentElement.className = theme;
localStorage.setItem("${THEME_KEY}", theme);
return theme;
}
// 确保在 DOM 内容加载前执行
if (document.documentElement) {
getInitialTheme();
} else {
document.addEventListener('DOMContentLoaded', getInitialTheme);
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = savedTheme || (prefersDark ? "dark" : "light");
document.documentElement.dataset.theme = theme;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
} catch (e) {
console.error('[ThemeScript] Error:', e);
document.documentElement.dataset.theme = 'light';
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add('light');
}
})()
`;
// ThemeScript 组件需要尽早执行
export const ThemeScript = () => {
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
return (
<script
dangerouslySetInnerHTML={{
__html: themeScript,
}}
/>
);
};
// 客户端专用的 hook
const useClientOnly = (callback: () => void, deps: any[] = []) => {
useEffect(() => {
if (typeof window !== 'undefined') {
callback();
}
}, deps);
};
export const ThemeModeToggle: React.FC = () => {
const [isDark, setIsDark] = useState<boolean | null>(null);
// 初始化主题状态
useEffect(() => {
if (typeof window !== 'undefined') {
const initTheme = () => {
const savedTheme = localStorage.getItem(THEME_KEY);
const currentTheme = document.documentElement.className;
// 确保 localStorage 和 DOM 的主题状态一致
if (savedTheme && savedTheme !== currentTheme) {
document.documentElement.className = savedTheme;
}
setIsDark(savedTheme === 'dark' || currentTheme === 'dark');
};
initTheme();
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem(THEME_KEY)) {
const newTheme = e.matches ? 'dark' : 'light';
document.documentElement.className = newTheme;
setIsDark(e.matches);
}
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange);
};
const [mounted, setMounted] = useState(false);
const [theme, setTheme] = useState<string>(() => {
if (typeof document !== 'undefined') {
return document.documentElement.dataset.theme || 'light';
}
return 'light';
});
useClientOnly(() => {
const currentTheme = document.documentElement.dataset.theme || 'light';
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(currentTheme);
setTheme(currentTheme);
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem(THEME_KEY)) {
const newTheme = e.matches ? 'dark' : 'light';
updateTheme(newTheme);
}
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
return () => mediaQuery.removeEventListener('change', handleSystemThemeChange);
}, [mounted]);
const updateTheme = (newTheme: string) => {
document.documentElement.dataset.theme = newTheme;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(newTheme);
setTheme(newTheme);
};
const toggleTheme = () => {
if (isDark === null) return;
const newIsDark = !isDark;
setIsDark(newIsDark);
const newTheme = newIsDark ? "dark" : "light";
document.documentElement.className = newTheme;
const newTheme = theme === 'dark' ? 'light' : 'dark';
updateTheme(newTheme);
localStorage.setItem(THEME_KEY, newTheme);
};
if (isDark === null) {
if (!mounted) {
return (
<Button
variant="ghost"
className="w-full h-full p-0 rounded-lg transition-all duration-300 transform"
aria-label="Loading theme"
aria-label="Theme toggle"
>
<MoonIcon className="w-full h-full" />
</Button>
@ -100,7 +108,7 @@ export const ThemeModeToggle: React.FC = () => {
className="w-full h-full p-0 rounded-lg transition-all duration-300 transform"
aria-label="Toggle theme"
>
{isDark ? (
{theme === 'dark' ? (
<SunIcon className="w-full h-full" />
) : (
<MoonIcon className="w-full h-full" />
@ -109,44 +117,34 @@ export const ThemeModeToggle: React.FC = () => {
);
};
// 更新类型定义
declare global {
interface Window {
__THEME__?: "light" | "dark";
}
}
export const useThemeMode = () => {
const [mounted, setMounted] = useState(false);
const [mode, setMode] = useState<"light" | "dark">("light");
useEffect(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(THEME_KEY);
if (saved) {
setMode(saved as "light" | "dark");
} else {
const isDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
setMode(isDark ? "dark" : "light");
}
useClientOnly(() => {
const handleThemeChange = () => {
const currentTheme = document.documentElement.dataset.theme as "light" | "dark";
setMode(currentTheme || "light");
};
// 监听主题变化事件
const handleThemeChange = (e: CustomEvent) => {
setMode(e.detail.theme);
};
handleThemeChange();
setMounted(true);
window.addEventListener(
"theme-change",
handleThemeChange as EventListener,
);
return () =>
window.removeEventListener(
"theme-change",
handleThemeChange as EventListener,
);
}
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-theme') {
handleThemeChange();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
return () => observer.disconnect();
}, []);
return { mode };
return { mode: mounted ? mode : "light" };
};

View File

@ -64,6 +64,7 @@
"postcss": "^8.4.38",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.4",
"terser": "^5.37.0",
"tsx": "^4.19.2",
"typescript": "^5.1.6",
"vite": "^5.1.0",

View File

@ -1,5 +1,5 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig, loadEnv } from "vite";
import { defineConfig, loadEnv, ConfigEnv, UserConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { resolve } from "path";
import { readEnvFile } from "./server/env";
@ -30,7 +30,7 @@ const createDefineConfig = (config: EnvConfig) => {
);
};
export default defineConfig(async ({ mode }) => {
export default defineConfig(async ({ mode }: ConfigEnv): Promise<UserConfig> => {
// 确保每次都读取最新的环境变量
const currentConfig = await getLatestEnv();
const env = loadEnv(mode, process.cwd(), "VITE_");
@ -64,8 +64,7 @@ export default defineConfig(async ({ mode }) => {
],
define: createDefineConfig(currentConfig),
server: {
host: true,
address: currentConfig.VITE_ADDRESS,
host: currentConfig.VITE_ADDRESS,
port: Number(env.VITE_SYSTEM_PORT ?? currentConfig.VITE_PORT),
strictPort: true,
hmr: true,
@ -89,9 +88,28 @@ export default defineConfig(async ({ mode }) => {
envPrefix: "VITE_",
build: {
rollupOptions: {
// 移除 manualChunks 配置
output: {
manualChunks(id) {
// 根据模块路径进行代码分割
if (id.includes('node_modules')) {
return 'vendor';
}
}
}
},
chunkSizeWarningLimit: 1500
chunkSizeWarningLimit: 1500,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
cssMinify: true,
cssCodeSplit: true,
sourcemap: mode !== 'production',
assetsInlineLimit: 4096,
reportCompressedSize: false,
},
ssr: {
noExternal: ['three', '@react-three/fiber', '@react-three/drei', 'gsap']