首页
赞助博主
友链
关于
随机CG图
推荐
我的B站主页
我的歌单
我的bgm
井字棋
待办事项
github加速
Search
1
iOS永久不续签随意装软件,trollstore巨魔商店安装教程
3,538 阅读
2
进入自己原神服务器
1,796 阅读
3
linux云服开原神服务器
1,655 阅读
4
从零开始的mc联机教程
1,176 阅读
5
win上开原神服务器
1,136 阅读
默认分类
原神
MC
iOS
galgame
ReinaManager
学习笔记
开发笔记
日常
登录
Search
标签搜索
reinamanager
rust
seaorm
原神
私服
win
tauri
tauri-plugin-sql
安卓
github
react
mui
react router
migration
sea-orm-cli
基线迁移
数据库迁移
dto
repository
sql
火神80
累计撰写
16
篇文章
累计收到
20
条评论
首页
栏目
默认分类
原神
MC
iOS
galgame
ReinaManager
学习笔记
开发笔记
日常
页面
赞助博主
友链
关于
随机CG图
推荐
我的B站主页
我的歌单
我的bgm
井字棋
待办事项
github加速
搜索到
7
篇与
的结果
2025-12-03
Git 自用笔记
目录Git 远程仓库连接Git commit 规范Git 自用命令速查1. 分支与合并 (Branching & Merging)git branch # 列出所有本地分支 (当前分支用 * 标记) git branch <name> # 创建一个新的分支 git branch -d <name> # 删除一个已经合并过的本地分支 git checkout <name> # 切换到指定的分支 git checkout -b <name> # 创建并立即切换到新的分支 git switch <name> # 切换分支 (推荐新指令) git merge <name> # 将指定分支的更改合并到当前分支 git cherry-pick <commit_id> # 将其他分支的特定提交"复制"并应用到当前分支2. 撤销与修改 (Undo & Modify)# 工作区与暂存区 git checkout -- <file> # 撤销对工作区单个文件的修改 git restore <file> # 丢弃工作区的修改 (新版本指令) git reset HEAD <file> # 将文件从暂存区移回工作区 (不撤销修改) # 提交回滚 git reset --soft HEAD^ # 撤销最近一次提交,保留修改在暂存区 (适合修正提交) git reset --hard <commit_id> # 回退到指定版本,丢弃所有后续修改 (慎用) git revert <commit_id> # 创建新提交来抵消旧提交 (安全,适合远程代码) # 技巧: git revert -n 可回退代码但不自动 commit,便于检查 # 修改历史 (高级) git commit --amend # 修改最后一次提交的信息或追加修改 # 技巧: message 中加 [skip ci] 可跳过 WorkFlows git rebase -i HEAD~n # 交互式修改前 n 次的提交 (如 reword 改 message) git push -f # 强制推送修改后的历史 (注意 Tag 不会自动随之移动) git push --force-with-lease # 更安全的强制推送,防止覆盖他人提交 # 救命指令 git reflog # 查看所有 HEAD 移动记录,找回被 reset 误删的 commit3. 暂存 (Stash)git stash # 将当前修改 (暂存区+工作区) 临时保存 git stash pop # 恢复最近一次保存的修改并从栈中删除4. 远程与查看 (Remote & Inspection)git remote -v # 查看远程仓库地址 git pull # 拉取远程代码并合并 git push # 推送本地提交 git log # 查看提交历史 git diff # 查看差异(工作区与暂存区) git diff --cached # 查看差异(暂存区与上次提交)5. 标签管理 (Tagging)git tag # 列出所有标签 git tag <name> # 给当前 commit 打轻量标签 (如 v1.0) git tag -a <name> -m "msg" # 打附注标签 (发布版本推荐) git push origin --tags # 推送本地所有标签到远程 git push origin :refs/tags/<name> # 删除远程标签Git 远程仓库连接git remote add origin https://github.com/username/my-project.git git branch -M main git push -u origin main # -u 它告诉 Git:"以后我的本地 main 分支就和远程的 origin/main 绑定了。" git remote -vGit commit 规范1. 提交结构 (Format Structure)一个完整的 Git Commit 消息应该包含标题 (Subject)、正文 (Body) 和页脚 (Footer) 三个部分,各部分之间用空行分隔。<type>(<scope>): <subject> <body> <footer>2. 提交类型 (Type) - 必需这是提交消息的第一个词,用于说明本次提交的目的。Type描述 (中文)场景示例feat新功能 (Feature)添加了用户登录功能。fix修复 Bug (Bug Fix)修复了按钮点击时未响应的错误。docs文档 (Documentation)仅修改了 README 或注释。style代码风格 (Styling)格式化代码,移除多余空格,不影响代码逻辑。refactor重构 (Refactoring)代码重构,既不属于新功能也不属于 Bug 修复。test测试相关 (Testing)增加或修改了测试文件。chore杂项/辅助 (Chore)修改构建流程、依赖升级、辅助工具配置等。3. 标题行规则 (Subject Line)标题行是提交摘要,应该简短明了,遵循以下规则:规则说明示例字数限制建议不超过 50 个字符 (方便日志查看)。feat(api): 增加用户注册接口使用祈使句使用现在时、祈使句(像命令一样),而不是过去时。推荐:Fix bug / 避免:Fixed bug小写开头提交类型后,主题描述请用小写字母开头。fix(auth): 修正密码校验逻辑句末无点结尾不要加句号 .。保持简洁。Scope (可选)使用括号 () 标识此次修改影响的范围(如:api, ui, auth)。fix(layout): 修复移动端布局错位4. 正文规则 (Body) - 可选正文用于详细说明提交的细节,应遵循以下规则:分隔空行:标题行后必须留一个空行。解释原因:重点描述修改的原因和解决的问题(Why),而不仅仅是做了什么(What)。细节说明:描述设计思路、替代方案、或潜在的副作用。换行限制:建议每行不超过 72 个字符,以提高可读性。5. 页脚 (Footer) - 可选页脚通常用于关联 Issue 或标记重大变更。关联 Issue:引用相关的 Issue 编号。Closes #123 (关闭 Issue 123)Refs #456 (参考 Issue 456)重大变更 (Breaking Change):如果本次修改不兼容旧版本,必须在页脚以大写 BREAKING CHANGE: 开头进行说明。示例一个完整的、符合规范的提交消息可能如下所示:feat(auth): 允许用户使用邮箱登录 这是本次更改的详细正文,解释了为什么需要增加邮箱登录功能。 原有的用户名登录限制性太强,为了优化用户体验,我们增加了兼容邮箱登录的校验。 本次修改包含了前端表单和后端API的变更。 BREAKING CHANGE: 移除了对旧版加密算法的支持。 Closes #123
2025年12月03日
7 阅读
1 评论
0 点赞
2025-11-17
构建前后端service层——从tauri-plugin-sql重构到seaorm #3
回顾前文seaorm迁移初体验——从tauri-plugin-sql重构到seaorm #1 数据库迁移脚本构建数据库repository层——从tauri-plugin-sql重构到seaorm #2使用tauri::command构建后端service层写好了repository层,显然前端没法直接调用这些curd操作,传统的前后端架构往往都需要通过写一些HTTP接口来实现前后端通信,而在Tauri应用中,可以利用tauri::command宏来简化这个过程。tauri::command允许我们通过进程间通信的方式,将Rust函数暴露给前端调用,从而实现前后端的数据交互。一个tauri command的示例:/// 插入游戏数据(包含关联数据) #[tauri::command] pub async fn insert_game_with_related( db: State<'_, DatabaseConnection>, game: InsertGameData, bgm: Option<BgmDataInput>, vndb: Option<VndbDataInput>, other: Option<OtherDataInput>, ) -> Result<i32, String> { GamesRepository::insert_with_related(&db, game, bgm, vndb, other) .await .map_err(|e| format!("插入游戏数据失败: {}", e)) };lib.rs中注册command写好了一个tauri command之后,还需要在lib.rs中注册这个command,才能让Tauri应用识别它:tauri::Builder::default() .invoke_handler(tauri::generate_handler![ insert_game_with_related, // 其他command... ])在前端使用invoke构建前端service层(调用command)在前端,我们可以通过Tauri提供的invoke方法来调用后端暴露的command,从而实现前后端的数据交互。我们可以将这些调用封装成一个service层,方便在应用中使用。由于command较多,如果一个command就写一个函数,那前端service层会显得非常臃肿,因此我们可以将相关的command进行分类,以及使用OOP的编程思想,将相关的command封装到一个类中。基础Service类:// 统一处理错误 export class BaseService { protected async invoke<T>( command: string, args?: Record<string, unknown>, ): Promise<T> { try { const result = await invoke<T>(command, args); return result; } catch (error) { console.error(`[Service Error] ${command}:`, error); throw error; } } }GameService类:class GameService extends BaseService { /** * 插入游戏数据(包含关联数据) */ async insertGame( game: RawGameData, bgm?: BgmData | null, vndb?: VndbData | null, other?: OtherData | null, ): Promise<number> { return this.invoke<number>("insert_game_with_related", { game, bgm: bgm || null, vndb: vndb || null, other: other || null, }); } //...更多的方法 }insertGame方法调用示例:import { gameService } from "@/services"; addGame: async (fullgame: FullGameData) => { try { if (isTauri()) { await gameService.insertGame( fullgame.game, fullgame.bgm_data, fullgame.vndb_data, fullgame.other_data, ); } else { insertGameLocal(fullgame); } // 使用通用刷新函数 await get().refreshGameData(); } catch (error) { console.error("Error adding game:", error); } },结语至此,完成了从tauri-plugin-sql重构到seaorm的重构工作。前端的职责变回了纯粹的UI展示和交互逻辑,后端则专注于数据库curd和部分底层功能逻辑。通过这种清晰的分层架构,代码的可维护性和扩展性都得到了显著提升。
2025年11月17日
38 阅读
0 评论
0 点赞
2025-10-21
构建数据库repository层——从tauri-plugin-sql重构到seaorm #2
前文回顾seaorm迁移初体验——从tauri-plugin-sql重构到seaorm #1 数据库迁移脚本使用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, })) }### 相关联的下一篇文章构建Tauri command层——从tauri-plugin-sql重构到seaorm #3 Tauri command层设计与实现
2025年10月21日
38 阅读
0 评论
1 点赞
2025-10-15
以太坊区块结构:从数字账本到世界计算机
前言:区块的骨架 —— 区块头与区块体+------------------------------------------------------+ | 以太坊区块 (Block) | +======================================================+ | 区块头 (Block Header) - 元数据与指纹 | |------------------------------------------------------| | - Parent Hash: 0xabc...def | | - State Root: 0x123...456 | | - Timestamp: 1678886400 | | - ... (其他元数据) | +======================================================+ | 区块体 (Block Body) - 交易列表 | |------------------------------------------------------| | +-----------------+ +-----------------+ +----------+ | | | Transaction 1 | | Transaction 2 | | ... | | | +-----------------+ +-----------------+ +----------+ | +------------------------------------------------------+每个以太坊区块都由两大部分组成:区块头 (Block Header) 和 区块体 (Block Body)。你可以把区块头想象成一本书的“扉页”和“摘要”,它包含了这个区块的所有元数据,像是身份证明和索引。而区块体,则是这本书的“正文”,记录了实际发生的所有事件。首先看区块头。它像一个高效的压缩包,里面装满了关键信息。例如,它会记录父哈希 (Parent Hash),这是上一个区块的加密指纹,正是它将所有区块像链条一样环环相扣,形成了不可篡改的区块链。它还有时间戳 (Timestamp),记录了区块被创建的确切时间。更有趣的是,区块头在过去的PoW时代还包含了一个被称为 Nonce 的字段。Nonce 是矿工为了解决加密难题而尝试的“幸运数字”,找到它就意味着获得了打包区块的权利。同时,难度 (Difficulty) 字段则量化了这个难题的复杂程度,它会动态调整,确保以太坊的出块速度保持在预期的稳定水平。接着是区块体,它就是这个区块内发生的所有交易列表 (Transaction List)。每一次ETH转账、每一次与智能合约的交互,都会被打包在这里。这些交易经过加密处理,最终会生成一个唯一的“交易根哈希”,这个哈希值被安全地储存在区块头中,确保了区块内所有交易的完整性和不可篡改性。模块一:以太坊的“独门绝技” —— 叔块 (Ommers/Uncles)讲完区块的骨架,现在要揭示以太坊一个非常独特且智慧的设计——“叔块”。这个概念是它在面对“分叉”问题时,给出的一个巧妙解决方案,也是以太坊区别于比特币的关键之一。在去中心化的区块链网络中,由于网络延迟和矿工之间的竞争,有时会发生两个区块几乎同时被创建的情况,导致区块链暂时出现分叉。在比特币的网络里,这种分叉中的“短链”会被完全抛弃,其上的工作量也随之浪费。但以太坊却不希望白白浪费这些计算资源,于是引入了叔块 (Ommers/Uncles) 的概念。你可以用一个家族树来理解:如果一个区块和当前区块有相同的“祖父区块”,但它自己却没能成为主链的一部分,那么它就成了当前区块的“叔叔”。当前区块在打包时,会将这些没能上主链的“叔块”的区块头包含进来。以太坊处理分叉的方式如下: [ 祖父区块 ] | +----+----+ | | [ 父区块 ] [ 叔块 (Ommer) ] | [ 当前区块 ] (包含了叔块的信息)比特币只会选择最长的链,而以太坊则允许当前区块引用这些叔块,从而部分承认它们的工作量。 [ ... 前一个区块 ... ] | [ 区块 A ] | +------+------+ | | [ 区块 B1 ] [ 区块 B2 ] <-- B2 成为“孤块”(Orphan Block),被彻底抛-弃 | [ 区块 C ] <-- C选择在B1后面继续,使得这条链成为无可争议的“最长链”为什么这个设计如此独特和重要?提高安全性与效率:叔块的工作量不会被完全浪费,而是会被主链部分承认。这使得整个网络的总计算能力得到了更充分的利用,增加了51%攻击的难度。即便一个区块没有成为主链的一部分,它的算力贡献也被吸收,变相提升了网络的健壮性。增加网络公平性:发现叔块的矿工也能获得一部分奖励。这鼓励了那些可能因网络延迟而略慢一步的参与者继续贡献算力,有助于维护网络的去中心化和公平性,避免算力过度集中。所以,叔块是以太坊在追求效率和去中心化之间找到的一个精妙平衡点,它让以太坊的网络更具韧性。模块二:以太坊的“双重灵魂”——两种核心账户类型 (EOA vs. 合约账户)了解了区块结构和叔块的独特设计后,接下来把视角转向区块链里的“主角”——账户。以太坊的世界状态,就是由无数账户共同构成。但这里的“账户”并非单一类型,而是分为两种,分别承载着主动与被动、意志与逻辑的双重灵魂。+------------------------------------------+------------------------------------------+ | 外部账户 (Externally Owned Account) | 合约账户 (Contract Account) | +==========================================+==========================================+ | 🧑💻 | 🤖 | | (用户 / 实体) | (程序 / 规则) | |------------------------------------------+------------------------------------------| | 控制方式: 私钥 🔑 | 控制方式: 内部代码 </> | |------------------------------------------+------------------------------------------| | 核心能力: ✅ 主动发起交易 | 核心能力: ❌ 不能主动发起交易 | | | ✅ 被动执行代码 | +------------------------------------------+------------------------------------------+这两种账户分别是外部账户(EOA)和合约账户(Contract Account)。理解它们的区别,是理解以太坊一切应用如何运作的钥匙。首先,外部账户(EOA),你可以把它想象成“人”或“实体”。我们每个人用钱包(比如MetaMask)创建的账户,就是EOA。它有一个独一无二的私钥,只有你自己能控制。EOA最核心的能力,就是主动发起交易。无论是简单的转账,还是启动一个DeFi协议,都是EOA在“按下启动键”。EOA有ETH余额,但本身没有代码。而合约账户,则像一台自动售货机,是区块链上的“程序”或“规则”。它没有私钥,不能主动发起交易,只能被EOA或其他合约账户“唤醒”后,自动执行预设的代码逻辑。合约账户不仅可以有ETH余额,更重要的是拥有与之关联的代码和内部存储。打个比方,EOA是“发起者”,合约账户是“执行者”。整个以太坊的运作模式,就是EOA发起一笔交易,去“激活”合约账户。合约账户被激活后,执行代码,可能会改变自己的数据,或者向其他EOA转账,甚至调用另一个合约账户,形成链式反应。正是这个“双账户”系统,让以太坊从一个简单的支付网络,演变成可以运行复杂应用的去中心化平台。它为构建一个无限可能的去中心化世界奠定了基础。模块三:交易的“DNA”——解剖以太坊的通用指令包(输入指令)理解了账户的双重属性后,自然会好奇:这些账户之间的互动是如何发生的?推动以太坊世界不断变化的“能量”,正是每一笔交易。接下来,聚焦于以太坊交易的内部结构,看看它如何成为驱动“世界计算机”运转的通用指令包。先对比一下,比特币的交易像一张银行支票,只能转账。而以太坊的交易,是一个“万能指令包”,不仅能转账,还能下达各种复杂指令。+---------------------------------------------+ | 以太坊交易 (Universal Packet) | |---------------------------------------------| | - to: 0xRecipient... | | - value: 1 ETH | | - nonce: 12 | | - gasLimit: 21000 | | - ... | |---------------------------------------------| | ▼ data 字段 (核心指令) ▼ | +---------------------------------------------+ | +---------------------+---------------------------------+ | IF data is EMPTY | IF data has CONTENT | +---------------------+---------------------------------+ | 结果: 简单ETH转账 | 结果: 调用智能合约 | | (像银行转账) | e.g., "transfer(0x..., 100)" | | | "mintNFT()" | | | (像发送程序指令) | +---------------------+---------------------------------+一笔以太坊交易,主要包含这些关键字段:to:接收方地址,可以是EOA,也可以是合约账户。value:转账的ETH数量。gasLimit:你愿意为这笔交易最多支付多少Gas。maxFeePerGas / maxPriorityFeePerGas:你愿意为每单位Gas支付的最高费用和小费。nonce:账户发出的交易序号,防止重复执行。最核心、最能体现以太坊可编程性的,是 data 字段。你可以这样理解:如果 data 为空,这就是一笔普通的ETH转账。如果 data 有内容,这就是一条“遥控指令”,比如mint NFT、投票、DeFi操作等。data里包含了要调用的合约函数和参数。正是这个 data 字段,让以太坊的交易变得灵活、可编程,支撑起丰富的应用生态。模块四:交易的“执行回执”与“事件索引” —— Receipts Root & LogsBloom(返回“输出”和“日志”)交易的结构阐释了了“指令”如何下达,但每一次指令执行后,结果如何被记录和追溯?以太坊为此设计了专门的机制,确保每笔交易的执行结果都可验证、可检索。下面,介绍一下 Receipts Root 和 LogsBloom 这两个关键字段。+-------------------------------------------------+ | [区块头] | | +-------------------------------------------+ | | | Receipts Root <----(聚合所有收据的指纹)----+ | | | LogsBloom <----(聚合所有日志的索引)------+ | | +-------------------------------------------+ | +-------------------------------------------------+ | +-------------------------------------------------+ | [区块体] | | +-------------------------------------------+ | | | Transaction 1 -----(执行)-----> [收据 1] | | | | | | | | | +-> Log A | | | | +-> Log B | | | | | | | | Transaction 2 -----(执行)-----> [收据 2] | | | | | | | | | +-> Log C | | | +-------------------------------------------+ | +-------------------------------------------------+你可能会问,交易发出后,怎么知道结果?在以太坊里,每笔交易执行后,都会生成一份“交易回执”(Transaction Receipt)。你可以把它理解为一张超详细的小票,上面会写明:这笔交易到底成功了还是失败了(Status)。实际消耗了多少Gas(Gas Used),也就是你为这次操作付了多少“手续费”。过程中有没有触发合约事件(Logs),比如ERC-20代币的转账事件。这些“回执”不是单独存放的,而是被打包进区块里。区块头有个Receipts Root(收据根),它其实是所有回执的加密指纹,保证这些回执内容不可篡改。那么,LogsBloom又是什么?你可以把它想象成区块的“事件关键词索引”。它是一个特殊的过滤器,能帮你快速判断某个区块里有没有发生过你关心的事件。比如你想查“某个地址有没有收到过某种代币”,不用把所有日志都翻一遍,只要看LogsBloom就能大致判断,大大提升了查询效率。举个生活例子:Receipts就像你每次购物后拿到的小票,详细记录了买了什么、花了多少钱;Receipts Root就像商场后台的总账本,确保所有小票都真实有效;LogsBloom则像商场的“活动索引牌”,让你一眼就能知道本月有没有搞促销。这套机制让以太坊的交易结果既透明又高效,既方便开发者做数据分析,也方便用户追溯和验证。模块五:共识的“迁徙”与经济的“变革” —— 难度与Gas机制演进(资源计费系统)随着对区块结构各个组成部分的逐步剖析,人们也见证了以太坊的不断进化。最后,聚焦于那些见证以太坊历史变迁的动态字段——难度与Gas机制,感受这台“世界计算机”在共识与经济模型上的重大变革。可以把以太坊的进化史分为两个部分:共识机制的迁徙和经济模型的变革。先简单介绍一下PoW和PoS:PoW(工作量证明)是比特币和早期以太坊采用的共识机制。矿工需要用算力去解数学难题,谁先解出来谁就能记账并获得奖励。这种方式安全性高,但能耗巨大。PoS(权益证明)则完全不同。它不靠算力竞争,而是根据你持有和质押的ETH数量来决定谁有权记账。你质押的币越多,被选中的概率越大,能耗极低。以太坊在转型过程中,做了一个非常巧妙的设计——“难度炸弹”(Difficulty Bomb)。你可以把它理解为一个倒计时装置。随着时间推移,难度会指数级增加,最终让PoW挖矿变得几乎不可能,强制推动以太坊从PoW转向PoS。PoW挖矿难度 ^ | | ****** | ** | ** <--- 难度炸弹“引爆” | ** “冰河时代”来临 | ** | * |**************************** | (难度平稳增长,维持出块稳定) +---------------------------------------------------> 时间以太坊的“大合并”(The Merge)实现了主链和PoS信标链的无缝切换。切换时,所有历史数据和账户余额都被完整保留,用户几乎无感知。PoS上线后,区块生产者从“矿工”变成了“验证者”,他们通过质押ETH参与共识,整个网络能耗骤降,安全性和去中心化依然得以保障。大合并后,difficulty 字段永久归零,PoW时代宣告结束。再说Gas机制。Gas可以理解为以太坊的“燃料费”,每次操作都要消耗Gas。EIP-1559升级前,Gas费像拍卖市场,谁出价高谁优先,用户体验很差。EIP-1559后,交易费被拆分为基础费(Base Fee)和优先费(Priority Fee)。基础费由协议根据网络拥堵自动调整,并且直接销毁(burn),这让ETH有了通缩的可能性,也防止了矿工操纵费用。优先费则是你自愿给验证者的小费,用来让自己的交易更快被打包。举个例子,基础费就像打车时的起步价,优先费就像你愿意多给司机的小费,让他优先接你。一笔交易的总费用构成 (EIP-1559) [==========================================|+++++++++++++] ^ ^ ^ | | | 基础费 (Base Fee) 优先费 (Priority Fee) - 由网络根据拥堵情况自动定价 - 用户自愿添加的小费 - 最终会被燃烧销毁 🔥 - 直接支付给验证者作为激励 💰通过这些变革,以太坊实现了更可预测的交易费用,更高效地管理了网络拥堵,也让经济模型更可持续。
2025年10月15日
17 阅读
0 评论
1 点赞
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。使用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-tauri/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相关联的下一篇文章构建前后端service层——从tauri-plugin-sql重构到seaorm #2
2025年10月04日
42 阅读
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日
48 阅读
0 评论
0 点赞
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日
153 阅读
0 评论
3 点赞