构建数据库repository层——从tauri-plugin-sql重构到seaorm #2
使用dto灵活转换数据结构在构建数据库的repository层时，灵活的数据结构转换是非常重要的。通过使用DTO（数据传输对象），可以在前后端之间传递数据时，保持数据结构的清晰和一致性。entity实体结构用于数据库操作，而DTO则用于与前端交互。对于前端需要插入游戏而传递的数据，不存在后端需要的自增主键id，entity结构中包含id字段，而且要求不为空，如果直接使用entity结构进行插入操作，则需要前端添加这个并无意义的id字段。为此我准备了一个GameInsertDto结构，只包含前端传递的必要字段：/// 用于插入游戏的数据结构（不包含 id, created_at, updated_at） #[derive(Clone, Debug, Serialize, Deserialize)] pub struct InsertGameData { pub bgm_id: Option<String>, pub vndb_id: Option<String>, pub id_type: String, pub date: Option<String>, pub localpath: Option<String>, pub savepath: Option<String>, pub autosave: Option<i32>, pub clear: Option<i32>, pub custom_name: Option<String>, pub custom_cover: Option<String>, }普通的dto结构体无法直接用于seaorm的数据库操作，因此需要实现一个转换方法:/// Trait：将 DTO 转换为 ActiveModel pub trait IntoActiveModel<T> { fn into_active_model(self, game_id: i32) -> T; } impl IntoActiveModel<bgm_data::ActiveModel> for BgmDataInput { fn into_active_model(self, game_id: i32) -> bgm_data::ActiveModel { bgm_data::ActiveModel { game_id: Set(game_id), image: Set(self.image), name: Set(self.name), name_cn: Set(self.name_cn), aliases: Set(self.aliases), summary: Set(self.summary), tags: Set(self.tags), rank: Set(self.rank), score: Set(self.score), developer: Set(self.developer), } } }更新数据需要删除某个列时(设为null)，正常情况下的反序列化会将缺失的字段和显式的null值都解析为None，导致无法区分这两种情况。为了解决这个问题，可以自定义一个double_option函数，用于分辨从前端传来的未定义字段和显式的null值:fn double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error> where D: Deserializer<'de>, T: Deserialize<'de>, { Ok(Some(Option::deserialize(deserializer)?)) } #[serde(default, deserialize_with = "double_option")] pub bgm_id: Option<Option<String>> // 各行数据......构建repository层重构到seaorm的优势将在这里体现出来：repository层中大部分的数据库操作逻辑，不再需要使用sql语句，而是通过orm方法进行增删改查；因为需要将原来的总games表进行拆分，有关数据库的操作从简单的一对一映射，变成了一对多映射，而seaorm可以轻松定义实体间的关系(如 One-to-One, One-to-Many等)，使一对多映射的实现变得简单。插入示例:/// 批量插入游戏数据（包含关联数据） pub async fn insert_with_related( db: &DatabaseConnection, game: InsertGameData, bgm: Option<BgmDataInput>, vndb: Option<VndbDataInput>, other: Option<OtherDataInput>, ) -> Result<i32, DbErr> { let txn = db.begin().await?; // 构建 ActiveModel 并插入游戏基础数据 let now = chrono::Utc::now().timestamp() as i32; let game_active = games::ActiveModel { id: NotSet, bgm_id: Set(game.bgm_id), vndb_id: Set(game.vndb_id), id_type: Set(game.id_type), date: Set(game.date), localpath: Set(game.localpath), savepath: Set(game.savepath), autosave: Set(game.autosave), clear: Set(game.clear), custom_name: Set(game.custom_name), custom_cover: Set(game.custom_cover), created_at: Set(Some(now)), updated_at: Set(Some(now)), }; let game_model = game_active.insert(&txn).await?; let game_id = game_model.id; // 使用辅助函数插入关联数据 Self::insert_bgm_data(&txn, game_id, bgm).await?; Self::insert_vndb_data(&txn, game_id, vndb).await?; Self::insert_other_data(&txn, game_id, other).await?; txn.commit().await?; Ok(game_id) }查询示例:/// 根据 ID 查询完整游戏数据（包含关联数据） pub async fn find_full_by_id( db: &DatabaseConnection, id: i32, ) -> Result<Option<FullGameData>, DbErr> { let game = match Games::find_by_id(id).one(db).await? { Some(g) => g, None => return Ok(None), }; let bgm = BgmData::find_by_id(id).one(db).await?; let vndb = VndbData::find_by_id(id).one(db).await?; let other = OtherData::find_by_id(id).one(db).await?; Ok(Some(FullGameData { game, bgm_data: bgm, vndb_data: vndb, other_data: other, })) }这篇文章没图，那就放个野生的里想奈/滑稽
seaorm迁移初体验——从tauri-plugin-sql重构到seaorm #1 数据库迁移脚本
前言随着reinamanager的逐步开发，我发现games表实在太臃肿了，为了以后能更好的，添加新的数据源、交叉显示游戏数据、发挥各数据源的特性等，于是我决定把games表拆分成多个表；由于项目初期使用了tauri-plugin-sql插件，games表拆分后会导致repository层多个game数据表的交互逻辑变得异常复杂，换言之就是sql语句会变得很复杂。再加上之前有人建议我使用orm代替纯sql查询issue。那就来吧！要说rust家族里的orm，那肯定首推seaorm。使用sea-orm-cli生成实体安装sea-orm-clicargo install sea-orm-cli使用sea-orm-cli生成实体sea-orm-cli generate entity -u "sqlite:path/to/your/database.db" -o src/entity --with-serde both追平与基线迁移原来使用的是tauri-plugin-sql，想彻底重构到seaorm得做基线迁移，在基线迁移脚本中判断用户类型，新用户运行数据库初始化函数，创建全新的数据库结构，老用户先运行旧的迁移脚本(追平)，然后将旧的迁移表_sqlx_migrations备份一份，以完成基线迁移。使用sea-orm-migration来创建一个迁移crate: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
在 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); } }; // 渲染为非锚点容器，避免嵌套 。不改动滚动的实现逻辑。 return ( {children} ); }; 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 || ''}`; // 外层不渲染 ，而是使用可访问的 div 进行编程式导航， // 在导航前 LinkWithScrollSave 会保存滚动位置，避免嵌套 。 return ( <LinkWithScrollSave to={to} style={{ textDecoration: 'none', color: 'inherit' }}> <DashboardSidebarPageItem item={item} {...params} />//保持原有样式 </LinkWithScrollSave> ); }, []); return ( <DashboardLayout renderPageItem={handleRenderPageItem} > <Outlet /> </DashboardLayout> ); }最终效果
使用批处理文件让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 以转区模式运行了。
前言废话我不是写了一个视觉小说管理工具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 服务统一管理最适合谁？想要个性化和轻量化的独立开发者需要可靠部署和统一管理的企业开发者体验简单脚本，上手快，自由度大结构化，学习曲线陡，但更规范管理员体验部署困难，自动化不可靠部署方便，完美支持静默安装和组策略普通用户体验体验可好可坏，取决于开发者体验一致、熟悉、安全
项目开源地址：https://github.com/huoshen80/ReinaManager废话前言在我开发 ReinaManager 之前，我一直都在用 WhiteCloud 这款视觉小说管理工具。随着我玩过的游戏越来越多，这软件的一些问题就出来了：启动缓慢：启动时游戏数据加载极慢，感觉全部游戏加载出来要个 1 分钟资源占用：后台常驻 3% 左右的 CPU 占用（应该是用了比较抽象的算法来实现游戏时长监测）文件管理问题：游戏目录的文件改个名字，软件里就改不了存档管理繁琐：每次加游戏老要我选个存档文件夹（每次我都是新建一个名为 1 的文件夹，我个人没有自动备份存档的需求）既然有这么多问题，我就一直在想有没有这软件的平替，或者更好用的呢？我个人比较偏好轻量化、界面简洁的工具。稍微去找了一下也没有发现符合我胃口的，于是 ReinaManager 就诞生了。食用指南0. 从WhiteCloud而来Reina Migrator点我下载 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。剩下的功能，就请你自己去探索了！
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）**
导入众所周知啊，mc一个人玩可能会觉得有些无聊，但是如果和朋友一起玩，那肯定就会好玩不少！那么问题来了怎么在mc中和物理距离较远的朋友愉快的联机呢？直接用某易？（×）要玩mc那么我们肯定玩国际版的呀！接下来80会教你一些常见简单的联机方式！一、判断自身适合哪种方法1、ipv4公网法（适合有公网IP的人）一般来讲这类人会比较少，但是不排除你就是天选之子（本来就有公网）或者是你比较爱折腾（肯打电话给宽带运营商要动态公网IP）一般来讲本来有公网的只会出现在部分宽带是电信（联通也有可能哦）的用户是电信（或者联通）宽带的用户可以自检一下哦，查看光猫后台或者路由器的IP是否和浏览器查出的IP一致，如果一致则有ipv4公网IP。可以试用ipv4公网法联机！ 点我查自己的ipv4地址 ps：我的路由器是红米ac2100哦，一般来说如果是pppoe（桥接）模式就能在路由器里看到公网ip了，如果不是pppoe模式，那你有可能需要访问光猫后台查看公网ip情况哦。 这是最好的联机方式！2、ipv6法（适合联机的所有人都有ipv6的方法，实测1.7.10以及以下不支持）这个方法挺好的，缺点只有一个那就是联机的每个人都要有ipv6！就像这样：只要满足所有人都有ipv6，这种方法甚至比ipv4公网法的延迟还低ps：现在一般来讲运营商都会下发ipv6，只要你路由器支持一般都会有ipv6，如果你电脑没有ipv6的话，有可能你路由器没开启ipv6功能。 小贴士：现在一般只要你电脑连上手机开的热点，都会有ipv6！因为手机现在的流量一般都会有ipv6地址的分发！（台式机没WiFi模块的可以用usb网络共享，要记得关闭手机的WiFi！使用手机的流量哦！） 你可以使用这个网站来检查你的设备是否拥有ipv6： https://www.test-ipv6.com/index.html.zh_CN ps：这个网站查到的ipv6地址就是你这个设备的公网ipv6地址哦~3、端口映射法（此方法适用于无法满足上述方法条件的人）端口映射可以说是最普遍的一种联机方式了，不过端口映射平台目前大多都要求实名认证。这里我推荐两个端口映射平台：第一个是开放映射openfrp.net（不过官方好像不建议用于mc开服，联机的话应该还好？），第二个是樱花映射natfrp.com二、配置客户端内置服务端1、直接联机（比较适用于都是正版玩家的情况，且游戏版本在1.19.3以及以上的版本）固定端口开放完局域网配合上述3种方法的一种就可以联机了2、使用联机模组（对于没正版和低版本mc有用，一般只要房主装就行了）联机模组存在的主要意义是将mc客户端的内置服务端正版验证给关闭，还有固定端口为默认开服端口：25565，以减少不必要的麻烦（每次点完对局域网开放端口都随机，导致每次都要修改映射）(1)、简单联机mod（适用于1.7.2~1.12.2的版本）mod介绍：https://www.mcmod.cn/class/1158.html在低版本mc里我推荐用这个模组，具体使用方法如下：先将mod扔进mods文件夹，然后进世界一次，点一次对局域网开放，然后退出到游戏菜单界面。接下来打开mc的存档文件夹saves（saves一般在.minecraft文件夹下，如果开了版本隔离的话就在.minecraf\version\对于版本文件夹下）,再打开对应的世界文件夹ps：注意只要联机的玩家里（除房主自己外只要有一个离线玩家就要关闭正版验证）(2)、更高级联机设置(适合1.15.2以及以上版本)mod介绍：https://www.mcmod.cn/class/4498.html无需修改配置文件，在游戏内对局域网开放按钮中添加附加功能。(3)、自定义局域网联机（适合1.12以及以上版本，极力推荐）mod介绍：https://www.mcmod.cn/class/2754.html无需修改配置文件，在游戏内对局域网开放按钮中添加附加功能。注意！这个mod房主和联机的朋友都要装！（亲测！）三、让你的朋友进入你的客户端1、ipv4公网法在确认自己有ipv4公网后,分两种情况：{collapse}{collapse-item label="我路由器是桥接模式（pppoe）"} 使用路由器的端口映射功能或者使用dmz主机： 端口映射： dmz主机：只要将电脑内网IP输进去就行了ps:查询电脑内网ip的方法可以使用win+r的快捷键呼出运行窗口，然后输入cmd然后回车，在命令提示符里面输入ipconfig回车。你的内网ip是这个哦： {/collapse-item}{collapse-item label="我的路由器是非桥接模式"} 使用光猫的端口映射功能或者使用dmz主机，其实与路由器相似，但是光猫一般会因为没有超级管理员权限的问题，导致无法使用dmz主机，所以我更推荐使用桥接模式的路由器（挂个电话叫运营商帮你改光猫桥接然后路由器输入宽带账号密码就好了）。{/collapse-item}{/collapse}ps：因为家宽公网的特殊性（ip会周期性改变），所以说我们有必要让这个动态的ip变成静态的域名，花点小钱买个域名然后使用ddns-go软件实现“动态公网固定化”ddns-go： ddns-go项目地址 最后让联机的朋友们用你的域名或者ip进入你的服务器。2、ipv6法在确认了大家都有ipv6后，作为主机的你，需要复制下你的ipv6地址，就是上面检测网站给你的ipv6地址，把地址发给你的联机朋友们。直接让朋友用你ipv6地址进入你的服务器。因为ipv6每次重启电脑都会变化，所以说我非常建议使用ddns-go软件对ipv6地址进行“动态公网固定化”有的路由器可能会默认开启ipv6公网的防火墙（比如说我的ac2100），这个没啥办法，你只能想办法去关闭它。3、端口映射开放映射： openfrp.net 樱花映射： natfrp.com 都需要注册登陆，节点的选择，我推荐是尽量选择离自己最近的节点，还有就是与自己运营商相同的节点。内部端口写25565，外部端口可以随机也可以选一个自己喜欢的。
项目地址： https://github.com/Grasscutters/Grasscutter （如果访问不了可以把github.com改成hub.fastgit.xyz） 此项目为开源项目，请不要进行非法使用 ！ 如有侵权我会快速删除本文章，可以通过发邮件的方式告诉我，huoshen80@hotmail.com前言首先你得先准备一台Linux的云服务器，和一个能连接云服务器的软件win和macOS这边推荐使用 finalshell安卓推荐使用juicessh（请自己找资源）然后准备一个清醒的脑子准备开始部署原神服务器。ps:如果你连ssh都不知道怎么连接，那这边建议你换win系统。 这边 有win开原神服务器的教程。ps:这边以centos系统为例子，Ubuntu系统可以把有yum指令的yum改成apt。一、准备jdk运行环境和芒果数据库请先确保你已经有root权限，没有root权限的话使用sudo su root回车然后输入root密码来获取root权限。1.升级软件源，安装必备软件yum -y update yum -y install wget git screen vimps:第一条指令运行可能要运行一段时间请耐心等待。2.安装jdk17注意下面指令一行一行用wget https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/linux/OpenJDK17U-jdk_x64_linux_hotspot_17.0.4.1_1.tar.gz tar zxvf OpenJDK17U-jdk_x64_linux_hotspot_17.0.4.1_1.tar.gz mv jdk-17.0.4.1+1 /usr/local/java vim /etc/profile # 按 i 进入编辑 在文件末尾添加以下内容 export JAVA_HOME=/usr/local/java export PATH=$PATH:$JAVA_HOME/bin; export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar; # 添加完成后按 esc 输入 :wq! 保存 source /etc/profile如果使用java -version指令然后出现下面这样就说明jdk安装并配置好了3.获取芒果数据库压缩包wget https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-rhel70-5.0.9.tgz #上面的适用于centos7，如果你是centos8或者ubuntu系统请看下面的教程{collapse}{collapse-item label="centos8和ubuntu系统" close} 请访问 https://www.mongodb.com/try/download/community 然后点然后在服务器ssh输入wget 复制到的链接然后回车等待运行完。{/collapse-item}{/collapse}然后使用tar zxvf mongodb-linux-x86_64-rhel70-5.0.9.tgz来解压芒果数据库ps:上面的解压指令要根据你实际下载到的压缩文件来改名，提示tab键可以快速补全文件名字二、准备好全部服务端文件1.获取服务端主体文件和资源文件git clone -b development https://ghproxy.com/https://github.com/Grasscutters/Grasscutter git clone https://ghproxy.com/https://github.com/Koko-boya/Grasscutter_Resources最终使用ls指令输出有的内容中有Grasscutter和Grasscutter_Resources即为正确2.将服务端资源文件放到正确位置cd Grasscutter_Resources mv Resources ~/Grasscutter/resources完成后你的Grasscutter文件夹下就有resources文件夹了接下来就可以开始编译服务端。3.编译服务端cd ~/Grasscutter ./gradlew jar ls倒数第二个指令可能需要运行比较长的时间，请耐心等待！最后如果显示这样，就说明编译成功了，即可进入下一步，正式开服。三、正式开服1.开芒果数据库由于要让程序一直运行，所以这里我们需要用到screen软件screen -R db cd ~/mongodb-linux-x86_64-rhel70-5.0.9 mkdir db cd bin wget huoshen80.top/usr/uploads/2022/06/start.sh sh start.sh最终完成，然后直接关闭ssh并重连进入下一步2.开服务端主体由于要让程序一直运行，所以这里我们需要用到screen软件screen -R ys cd ~/Grasscutter java -jar grasscutter-1.2.2-dev.jar选择语言输入chs然后回车。出现下面这个表示开好了由于第一次开服会生成config.json文件，云服必须修改此文件，所以说需要先关闭服务端然后修改此文件。stop vim config.json # 按 i 进入编辑 在文件的这些位置进行修改 # 添加完成后按 esc 输入 :wq! 保存两个ip必须换成自己服务器的ip或者域名，两个端口这边不建议修改此外记得一定要去服务商那边放行443端口（tcp），22102（udp），如果有宝塔的话也要放行这两个端口，请根据你自己设置的端口进行放行！！！否则可能会导致客户端无法连接！然后再开服java -jar grasscutter-1.2.2-dev.jar然后就可以开始享受了！ps：如果服务器重启的话就需要重新创建screen然后先开芒果数据库，再开服务端整体。四、补充1.screen软件的用法进入一个screen，如果没有就创建一个：screen -R 名字进入一个指定的screen：screen -r 名字列出现有的screen：screen -ls进入上一次的screen：screen -x关闭指定的screen：screen -S 名字 -X quit你还可以在screen里面输入exit来关闭screen2.升级服务端cd ~/Grasscutter git pull ./gradlew jar然后耐心等待即可。接下来就是进入服务器的教程： 点我
如有侵权我会快速删除本文章，可以通过发邮件的方式告诉我，huoshen80@hotmail.com在上篇文章中我们完成了服务端的搭建，这篇文章我将教大家怎么进入自己的服务器。在进入服务器之前先创建一个用户！在服务端控制台输入account create (你想要的用户名) (你想要的uid)然后回车例如：account create huoshen80 10001[创建一个uid为10001名为huoshen80的用户]一、windows进服1.下载代理软件点我 下载代理软件（如果报毒那是误报，此软件没毒请放心使用）2.打开代理软件下好后右键它选择管理员身份运行本地开服的话直接点击开始代理（如果代理端口被占用可以改）如果是云服务器开服的话填写服务器ip或者域名3.进入游戏代理打开了后直接打开游戏用户名输入刚刚创建好的账号，比如说我创的是huoshen80那就输入它，密码随便输什么都可以。然后就完事了，享受。。。。。。二、安卓手机进服1.下载已魔改过的国际服启动器在这个网站 点我 最底下的第一个就是要下载的安装包最底下:下载好后进行安装。（如果手机上已经有国际服的话，请参考下面折叠的教程）{collapse}{collapse-item label="已有国际服" close}根据这边的教程你可以跳过下边2.的部分因为你已经有了国际服所以你可以跳过下载国际服游戏文件，或者改官服包名为国际服包名。（这边以mt管理器为例）来到根目录下Android文件夹中的data文件夹找这个文件夹你得先将国际服文件夹先改个包名比如说在com.miHoYo.GenshinImpact后面加个1什么的，然后再将你原本的国际服安装包卸载，安装上上面说的魔改后的国际服安装包，然后再将国际服的包名改回com.miHoYo.GenshinImpact{/collapse-item}{/collapse}2.没有国际服，但有官服或者b服，获取游戏文件(1)改包名的方法[适用于非OPPO和realme手机]不想下13个G的文件怎么办？这里以mt管理器为例来到根目录下Android文件夹中的data文件夹找这个文件夹他们从上往下分别是国际服，b服，官服。你哪个服有13个G的文件就把哪个文件名改为国际服的文件名，国际服的文件名是com.miHoYo.GenshinImpact，如果有重名文件夹，可以将它们后面加个1之类的。(2)复制或移动文件法[适用于OPPO和realme手机]如果color os系统的手机改包名的话会发生这样的事情目前无解，感觉就是玄学。所以说color os系统复制移动大法好使。(我就是realme手机)复制比移动更好，因为移动容易使游戏文件寄掉(我就搞寄过)在Android/data文件夹里创建一个com.miHoYo.GenshinImpact的文件夹，再将别的服的files文件复制或者移动到com.miHoYo.GenshinImpact内即可。3.有了游戏文件也安装上了魔改的包后进服打开上面安装好的魔改国际服，你将会看到下面的这个窗口，如果没有的话关掉重开就有了。像这样点上第一个和倒数第二个✓然后填（https://ip）,如果是本地电脑开服的话，你需要改一下服务端的config.json文件,把两个127.0.0.1改成你电脑的ip{collapse}{collapse-item label="如何查看电脑ip地址" close} win+r然后输入cmd回车 输入ipconfig回车，就能看到自己电脑的ip了{/collapse-item}{/collapse}全部勾选好和填写好后，将游戏彻底关掉，然后重开，输入你事先创建好的用户名，密码随便填，然后享受游戏。有什么不懂的欢迎加我QQ来问我，QQ:2567434593