⚠️ 这篇已失效,仅作历史记录保留

本文写于 2026-08-10,依据的是 gameboxclient旧代码基线。 当前代码库已被整体替换:文中反复出现的 /workspace(游戏资源库)、 /option(游戏发布栈)、src/views/Workspace/src/views/UI/ 在新代码中均已不存在, 所有 file:line 引用都无法再定位。

请改读重建后的课程首页, 或直接看新的架构图接手文档

Lesson 02 · 定位术

同事发来一张截图,怎么找到该改的那一行

从界面文字走到组件、API 与副作用。起手分三条岔路,走完通常不到 15 分钟。

先热身:把课 01 取出来

不要往回翻。这两题是检索练习,取不出来也先点一个——回忆失败本身也在加固记忆。

1. service.ts 决定「这次请求发去哪台服务器」时,最先看的是哪个值?

2. 为什么 service.tsuseGameEvnWithOut(), 而不是页面里那种 useGameEvn()

先说人话:这一课解决的是什么场面

美术在群里发一张截图:「游戏资源库里我点了资源卡片上的替换,弹了『更新成功』,但资源没变。」 配一张框住那个按钮的图。没有报错信息,没有堆栈,没有接口名。

你现在要做的第一件事不是修它,而是回答一个更基础的问题: 这句「替换」对应的代码在哪个文件的第几行?

接手一个陌生项目,绝大多数时间不是花在「怎么改」上,是花在「改哪里」上。 课 01 给了你一条纵向链路(一个值怎么从 .env 走到请求头); 这一课给你一条横向链路:从用户能看见的东西,走到你能编辑的那一行。

这一课服务的 mission 目标

「15 分钟定位」和「拿到报障能判定归属层」。定位不准,后面所有排障都是猜。 本课结束时,故障分诊表 会多出「定位层」的第一条检查点。

定位四步:起手分三条岔路

终点总是一样的(组件 → import 块 → API 层),但起手有三条岔路, 取决于你手上那句界面文字是什么性质。 不要硬走路由表——本课自己的案例就跳过了它。

① 界面文字 │ ├─ A 菜单/页面标题(走 i18n) ─→ locales 拿 key ─→ 路由表 ─→ 组件 │ ├─ B 按钮/提示(硬编码,多数属于这类) ─→ rg 一步命中组件 │ └─ C 全局弹窗(GM、个人信息) ─→ 不经路由,在 App.vue 里直接挂载 ▼ ② 组件的 import 块 ─→ 这个页面碰了哪几层 ▼ ③ API 模块 ─→ 请求 or 本地副作用(SVN / 文件系统 / IPC)

为什么强调这个:路由表是最容易先入为主的一步,但 GameBox 有相当多界面根本不经过路由。 走 A 的多是菜单和页面标题,走 B 的是绝大多数按钮,走 C 的是 GM 弹窗那一类——它在 src/App.vue:12 被直接 import、在 src/App.vue:148 渲染,地址栏自始至终不变。

第 1 步:先分清这句文字是 A 还是 B

GameBox 的中文文案没有统一走 i18n。两种情况:

情况 典型位置 怎么找
走 i18n(主要是菜单/页面标题) src/locales/zh-CN.tsrouter rg "游戏资源库" src/locales → 拿到 key router.workspace → 再 rg "router.workspace"
硬编码(绝大多数按钮、提示、菜单项) 直接写在 .vue rg "替换" src → 一步命中组件

「游戏资源库」这几个字在 src/locales/zh-CN.ts:117,是 router 段的一条 (整段是 src/locales/zh-CN.ts:113-141)。 而资源卡片上那个「替换」按钮是硬编码,在 src/views/Workspace/Components/ViewCard.vue:184。 它不是右键菜单——鼠标停在卡片上时,右上角会出现一排按钮 (src/views/Workspace/Components/ViewCard.vue:104-110), 「替换」是其中一个。

实践建议:先 rg "那句中文" src,一步命中就赚了。 命中不了再去 src/locales/zh-CN.ts 找 key。反过来做会慢一倍, 因为走 i18n 的文案在这个项目里是少数。

src/locales/zh-CN.tsrouter 段里有 dashboardanalysisdevToolpassword 等 key 没有任何对应路由——脚手架残留。搜到它们不要顺着找页面,那里没有页面。

第 2 步(只有 A 分支要走):路由 → 组件,名字不对应

路由表在 src/router/index.ts:46-262asyncRouterMap)。 五条 path 和目录名对不上,外加一条入口文件名例外,凭直觉猜必错:

路由 你会猜的目录 真实组件 证据
/excel views/Excel/ views/Database/index.vue src/router/index.ts:104
/animation views/Animation/ views/Editor/index.vue src/router/index.ts:109
/map views/Map/ views/Default/index.vue src/router/index.ts:120
/tmap views/Tmap/ views/MapTransform/index.vue src/router/index.ts:162
/store views/Store/ views/Shop/index.vue src/router/index.ts:179
/option views/Option/index.vue views/Option/Option.vue src/router/index.ts:68

最后一条最阴:目录名是对的,文件名不是 index.vue。你打开 views/Option/ 会看到一堆文件,入口是 Option.vue。全表在 路由定位速查卡,别背,用的时候查。

还有两条路由整段被注释掉了(/creator/projectsrc/router/index.ts:142-159)。搜到它们时不要以为是自己没找对文件。

地址栏为什么长这样

路由用的是 createWebHashHistory()src/router/index.ts:265),所以地址是 #/workspace 这种带井号的形式。 桌面端打包后页面由 file:// 加载,没有服务器做 URL 重写, hash 模式是这类应用的常规选择Vue Router · History 模式)。看到井号不要当成配置错误。

第 3 步:组件的 import 块就是一张分层地图

这是本课最省时间的一招。打开任何一个页面组件, 先不看模板、不看逻辑,只读 <script setup> 开头的 import 块——它直接告诉你这个页面会碰到哪几层,也就告诉你出问题时该怀疑谁。

拿资源工作台举例 (src/views/Workspace/index.vue:53-72):

这一行 说明这个页面会 出问题时怀疑
import * as WORK from '@/api/workspace'(:58) 调业务 API 请求层 / SVN 副作用
import * as Extend from '@/utils/extend'(:59) 直接驱动外部工具链 SVN / Python / 第三方程序没装或没权限
import { useGameEvnWithOut } from '@/store/modules/GameEvn'(:66) 读运行时配置 课 01 那条链路
import { ipcRenderer } from 'electron'(:57) 和主进程通信 主进程 handler(课 03)
import http from 'node:http' / node:https(:54-55)、 node:fs(:64) 在渲染层直接用 Node 内置模块 文件系统权限、路径、网络代理

最后一行值得停一下。这是渲染进程,正常情况下它不该能 import 'node:http' 它能,是因为主进程把渲染层的 Node 能力整个打开了 (electron/main/index.ts:47-51)。 这件事的完整后果是课 03 的主题; 现在你只需要知道:在 GameBox 里,「前端页面」可以直接读写你的磁盘。 定位时不要因为「这是前端」就排除文件系统问题。

第 4 步:进了 src/api/ 不等于就在发请求

到了 src/api/**,很多人默认「这里都是发 HTTP 请求的」。在这个项目里不是—— src/api/ 下的模块分成两类,看 import 块的第一行就能分清:

类型 判据 例子
请求模块 import request from '@/config/axios' src/api/gm/index.ts:2 → 走课 01 那条链路,问题多半在 baseURL 或 pCode
本地副作用模块 导入 fs-extranode:fsSVN@electron/remote src/api/workspace/index.ts:1-17 ——整个文件一行 axios 都没有,它是工作副本 / SVN 模块

分流的实际意义:副作用模块出问题时,DevTools 的 Network 面板是空的。 在那里找证据会白找很久——本课的「替换」就是这种情况。

实战:追一次「替换」

现在走完开头那张截图。「替换」是硬编码文案,所以走 B 分支——rg 一步命中组件,不碰路由表。 全程五跳,每一跳都有行号。

① rg "替换" src ─→ src/views/Workspace/Components/ViewCard.vue:184(label: '替换', command: 3) │ ▼ ② 同文件 :109 @click="commandHandle(Number(item.command), data)" │ ▼ ③ 同文件 :317 commandHandle → :325-326 case 3 → replaceRes(data) │ ▼ ④ 同文件 :275-276 replaceRes → WorkSpace.replaceFile(fileInfo) │ ▼ ⑤ src/api/workspace/index.ts:715 replaceFile ─→ :771-772 SVN.svnDelete + SVN.svnCommit

五跳,全部靠 rg 和「跳转到定义」,不需要读懂任何一个函数的内部逻辑。 这就是 15 分钟定位的实际样子——它是机械的,不需要灵感。

第 ② 跳需要一点 Vue 模板知识:@click="…" 是绑定点击事件(相当于 onclick),v-for 是循环渲染,v-if 是条件渲染。定位阶段你只需要认得 @click——它是「界面元素 → 处理函数」之间那根线。 完整的模板语法在课 04 讲。

走到终点才看得见的两件事

第一件:这个「替换」在特定条件下会直接提交一次 SVN 删除。

要同时满足两个条件才会走到那里:

if (FS.existsSync(targetDir)) {
  await SVN.svnDelete(targetDir)
  await SVN.svnCommit([targetDir])
}

注意提交的是什么:只有删除。 后面把新目录移进来的那几步(src/api/workspace/index.ts:774-781没有 svnAdd,也没有第二次 svnCommit。 所以准确的结论是:它可能已经把旧目录从仓库里删掉并提交了,但新内容只落在你本地。

这比「资源没变」严重得多——仓库里那份可能真的没了,而本地看起来是好的。 而且这一切不需要你去发布栈点任何按钮,和「所有提交都统一在游戏发布栈做」的直觉相反。

第二件:那句「更新成功」可能早于实际完成。

根因只有一处,就在 src/api/workspace/index.ts:759-760

// await Promise.all(
select.map(async (element) => {
  …
  await SVN.svnDelete(targetDir)
  await SVN.svnCommit([targetDir])
  …
})
// )

map 里每个回调都是 async,各自返回一个 Promise;await Promise.all(…) 那两行被注释掉之后, 这组 Promise 没有任何人接住replaceFile 本身是普通函数,它会一路执行到 src/api/workspace/index.ts:784return true 并立刻返回,而目录分支里的 await SVN.svnDelete(…) 才刚排上队。

要把因果说准:调用方 src/views/Workspace/Components/ViewCard.vue:326 确实没有 await replaceRes(data),但 replaceRes 内部并没有 await, 它是同步跑到弹窗那一步的——所以少写这个 await 不是本次现象的原因。 真正的原因只有上面那一处。定位的价值就在这里:能把「哪一行导致了这个现象」和「哪一行只是写得不好」分开。

于是 src/views/Workspace/Components/ViewCard.vue:278typeof result == 'boolean' 立刻拿到 true此时目录分支里的 SVN 操作和文件移动可能一步都还没做完, 弹窗已经写着「更新成功」了。

这就解释了截图里的现象。注意我们没有修任何东西——定位的产物是一个带证据的结论,不是一个补丁。 能把话说到这个份上(哪一行是根因、哪一行只是碍眼、后果具体是什么), 你才知道该改哪一层、回归范围有多大。

权限:不要去路由表里找

「为什么他看不到这个按钮 / 他点进去提示无权限」是接手后最常收到的问题之一。 在 GameBox 里,路由表不是答案

src/store/modules/permission.ts:36-56generateRoutes() 做的全部事情是:cloneDeep(asyncRouterMap) 再拼一条 404 兜底路由。一行权限过滤都没有,也没有任何后端菜单下发。 所有登录用户拿到的路由表完全一样。

真正的限制分散在两类地方。先看守卫这一类:

位置 管什么
src/permission.ts:23-24 未登录白名单,只有 ['/login'];开发模式下多一条 /onboarding/day1。其余一律被赶去登录页 (src/permission.ts:136
src/permission.ts:81-97 uiView()/ui 需要 userInfo.Privilege 或者用户名正好是 test001src/permission.ts:87), 否则提示「无权限」并退回来源页
src/permission.ts:40-79 docView():按 getcurrentWin双窗口分流, 文档窗口里只允许待在 /document

再看页面内这一类,它没有统一写法。 「看不到按钮」很容易被想当然地归给 v-if,但这个项目里至少有四种:

写法 实例
v-if 条件渲染 src/views/Workspace/Components/ViewCard.vue:107 ——按钮按 item.show(data) 决定出不出现
:disabled ——按钮看得见,但点不动 src/views/Option/Option.vue:227:disabled="getStorage('userInfo')?.user_type == 3"
TSX 里的 && 条件渲染 src/views/UI/UIManager/TabsPane.vue:137 ——按 user_type 决定渲不渲染
页面初始化时直接拦下 src/views/Workspace/index.vue:107 ——商城资源管理员进来直接提示「无游戏权限」

所以收到「某人看不到 / 点不动某个按钮」,正确动作是:先定位到那个按钮所在的组件,然后在组件里搜这一串关键词—— userInfoPrivilegeuser_typev-ifv-show:disabled&&。 去翻路由表是白费时间。

一句必须记住的话

以上全部只是「界面上看不看得见」,不是「有没有权限做这件事」。 v-if 藏起来的按钮,对应的函数在 Console 里照样能调;:disabled 也一样。 真正的授权只能由服务端判定 ——排障时可以用它们解释「为什么他看不到」, 但绝不能把它们当成安全边界

项目里还注册了一个 v-hasPermi 指令 (src/directives/permission/hasPermi.ts:43)。 但全项目除了这处注册,没有任何一个地方使用它——脚手架残留,搜到了不用顺藤摸瓜。

顺带解开课 01 留的一个扣

课 01 说 sessionStorage.currentRouter「不等于地址栏」。 现在你有背景知识了:GM 是弹窗,不是路由页面 (在 src/App.vue:12 直接引入,地址栏不变)。 但 service.ts 要靠一个「当前在哪」的标识来选接口环境, 弹窗又没有自己的路由——所以只能人为写一个虚拟值 '/GM' 进去。 这个设计的代价是:定位时不能反过来用 currentRouter 推断用户在哪个页面。

这个实现值不值得模仿

⚠️ 隔离 —— 零过滤的 generateRoutes()

src/store/modules/permission.ts:36-56 保留了「动态路由」的整套形状(store、addRoutersaddRoute()), 内部却没有任何过滤。它是个空壳:读代码的人以为有权限体系,实际没有。

规范做法:要么真的按后端下发的角色过滤路由表, 要么就诚实地用静态路由,别留一个看起来像权限系统的壳 (Vue Router · 动态路由)。

你现在该怎么做:读懂、绕开。新增页面时照现有形状加进 asyncRouterMap 即可,但不要以为加进去就受权限保护了——它对所有登录用户可见。需要控制可见性,去页面里做。

❌ 别学 —— 导航守卫里 next() 可能被调用多次

src/permission.ts:99-140beforeEach 依次调了 await docView(to, from, next)uiView(to, from, next)然后继续往下走自己的分支,最后还会再调一次 next()docView 内部在 src/permission.ts:62,68,70,74 都可能调 next()uiViewsrc/permission.ts:94next(from.path) 之后也没有阻止外层继续。

Vue Router 明确要求:任何一次导航中 next 必须恰好被调用一次,多调会导致导航行为不确定 (Vue Router · 导航守卫)。

规范做法:新代码里的守卫不要用 next,改用返回值——放行 return true,中止 return false,改道 return '/login'。返回值形式天然不可能「调用两次」。

排障含义:遇到「点了一下跳了两次」「偶尔停在奇怪的页面」, 这里是第一嫌疑人。

❌ 别学 —— 被注释掉的 await Promise.all(

src/api/workspace/index.ts:759-760。 这和 src/views/Option/getPackage.ts:78await list.forEach(async …) 是同一类错误的两个变体: 循环体里的 await 只在各自的回调里生效,外部拿不到任何凭证去等它们

规范做法:await Promise.all(select.map(async (el) => { … })) ——把 map 返回的 Promise 数组交给 Promise.allMDN · Promise.all())。这行代码原本就写对了,被注释掉了而已。 完整的修法是三处一起改:replaceFile 改成 async、内部 await Promise.all(…)、调用方再 await 它的结果。

识别技巧:看到 .map(async.forEach(async立刻往外看一层有没有 Promise.all。这是本项目复发率最高的一类 bug,第 4、5 课还会再遇到。

两个变体的后果不同,别混为一谈:这里是「界面说成功了,活儿还没干完」; getPackage.ts:78 那处是「调用方可能拿到一份还没填好的列表」。 同一个成因,表现完全不一样。

❌ 别学 —— 用三种类型表达三种结果

replaceFile() 的返回值是 false | 'cancel' | true,三种结果三种类型: 文件类型不支持返回 falsesrc/api/workspace/index.ts:727)、 用户取消选择返回字符串 'cancel'src/api/workspace/index.ts:755-757)、 正常走完返回 truesrc/api/workspace/index.ts:784)。 调用方只能靠 typeof result == 'boolean' 来区分 (src/views/Workspace/Components/ViewCard.vue:278)——于是「取消」和「不支持的类型」被合并成了同一句提示。

规范做法:用可辨识联合表达结果,例如 { status: 'cancelled' } | { status: 'ok' } | { status: 'unsupported' }, 让 TypeScript 帮你穷尽分支(TypeScript · Discriminated Unions)。 参数上的 fileInfo: anysrc/api/workspace/index.ts:715)同理—— any 让这个函数完全脱离类型检查。

❌ 别学 —— 一个界面动作直接提交版本库

src/api/workspace/index.ts:771-772。 资源库里一次 「替换」会在满足条件时直接 svnDelete + svnCommit, 用户没有任何确认步骤,界面上也没有任何提示说「这会改动仓库」。 更糟的是它只提交删除,新内容留在本地 ——仓库因此进入一个既不是旧版也不是新版的中间状态。

规范做法:破坏性的远端操作要满足三点—— 操作前明确告知并要求确认、失败时能回滚或至少有明确的补救提示、 删除与新增在同一次提交里完成(要么都成,要么都不成)。 本项目三条都没做到。

你现在该怎么做:在业务口确认「哪些工作副本可以安全操作」之前, 不要在真机上点这个按钮

⚠️ 隔离 —— 渲染层开着完整的 Node 能力

electron/main/index.ts:47-51nodeIntegration: truecontextIsolation: falsewebSecurity: false 三个开关全开。 这就是为什么页面能直接 import http from 'node:http'

这不是常态,是 Electron 官方明确反对的配置——它意味着页面里 任何一段被注入的脚本都拥有你本机的完整文件系统权限 (Electron · Security)。 规范做法是关掉 nodeIntegration、开启 contextIsolation,通过 preload + contextBridge 只暴露必要的接口。

你现在该怎么做:架构既定、改动成本极高,读懂即可, 但不要在新代码里继续扩大依赖面——新功能优先走 IPC,而不是在渲染层直接操作文件系统。完整讨论在课 03。

✅ 照做 —— 路由懒加载

component: () => import('@/views/Workspace/index.vue')src/router/index.ts:56 等)。 每个页面一个动态 import,是 Vue Router 推荐写法,新增页面照抄 (Vue Router · 路由懒加载)。

定位含义:页面组件是按需加载的, 所以在 DevTools 的 Sources 里找不到某个组件,很可能只是还没访问过那个页面, 不代表文件不存在。

检索练习

同样先从记忆里取。

1. 你要改「策划工作台」页面上的一个按钮。第一步打开哪个目录?

2. 有人反馈「我这个账号看不到 UI 编辑器的保存按钮」。先看哪里?

3. 在资源库「替换」一个目录,抓包却看不到任何请求。为什么?

4. 看到 select.map(async (el) => { … }),第一反应该是什么?

去真机上验证(约 15 分钟)

读到这里你只是「看过」了。下面每一步都要在跑起来的应用里做完。 源码证明当前实现是怎么写的,真机证明用户实际看到什么——两者都是证据。 如果你在真机上看到的和这一课写的不一致, 那更值得记下来:多半是我漏了一个条件分支。

cd ~/Projects/works/gameboxclient
npm run dev

给分诊表添一条

做完上面的步骤,把这条加进 故障分诊表 的定位层: 「界面有反馈但结果不对」时,先确认那条链路是请求还是副作用 ——查页面 import 块和 API 函数体里有没有 SVN. / FS. / Extend.。 是副作用,就不要再去 Network 面板找证据了。