初始化

需要注意的是饥荒不够完善:动作、手持工具、细节交互、系统逻辑
This commit is contained in:
李琦
2026-08-15 07:24:34 +08:00
parent aa37fc4119
commit cd1ab90013
150 changed files with 40801 additions and 271 deletions

383
electron/e2e-starve.cjs Normal file
View File

@@ -0,0 +1,383 @@
// 饥荒专项端到端自检(开发用):登录 → 进入饥荒 → 单人开局 →
// 通过 window.__starve 后门验证:世界生成/三围/合成科技/查理/季节体温/巨鹿/小地图/存档
// 追加:宽屏布局无滚动条断言 + Mod 体系全链路(8 个内置 mod 勾选开局逐一验证)
// 运行:npm run electron:dist; npx electron electron/e2e-starve.cjs
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')
const SERVER = process.env.NLG_SERVER || 'http://127.0.0.1:8080'
const OUT_DIR = process.env.NLG_E2E_OUT || path.join(process.cwd(), 'e2e-out')
const pageErrors = []
ipcMain.on('nlg:get-server-base', (e) => { e.returnValue = SERVER })
ipcMain.handle('nlg:set-server-base', () => ({ ok: true }))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
let failures = 0
const check = (cond, msg) => {
if (cond) console.log(' PASS', msg)
else { failures++; console.error(' FAIL', msg) }
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true })
const win = new BrowserWindow({
width: 1360,
height: 860,
show: true,
backgroundColor: '#0f1220',
webPreferences: { preload: path.join(__dirname, 'preload.cjs') },
})
win.webContents.on('console-message', (e) => {
if (e?.level === 'error' && e?.message) pageErrors.push(e.message)
})
const js = (code) => win.webContents.executeJavaScript(code, true)
const shot = async (name) => {
for (let i = 0; i < 3; i++) {
try {
const img = await win.webContents.capturePage()
fs.writeFileSync(path.join(OUT_DIR, `${name}.png`), img.toPNG())
console.log('[e2e] screenshot:', name)
return
} catch { await sleep(600) }
}
}
const waitFor = async (cond, timeoutMs, desc) => {
const t0 = Date.now()
while (Date.now() - t0 < timeoutMs) {
try { if (await js(cond)) return true } catch {}
await sleep(250)
}
throw new Error(`等待超时:${desc}`)
}
const st = () => js('window.__starve ? JSON.stringify(window.__starve.state()) : "null"').then((s) => JSON.parse(s))
const distIndex = path.join(__dirname, '../dist/index.html')
if (!fs.existsSync(distIndex)) throw new Error('缺少 dist,请先 npm run electron:dist')
await win.loadFile(distIndex)
// 登录
await js('localStorage.clear(); true')
await win.webContents.reload()
await waitFor(`!!document.querySelector('input[placeholder^="用户名"]')`, 10000, '登录页')
await js(`(() => {
const u = document.querySelector('input[placeholder^="用户名"]')
u.value = 'xiaoming'; u.dispatchEvent(new Event('input', { bubbles: true }))
const p = document.querySelector('input[type="password"]')
p.value = '123456'; p.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('button.submit').click()
return true
})()`)
await waitFor(`!!localStorage.getItem('token') && location.hash === '#/'`, 10000, '登录成功')
console.log('[e2e] 登录成功')
// 确保饥荒可玩(必要时购买)
const gameName = await js(`
fetch(window.desktop.serverBase + '/api/games', { headers: { Authorization: 'Bearer ' + localStorage.getItem('token') } })
.then((r) => r.json()).then((j) => ((j.data || []).find((g) => g.code === 'starve') || {}).name || '')
`)
if (!gameName) throw new Error('饥荒游戏不存在')
await sleep(1200)
const cardBtn = `(() => {
const card = [...document.querySelectorAll('.game-card')].find((c) => (c.querySelector('.name')?.textContent || '').trim() === ${JSON.stringify(gameName)})
const btn = card && card.querySelector('.actions button:last-child')
return btn ? btn.textContent.trim() : ''
})()`
if ((await js(cardBtn)) === '去购买') {
await js(`(() => {
const card = [...document.querySelectorAll('.game-card')].find((c) => (c.querySelector('.name')?.textContent || '').trim() === ${JSON.stringify(gameName)})
card.querySelector('.actions button:last-child').click(); return true
})()`)
await waitFor(`!!document.querySelector('.buy-modal')`, 4000, '购买弹窗')
await js(`document.querySelector('.buy-modal .bm-actions .btn:last-child').click(); true`)
await waitFor(`${cardBtn} === '开始游戏'`, 8000, '购买完成')
console.log('[e2e] 已购买饥荒')
}
// 进入饥荒 → 单人冒险
await js(`location.hash = '#/play/starve'; true`)
await waitFor(`!!document.querySelector('.overlay .mode-card')`, 10000, '模式选择面板')
await js(`document.querySelector('.overlay .mode-card').click(); true`) // 单人冒险
await sleep(400)
// 有旧档则弃档重开,保证确定性
await waitFor(`!![...document.querySelectorAll('.overlay button')].find((b) => b.textContent.includes('新的冒险') || b.textContent.includes('放弃存档重新开始'))`, 6000, '单人面板')
await js(`(() => {
const btns = [...document.querySelectorAll('.overlay button')]
const btn = btns.find((b) => b.textContent.includes('放弃存档重新开始')) || btns.find((b) => b.textContent.includes('新的冒险'))
btn.click()
return true
})()`)
await waitFor(`!!window.__starve && window.__starve.state().ents > 50`, 10000, '游戏开局 + 世界生成')
console.log('[e2e] 饥荒已开局')
await sleep(2500)
// 1) 开局状态
let s = await st()
console.log('[e2e] 开局状态:', JSON.stringify(s))
check(s.hp === 150 && s.hunger <= 150 && s.san <= 200, `三围官方值 (${s.hp}/${Math.round(s.hunger)}/${Math.round(s.san)})`)
check(s.season === 'autumn' && s.day === 1, `秋季第 1 天开局`)
check(!!s.biome, `出生在群系 ${s.biome}`)
check(s.fps > 25, `帧率正常 (${s.fps})`)
check(s.monKinds.includes('beefalo') && s.monKinds.includes('pig') && s.monKinds.includes('frog') && s.monKinds.includes('spider'), `生物齐备 (${[...new Set(s.monKinds)].join(',')})`)
// 宽屏布局:整页贴合视口不出滚动条,画布铺满容器
const layout = await js(`(() => {
const root = document.scrollingElement || document.documentElement
const cv = document.querySelector('canvas.starve-canvas')
const wrap = cv && cv.parentElement
const r1 = cv && cv.getBoundingClientRect()
const r2 = wrap && wrap.getBoundingClientRect()
return JSON.stringify({
wide: document.body.classList.contains('wide-page'),
noScroll: root.scrollHeight <= window.innerHeight + 1 && root.scrollWidth <= window.innerWidth + 1,
sh: root.scrollHeight, ih: window.innerHeight,
fill: !!(r1 && r2 && Math.abs(r1.height - r2.height) < 3 && Math.abs(r1.width - r2.width) < 3),
ch: r1 ? Math.round(r1.height) : 0, wh: r2 ? Math.round(r2.height) : 0,
})
})()`).then(JSON.parse)
check(layout.wide, 'wide-page 布局生效')
check(layout.noScroll, `无滚动条(页高 ${layout.sh} <= 视口 ${layout.ih})`)
check(layout.fill, `画布铺满游戏区 (画布高 ${layout.ch} / 容器高 ${layout.wh})`)
await shot('starve-1-autumn-day')
// 2) 合成与科技
await js(`(() => { const g = window.__starve; g.give('twig',8); g.give('flint',6); g.give('grass',12); g.give('log',24); g.give('rock',24); g.give('gold',8); return true })()`)
await js(`window.__starve.craft('axe'); true`)
await sleep(300)
s = await st()
check(s.equip.hand === 'axe', `打造斧头并自动装备 (${s.equip.hand})`)
const spearBefore = await js(`(() => { window.__starve.craft('spear'); return window.__starve.state().slots.join(',') })()`)
check(!spearBefore.includes('spear'), '无科学机器时长矛不可合成(科技封锁)')
await js(`window.__starve.craft('sciencemachine'); window.__starve.give('rope',2); true`)
await sleep(300)
await js(`window.__starve.craft('spear'); true`)
await sleep(300)
s = await st()
check(s.slots.join(',').includes('spear'), `科学机器旁解锁长矛原型 (${s.slots.join(',')})`)
await js(`window.__starve.craft('campfire'); true`)
await sleep(300)
s = await st()
check(s.fires >= 1, '篝火点燃')
await shot('starve-2-craft')
// 3) 夜晚 + 查理
await js(`window.__starve.time(150); true`)
await sleep(800)
await shot('starve-3-night')
// 走离篝火再测查理(先记录当前 hp)
const hpBefore = (await st()).hp
await js(`(() => {
const g = window.__starve
// 传送到远处黑暗地带(直接改不了坐标,用给火把再收走的方式不行——改用等待查理打到)
return true
})()`)
// 玩家出生点附近有篝火,把时间拨到夜晚但玩家未必在火圈外——直接验证黑暗提示逻辑成本高,
// 改为验证「夜晚黑暗掉血」:等 8 秒看是否遭袭(若在光圈内则跳过该断言)
await sleep(8000)
s = await st()
const struck = s.hp < hpBefore - 50
console.log(`[e2e] 查理测试:hp ${hpBefore} -> ${s.hp}${struck ? '(遭袭)' : '(在光圈内,跳过)'}`)
await js(`window.__starve.time(20); true`) // 拨回白天防连击致死
// 4) 冬季 + 体温 + 巨鹿(先传送远离篝火,避免火焰取暖干扰)
await js(`(() => { const p = window.__starve.state().pos; window.__starve.pos(p[0] + 400, p[1]); window.__starve.season('winter'); return true })()`)
await sleep(400)
s = await st()
check(s.season === 'winter', '跳转冬季')
const tempBefore = s.temp
await sleep(4000)
s = await st()
check(s.temp < tempBefore, `冬季体温下降 (${tempBefore}° -> ${s.temp}°)`)
await shot('starve-4-winter')
await js(`window.__starve.deerclops(); true`)
await sleep(2500)
s = await st()
check(s.monKinds.includes('deerclops'), `独眼巨鹿降临 (${[...new Set(s.monKinds)].join(',')})`)
await sleep(1500)
await shot('starve-5-deerclops')
// 5) 小地图
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' })); true`)
await sleep(800)
const mapVisible = await js(`(() => { const el = document.querySelector('.ds-map'); return el && el.style.display !== 'none' })()`)
check(mapVisible, '小地图 M 键开启')
await shot('starve-6-minimap')
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' })); window.dispatchEvent(new KeyboardEvent('keyup', { key: 'm' })); true`)
// 6) 存档往返
await js(`[...document.querySelectorAll('.hud-left .ds-btn')].find((b) => b.textContent.includes('保存'))?.click(); true`)
await sleep(1200)
const saved = await js(`
fetch(window.desktop.serverBase + '/api/games/starve/save', { headers: { Authorization: 'Bearer ' + localStorage.getItem('token') } })
.then((r) => r.json()).then((j) => j.data || j).then((d) => !!(d && d.exists !== false && d.data)).catch(() => false)
`)
check(saved, '云存档已写入(v4)')
// ===== 7) Mod 体系全链路 =====
console.log('[e2e] === Mod 体系 ===')
// 重新进入游玩页:单人面板勾选全部 mod 后弃档重开
await js(`location.hash = '#/'; true`)
await sleep(700)
await js(`location.hash = '#/play/starve'; true`)
await waitFor(`!!document.querySelector('.overlay .mode-card')`, 10000, '模式选择面板(二次)')
await js(`document.querySelector('.overlay .mode-card').click(); true`)
await waitFor(`document.querySelectorAll('.mod-pick input').length >= 8`, 6000, 'Mod 勾选列表')
const modCount = await js(`(() => {
const boxes = [...document.querySelectorAll('.mod-pick input')]
boxes.forEach((cb) => { if (!cb.checked) cb.click() })
return boxes.filter((cb) => cb.checked).length
})()`)
check(modCount === 8, `勾选全部 8 个 mod (${modCount})`)
const factorTxt = await js(`(document.querySelector('.mod-pick .mp-total') || {}).textContent || ''`)
check(factorTxt.includes('0.64'), `面板显示难度系数 (${factorTxt.trim()})`)
await shot('starve-7-mod-pick')
await js(`(() => {
const btns = [...document.querySelectorAll('.overlay button')]
const btn = btns.find((b) => b.textContent.includes('放弃存档重新开始')) || btns.find((b) => b.textContent.includes('新的冒险'))
btn.click()
return true
})()`)
await waitFor(`!!window.__starve && window.__starve.mods().length === 8 && window.__starve.state().ents > 50`, 12000, 'Mod 局开局')
s = await st()
check(s.mods.length === 8, `本局激活 mod:${s.mods.join(',')}`)
check(s.scoreFactor === 0.64, `得分系数 0.64 (${s.scoreFactor})`)
// 和平模式:现在就排猎犬波,结尾处验证一直没有狗(无 mod 时 6 秒后必出)
await js(`window.__starve.hound(); true`)
// 宝藏:世界生成撒了箱子;传送过去开箱得战利品
const chestN = await js(`window.__starve.entCount('chest')`)
check(chestN >= 6 && chestN <= 10, `宝箱撒点 6~10 个 (${chestN})`)
const chestPos = await js(`JSON.stringify(window.__starve.find('chest'))`).then(JSON.parse)
await js(`window.__starve.pos(${chestPos[0]}, ${chestPos[1] + 36}); true`)
const slotsBeforeChest = (await st()).slots.join(',')
await js(`window.__starve.poke('chest'); true`)
await sleep(300)
s = await st()
check(s.slots.join(',') !== slotsBeforeChest, `开箱获得战利品 (${s.slots.join(',')})`)
// 便利包:堆叠上限 99(原版 40)
await js(`window.__starve.give('grass', 90); true`)
await sleep(200)
s = await st()
check(s.slots.some((x) => x === 'grassx90'), `草堆叠 90/99 一格 (${s.slots.find((x) => x.startsWith('grass'))})`)
// 农场:锄头开垦 → 播种 → 拨快生长 → 收获(撒点太密会提示"太挤",挪几步重试)
await js(`window.__starve.give('hoe', 1); true`)
await sleep(200)
for (let k = 0; k < 8; k++) {
await js(`window.__starve.useCode('hoe'); true`)
await sleep(150)
if (await js(`window.__starve.entCount('farmplot')`)) break
await js(`(() => { const g = window.__starve; const p = g.state().pos; g.pos(p[0] + 56, p[1] + (${k} % 2 ? 64 : -64)); return true })()`)
}
check((await js(`window.__starve.entCount('farmplot')`)) >= 1, '锄头开垦出农田')
await js(`window.__starve.give('carrotseeds', 2); window.__starve.poke('farmplot'); true`)
await sleep(200)
await js(`window.__starve.tickEnts(400); true`) // 拨快 400 秒 → 成熟
await sleep(200)
await js(`window.__starve.poke('farmplot'); true`)
await sleep(200)
s = await st()
check(s.slots.some((x) => x.startsWith('carrotx')), `农田收获胡萝卜 (${s.slots.find((x) => x.startsWith('carrotx'))})`)
await shot('starve-8-mod-farm')
// 噩梦模式:蜘蛛血量 100 → 150(就近读我们刚召的那只,别误读远处巢里的)
await js(`window.__starve.spider(); true`)
await sleep(300)
const spiderHp = await js(`window.__starve.nearMobHp('spider')`)
check(spiderHp === 150, `噩梦蜘蛛血量 150 (${spiderHp})`)
// 魔法:火魔杖 AoE 灼烧(35 伤害)→ 回旋镖补刀(20 伤害)
await js(`window.__starve.give('firestaff', 1); true`)
await sleep(200)
await js(`window.__starve.useCode('firestaff'); true`)
await sleep(300)
const afterStaff = await js(`window.__starve.nearMobHp('spider')`)
check(afterStaff === 115, `火魔杖灼烧 150→115 (${afterStaff})`)
await js(`window.__starve.give('boomerang', 1); true`)
await sleep(200)
await js(`window.__starve.useCode('boomerang'); true`)
await sleep(1600)
const afterBoom = await js(`window.__starve.nearMobHp('spider')`)
check(afterBoom < afterStaff, `回旋镖命中 (${afterStaff}→${afterBoom})`)
// 清场:清掉敌对生物(那只 95 血的噩梦蜘蛛会一路追杀)+ 回满三围,保证后续断言确定性
await js(`window.__starve.wipeHostiles(); window.__starve.heal(); true`)
await js(`(() => { const t = window.__starve.find('tree'); if (t) window.__starve.pos(t[0] + 40, t[1] + 40); return true })()`)
await sleep(300)
// 切斯特:拿到眼骨后现身跟随;丢地上的东西会被它吞掉,点它吐出
await js(`window.__starve.give('eyebone', 1); true`)
await waitFor(`window.__starve.state().monKinds.includes('chester')`, 6000, '切斯特现身')
check(true, '眼骨召来切斯特')
const store0 = await js(`JSON.stringify(window.__starve.mobStore('chester') || [])`).then(JSON.parse)
check(store0.length === 0, '切斯特初始空仓')
await js(`window.__starve.drop('log', 5); window.__starve.drop('flint', 3); true`)
await sleep(3000)
const store1 = await js(`JSON.stringify(window.__starve.mobStore('chester') || [])`).then(JSON.parse)
check(store1.length >= 1, `切斯特吞了掉落物 (${store1.join(',') || '空'})`)
await js(`window.__starve.pokeMob('chester'); true`)
await sleep(300)
const store2 = await js(`JSON.stringify(window.__starve.mobStore('chester') || [])`).then(JSON.parse)
check(store2.length === 0, '点击切斯特吐出全部物品')
await shot('starve-9-mod-chester')
// 复活雕像:二级科技建造 → 致死伤害被拦截原地复活(先清背包防溢出落地)
await js(`(() => { const g = window.__starve; g.clearInv(); g.give('rock', 40); g.give('gold', 14); g.give('log', 12); return true })()`)
await js(`window.__starve.craft('sciencemachine'); true`)
await sleep(200)
await js(`window.__starve.craft('alchemyengine'); true`)
await sleep(200)
await js(`window.__starve.craft('revivestatue'); true`)
await sleep(200)
check((await js(`window.__starve.entCount('revivestatue')`)) === 1, '建成复活雕像')
await js(`window.__starve.hurt(999); true`)
await sleep(400)
s = await st()
check(s.hp === 75, `致死伤害被雕像拦截,半血复活 (hp=${s.hp})`)
check((await js(`window.__starve.entCount('revivestatue')`)) === 0, '雕像已消耗')
const overlayDead = await js(`!!document.querySelector('.death-overlay')`)
check(!overlayDead, '未进入死亡结算')
// 和平模式收尾断言:排波已过去 10 秒+,仍然无猎犬
s = await st()
check(!s.monKinds.includes('hound') && !s.monKinds.includes('icehound'), `和平模式全程无猎犬 (${[...new Set(s.monKinds)].join(',')})`)
// 便利包无迷雾:直接开地图截图(全图可见)
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' })); true`)
await sleep(700)
await shot('starve-10-mod-map-nofog')
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' })); window.dispatchEvent(new KeyboardEvent('keyup', { key: 'm' })); true`)
// Mod 存档往返:保存 → 云端应带 mods 字段
await js(`[...document.querySelectorAll('.hud-left .ds-btn')].find((b) => b.textContent.includes('保存'))?.click(); true`)
await sleep(1200)
const savedMods = await js(`
fetch(window.desktop.serverBase + '/api/games/starve/save', { headers: { Authorization: 'Bearer ' + localStorage.getItem('token') } })
.then((r) => r.json()).then((j) => j.data || j)
.then((d) => { try { return JSON.parse(d.data).mods || [] } catch { return [] } })
.catch(() => [])
`)
check(Array.isArray(savedMods) && savedMods.length === 8, `存档记录 mod 集合 (${savedMods.length} 个)`)
// 汇总
const errs = pageErrors.filter((m) => !m.includes('favicon') && !m.includes('DevTools'))
if (errs.length) {
console.error(`[e2e] 页面报错 ${errs.length} 条:`)
errs.slice(0, 10).forEach((m) => console.error(' -', m))
failures += errs.length
}
console.log(failures ? `[e2e] ${failures} 项失败` : '[e2e] 饥荒专项自检全部通过')
app.exit(failures ? 1 : 0)
}
app.whenReady().then(() =>
main().catch((err) => {
console.error('[e2e] 失败:', err.message)
app.exit(1)
})
)
setTimeout(() => {
console.error('[e2e] 总超时')
app.exit(1)
}, 220000)

233
electron/e2e.cjs Normal file
View File

@@ -0,0 +1,233 @@
// 端到端自检(开发用,不随应用打包),支持两种模式:
// desktop(默认):以 file:// 加载 dist 构建产物 + preload 注入,验证桌面端(hash 路由 + 配置服务器地址)
// web:不带 preload 加载 Vite 开发服务器,等价纯浏览器环境,验证网页版(history 路由 + 同源代理)
// 流程:清空登录态 → 演示账号真实登录 → 大厅(含购买弹窗)→ 对战大厅 → WS 建房/退房 → 单机开一局 → 汇总页面报错
// 运行:npm run electron:e2e(桌面模式);$env:NLG_E2E_MODE='web'; npx electron electron/e2e.cjs(网页模式,需 5173 开发服务器)
// NLG_E2E_GAME 可指定单机步骤玩哪款游戏(默认 snake,如 mario / linkgame)
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')
const MODE = process.env.NLG_E2E_MODE === 'web' ? 'web' : 'desktop'
const SERVER = process.env.NLG_SERVER || 'http://127.0.0.1:8080'
const WEB_URL = process.env.NLG_E2E_URL || 'http://localhost:5173'
const OUT_DIR = process.env.NLG_E2E_OUT || process.cwd()
const GAME = process.env.NLG_E2E_GAME || 'snake'
const SUFFIX = MODE === 'web' ? '-web' : ''
const pageErrors = []
// preload 依赖该同步 IPC 提供服务器地址(仅桌面模式挂 preload)
ipcMain.on('nlg:get-server-base', (e) => { e.returnValue = SERVER })
ipcMain.handle('nlg:set-server-base', () => ({ ok: true }))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function main() {
const win = new BrowserWindow({
width: 1280,
height: 820,
show: true,
backgroundColor: '#0f1220',
webPreferences: MODE === 'desktop' ? { preload: path.join(__dirname, 'preload.cjs') } : {},
})
// 收集页面报错(Electron 新版事件对象:event.level 为 'error' 等字符串)
win.webContents.on('console-message', (e) => {
if (e?.level === 'error' && e?.message) pageErrors.push(e.message)
})
const js = (code) => win.webContents.executeJavaScript(code, true)
// 截图:capturePage 在 Windows 上偶发 UnknownVizError(GPU 合成器抖动),重试且失败不阻断流程
const shot = async (name) => {
for (let i = 0; i < 3; i++) {
try {
const img = await win.webContents.capturePage()
const file = path.join(OUT_DIR, `${name}${SUFFIX}.png`)
fs.writeFileSync(file, img.toPNG())
console.log('[e2e] screenshot:', file)
return
} catch (err) {
if (i === 2) console.warn(`[e2e] 截图 ${name} 失败(不影响流程):${err.message}`)
else await sleep(600)
}
}
}
// 轮询等待页面条件成立
const waitFor = async (cond, timeoutMs, desc) => {
const t0 = Date.now()
while (Date.now() - t0 < timeoutMs) {
try {
if (await js(cond)) return true
} catch {}
await sleep(300)
}
throw new Error(`等待超时:${desc}`)
}
// 路由条件与跳转:桌面端 hash 模式,网页版 history 模式
const atRoute = (p) => (MODE === 'desktop' ? `location.hash === '#${p}'` : `location.pathname === '${p}'`)
const goto = async (p) => {
if (MODE === 'desktop') {
await js(`location.hash = '#${p}'; true`)
} else {
// history 模式下直接整页跳转(等价用户输入地址),开发服务器有 SPA 回退
await win.loadURL(`${WEB_URL}${p}`)
}
await waitFor(atRoute(p), 5000, `路由到 ${p}`)
}
if (MODE === 'desktop') {
const distIndex = path.join(__dirname, '../dist/index.html')
if (!fs.existsSync(distIndex)) throw new Error('缺少 dist/index.html,请先 npm run electron:dist')
await win.loadFile(distIndex)
} else {
await win.loadURL(WEB_URL).catch(() => {
throw new Error(`无法访问 ${WEB_URL},请先 npm run dev 启动开发服务器`)
})
}
// 第 0 步:清空历史登录态,确保从登录页开始
await js('localStorage.clear(); true')
await win.webContents.reload()
await waitFor(`!!document.querySelector('input[placeholder^="用户名"]')`, 10000, '登录页渲染')
console.log(`[e2e] 登录页已渲染(${MODE === 'desktop' ? 'file:// + hash 路由' : 'http + history 路由'})`)
// 第 1 步:填写演示账号并提交(触发 input 事件以驱动 v-model)
await js(`
(() => {
const u = document.querySelector('input[placeholder^="用户名"]')
u.value = 'xiaoming'; u.dispatchEvent(new Event('input', { bubbles: true }))
const p = document.querySelector('input[type="password"]')
p.value = '123456'; p.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('button.submit').click()
return true
})()
`)
// 第 2 步:等待登录成功(Token 落库 + 路由跳到大厅)
await waitFor(`!!localStorage.getItem('token') && ${atRoute('/')}`, 10000, '登录成功并跳转大厅')
console.log('[e2e] 登录成功,Token 已写入,已跳转大厅')
await sleep(1800) // 等游戏列表与字体渲染
await shot('electron-smoke-lobby')
// 第 2.5 步:大厅购买弹窗(若存在待解锁游戏则点开验证展示与关闭)
const hasBuyBtn = await js(
`!![...document.querySelectorAll('.game-card button')].find((b) => b.textContent.includes('去购买'))`
)
if (hasBuyBtn) {
await js(`
[...document.querySelectorAll('.game-card button')].find((b) => b.textContent.includes('去购买')).click(); true
`)
await waitFor(`!!document.querySelector('.buy-modal')`, 4000, '购买弹窗打开')
await sleep(400)
await shot('electron-smoke-buymodal')
await js(`document.querySelector('.buy-modal .bm-close').click(); true`)
await waitFor(`!document.querySelector('.buy-modal')`, 3000, '购买弹窗关闭')
console.log('[e2e] 大厅购买弹窗:打开展示详情 / 关闭均正常')
} else {
console.log('[e2e] 大厅无待解锁游戏,跳过购买弹窗检查')
}
// 第 2.6 步:确保目标游戏可玩——未拥有则通过弹窗「立即购买」走一遍真实购买闭环
const gameName = await js(`
fetch((window.desktop && window.desktop.serverBase ? window.desktop.serverBase : '') + '/api/games', {
headers: { Authorization: 'Bearer ' + localStorage.getItem('token') },
}).then((r) => r.json()).then((j) => ((j.data || []).find((g) => g.code === '${GAME}') || {}).name || '')
`)
if (!gameName) throw new Error(`游戏 ${GAME} 不存在或列表接口异常`)
const cardBtnLabel = `(() => {
const card = [...document.querySelectorAll('.game-card')].find((c) => (c.querySelector('.name')?.textContent || '').trim() === ${JSON.stringify(gameName)})
const btn = card && card.querySelector('.actions button:last-child')
return btn ? btn.textContent.trim() : ''
})()`
if ((await js(cardBtnLabel)) === '去购买') {
await js(`(() => {
const card = [...document.querySelectorAll('.game-card')].find((c) => (c.querySelector('.name')?.textContent || '').trim() === ${JSON.stringify(gameName)})
card.querySelector('.actions button:last-child').click()
return true
})()`)
await waitFor(`!!document.querySelector('.buy-modal')`, 4000, '目标游戏购买弹窗打开')
const canBuy = await js(`!document.querySelector('.buy-modal .bm-actions .btn:last-child').disabled`)
if (!canBuy) throw new Error(`积分不足,无法购买 ${gameName},无法继续单机游戏步骤`)
await js(`document.querySelector('.buy-modal .bm-actions .btn:last-child').click(); true`)
await waitFor(`!document.querySelector('.buy-modal')`, 6000, '购买完成弹窗自动关闭')
await waitFor(`${cardBtnLabel} === '开始游戏'`, 6000, '购买后卡片刷新为可玩')
console.log(`[e2e] 已通过大厅弹窗直接购买「${gameName}」,卡片已解锁`)
} else {
console.log(`[e2e] 「${gameName}」已可玩,无需购买`)
}
// 第 3 步:进入对战大厅
await goto('/battle')
await sleep(1500)
await shot('electron-smoke-battle')
// 第 4 步:WebSocket 建房(联机核心链路)——创建斗地主 AI 房间并拿到邀请码
await js(`
(() => {
const btn = [...document.querySelectorAll('button')].find((b) => b.textContent.includes('创建房间'))
btn.click()
return true
})()
`)
await waitFor(
`${atRoute('/battle/room')} && /^[A-Z0-9]{6}$/.test(document.querySelector('.code-box b')?.textContent?.trim() || '')`,
10000,
'WS 建房成功并显示 6 位邀请码'
)
const roomCode = await js(`document.querySelector('.code-box b').textContent.trim()`)
console.log('[e2e] WebSocket 建房成功,邀请码:' + roomCode)
await sleep(900)
await shot('electron-smoke-room')
// 退出房间,回到对战大厅
await js(`
(() => {
const btn = [...document.querySelectorAll('button')].find((b) => b.textContent.includes('退出房间'))
btn.click()
return true
})()
`)
await waitFor(atRoute('/battle'), 5000, '退房返回对战大厅')
console.log('[e2e] 已退出房间')
// 第 5 步:单机游戏运行(渲染与游戏循环)——默认贪吃蛇,可用 NLG_E2E_GAME 换游戏
// 普通游戏是「开始游戏」按钮;带关卡的游戏(连连看/消消乐/马里奥)是关卡选择面板的继续按钮
await goto(`/play/${GAME}`)
const startBtnExpr = `(() => {
return document.querySelector('.overlay .ls-continue') ||
[...document.querySelectorAll('.overlay button')].find((b) => b.textContent.includes('开始游戏')) || null
})()`
await waitFor(`!!${startBtnExpr}`, 10000, `${GAME} 游玩页就绪`)
const isLevelGame = await js(`!!document.querySelector('.overlay .level-select')`)
if (isLevelGame) {
console.log(`[e2e] ${GAME} 为关卡制游戏,关卡选择面板已展示`)
}
await js(`(${startBtnExpr}).click(); true`)
await sleep(2200) // 让游戏跑几帧
// Canvas 游戏看画布,DOM 游戏(连连看/消消乐)看棋盘容器
await waitFor(
`!!document.querySelector('.stage canvas') || !!document.querySelector('.stage .link-board') || !!document.querySelector('.stage .m3-board .board')`,
5000,
'游戏画面渲染'
)
console.log(`[e2e] ${GAME} 已开局,画面渲染正常`)
await shot('electron-smoke-game')
// 汇总
if (pageErrors.length) {
console.error(`[e2e] 页面报错 ${pageErrors.length} 条:`)
for (const m of pageErrors.slice(0, 10)) console.error(' -', m)
app.exit(1)
return
}
console.log(`[e2e] 全部通过(${MODE} 模式):登录 / 路由 / 大厅 / WS 建房退房 / 单机游戏均正常,无页面报错`)
app.exit(0)
}
app.whenReady().then(() =>
main().catch((err) => {
console.error('[e2e] 失败:', err.message)
app.exit(1)
})
)
// 兜底超时
setTimeout(() => {
console.error('[e2e] 总超时')
app.exit(1)
}, 90000)

461
electron/main.cjs Normal file
View File

@@ -0,0 +1,461 @@
// Electron 主进程:窗口生命周期、应用菜单、后端服务器地址配置、外链拦截
// 加载策略:开发模式(未打包)优先连 Vite 开发服务器(5173),连不上回退到本地 dist 构建产物;
// 打包后固定加载 asar 内的 dist。渲染进程通过 preload 注入的 window.desktop 获取服务器地址
const { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, shell, dialog, net } = require('electron')
const path = require('path')
const fs = require('fs')
// 后端服务器默认地址(可在「菜单 → 服务器设置」中修改,保存在用户数据目录)
// 生产桌面端默认连线上;本机调试可在菜单里改回 http://127.0.0.1:8080
const DEFAULT_SERVER = 'https://game.nailaoyun.cn'
const DEV_URL = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173'
// NLG_LOAD=dist 可在开发模式下强制加载 dist(用于模拟打包后的运行环境)
const FORCE_DIST = process.env.NLG_LOAD === 'dist'
// 冒烟自检模式:页面加载完成后截图保存并退出(用于自动化验证桌面端能正常启动)
const SMOKE = process.argv.includes('--smoke') || process.env.NLG_SMOKE === '1'
let mainWin = null
let settingsWin = null
let tray = null
// 真正退出时置 true(托盘「退出」/ app.quit);否则点关闭按钮只隐藏到托盘
let isQuitting = false
function appIconPath() {
return path.join(__dirname, '../build/icon.png')
}
// ---------------------------------------------------------------------
// 配置读写:%APPDATA%/<app>/config.json
// ---------------------------------------------------------------------
function cfgFile() {
return path.join(app.getPath('userData'), 'config.json')
}
function readCfg() {
try {
return { serverBase: DEFAULT_SERVER, ...JSON.parse(fs.readFileSync(cfgFile(), 'utf8')) }
} catch {
return { serverBase: DEFAULT_SERVER }
}
}
function writeCfg(patch) {
const next = { ...readCfg(), ...patch }
fs.mkdirSync(path.dirname(cfgFile()), { recursive: true })
fs.writeFileSync(cfgFile(), JSON.stringify(next, null, 2))
return next
}
// ---------------------------------------------------------------------
// IPC:preload 同步取服务器地址;设置窗口保存新地址后重载主窗口
// ---------------------------------------------------------------------
ipcMain.on('nlg:get-server-base', (e) => {
e.returnValue = readCfg().serverBase
})
ipcMain.handle('nlg:set-server-base', (_e, url) => {
const clean = String(url || '').trim().replace(/\/+$/, '')
if (!/^https?:\/\/.+/i.test(clean)) {
return { ok: false, msg: '地址需以 http:// 或 https:// 开头' }
}
writeCfg({ serverBase: clean })
if (settingsWin && !settingsWin.isDestroyed()) settingsWin.close()
// 重载后 preload 会重新读取配置,前端以新地址发起请求
if (mainWin && !mainWin.isDestroyed()) mainWin.reload()
return { ok: true }
})
// ---------------------------------------------------------------------
// 版本检查:拉取后端 /api/version/latest 与本地 app.getVersion() 比较
// 后端返回 { version, download_url, release_notes, force }
// ---------------------------------------------------------------------
// 语义化版本比较:返回 -1/0/1(a<b / a==b / a>b),非法版本视为 0.0.0
function compareVer(a, b) {
const pa = String(a || '0').split('.').map((n) => parseInt(n, 10) || 0)
const pb = String(b || '0').split('.').map((n) => parseInt(n, 10) || 0)
const len = Math.max(pa.length, pb.length)
for (let i = 0; i < len; i++) {
const va = pa[i] || 0
const vb = pb[i] || 0
if (va > vb) return 1
if (va < vb) return -1
}
return 0
}
// 拉取一次最新版本信息(用 Electron 内置 net,自动走系统代理)
function fetchLatestVersion() {
return new Promise((resolve) => {
const base = readCfg().serverBase.replace(/\/+$/, '')
if (!base) return resolve({ ok: false, msg: '未配置服务器地址' })
const req = net.request({
method: 'GET',
url: base + '/api/version/latest',
redirect: 'follow',
})
let body = ''
req.on('response', (resp) => {
// 3xx 已由 redirect:'follow' 处理;这里只关心 2xx 最终响应
resp.on('data', (chunk) => (body += chunk.toString('utf8')))
resp.on('end', () => {
try {
const parsed = JSON.parse(body)
// 后端统一返回 { code, msg, data },code=0 表示成功
const data = parsed && parsed.data ? parsed.data : parsed
resolve({ ok: true, data })
} catch {
resolve({ ok: false, msg: '解析版本信息失败' })
}
})
})
req.on('error', (err) => resolve({ ok: false, msg: '网络错误:' + err.message }))
// 超时兜底(10s)
setTimeout(() => {
try { req.abort() } catch {}
resolve({ ok: false, msg: '请求超时' })
}, 10000)
req.end()
})
}
// 弹出更新对话框(silent=true 表示自动检查,已是最新版不弹窗)
async function checkUpdate(silent = false) {
const local = app.getVersion()
const r = await fetchLatestVersion()
if (!r.ok) {
if (!silent) dialog.showErrorBox('检查更新失败', r.msg || '无法连接服务器')
return
}
const { version, download_url: url, release_notes: notes, force } = r.data || {}
if (compareVer(version, local) <= 0) {
if (!silent) {
dialog.showMessageBox(mainWin, {
type: 'info',
title: '已是最新版本',
message: '当前已是最新版本',
detail: `版本 ${local}`,
})
}
return
}
const buttons = url ? ['立即下载', force ? '确定' : '稍后提醒'] : [force ? '确定' : '稍后提醒']
const { response } = await dialog.showMessageBox(mainWin, {
type: force ? 'warning' : 'info',
title: '发现新版本',
message: `发现新版本 ${version}`,
detail: notes || '请下载最新版本安装包覆盖安装。',
buttons,
defaultId: 0,
cancelId: buttons.length - 1,
noLink: true,
})
// 点了「立即下载」(按钮 0 且存在下载链接);相对路径拼服务器基址
if (response === 0 && url) {
const abs = /^https?:\/\//i.test(url)
? url
: readCfg().serverBase.replace(/\/+$/, '') + (url.startsWith('/') ? url : '/' + url)
shell.openExternal(abs)
}
}
// IPC:渲染进程可触发检查更新(预留菜单按钮 / 页面入口)
ipcMain.handle('nlg:check-update', (_e, silent) => checkUpdate(silent))
// ---------------------------------------------------------------------
// 服务器设置窗口(极简内嵌页面,深色风格与主站一致)
// ---------------------------------------------------------------------
function openSettings() {
if (settingsWin && !settingsWin.isDestroyed()) {
settingsWin.focus()
return
}
settingsWin = new BrowserWindow({
width: 460,
height: 240,
parent: mainWin || undefined,
modal: !!mainWin,
resizable: false,
minimizable: false,
maximizable: false,
autoHideMenuBar: true,
backgroundColor: '#12142a',
title: '服务器设置',
webPreferences: { preload: path.join(__dirname, 'preload.cjs') },
})
const cur = readCfg().serverBase
const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>服务器设置</title>
<style>
body{margin:0;background:#12142a;color:#e8e9f5;font:14px/1.6 "Microsoft YaHei",sans-serif;padding:22px 26px}
h3{margin:0 0 4px;font-size:15px}
p{margin:0 0 12px;font-size:12px;color:#8d90b3}
input{width:100%;box-sizing:border-box;background:#1c1f3d;border:1px solid #34386b;color:#e8e9f5;
border-radius:8px;padding:9px 12px;font-size:13px;outline:none}
input:focus{border-color:#6d6ff0}
.row{display:flex;gap:10px;justify-content:flex-end;margin-top:16px}
button{border:0;border-radius:8px;padding:8px 22px;font-size:13px;cursor:pointer}
.ok{background:linear-gradient(135deg,#6d6ff0,#9b6df0);color:#fff}
.cancel{background:#262a52;color:#c6c8e8}
.err{color:#ff7b8a;font-size:12px;margin-top:8px;min-height:16px}
</style></head><body>
<h3>后端服务器地址</h3>
<p>桌面端连接的游戏服务器(修改后自动重新加载)</p>
<input id="url" value="${cur.replace(/"/g, '&quot;')}" placeholder="https://game.nailaoyun.cn" />
<div class="err" id="err"></div>
<div class="row">
<button class="cancel" onclick="window.close()">取消</button>
<button class="ok" id="save">保存</button>
</div>
<script>
document.getElementById('save').onclick = async () => {
const r = await window.desktop.setServerBase(document.getElementById('url').value)
if (!r.ok) document.getElementById('err').textContent = r.msg
}
document.getElementById('url').addEventListener('keyup', (e) => {
if (e.key === 'Enter') document.getElementById('save').click()
})
</script>
</body></html>`
settingsWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))
settingsWin.on('closed', () => { settingsWin = null })
}
// ---------------------------------------------------------------------
// 系统托盘:关闭窗口隐藏到托盘,右键可显示/检查更新/退出
// ---------------------------------------------------------------------
function showMainWindow() {
if (!mainWin || mainWin.isDestroyed()) {
createWindow()
return
}
if (mainWin.isMinimized()) mainWin.restore()
mainWin.show()
mainWin.focus()
}
function createTray() {
if (tray) return
const iconFile = appIconPath()
let img = fs.existsSync(iconFile)
? nativeImage.createFromPath(iconFile)
: nativeImage.createEmpty()
// Windows 托盘建议 16×16;过大图标会被系统缩放发糊,主动缩一下
if (!img.isEmpty() && (img.getSize().width > 32 || img.getSize().height > 32)) {
img = img.resize({ width: 16, height: 16 })
}
tray = new Tray(img)
tray.setToolTip('像素游戏厅')
tray.setContextMenu(Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => showMainWindow() },
{ label: '检查更新…', click: () => checkUpdate(false) },
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
},
},
]))
// 单击 / 双击托盘图标都拉起主窗口(Windows 习惯)
tray.on('click', () => showMainWindow())
tray.on('double-click', () => showMainWindow())
}
// ---------------------------------------------------------------------
// 应用菜单
// ---------------------------------------------------------------------
function buildMenu() {
const template = [
{
label: '应用',
submenu: [
{ label: '服务器设置…', accelerator: 'CmdOrCtrl+,', click: openSettings },
{ label: '检查更新…', click: () => checkUpdate(false) },
{ type: 'separator' },
{ label: '刷新', role: 'reload' },
{ label: '强制刷新(忽略缓存)', role: 'forceReload' },
{ label: '开发者工具', role: 'toggleDevTools' },
{ type: 'separator' },
{
label: '退出',
accelerator: 'CmdOrCtrl+Q',
click: () => {
isQuitting = true
app.quit()
},
},
],
},
{
label: '编辑',
submenu: [
{ label: '撤销', role: 'undo' },
{ label: '重做', role: 'redo' },
{ type: 'separator' },
{ label: '剪切', role: 'cut' },
{ label: '复制', role: 'copy' },
{ label: '粘贴', role: 'paste' },
{ label: '全选', role: 'selectAll' },
],
},
{
label: '视图',
submenu: [
{ label: '放大', role: 'zoomIn' },
{ label: '缩小', role: 'zoomOut' },
{ label: '实际大小', role: 'resetZoom' },
{ type: 'separator' },
{ label: '全屏', role: 'togglefullscreen' },
],
},
{
label: '帮助',
submenu: [
{
label: '关于',
click: () => {
dialog.showMessageBox(mainWin, {
type: 'info',
title: '关于',
message: '像素游戏厅 桌面版',
detail: `版本 ${app.getVersion()}\nElectron ${process.versions.electron} / Chromium ${process.versions.chrome}\n服务器:${readCfg().serverBase}`,
})
},
},
],
},
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
// ---------------------------------------------------------------------
// 主窗口
// ---------------------------------------------------------------------
function createWindow() {
// 窗口图标:打包后 exe 自带图标,这里主要供开发模式(electron .)使用
const iconPath = appIconPath()
// 恢复上次的窗口大小与位置(若显示器变化导致越界,Electron 会自动拉回可见区域)
const cfg = readCfg()
const saved = cfg.winBounds || {}
mainWin = new BrowserWindow({
width: saved.width || 1280,
height: saved.height || 820,
...(Number.isFinite(saved.x) && Number.isFinite(saved.y) ? { x: saved.x, y: saved.y } : {}),
minWidth: 960,
minHeight: 640,
show: false,
backgroundColor: '#0f1220',
title: '像素游戏厅',
...(fs.existsSync(iconPath) ? { icon: iconPath } : {}),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
spellcheck: false,
},
})
mainWin.once('ready-to-show', () => {
if (cfg.winMax) mainWin.maximize()
mainWin.show()
// 启动后 3 秒静默检查一次更新(已是最新版不弹窗,发现新版本才提示)
setTimeout(() => { checkUpdate(true).catch(() => {}) }, 3000)
})
// 点关闭:记住窗口状态并隐藏到托盘(冒烟模式 / 真正退出时直接关)
mainWin.on('close', (e) => {
try {
writeCfg({ winBounds: mainWin.getNormalBounds(), winMax: mainWin.isMaximized() })
} catch {}
if (!isQuitting && !SMOKE && tray) {
e.preventDefault()
mainWin.hide()
}
})
// 页面内打开新窗口(target=_blank 等)→ 交给系统浏览器
mainWin.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) shell.openExternal(url)
return { action: 'deny' }
})
// 阻止主窗口导航离开应用(外部 http 链接转系统浏览器)
mainWin.webContents.on('will-navigate', (e, url) => {
const inApp = url.startsWith(DEV_URL) || url.startsWith('file://')
if (!inApp) {
e.preventDefault()
if (/^https?:\/\//i.test(url)) shell.openExternal(url)
}
})
const distIndex = path.join(__dirname, '../dist/index.html')
const loadDist = () => {
if (!fs.existsSync(distIndex)) {
dialog.showErrorBox('缺少构建产物', '未找到 dist/index.html,请先执行 npm run electron:dist')
app.quit()
return
}
mainWin.loadFile(distIndex)
}
if (!app.isPackaged && !FORCE_DIST) {
// 开发模式:优先 Vite 开发服务器,连不上回退 dist
mainWin.loadURL(DEV_URL)
mainWin.webContents.once('did-fail-load', (_e, _code, _desc, _url, isMainFrame) => {
if (isMainFrame) loadDist()
})
} else {
loadDist()
}
// 冒烟自检:渲染完成后截图退出,供自动化验证
if (SMOKE) {
let done = false
mainWin.webContents.on('did-finish-load', () => {
setTimeout(async () => {
if (done) return
done = true
try {
const img = await mainWin.webContents.capturePage()
const out = process.env.NLG_SHOT_PATH || path.join(process.cwd(), 'electron-smoke.png')
fs.writeFileSync(out, img.toPNG())
console.log('[smoke] screenshot saved:', out)
app.exit(0)
} catch (err) {
console.error('[smoke] capture failed:', err)
app.exit(1)
}
}, 2200)
})
// 兜底:25 秒仍未完成视为失败
setTimeout(() => {
if (!done) {
console.error('[smoke] timeout')
app.exit(1)
}
}, 25000)
}
mainWin.on('closed', () => { mainWin = null })
}
// 单实例锁:二次启动聚焦已有窗口
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
app.quit()
} else {
app.on('second-instance', () => {
showMainWindow()
})
app.whenReady().then(() => {
// Windows 任务栏/托盘分组 ID,避免与其他 Electron 应用挤在一起
if (process.platform === 'win32') {
app.setAppUserModelId('cn.nailaoyun.pixelarcade')
}
buildMenu()
createTray()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
else showMainWindow()
})
})
// 有托盘时关窗不等于退出,不要在 window-all-closed 里 quit
app.on('window-all-closed', () => {
if (process.platform !== 'darwin' && !tray) app.quit()
})
app.on('before-quit', () => { isQuitting = true })
}

20
electron/preload.cjs Normal file
View File

@@ -0,0 +1,20 @@
// Electron 预加载脚本:以 contextBridge 向渲染进程暴露最小桌面端 API
// 前端凭 window.desktop 是否存在来区分「桌面端 / 浏览器」两种运行环境
const { contextBridge, ipcRenderer } = require('electron')
// 同步读取后端服务器地址:必须在页面脚本执行前就绪(axios/WS 初始化要用)
const serverBase = ipcRenderer.sendSync('nlg:get-server-base')
contextBridge.exposeInMainWorld('desktop', {
// 后端服务器地址(形如 http://127.0.0.1:8080,结尾不带斜杠)
serverBase,
// 修改服务器地址(校验通过后主进程会自动重载页面)
setServerBase: (url) => ipcRenderer.invoke('nlg:set-server-base', url),
// 检查桌面端新版本(silent=true 时已是最新版不弹窗,用于启动自动检查)
checkUpdate: (silent = false) => ipcRenderer.invoke('nlg:check-update', silent),
// 运行环境版本信息
versions: {
electron: process.versions.electron,
chrome: process.versions.chrome,
},
})