diff --git a/CLAUDE.md b/CLAUDE.md index cf7399a..6f8f4e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,19 +10,30 @@ Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队 ```bash # 构建前端 +# 注意:npm run build 默认加载 .env.dev 的变量。Docker 构建时通过 ARG 注入空 VITE_PB_URL,由 nginx 代理处理 +# 如需本地测试 UAT 构建设置,使用:cd frontend && npm run build -- --mode uat cd frontend && npm run build -# 本地开发(一般不用,用 Docker 部署代替) +# 本地 vite dev server(一般不用,用 Docker 部署代替) cd frontend && npm run dev # 部署脚本(根目录) -./deploy-backend.sh # 部署 PocketBase 后端 +./deploy-backend.sh # 部署 PocketBase + LiveKit + Voice Token 后端 ./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033) ./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712) ./stop-all.sh # 停止所有服务 +# Electron 桌面端 +cd electron && npm install +cd electron && npm run start # 启动桌面端(默认连接 Dev) +cd electron && npm run start:dev # 同上,显式指定 dev 环境 +cd electron && npm run start:uat # 连接 UAT 环境 +cd electron && npm run build # 打包 Windows 可执行文件 + # 查看日志 -docker logs -f gamegroup-pb # 后端 +docker logs -f gamegroup-pb # 后端 +docker logs -f gamegroup-livekit # LiveKit 语音服务 +docker logs -f gamegroup-voice-token # Voice Token 服务 docker logs -f gamegroup-frontend-dev # Dev docker logs -f gamegroup-frontend-uat # UAT ``` @@ -36,6 +47,8 @@ docker logs -f gamegroup-frontend-uat # UAT - **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),localStorage 持久化认证 - **实时通信**: PocketBase realtime subscriptions - **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题 +- **语音通话**: LiveKit server v1.10 + `livekit-client` + 独立 Express token 服务 +- **桌面端**: Electron 35 + `electron-store` + `electron-builder` ## 环境与端口 @@ -43,9 +56,13 @@ docker logs -f gamegroup-frontend-uat # UAT |------|-----|-----| | 前端 (nginx) | 7033 | 7034 | | PocketBase | 8090 | 8712 | +| LiveKit | 7880/7881/7882 | 7880/7881/7882 | +| Voice Token | 7882 | 7882 | Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`、`docker-compose.uat.yml`,共享 `gamegroup-net` 网络。 +前端 `.env` 文件:`frontend/.env.dev`(VITE_PB_URL=8711, PORT=7033)和 `frontend/.env.uat`(VITE_PB_URL=8711, PORT=7034)。注意 Dev/UAT 构建时 VITE_PB_URL 都指向 8711,因为 Docker 构建时会将其覆盖为空,由 nginx 反向代理处理。 + ## 架构 ### 前端核心流程 @@ -59,9 +76,10 @@ pocketbase.ts (PB 客户端初始化) ``` - **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()` -- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`) +- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`, `voice.ts`) - **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`) - **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理 +- **`composables/useVoiceRoom.ts`** — LiveKit 语音房间封装,处理连接/断开/麦克风/扬声器控制 - **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap`、`TeamStatusMap`) ### 认证流程 @@ -72,17 +90,41 @@ pocketbase.ts (PB 客户端初始化) ### 路由结构 -Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。 +Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), VoiceRoom (`/group/:groupId/voice/:sessionId`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。 ### Vite 代理 vs Nginx -开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。 +开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。Voice Token 服务通过 `/voice-api/` 路径由 nginx 代理到 7882 端口。 + +前端 nginx 配置有两份:`nginx.conf`(dev,代理到 8090)和 `nginx.uat.conf`(代理到 8712),Docker 构建时通过 `NGINX_CONF` build arg 选择。静态资源缓存一年,HTML 不缓存。 + +### 语音通话架构 + +组队会话 (`team_sessions`) 支持语音房间。语音流程: +1. 用户点击语音按钮 → 路由跳转到 `VoiceRoom.vue` +2. `useVoiceRoom.connect(sessionId)` 调用 `api/voice.ts` 的 `fetchVoiceToken` +3. `fetchVoiceToken` 向后端 `/voice-api/voice-token/:sessionId` 请求 token +4. Voice Token 服务验证用户 PB token → 查询 `team_sessions` 确认成员身份 → 签发 LiveKit JWT +5. 前端用 token 连接 LiveKit server (`ws://192.168.1.14:7880`) + +HTTP 环境下 `navigator.mediaDevices` 受限,已在 `useVoiceRoom.ts` 中给出明确的 Chrome flags 引导错误提示。 + +### Electron 桌面端 + +- `electron/main.js` — 主进程,加载远程 URL(dev/uat 可切换),窗口大小 1280x800 +- `electron/preload.js` — 预加载脚本(当前为空壳,保留扩展点) +- `electron/package.json` — 独立包,依赖 `electron-store`(用于本地配置持久化) +- 构建产物为 Windows portable 可执行文件 + +### 实时订阅管理 + +所有 PocketBase 实时订阅统一通过 `useRealtime.ts` composable 管理。组件使用时在 `onMounted` 中调用订阅方法,`onUnmounted` 时自动调用 `unsubscribeAll` 清理。不要直接在组件中创建孤立的 `pb.collection().subscribe()` 而不做清理。 ### 数据模型(PocketBase Collections) - **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away - **groups** — owner + members 关系,支持审核加入(requireApproval) -- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved +- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved。含 `voiceRoom` 和 `voiceActive` 字段 - **invitations** — 组队邀请,pending/accepted/rejected - **games** — 游戏库,归属 group,含平台、标签、封面 - **game_comments** / **game_favorites** — 评论和收藏 @@ -90,7 +132,7 @@ Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), - **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间 - **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算 - **point_logs** — 积分流水(vote/team/memory/bet 行为) -- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group +- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group。nginx 配置 `client_max_body_size 500m` 支持大文件上传 - **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类 - **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者 - **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 70b7c15..ffe9dc9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -6,6 +6,8 @@ services: container_name: gamegroup-frontend-dev ports: - "7033:80" + volumes: + - ./electron-update:/usr/share/nginx/html/electron-update environment: - NODE_ENV=production restart: unless-stopped diff --git a/docker-compose.uat.yml b/docker-compose.uat.yml index 6fcb27b..4b85ecb 100644 --- a/docker-compose.uat.yml +++ b/docker-compose.uat.yml @@ -59,6 +59,8 @@ services: container_name: gamegroup-frontend-uat ports: - "7034:80" + volumes: + - ./electron-update:/usr/share/nginx/html/electron-update environment: - NODE_ENV=production restart: unless-stopped diff --git a/electron-update/.gitkeep b/electron-update/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/electron/main.js b/electron/main.js index 04af177..685e005 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,5 +1,22 @@ -const { app, BrowserWindow, session } = require('electron') +const { app, BrowserWindow, session, dialog, ipcMain } = require('electron') +const { autoUpdater } = require('electron-updater') const path = require('path') +const Store = require('electron-store') + +const store = new Store() + +// 同步 IPC:供 preload 读写持久化数据 +ipcMain.on('store-get-sync', (event, key) => { + event.returnValue = store.get(key) +}) +ipcMain.on('store-set-sync', (event, key, value) => { + store.set(key, value) + event.returnValue = true +}) +ipcMain.on('store-delete-sync', (event, key) => { + store.delete(key) + event.returnValue = true +}) const ENV_URLS = { dev: 'http://192.168.1.14:7033', @@ -15,11 +32,19 @@ function getWindowUrl() { return ENV_URLS.dev } +function getEnvName() { + const url = getWindowUrl() + if (url.includes('7034')) return 'uat' + return 'dev' +} + // 在 Chromium 启动前将 HTTP 内网地址标记为安全源, // 否则 navigator.mediaDevices 在 HTTP 非 localhost 下会被置空 const insecureOrigins = Object.values(ENV_URLS).join(',') app.commandLine.appendSwitch('unsafely-treat-insecure-origin-as-secure', insecureOrigins) +let mainWindow = null + function createWindow() { const win = new BrowserWindow({ width: 1280, @@ -36,6 +61,8 @@ function createWindow() { }, }) + mainWindow = win + const url = getWindowUrl() win.loadURL(url) @@ -44,6 +71,101 @@ function createWindow() { event.preventDefault() win.setTitle(`Game Group - ${title}`) }) + + win.on('closed', () => { + mainWindow = null + }) + + return win +} + +// 自动更新配置 +function setupAutoUpdater(win) { + const env = getEnvName() + const feedUrl = `${ENV_URLS[env]}/electron-update/` + + autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl }) + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + + autoUpdater.on('checking-for-update', () => { + console.log('[updater] Checking for update at', feedUrl) + }) + + autoUpdater.on('update-available', (info) => { + console.log('[updater] Update available:', info.version) + if (win && !win.isDestroyed()) { + win.webContents.send('update-available', info) + dialog.showMessageBox(win, { + type: 'info', + title: '发现新版本', + message: `发现新版本 ${info.version},是否立即下载更新?`, + buttons: ['立即下载', '稍后再说'], + defaultId: 0, + cancelId: 1, + }).then(({ response }) => { + if (response === 0) { + autoUpdater.downloadUpdate().catch((err) => { + console.error('[updater] Download failed:', err.message) + }) + } + }) + } + }) + + autoUpdater.on('update-not-available', () => { + console.log('[updater] No update available') + }) + + autoUpdater.on('error', (err) => { + console.error('[updater] Error:', err.message) + if (win && !win.isDestroyed()) { + win.webContents.send('update-error', err.message) + } + }) + + autoUpdater.on('download-progress', (progress) => { + console.log(`[updater] Download progress: ${progress.percent.toFixed(1)}%`) + if (win && !win.isDestroyed()) { + win.webContents.send('download-progress', progress) + } + }) + + autoUpdater.on('update-downloaded', (info) => { + console.log('[updater] Update downloaded:', info.version) + if (win && !win.isDestroyed()) { + win.webContents.send('update-downloaded', info) + dialog.showMessageBox(win, { + type: 'info', + title: '更新就绪', + message: `新版本 ${info.version} 已下载完成,是否立即重启安装?`, + buttons: ['立即重启', '稍后'], + defaultId: 0, + }).then(({ response }) => { + if (response === 0) { + autoUpdater.quitAndInstall() + } + }) + } + }) + + // 手动触发检查更新(供前端调用) + ipcMain.on('check-for-updates', () => { + autoUpdater.checkForUpdates().catch((err) => { + console.error('[updater] Manual check failed:', err.message) + }) + }) + + ipcMain.on('quit-and-install', () => { + autoUpdater.quitAndInstall() + }) + + // 启动后延迟 5 秒检查更新(等窗口稳定后再检查) + setTimeout(() => { + autoUpdater.checkForUpdates().catch((err) => { + console.error('[updater] Initial check failed:', err.message) + }) + }, 5000) } app.whenReady().then(() => { @@ -64,7 +186,8 @@ app.whenReady().then(() => { return false }) - createWindow() + const win = createWindow() + setupAutoUpdater(win) }) app.on('window-all-closed', () => { diff --git a/electron/package.json b/electron/package.json index 1721b8c..165252d 100644 --- a/electron/package.json +++ b/electron/package.json @@ -7,11 +7,15 @@ "start": "electron .", "start:dev": "electron . --env=dev", "start:uat": "electron . --env=uat", - "build": "electron-builder --win", - "build:portable": "electron-builder --win portable" + "build": "electron-builder --win && node scripts/copy-to-nas.js", + "build:portable": "electron-builder --win portable && node scripts/copy-to-nas.js", + "copy-to-nas": "node scripts/copy-to-nas.js" }, "dependencies": { - "electron-store": "^8.2" + "electron-store": "^8.2", + "electron-updater": "^6.8.3", + "png-to-ico": "^3.0.1", + "sharp": "^0.34.5" }, "devDependencies": { "electron": "^35.0", @@ -26,12 +30,24 @@ "win": { "target": [ { - "target": "portable", + "target": "nsis", "arch": ["x64"] } ], "icon": "build/icon.ico" }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "GameGroup" + }, + "publish": { + "provider": "generic", + "url": "http://nas.wjl-work.top:7034/electron-update/", + "channel": "latest" + }, "files": [ "main.js", "preload.js", diff --git a/electron/preload.js b/electron/preload.js index 9ed04c5..3864549 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,6 +1,19 @@ -// preload.js - 目前为空,预留用于未来需要暴露给渲染进程的 API -const { contextBridge } = require('electron') +const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('electronAPI', { platform: process.platform, + + // 自动更新相关 IPC + onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (_event, info) => callback(info)), + onUpdateError: (callback) => ipcRenderer.on('update-error', (_event, message) => callback(message)), + onDownloadProgress: (callback) => ipcRenderer.on('download-progress', (_event, progress) => callback(progress)), + onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (_event, info) => callback(info)), + + checkForUpdates: () => ipcRenderer.send('check-for-updates'), + quitAndInstall: () => ipcRenderer.send('quit-and-install'), + + // 持久化存储(用于记住登录状态) + storeGet: (key) => ipcRenderer.sendSync('store-get-sync', key), + storeSet: (key, value) => ipcRenderer.sendSync('store-set-sync', key, value), + storeDelete: (key) => ipcRenderer.sendSync('store-delete-sync', key), }) diff --git a/electron/scripts/copy-to-nas.js b/electron/scripts/copy-to-nas.js new file mode 100644 index 0000000..1e93ca6 --- /dev/null +++ b/electron/scripts/copy-to-nas.js @@ -0,0 +1,63 @@ +const fs = require('fs') +const path = require('path') + +const srcDir = path.join(__dirname, '..', 'dist') +// 项目根目录下的 electron-update,用于 docker volume 挂载给 nginx +const localUpdateDir = path.join(__dirname, '..', '..', 'electron-update') +// NAS 共享路径 +const nasDir = '\\\\JIULUGNAS\\personal_folder\\CodeSpace\\GameGroup2' + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + return true + } catch (err) { + console.error(`[copy-to-nas] failed to create dir ${dir}:`, err.message) + return false + } +} + +function copyFilesTo(destDir, label) { + if (!fs.existsSync(srcDir)) { + console.log(`[copy-to-nas] dist not found, skipping ${label}`) + return + } + + const files = fs.readdirSync(srcDir).filter((f) => { + // 只复制更新相关文件:安装包、yml、blockmap、nupkg + const ext = path.extname(f).toLowerCase() + return ( + ext === '.exe' || + ext === '.yml' || + ext === '.blockmap' || + ext === '.nupkg' || + ext === '.yaml' + ) + }) + + if (files.length === 0) { + console.log(`[copy-to-nas] no update files found in dist, skipping ${label}`) + return + } + + if (!ensureDir(destDir)) return + + for (const file of files) { + const src = path.join(srcDir, file) + const dest = path.join(destDir, file) + try { + fs.copyFileSync(src, dest) + console.log(`[copy-to-nas] copied ${file} -> ${destDir}`) + } catch (err) { + console.error(`[copy-to-nas] failed to copy ${file} to ${label}:`, err.message) + } + } +} + +// 复制到本地目录(供 docker volume 挂载) +copyFilesTo(localUpdateDir, 'local') + +// 复制到 NAS +copyFilesTo(nasDir, 'NAS') diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 4a5e39e..836a995 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -39,6 +39,14 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # Electron 自动更新文件托管 + # 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录 + location /electron-update/ { + alias /usr/share/nginx/html/electron-update/; + autoindex on; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + # API 代理到局域网 PocketBase location /api/ { client_max_body_size 500m; diff --git a/frontend/nginx.uat.conf b/frontend/nginx.uat.conf index ebed34e..c6095b6 100644 --- a/frontend/nginx.uat.conf +++ b/frontend/nginx.uat.conf @@ -39,6 +39,14 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # Electron 自动更新文件托管 + # 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录 + location /electron-update/ { + alias /usr/share/nginx/html/electron-update/; + autoindex on; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + # API 代理到局域网 PocketBase location /api/ { proxy_pass http://192.168.1.14:8712; diff --git a/frontend/src/api/pocketbase.ts b/frontend/src/api/pocketbase.ts index 250b006..925d739 100644 --- a/frontend/src/api/pocketbase.ts +++ b/frontend/src/api/pocketbase.ts @@ -7,6 +7,27 @@ export const pb = new PocketBase(pbUrl) // SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作 +// Electron 环境下使用 electron-store 做更持久的备份 +const eAPI = (window as any).electronAPI +if (eAPI?.storeGet) { + // 若 localStorage 无有效认证,尝试从 electron-store 恢复 + if (!pb.authStore.isValid) { + const saved = eAPI.storeGet('pb_auth') + if (saved?.token && saved?.model) { + pb.authStore.save(saved.token, saved.model) + } + } + + // 认证状态变化时同步到 electron-store + pb.authStore.onChange((token: string, model: any) => { + if (token && model) { + eAPI.storeSet('pb_auth', { token, model }) + } else { + eAPI.storeDelete('pb_auth') + } + }) +} + // 获取当前用户 export function getCurrentUser() { return pb.authStore.model @@ -20,6 +41,10 @@ export function isAuthenticated(): boolean { // 登出 export function logout() { pb.authStore.clear() + const eAPI = (window as any).electronAPI + if (eAPI?.storeDelete) { + eAPI.storeDelete('pb_auth') + } window.location.href = '/login' }