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

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

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

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 的 stateactions、组件外用法这三件事学完。

课末会产出故障分诊表的第一行 ——这张表是你 mission 里点名要的东西,它现在还不存在,我们一课一行地把它建起来。

为什么第一课是它

你的 mission 有四个目标,这一条链路同时压着其中三个:

Mission 目标 为什么绕不开运行时配置
照分诊表定位线上问题 「请求去了错误的域 / pCode」是这类桌面工具最高频的一类报障, 而它的全部判定点都在这条链上
独立改发布栈 /option Jenkins job 名不是写死的,全部经 gameEnv.getEnvKey(...) 取 (src/api/task/index.ts:39 起连续几十处)
独立改资源工作台 /workspace SVN 地址、账号、密码同样来自这里,不来自 .envsrc/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 在打包完成的那一刻就凝固成常量了, 它不可能知道你登录的是哪一款游戏。所以本项目必须在它之上再叠一层运行时配置 ——这层就是今天的主角。

Pinia 最小知识集:读懂 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:

  1. defineStore('game', {...}) —— 第一个参数是全局唯一 id, 不是变量名。注意这里 id 叫 'game'、文件叫 GameEvn.ts、 函数叫 useGameEvn——三个名字都不一样,搜索时三个都得试。
  2. state 必须是个函数,返回初始状态。是函数而不是对象, 因为每个 Pinia 实例都要拿到一份全新的、互不干扰的状态 (Pinia · State)。
  3. actions 里的 this 就是 storethis.ENV[key] = value 直接改状态,不需要 mutation、不需要 dispatch (Pinia · Actions)。

第四件事:为什么有个 useGameEvnWithOut

export 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 PiniaPinia · 在组件外使用 store)。

那个 store 就是 createPinia() 的返回值:

const store = createPinia()
export const setupStore = (app: App<Element>) => { app.use(store) }
export { store }
src/store/index.ts:4-10

项目约定:所有 store 都成对导出 useXxxuseXxxWithOut。 组件里用前者,src/api/**src/config/**src/utils/** 里用后者。

更正:这是应该的约定,但代码里没守住—— src/api/login/index.ts:7src/api/progress/index.ts:14 都是在非组件模块里裸调 useJenkinsStore()。它们能跑靠的是 import 顺序, 详见 课 02 的 ⚠️ 一节

四层链路:一个值从 .env 走到请求头

.env / .env.dev / .env.pro ← 模板与默认值,登录后会被覆盖 │ vite --mode xxx,构建期替换 ▼ import.meta.env ← 凝固的字面量 │ electron/main/processHandler.ts:6-9 for-in 逐个灌进主进程 ▼ 主进程 process.env ← 另加 DIST_ELECTRON / DIST_EXTEND / DIST / PUBLIC │ src/utils/extend/electron.ts:1-4 经 @electron/remote 取回渲染层 ▼ ENV │ 登录成功 → src/api/login/index.ts:11 → Extend.initEnv(userInfo.game, …) │ src/utils/extend/envOption.ts:39-69 后端下发配置覆盖 GM/SVN/Jenkins ▼ Pinia useGameEvn().ENV ← ★ 运行时真值在这里 │ src/store/modules/GameEvn.ts:44-86 模块顶层 watch(deep) 自动持久化 ▼ sessionStorage['GameENV'] ← ★ 运行时真值也在这里(副本)

覆盖动作长这样,注意它是写 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 不会报错,运行时静悄悄返回 nullGameEvn.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.tscurrentRouter 在两者之间切换service.ts:92-94)。

但它不是随手写坏的,是被逼出来的:切「测试版 / 正式版」的那 13 个页面里, 有 10 个是直接 setStorage('GameENV', ...)、绕过 Pinia 的 (views/GM/*views/Scriptviews/NewScriptviews/GateWay)。既然写的一方绕开了 store,读的一方就只能也去读 sessionStorage ——否则读不到用户刚切的版本。

代价是:整份运行时配置失去了单一数据源,两个副本谁新谁旧取决于 最后一次写走的是哪条路,也正是上面那个时序窗口存在的根本原因。

规范做法:写和读都收敛到 store——页面调 setEnvKey() 而不是直写 sessionStorage,持久化交给 $subscribe新代码一律用 gameEnv.getEnvKey('VITE_XXX'), 既不新增直读也不新增直写 GameENV 的地方。

检索练习

不要往回翻。先从记忆里取,取不出来也点一个——取的动作本身才是练习。

1. 登录成功之后,.env 里配的 VITE_SVN_URL 还作数吗?

2. service.ts 为什么用 useGameEvnWithOut() 而不是 useGameEvn()

3. GM 弹窗开着的时候发出的 1000y 请求,baseURL 从哪里读?

4. 新加的页面接口打去了错误的服务。第一个该看什么?

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

光读会产生「我懂了」的错觉。下面每一步都要在跑起来的应用里做完 ——真机是本课程唯一的证据来源。

cd ~/Projects/works/gameboxclient
npm run dev

安全边界

这一课全程只读。不要点资源工作台里的提交 / 清理 / 替换 / 删除,不要启动任何 Jenkins 构建 ——在业务方确认哪些环境可安全操作之前,所有写操作只做代码追踪。 见学习任务里的「仍待确认」。