Lesson 01 · 运行时配置
用 GameBox 里唯一重要的那个 Pinia store,一次把 Pinia 学到够用。
这个工具不是只连一个后端,它同时连着好几套。 每次点按钮发请求,代码都要现场算两件事:
| 算什么 | 说人话 | 算错的后果 |
|---|---|---|
baseURL |
这次请求发给哪一台服务器 | 你以为在测试版改,其实改到了正式服 |
pCode |
这次操作的是哪一款游戏 | 你以为在改 A 游戏,其实改了 B 游戏 |
「接口打到了错的服务」= 这两个值算错了任意一个。 请求本身会成功,界面也不报错,但它作用在了错误的对象上。
最难受的版本:你在 GM 里选了「测试版」,写了条游戏公告点发布, 结果它出现在正式服全体玩家眼前。没有报错、没有红字,你甚至以为自己成功了。 ——这类「静默地做对了错误的事」,是本工具最贵的一类故障,所以第一课讲它。
ENV: {
VITE_API_BASEPATH_DEV: '测试版',
VITE_API_BASEPATH: '正式版'
}
src/locales/zh-CN.ts:214-215
GM 那几个页面顶部的「测试版 / 正式版」下拉,切换时干这些事:
const GameENVData = getStorage('GameENV')
if (val == 'VITE_API_BASEPATH_DEV') {
GameENVData['VITE_API_GAMENOTICE_PCODE'] = gameEnv.ENV.VITE_API_BASE_PCODE + '_dev'
} else {
GameENVData['VITE_API_GAMENOTICE_PCODE'] = gameEnv.ENV.VITE_API_BASE_PCODE
}
GameENVData['VITE_API_GAMENOTICE_PATH'] = gameEnv.getEnvKey(val)
setStorage('GameENV', GameENVData)
src/views/GM/GameNotice/index.vue:105-120(同款写法散布在 13
个页面)
「选测试版」的实现方式,就是给 pCode 拼一个
_dev 后缀。
记住这一点,后面那个「pCode 含 _dev」的判断就不再是边缘情况了
——它是「测试版」这个开关本身。
这一课要拿到的东西
看到「某个页面的接口打去了错误的域名 /
pCode」,你能在三分钟内说出该查哪四个位置、 为什么是这四个,并在
DevTools 里当场验证。顺带把 Pinia 的 state、
actions、组件外用法这三件事学完。
课末会产出故障分诊表的第一行 ——这张表是你 mission 里点名要的东西,它现在还不存在,我们一课一行地把它建起来。
你的 mission 有四个目标,这一条链路同时压着其中三个:
| Mission 目标 | 为什么绕不开运行时配置 |
|---|---|
| 照分诊表定位线上问题 | 「请求去了错误的域 / pCode」是这类桌面工具最高频的一类报障, 而它的全部判定点都在这条链上 |
独立改发布栈 /option |
Jenkins job 名不是写死的,全部经
gameEnv.getEnvKey(...) 取 (src/api/task/index.ts:39
起连续几十处)
|
独立改资源工作台 /workspace |
SVN 地址、账号、密码同样来自这里,不来自
.env
(src/utils/extend/envOption.ts:58-64)
|
而且它对你特别友好:这是纯数据流,没有一行模板语法。 你的 TypeScript / Node 经验在这里 100% 有效。
| 你熟悉的 | 这里对应什么 | 差在哪 |
|---|---|---|
| 一个导出单例的模块 | 一个 Pinia store |
store 的
state
是响应式的:谁读了它,谁就会在它变化时被重新触发
|
| 模块上的方法 | store 的 actions |
里面的 this 指向 store 自身,可以直接写
this.ENV = ...
|
process.env |
import.meta.env |
它不是运行时读取。Vite
在构建期把
import.meta.env.VITE_X 整个替换成字面量 (Vite · 环境变量与模式)
|
第三行是这一课最重要的一句话。import.meta.env
在打包完成的那一刻就凝固成常量了,
它不可能知道你登录的是哪一款游戏。所以本项目必须在它之上再叠一层运行时配置
——这层就是今天的主角。
GameEvn没听说过 Pinia?先花五分钟读 课 02 · Pinia 是什么 ——它从你熟悉的 Node 模块级单例讲起,讲完再回来这一节。
整个 store 只有 38 行。先看骨架:
export const useGameEvn = defineStore('game', {
state: () => {
return {
cos: null,
ENV: {} as any
}
},
getters: {},
actions: {
init(ENV_ARG: any) { … },
getEnvKey(key: string) {
return this.ENV[key] || null
},
setEnvKey(key: string, value: any) {
this.ENV[key] = value
},
clean() { … }
}
})
src/store/modules/GameEvn.ts:8-38
三件事就够你读懂本项目所有 store:
defineStore('game', {...}) ——
第一个参数是全局唯一 id, 不是变量名。注意这里 id 叫
'game'、文件叫 GameEvn.ts、 函数叫
useGameEvn——三个名字都不一样,搜索时三个都得试。
state 必须是个函数,返回初始状态。是函数而不是对象, 因为每个 Pinia
实例都要拿到一份全新的、互不干扰的状态 (Pinia · State)。
actions 里的 this 就是 store: this.ENV[key] = value 直接改状态,不需要
mutation、不需要 dispatch (Pinia · Actions)。
useGameEvnWithOutexport const useGameEvnWithOut = () => {
return useGameEvn(store)
}
src/store/modules/GameEvn.ts:40-42
在 .vue 组件里可以直接 useGameEvn(),因为 Vue
知道「当前活跃的应用实例」。 但
src/config/axios/service.ts 是个普通 TS
模块,没有组件上下文——这时必须
把 pinia 实例当参数显式传进去,否则会抛
getActivePinia was called with no active Pinia
(Pinia · 在组件外使用 store)。
那个 store 就是 createPinia() 的返回值:
const store = createPinia()
export const setupStore = (app: App<Element>) => { app.use(store) }
export { store }
src/store/index.ts:4-10
项目约定:所有 store 都成对导出 useXxx 和
useXxxWithOut。
组件里用前者,src/api/**、src/config/**、src/utils/**
里用后者。
更正:这是应该的约定,但代码里没守住——
src/api/login/index.ts:7 和
src/api/progress/index.ts:14 都是在非组件模块里裸调
useJenkinsStore()。它们能跑靠的是 import 顺序, 详见
课 02 的 ⚠️ 一节。
.env 走到请求头
覆盖动作长这样,注意它是写 store 的 state,不是写
.env:
gameEnv.ENV.VITE_SVN_URL = svn_url || gameEnv.ENV.VITE_SVN_URL
gameEnv.ENV.VITE_SVN_USER = svn_account
gameEnv.ENV.VITE_SVN_PASSWORD = svn_password
gameEnv.ENV.VITE_JENKINS_URL = jenkins_url || gameEnv.ENV.VITE_JENKINS_URL
src/utils/extend/envOption.ts:58-67
还有一步容易漏:.env 里 Jenkins job 名带着字面量
channel 这个词, 登录时被替换成实际渠道号。
const replaceChannelInUrls = (url: string) => url.replace('channel', channel)
src/utils/extend/envOption.ts:72(作用于 73-85 行列出的 11 个 job
key)
所以你在 .env 里看到的 job 名不是真正提交给
Jenkins 的那个。 这条到第 5 课讲发布栈时会再用到。
service.ts 有两个读取来源这是本课最需要记住的一段。发请求时 baseURL 怎么定:
function getRouteConfig(): RouteConfig | null {
const currentRouter = sessionStorage.getItem('currentRouter')
return currentRouter ? ROUTE_CONFIG_MAP[currentRouter] : null
}
function getBaseURL(pcode: string): string {
const routeConfig = getRouteConfig()
if (pcode.includes('_dev')) {
return 'http://common-gm-460qntest.3975app.com/n1000y-api/' // 硬编码测试域名
}
return routeConfig
? getStorage('GameENV')[routeConfig.pathKey] // ① sessionStorage
: gameEnv.getEnvKey('VITE_API_PATH') || '…prod…/n1000y-api/' // ② Pinia 内存
}
src/config/axios/service.ts:67-95(getPcode 在 73-82
行,同一套分叉)
读懂这段,你就知道「接口打错服务」只可能是下面四个原因之一:
| # | 可能原因 | 怎么当场确认 |
|---|---|---|
| 1 | currentRouter 不是你以为的值 |
Console 敲 sessionStorage.currentRouter |
| 2 |
该路由压根不在 ROUTE_CONFIG_MAP 里,落到了默认分支
|
对照 service.ts:31-64 的 8 个 key |
| 3 | GameENV 里对应的 pathKey 是空的 |
Application → Session Storage → GameENV |
| 4 |
pCode 含
_dev(=用户选了「测试版」),被硬编码测试域名劫持
|
service.ts:88-89,这条优先级最高 |
只有 url 里含 1000y 的请求才走这套动态切换
(src/config/axios/service.ts
的请求拦截器)。其余请求不走, 别拿这套逻辑去解释所有请求。
currentRouter 不等于地址栏全项目只有这几处写它:
| 写入点 | 写入什么 |
|---|---|
src/layout/TagsView.vue:71 |
切标签时写入该标签的 path——唯一「正常」的一处 |
src/App.vue:63-66 |
初始化时按当前标签补一次 |
src/layout/GlobalEventListener.vue:187, 202 |
GM 是弹窗形态,这里手写虚拟值 '/GM' |
src/layout/GlobalEventListener.vue:184 |
写成空串 '' |
把它理解成接口环境开关,不是页面位置记录。地址栏没动、它却变了,是正常的。
useXxxWithOut() 模式
在非组件模块里显式传 pinia 实例,正是 Pinia 官方推荐做法。新写的
src/api/** 或工具模块要用 store,照抄这个模式。
ENV: {} as any
整份运行时配置零类型。getEnvKey('VITE_API_PAHT')
拼错一个字母,TypeScript 不会报错,运行时静悄悄返回
null(GameEvn.ts:28 的
|| null 把错误吞了)。
规范做法:给 state 声明接口让 Pinia 推导类型,key
用联合类型或 as const 常量表收敛。见
Pinia · State · TypeScript。 新增配置项时至少把新 key 加进一个集中的常量表,别再往
any 里塞。
watch 做持久化
GameEvn.ts:44-86 在模块顶层直接调
watch(..., { deep: true }), 把整个 ENV 写进
sessionStorage。两个后果:
(1)这是绑在 import 时机上的副作用——谁先 import
这个文件,watch 就什么时候 开始生效,顺序不受控。
(2)watch 默认
flush: 'pre',回调是异步批处理的 (Vue · Watchers · 回调触发时机)。 也就是说 setEnvKey() 之后同一 tick 内立刻发请求, service.ts 从 sessionStorage
读到的可能仍是旧值。这是语义上确实存在的时序窗口 ——第 7
课排障演练会真去测一次它会不会咬人。
规范做法:持久化用 Pinia 的
$subscribe
或社区插件 pinia-plugin-persistedstate,在 store
定义内部完成;确实需要同步落盘时 显式写
flush: 'sync'。但不要现在去改——全项目都依赖这个副作用。
Pinia 内存里的 gameEnv.ENV 和 sessionStorage 里的
GameENV 是同一份数据的两个副本,而
service.ts 会按 currentRouter 在两者之间切换(service.ts:92-94)。
但它不是随手写坏的,是被逼出来的:切「测试版 /
正式版」的那 13 个页面里, 有 10 个是直接 setStorage('GameENV', ...)、绕过 Pinia
的
(views/GM/*、views/Script、views/NewScript、
views/GateWay)。既然写的一方绕开了
store,读的一方就只能也去读 sessionStorage ——否则读不到用户刚切的版本。
代价是:整份运行时配置失去了单一数据源,两个副本谁新谁旧取决于 最后一次写走的是哪条路,也正是上面那个时序窗口存在的根本原因。
规范做法:写和读都收敛到 store——页面调
setEnvKey() 而不是直写 sessionStorage,持久化交给
$subscribe。
新代码一律用 gameEnv.getEnvKey('VITE_XXX'), 既不新增直读也不新增直写 GameENV 的地方。
不要往回翻。先从记忆里取,取不出来也点一个——取的动作本身才是练习。
1. 登录成功之后,.env 里配的
VITE_SVN_URL 还作数吗?
B。src/utils/extend/envOption.ts:58 写的是
svn_url || gameEnv.ENV.VITE_SVN_URL——后端给了就用后端的,
.env 只在后端没给时兜底。所以
.env 是模板,不是真值。
2. service.ts 为什么用
useGameEvnWithOut() 而不是 useGameEvn()?
C。service.ts 是普通 TS
模块,没有组件上下文,Pinia 找不到
「当前活跃实例」。useGameEvnWithOut()(GameEvn.ts:40-42)把
createPinia() 的返回值显式传进去解决这个问题。
3. GM 弹窗开着的时候发出的 1000y 请求,baseURL 从哪里读?
A。currentRouter 被写成虚拟值
'/GM' (GlobalEventListener.vue:187),命中
ROUTE_CONFIG_MAP
(service.ts:32-35),于是走
getStorage('GameENV')[routeConfig.pathKey]
这一支(service.ts:92-93) ——读的是 sessionStorage,不是 Pinia 内存。只有没命中映射表时 才回落到
gameEnv.getEnvKey('VITE_API_PATH')。
4. 新加的页面接口打去了错误的服务。第一个该看什么?
D。它决定了走哪一支、取哪个
key、从哪个来源读,是整段分叉的输入。 新页面最典型的失败就是路由没进
ROUTE_CONFIG_MAP,于是静静落到默认分支。 A
几乎一定是错的方向——登录后 .env 已经被覆盖了。
光读会产生「我懂了」的错觉。下面每一步都要在跑起来的应用里做完 ——真机是本课程唯一的证据来源。
cd ~/Projects/works/gameboxclient
npm run dev
GameENV。 确认它的形状是
{"type":"Object","value":{…}}——外面包了一层,因为
useStorage
会连类型一起存(src/hooks/web/useStorage.ts:8-11)。
只记键名和结构,不要复制里面的账号、密码、token。
sessionStorage.currentRouter,记下当前值。切一个标签页再敲一次, 确认它跟着变(写入点
src/layout/TagsView.vue:71)。
/GM——尽管地址栏根本没变。
这一条亲手看到,你就永远不会再把它当成路由记录了。
GameENV 里的
VITE_API_GAMENOTICE_PCODE—— 确认「测试版」时它多了个
_dev 尾巴。只切换、只观察,不要点发布。
GameNotice/index.vue:118 把
VITE_API_GAMENOTICE_PATH 设成了 gm_url_dev,可
service.ts:88-89 一看到 pCode 含 _dev 就直接
return 硬编码域名——那第 118 行设的地址,在「测试版」下是不是根本没被用上?
在 Network 面板看实际请求域名是不是
common-gm-460qntest.3975app.com。
如果是,这就是一条该记进坏味道清单的发现,告诉我,我补进课里。
src/config/axios/service.ts:86(getBaseURL
里 const routeConfig = … 那行)下断点,触发任意一个
1000y 请求, 在 Scope 面板看
routeConfig 是对象还是 null,走的是哪一支。
src/App.vue:24 调的是
gameEnv.init(process.env),这个
process 是渲染进程的全局对象 (App.vue 里没有 import
它);而 src/utils/extend/electron.ts:1 拿的
process 来自
@electron/remote,是主进程的。 在 Console
里比一比:
process.env.VITE_API_PATH
require('@electron/remote').process.env.VITE_API_PATH
两者相等吗?把结论告诉我,我回填进
速查卡。
安全边界
这一课全程只读。不要点资源工作台里的提交 / 清理 / 替换 / 删除,不要启动任何 Jenkins 构建 ——在业务方确认哪些环境可安全操作之前,所有写操作只做代码追踪。 见学习任务里的「仍待确认」。