☰
AnyPS5:基于 Electron 的 PS5 游戏库本地管理工具复盘
2026/10/14 9:31:16 网站建设 项目流程

相信每个入手了新主机的人,都有过这种尴尬瞬间:游戏越买越多,存储空间却越来越小;明明库里堆了几十款作品,真到想玩的时候反而不知道从哪款开始;通关之后想记录一下自己的游玩时长,还得打开主机层层翻菜单。我自己平时写代码,也算是个重度主机玩家,最近梳理并发布了一个小项目 AnyPS5,专门用来做游戏库整理和进度追踪的本地管理工具。项目目标不是再造一个商店,而是把游戏库存管理这个事彻底拿回自己手里:一套导入界面、一张统计面板、一份可以随时备份的本地数据。如果你既是主机玩家,也喜欢自己做点效率工具,这篇文章应该能给你不少可以“抄作业”的参考。

需要先说清楚的是,我对项目本身的定位做过很多次收敛。市面上其实已经有不少游戏库管理网站,能在线标记“已拥有”“已通关”,还能抓取封面和评分。但实际用下来,我发现这类工具普遍有几个痛点:数据都在云端,想离线查看很麻烦;录入一次之后,想改字段或者批量更新又得靠平台支持;最重要的,是这些服务一旦停止运营,自己的游戏清单就跟着没了。AnyPS5 的核心思路,就是把管理权放到本地:不需要联网,不需要注册账号,所有数据都落在自己电脑上的 JSON 文件里,甚至后续想迁移到别的软件都方便。这篇文章我会从项目设计、技术选型、核心实现到踩坑记录做一个完整复盘,保证你可以照着思路自己搭一套。

1. 项目缘起:为什么非要自己折腾一套管理工具

1.1 主机玩家都懂的那些“库管理焦虑”

先聊一个很现实的问题:主机上的游戏库管理,其实比很多人想象的原始。PS5 主界面虽然能按最近游玩、安装状态来排序,但它不会告诉你“这个游戏我上次玩到百分之多少”“这个 DLC 我到底买没买”“我常玩的前十款游戏加起来占了多少存储空间”。你要是想一口气删掉几个不常玩的大体积游戏,只能一个个点进详情页去看容量,再回到总界面继续翻。

我自己的情况更复杂一点:数字版和光碟版混着买,部分游戏会同时在另一台设备上玩进度,所以经常需要对照着主机记录来决定接下来玩什么。过去我试过用一个在线表格记录,结果每次都要打开网页、找记录行、手动改状态,过程繁琐,坚持了不到一个月就放弃了。后来我意识到,这类工具要真正好用,必须满足三个条件:打开速度足够快,录入操作足够少,数据归属足够明确。于是 AnyPS5 这个项目的雏形就出来了。

1.2 AnyPS5 想解决的具体问题

AnyPS5 最初的版本,目标非常克制,就解决三个问题:

  • 用最少的点击录入一款游戏的基本信息、游玩状态和进度。
  • 用一张统计面板直观看到游戏库的分布情况,包括存储空间占用估算。
  • 所有数据本地保存、一键导出备份,不依赖任何第三方服务。

后来在使用的过程中,我又逐步加了版本记录、封面图片本地管理、搜索过滤和自定义状态标签。但这个项目的底线一直没有变:不碰主机系统文件,不做任何违反平台规则的操作,纯粹作为一个辅助工具存在。

1.3 这个项目适合谁来参考

如果你符合下面任意一种情况,我建议你认真看看这个项目的设计思路:

  • 主机游戏玩家,希望对自己的游戏库有一个长期稳定、可离线查看的数据库。
  • 喜欢用本地文件管理个人数据的效率爱好者,比如习惯用 Markdown 写笔记、用本地记账的人。
  • 对 Electron 开发感兴趣,想找一个结构清晰、没有复杂依赖的小项目作为练习。

这个项目的代码量不大,但麻雀虽小五脏俱全,非常适合作为桌面端工具开发的入门范例。你也可以完全不用代码,只是把我后面给出的数据结构抄下来,用表格软件做一套自己的库。

2. 技术选型与整体设计:先想清楚再动手,能少走很多弯路

2.1 为什么最后选了 Electron 而不是纯网页

在确定 Electron 之前,我其实先写了一个纯网页版本,直接在浏览器里打开,用 localStorage 存数据。这样做的好处是零配置、双击就能用,但问题也很明显:浏览器缓存可能被无预警清理;不同浏览器的数据隔离让我换台电脑就看不了;没有友好的文件导出入口,备份得靠开发者工具手动操作。考虑到这是要和电脑绑定的本地效率工具,我决定上 Electron。

Electron 本质上是把 Chromium 和 Node.js 打包在一起,所以我既能用熟悉的前端技术写界面,又能通过 fs 模块直接读写本地文件。对 AnyPS5 这种小众工具来说,Electron 确实有点“重”,但换来的是体验上的确定性:数据落在固定目录,不会被浏览器策略误删;导出备份就是复制文件这么简单。如果哪天觉得 Electron 太臃肿,还可以考虑用 Tauri 重写,但功能验证阶段没必要一开始就给自己上难度。

2.2 为什么数据不做成数据库,而是用 JSON 文件

一开始我也想过用 SQLite 存储,毕竟看起来更“正经”,查询能力也更强。但考虑到这个项目的数据量——普通玩家可能也就几百条游戏记录,SQLite 的复杂度和收益完全不成比例。JSON 文件的优势是肉眼可读、git 可追踪、改错了好恢复。随着后续版本迭代,我只需要保证结构向后兼容,旧文件也能平滑迁移。

我的数据存储方案是:

  • 主文件library.json:存放游戏总表和所有游戏条目。
  • 配置文件settings.json:存界面偏好、默认路径、自定义标签。
  • 封面文件夹covers/:存放每款游戏的封面图,文件名用游戏ID关联。

所有内容全部躺在项目根目录的data/文件夹下,结构干净,谁拿到都能看懂。对于个人工具来说,最好的数据格式就是“你随时能用记事本打开看”的格式。

2.3 三个核心数据结构设计

在设计数据结构时,我的原则是尽量扁平化,能不加嵌套就不加。每一款游戏对应一个 JSON 对象,放在library.json的games数组里:

{ "gameId": "PS-2025-001", "title": "仙剑奇侠传七", "platform": "PS5", "version": "1.04", "status": "ingame", "storageSize": 52.3, "progress": 45, "totalPlayHours": 18.5, "lastPlayed": "2025-01-18", "coverImage": "covers/PS-2025-001.jpg", "notes": "刚过第二章,主线进度慢", "tags": ["国产", "动作角色扮演"] }

字段含义不复杂,但有几个细节值得说明:storageSize用数字类型,单位是 GB,方便后面直接做求和;status不写死,而是维护一份允许的自定义标签列表,默认是“未开始”“进行中”“已通关”“已弃坑”。gameId是我在录入时自动生成的,规则是“PS-年份-序号”,这样即使同一游戏出了多个版本,也能在库里区分开。

settings.json则比较简单:

{ "viewMode": "card", "sortBy": "lastPlayed", "sortOrder": "desc", "customStatus": ["已白金", "收藏"], "storageLimit": 800 }

storageLimit是主机内置空间的设定值,统计面板会把它作为分子分母计算可用的剩余空间。

2.4 界面布局和用户操作的取舍

AnyPS5 的界面设计没有走花哨路线,整体就四块:左侧导航、顶部工具栏、主内容区、底部状态栏。左侧放“游戏库”“统计面板”“设置”三个入口;顶部工具栏包含搜索框、状态筛选、新增游戏按钮;主内容区默认为卡片视图,展示封面、标题、状态和进度;底部状态栏实时显示当前筛选条件下的游戏数量和总存储占用。

这里我想特别强调一个设计原则:功能越小的工具,越不要贪多。我在第一版曾经加过评分系统、游戏笔记 Markdown 编辑、多标签并存、分数排行榜,结果页面变得很乱,录入一笔数据仿佛要填表。最终我把功能砍到只剩核心,把“自定义标签”和“备注文本”作为兜底,既保留了灵活性,又不至于让界面失控。

3. 从零实现核心功能:项目初始化、录入、统计与备份

3.1 初始化项目与运行环境

Electron 项目初始化没什么特别的,我直接用 npm 初始化,然后安装依赖:

mkdir anyps5 cd anyps5 npm init -y npm install --save-dev electron

主进程入口要创建一个窗口,同时把数据目录的读写能力暴露给渲染进程。为了减少依赖,我没有引入额外的 IPC 封装库,直接在 preload 脚本里用contextBridge暴露几个白名单方法:

const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); const fs = require('fs'); let dataDir = path.join(app.getPath('userData'), 'data'); function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }); win.loadFile('index.html'); }

为什么不直接在渲染进程里用 Node.js?因为安全习惯。即便这是个离线工具,也最好保持contextIsolation开启,只暴露必要的接口,避免页面里任何第三方代码直接操作电脑文件。

preload.js暴露的方法大概长这样:

const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('anyps5', { readLibrary: () => ipcRenderer.invoke('read-library'), writeLibrary: (data) => ipcRenderer.invoke('write-library', data), exportZip: () => ipcRenderer.invoke('export-zip') });

主进程里对应实现:

ipcMain.handle('read-library', () => { const filePath = path.join(dataDir, 'library.json'); // 若文件不存在则返回空结构 if (!fs.existsSync(filePath)) return { games: [] }; return JSON.parse(fs.readFileSync(filePath, 'utf-8')); }); ipcMain.handle('write-library', (event, data) => { fs.writeFileSync(path.join(dataDir, 'library.json'), JSON.stringify(data, null, 2), 'utf-8'); return true; });

这里有个细节:写入时我用JSON.stringify(data, null, 2)缩进两个空格,纯粹是为了以后自己排查数据方便。这个习惯一直保留了下来。

3.2 游戏入库表单:让新增操作尽量短

新增游戏表单是整个工具中被使用频率最高的部分,所以我把字段分成三档:

  • 必填:游戏标题、状态。
  • 选填:平台、版本、存储大小、进度、游戏时长。
  • 补充:封面图、标签、备注、最后游玩日期。

表单采用单列布局,没用弹窗嵌套,直接在右侧滑出面板。这样做的原因是,弹窗会让用户频繁切换焦点,而滑出面板可以在不离开列表心理位置的情况下快速填写。新增保存后会自动生成gameId,并把当前时间设为lastPlayed的默认值。

对于封面图,我没有做复杂的图片压缩,只做了一件事:本地文件选择后复制到data/covers/下,文件名统一改成{gameId}.jpg。渲染时如果用原图太慢,就用 CSS 限制显示尺寸,同时把图片解码设置成异步,这样列表滑动时就不会卡。

存储大小录入我做了智能单位换算。玩家在主机上看到的信息通常是“52.3 GB”,但偶尔也会有“743.2 MB”。表单里允许输入数字加单位,解析函数会自动统一成 GB 数值存储:

function parseSize(input) { const match = input.trim().match(/^([\d.]+)\s*(gb|mb)?$/i); if (!match) return null; const value = parseFloat(match[1]); const unit = match[2] ? match[2].toLowerCase() : 'gb'; if (unit === 'mb') return Math.round(value / 1024 * 10) / 10; return Math.round(value * 10) / 10; }

四舍五入保留一位小数,够用又不啰嗦。

3.3 统计面板与存储空间估算

统计面板是整个项目里最容易给人“惊喜”的部分。我每天打开 AnyPS5 的原因之一,就是看这个统计面板,它会告诉我:库里有多少个“在此主机上”的已装游戏,占了多少空间,按现在的存储上限还剩多少。

存储空间的计算公式并不复杂:

剩余空间 = 存储上限 - SUM(所有“已安装”游戏的 storageSize) - 保留空间(20GB)

其中保留空间是我自己设的安全阀值,避免把系统预留空间全部算进去导致误判。实际操作中,主机系统本身会占用一部分空间,所以我会在设置页把“存储上限”设成主机宣传容量再减去 50GB 左右,比如内置 825GB 的机型,我设成 775GB 作为统计上限。

统计面板的展示不只给一个数字,我做了一个简单但有效的分布视图:

  • 按状态统计各分类的游戏数量。
  • 按游戏容量从大到小列出 TOP10 游戏。
  • 按标签展示每个标签涉及的游戏数量和总存储。
  • 一个横向柱状图,直观展示“已用空间”和“剩余空间”的比例。

实现柱状图我没引图表库,直接用 CSS 宽度百分比搞定,毕竟数据量不大,没必要为一个小项目引入大依赖。计算方式就是:

function calcStorageStats(games, storageLimit) { const installedGames = games.filter(g => g.status !== 'abandoned' && g.storageSize > 0); const totalUsed = installedGames.reduce((acc, cur) => acc + cur.storageSize, 0); const reserved = storageLimit - totalUsed - 20; return { totalUsed: Math.round(totalUsed * 10) / 10, installedCount: installedGames.length, reserved: Math.round(reserved * 10) / 10, percentUsed: Math.min(100, Math.round(totalUsed / storageLimit * 100)) }; }

有朋友问我为什么要把“已弃坑”游戏排除在存储统计之外,其实很简单:一个游戏你都不打算玩了,继续把它算进“已用空间”只会干扰判断。真正需要处理的是“已安装但尚未启动”和“已安装偶尔玩”这两类,它们才是存储焦虑的源头。

3.4 导出与备份:让数据真正属于自己

本地工具最大的卖点就是数据可控,所以备份功能我必须放到核心位置。AnyPS5 提供两种备份方式:

  • 手动导出:一键把library.json、settings.json、covers/打包成一个 zip 文件,放到指定目录。
  • 自动备份:每次写入library.json时,同时复制一份到data/backup/下,文件名带上日期。我保留最近七天的自动备份,再久的自动覆盖。

自动备份的逻辑很简单,每次写库后就同步执行一次:

const day = new Date().toISOString().slice(0, 10); const backupPath = path.join(dataDir, 'backup', `library-${day}.json`); fs.copyFileSync(libraryPath, backupPath); // 清理7天前文件 const oldFiles = fs.readdirSync(path.dirname(backupPath)) .filter(f => f.endsWith('.json') && f < `library-${day - 7}.json`); oldFiles.forEach(f => fs.unlinkSync(path.join(path.dirname(backupPath), f)));

这里日期比较有一个小技巧:ISO 日期字符串格式是YYYY-MM-DD,直接按字典序比较就能判断先后,不需要转时间戳。代码维护起来也直观。

数据导出 zip 在 Electron 主进程里做,用 Node.js 内置的zlib能力。不过为了省事,我最终选择直接复制整个data/目录生成一个时间戳文件夹作为备份,zip 打包让用户自己用系统工具完成。个人工具没必要在代码里追求全功能,够用才是第一位。

4. 实测开发中的踩坑记录:这些问题最容易劝退新人

4.1 Electron 文件路径写错,程序在打包后找不到数据

这个问题几乎每个 Electron 新手都会遇到。开发环境里app.getPath('userData')指向的路径和开发模式一致,但打包后应用名字一变,userData路径也会跟着变,容易导致打开应用时看起来像“数据丢了”。

我的解决办法是在启动时做一次路径校验,如果目录不存在就自动创建,并在首页显示当前数据目录绝对路径,方便第一时间排查:

function ensureDataDir() { if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } const coversDir = path.join(dataDir, 'covers'); if (!fs.existsSync(coversDir)) { fs.mkdirSync(coversDir, { recursive: true }); } }

4.2 JSON 文件出现 BOM 导致首行无法解析

有次用户反馈,程序升级后原有的库文件打不开。排查了半天,发现是之前某个编辑器在保存library.json时自动加了 UTF-8 BOM,导致JSON.parse直接报错。虽然这个项目主要是自己用,但潜在风险说明了一个问题:写文件时最好显式指定utf8编码,读取时偶尔要容忍 BOM。

我在读取文件时加了一个兼容处理:

function readJson(filePath, fallback) { try { const raw = fs.readFileSync(filePath, 'utf-8'); if (raw.charCodeAt(0) === 0xFEFF) { return JSON.parse(raw.slice(1)); } return JSON.parse(raw); } catch (e) { console.warn('readJson error', filePath, e); return fallback; } }

虽然代码看着有点丑,但它确实避免了一次“全库变空”的灾难。

4.3 封面图删不掉、改名后列表图裂

游戏封面文件以gameId命名,正常情况下不会冲突。但我在做“删除游戏”功能时忘了同步删除封面文件,结果是主列表里游戏没了,covers/文件夹里却残留一堆孤儿图片。时间久了文件夹越来越大。

修复思路很粗暴但有效:每次启动时扫描covers/目录,删除文件名对应的gameId不在library.json里的图片。这个清理逻辑写成了独立函数,放在“设置”页让用户手动触发,避免每次启动都做大量 I/O。

4.4 列表更新后界面卡顿,防抖自动保存来帮忙

第一版实现是在每次修改字段时立即调用写入,结果就是进度条拖动时频繁写盘,界面肉眼可见地掉帧。后来我给表单的输入事件加了 300ms 防抖,并且把“写库”和“渲染更新”拆开:界面修改只更新内存数据,防抖结束后统一写文件。

let saveTimer = null; function scheduleSave() { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { window.anyps5.writeLibrary(currentLibrary); }, 300); }

这里要注意一个边界:如果用户在 300ms 内直接点了关闭窗口,最后一次修改可能来不及保存。所以在窗口关闭事件里,我强制同步写一次:

win.on('close', async () => { if (saveTimer) { clearTimeout(saveTimer); await window.anyps5.writeLibrary(currentLibrary); } });

4.5 图片加载失败时假装有封面,避免丑裂

网络图容易挂,本地路径有时候也会因为大小写问题导致找不到。我给封面图加了onerror事件,失败时自动显示一个占位色块和游戏标题首字母。这样列表视觉上不会出现大面积裂图。

<div class="cover"> <img src="{coverUrl}" alt="{title}" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"> <div class="cover-fallback" style="display:none">{fallbackChar}</div> </div>

实际上手之后,这套兜底策略让整个工具的观感提升了一个档次。细节不一定显眼,但少了它用户就会觉得不专业。

5. 常见问题速查手册:给后来人少走弯路的总结

现象可能原因解决建议
打开软件后游戏库为空数据目录变了在设置页确认当前数据目录,重启后再检查
修改记录后关闭软件,最后几步改动丢失防抖机制里的关闭事件没触发确保close事件里同步写一次库
JSON 文件被外部编辑器改坏,导致闪退手误写坏格式恢复backup/里最近一次自动备份
封面图加载缓慢图片未压缩且列表一次加载太多用 CSScontent-visibility或懒加载,只渲染可视区域
搜索框输入中文没反应事件绑定在input上没防抖加上防抖和compositionend事件判断组合输入
统计面板剩余空间变成负数游戏容量总和超过设定上限检查重复录入,调整存储上限设置

关于搜索框输入中文,这是很多前端新手容易忽略的点:输入拼音的过程中会触发一系列input事件,如果不判断“是否在组合输入状态”,搜索条件就会在用户还没选字前执行,结果体验极差。稳妥做法是监听compositionstart和compositionend,组合输入期间等待,结束后再执行过滤。

6. 实践中的进一步思考:这款工具还能往哪里走

这个项目从零到现在,最大的价值反而不是功能本身,而是让我重新理解了效率工具的边界。个人小工具最理想的状态不是无限加功能,而是能做到“想改哪里就改哪里”的可控感。AnyPS5 目前已满足我日常的库管理需求,但我也在它身上规划了几个后续方向:

  • 把数据格式标准化,做出一版通用的“游戏库交换格式”,方便以后迁移到其他工具。
  • 在数据目录固定之后,通过局域网提供只读视图,这样在手机浏览器上也能打开主机的游戏统计页。
  • 加入云同步能力,但同步前必须先做加密和冲突合并策略,否则宁可保持纯本地。
  • 把日程提醒做成可选项,比如“超过三个月没玩的游戏”自动打上标签,倒逼自己清理库存。

这些都是很个人的想法,不一定适合所有人。如果你也想做自己的效率工具,我的建议是:先把你最难受的一个问题写下来,然后做一个只解决这个问题的版本,别加任何锦上添花的东西。使用两周后,再根据真实使用频率来决定下一步加什么。AnyPS5 就是这么一步步长成现在这个样子的——没有一夜完成的大设计,都是被实际需求推着走的。

最后再分享一个小细节:这个项目里我最满意的一段代码,不是复杂的统计算法,也不是界面框架,而是数据文件里的一个注释约定。每次在library.json手动调整数据时,我都会在顶部追加一行说明,例如“2月归档了一批未安装游戏”。三个月后再看库文件,能清晰看到每一次大调整的记录。这种把数据当文本资产来维护的习惯,往外延伸一点,其实就是我们现在很多人追求的“日志驱动”思路。做工具、写代码、管游戏库,道理都是相通的:掌握自己的数据,而不是被别人的规则推着走。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询