Lesson 02 · 定位术
从界面文字走到组件、API 与副作用。起手分三条岔路,走完通常不到 15 分钟。
不要往回翻。这两题是检索练习,取不出来也先点一个——回忆失败本身也在加固记忆。
1.
service.ts
决定「这次请求发去哪台服务器」时,最先看的是哪个值?
B。它是整段分叉的输入,决定走哪一支、取哪个
key、从哪个来源读
(src/config/axios/service.ts:67-70)。而且它不等于地址栏
——GM 弹窗会把它写成虚拟值
'/GM'。这一课你会看到它为什么必须是「虚拟」的。
2. 为什么 service.ts 用 useGameEvnWithOut(),
而不是页面里那种 useGameEvn()?
C。普通 TS 模块没有组件上下文,Pinia
找不到「当前活跃实例」
(src/store/modules/GameEvn.ts:40-42)。记住这个形状:这一课的
src/permission.ts:13,15,17 在模块顶层一口气调了三个
useXxxStoreWithOut(),是同一个原因。
美术在群里发一张截图:「游戏资源库里我点了资源卡片上的替换,弹了『更新成功』,但资源没变。」 配一张框住那个按钮的图。没有报错信息,没有堆栈,没有接口名。
你现在要做的第一件事不是修它,而是回答一个更基础的问题: 这句「替换」对应的代码在哪个文件的第几行?
接手一个陌生项目,绝大多数时间不是花在「怎么改」上,是花在「改哪里」上。
课 01 给了你一条纵向链路(一个值怎么从 .env 走到请求头);
这一课给你一条横向链路:从用户能看见的东西,走到你能编辑的那一行。
这一课服务的 mission 目标
「15 分钟定位」和「拿到报障能判定归属层」。定位不准,后面所有排障都是猜。 本课结束时,故障分诊表 会多出「定位层」的第一条检查点。
终点总是一样的(组件 → import 块 → API 层),但起手有三条岔路, 取决于你手上那句界面文字是什么性质。 不要硬走路由表——本课自己的案例就跳过了它。
为什么强调这个:路由表是最容易先入为主的一步,但 GameBox 有相当多界面根本不经过路由。 走 A 的多是菜单和页面标题,走 B 的是绝大多数按钮,走 C 的是 GM 弹窗那一类——它在 src/App.vue:12 被直接 import、在 src/App.vue:148 渲染,地址栏自始至终不变。
GameBox 的中文文案没有统一走 i18n。两种情况:
| 情况 | 典型位置 | 怎么找 |
|---|---|---|
| 走 i18n(主要是菜单/页面标题) | src/locales/zh-CN.ts 的 router 段 |
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.ts 的 router 段里有
dashboard、analysis、devTool、password
等 key
没有任何对应路由——脚手架残留。搜到它们不要顺着找页面,那里没有页面。
路由表在
src/router/index.ts:46-262(asyncRouterMap)。
五条 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 与
/project,
src/router/index.ts:142-159)。搜到它们时不要以为是自己没找对文件。
地址栏为什么长这样
路由用的是
createWebHashHistory()(src/router/index.ts:265),所以地址是 #/workspace 这种带井号的形式。
桌面端打包后页面由 file:// 加载,没有服务器做 URL 重写,
hash 模式是这类应用的常规选择(Vue Router · History 模式)。看到井号不要当成配置错误。
这是本课最省时间的一招。打开任何一个页面组件,
先不看模板、不看逻辑,只读 <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 里,「前端页面」可以直接读写你的磁盘。
定位时不要因为「这是前端」就排除文件系统问题。
src/api/ 不等于就在发请求
到了 src/api/**,很多人默认「这里都是发 HTTP
请求的」。在这个项目里不是——
src/api/ 下的模块分成两类,看
import 块的第一行就能分清:
| 类型 | 判据 | 例子 |
|---|---|---|
| 请求模块 | import request from '@/config/axios' |
src/api/gm/index.ts:2 → 走课 01 那条链路,问题多半在 baseURL 或 pCode |
| 本地副作用模块 |
导入
fs-extra、node:fs、SVN、@electron/remote
|
src/api/workspace/index.ts:1-17 ——整个文件一行 axios 都没有,它是工作副本 / SVN 模块 |
分流的实际意义:副作用模块出问题时,DevTools 的 Network 面板是空的。 在那里找证据会白找很久——本课的「替换」就是这种情况。
现在走完开头那张截图。「替换」是硬编码文案,所以走
B 分支——rg 一步命中组件,不碰路由表。
全程五跳,每一跳都有行号。
五跳,全部靠
rg 和「跳转到定义」,不需要读懂任何一个函数的内部逻辑。
这就是 15 分钟定位的实际样子——它是机械的,不需要灵感。
第 ② 跳需要一点 Vue 模板知识:@click="…"
是绑定点击事件(相当于 onclick),v-for
是循环渲染,v-if
是条件渲染。定位阶段你只需要认得
@click——它是「界面元素 → 处理函数」之间那根线。
完整的模板语法在课 04 讲。
第一件:这个「替换」在特定条件下会直接提交一次 SVN 删除。
要同时满足两个条件才会走到那里:
extname(element) == ''
(src/api/workspace/index.ts:763);
FS.existsSync(targetDir)
(src/api/workspace/index.ts:770)。
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:784 的
return true 并立刻返回,而目录分支里的
await SVN.svnDelete(…) 才刚排上队。
要把因果说准:调用方
src/views/Workspace/Components/ViewCard.vue:326
确实没有 await replaceRes(data),但
replaceRes 内部并没有 await,
它是同步跑到弹窗那一步的——所以少写这个 await 不是本次现象的原因。 真正的原因只有上面那一处。定位的价值就在这里:能把「哪一行导致了这个现象」和「哪一行只是写得不好」分开。
于是
src/views/Workspace/Components/ViewCard.vue:278
的 typeof result == 'boolean' 立刻拿到
true,此时目录分支里的 SVN 操作和文件移动可能一步都还没做完, 弹窗已经写着「更新成功」了。
这就解释了截图里的现象。注意我们没有修任何东西——定位的产物是一个带证据的结论,不是一个补丁。 能把话说到这个份上(哪一行是根因、哪一行只是碍眼、后果具体是什么), 你才知道该改哪一层、回归范围有多大。
「为什么他看不到这个按钮 / 他点进去提示无权限」是接手后最常收到的问题之一。 在 GameBox 里,路由表不是答案。
src/store/modules/permission.ts:36-56 的
generateRoutes() 做的全部事情是:cloneDeep(asyncRouterMap)
再拼一条 404
兜底路由。一行权限过滤都没有,也没有任何后端菜单下发。
所有登录用户拿到的路由表完全一样。
真正的限制分散在两类地方。先看守卫这一类:
| 位置 | 管什么 |
|---|---|
| src/permission.ts:23-24 |
未登录白名单,只有 ['/login'];开发模式下多一条
/onboarding/day1。其余一律被赶去登录页 (src/permission.ts:136)
|
| src/permission.ts:81-97 |
uiView():/ui 需要
userInfo.Privilege
或者用户名正好是 test001
(src/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 ——商城资源管理员进来直接提示「无游戏权限」 |
所以收到「某人看不到 /
点不动某个按钮」,正确动作是:先定位到那个按钮所在的组件,然后在组件里搜这一串关键词——
userInfo、Privilege、user_type、v-if、v-show、:disabled、&&。
去翻路由表是白费时间。
一句必须记住的话
以上全部只是「界面上看不看得见」,不是「有没有权限做这件事」。
v-if 藏起来的按钮,对应的函数在 Console 里照样能调;:disabled
也一样。 真正的授权只能由服务端判定
——排障时可以用它们解释「为什么他看不到」,
但绝不能把它们当成安全边界。
项目里还注册了一个 v-hasPermi 指令 (src/directives/permission/hasPermi.ts:43)。
但全项目除了这处注册,没有任何一个地方使用它——脚手架残留,搜到了不用顺藤摸瓜。
课 01 说 sessionStorage.currentRouter「不等于地址栏」。
现在你有背景知识了:GM 是弹窗,不是路由页面 (在
src/App.vue:12 直接引入,地址栏不变)。 但
service.ts 要靠一个「当前在哪」的标识来选接口环境,
弹窗又没有自己的路由——所以只能人为写一个虚拟值 '/GM' 进去。
这个设计的代价是:定位时不能反过来用
currentRouter 推断用户在哪个页面。
generateRoutes()
src/store/modules/permission.ts:36-56
保留了「动态路由」的整套形状(store、addRouters、addRoute()),
内部却没有任何过滤。它是个空壳:读代码的人以为有权限体系,实际没有。
规范做法:要么真的按后端下发的角色过滤路由表, 要么就诚实地用静态路由,别留一个看起来像权限系统的壳 (Vue Router · 动态路由)。
你现在该怎么做:读懂、绕开。新增页面时照现有形状加进
asyncRouterMap
即可,但不要以为加进去就受权限保护了——它对所有登录用户可见。需要控制可见性,去页面里做。
next() 可能被调用多次
src/permission.ts:99-140 的
beforeEach 依次调了
await docView(to, from, next)、uiView(to, from, next),
然后继续往下走自己的分支,最后还会再调一次
next()。 docView 内部在
src/permission.ts:62,68,70,74 都可能调
next(), uiView 在
src/permission.ts:94 调
next(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:78 的
await list.forEach(async …) 是同一类错误的两个变体:
循环体里的
await
只在各自的回调里生效,外部拿不到任何凭证去等它们。
规范做法:await Promise.all(select.map(async (el) => { … })) ——把
map 返回的 Promise 数组交给 Promise.all(MDN · Promise.all())。这行代码原本就写对了,被注释掉了而已。
完整的修法是三处一起改:replaceFile 改成
async、内部 await Promise.all(…)、调用方再
await 它的结果。
识别技巧:看到 .map(async 或
.forEach(async,
立刻往外看一层有没有 Promise.all。这是本项目复发率最高的一类 bug,第 4、5 课还会再遇到。
两个变体的后果不同,别混为一谈:这里是「界面说成功了,活儿还没干完」;
getPackage.ts:78
那处是「调用方可能拿到一份还没填好的列表」。
同一个成因,表现完全不一样。
replaceFile() 的返回值是
false | 'cancel' | true,三种结果三种类型:
文件类型不支持返回 false(src/api/workspace/index.ts:727)、 用户取消选择返回字符串 'cancel'(src/api/workspace/index.ts:755-757)、 正常走完返回 true(src/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: any(src/api/workspace/index.ts:715)同理—— any 让这个函数完全脱离类型检查。
src/api/workspace/index.ts:771-772。
资源库里一次 「替换」会在满足条件时直接 svnDelete +
svnCommit,
用户没有任何确认步骤,界面上也没有任何提示说「这会改动仓库」。
更糟的是它只提交删除,新内容留在本地
——仓库因此进入一个既不是旧版也不是新版的中间状态。
规范做法:破坏性的远端操作要满足三点—— 操作前明确告知并要求确认、失败时能回滚或至少有明确的补救提示、 删除与新增在同一次提交里完成(要么都成,要么都不成)。 本项目三条都没做到。
你现在该怎么做:在业务口确认「哪些工作副本可以安全操作」之前, 不要在真机上点这个按钮。
electron/main/index.ts:47-51 里
nodeIntegration: true、contextIsolation: false、webSecurity: 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. 你要改「策划工作台」页面上的一个按钮。第一步打开哪个目录?
C。路由 /excel 指向
views/Database/index.vue
(src/router/index.ts:104)。 这是 path
与目录不一致的五条之一,凭 path 猜目录会猜错。
2. 有人反馈「我这个账号看不到 UI 编辑器的保存按钮」。先看哪里?
B。generateRoutes() 零过滤,也没有后端菜单下发,
路由层根本不区分用户,所以答案一定在页面内。
但别只盯 v-if——这个项目还用
:disabled(src/views/Option/Option.vue:227)、
TSX 的
&&(src/views/UI/UIManager/TabsPane.vue:137)。
「看得见但点不动」和「根本看不见」是两种写法。 注意区分:整个
/ui 进不去是另一回事,那在
src/permission.ts:81-97 的 uiView()。
3. 在资源库「替换」一个目录,抓包却看不到任何请求。为什么?
D。src/api/** 里不是只有 HTTP 请求。 replaceFile 走的是 FS. 和
SVN.(api/workspace/index.ts:771-772),
Network 面板当然是空的。定位时先分流「请求 or
副作用」,否则会在错误的工具里找半天。
4. 看到
select.map(async (el) => { … }),第一反应该是什么?
A。map 会返回一组 Promise,
没人接住就等于没人在等。本项目里这个模式已经出现两次
(src/api/workspace/index.ts:760、src/views/Option/getPackage.ts:78)。
但两处的后果不同:前者让「更新成功」提前弹出,
后者让调用方可能拿到一份还没填好的列表。同一个成因,表现不一样。
读到这里你只是「看过」了。下面每一步都要在跑起来的应用里做完。 源码证明当前实现是怎么写的,真机证明用户实际看到什么——两者都是证据。 如果你在真机上看到的和这一课写的不一致, 那更值得记下来:多半是我漏了一个条件分支。
cd ~/Projects/works/gameboxclient
npm run dev
rg "那句中文" src 开始,走到 src/api/** 或
src/utils/extend/**
为止。计时,看看实际花了几分钟。
addRoute() 上打断点,然后登录。 断点停下时在 Sources
面板的作用域里看 permissionStore.getAddRouters 的长度,
再单步过去看 router.getRoutes().length 怎么变
——路由是在这里逐条补进去的。
注意不能在 Console 里直接敲 $router:这个项目没有把 router 挂到
window 上,只有在断点的作用域里才拿得到它。
next()。
在 beforeEach(src/permission.ts:99)和 docView、uiView 里每一处
next(…) 上都打断点,然后切一次页面,
看单次导航实际停了几次——这是验证「next()
被多次调用」最直接的方式。
不要靠 Console 里的 放行: 日志数数:
它只在首次动态加路由那一次打印,之后的导航会在
src/permission.ts:112-114 提前
return,根本走不到那行 console.log。
#/workspace 这种形式,切页面时井号后面跟着变。
给分诊表添一条
做完上面的步骤,把这条加进
故障分诊表 的定位层:
「界面有反馈但结果不对」时,先确认那条链路是请求还是副作用
——查页面 import 块和 API 函数体里有没有
SVN. / FS. / Extend.。
是副作用,就不要再去 Network 面板找证据了。