首页
赞助博主
友链
关于
随机CG图
推荐
我的B站主页
我的歌单
我的bgm
井字棋
待办事项
github加速
Search
1
iOS永久不续签随意装软件,trollstore巨魔商店安装教程
3,427 阅读
2
进入自己原神服务器
1,751 阅读
3
linux云服开原神服务器
1,606 阅读
4
从零开始的mc联机教程
1,120 阅读
5
win上开原神服务器
1,108 阅读
默认分类
原神
MC
iOS
galgame
ReinaManager
学习笔记
开发笔记
登录
Search
标签搜索
原神
私服
win
reinamanager
安卓
rust
tauri
react
seaorm
mui
react router
tauri-plugin-sql
migration
sea-orm-cli
基线迁移
数据库迁移
火神80
累计撰写
12
篇文章
累计收到
14
条评论
首页
栏目
默认分类
原神
MC
iOS
galgame
ReinaManager
学习笔记
开发笔记
页面
赞助博主
友链
关于
随机CG图
推荐
我的B站主页
我的歌单
我的bgm
井字棋
待办事项
github加速
搜索到
12
篇与
的结果
2025-10-04
seaorm迁移初体验——从tauri-plugin-sql重构到seaorm #1 数据库迁移脚本
前言随着reinamanager的逐步开发,我发现games表实在太臃肿了,为了以后能更好的,添加新的数据源、交叉显示游戏数据、发挥各数据源的特性等,于是我决定把games表拆分成多个表;由于项目初期使用了tauri-plugin-sql插件,games表拆分后会导致repository层多个game数据表的交互逻辑变得异常复杂,换言之就是sql语句会变得很复杂。再加上之前有人建议我使用orm代替纯sql查询issue。那就来吧!要说rust家族里的orm,那肯定首推seaorm。追平与基线迁移原来使用的是tauri-plugin-sql,想彻底重构到seaorm得做基线迁移,在基线迁移脚本中判断用户类型,新用户运行数据库初始化函数,创建全新的数据库结构,老用户先运行旧的迁移脚本(追平),然后将旧的迁移表_sqlx_migrations备份一份,以完成基线迁移。使用sea-orm-migration来创建一个迁移crate:cargo install sea-orm-cli sea-orm-cli migrate init sea-orm-cli migrate generate xxx因为旧的迁移脚本是基于sqlx的,还有数据库放在AppData下,所以为migration crate添加sqlx、dirs-next、url等依赖:[dependencies] sqlx = { version = "0.8", features = [ "sqlite", "runtime-tokio-native-tls", "migrate", ] } dirs-next = "2" url = "2"基线迁移脚本:use sea_orm::{ConnectionTrait, DatabaseBackend, Statement}; use sea_orm_migration::prelude::*; use sea_orm_migration::sea_orm::TransactionTrait; # [derive(DeriveMigrationName)] pub struct Migration; # [async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { let conn = manager.get_connection(); // 开启事务,保证所有操作的原子性 let txn = conn.begin().await?; // 判断是否为新用户 - 检查是否存在任何遗留数据表 let is_new_user = !has_any_legacy_tables(&txn).await?; if is_new_user { println!("[MIGRATION] New user detected, creating modern split table structure"); create_modern_schema(&txn).await?; } else { println!("[MIGRATION] Existing user detected, running legacy migration catch-up"); run_legacy_migrations_with_sqlx().await?; } // 提交事务 txn.commit().await?; println!("[MIGRATION] v1 baseline schema created successfully"); Ok(()) } } /// 检查是否存在任何遗留数据表或数据 async fn has_any_legacy_tables<C>(conn: &C) -> Result<bool, DbErr> where C: ConnectionTrait, { // 检查是否存在 tauri-plugin-sql 的迁移表 let legacy_migration_exists = conn .query_one(Statement::from_string( DatabaseBackend::Sqlite, "SELECT 1 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'", )) .await? .is_some(); Ok(legacy_migration_exists) } /// 为新用户创建现代的拆分表结构 async fn create_modern_schema<C>(conn: &C) -> Result<(), DbErr> where C: ConnectionTrait, { ...... // 5. 创建关联表 create_related_tables(conn).await?; // 6. 创建现代结构的索引 create_modern_indexes(conn).await?; Ok(()) } /// 创建关联表(游戏会话、统计、存档等) async fn create_related_tables<C>(conn: &C) -> Result<(), DbErr> where C: ConnectionTrait, { ...... Ok(()) } /// 为现代拆分结构创建索引 async fn create_modern_indexes<C>(conn: &C) -> Result<(), DbErr> where C: ConnectionTrait, { let indexes = [ // games 表索引 ...... ]; for (index_name, table_name, column_name) in &indexes { conn.execute(Statement::from_string( DatabaseBackend::Sqlite, format!( r#"CREATE INDEX IF NOT EXISTS "{}" ON "{}" ("{}")"#, index_name, table_name, column_name ), )) .await?; } Ok(()) } /// 为现有用户运行旧的 tauri-plugin-sql 迁移,使用 sqlx 执行 async fn run_legacy_migrations_with_sqlx() -> Result<(), DbErr> { println!("[MIGRATION] Running legacy migrations with sqlx..."); // 获取数据库连接 URL(从系统目录推导) let database_url = get_db_path()?; // 创建 sqlx 连接池 let pool = sqlx::SqlitePool::connect(&database_url) .await .map_err(|e| DbErr::Custom(format!("Failed to connect with sqlx: {}", e)))?; // 检查并运行旧迁移 run_legacy_migration_001(&pool).await?; run_legacy_migration_002(&pool).await?; // 清理 sqlx 的迁移记录,因为我们转移到 SeaORM cleanup_sqlx_migration_table(&pool).await?; pool.close().await; println!("[MIGRATION] Legacy migrations completed successfully"); Ok(()) } /// 从系统目录推导数据库连接字符串(无需外部参数) fn get_db_path() -> Result<String, DbErr> { use std::path::PathBuf; // 使用 config_dir (Roaming on Windows) 来匹配原先的 app_data_dir 行为 let base = dirs_next::config_dir() .or_else(dirs_next::data_dir) .ok_or_else(|| DbErr::Custom("Failed to resolve user data directory".to_string()))?; let db_path: PathBuf = base .join("com.reinamanager.dev") .join("data") .join("reina_manager.db"); // 使用 url::Url::from_file_path 保证路径格式正确 let db_url = url::Url::from_file_path(&db_path) .map_err(|_| DbErr::Custom("Invalid database path".to_string()))?; let conn = format!("sqlite:{}?mode=rwc", db_url.path()); Ok(conn) } /// 运行旧迁移 001 - 数据库初始化 async fn run_legacy_migration_001(pool: &sqlx::SqlitePool) -> Result<(), DbErr> { println!("[MIGRATION] Checking legacy migration 001..."); // 检查是否已经执行过这个迁移 let migration_exists = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM _sqlx_migrations WHERE version = 1") .fetch_one(pool) .await .unwrap_or(0) > 0; if migration_exists { println!("[MIGRATION] Migration 001 already applied, skipping"); return Ok(()); } println!("[MIGRATION] Applying migration 001 - database initialization"); // 执行迁移 001 的 SQL let migration_sql = include_str!("../old_migrations/001_database_initialization.sql"); sqlx::query(migration_sql) .execute(pool) .await .map_err(|e| DbErr::Custom(format!("Failed to execute migration 001: {}", e)))?; // 记录迁移 sqlx::query( "INSERT INTO _sqlx_migrations (version, description, installed_on, success, checksum, execution_time) VALUES (1, 'database_initialization', datetime('now'), 1, 0, 0)" ) .execute(pool) .await .map_err(|e| DbErr::Custom(format!("Failed to record migration 001: {}", e)))?; println!("[MIGRATION] Migration 001 applied successfully"); Ok(()) } /// 运行旧迁移 002 - 添加自定义字段 async fn run_legacy_migration_002(pool: &sqlx::SqlitePool) -> Result<(), DbErr> { println!("[MIGRATION] Checking legacy migration 002..."); // 检查是否已经执行过这个迁移 let migration_exists = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM _sqlx_migrations WHERE version = 2") .fetch_one(pool) .await .unwrap_or(0) > 0; if migration_exists { println!("[MIGRATION] Migration 002 already applied, skipping"); return Ok(()); } println!("[MIGRATION] Applying migration 002 - add custom fields"); // 执行迁移 002 的 SQL let migration_sql = include_str!("../old_migrations/002_add_custom_fields.sql"); sqlx::query(migration_sql) .execute(pool) .await .map_err(|e| DbErr::Custom(format!("Failed to execute migration 002: {}", e)))?; // 记录迁移 sqlx::query( "INSERT INTO _sqlx_migrations (version, description, installed_on, success, checksum, execution_time) VALUES (2, 'add_custom_fields', datetime('now'), 1, 0, 0)" ) .execute(pool) .await .map_err(|e| DbErr::Custom(format!("Failed to record migration 002: {}", e)))?; println!("[MIGRATION] Migration 002 applied successfully"); Ok(()) } /// 清理 sqlx 的迁移记录表,为转移到 SeaORM 做准备 async fn cleanup_sqlx_migration_table(pool: &sqlx::SqlitePool) -> Result<(), DbErr> { println!("[MIGRATION] Cleaning up sqlx migration records..."); // 可选:保留迁移历史但重命名表 sqlx::query("ALTER TABLE _sqlx_migrations RENAME TO _legacy_sqlx_migrations") .execute(pool) .await .map_err(|e| DbErr::Custom(format!("Failed to rename sqlx migrations table: {}", e)))?; println!("[MIGRATION] sqlx migration table renamed to _legacy_sqlx_migrations"); Ok(()) }games表拆分先关闭外键约束,再创建新的核心games表,创建各个数据源的数据表,以及一个other_data表用于存放一些通用数据,其次用旧的games表数据填充新的数据表,然后备份、删除并重建受外键影响的表,再然后删除原games表并重命名新表,最后重新开启外键约束,重建数据库以回收空间并整理碎片。games表拆分脚本:use sea_orm::{ConnectionTrait, DatabaseBackend, Statement}; use sea_orm_migration::prelude::*; use sea_orm_migration::sea_orm::TransactionTrait; #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { // 检查是否已经拆分(通过检查 bgm_data 表是否存在) let already_split = manager.has_table("bgm_data").await?; if already_split { // 已经拆分过,直接返回 return Ok(()); } // 执行表拆分逻辑 split_games_table(manager).await?; Ok(()) } } async fn split_games_table(manager: &SchemaManager<'_>) -> Result<(), DbErr> { let conn = manager.get_connection(); // 0. 关闭外键约束 conn.execute(Statement::from_string( DatabaseBackend::Sqlite, "PRAGMA foreign_keys = OFF;", )) .await?; // 开启事务,保证所有操作的原子性 let txn = conn.begin().await?; // 1. 创建新的核心 games 表(只保留本地管理相关字段) // 2. 创建 BGM 数据表 // 3. 创建 VNDB 数据表 // 4. 创建其他数据表 // 5. 迁移数据从原 games 表到新表结构 // 5.1 迁移核心 games 数据 // 5.2 迁移 BGM 相关数据 // 5.3 迁移 VNDB 相关数据 // 5.4 迁移其他数据(custom, Whitecloud 等) // 6. 备份、删除并重建受外键影响的表 // 6.1 处理 game_sessions 表 // 6.2 处理 game_statistics 表 // 6.3 处理 savedata 表 // 7. 删除原 games 表并重命名新表 // 8. 提交事务 txn.commit().await?; // 9. 重新开启外键约束 conn.execute(Statement::from_string( DatabaseBackend::Sqlite, "PRAGMA foreign_keys = ON;", )) .await?; // 10. (推荐) 重建数据库以回收空间并整理碎片 conn.execute_unprepared("VACUUM;").await?; Ok(()) }相比tauri-plugin-sql的sql式迁移脚本,seaorm的rust迁移脚本可太好用了好吧。迁移代码详情见migration
2025年10月04日
10 阅读
0 评论
1 点赞
2025-09-14
在 MUI Toolpad Core(仪表盘布局) + React Router 项目中实现滚动条的保存与恢复
放一只无人认领的希亚(x省流点我跳到原因与解决方案引言:一个“简单”的需求在我开发 ReinaManager 的过程中,我有一个简单的需求:在不同路由页面间切换时,能够保存并恢复页面的滚动条位置。比如:当我在游戏库向下滑动了一段距离,点击进入某个游戏的详情页,然后再返回游戏仓库时,我希望它能回到之前浏览的位置,而不是页面的最顶端。这听起来很简单,对吧?我一开始也这么认为。然而,就是这个看似“简单”的需求,将我拖入了一场长达数天的、与 MUI Toolpad Core 中仪表盘布局(Dashboard Layout)、React Router 和各种状态管理库之间的战斗...第一阶段:天真的尝试 —— KeepAlive 与 Router 的 <ScrollRestoration />1. “釜底抽薪”:组件保活(react-activation)我的第一个想法是:如果页面不被卸载,那滚动条位置不就自然保存下来了吗?于是我引入了 react-activation 库。实际上,react-activation 的组件保活不包括滚动条位置的保存,它提供了一个 saveScrollPosition 属性:2. “官方正统”<ScrollRestoration />React Router v6.4+ 官方提供了一个保存滚动条的解决方案:<ScrollRestoration /> 组件。文档说明,只需要在应用中渲染它,就能自动处理滚动恢复。小结在我的项目中,这两种方法都没能奏效,于是就这样进入了第二阶段...第二阶段:原因的探索 —— 为什么这些方法都不奏效?既然别人造的轮子都没用,那就自己动手搓一个,可是要想自己造轮子,首先得弄清楚为什么这些轮子在我的项目中不适用,不弄清楚这个“为什么”,自定义的方案也无从下手。经过文档翻阅、devtools 调试、排除法(最笨但是很有效 x)等手段,我终于发现了问题的根源:Toolpad Core 仪表盘布局(Dashboard Layout)渲染的滚动容器并不是整个 window,而是在一个 main 标签内,这个 main 标签是由 DashboardLayout 组件渲染的。仪表盘布局结构如下:DashboardLayout (渲染滚动容器 main) └── <Outlet /> (渲染各个页面组件)对于 KeepAlive:它只检测 KeepAlive 子组件中的可滚动元素。如果放在 DashboardLayout 外层,因为路由的切换,Outlet 部分会变化,导致子组件无法缓存,切换路由会让子组件重新渲染(我不是为了保活组件才加 react-activation 这个库的么?)。如果放在子组件外层,如包裹 Library 组件,滚动容器又不是在子组件内,saveScrollPosition 属性就无效了。对于 <ScrollRestoration />:它期望滚动发生在 window 或 document 上。位于 main 标签内的滚动容器不在它的监控范围内,因此它无法正确监听到不同子组件的滚动事件,也就无法保存和恢复滚动位置。第三阶段:自定义方案 —— 自己动手,丰衣足食既然知道了滚动容器是 main 标签,那我就有了这样的想法:在路由切换之前保存滚动条位置,组件加载时用自定义 hook 恢复滚动条。1. 保存滚动位置scrollStore.ts// src/store/scrollStore.ts // 使用 zustand 创建一个简单的全局状态管理,用于保存各个路径的滚动位置 import { create } from 'zustand'; interface ScrollState { scrollPositions: Record<string, number>; setScrollPosition: (path: string, position: number) => void; } export const useScrollStore = create<ScrollState>((set) => ({ scrollPositions: {}, setScrollPosition: (path, position) => set((state) => ({ scrollPositions: { ...state.scrollPositions, [path]: position, }, })), }));scrollUtils.ts// 工具函数,用于保存滚动位置 // src/utils/scrollUtils.ts import { useScrollStore } from '@/store/scrollStore'; //保存指定路径的滚动条位置 export const saveScrollPosition = (path: string) => { const SCROLL_CONTAINER_SELECTOR = 'main'; const container = document.querySelector<HTMLElement>(SCROLL_CONTAINER_SELECTOR); // 增加一个检查,确保容器是可滚动的,避免无效保存 if (container && container.scrollHeight > container.clientHeight) { const scrollTop = container.scrollTop; useScrollStore.setState(state => ({ scrollPositions: { ...state.scrollPositions, [path]: scrollTop, } })); } };2. 恢复滚动位置(二编)useRestoreScroll.tsimport { useEffect, useRef, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { useScrollStore } from '@/store/scrollStore'; import { useActivate, useUnactivate } from 'react-activation'; interface UseScrollRestoreOptions { /** 滚动容器选择器,默认 'main' */ containerSelector?: string; /** 是否正在加载中 */ isLoading?: boolean; /** 超时时间(ms),默认 2000 */ timeout?: number; /** 是否启用调试日志 */ debug?: boolean; /** 内容稳定检测的等待时间(ms),默认 150 */ stabilityDelay?: number; /** 是否在 KeepAlive 中使用 */ useKeepAlive?: boolean; } const DEFAULT_OPTIONS: Required<Omit<UseScrollRestoreOptions, 'isLoading'>> = { containerSelector: 'main', timeout: 1500, debug: false, stabilityDelay: 0, useKeepAlive: false, }; /** * 滚动位置还原 Hook (优化版) * * 特性: * - 智能检测内容是否渲染完成(高度稳定检测) * - 支持滚动到底部的场景 * - 避免滚动抖动和跳跃 * - 自动清理资源,防止内存泄漏 */ export function useScrollRestore( scrollPath: string, options: UseScrollRestoreOptions = {} ) { const { containerSelector, isLoading, timeout, debug, stabilityDelay, useKeepAlive } = { ...DEFAULT_OPTIONS, ...options, }; const location = useLocation(); const { scrollPositions } = useScrollStore(); const cleanupRef = useRef<(() => void) | null>(null); const settledRef = useRef(false); const lastPathRef = useRef<string>(''); const lastHeightRef = useRef(0); const stabilityTimerRef = useRef<number | null>(null); const log = useCallback( (...args: any[]) => { if (debug) console.log('[useScrollRestore]', ...args); }, [debug] ); useEffect(() => { if ('scrollRestoration' in window.history) { window.history.scrollRestoration = 'manual'; } }, []); // 提取滚动恢复逻辑为独立函数 const performScrollRestore = useCallback(() => { // 路径变化时重置状态 if (lastPathRef.current !== location.pathname) { settledRef.current = false; lastPathRef.current = location.pathname; lastHeightRef.current = 0; } // 清理上一次的副作用 if (cleanupRef.current) { log('Cleaning up previous effect'); cleanupRef.current(); cleanupRef.current = null; } if (isLoading) { log('Skipping: isLoading=true'); return; } const container = document.querySelector<HTMLElement>(containerSelector); if (!container) { log('Container not found:', containerSelector); return; } const isTargetPath = location.pathname === scrollPath; const target = isTargetPath ? (scrollPositions[scrollPath] || 0) : 0; log('Target position:', target, 'for path:', location.pathname); // 快速路径:目标为 0 if (target === 0) { container.scrollTop = 0; settledRef.current = true; log('Scrolled to top immediately'); return; } if (settledRef.current) { log('Already settled, skipping'); return; } let ro: ResizeObserver | null = null; let fallbackTimer: number | null = null; // 清理函数(先定义,避免在 performRestore 中引用未定义的变量) const cleanup = () => { if (ro) { ro.disconnect(); ro = null; } if (fallbackTimer !== null) { window.clearTimeout(fallbackTimer); fallbackTimer = null; } if (stabilityTimerRef.current !== null) { window.clearTimeout(stabilityTimerRef.current); stabilityTimerRef.current = null; } }; // 执行滚动恢复 const performRestore = (reason: string) => { if (settledRef.current) return; const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight); const clampedTarget = Math.max(0, Math.min(target, maxScroll)); const prevBehavior = container.style.scrollBehavior; container.style.scrollBehavior = 'auto'; container.scrollTop = clampedTarget; container.style.scrollBehavior = prevBehavior; settledRef.current = true; if (clampedTarget < target) { log(`⚠ Restored to bottom (${clampedTarget}/${target}) - ${reason}`); } else { log(`✓ Restored scroll to ${clampedTarget} - ${reason}`); } // 清理资源 cleanup(); }; // 检查内容高度是否稳定 const checkStability = () => { const currentHeight = container.scrollHeight; const maxScroll = currentHeight - container.clientHeight; log('Height check:', { current: currentHeight, last: lastHeightRef.current, maxScroll, target, }); // 情况1: 内容已经足够高,可以直接恢复 if (maxScroll >= target) { performRestore('content sufficient'); return; } // 情况2: 高度稳定(不再增长) if (lastHeightRef.current > 0 && currentHeight === lastHeightRef.current) { // 高度不再变化,说明内容已渲染完成 // 即使 maxScroll < target,也恢复到最大可滚动位置 performRestore('content stable'); return; } // 更新上次高度 lastHeightRef.current = currentHeight; // 清除旧的稳定性计时器 if (stabilityTimerRef.current !== null) { window.clearTimeout(stabilityTimerRef.current); } // 设置新的稳定性计时器 // 如果在 stabilityDelay 时间内高度没有变化,认为内容已稳定 stabilityTimerRef.current = window.setTimeout(() => { if (!settledRef.current) { checkStability(); } }, stabilityDelay); }; // 立即检查一次 checkStability(); // 使用 ResizeObserver 监听容器尺寸变化 try { ro = new ResizeObserver(() => { if (!settledRef.current) { checkStability(); } }); ro.observe(container); log('ResizeObserver attached'); } catch (err) { log('ResizeObserver not available'); } // 超时保护 fallbackTimer = window.setTimeout(() => { if (!settledRef.current) { log('⏰ Timeout reached, forcing restore'); performRestore('timeout'); } }, timeout); cleanupRef.current = cleanup; return cleanup; }, [location.pathname, scrollPath, scrollPositions, isLoading, containerSelector, timeout, stabilityDelay, log]); // 普通模式:使用 useEffect useEffect(() => { if (!useKeepAlive) { performScrollRestore(); } }, [useKeepAlive, performScrollRestore]); // KeepAlive 模式:使用 useActivate useActivate(() => { if (useKeepAlive) { log('[KeepAlive] 组件激活,触发滚动恢复'); // 重置状态,因为可能是从其他页面返回 settledRef.current = false; lastHeightRef.current = 0; performScrollRestore(); } }); // KeepAlive 失活时清理 useUnactivate(() => { if (useKeepAlive && cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; } }); }3. 使用例子Library.tsx// src/components/Library.tsx import Cards from "@/components/Cards"; import { useScrollRestore } from "@/hooks/useScrollRestore"; export const Libraries: React.FC = () => { useScrollRestore('/libraries', { useKeepAlive: true });//更好支持KeepAlive,如果没使用KeepAlive,则直接传入路径即可。 return ( <Cards /> ) }4. 导航自定义LinkWithScrollSave.tsx// src/components/LinkWithScrollSave.tsx // 自定义 Link 组件,点击时保存滚动位置 import React, { KeyboardEvent } from 'react'; import { LinkProps, useLocation, useNavigate } from 'react-router-dom'; import { saveScrollPosition } from '@/utils/scrollUtils.ts'; export const LinkWithScrollSave: React.FC<LinkProps> = (props) => { const { to, onClick, children, ...rest } = props as any; const location = useLocation(); const navigate = useNavigate(); // 保持原有的滚动保存实现:只在导航前调用一次 saveScrollPosition const handleClick = (event: React.MouseEvent<any>) => { saveScrollPosition(location.pathname); if (props.onClick) { props.onClick(event); } }; const performNavigation = (target: any) => { try { if (typeof target === 'string' || typeof target === 'object') { navigate(target); } } catch (err) { // swallow navigation errors to avoid breaking UI console.error('navigation failed', err); } }; const handleDivClick = (event: React.MouseEvent<HTMLDivElement>) => { handleClick((event as unknown) as React.MouseEvent<HTMLAnchorElement>); performNavigation(to); }; const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); // @ts-ignore - reuse handleClick semantics handleClick((event as unknown) as React.MouseEvent<HTMLAnchorElement>); performNavigation(to); } }; // 渲染为非锚点容器,避免嵌套 <a>。不改动滚动的实现逻辑。 return ( <div role="link" tabIndex={0} onClick={handleDivClick} onKeyDown={handleKeyDown} {...(rest as any)} > {children} </div> ); }; export default LinkWithScrollSave;Layout.tsx// src/components/Layout.tsx // 使用自定义 Link 组件接管导航 import React, { useCallback } from 'react'; import { DashboardLayout, DashboardSidebarPageItem, type SidebarFooterProps, } from '@toolpad/core/DashboardLayout'; import { Outlet } from 'react-router'; import { LinkWithScrollSave } from '../LinkWithScrollSave'; import { NavigationPageItem } from '@toolpad/core/AppProvider'; export const Layout: React.FC = () => { const handleRenderPageItem = useCallback((item: NavigationPageItem, params: any) => { const to = `/${item.segment || ''}`; // 外层不渲染 <a>,而是使用可访问的 div 进行编程式导航, // 在导航前 LinkWithScrollSave 会保存滚动位置,避免嵌套 <a>。 return ( <LinkWithScrollSave to={to} style={{ textDecoration: 'none', color: 'inherit' }}> <DashboardSidebarPageItem item={item} {...params} />//保持原有样式 </LinkWithScrollSave> ); }, []); return ( <DashboardLayout renderPageItem={handleRenderPageItem} > <Outlet /> </DashboardLayout> ); }最终效果
2025年09月14日
23 阅读
0 评论
0 点赞
2025-08-24
使用批处理文件让ReinaManager可以转区运行部分游戏
第一步:准备工作在开始之前,请确保你已完成以下准备:下载 Locale Emulator这是一个必要的转区工具。如果你的电脑上还没有,请前往官方发布页面下载最新版本。下载链接: Locale Emulator Releases on GitHub下载后,将其解压并放置在一个你方便管理的位置(例如 D:\Tools\Locale-Emulator)。下载 start.bat 脚本下载准备好的启动脚本。下载链接: start.bat下载后,将这个 start.bat 文件放入你需要转区运行的游戏的根目录下。第二步:配置 start.bat 脚本这是最关键的一步。我们需要编辑 start.bat 文件,填入正确的路径信息。获取 Locale Emulator 的绝对路径打开你存放 Locale Emulator 的文件夹,并复制其完整的路径。获取游戏目录的绝对路径同样地,打开你的游戏文件夹,并复制其完整的路径。编辑 start.bat 文件右键点击放在游戏目录下的 start.bat 文件,选择“编辑”或用记事本等文本编辑器打开。填入第一个路径:将刚才复制的 Locale Emulator 路径粘贴到第二对双引号内,\符号前。填入第二个路径:将刚才复制的 游戏目录路径粘贴到第三对双引号内,\符号前,并在\符号后,.exe之前写入你需要运行的游戏主程序文件名。修改完成后的 start.bat 文件内容示例:注意:请务必将上面的路径替换为你自己的实际路径。第三步:在ReinaManager中添加或修改游戏现在,我们让 ReinaManager 通过这个脚本来启动游戏。打开 ReinaManager,找到对应的游戏或添加一个新游戏。选择这个start.bat大功告成!现在,你可以直接通过 ReinaManager 点击“启动游戏”,游戏便会自动通过 Locale Emulator 以转区模式运行了。
2025年08月24日
84 阅读
0 评论
1 点赞
2025-08-14
NSIS和MSI两种常见win软件安装程序的区别
前言废话我不是写了一个视觉小说管理工具ReinaManager么,通过Tauri自动打包出来的有三种文件:可执行文件MSI安装包Setup.exe即NSIS安装包那我就在想,这NSIS和MSI两种安装包有啥区别呢?以前不是开发者的时候,感觉也就是一个长得花哨一些,另一个看起来千篇一律这样的区别罢了,那么成为开发者后我的想法还会和之前一样吗?两者的本质区别从它们两的工作原理来讲:NSIS就像一个脚本,这个脚本可以供开发者高度自定义,安装程序就根据这个开发者定制的脚本来一步一步执行。MSI就像一个数据库,安装程序就像在填表,而这个表没什么可自定义的内容,安装过程可以说是千篇一律的,由Windows Installer管理这张表,可以说是高度的标准化和工程化。选择哪个?对于开发者来说选择 NSIS 还是 MSI,核心的权衡点是:想要多大的自由度?选择 NSIS,就是选择了“完全的自由”。优点 (Pro): 开发者可以通过编写脚本,像写程序一样精确控制安装过程的每一个细节。他们可以设计出独一无二、带有酷炫动画和自定义页面的安装界面。整个安装包可以做得非常小,因为只打包了必要的东西。这就像用乐高积木盖房子,可以天马行空,不受限制。缺点 (Con): 自由也意味着责任。复杂的安装逻辑需要复杂的脚本,很容易出错。最关键的是,卸载程序也需要开发者自己手动编写脚本,如果疏忽了某个文件或注册表项,就会导致“卸载不干净”的流氓行为。选择 MSI,就是选择了“标准化和可靠性”。优点 (Pro): 开发者不用关心具体的执行步骤,而是像填表格一样,声明“有哪些文件”、“要创建哪些快捷方式”。Windows Installer 服务会保证这些事情被稳妥地完成。这种标准化让后续的更新、修复和卸载都非常可靠,是企业环境的最爱。缺点 (Con): 标准化牺牲了灵活性。用 MSI 很难做出个性化的安装界面,通常都是 Windows 经典的那几步。学习使用创建 MSI 的工具(比如 WiX Toolset)也比写 NSIS 脚本要复杂和陡峭。这就像用预制板盖房子,虽然坚固标准,但样式比较单一。对于普通用户而言对于希望有控制权的用户:NSIS 通常能提供更好的体验。开发者可以很轻松地加入“选择安装路径”、“选择安装组件”(比如要不要装桌面快捷方式)等页面。这种自由度正是你想要的。对于 “电脑小白”或普通用户:他们最怕的可能就是未知和复杂。一个陌生的、花里胡哨的安装界面,或者一堆看不懂的选项,可能会让他们感到不安。这时候 MSI 的“死板”反而成了优点。它那千篇一律的、Windows 风格的界面让人感到熟悉和安全。“下一步”、“下一步”、“完成”,操作简单,符合预期,不容易出错。也不能简单地说哪个就一定更好。NSIS 的上限很高(可以做得非常友好和强大),但下限也很低(开发者可能会滥用它的灵活性,捆绑流氓软件或做出很烂的界面)。MSI 则非常稳定,体验永远不会太差,但也永远不会有太多惊喜。在软件更新时会发生什么如果把NSIS比作是一个“操作步骤”的菜谱 📜,那么MSI就像是有一张“最终安装状态”的点菜单 📝,电脑是厨房,Windows Installer服务是厨师。新版本的 NSIS 安装包,就是一份新的菜谱,当需要用新菜谱(新版 NSIS)替换旧菜谱(旧版 NSIS)时,最稳妥的办法是先把旧菜谱做的菜全部扔掉(运行旧版本的卸载程序),然后再按照新菜谱重新做一遍(运行新版本的安装程序)。厨师(Windows Installer 服务)会拿着新旧两张点菜单进行对比,然后发现:“哦,这桌需要加一个菜,或者这道菜需要加一些盐,那我就只要在这张桌子加上一道新菜,或者只需要给这道菜调味一下。”小结一下:NSIS 更新:通常是“先完全卸载,再完全安装” MSI 更新:通常是“差量更新”,只修改有变化的部分总结特性NSIS (Nullsoft Scriptable Install System)MSI (Microsoft Installer)核心原理脚本驱动 (像菜谱,按步骤执行)数据库驱动 (像清单,声明最终状态)灵活性✅ 极高,可完全自定义界面和逻辑❌ 较低,流程和界面都比较标准化标准化❌ 较低,每个安装包都可能不同✅ 极高,由 Windows Installer 服务统一管理最适合谁?想要个性化和轻量化的独立开发者需要可靠部署和统一管理的企业开发者体验简单脚本,上手快,自由度大结构化,学习曲线陡,但更规范管理员体验部署困难,自动化不可靠部署方便,完美支持静默安装和组策略普通用户体验体验可好可坏,取决于开发者体验一致、熟悉、安全
2025年08月14日
96 阅读
0 评论
1 点赞
2025-08-07
一款轻量级的GalGame/视觉小说管理工具
项目开源地址:https://github.com/huoshen80/ReinaManager废话前言在我开发 ReinaManager 之前,我一直都在用 WhiteCloud 这款视觉小说管理工具。随着我玩过的游戏越来越多,这软件的一些问题就出来了:启动缓慢:启动时游戏数据加载极慢,感觉全部游戏加载出来要个 1 分钟资源占用:后台常驻 3% 左右的 CPU 占用(应该是用了比较抽象的算法来实现游戏时长监测)文件管理问题:游戏目录的文件改个名字,软件里就改不了存档管理繁琐:每次加游戏老要我选个存档文件夹(每次我都是新建一个名为 1 的文件夹,我个人没有自动备份存档的需求)既然有这么多问题,我就一直在想有没有这软件的平替,或者更好用的呢?我个人比较偏好轻量化、界面简洁的工具。稍微去找了一下也没有发现符合我胃口的,于是 ReinaManager 就诞生了。食用指南1. 下载软件GitHub 下载(可能需要魔法):点我下载 最新版本加速下载(无需魔法):点我下载 最新版本下载完双击打开,一直点击 Next 完成安装即可。安装完成后,桌面上会出现一个名为 ReinaManager 的快捷方式。PS.支持win_arm64,有需要可以直接去Release下载。2. 第一次使用推荐进行的设置BGM 令牌设置注意:如果你没有 Bangumi 账号需要临时注册的话,就算获取到了 token 填入软件中,实际使用时也和没有填一样。因为 Bangumi 账号默认注册三个月后才给浏览 R18 类条目的权限。所以没有号的人先注册,然后跳过这个步骤,添加游戏的时候请使用VNDB api来获取游戏数据。获取步骤:点击「获取令牌」按钮,登录 Bangumi 账号随便填写一个名称,选择令牌的有效期(强烈推荐 365 天,免得老过期)点击 Submit复制生成的 token 粘贴到「BGM 令牌」输入框点击保存游戏存档备份路径设置存放游戏存档备份的根目录路径。3. 添加游戏添加游戏:在仓库页面先点击「添加游戏」,然后选择一个启动程序(exe 文件)设置信息:游戏名称部分会自动填充,请尽量确保游戏名称部分准确搜索模式:推荐使用 Mixed 模式手动匹配游戏信息如果添加好后的游戏和你实际想要的游戏不一样,可以通过以下方式手动匹配:前往 Bangumi 或 VNDB 搜索游戏在游戏页面的地址栏复制游戏 ID例如 Bangumi:通过右键菜单进入游戏详情页将得到的游戏 ID 填入编辑选项卡的相应位置点击「从数据源更新数据」并确认小贴士:数据源为 Mixed 时,也可以只填入一种 ID,它会自动获取另一个 ID。剩下的功能,就请你自己去探索了!
2025年08月07日
529 阅读
2 评论
1 点赞
2024-02-17
iOS永久不续签随意装软件,trollstore巨魔商店安装教程
目前巨魔商店支持范围: 14.0 beta 2 ~ 16.7rc 17.0 beta 1 ~ 17.0 其中 16.7rc 版本的ios,芯片为a12~a17(包括m1,m2)暂无安装巨魔商店的方法[未来可能有] 17.0 beta 1 ~ 17.0 的ios版本中,芯片为a11以及以下的有安装方法【需要越狱】( 17.0 beta 1 ~ 17.0 beta 4 芯片为A12-A14/M1-M2的设备即将有安装巨魔商店的方法)下面这张图源自 巨魔商店github 上给出的外链,巨魔商店的 支持列表 :旧:新:在正式开始之前,你需要有一台电脑,一根连接电脑和苹果设备的数据线,一双肯动手的手。如果没有电脑的话,得看下方状态,打勾 {x} 表示目前可以,没打勾表示目前不行。{ } 此处打勾表示企业证书有效,可以不用电脑装巨魔 注意目前可以使用“一、2、”里面的在线安装,不需要使用电脑!!!可跳过0、和1、一、安装trollstore巨魔的各种安装器(请从0、特殊版本开始看)0、特别的版本低于或等于A11处理器的设备且iOS 15.0 to 15.5 beta 4 和 15.6 beta 1 to 15.6 beta 5请使用在线安装: a11以及以下的处理器 高于或等于A12处理器的设备(包括m1/m2)且iOS 14.0 beta 2 to 15.6.1请使用在线安装: a12以及以上的处理器(包括m1/m2) 请使用safari浏览器点开上述链接!!!然后安装相应软件 ps:如果设备满足上述情况,可以跳过下面的1,2小步直接到第三大步1、有电脑的情况下电脑需要下载的工具:爱思助手下载 下载好后安装电脑上打开爱思助手,然后根据爱思助手提示安装需要的驱动。设备用数据线与电脑连接。如果你是ios16或者以上的设备,请检查设备是否打开了开发者模式! ios16或者以上的设备,必须打开开发者模式! {collapse}{collapse-item label="检查是否打开了开发者模式" close} 打开设备的 设置-隐私与安全性 如果说在隐私与安全性界面最后有开发者模式,然后显示打开,那就说明你打开了开发者模式。如果显示关闭那就点进去打开它,如果在隐私与安全性界面内最后没有这个选项,请参考下面的打开方法。{/collapse-item}{collapse-item label="打开开发者模式的方法"} 点击工具箱内的虚拟定位点击一次修改虚拟定位,然后会提示你需要打开开发者模式,此时你就可以在设备的设置-隐私与安全性最下方找到开发者模式,打开开发者模式后提示重启,重启后会有提示你打开开发者模式,最后输入完锁屏密码就会打开开发者模式了。{/collapse-item}{/collapse}连接好后点击工具箱点击ipa签名然后选择对应的巨魔安装器:iOS 16.0 to 16.6.1请下载 trollstar iOS 15.0 to 15.7.1请下载 TrollInstallerMDC iOS 14.0 beta 2 to 14.8.1和15.7.2 to 15.8.1请下载 trollmisaka 官方新巨魔安装器 自签安装器第一步:自签安装器第二步: 如果你是用手机号注册的id,且用手机号登陆的话,记得填id的时候要在前面+86,不然会登陆不上(实测) 如果自签不上报错了的话,请你确定一下你的apple id账号密码有没有输错,在 appleid官网 上登陆一下你的账号后再重试自签安装器第二步。安装安装器第一步:安装安装器第二步: 重要! 安装成功后可以在设备的桌面上找到对应的安装器,你先别着急打开,先去设置-通用-VPN与设备管理中的开发者APP中信任自己的id。然后也先别急着打开,特别是使用TrollInstallerMDC的设备!先看下面第二大步中的相应部分,到时候再打开。trollmisaka的使用在第二大步的最后面。2、没电脑的情况下(已掉签,目前不可用!){collapse}{collapse-item label="iOS 16.0 to 16.6.1时" close} 请使用safari浏览器! TrollStar点我然后点安装 {/collapse-item}{collapse-item label="iOS 15.0 to 15.7.1时"} 请使用safari浏览器! TrollInstallerMDC点我然后点安装 {/collapse-item}{collapse-item label="iOS 14.0 beta 2 to 14.8.1和15.7.2 to 15.8.1时"} 请使用safari浏览器! trollmisaka点我然后点安装 {/collapse-item}{/collapse}安装成功后可以在设备的桌面上找到对应的安装器,但是你先别着急打开,先去设置-通用-VPN与设备管理中的开发者APP中信任开发者,然后再返回桌面打开安装器,相应安装器的使用教程请看下方各部分。二、使用巨魔安装器注入TrollHelper(从上到下依次为trollstar,trollinstallermdc,trollmisaka)trollstar安装完trollstar后,先确定一下设备上是否安装了提示app( 这时千万别打开提示 ),如果没有请去apple store安装,如果你有提示app请你卸载它然后重新在apple store上安装( 这时千万别打开提示 )在设备上打开trollstar,特别提醒如果是平板的话要点左上角的按钮才会显示出下面的界面:点击开始按钮,然后下方的按钮会亮起error的样子:successful的样子:如果打开提示app是这样的话那就成功了:接下来请跳到下面的第三步、使用TrollHelper安装trollstore巨魔商店TrollInstallerMDC安装完TrollInstallerMDC后,先确定一下设备上是否安装了提示app( 这时千万别打开提示 ),如果没有请去apple store安装,如果你有提示app请你卸载它然后重新在apple store上安装( 这时千万别打开提示 )然后打开TrollInstallerMDC,它会自动执行,如果等了一下后显示这个那就说明成功了:要显示successful才算成功哦!然后打开提示app如果打开提示app是这样的话那就成功了:接下来请跳到下面的第三步、使用TrollHelper安装trollstore巨魔商店TrollMisaka安装完TrollMisaka后打开TrollMisaka,然后返回桌面,点桌面上的设置,在设置中依次点击 通用-键盘-键盘-添加新键盘... 然后在第三方键盘中选择TrollMisaka。然后点一下TrollMisaka,把允许完全访问打开,选允许。然后重新打开桌面上的trollmisaka。然后切换到后台,并重启设备。重启后在任意地方打开输入法,长按左下角的地球图标然后选择TrollMisaka输入法。然后会出现这样的一个界面:点击左下的kopen开始,等待一会如果下方出现successful的提示,然后右边的install TrollStore亮起,那就点击右边的按钮安装,提示成功后,切换到后台,然后打开trollmisaka,如果它变化了,那就说明成功了。接下来请跳到下面的第三步、使用TrollHelper安装trollstore巨魔商店三、使用TrollHelper安装trollstore巨魔商店打开提示、trollmisaka或者打开GTA Car Tracker(特殊的版本)后是这样的界面:安装好trollstore巨魔商店后,在主界面打开,第一次打开会帮你自动安装ldid。如果它没有自动帮你安装ldid,那你就点击setting然后:如果这样那就说明成功:然后:(这一步如果是用trollmisaka安装器的话可以不搞)url的功能可以选择打开,部分地方ipa资源需要打开这个,打开后选择rebuild now:然后就大功告成了,接下来enjoy it~ **教程看完了,先别走嘛,最起码玩b站的朋友关注一下我呗~qwq如果有实力的朋友,还请赞助一下博主!博主用爱发电很不容易,还请多多支持!(请我喝瓶可乐就行awa)**
2024年02月17日
3,427 阅读
1 评论
10 点赞
1
2