Compare commits

..

34 Commits

Author SHA1 Message Date
7c12697579 fix: 暂存 2026-06-23 14:32:00 +08:00
0fae4feadb fix: 修改房间逻辑 2026-06-23 13:48:40 +08:00
f34dbeccb2 fix: 路由bug修改 2026-06-23 12:52:53 +08:00
88ab464d6a fix: 路由bug修改 2026-06-23 12:06:58 +08:00
47fb151ea8 feat: 游戏房间 2026-06-23 11:49:31 +08:00
f937b5cdb3 fix: 代码审查问题修复 — socket 代理提供 + inject 空安全 + 观战人数响应式 2026-06-23 11:28:27 +08:00
cf1a14bf24 feat: 创建房间新增观战开关,成功后跳转到房间页 2026-06-23 11:20:28 +08:00
78c172cce0 feat: 实现大厅到房间页面的导航跳转 2026-06-23 11:19:33 +08:00
eecec1aae1 feat: 修改房间卡片点击行为,卡片默认观战,空椅子入座 2026-06-23 11:18:55 +08:00
2a2c941d32 feat: 添加五子棋棋盘组件(Canvas 渲染) 2026-06-23 11:18:13 +08:00
5d45ad037f feat: 添加游戏画布容器组件 2026-06-23 11:17:19 +08:00
f91b76084a feat: 添加房间页面全屏容器和 WS 中枢 2026-06-23 11:16:40 +08:00
b435256157 feat: 添加底部观战栏组件 2026-06-23 11:15:11 +08:00
b99d397d1f feat: 添加右侧面板、聊天和走棋记录组件 2026-06-23 11:14:22 +08:00
c3c26f2935 feat: 添加房间侧边栏和操作按钮组件 2026-06-23 11:13:19 +08:00
6c7d1ce128 feat: 添加房间顶部信息栏组件 2026-06-23 11:12:12 +08:00
16d3670b5d feat: 添加房间路由和获取房间详情 API 2026-06-23 11:11:20 +08:00
0d76d7a6de feat: 添加 WebSocket 客户端工具 2026-06-23 11:10:34 +08:00
e2068ad95e feat: 添加房间状态 Store 2026-06-23 11:09:46 +08:00
205b60711a feat: 添加房间详情和 WebSocket 消息类型定义 2026-06-23 11:08:58 +08:00
0383572d39 feat: 使用@microsoft/fetch-event-source库 2026-06-23 10:13:53 +08:00
70c4bab3f6 feat: 使用mitt库 2026-06-22 14:36:20 +08:00
fe8918c06f fix: mitt v3 on() 返回 void,改用 off() 取消订阅 2026-06-22 14:02:23 +08:00
f8db0ea4d6 feat: App.vue 集成 SSE 连接管理,Lobby.vue 订阅实时房间事件 2026-06-22 13:47:15 +08:00
dfc54edd83 feat: 创建 useSse SSE 连接管理 composable 2026-06-22 13:46:36 +08:00
aa6f0a002e feat: 安装 mitt 并创建 sseBus 类型化事件总线 2026-06-22 13:46:03 +08:00
3354623dd9 feat: 用户信息修改 2026-06-22 12:57:31 +08:00
28b27bf127 feat: 头像裁剪 2026-06-22 12:42:41 +08:00
ec66afaa72 feat: 个人信息修改 2026-06-22 12:31:04 +08:00
1a175bfdd0 feat: user-bar 弹出修改 2026-06-22 11:31:43 +08:00
349a50339f feat: user-bar 修改 2026-06-22 11:19:11 +08:00
7d2d6707b7 feat: user-bar 修改 2026-06-22 10:32:53 +08:00
0b7a89c06b feat: 数据mock 2026-06-22 09:28:15 +08:00
676f4d5e2d feat: 数据mock 2026-06-18 16:36:37 +08:00
45 changed files with 4755 additions and 434 deletions

View File

@@ -1,3 +1,3 @@
# 开发环境启用 Mock 拦截器
# 后端接口就绪后设为 false 即可切换到真实请求
VITE_USE_MOCK=true
# 后端服务地址(开发环境)
# 头像等静态资源通过此地址访问,生产环境留空即使用相对路径
VITE_BACKEND_URL=http://localhost:8080

20
components.d.ts vendored
View File

@@ -12,16 +12,26 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AvatarCropper: typeof import('./src/components/baseLayouts/AvatarCropper.vue')['default']
CreateRoomDialog: typeof import('./src/components/baseLayouts/CreateRoomDialog.vue')['default']
EditProfileDialog: typeof import('./src/components/baseLayouts/EditProfileDialog.vue')['default']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default']
@@ -34,16 +44,26 @@ declare module 'vue' {
// For TSX support
declare global {
const AvatarCropper: typeof import('./src/components/baseLayouts/AvatarCropper.vue')['default']
const CreateRoomDialog: typeof import('./src/components/baseLayouts/CreateRoomDialog.vue')['default']
const EditProfileDialog: typeof import('./src/components/baseLayouts/EditProfileDialog.vue')['default']
const ElAvatar: typeof import('element-plus/es')['ElAvatar']
const ElBadge: typeof import('element-plus/es')['ElBadge']
const ElButton: typeof import('element-plus/es')['ElButton']
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
const ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
const ElDialog: typeof import('element-plus/es')['ElDialog']
const ElForm: typeof import('element-plus/es')['ElForm']
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
const ElIcon: typeof import('element-plus/es')['ElIcon']
const ElInput: typeof import('element-plus/es')['ElInput']
const ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
const ElOption: typeof import('element-plus/es')['ElOption']
const ElPopover: typeof import('element-plus/es')['ElPopover']
const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
const ElSelect: typeof import('element-plus/es')['ElSelect']
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
const ElTooltip: typeof import('element-plus/es')['ElTooltip']
const GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
const Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default']

150
package-lock.json generated
View File

@@ -8,8 +8,11 @@
"name": "web-game",
"version": "0.0.0",
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"axios": "^1.17.0",
"cropperjs": "^2.1.1",
"element-plus": "^2.14.2",
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4",
@@ -20,6 +23,7 @@
"@element-plus/icons-vue": "^2.3.2",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.2",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/tsconfig": "^0.9.1",
@@ -499,6 +503,126 @@
"devOptional": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@cropper/element": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element/-/element-2.1.1.tgz",
"integrity": "sha512-pthgIQq3PFAFRGUts96yrMgmMY/4rS/zKEq/Vvw7L0Ur09MgzrUg15z4k96K53bJ8XvNeXdQ0qIDw90gh4Xcug==",
"license": "MIT",
"dependencies": {
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-canvas": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-canvas/-/element-canvas-2.1.1.tgz",
"integrity": "sha512-QHiHnPHsykkc4salOiCLOIKWPK3sLQ1SzpJYFE/yQPvqTPu6ERZNcxVwB1x7WhNN+SdP0S0CEHPYIkgIfUo7VA==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-crosshair": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-crosshair/-/element-crosshair-2.1.1.tgz",
"integrity": "sha512-DtZnOiY2RZSizDZzMUvya3wZ55YnWclf3hmIrLW6jdcFlQkYEbX6bkOyOJzQmEX0yIbmwMkXfFcMkEZ9dE1rJQ==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-grid": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-grid/-/element-grid-2.1.1.tgz",
"integrity": "sha512-MZdPBh5QMU10T78nqG+5cK+gq4DHoWZvdOo+/wZ9/z3VH0u21sROeHOCnf0QAdxguPHSSFsPjzARK+ffS4Loog==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-handle": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-handle/-/element-handle-2.1.1.tgz",
"integrity": "sha512-TmSSphyMr2NEA0DqUTf9PCN8kcpzM0BxreOMyzbfuq1NUUePV1LxYgSOUT7dftMjZnTcu8cHQeFYa69U2BZnBA==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-image": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-image/-/element-image-2.1.1.tgz",
"integrity": "sha512-cldzN3hVJo0Luui+FDSIkmbQtwIjxWiTcTMLCMSi3hqIq6lc7YOBz+rC5R8AYnYV7YvsWonE6PrJZS+iZpBkdQ==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-selection": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-selection/-/element-selection-2.1.1.tgz",
"integrity": "sha512-QtMYhcuR+8JG3QEv8iSRCfQEVYljor61vLPrnGCFIkDJhvcyEWDx4H/Kfn6Jrdo90PyXf2VbpTG7TgH8i/EZFg==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/element-image": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-shade": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-shade/-/element-shade-2.1.1.tgz",
"integrity": "sha512-MhJVc3jC87TjQu4EfBnNFddL3LxvA+Wzjy1Qhyi0wNdeTAzs97uYJ3KwGVtvzPYP/4JaSU4JDpw+BfIdUqR0bg==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/element-selection": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/element-viewer": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/element-viewer/-/element-viewer-2.1.1.tgz",
"integrity": "sha512-RpXTGW/rTtJVNd3/R3imdCBJzyZRhA/OeWNo7RID4qW7UYEiL6Lwh7Six3QhFIci/IaUAyL9NAAN10av2YWqog==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/element-image": "^2.1.1",
"@cropper/element-selection": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/@cropper/elements": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/elements/-/elements-2.1.1.tgz",
"integrity": "sha512-cHShk4l6iExM4S6B1xx7+09gtDs+kdxWqe7Yv6DEbAyWhxVIVJyJAPpKlzLfA5MYnlSnkvCvY66hLLQPwooRMQ==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.1.1",
"@cropper/element-canvas": "^2.1.1",
"@cropper/element-crosshair": "^2.1.1",
"@cropper/element-grid": "^2.1.1",
"@cropper/element-handle": "^2.1.1",
"@cropper/element-image": "^2.1.1",
"@cropper/element-selection": "^2.1.1",
"@cropper/element-shade": "^2.1.1",
"@cropper/element-viewer": "^2.1.1"
}
},
"node_modules/@cropper/utils": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@cropper/utils/-/utils-2.1.1.tgz",
"integrity": "sha512-0eQs08WyQUNFMfU3ieE091dEQkrW0YdgZ3n6Thnk1EUQv45ypfiL+MevHwr7UzNnUlqUYR2FNSCgE6qBxK0sFw==",
"license": "MIT"
},
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
@@ -618,6 +742,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@microsoft/fetch-event-source": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
"integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -1647,6 +1777,16 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -2373,6 +2513,16 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/cropperjs": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/cropperjs/-/cropperjs-2.1.1.tgz",
"integrity": "sha512-FDJMarkY+/SepYarPZsvkG2LmI2PElecciMFnvBiBIoKnFYua/scprC5qejCLLyuX2jEqJRS2njbAsHxfjtIXA==",
"license": "MIT",
"dependencies": {
"@cropper/elements": "^2.1.1",
"@cropper/utils": "^2.1.1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",

View File

@@ -12,8 +12,11 @@
"format": "oxfmt src/"
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"axios": "^1.17.0",
"cropperjs": "^2.1.1",
"element-plus": "^2.14.2",
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4",

121
pnpm-lock.yaml generated
View File

@@ -8,12 +8,21 @@ importers:
.:
dependencies:
'@microsoft/fetch-event-source':
specifier: ^2.0.1
version: 2.0.1
axios:
specifier: ^1.17.0
version: 1.17.0
cropperjs:
specifier: ^2.1.1
version: 2.1.1
element-plus:
specifier: ^2.14.2
version: 2.14.2(vue@3.5.35(typescript@6.0.3))
mitt:
specifier: ^3.0.1
version: 3.0.1
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3))
@@ -245,6 +254,39 @@ packages:
'@bufbuild/protobuf@2.12.0':
resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==}
'@cropper/element-canvas@2.1.1':
resolution: {integrity: sha512-QHiHnPHsykkc4salOiCLOIKWPK3sLQ1SzpJYFE/yQPvqTPu6ERZNcxVwB1x7WhNN+SdP0S0CEHPYIkgIfUo7VA==}
'@cropper/element-crosshair@2.1.1':
resolution: {integrity: sha512-DtZnOiY2RZSizDZzMUvya3wZ55YnWclf3hmIrLW6jdcFlQkYEbX6bkOyOJzQmEX0yIbmwMkXfFcMkEZ9dE1rJQ==}
'@cropper/element-grid@2.1.1':
resolution: {integrity: sha512-MZdPBh5QMU10T78nqG+5cK+gq4DHoWZvdOo+/wZ9/z3VH0u21sROeHOCnf0QAdxguPHSSFsPjzARK+ffS4Loog==}
'@cropper/element-handle@2.1.1':
resolution: {integrity: sha512-TmSSphyMr2NEA0DqUTf9PCN8kcpzM0BxreOMyzbfuq1NUUePV1LxYgSOUT7dftMjZnTcu8cHQeFYa69U2BZnBA==}
'@cropper/element-image@2.1.1':
resolution: {integrity: sha512-cldzN3hVJo0Luui+FDSIkmbQtwIjxWiTcTMLCMSi3hqIq6lc7YOBz+rC5R8AYnYV7YvsWonE6PrJZS+iZpBkdQ==}
'@cropper/element-selection@2.1.1':
resolution: {integrity: sha512-QtMYhcuR+8JG3QEv8iSRCfQEVYljor61vLPrnGCFIkDJhvcyEWDx4H/Kfn6Jrdo90PyXf2VbpTG7TgH8i/EZFg==}
'@cropper/element-shade@2.1.1':
resolution: {integrity: sha512-MhJVc3jC87TjQu4EfBnNFddL3LxvA+Wzjy1Qhyi0wNdeTAzs97uYJ3KwGVtvzPYP/4JaSU4JDpw+BfIdUqR0bg==}
'@cropper/element-viewer@2.1.1':
resolution: {integrity: sha512-RpXTGW/rTtJVNd3/R3imdCBJzyZRhA/OeWNo7RID4qW7UYEiL6Lwh7Six3QhFIci/IaUAyL9NAAN10av2YWqog==}
'@cropper/element@2.1.1':
resolution: {integrity: sha512-pthgIQq3PFAFRGUts96yrMgmMY/4rS/zKEq/Vvw7L0Ur09MgzrUg15z4k96K53bJ8XvNeXdQ0qIDw90gh4Xcug==}
'@cropper/elements@2.1.1':
resolution: {integrity: sha512-cHShk4l6iExM4S6B1xx7+09gtDs+kdxWqe7Yv6DEbAyWhxVIVJyJAPpKlzLfA5MYnlSnkvCvY66hLLQPwooRMQ==}
'@cropper/utils@2.1.1':
resolution: {integrity: sha512-0eQs08WyQUNFMfU3ieE091dEQkrW0YdgZ3n6Thnk1EUQv45ypfiL+MevHwr7UzNnUlqUYR2FNSCgE6qBxK0sFw==}
'@ctrl/tinycolor@4.2.0':
resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
engines: {node: '>=14'}
@@ -288,6 +330,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@microsoft/fetch-event-source@2.0.1':
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@@ -891,6 +936,9 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
cropperjs@2.1.1:
resolution: {integrity: sha512-FDJMarkY+/SepYarPZsvkG2LmI2PElecciMFnvBiBIoKnFYua/scprC5qejCLLyuX2jEqJRS2njbAsHxfjtIXA==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2077,6 +2125,72 @@ snapshots:
'@bufbuild/protobuf@2.12.0': {}
'@cropper/element-canvas@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-crosshair@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-grid@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-handle@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-image@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-selection@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/element-image': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-shade@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/element-selection': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element-viewer@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/element-image': 2.1.1
'@cropper/element-selection': 2.1.1
'@cropper/utils': 2.1.1
'@cropper/element@2.1.1':
dependencies:
'@cropper/utils': 2.1.1
'@cropper/elements@2.1.1':
dependencies:
'@cropper/element': 2.1.1
'@cropper/element-canvas': 2.1.1
'@cropper/element-crosshair': 2.1.1
'@cropper/element-grid': 2.1.1
'@cropper/element-handle': 2.1.1
'@cropper/element-image': 2.1.1
'@cropper/element-selection': 2.1.1
'@cropper/element-shade': 2.1.1
'@cropper/element-viewer': 2.1.1
'@cropper/utils@2.1.1': {}
'@ctrl/tinycolor@4.2.0': {}
'@element-plus/icons-vue@2.3.2(vue@3.5.35(typescript@6.0.3))':
@@ -2129,6 +2243,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@microsoft/fetch-event-source@2.0.1': {}
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -2659,6 +2775,11 @@ snapshots:
dependencies:
is-what: 5.5.0
cropperjs@2.1.1:
dependencies:
'@cropper/elements': 2.1.1
'@cropper/utils': 2.1.1
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1

View File

@@ -5,7 +5,26 @@
</template>
<script setup lang="ts">
import { watch } from 'vue'
import { RouterView } from 'vue-router'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { useSse } from '@/common/hooks/useSse'
import { useAccountStore } from '@/stores/modules/account'
const accountStore = useAccountStore()
const { connect, disconnect } = useSse()
// 登录后建立 SSE 连接,登出时断开
watch(
() => accountStore.accessToken,
(token) => {
if (token) {
connect()
} else {
disconnect()
}
},
{ immediate: true }
)
</script>

View File

@@ -1,33 +0,0 @@
import type { GameItem } from '../modules/game/types'
/** 游戏列表 Mock 数据 */
export const mockGameList: GameItem[] = [
{
id: 'snake',
icon: '🐍',
name: '贪吃蛇',
description: '经典贪吃蛇竞技,在狭小空间内争夺生存权',
gameplay: '方向键控制蛇的移动,吃到食物变长。撞墙或其他蛇的身体即死亡,最后存活的玩家获胜。'
},
{
id: 'tetris',
icon: '🧊',
name: '俄罗斯方块',
description: '经典下落消除,与对手一较高下',
gameplay: '方向键移动和旋转方块,填满横排即可消除。同时向对手发送干扰行,坚持到最后的玩家获胜。'
},
{
id: 'gomoku',
icon: '⚫',
name: '五子棋',
description: '黑白对弈,先连成五子者胜',
gameplay: '鼠标点击落子,黑白双方轮流在棋盘上放置棋子。先在横、竖、斜任意方向连成五子的一方获胜。'
},
{
id: 'guess-number',
icon: '🔢',
name: '猜数字',
description: '逻辑推理对战,猜中对方的秘密数字',
gameplay: '每轮输入猜测的数字组合,系统会提示位置和数字正确的数量。率先猜中对手数字组合的玩家获胜。'
}
]

View File

@@ -1,156 +0,0 @@
import type { RoomItem } from '../modules/game/types'
/** 根据名字生成头像 URL */
function avatarUrl(name: string): string {
return `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${encodeURIComponent(name)}`
}
/** 各游戏的房间 Mock 数据 */
export const mockRoomList: Record<string, RoomItem[]> = {
/* ---- 贪吃蛇 ---- */
snake: [
{
id: 'snake-001', name: '桌 01', gameIcon: '🐍', gameId: 'snake',
maxPlayers: 4,
players: [{ avatar: avatarUrl('小龙'), nickname: '小龙' }, { avatar: avatarUrl('阿蟒'), nickname: '阿蟒' }],
status: 'waiting', statusText: '等待中', stakes: 300, mode: '经典模式', creatorName: '小龙'
},
{
id: 'snake-002', name: '桌 02', gameIcon: '🐍', gameId: 'snake',
maxPlayers: 4,
players: [
{ avatar: avatarUrl('闪电'), nickname: '闪电' }, { avatar: avatarUrl('疾风'), nickname: '疾风' },
{ avatar: avatarUrl('游侠'), nickname: '游侠' }, { avatar: avatarUrl('幻影'), nickname: '幻影' }
],
status: 'playing', statusText: '进行中', stakes: 500, mode: '道具模式', creatorName: '闪电'
},
{
id: 'snake-003', name: '桌 03', gameIcon: '🐍', gameId: 'snake',
maxPlayers: 4,
players: [{ avatar: avatarUrl('青蛇'), nickname: '青蛇' }],
status: 'waiting', statusText: '等待中', stakes: 200, mode: '加速模式', creatorName: '青蛇'
},
{
id: 'snake-004', name: '桌 04', gameIcon: '🐍', gameId: 'snake',
maxPlayers: 4, players: [],
status: 'waiting', statusText: '等待中', stakes: 100, mode: '经典模式', creatorName: '毒牙'
},
{
id: 'snake-005', name: '桌 05', gameIcon: '🐍', gameId: 'snake',
maxPlayers: 4,
players: [
{ avatar: avatarUrl('巨蟒'), nickname: '巨蟒' }, { avatar: avatarUrl('灵蛇'), nickname: '灵蛇' },
{ avatar: avatarUrl('毒牙'), nickname: '毒牙' }
],
status: 'waiting', statusText: '等待中', stakes: 400, mode: '道具模式', creatorName: '巨蟒'
}
],
/* ---- 俄罗斯方块 ---- */
tetris: [
{
id: 'tetris-001', name: '桌 01', gameIcon: '🧊', gameId: 'tetris',
maxPlayers: 2,
players: [{ avatar: avatarUrl('方块大师'), nickname: '方块大师' }],
status: 'waiting', statusText: '等待中', stakes: 300, mode: '1v1 对战', creatorName: '方块大师'
},
{
id: 'tetris-002', name: '桌 02', gameIcon: '🧊', gameId: 'tetris',
maxPlayers: 2,
players: [{ avatar: avatarUrl('极速下落'), nickname: '极速下落' }, { avatar: avatarUrl('消行高手'), nickname: '消行高手' }],
status: 'playing', statusText: '进行中', stakes: 500, mode: '竞速模式', creatorName: '极速下落'
},
{
id: 'tetris-003', name: '桌 03', gameIcon: '🧊', gameId: 'tetris',
maxPlayers: 2, players: [],
status: 'waiting', statusText: '等待中', stakes: 150, mode: '1v1 对战', creatorName: '堆叠王者'
},
{
id: 'tetris-004', name: '桌 04', gameIcon: '🧊', gameId: 'tetris',
maxPlayers: 2,
players: [{ avatar: avatarUrl('T旋大师'), nickname: 'T旋大师' }],
status: 'waiting', statusText: '等待中', stakes: 200, mode: '1v1 对战', creatorName: 'T旋大师'
},
{
id: 'tetris-005', name: '桌 05', gameIcon: '🧊', gameId: 'tetris',
maxPlayers: 2,
players: [{ avatar: avatarUrl('连击王'), nickname: '连击王' }, { avatar: avatarUrl('消除者'), nickname: '消除者' }],
status: 'playing', statusText: '进行中', stakes: 400, mode: '竞速模式', creatorName: '连击王'
}
],
/* ---- 五子棋 ---- */
gomoku: [
{
id: 'gomoku-001', name: '桌 01', gameIcon: '⚫', gameId: 'gomoku',
maxPlayers: 2,
players: [{ avatar: avatarUrl('棋圣'), nickname: '棋圣' }],
status: 'waiting', statusText: '等待中', stakes: 200, mode: '无禁手', creatorName: '棋圣'
},
{
id: 'gomoku-002', name: '桌 02', gameIcon: '⚫', gameId: 'gomoku',
maxPlayers: 2,
players: [{ avatar: avatarUrl('黑白子'), nickname: '黑白子' }, { avatar: avatarUrl('五连珠'), nickname: '五连珠' }],
status: 'playing', statusText: '进行中', stakes: 400, mode: '有禁手', creatorName: '黑白子'
},
{
id: 'gomoku-003', name: '桌 03', gameIcon: '⚫', gameId: 'gomoku',
maxPlayers: 2, players: [],
status: 'waiting', statusText: '等待中', stakes: 100, mode: '无禁手', creatorName: '落子无悔'
},
{
id: 'gomoku-004', name: '桌 04', gameIcon: '⚫', gameId: 'gomoku',
maxPlayers: 2,
players: [{ avatar: avatarUrl('天元'), nickname: '天元' }],
status: 'waiting', statusText: '等待中', stakes: 300, mode: '有禁手', creatorName: '天元'
},
{
id: 'gomoku-005', name: '桌 05', gameIcon: '⚫', gameId: 'gomoku',
maxPlayers: 2,
players: [{ avatar: avatarUrl('先手必胜'), nickname: '先手必胜' }, { avatar: avatarUrl('后手无敌'), nickname: '后手无敌' }],
status: 'playing', statusText: '进行中', stakes: 500, mode: '有禁手', creatorName: '先手必胜'
}
],
/* ---- 猜数字 ---- */
'guess-number': [
{
id: 'guess-001', name: '桌 01', gameIcon: '🔢', gameId: 'guess-number',
maxPlayers: 4,
players: [{ avatar: avatarUrl('逻辑怪'), nickname: '逻辑怪' }, { avatar: avatarUrl('推理王'), nickname: '推理王' }],
status: 'waiting', statusText: '等待中', stakes: 200, mode: '经典模式', creatorName: '逻辑怪'
},
{
id: 'guess-002', name: '桌 02', gameIcon: '🔢', gameId: 'guess-number',
maxPlayers: 4,
players: [
{ avatar: avatarUrl('秒猜'), nickname: '秒猜' },
{ avatar: avatarUrl('心算'), nickname: '心算' },
{ avatar: avatarUrl('直觉'), nickname: '直觉' }
],
status: 'waiting', statusText: '等待中', stakes: 300, mode: '限时模式', creatorName: '秒猜'
},
{
id: 'guess-003', name: '桌 03', gameIcon: '🔢', gameId: 'guess-number',
maxPlayers: 4,
players: [{ avatar: avatarUrl('数字猎人'), nickname: '数字猎人' }],
status: 'waiting', statusText: '等待中', stakes: 150, mode: '经典模式', creatorName: '数字猎人'
},
{
id: 'guess-004', name: '桌 04', gameIcon: '🔢', gameId: 'guess-number',
maxPlayers: 4,
players: [
{ avatar: avatarUrl('密码破解'), nickname: '密码破解' },
{ avatar: avatarUrl('枚举大师'), nickname: '枚举大师' },
{ avatar: avatarUrl('二分法'), nickname: '二分法' },
{ avatar: avatarUrl('穷举王'), nickname: '穷举王' }
],
status: 'playing', statusText: '进行中', stakes: 500, mode: '限时模式', creatorName: '密码破解'
},
{
id: 'guess-005', name: '桌 05', gameIcon: '🔢', gameId: 'guess-number',
maxPlayers: 4, players: [],
status: 'waiting', statusText: '等待中', stakes: 100, mode: '经典模式', creatorName: '神秘数字'
}
]
}

View File

@@ -1,101 +0,0 @@
import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { requestClient } from '@/api/request'
import { mockGameList } from './game-list'
import { mockRoomList } from './game-room'
import type { GetRoomListRequest } from '../modules/game/types'
/**
* Mock 处理器函数类型
*
* 接收 axios 配置,返回 mock 响应数据体ApiResponse.data 部分)
*/
type MockHandler = (config: InternalAxiosRequestConfig) => unknown
/** Mock 路由条目 */
interface MockRoute {
/** 请求方法,小写 */
method: string
/** URL 路径(支持精确匹配和前缀匹配) */
url: string
/** 模拟网络延迟(毫秒),默认 150ms */
delay?: number
/** 处理器 */
handler: MockHandler
}
/** Mock 路由表 */
const mockRoutes: MockRoute[] = [
{
method: 'get',
url: '/game/list',
delay: 120,
handler: () => ({ list: mockGameList })
},
{
method: 'get',
url: '/game/room/list',
delay: 150,
handler: (config) => {
const params = config.params as GetRoomListRequest | undefined
const gameId = params?.gameId ?? 'snake'
const statusFilter = params?.status
let rooms = mockRoomList[gameId] ?? []
if (statusFilter) {
rooms = rooms.filter((r) => r.status === statusFilter)
}
return { list: rooms }
}
}
]
/**
* 安装 Mock 拦截器
*
* 在 axios 请求拦截器链中注入 mock 逻辑:
* 当请求 URL + method 匹配 mock 路由表时,将请求适配器替换为 mock 处理器,
* 绕开真实 HTTP 请求,直接返回模拟数据。
*
* 仅在开发环境且 VITE_USE_MOCK=true 时启用。
*/
export function setupMock(): void {
// 仅在 mock 模式启用时注册拦截器
if (import.meta.env.VITE_USE_MOCK !== 'true') {
return
}
requestClient.instance.interceptors.request.use((config) => {
const method = (config.method ?? 'get').toLowerCase()
const url = config.url ?? ''
// 查找匹配的 mock 路由
const route = mockRoutes.find((r) => r.method === method && url === r.url)
if (!route) return config
// 替换适配器,使请求不发出 HTTP 调用,直接返回 mock 数据
config.adapter = async (adapterConfig: AxiosRequestConfig) => {
// 模拟网络延迟
const delay = route.delay ?? 150
await new Promise((resolve) => setTimeout(resolve, delay))
const mockData = route.handler(adapterConfig)
// 构造符合 ApiResponse 格式的响应
const response: AxiosResponse = {
data: { code: 0, message: 'ok', data: mockData },
status: 200,
statusText: 'OK',
headers: {},
config: adapterConfig as InternalAxiosRequestConfig,
request: {}
}
return response
}
return config
})
}
/**
* 重新导出 mock 数据,供开发调试使用
*/
export { mockGameList, mockRoomList }

View File

@@ -1,5 +1,13 @@
import { requestClient } from '@/api/request'
import type { GetRoomListRequest, GetRoomListResponse } from './types'
import type {
CreateRoomRequest,
CreateRoomResponse,
GetRoomListRequest,
GetRoomListResponse,
JoinRoomRequest,
RoomDetailResponse,
RoomItem
} from './types'
/**
* 获取指定游戏的房间列表
@@ -16,3 +24,43 @@ export function getRoomList(params: GetRoomListRequest) {
params as unknown as Record<string, unknown>
)
}
/**
* 创建房间
*
* 根据当前游戏和选定的参数创建新房间。
* 房间名称留空时由后端自动生成"桌 XX"编号。
* 密码留空时房间为公开房间。
*
* @param data - 创建房间参数
* @returns 新建的房间信息
*/
export function createRoom(data: CreateRoomRequest) {
return requestClient.post<CreateRoomResponse>('/game/room/create', data)
}
/**
* 加入房间
*
* 玩家通过此接口加入指定房间,成为房间内的玩家。
* 公开房间无需传密码。加入成功后需通过 WebSocket 进行后续交互。
*
* @param data - 加入房间参数roomId 和可选的 password
* @returns 更新后的房间信息
*/
export function joinRoom(data: JoinRoomRequest) {
return requestClient.post<RoomItem>('/game/room/join', data)
}
/**
* 获取房间详情
*
* 用于进入房间页时获取初始快照(棋盘状态、玩家信息等)。
* 无需登录即可调用(观战者也需访问)。
*
* @param roomId 房间 ID
* @returns 房间详情
*/
export function getRoomDetail(roomId: string) {
return requestClient.get<RoomDetailResponse>(`/game/room/${roomId}`)
}

View File

@@ -16,6 +16,8 @@ export interface GameItem {
description: string
/** 玩法介绍 */
gameplay: string
/** 游戏配置(模式、人数、底注等选项) */
config: GameConfig
}
/** 房间内玩家 */
@@ -26,6 +28,16 @@ export interface RoomPlayer {
nickname: string
}
/** 游戏配置(随游戏列表接口一起返回) */
export interface GameConfig {
/** 最大玩家数 */
maxPlayers: number
/** 可选玩法模式列表 */
modes: string[]
/** 可选底注档位列表 */
stakesOptions: number[]
}
/** 房间状态 */
export type RoomStatus = 'waiting' | 'playing' | 'finished'
@@ -53,6 +65,8 @@ export interface RoomItem {
mode: string
/** 房主昵称 */
creatorName: string
/** 是否允许观战 */
allowSpectate: boolean
}
/** 获取游戏列表 — 无请求参数 */
@@ -72,3 +86,107 @@ export interface GetRoomListRequest {
export interface GetRoomListResponse {
list: RoomItem[]
}
/** 创建房间请求参数 */
export interface CreateRoomRequest {
/** 游戏 ID */
gameId: string
/** 房间名称(可选,留空由后端自动生成) */
name?: string
/** 房间密码(可选,留空为公开房间) */
password?: string
/** 玩法模式 */
mode: string
/** 底注金额 */
stakes: number
/** 最大玩家数 */
maxPlayers: number
/** 是否允许观战 */
allowSpectate?: boolean
}
/** 创建房间响应 */
export interface CreateRoomResponse {
/** 新建的房间信息 */
room: RoomItem
}
/** 房间详情(进入房间页时的初始快照) */
export interface RoomDetailResponse {
roomId: string
name: string
gameId: string
gameIcon: string
gameName: string
maxPlayers: number
mode: string
stakes: number
status: RoomStatus
creatorId: string
creatorName: string
allowSpectate: boolean
players: RoomPlayerDetail[]
/** 棋盘状态(对局中才有) */
boardState?: number[][]
/** 当前轮到谁userIdnull 表示未开始 */
currentTurn?: string
/** 走棋记录 */
moves?: MoveItem[]
}
/** 房间内玩家详情(含座位号和准备状态) */
export interface RoomPlayerDetail {
userId: string
avatar: string
nickname: string
seatNumber: number
ready: boolean
}
/** 走棋记录项 */
export interface MoveItem {
moveIndex: number
playerId: string
row: number
col: number
}
/** 加入房间请求参数 */
export interface JoinRoomRequest {
/** 房间 ID */
roomId: string
/** 房间密码(可选,公开房间留空) */
password?: string
}
/** WebSocket 消息通用封装 */
export interface WsMessage<T = unknown> {
type: string
data: T
}
/** WebSocket 上行消息类型 */
export type WsInboundType =
| 'place_stone'
| 'chat'
| 'ready'
| 'resign'
| 'draw_request'
| 'draw_response'
| 'leave'
| 'start'
/** WebSocket 下行消息类型 */
export type WsOutboundType =
| 'board_sync'
| 'move'
| 'turn_change'
| 'chat_broadcast'
| 'game_over'
| 'player_join'
| 'player_leave'
| 'ready_change'
| 'spectator_join'
| 'spectator_leave'
| 'move_history'
| 'error'

View File

@@ -22,7 +22,20 @@ export type SexType = (typeof Sex)[keyof typeof Sex]
* 对应 OpenAPI: UserInfoResponse
* 所有字段均为可选,部分字段可能为 null
*/
/** 在线状态枚举 */
export const UserStatus = {
ONLINE: 'ONLINE',
AWAY: 'AWAY',
DND: 'DND',
INVISIBLE: 'INVISIBLE'
} as const
/** 在线状态类型 */
export type UserStatusType = (typeof UserStatus)[keyof typeof UserStatus]
export interface UserInfoResponse {
/** 用户唯一标识UUID */
userId?: string
/** 用户昵称,最长 16 位 */
nickName?: string
/** 头像图片 URL 地址,可能为 null */
@@ -35,13 +48,20 @@ export interface UserInfoResponse {
sex?: SexType | null
/** 生日ISO 8601 日期格式),可能为 null */
birthday?: string | null
/** 在线状态ONLINE / AWAY / DND / INVISIBLE */
status?: UserStatusType
}
/** 更新在线状态请求 */
export interface UpdateStatusRequest {
/** 状态值ONLINE、AWAY、DND、INVISIBLE */
status: UserStatusType
}
/**
* 获取当前登录用户信息
*
* 根据请求头中的 saToken 获取当前登录用户的详细信息
* 包括昵称、头像、个性签名、年龄、性别、生日。
* 根据请求头中的 saToken 获取当前登录用户的详细信息
* 需登录认证。
*
* @returns 当前登录用户的详细信息
@@ -49,3 +69,67 @@ export interface UserInfoResponse {
export function getUserInfo() {
return requestClient.get<UserInfoResponse>('/user/info')
}
/** 更新用户信息请求(所有字段可选,传什么更新什么) */
export interface UpdateUserInfoRequest {
/** 用户昵称,最长 16 位 */
nickName?: string
/** 头像图片 URL 地址 */
avatar?: string
/** 个性签名,最长 64 位 */
signature?: string
/** 年龄 */
age?: number
/** 性别1=男2=女 */
sex?: number
/** 生日ISO 8601 日期格式,如 "1998-06-15" */
birthday?: string
}
/**
* 更新当前登录用户信息
*
* 所有字段可选,传什么更新什么。
*
* @param data - 更新请求
* @returns 更新后的用户信息
*/
export function updateUserInfo(data: UpdateUserInfoRequest) {
return requestClient.put<UserInfoResponse>('/user/info', data)
}
/**
* 上传用户头像
*
* 接收裁剪后的图片文件,保存后返回更新后的用户信息。
*
* @param file - 头像文件JPEG/PNG/WebP/GIF≤2MB
* @returns 更新后的用户信息
*/
export function uploadAvatar(file: File) {
const formData = new FormData()
formData.append('file', file)
return requestClient.post<UserInfoResponse>('/user/avatar', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
} as any)
}
/**
* 删除用户头像
*
* 删除磁盘文件并将 DB 中 avatar 置空。
*
* @returns 更新后的用户信息avatar 为 null
*/
export function deleteAvatar() {
return requestClient.delete<UserInfoResponse>('/user/avatar')
}
/**
* 更新当前登录用户在线状态
*
* @param data - 状态更新请求
*/
export function updateStatus(data: UpdateStatusRequest) {
return requestClient.patch<void>('/user/status', data)
}

View File

@@ -1,6 +1,5 @@
import { ElMessage } from 'element-plus'
import { eventBus, AppEvents } from '@/utils'
import type { RequestErrorEvent } from './types'
/**
* 注册请求错误 UI 展示
@@ -8,7 +7,6 @@ import type { RequestErrorEvent } from './types'
* 订阅应用级 REQUEST_ERROR 事件,统一展示错误提示,
* 实现网络模块与 UI 的解耦。
*/
eventBus.on(AppEvents.REQUEST_ERROR, (payload: unknown) => {
const { message: errorMessage } = payload as RequestErrorEvent
ElMessage.error(errorMessage)
eventBus.on(AppEvents.REQUEST_ERROR, (payload) => {
ElMessage.error(payload.message)
})

View File

@@ -38,7 +38,9 @@ export function createRefreshTokenInterceptor(config: RefreshInterceptorConfig)
*/
async function onFulfilled(response: AxiosResponse<ApiResponse>) {
const data = response.data
if (data.code !== 401) {
// 前一个拦截器在 code !== 401 时已将 AxiosResponse 解包为 ApiResponse
// 此时 response.data 是业务数据本身,可能为 null如 updateStatus 返回 data: null
if (!data || data.code !== 401) {
return response
}

View File

@@ -1,7 +1,7 @@
import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios'
import { useAccountStore } from '@/stores'
import { eventBus, AppEvents } from '@/utils'
import type { ApiResponse, InternalRequestConfig, RequestErrorEvent } from './types'
import type { ApiResponse, InternalRequestConfig } from './types'
import { createRefreshTokenInterceptor } from './refreshInterceptor'
/**
@@ -36,7 +36,7 @@ function resolvePathParams(url: string, pathParams?: Record<string, string | num
* ```
*/
class RequestClient {
/** axios 实例(暴露给 mock 等外部模块使用) */
/** axios 实例 */
public instance: AxiosInstance
/** 是否正在刷新 token */
@@ -106,7 +106,7 @@ class RequestClient {
type: 'business',
code: data.code,
message: data.message || '请求失败'
} satisfies RequestErrorEvent)
})
return Promise.reject(data)
},
(error) => {
@@ -121,7 +121,7 @@ class RequestClient {
message: error.response
? `请求错误 (${error.response.status})`
: '网络异常,请检查网络连接'
} satisfies RequestErrorEvent)
})
return Promise.reject(error)
}
)

191
src/common/hooks/useSse.ts Normal file
View File

@@ -0,0 +1,191 @@
import { ref } from 'vue'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { sseBus } from '@/stores/sseBus'
import { useAccountStore } from '@/stores/modules/account'
/**
* SSE 连接管理 composable基于 @microsoft/fetch-event-source
*
* 设计原则:
* - 全局唯一:同一时间只有一条活跃的 SSE 连接
* - token 驱动:仅 accessToken 变更时才断开重连
* - 防重复:已连接(相同 token时 connect() 直接跳过
* - 所有业务变更通过 sseBus 事件总线推送,各组件自行订阅
*
* 使用示例:
* ```ts
* const { isConnected, connect, disconnect } = useSse()
* watch(() => accountStore.accessToken, (v) => v ? connect() : disconnect())
* ```
*/
export function useSse() {
/** SSE 连接是否已建立 */
const isConnected = ref(false)
/** 用于取消 inflight 连接的控制器 */
let abortController: AbortController | null = null
/** 当前连接使用的 token用于判断是否需要重连 */
let currentToken: string | null = null
/** 是否正在建立连接(防并发) */
let connecting = false
/** 当前连接的重试计数 */
let retryCount = 0
/** 是否正在刷新 token防并发 */
let isRefreshing = false
/**
* 建立 SSE 连接
*
* 通过 fetch-event-source 发起,设置 saToken 请求头鉴权。
* 连接期间持续接收服务端推送事件,直到 abort 或服务端关闭。
*
* 防重复:如果已用相同 token 连接或正在连接中,直接跳过。
*/
async function connect(): Promise<void> {
const accountStore = useAccountStore()
const token = accountStore.accessToken
if (!token) {
console.warn('[SSE] 无 accessToken跳过连接')
return
}
// 相同 token 已连接或正在连接中,跳过
if (currentToken === token && (isConnected.value || connecting)) {
return
}
// 取消旧连接
abort()
connecting = true
currentToken = token
abortController = new AbortController()
// Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe
fetchEventSource('/api/sse/subscribe', {
headers: { saToken: token },
signal: abortController.signal,
/** 连接建立 */
async onopen() {
connecting = false
isConnected.value = true
retryCount = 0
console.log('[SSE] 连接已建立')
},
/** 收到服务端事件 */
onmessage(msg) {
// 心跳和 connected 确认消息可能 data 为空
if (!msg.data) return
try {
const data = JSON.parse(msg.data)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sseBus.emit(msg.event as any, data)
} catch {
console.warn('[SSE] 事件数据解析失败:', msg.event, msg.data)
}
},
/** 连接关闭 */
onclose() {
connecting = false
isConnected.value = false
console.log('[SSE] 连接已关闭')
},
/**
* 连接错误处理(必须同步,签名: (err) => number | void
*
* 401 → fire-and-forget 刷新 token阻止当前连接重连
* 刷新成功后由 Vue watcher 触发 connect() 自动重连
* 其他错误 → 返回重试延迟ms库自动排队重连
*/
onerror(err) {
connecting = false
isConnected.value = false
// 鉴权失败 → 刷新 token异步 fire-and-forgetonerror 本身保持同步)
if (err.response?.status === 401) {
if (isRefreshing) {
// 已有刷新在进行中,直接放弃本次连接
throw err
}
isRefreshing = true
const store = useAccountStore()
if (!store.refreshToken) {
sseBus.emit('error', { message: '登录已过期,请重新登录', retryCount: 0 })
throw err
}
// 异步刷新,不阻塞 onerror 返回
store
.refreshAccessToken()
.then((newToken) => {
if (newToken) {
console.log('[SSE] token 刷新成功,重新建立连接')
retryCount = 0
// connect() 由 App.vue 的 watch 触发accessToken 变更)
}
})
.catch(() => {
console.warn('[SSE] token 刷新失败')
sseBus.emit('error', { message: '实时连接异常,请刷新页面', retryCount: 0 })
})
.finally(() => {
isRefreshing = false
})
throw err // 不自动重连
}
// 其他错误:指数退避重连
retryCount++
if (retryCount >= 5) {
sseBus.emit('error', {
message: '实时连接异常,请刷新页面',
retryCount
})
throw err // 超过阈值,停止重连
}
// 返回重试间隔ms指数退避3s, 6s, 12s, 24s最大 30s
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 30000)
console.warn(`[SSE] 连接错误,${delay / 1000}s 后重试 (${retryCount}/5)`)
return delay
}
})
}
/**
* 取消当前 SSE 连接
*/
function abort(): void {
if (abortController) {
abortController.abort()
abortController = null
}
connecting = false
isConnected.value = false
currentToken = null
retryCount = 0
}
/**
* 断开 SSE 连接
*
* 在用户登出时调用。
*/
function disconnect(): void {
abort()
console.log('[SSE] 连接已断开')
}
return {
isConnected,
connect,
disconnect
}
}

View File

@@ -0,0 +1,196 @@
import type { WsMessage } from '@/api/modules/game'
/** 重连配置 */
const RECONNECT_MAX_RETRIES = 5
/** 重连基础延迟ms指数退避1s, 2s, 4s, 8s, 16s */
const RECONNECT_BASE_DELAY = 1000
/** 重连最大延迟ms */
const RECONNECT_MAX_DELAY = 16000
/** WebSocket 连接回调 */
export interface RoomSocketCallbacks {
onOpen?: () => void
onClose?: (event: CloseEvent) => void
onError?: (event: Event) => void
onMessage?: (message: WsMessage) => void
/** 自动重连成功回调(重连后需调用方重新拉取房间状态) */
onReconnect?: () => void
/** 重连次数耗尽回调 */
onReconnectFailed?: () => void
}
/** WebSocket 客户端封装 */
export interface RoomSocket {
/** 发送消息 */
send: (type: string, data?: unknown) => void
/** 关闭连接(主动关闭不触发重连) */
close: () => void
/** 连接状态 */
isOpen: () => boolean
}
/**
* 创建房间 WebSocket 连接(含自动重连)
*
* 连接断开时自动指数退避重连,最多重试 5 次。
* 主动调用 close() 或重连耗尽时触发 onClose 回调。
* 重连成功后触发 onReconnect 回调,调用方应在此重新拉取房间状态。
*
* @param roomId 房间 ID
* @param token 用户认证 token
* @param callbacks 事件回调
* @returns RoomSocket 控制对象
*/
export function createRoomSocket(
roomId: string,
token: string,
callbacks: RoomSocketCallbacks
): RoomSocket {
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/room?roomId=${encodeURIComponent(roomId)}&token=${encodeURIComponent(token)}`
/** 当前 WebSocket 实例 */
let ws: WebSocket | null = null
/** 当前重试次数 */
let retryCount = 0
/** 是否主动关闭(主动关闭不重连) */
let intentionalClose = false
/** 重连定时器 ID用于组件卸载时取消 */
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
/** 消息发送队列(连接未就绪时暂存) */
let messageQueue: Array<{ type: string; data?: unknown }> = []
/** 最大排队消息数 */
const MAX_QUEUE_SIZE = 50
/** 建立 WebSocket 连接 */
function connect(): void {
// 如果已有旧的重连定时器,取消它
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
ws = new WebSocket(wsUrl)
ws.onopen = () => {
// 用户已离开,直接关闭连接
if (intentionalClose) {
ws?.close(1000, '用户已离开')
return
}
// eslint-disable-next-line no-console
console.log('[WS] 房间连接已建立, roomId=', roomId)
if (retryCount > 0) {
// 这是重连成功
// eslint-disable-next-line no-console
console.log('[WS] 重连成功, roomId=', roomId, '重试次数=', retryCount)
retryCount = 0
callbacks.onReconnect?.()
} else {
callbacks.onOpen?.()
}
// 刷新排队中的消息
flushQueue()
}
ws.onclose = (event) => {
// 主动关闭,不重连
if (intentionalClose) {
// eslint-disable-next-line no-console
console.log('[WS] 主动关闭连接, roomId=', roomId, 'code=', event.code)
callbacks.onClose?.(event)
return
}
// 已达最大重试次数
if (retryCount >= RECONNECT_MAX_RETRIES) {
// eslint-disable-next-line no-console
console.log('[WS] 重连次数耗尽, roomId=', roomId, 'retries=', retryCount)
callbacks.onReconnectFailed?.()
callbacks.onClose?.(event)
return
}
// 指数退避重连
retryCount++
const delay = Math.min(
RECONNECT_BASE_DELAY * Math.pow(2, retryCount - 1),
RECONNECT_MAX_DELAY
)
// eslint-disable-next-line no-console
console.log(
`[WS] 连接断开,${delay / 1000}s 后重连 (${retryCount}/${RECONNECT_MAX_RETRIES}), roomId=`,
roomId
)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, delay)
}
ws.onerror = (event) => {
console.error('[WS] 连接错误, roomId=', roomId, event)
callbacks.onError?.(event)
}
ws.onmessage = (event) => {
try {
const message: WsMessage = JSON.parse(event.data as string)
callbacks.onMessage?.(message)
} catch (e) {
console.error('[WS] 消息解析失败:', e)
}
}
}
// 初始连接
connect()
/** 刷新消息发送队列 */
function flushQueue(): void {
if (messageQueue.length === 0) return
// eslint-disable-next-line no-console
console.log(`[WS] 刷新排队消息, count=${messageQueue.length}`)
const pending = messageQueue
messageQueue = []
for (const msg of pending) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
}
}
}
return {
send(type: string, data?: unknown) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, data }))
} else if (ws && ws.readyState === WebSocket.CONNECTING) {
// 连接尚未就绪,排队等待
if (messageQueue.length < MAX_QUEUE_SIZE) {
messageQueue.push({ type, data })
}
} else {
console.warn(`[WS] 消息未发送(连接未就绪), type=${type}, readyState=${ws?.readyState ?? 'null'}`)
}
},
close() {
intentionalClose = true
// 取消进行中的重连定时器
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (ws) {
// 仅 OPEN 状态可安全关闭CONNECTING 时由 onopen 处理
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, '用户离开')
}
}
},
isOpen() {
return ws?.readyState === WebSocket.OPEN
}
}
}

View File

@@ -0,0 +1,279 @@
<template>
<Teleport to="body">
<div class="avatar-cropper-overlay" @click.self="handleCancel">
<div class="avatar-cropper">
<!-- 标题栏 -->
<div class="avatar-cropper__header">
<span class="avatar-cropper__title">裁剪头像</span>
<el-icon :size="20" class="avatar-cropper__close" @click="handleCancel">
<Close />
</el-icon>
</div>
<!--
源图片cropperjs 读取此图片后将其 display:none
并在其 DOM 位置之后插入裁剪画布
-->
<img
ref="imageRef"
:src="imageSrc"
alt=""
class="avatar-cropper__source-image"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- 底部操作 -->
<div class="avatar-cropper__footer">
<span class="avatar-cropper__hint">拖拽或缩放调整裁剪区域</span>
<div class="avatar-cropper__actions">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="confirming" @click="handleConfirm">确认</el-button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { Close } from '@element-plus/icons-vue'
import Cropper, { type CropperSelection } from 'cropperjs'
/** 裁剪输出尺寸(宽高相等,正方形) */
const CROP_SIZE = 200
/** JPEG 导出质量 0-1 */
const CROP_QUALITY = 0.85
/** 允许的图片 MIME 类型 */
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const
/** 文件大小上限 2MB */
const MAX_FILE_SIZE = 2 * 1024 * 1024
/**
* cropperjs v2 模板
* 1:1 正方形裁剪框,初始覆盖 90% 区域,可移动/缩放
* 注意grid/crosshair/handles 必须在 <cropper-selection> 内部
*/
const CROPPER_TEMPLATE = `
<cropper-canvas background>
<cropper-image></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="select" plain></cropper-handle>
<cropper-selection
aspect-ratio="1"
initial-coverage="0.9"
movable
resizable
outlined
>
<cropper-grid role="grid" bordered covered></cropper-grid>
<cropper-crosshair centered></cropper-crosshair>
<cropper-handle action="move" theme-color="rgba(255, 255, 255, 0.35)"></cropper-handle>
<cropper-handle action="n-resize"></cropper-handle>
<cropper-handle action="e-resize"></cropper-handle>
<cropper-handle action="s-resize"></cropper-handle>
<cropper-handle action="w-resize"></cropper-handle>
<cropper-handle action="ne-resize"></cropper-handle>
<cropper-handle action="nw-resize"></cropper-handle>
<cropper-handle action="se-resize"></cropper-handle>
<cropper-handle action="sw-resize"></cropper-handle>
</cropper-selection>
</cropper-canvas>
`
const props = defineProps<{
/** 要裁剪的图片文件 */
file: File
}>()
const emit = defineEmits<{
(e: 'confirm', blob: Blob): void
(e: 'cancel'): void
}>()
const imageRef = ref<HTMLImageElement>()
const imageSrc = ref('')
const confirming = ref(false)
let cropper: Cropper | null = null
/** 图片是否已加载完成 */
let imageLoaded = false
/** 校验文件格式和大小 */
function validateFile(file: File): string | null {
if (!(ALLOWED_TYPES as readonly string[]).includes(file.type)) {
return '仅支持 JPG、PNG、WebP、GIF 格式'
}
if (file.size > MAX_FILE_SIZE) {
return '文件大小不能超过 2MB'
}
return null
}
onMounted(() => {
const error = validateFile(props.file)
if (error) {
emit('cancel')
return
}
// 生成 Object URL 触发图片加载
imageSrc.value = URL.createObjectURL(props.file)
})
onBeforeUnmount(() => {
destroyCropper()
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
imageSrc.value = ''
}
})
/** 图片加载成功后初始化 Cropper */
function handleImageLoad() {
imageLoaded = true
if (!imageRef.value) return
// cropperjs v2: 以 img 为数据源,不传 container直接在 img 父元素中渲染裁剪 UI
cropper = new Cropper(imageRef.value, {
template: CROPPER_TEMPLATE
})
}
/** 图片加载失败 */
function handleImageError() {
emit('cancel')
}
/** 销毁 Cropper 实例 */
function destroyCropper() {
if (cropper) {
cropper.destroy()
cropper = null
}
}
/** 确认裁剪:导出 Canvas → Blob */
async function handleConfirm() {
if (!cropper) return
confirming.value = true
try {
const selection: CropperSelection | null = cropper.getCropperSelection()
if (!selection) {
emit('cancel')
return
}
// $toCanvas 返回 HTMLCanvasElement
const canvas = await selection.$toCanvas({
width: CROP_SIZE,
height: CROP_SIZE
})
canvas.toBlob(
(blob) => {
if (!blob) {
emit('cancel')
return
}
emit('confirm', blob)
},
'image/jpeg',
CROP_QUALITY
)
} catch {
emit('cancel')
} finally {
confirming.value = false
}
}
function handleCancel() {
emit('cancel')
}
</script>
<style scoped lang="scss">
/* ========== 遮罩层 ========== */
.avatar-cropper-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
/* ========== 裁剪弹窗 ========== */
.avatar-cropper {
display: flex;
flex-direction: column;
width: 440px;
max-width: 90vw;
background: var(--bg-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.avatar-cropper__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--border-default);
}
.avatar-cropper__title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.avatar-cropper__close {
cursor: pointer;
color: var(--text-tertiary);
transition: color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
/* 源图片cropperjs 初始化时将其 display:none在此之前短暂可见 */
.avatar-cropper__source-image {
display: block;
max-width: 100%;
max-height: 400px;
}
/* cropperjs 生成的 cropper-canvas 撑满可用空间 */
:deep(cropper-canvas) {
display: block;
min-height: 300px;
max-height: 420px;
background: var(--bg-body);
}
/* ========== 底部操作栏 ========== */
.avatar-cropper__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-lg) var(--space-md);
}
.avatar-cropper__hint {
font-size: 12px;
color: var(--text-tertiary);
}
.avatar-cropper__actions {
display: flex;
gap: var(--space-sm);
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="420px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="72px"
label-position="left"
@submit.prevent="handleSubmit"
>
<!-- 房间名称可选 -->
<el-form-item label="房间名" prop="name">
<el-input
v-model="form.name"
placeholder="留空自动生成桌号"
maxlength="20"
show-word-limit
clearable
/>
<div class="create-room-dialog__hint">留空将自动生成"桌 XX"编号</div>
</el-form-item>
<!-- 游戏模式 -->
<el-form-item label="模式" prop="mode">
<el-select v-model="form.mode" placeholder="请选择模式">
<el-option v-for="m in gameConfig?.modes ?? []" :key="m" :label="m" :value="m" />
</el-select>
</el-form-item>
<!-- 底注金额 -->
<el-form-item label="底注" prop="stakes">
<el-select v-model="form.stakes" placeholder="请选择底注">
<el-option
v-for="s in gameConfig?.stakesOptions ?? []"
:key="s"
:label="`${s} 金币`"
:value="s"
/>
</el-select>
</el-form-item>
<!-- 房间密码可选 -->
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="留空为公开房间"
maxlength="16"
show-password
clearable
/>
<div class="create-room-dialog__hint">设置密码后其他玩家需输入密码才能加入</div>
</el-form-item>
<!-- 是否允许观战 -->
<el-form-item label="观战">
<el-switch v-model="form.allowSpectate" />
<div class="create-room-dialog__hint">开启后其他玩家可旁观对局</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit"> 确认创建 </el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import { useBaseLayoutStore } from '@/stores'
import { storeToRefs } from 'pinia'
import type { GameConfig } from '@/api/modules/game'
import { createRoom } from '@/api/modules/game'
/** 创建房间表单数据 */
interface CreateRoomForm {
name: string
mode: string
stakes: number | undefined
password: string
allowSpectate: boolean
}
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'created'): void
}>()
const router = useRouter()
const baseLayoutStore = useBaseLayoutStore()
const { activeGameName, activeGameConfig } = storeToRefs(baseLayoutStore)
const formRef = ref<FormInstance>()
const submitting = ref(false)
/** 对话框可见性(双向绑定) */
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
})
/** 对话框标题 */
const dialogTitle = computed(() => `创建房间 — ${activeGameName.value}`)
/** 当前游戏配置(只读) */
const gameConfig = computed<GameConfig | null>(() => activeGameConfig.value)
/** 表单数据 */
const form = reactive<CreateRoomForm>({
name: '',
mode: '',
stakes: undefined,
password: '',
allowSpectate: true
})
/** 初始化默认值:取当前游戏配置的第一个选项 */
function initDefaults() {
if (gameConfig.value) {
form.mode = gameConfig.value.modes[0] ?? ''
form.stakes = gameConfig.value.stakesOptions[0]
}
form.name = ''
form.password = ''
form.allowSpectate = true
}
/** 弹窗打开时设置默认值 */
watch(
() => props.modelValue,
(val) => {
if (val) initDefaults()
}
)
/** 切换游戏时重置默认值 */
watch(activeGameConfig, () => {
if (props.modelValue) initDefaults()
})
const rules: FormRules = {
mode: [{ required: true, message: '请选择游戏模式', trigger: 'change' }],
stakes: [{ required: true, message: '请选择底注金额', trigger: 'change' }],
name: [{ max: 20, message: '房间名不超过 20 个字符', trigger: 'blur' }],
password: [{ max: 16, message: '密码不超过 16 个字符', trigger: 'blur' }]
}
/** 提交创建房间 */
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const res = await createRoom({
gameId: baseLayoutStore.activeGameId,
name: form.name.trim() || undefined,
password: form.password || undefined,
mode: form.mode,
stakes: form.stakes!,
maxPlayers: gameConfig.value?.maxPlayers ?? 0,
allowSpectate: form.allowSpectate
})
visible.value = false
emit('created')
// 跳转到新创建的房间
router.push(`/room/${res.data.room.id}`)
} finally {
submitting.value = false
}
}
/** 弹窗关闭动画结束后重置表单 */
function handleClosed() {
initDefaults()
formRef.value?.resetFields()
}
</script>
<style scoped lang="scss">
.create-room-dialog__hint {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
line-height: 1.4;
}
</style>

View File

@@ -0,0 +1,978 @@
<template>
<el-dialog
v-model="visible"
width="640px"
:close-on-click-modal="false"
:show-close="false"
class="edit-profile-dialog"
@closed="handleClosed"
>
<template #header>
<div class="ep-dialog__header">
<span class="ep-dialog__title">用户设置</span>
<span class="ep-dialog__subtitle">管理你的个人资料与偏好</span>
</div>
</template>
<div class="ep-layout">
<!-- 左侧导航 -->
<nav class="ep-nav">
<div
class="ep-nav__item"
:class="{ 'is-active': activeTab === 'profile' }"
@click="activeTab = 'profile'"
>
<span class="ep-nav__icon"
><el-icon :size="18"><User /></el-icon
></span>
<span class="ep-nav__text">个人信息</span>
<span v-if="activeTab === 'profile'" class="ep-nav__indicator" />
</div>
<div
class="ep-nav__item"
:class="{ 'is-active': activeTab === 'settings' }"
@click="activeTab = 'settings'"
>
<span class="ep-nav__icon"
><el-icon :size="18"><Setting /></el-icon
></span>
<span class="ep-nav__text">全局设置</span>
<span v-if="activeTab === 'settings'" class="ep-nav__indicator" />
</div>
</nav>
<!-- 右侧内容 -->
<div class="ep-content">
<Transition name="tab-fade" mode="out-in">
<!-- ========== 个人信息 ========== -->
<div v-if="activeTab === 'profile'" key="profile" class="ep-tab">
<!-- 头像 签名元素渐变光环 -->
<div class="ep-avatar-block">
<div
class="ep-avatar__ring"
:class="{ 'has-avatar': hasAvatar }"
@click="triggerFilePicker"
title="点击更换头像"
>
<el-avatar
:size="88"
shape="circle"
:src="avatarDisplaySrc"
class="ep-avatar__img"
/>
<div class="ep-avatar__overlay">
<el-icon :size="22"><Camera /></el-icon>
</div>
</div>
<div class="ep-avatar__meta">
<span class="ep-avatar__name">{{ form.nickName || '你的昵称' }}</span>
<span class="ep-avatar__hint">点击头像更换支持 JPG / PNG / WebP</span>
<div class="ep-avatar__btns">
<el-button size="small" round @click="triggerFilePicker">
<el-icon :size="14"><Upload /></el-icon>
更换
</el-button>
<el-button
v-if="hasAvatar"
size="small"
round
class="ep-avatar__remove-btn"
@click="handleRemoveAvatar"
>
<el-icon :size="14"><Delete /></el-icon>
移除
</el-button>
</div>
</div>
</div>
<input
ref="fileInputRef"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="ep-file-input"
@change="handleFileChange"
/>
<AvatarCropper
v-if="cropperFile"
:file="cropperFile"
@confirm="handleCropConfirm"
@cancel="handleCropCancel"
/>
<!-- 表单 -->
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="56px"
label-position="left"
size="large"
class="ep-form"
>
<el-form-item label="昵称" prop="nickName">
<el-input
v-model="form.nickName"
placeholder="给自己起个响亮的名字"
maxlength="16"
show-word-limit
/>
</el-form-item>
<el-form-item label="签名" prop="signature">
<el-input
v-model="form.signature"
type="textarea"
placeholder="用一句话介绍自己…"
maxlength="64"
show-word-limit
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.sex" class="ep-radio-group">
<el-radio-button :value="0">保密</el-radio-button>
<el-radio-button :value="1">
<el-icon :size="14"><Male /></el-icon>
</el-radio-button>
<el-radio-button :value="2">
<el-icon :size="14"><Female /></el-icon>
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="生日">
<el-date-picker
v-model="form.birthday"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="年龄">
<div class="ep-age">
<span v-if="form.birthday" class="ep-age__num">{{ calculatedAge }}</span>
<span v-if="form.birthday" class="ep-age__unit"></span>
<span v-else class="ep-age__hint">选择生日后自动计算</span>
</div>
</el-form-item>
</el-form>
</div>
<!-- ========== 全局设置 ========== -->
<div v-else key="settings" class="ep-tab">
<div class="ep-placeholder">
<div class="ep-placeholder__icon">
<el-icon :size="36"><Setting /></el-icon>
</div>
<h3 class="ep-placeholder__title">全局设置</h3>
<p class="ep-placeholder__desc">更多个性化选项即将上线敬请期待</p>
<div class="ep-feature-list">
<div class="ep-feature-card">
<span class="ep-feature-card__icon"
><el-icon :size="20"><Bell /></el-icon
></span>
<div class="ep-feature-card__body">
<span class="ep-feature-card__title">消息通知</span>
<span class="ep-feature-card__desc">管理游戏邀请与系统消息</span>
</div>
</div>
<div class="ep-feature-card">
<span class="ep-feature-card__icon"
><el-icon :size="20"><Brush /></el-icon
></span>
<div class="ep-feature-card__body">
<span class="ep-feature-card__title">主题切换</span>
<span class="ep-feature-card__desc">选择你喜欢的颜色方案</span>
</div>
</div>
<div class="ep-feature-card">
<span class="ep-feature-card__icon"
><el-icon :size="20"><Lock /></el-icon
></span>
<div class="ep-feature-card__body">
<span class="ep-feature-card__title">隐私设置</span>
<span class="ep-feature-card__desc">控制谁可以查看你的信息</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
<template v-if="activeTab === 'profile'" #footer>
<div class="ep-dialog__footer">
<span class="ep-dialog__footer-hint">修改将在保存后生效</span>
<div class="ep-dialog__footer-actions">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import {
Setting,
User,
Camera,
Upload,
Delete,
Male,
Female,
Bell,
Brush,
Lock
} from '@element-plus/icons-vue'
import { useAccountStore } from '@/stores'
import { storeToRefs } from 'pinia'
import { getAvatarUrl } from '@/utils'
import AvatarCropper from './AvatarCropper.vue'
/** 编辑资料表单数据 */
interface EditProfileForm {
nickName: string
signature: string
sex: number | null
age: number | null
birthday: string | null
}
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
/** 对话框可见性v-model 双向绑定) */
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
})
const accountStore = useAccountStore()
const { accountInfo } = storeToRefs(accountStore)
const formRef = ref<FormInstance>()
const fileInputRef = ref<HTMLInputElement>()
const saving = ref(false)
/** 当前激活的导航 tab */
const activeTab = ref<'profile' | 'settings'>('profile')
/** 表单数据 */
const form = reactive<EditProfileForm>({
nickName: '',
signature: '',
sex: null,
age: null,
birthday: null
})
/** 表单校验规则 */
const rules: FormRules = {
nickName: [{ max: 16, message: '昵称不超过 16 个字符', trigger: 'blur' }],
signature: [{ max: 64, message: '签名不超过 64 个字符', trigger: 'blur' }]
}
// ========== 头像裁剪相关 ==========
/** 待裁剪的文件(非 null 时显示 AvatarCropper */
const cropperFile = ref<File | null>(null)
/** 裁剪后的 Blob待上传 */
const croppedBlob = ref<Blob | null>(null)
/** 裁剪结果的预览 Object URL */
const croppedPreviewUrl = ref('')
/** 是否已标记移除头像 */
const avatarRemoved = ref(false)
/** 当前是否有头像(含自定义头像和默认 SVG */
const hasAvatar = computed(() => {
const av = accountInfo.value?.avatar
return !!(av && av.length > 0)
})
/** 头像显示源:裁剪预览 > 移除状态 > store 数据(已拼接后端域名) */
const avatarDisplaySrc = computed(() => {
if (avatarRemoved.value) return ''
if (croppedPreviewUrl.value) return croppedPreviewUrl.value
return getAvatarUrl(accountInfo.value?.avatar)
})
/** 根据生日计算年龄(周岁) */
function calcAge(birthday: string | null): number | null {
if (!birthday) return null
const birth = new Date(birthday)
const now = new Date()
let age = now.getFullYear() - birth.getFullYear()
const monthDiff = now.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) {
age--
}
return age
}
/** 由生日自动计算的年龄(只读) */
const calculatedAge = computed(() => calcAge(form.birthday))
/** 生日变化时同步计算年龄到表单数据(用于提交) */
watch(
() => form.birthday,
(val) => {
form.age = calcAge(val)
}
)
/** 未来日期不可选(生日不能是未来) */
function disabledDate(date: Date): boolean {
return date.getTime() > Date.now()
}
// ========== 初始化表单 ==========
watch(
() => props.modelValue,
(val) => {
if (val) initForm()
}
)
/** 从 store 填充表单 */
function initForm() {
const info = accountInfo.value
form.nickName = info?.nickName || ''
form.signature = info?.signature || ''
form.sex = info?.sex != null && [1, 2].includes(info.sex) ? info.sex : 0
form.age = info?.age ?? null
form.birthday = info?.birthday || null
form.age = calcAge(form.birthday)
// 重置头像状态
releaseCroppedPreview()
croppedBlob.value = null
avatarRemoved.value = false
activeTab.value = 'profile'
}
/** 清理裁剪预览 Object URL */
function releaseCroppedPreview() {
if (croppedPreviewUrl.value) {
URL.revokeObjectURL(croppedPreviewUrl.value)
croppedPreviewUrl.value = ''
}
}
// ========== 头像操作 ==========
/** 触发文件选择器 */
function triggerFilePicker() {
fileInputRef.value?.click()
}
/** 文件选择后打开裁剪弹窗 */
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
// 基础校验AvatarCropper 内部还会再校验)
const MAX_SIZE = 2 * 1024 * 1024
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!ALLOWED.includes(file.type)) {
ElMessage.warning('仅支持 JPG、PNG、WebP、GIF 格式')
clearFileInput()
return
}
if (file.size > MAX_SIZE) {
ElMessage.warning('文件大小不能超过 2MB')
clearFileInput()
return
}
cropperFile.value = file
clearFileInput()
}
/** 清空 file input允许重复选择同一文件 */
function clearFileInput() {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
/** 裁剪确认:接收 Blob生成预览 */
function handleCropConfirm(blob: Blob) {
releaseCroppedPreview()
croppedBlob.value = blob
croppedPreviewUrl.value = URL.createObjectURL(blob)
avatarRemoved.value = false
cropperFile.value = null
}
/** 取消裁剪 */
function handleCropCancel() {
cropperFile.value = null
clearFileInput()
}
/** 移除头像 */
function handleRemoveAvatar() {
releaseCroppedPreview()
croppedBlob.value = null
avatarRemoved.value = true
}
// ========== 保存 ==========
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
// 1. 头像变更优先处理
if (avatarRemoved.value) {
await accountStore.removeAvatar()
} else if (croppedBlob.value) {
// Blob → File
const file = new File([croppedBlob.value], 'avatar.jpg', {
type: croppedBlob.value.type || 'image/jpeg'
})
await accountStore.uploadAvatar(file)
}
// 2. 更新文本字段(只传变更的字段)
const info = accountInfo.value
const payload: Record<string, unknown> = {}
if (form.nickName !== (info?.nickName || '')) {
payload.nickName = form.nickName
}
if (form.signature !== (info?.signature || '')) {
payload.signature = form.signature
}
if (form.sex !== (info?.sex ?? 0)) {
payload.sex = form.sex === 0 ? null : form.sex
}
if (form.age !== (info?.age ?? null)) {
payload.age = form.age
}
if (form.birthday !== (info?.birthday || null)) {
payload.birthday = form.birthday
}
if (Object.keys(payload).length > 0) {
await accountStore.updateProfile(payload)
}
visible.value = false
ElMessage.success('个人资料已更新')
} catch {
ElMessage.error('保存失败,请重试')
} finally {
saving.value = false
}
}
/** 弹窗关闭动画结束后重置表单 */
function handleClosed() {
releaseCroppedPreview()
croppedBlob.value = null
avatarRemoved.value = false
formRef.value?.resetFields()
}
</script>
<style scoped lang="scss">
/* ============================================================
* 用户设置弹窗 — 样式
* 设计方向:"精奢游戏大厅",高端棋牌平台质感
* 签名元素:头像渐变光环,呼应 Auth 页面径向渐变品牌语言
* ============================================================ */
.ep-layout {
display: flex;
height: 460px;
}
/* ========== Tab 过渡 ========== */
.tab-fade-enter-active,
.tab-fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.tab-fade-enter-from {
opacity: 0;
transform: translateY(6px);
}
.tab-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
/* ========== 左侧导航 ========== */
.ep-nav {
display: flex;
flex-direction: column;
width: 148px;
flex-shrink: 0;
padding: var(--space-md) var(--space-sm);
background-color: var(--bg-sidebar);
border-right: 1px solid var(--border-light);
gap: 2px;
}
.ep-nav__item {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover {
background-color: var(--bg-surface-hover);
color: var(--text-primary);
}
&.is-active {
background-color: var(--bg-surface);
color: var(--color-primary);
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
}
.ep-nav__icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
flex-shrink: 0;
transition: background-color 0.15s ease;
.ep-nav__item.is-active & {
background-color: var(--color-primary-light);
}
}
.ep-nav__text {
white-space: nowrap;
}
/* 激活态左侧指示条 */
.ep-nav__indicator {
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 0 2px 2px 0;
background: linear-gradient(180deg, var(--color-primary), #818cf8);
}
/* ========== 右侧内容区 ========== */
.ep-content {
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 10px;
&:hover {
background: var(--text-tertiary);
}
}
}
.ep-tab {
padding: var(--space-lg);
}
/* ========== 头像区块(签名元素) ========== */
.ep-avatar-block {
display: flex;
align-items: center;
gap: var(--space-lg);
padding: var(--space-xs) 0 var(--space-lg);
margin-bottom: var(--space-md);
border-bottom: 1px solid var(--border-light);
}
/* 渐变光环 —— 与 Auth 页面径向渐变呼应 */
.ep-avatar__ring {
position: relative;
cursor: pointer;
flex-shrink: 0;
border-radius: 50%;
padding: 3px;
background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.3),
rgba(59, 130, 246, 0.3),
rgba(16, 185, 129, 0.25)
);
transition:
transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.25s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 0 20px rgba(79, 70, 229, 0.15);
}
/* 无头像时不显示光环 = 纯装饰 */
&.has-avatar {
background: linear-gradient(135deg, var(--color-primary), #6366f1, var(--color-info), #34d399);
}
}
.ep-avatar__img {
display: block;
border: 3px solid var(--bg-surface);
border-radius: 50%;
transition: border-color 0.2s ease;
.ep-avatar__ring:hover & {
border-color: rgba(255, 255, 255, 0.6);
}
}
.ep-avatar__overlay {
position: absolute;
inset: 3px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(15, 23, 42, 0.4);
color: #fff;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
.ep-avatar__ring:hover & {
opacity: 1;
}
}
.ep-avatar__meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ep-avatar__name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ep-avatar__hint {
font-size: 12px;
color: var(--text-tertiary);
}
.ep-avatar__btns {
display: flex;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.ep-avatar__remove-btn {
color: var(--text-tertiary);
border-color: var(--border-default);
&:hover {
color: var(--color-danger);
border-color: var(--color-danger);
background-color: var(--color-danger-light);
}
}
.ep-file-input {
display: none;
}
/* ========== 表单 ========== */
.ep-form {
--el-form-label-font-size: 13px;
--el-form-label-color: var(--text-secondary);
}
.ep-form :deep(.el-form-item) {
margin-bottom: 20px;
}
.ep-form :deep(.el-form-item__label) {
font-weight: 500;
}
/* 输入框 & 文本域:自定义焦点环 */
.ep-form :deep(.el-input__wrapper),
.ep-form :deep(.el-textarea__inner) {
border-radius: var(--radius-sm);
box-shadow: 0 0 0 1px var(--border-default) inset;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 0 0 1px var(--text-tertiary) inset;
}
}
.ep-form :deep(.el-input__wrapper.is-focus),
.ep-form :deep(.el-textarea__inner:focus) {
box-shadow:
0 0 0 3px rgba(79, 70, 229, 0.1) inset,
0 0 0 1px var(--color-primary) inset;
}
/* 单选按钮组 */
.ep-radio-group :deep(.el-radio-button__inner) {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 14px;
border-radius: 0;
transition: all 0.2s ease;
}
.ep-radio-group :deep(.el-radio-button:first-child .el-radio-button__inner) {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.ep-radio-group :deep(.el-radio-button:last-child .el-radio-button__inner) {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
/* 年龄展示 */
.ep-age {
display: flex;
align-items: baseline;
gap: 4px;
}
.ep-age__num {
font-size: 28px;
font-weight: 700;
color: var(--color-primary);
line-height: 1;
letter-spacing: -1px;
}
.ep-age__unit {
font-size: 14px;
color: var(--text-secondary);
}
.ep-age__hint {
font-size: 13px;
color: var(--text-tertiary);
}
/* ========== 全局设置占位页 ========== */
.ep-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 380px;
gap: var(--space-sm);
}
.ep-placeholder__icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 50%;
background:
radial-gradient(circle at 30% 30%, rgba(79, 70, 229, 0.08), transparent 70%), var(--bg-sidebar);
color: var(--text-tertiary);
margin-bottom: var(--space-xs);
}
.ep-placeholder__title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.ep-placeholder__desc {
font-size: 13px;
color: var(--text-tertiary);
margin: 0;
}
.ep-feature-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: var(--space-lg);
width: 100%;
max-width: 300px;
}
.ep-feature-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: var(--radius-md);
background-color: var(--bg-sidebar);
border: 1px solid var(--border-light);
cursor: default;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
background-color: var(--bg-surface);
border-color: var(--border-default);
box-shadow: var(--shadow-sm);
}
}
.ep-feature-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
background-color: var(--color-primary-light);
color: var(--color-primary);
flex-shrink: 0;
}
.ep-feature-card__body {
display: flex;
flex-direction: column;
gap: 2px;
}
.ep-feature-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.ep-feature-card__desc {
font-size: 12px;
color: var(--text-tertiary);
}
</style>
<style lang="scss">
/* ============================================================
* 弹窗全局样式(非 scoped覆盖 el-dialog 默认样式)
* ============================================================ */
.edit-profile-dialog {
.el-dialog {
border-radius: var(--radius-lg);
overflow: hidden;
}
.el-dialog__header {
position: relative;
padding: var(--space-lg) var(--space-lg) var(--space-md);
border-bottom: 1px solid var(--border-default);
/* 顶部渐变装饰线 —— 呼应 Auth 页面背景渐变 */
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--color-primary), #818cf8, var(--color-info), #34d399);
}
}
.el-dialog__body {
padding: 0;
}
.el-dialog__footer {
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--border-default);
background-color: var(--bg-sidebar);
}
}
/* 弹窗标题区 */
.ep-dialog__header {
display: flex;
flex-direction: column;
gap: 4px;
}
.ep-dialog__title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
.ep-dialog__subtitle {
font-size: 13px;
color: var(--text-tertiary);
}
/* 弹窗底部 */
.ep-dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.ep-dialog__footer-hint {
font-size: 12px;
color: var(--text-tertiary);
}
.ep-dialog__footer-actions {
display: flex;
gap: var(--space-sm);
}
</style>

View File

@@ -53,11 +53,18 @@ async function fetchGameList() {
try {
const res = await getGameList()
games.value = res.data.list
// 默认选第一个游戏
if (games.value.length > 0 && !activeGameId.value) {
const first = games.value[0]
if (first) {
baseLayoutStore.setActiveGame(first.id, first.icon, first.name)
// 优先恢复已选中的游戏,否则默认选第一个
if (games.value.length > 0) {
const storedGame = games.value.find((g) => g.id === activeGameId.value)
if (storedGame) {
// 已有选中的游戏,同步最新配置到 store
baseLayoutStore.setActiveGame(storedGame.id, storedGame.icon, storedGame.name, storedGame.config)
} else {
// 没有匹配的已选游戏,默认选第一个
const first = games.value[0]
if (first) {
baseLayoutStore.setActiveGame(first.id, first.icon, first.name, first.config)
}
}
}
} finally {
@@ -79,7 +86,7 @@ const activeGame = computed(() => games.value.find((g) => g.id === activeGameId.
function selectGame(id: string) {
const game = games.value.find((g) => g.id === id)
if (game) {
baseLayoutStore.setActiveGame(game.id, game.icon, game.name)
baseLayoutStore.setActiveGame(game.id, game.icon, game.name, game.config)
}
}
</script>
@@ -113,7 +120,9 @@ function selectGame(id: string) {
height: 48px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color 0.15s ease, transform 0.15s ease;
transition:
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
background-color: var(--bg-surface-hover);

View File

@@ -37,16 +37,23 @@
<p class="lobby__empty-text">暂无符合条件的房间</p>
<p class="lobby__empty-hint">换个筛选条件或者自己创建一个房间吧</p>
</div>
<!-- 创建房间弹窗 -->
<CreateRoomDialog v-model="showCreateDialog" @created="fetchRoomList" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import TableCard from './TableCard.vue'
import CreateRoomDialog from './CreateRoomDialog.vue'
import { useRouter } from 'vue-router'
import type { RoomItem } from '@/api/modules/game'
import { useBaseLayoutStore } from '@/stores'
import { storeToRefs } from 'pinia'
import { getRoomList } from '@/api/modules/game'
import { getRoomList, joinRoom } from '@/api/modules/game'
import { sseBus } from '@/stores/sseBus'
import { ElMessage } from 'element-plus'
/** 状态筛选选项 */
const statusFilters = [
@@ -56,6 +63,7 @@ const statusFilters = [
]
const activeFilter = ref('all')
const router = useRouter()
const baseLayoutStore = useBaseLayoutStore()
const { activeGameId } = storeToRefs(baseLayoutStore)
@@ -84,6 +92,35 @@ watch(activeGameId, () => {
/** 首次加载 */
fetchRoomList()
// ===== SSE 实时事件订阅处理函数 =====
/** 新房间创建 → 添加到列表头部 */
function onRoomCreated(room: RoomItem) {
rooms.value.unshift(room)
}
/** 房间状态更新 → 局部替换 */
function onRoomUpdated(room: RoomItem) {
const idx = rooms.value.findIndex((r) => r.id === room.id)
if (idx >= 0) {
rooms.value[idx] = room
}
}
/** 房间移除 → 从列表中删除 */
function onRoomRemoved({ roomId }: { roomId: string }) {
rooms.value = rooms.value.filter((r) => r.id !== roomId)
}
// ===== SSE 实时事件订阅 =====
onMounted(() => {
sseBus.on('room_created', onRoomCreated)
sseBus.on('room_updated', onRoomUpdated)
sseBus.on('room_removed', onRoomRemoved)
})
onUnmounted(() => {
sseBus.off('room_created', onRoomCreated)
sseBus.off('room_updated', onRoomUpdated)
sseBus.off('room_removed', onRoomRemoved)
})
/** 筛选后的房间列表 */
const filteredRooms = computed(() => {
const list = rooms.value
@@ -93,28 +130,36 @@ const filteredRooms = computed(() => {
return list
})
/** 加入房间 */
function handleJoin(roomId: string) {
// eslint-disable-next-line no-console
console.log('加入房间:', roomId)
/** 加入房间(需先调用后端 join API 成为玩家) */
async function handleJoin(roomId: string) {
try {
await joinRoom({ roomId })
router.push(`/room/${roomId}`)
} catch (e: unknown) {
// 后端业务错误通过 ApiResponse.message 传递,需兼容多种 reject 类型
const msg =
(e as { message?: string })?.message ||
(e instanceof Error ? e.message : '加入房间失败,请稍后重试')
ElMessage.error(msg)
}
}
/** 加入指定座位 */
function handleJoinSeat(roomId: string, seatIndex: number) {
// eslint-disable-next-line no-console
console.log('加入房间:', roomId, '座位:', seatIndex)
/** 加入指定座位(后端自动分配座位,调用 join API 即可) */
async function handleJoinSeat(roomId: string, _seatIndex: number) {
await handleJoin(roomId)
}
/** 观战 */
/** 观战(不调用 join API仅建立 WebSocket 连接接收对局数据) */
function handleSpectate(roomId: string) {
// eslint-disable-next-line no-console
console.log('观战房间:', roomId)
router.push(`/room/${roomId}`)
}
/** 创建房间对话框可见性 */
const showCreateDialog = ref(false)
/** 创建房间 */
function handleCreateRoom() {
// eslint-disable-next-line no-console
console.log('创建房间')
showCreateDialog.value = true
}
</script>

View File

@@ -30,7 +30,7 @@
>
<div class="table-card__chair-seat">
<img
:src="room.players[idx].avatar"
:src="getAvatarUrl(room.players[idx].avatar)"
:alt="room.players[idx].nickname"
class="table-card__chair-avatar"
/>
@@ -73,13 +73,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { RoomItem, RoomPlayer, RoomStatus } from '@/api/modules/game'
import { getAvatarUrl } from '@/utils'
const props = defineProps<{
room: RoomItem
}>()
const emit = defineEmits<{
(e: 'join', roomId: string): void
(e: 'join-seat', roomId: string, seatIndex: number): void
(e: 'spectate', roomId: string): void
}>()
@@ -142,13 +142,9 @@ const statusLabelClass = computed(() => {
}
})
/** 点击卡片 → 加入或观战 */
/** 点击卡片 → 观战(不直接加入) */
function handleClick() {
if (props.room.status === 'playing') {
emit('spectate', props.room.id)
} else {
emit('join', props.room.id)
}
emit('spectate', props.room.id)
}
/** 点击空椅子 → 指定座位加入 */

View File

@@ -1,37 +1,223 @@
<template>
<div class="user-bar">
<div class="container">
<el-popover trigger="click" :width="260">
<el-popover
v-model:visible="showUserPanel"
trigger="click"
:width="280"
:show-arrow="false"
:offset="14"
placement="top"
popper-class="user-popover"
>
<template #reference>
<div class="user-info">
<el-badge is-dot :offset="[-4, 20]" class="user-badge">
<el-avatar
:size="28"
shape="circle"
src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
/>
<el-avatar :size="28" shape="circle" :src="userAvatar" />
</el-badge>
<div class="user-name">
<div class="user-title">MarchGlow</div>
<div class="user-state">在线</div>
<div class="user-title">{{ displayName }}</div>
<div class="user-state">{{ currentStatus.label }}</div>
</div>
</div>
</template>
<span>I wish they all could be California girls</span>
<!-- 弹出面板内容 -->
<div class="user-panel">
<!-- 顶部大头像 + 昵称 + 在线状态 -->
<div class="user-panel__header">
<el-avatar :size="56" shape="circle" :src="userAvatar" class="user-panel__avatar" />
<div class="user-panel__identity">
<div class="user-panel__nickname">{{ displayName }}</div>
<div class="user-panel__status">
<span
class="user-panel__status-dot"
:style="{ backgroundColor: currentStatus.color }"
></span>
<span>{{ currentStatus.label }}</span>
</div>
</div>
</div>
<!-- 个性签名 -->
<div class="user-panel__signature">
{{ signatureText }}
</div>
<div class="user-panel__divider"></div>
<!-- 设置区编辑个人资料 + 在线状态 -->
<div class="user-panel__section">
<div class="user-panel__item" @click="handleEditProfile">
<el-icon :size="18"><EditPen /></el-icon>
<span>编辑个人资料</span>
</div>
<!-- 在线状态hover 右侧弹出子菜单 -->
<el-popover
trigger="hover"
placement="right-start"
:show-arrow="false"
:offset="16"
:show-after="100"
:hide-after="200"
:teleported="false"
popper-class="status-submenu-popper"
>
<template #reference>
<div class="user-panel__item">
<el-icon :size="18">
<span
class="user-panel__status-dot"
:style="{ backgroundColor: currentStatus.color }"
></span>
</el-icon>
<span>{{ currentStatus.label }}</span>
<el-icon :size="14" class="user-panel__item-arrow"><ArrowRight /></el-icon>
</div>
</template>
<div class="status-submenu">
<div class="status-submenu__header">在线状态</div>
<div
v-for="status in statusOptions"
:key="status.key"
class="status-submenu__item"
:class="{ 'is-active': currentStatus.key === status.key }"
@click="selectStatus(status)"
>
<el-icon :size="18">
<span
class="user-panel__status-dot"
:style="{ backgroundColor: status.color }"
></span>
</el-icon>
<span>{{ status.label }}</span>
<el-icon
v-if="currentStatus.key === status.key"
:size="14"
class="user-panel__check"
>
<Check />
</el-icon>
</div>
</div>
</el-popover>
</div>
<div class="user-panel__divider"></div>
<!-- 操作区复制账号ID + 退出登录 -->
<div class="user-panel__section">
<div class="user-panel__item" @click="handleCopyUserId">
<el-icon :size="18"><DocumentCopy /></el-icon>
<span>复制账号ID</span>
</div>
<div class="user-panel__item user-panel__item--danger" @click="handleLogout">
<el-icon :size="18"><SwitchButton /></el-icon>
<span>退出登录</span>
</div>
</div>
</div>
</el-popover>
<div class="feature">
<el-icon :size="26"><Setting /></el-icon>
</div>
</div>
<!-- 编辑个人资料弹窗 -->
<EditProfileDialog v-model="showEditProfile" />
</div>
</template>
<script setup lang="ts">
import { Setting } from '@element-plus/icons-vue'
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import { Setting, EditPen, ArrowRight, Check, DocumentCopy, SwitchButton } from '@element-plus/icons-vue'
import { useAccountStore } from '@/stores'
import { UserStatus, type UserStatusType } from '@/api/modules/user'
import { getAvatarUrl } from '@/utils'
import EditProfileDialog from './EditProfileDialog.vue'
/** 在线状态选项 */
interface StatusOption {
key: string
value: UserStatusType
label: string
color: string
}
const STATUS_OPTIONS: StatusOption[] = [
{ key: 'online', value: UserStatus.ONLINE, label: '在线', color: 'var(--color-success)' },
{ key: 'away', value: UserStatus.AWAY, label: '离开', color: 'var(--color-warning)' },
{ key: 'dnd', value: UserStatus.DND, label: '请勿打扰', color: 'var(--color-danger)' },
{ key: 'invisible', value: UserStatus.INVISIBLE, label: '隐身', color: 'var(--text-tertiary)' }
]
const statusMap = new Map<UserStatusType, StatusOption>(STATUS_OPTIONS.map((s) => [s.value, s]))
const accountStore = useAccountStore()
const { accountInfo } = storeToRefs(accountStore)
const showEditProfile = ref(false)
const showUserPanel = ref(false)
const statusOptions = STATUS_OPTIONS
const currentStatus = computed<StatusOption>(() => {
const backendStatus = accountInfo.value?.status
return backendStatus ? (statusMap.get(backendStatus) ?? STATUS_OPTIONS[0]!) : STATUS_OPTIONS[0]!
})
const userAvatar = computed(() => getAvatarUrl(accountInfo.value?.avatar))
const displayName = computed(() => accountInfo.value?.nickName || '未登录')
const signatureText = computed(() => accountInfo.value?.signature || '这个人很懒,什么都没写...')
const handleEditProfile = () => {
// 先关闭 popover再打开弹窗避免浮层残留
showUserPanel.value = false
showEditProfile.value = true
}
const selectStatus = async (option: StatusOption) => {
try {
await accountStore.updateUserStatus(option.value)
} catch {
ElMessage.error('状态更新失败,请重试')
}
}
const handleCopyUserId = () => {
const userId = accountInfo.value?.userId
if (!userId) {
ElMessage.warning('账号ID获取失败请刷新后重试')
return
}
try {
const textarea = document.createElement('textarea')
textarea.value = userId
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
ElMessage.success('账号ID已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
const handleLogout = () => {
accountStore.logout()
ElMessage.success('已退出登录')
}
</script>
<style scoped lang="scss">
/* ========== user-bar 容器 ========== */
.user-bar {
display: flex;
width: 100%;
@@ -47,9 +233,8 @@ import { Setting } from '@element-plus/icons-vue'
width: 12px;
height: 12px;
}
.user-badge :deep(.el-badge__content.is-dot) {
background-color: var(--color-success);
background-color: v-bind('currentStatus.color');
}
.container {
@@ -80,7 +265,6 @@ import { Setting } from '@element-plus/icons-vue'
display: flex;
flex-direction: column;
gap: 2px;
/* 默认内容宽度,超长时上限 160px 触发省略 */
max-width: 160px;
min-width: 0;
}
@@ -122,16 +306,173 @@ import { Setting } from '@element-plus/icons-vue'
background-color: var(--bg-surface-hover);
color: var(--text-primary);
/* 移入时缓动旋转 180°完成后保持 */
:deep(.el-icon) {
transition: transform 0.6s cubic-bezier(0.6, 0, 1, 1);
transform: rotate(180deg);
}
}
/* 移出时无过渡,瞬间复位 */
:deep(.el-icon) {
transition: none;
}
}
/* ========== 弹出面板 ========== */
.user-panel {
display: flex;
flex-direction: column;
overflow: visible;
}
.user-panel__status-dot {
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.user-panel__header {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0 12px;
}
.user-panel__avatar {
flex-shrink: 0;
}
.user-panel__identity {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.user-panel__nickname {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-panel__status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.user-panel__signature {
padding: 8px 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
word-break: break-word;
}
.user-panel__divider {
height: 1px;
background-color: var(--border-light);
margin: 0;
}
.user-panel__check {
color: var(--color-primary);
flex-shrink: 0;
margin-left: auto;
}
/* ========== 菜单项(纯 div无 el-menu ========== */
.user-panel__section {
display: flex;
flex-direction: column;
padding: 4px 0;
}
.user-panel__item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
color: var(--text-primary);
transition: background-color 0.12s ease;
&:hover {
background-color: var(--bg-surface-hover);
}
}
.user-panel__item--danger {
color: var(--color-danger);
&:hover {
background-color: var(--color-danger-light);
}
}
.user-panel__item-arrow {
margin-left: auto;
color: var(--text-tertiary);
flex-shrink: 0;
}
</style>
<!-- scoped 样式 -->
<style lang="scss">
/* 主弹出面板 */
.user-popover {
padding: 8px 12px !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-lg) !important;
border: 1px solid var(--border-default) !important;
overflow: visible !important;
}
/* 在线状态子菜单弹出层 */
.status-submenu-popper {
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-lg) !important;
border: 1px solid var(--border-default) !important;
padding: 4px 8px !important;
}
/* 状态子菜单内部 */
.status-submenu {
display: flex;
flex-direction: column;
min-width: 140px;
}
.status-submenu__header {
padding: 8px 8px 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
}
.status-submenu__item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
color: var(--text-primary);
transition: background-color 0.12s ease;
&:hover {
background-color: var(--bg-surface-hover);
}
}
</style>

View File

@@ -3,14 +3,10 @@ import 'element-plus/dist/index.css'
import { createApp } from 'vue'
import { initPinia } from './stores'
import { setupMock } from './api/mock'
import App from './App.vue'
import router from './router'
/** 开发环境启用 Mock 数据拦截 */
setupMock()
const app = createApp(App)
app.use(initPinia())

View File

@@ -20,7 +20,6 @@ function isPublicPath(path: string): boolean {
*/
function setupCommonGuard(router: Router) {
router.beforeEach(async (to) => {
return true
const accountStore = useAccountStore()
// 已登录用户访问公开路由(如登录页),直接跳转首页
@@ -28,6 +27,15 @@ function setupCommonGuard(router: Router) {
return '/'
}
// 有 token 但未加载用户信息(页面刷新后 Pinia 只持久化了 token
if (accountStore.accessToken && !accountStore.accountInfo) {
try {
await accountStore.fetchUserInfo()
} catch {
// 获取失败token 过期等),由刷新拦截器处理,此处静默
}
}
// accessToken 检查
if (!accountStore.accessToken) {
// 明确声明忽略权限访问权限,则可以访问
@@ -41,8 +49,8 @@ function setupCommonGuard(router: Router) {
return { path: '/auth/login', query: { redirect: to.fullPath }, replace: true }
}
// 没有访问权限,跳转登录页面
if (to.fullPath !== '/auth/login') {
// 没有访问权限,跳转登录页面(使用 to.path 而非 to.fullPath避免 query 参数导致无限重定向)
if (to.path !== '/auth/login') {
return {
path: '/auth/login',
query: { redirect: to.fullPath },
@@ -50,7 +58,8 @@ function setupCommonGuard(router: Router) {
replace: true
}
}
return to
// 登录页面且已带 redirect 参数(由上面逻辑设置),允许进入
return true
}
return true

View File

@@ -7,6 +7,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/layouts/BaseLayout.vue'),
children: []
},
{
path: '/room/:roomId',
name: 'Room',
component: () => import('@/views/game/RoomPage.vue'),
meta: { requiresAuth: true }
},
{
path: '/auth',
name: 'Authentication',

View File

@@ -7,10 +7,20 @@ import {
type LoginRequest,
type RegisterRequest
} from '@/api/modules/account'
import { getUserInfo } from '@/api/modules/user'
import {
getUserInfo,
updateUserInfo as updateUserInfoApi,
uploadAvatar as uploadAvatarApi,
deleteAvatar as deleteAvatarApi,
updateStatus as updateStatusApi,
type UserStatusType,
type UpdateUserInfoRequest
} from '@/api/modules/user'
/** 账户信息 */
export interface AccountState {
/** 用户唯一标识UUID */
userId?: string
/** 用户昵称 */
nickName?: string
/** 头像 */
@@ -23,6 +33,8 @@ export interface AccountState {
sex?: number | null
/** 生日 */
birthday?: string | null
/** 在线状态ONLINE / AWAY / DND / INVISIBLE */
status?: UserStatusType
}
export const useAccountStore = defineStore(
@@ -48,7 +60,7 @@ export const useAccountStore = defineStore(
const res = await loginApi(params)
accessToken.value = res.data.accessToken
refreshToken.value = res.data.refreshToken
// await fetchUserInfo()
await fetchUserInfo()
isTokenValid.value = true
}
@@ -61,7 +73,7 @@ export const useAccountStore = defineStore(
const res = await registerApi(params)
accessToken.value = res.data.accessToken
refreshToken.value = res.data.refreshToken
// await fetchUserInfo()
await fetchUserInfo()
isTokenValid.value = true
}
@@ -105,6 +117,47 @@ export const useAccountStore = defineStore(
return accessToken.value
}
/**
* 更新当前用户的个人资料
* 调用后端接口持久化,并同步更新本地 accountInfo
* @param data - 要更新的字段(所有字段可选)
*/
const updateProfile = async (data: UpdateUserInfoRequest) => {
const res = await updateUserInfoApi(data)
accountInfo.value = res.data
}
/**
* 上传用户头像
* 调用后端接口上传文件并更新 DB同步更新本地 accountInfo
* @param file - 头像文件
*/
const uploadAvatar = async (file: File) => {
const res = await uploadAvatarApi(file)
accountInfo.value = res.data
}
/**
* 删除用户头像
* 调用后端接口删除文件并清空 DB同步更新本地 accountInfo
*/
const removeAvatar = async () => {
const res = await deleteAvatarApi()
accountInfo.value = res.data
}
/**
* 更新当前用户的在线状态
* 调用后端接口持久化,并同步更新本地 accountInfo
* @param status - 新状态值
*/
const updateUserStatus = async (status: UserStatusType) => {
await updateStatusApi({ status })
if (accountInfo.value) {
accountInfo.value = { ...accountInfo.value, status }
}
}
/** 清除所有认证状态 */
const clearAuth = () => {
accessToken.value = ''
@@ -124,7 +177,11 @@ export const useAccountStore = defineStore(
register,
logout,
refreshAccessToken,
fetchUserInfo
fetchUserInfo,
updateProfile,
uploadAvatar,
removeAvatar,
updateUserStatus
}
},
{

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { GameConfig } from '@/api/modules/game'
/** 侧边栏最小宽度 */
export const MIN_SIDEBAR_WIDTH = 200
@@ -19,6 +20,8 @@ export const useBaseLayoutStore = defineStore(
const activeGameIcon = ref<string>('🐍')
/** 当前选中的游戏名称 */
const activeGameName = ref<string>('贪吃蛇')
/** 当前选中游戏的配置(模式、底注、人数等选项) */
const activeGameConfig = ref<GameConfig | null>(null)
/** 侧边栏当前宽度 */
const sidebarWidth = ref<number>(DEFAULT_SIDEBAR_WIDTH)
@@ -36,10 +39,11 @@ export const useBaseLayoutStore = defineStore(
* @param icon - 游戏图标
* @param name - 游戏名称
*/
const setActiveGame = (id: string, icon: string, name: string): void => {
const setActiveGame = (id: string, icon: string, name: string, config: GameConfig): void => {
activeGameId.value = id
activeGameIcon.value = icon
activeGameName.value = name
activeGameConfig.value = config
}
/**
@@ -55,6 +59,7 @@ export const useBaseLayoutStore = defineStore(
activeGameId,
activeGameIcon,
activeGameName,
activeGameConfig,
sidebarWidth,
setTopbarTitle,
setActiveGame,

View File

@@ -1,2 +1,3 @@
export * from './account.ts'
export * from './baseLayout.ts'
export * from './room.ts'

191
src/stores/modules/room.ts Normal file
View File

@@ -0,0 +1,191 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { RoomDetailResponse, RoomPlayerDetail, MoveItem, RoomStatus } from '@/api/modules/game'
export const useRoomStore = defineStore('room', () => {
// ===== 房间基础信息 =====
const roomId = ref<string>('')
const roomName = ref<string>('')
const gameId = ref<string>('')
const gameIcon = ref<string>('')
const gameName = ref<string>('')
const maxPlayers = ref<number>(0)
const mode = ref<string>('')
const stakes = ref<number>(0)
const status = ref<RoomStatus>('waiting')
const creatorId = ref<string>('')
const creatorName = ref<string>('')
const allowSpectate = ref<boolean>(true)
// ===== 玩家信息 =====
const players = ref<RoomPlayerDetail[]>([])
/** 当前用户是否为房主 */
const isOwner = computed(() => creatorId.value === currentUserId.value)
// ===== 对局状态 =====
const boardState = ref<number[][]>([])
const currentTurn = ref<string | null>(null)
const moves = ref<MoveItem[]>([])
const winner = ref<string | null>(null)
const resultType = ref<string | null>(null)
/** 是否对局中 */
const isPlaying = computed(() => status.value === 'playing')
/** 是否已结束 */
const isFinished = computed(() => status.value === 'finished')
// ===== 聊天消息 =====
interface ChatMessage {
userId: string
content: string
timestamp: number
}
const messages = ref<ChatMessage[]>([])
// ===== 当前用户身份 =====
const currentUserId = ref<string>('')
/** 当前用户在房间中的身份player玩家| spectator观战者 */
const myRole = ref<'player' | 'spectator'>('spectator')
/** 当前用户是否为玩家 */
const isPlayer = computed(() => myRole.value === 'player')
// ===== 求和状态 =====
const drawRequested = ref<boolean>(false)
const drawRequestFrom = ref<string | null>(null)
// ===== 观战者 =====
const spectatorCount = ref<number>(0)
/**
* 从房间详情 HTTP 响应初始化 Store
* @param detail - 房间详情响应数据
* @param userId - 当前用户 ID
*/
function initFromDetail(detail: RoomDetailResponse, userId: string) {
roomId.value = detail.roomId
roomName.value = detail.name
gameId.value = detail.gameId
gameIcon.value = detail.gameIcon
gameName.value = detail.gameName
maxPlayers.value = detail.maxPlayers
mode.value = detail.mode
stakes.value = detail.stakes
status.value = detail.status
creatorId.value = detail.creatorId
creatorName.value = detail.creatorName
allowSpectate.value = detail.allowSpectate
players.value = detail.players
currentUserId.value = userId
boardState.value = detail.boardState ?? []
currentTurn.value = detail.currentTurn ?? null
moves.value = detail.moves ?? []
// 判定当前用户是玩家还是观战者
const isPlayerInRoom = detail.players.some((p) => p.userId === userId)
myRole.value = isPlayerInRoom ? 'player' : 'spectator'
}
/** 更新棋盘状态WS move 事件) */
function applyMove(row: number, col: number, playerId: string, moveIndex: number) {
moves.value.push({ moveIndex, playerId, row, col })
if (!boardState.value[row]) {
boardState.value[row] = []
}
// 判定棋子颜色moveIndex 从 1 开始,奇数=黑方(1),偶数=白方(2)
const stone = moveIndex % 2 === 1 ? 1 : 2
boardState.value[row][col] = stone
}
/** 更新当前轮次 */
function setCurrentTurn(playerId: string | null) {
currentTurn.value = playerId
}
/** 添加聊天消息 */
/** 添加聊天消息(自动去重:同用户同内容 2 秒内只保留一条) */
function addMessage(msg: ChatMessage) {
// 去重:检查最后一条消息是否与即将添加的相同
const last = messages.value[messages.value.length - 1]
if (
last &&
last.userId === msg.userId &&
last.content === msg.content &&
Math.abs(last.timestamp - msg.timestamp) < 2000
) {
// 用后端的时间戳更新(更准确),其它不变
last.timestamp = msg.timestamp
return
}
messages.value.push(msg)
}
/** 更新准备状态 */
function updateReady(userId: string, ready: boolean) {
const player = players.value.find((p) => p.userId === userId)
if (player) {
player.ready = ready
}
}
/** 玩家加入 */
function addPlayer(player: RoomPlayerDetail) {
if (!players.value.find((p) => p.userId === player.userId)) {
players.value.push(player)
}
}
/** 玩家离开 */
function removePlayer(userId: string) {
players.value = players.value.filter((p) => p.userId !== userId)
}
/** 对局结束 */
function setGameOver(winnerId: string | null, type: string) {
status.value = 'finished'
winner.value = winnerId
resultType.value = type
}
/** 更新观战人数 */
function setSpectatorCount(count: number) {
spectatorCount.value = count
}
/** 收到求和请求 */
function receiveDrawRequest(fromUserId: string) {
drawRequested.value = true
drawRequestFrom.value = fromUserId
}
/** 清除求和请求状态 */
function clearDrawRequest() {
drawRequested.value = false
drawRequestFrom.value = null
}
/** 重置状态 */
function reset() {
roomId.value = ''
players.value = []
boardState.value = []
currentTurn.value = null
moves.value = []
messages.value = []
winner.value = null
resultType.value = null
drawRequested.value = false
drawRequestFrom.value = null
}
return {
roomId, roomName, gameId, gameIcon, gameName,
maxPlayers, mode, stakes, status, creatorId, creatorName, allowSpectate,
players, isOwner, currentUserId, myRole, isPlayer,
boardState, currentTurn, moves, winner, resultType,
isPlaying, isFinished,
messages,
drawRequested, drawRequestFrom, spectatorCount,
initFromDetail, applyMove, setCurrentTurn, addMessage,
updateReady, addPlayer, removePlayer, setGameOver, setSpectatorCount,
receiveDrawRequest, clearDrawRequest, reset
}
})

40
src/stores/sseBus.ts Normal file
View File

@@ -0,0 +1,40 @@
// webgame-frontend/src/stores/sseBus.ts
import mitt from 'mitt'
import type { RoomItem } from '@/api/modules/game'
/**
* SSE 推送事件类型映射
*
* 每个事件类型对应一个数据类型,类型安全。
* connected / error 由 useSse 连接管理发出,其余由服务端 SSE 事件映射而来。
*/
type SseEvents = {
/** SSE 连接成功 */
connected: void
/** SSE 连接异常 */
error: { message: string; retryCount: number }
/** 新房间创建 */
room_created: RoomItem
/** 房间状态更新 */
room_updated: RoomItem
/** 房间移除 */
room_removed: { roomId: string }
/** 被踢出房间 */
kicked: { roomId: string }
/** 所在房间游戏开始 */
game_started: { roomId: string }
/** 余额变动 */
balance_chg: { balance: number; delta: number }
}
/**
* 全局 SSE 事件总线(发布-订阅模式)
*
* 示例:
* ```ts
* import { sseBus } from '@/stores/sseBus'
* const off = sseBus.on('room_updated', (room) => { ... })
* onUnmounted(off)
* ```
*/
export const sseBus = mitt<SseEvents>()

33
src/utils/avatar.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* 头像 URL 工具
*
* 将后端返回的相对头像路径拼接为完整可访问的 URL。
* 开发环境使用 VITE_BACKEND_URL如 http://localhost:8080
* 生产环境使用相对路径(前后端同域)。
*/
/** 后端基础 URLVite 环境变量,开发时为 http://localhost:8080生产为空 */
const BACKEND_BASE = import.meta.env.VITE_BACKEND_URL || ''
/**
* 获取完整的头像展示 URL
*
* - 空值/undefined 返回空字符串,前端显示默认头像
* - data URI注册默认头像直接返回
* - 已是完整 URLhttp/https 开头)直接返回
* - 相对路径(如上传的自定义头像)拼接后端域名
*
* @param avatarPath - 后端返回的头像路径,如 "/uploads/avatars/xxx.jpg" 或 "data:image/svg+xml;base64,..."
* @returns 可直接用于 <img src> 的完整 URL
*/
export function getAvatarUrl(avatarPath: string | null | undefined): string {
if (!avatarPath) return ''
// data URI 或完整 URL直接返回
if (avatarPath.startsWith('data:') || avatarPath.startsWith('http://') || avatarPath.startsWith('https://')) {
return avatarPath
}
// 相对路径拼接后端域名
return BACKEND_BASE + avatarPath
}

View File

@@ -1,16 +1,25 @@
/** 事件处理器类型 */
type EventHandler = (...args: unknown[]) => void
import mitt from 'mitt'
/** 应用级事件类型定义 */
type AppEventsMap = {
/** 401 未授权 */
'app:unauthorized': void
/** 请求错误(业务错误 / HTTP 错误 / 网络错误) */
'app:request:error': {
type: 'business' | 'http' | 'network'
code?: number
message: string
}
}
/** 应用级事件常量 */
export const AppEvents = {
/** 401 未授权 */
UNAUTHORIZED: 'app:unauthorized',
/** 请求错误(业务错误 / HTTP 错误 / 网络错误) */
REQUEST_ERROR: 'app:request:error'
} as const
/**
* 发布订阅事件总线
* 应用级事件总线(基于 mitt
*
* @example
* ```ts
@@ -23,69 +32,4 @@ export const AppEvents = {
* eventBus.emit(AppEvents.UNAUTHORIZED)
* ```
*/
class EventBus {
/** 事件名 → 处理器集合 */
private handlers = new Map<string, Set<EventHandler>>()
/**
* 注册事件处理器
* @param event - 事件名
* @param handler - 处理器函数
*/
on(event: string, handler: EventHandler): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set())
}
this.handlers.get(event)!.add(handler)
}
/**
* 注册一次性事件处理器(触发后自动取消注册)
* @param event - 事件名
* @param handler - 处理器函数
*/
once(event: string, handler: EventHandler): void {
const wrapper: EventHandler = (...args: unknown[]) => {
handler(...args)
this.off(event, wrapper)
}
this.on(event, wrapper)
}
/**
* 取消注册事件处理器
* @param event - 事件名
* @param handler - 要取消的处理器函数
*/
off(event: string, handler: EventHandler): void {
this.handlers.get(event)?.delete(handler)
}
/**
* 发布事件
* @param event - 事件名
* @param args - 传递给处理器的参数
*/
emit(event: string, ...args: unknown[]): void {
const handlers = this.handlers.get(event)
if (!handlers) return
handlers.forEach((handler) => {
handler(...args)
})
}
/**
* 清除指定事件的所有处理器,或清除所有事件
* @param event - 可选,不传则清除全部
*/
clear(event?: string): void {
if (event) {
this.handlers.delete(event)
} else {
this.handlers.clear()
}
}
}
/** 事件总线单例 */
export const eventBus = new EventBus()
export const eventBus = mitt<AppEventsMap>()

View File

@@ -1,2 +1,3 @@
export * from './eventBus.ts'
export * from './useDebouncedRef.ts'
export * from './avatar.ts'

205
src/views/game/RoomPage.vue Normal file
View File

@@ -0,0 +1,205 @@
<template>
<div class="room-page">
<!-- 顶部信息栏 -->
<RoomTopBar />
<!-- 主体区域 -->
<div class="room-page__main">
<!-- 左侧面板 -->
<RoomSidePanel />
<!-- 中央游戏画布 -->
<div class="room-page__canvas">
<GameCanvas />
</div>
<!-- 右侧面板 -->
<RoomRightPanel />
</div>
<!-- 底部观战栏 -->
<SpectatorBar v-if="roomStore.allowSpectate" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoomStore } from '@/stores'
import { useAccountStore } from '@/stores'
import { getRoomDetail } from '@/api/modules/game'
import { createRoomSocket } from '@/common/websocket/roomSocket'
import type { RoomSocket } from '@/common/websocket/roomSocket'
import type { WsMessage } from '@/api/modules/game'
import RoomTopBar from './components/RoomTopBar.vue'
import RoomSidePanel from './components/RoomSidePanel.vue'
import GameCanvas from './components/GameCanvas.vue'
import RoomRightPanel from './components/RoomRightPanel.vue'
import SpectatorBar from './components/SpectatorBar.vue'
const route = useRoute()
const router = useRouter()
const roomStore = useRoomStore()
const accountStore = useAccountStore()
let socket: RoomSocket | null = null
/**
* WebSocket 代理对象
*
* 在 WS 未建立之前 provide 给子组件的安全代理。
* 子组件调用 send/close/isOpen 时,代理将请求转发到实际 socket。
* WS 未连接时 send() 静默丢弃消息,避免子组件因 inject undefined 而崩溃。
*/
const socketProxy: RoomSocket = {
send(type: string, data?: unknown) {
socket?.send(type, data)
},
close() {
socket?.close()
},
isOpen() {
return socket?.isOpen() ?? false
}
}
// 在 setup 阶段同步 provide确保子组件不会 inject 到 undefined
provide('roomSocket', socketProxy)
onMounted(async () => {
const roomId = route.params.roomId as string
const userId = accountStore.accountInfo?.userId ?? ''
try {
// 1. 获取房间详情(初始快照)
const res = await getRoomDetail(roomId)
roomStore.initFromDetail(res.data, userId)
// 2. 建立 WebSocket 连接(含自动重连)
const token = accountStore.accessToken
socket = createRoomSocket(roomId, token, {
onMessage: handleWsMessage,
/**
* 重连成功 → 重新拉取房间详情恢复完整状态(棋盘、轮次、玩家列表等)
*/
onReconnect: async () => {
try {
const res = await getRoomDetail(roomId)
roomStore.initFromDetail(res.data, userId)
} catch {
// 重连后拉取房间状态失败(房间可能已结束),返回大厅
router.push('/')
}
},
/**
* 重连次数耗尽 → 返回大厅
*/
onReconnectFailed: () => {
router.push('/')
}
})
// socketProxy 已在 setup 阶段同步 provide子组件现在调用 send() 即可
} catch {
// 房间不存在或加载失败,返回大厅
router.push('/')
}
})
onUnmounted(() => {
socket?.close()
roomStore.reset()
})
/** 处理 WebSocket 下行消息 */
function handleWsMessage(msg: WsMessage) {
switch (msg.type) {
case 'board_sync': {
const data = msg.data as { board: number[][]; currentTurn: string }
roomStore.boardState = data.board
roomStore.setCurrentTurn(data.currentTurn)
break
}
case 'move': {
const data = msg.data as { row: number; col: number; playerId: string; moveIndex: number }
roomStore.applyMove(data.row, data.col, data.playerId, data.moveIndex)
break
}
case 'turn_change': {
const data = msg.data as { playerId: string }
roomStore.setCurrentTurn(data.playerId)
break
}
case 'chat_broadcast':
roomStore.addMessage(msg.data as { userId: string; content: string; timestamp: number })
break
case 'game_over': {
const data = msg.data as { winnerId: string | null; resultType: string }
roomStore.setGameOver(data.winnerId, data.resultType)
break
}
case 'player_join': {
const data = msg.data as { userId: string; avatar: string; nickname: string; seatNumber: number; ready: boolean }
roomStore.addPlayer(data)
break
}
case 'player_leave': {
const data = msg.data as { userId: string }
roomStore.removePlayer(data.userId)
break
}
case 'ready_change': {
const data = msg.data as { userId: string; ready: boolean }
roomStore.updateReady(data.userId, data.ready)
break
}
case 'spectator_join': {
const data = msg.data as { count: number }
roomStore.setSpectatorCount(data.count)
break
}
case 'spectator_leave': {
const data = msg.data as { count: number }
roomStore.setSpectatorCount(data.count)
break
}
case 'draw_requested': {
const data = msg.data as { fromUserId: string }
roomStore.receiveDrawRequest(data.fromUserId)
break
}
case 'draw_rejected': {
roomStore.clearDrawRequest()
break
}
}
}
</script>
<style scoped lang="scss">
.room-page {
display: flex;
flex-direction: column;
height: 100lvh;
width: 100lvw;
overflow: hidden;
background-color: var(--bg-body);
}
.room-page__main {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.room-page__canvas {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="game-canvas">
<GomokuBoard v-if="store.gameId === 'gomoku'" />
<!-- 后续其他游戏在此添加 -->
<div v-else class="game-canvas__unsupported">
<p>暂不支持的游戏类型</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
import GomokuBoard from './GomokuBoard.vue'
const store = useRoomStore()
const { gameId } = storeToRefs(store)
</script>
<style scoped lang="scss">
.game-canvas {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.game-canvas__unsupported {
color: var(--text-tertiary);
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,254 @@
<template>
<div class="gomoku-board-wrapper">
<canvas
ref="canvasRef"
class="gomoku-board"
:width="canvasSize"
:height="canvasSize"
@click="handleClick"
@mousemove="handleMouseMove"
@mouseleave="hoverPos = null"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, inject } from 'vue'
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
import type { RoomSocket } from '@/common/websocket/roomSocket'
/** 棋盘常量 */
const BOARD_SIZE = 15
const CELL_SIZE = 36
const PADDING = 24
const STONE_RADIUS = 15
/** Canvas 总尺寸 */
const canvasSize = PADDING * 2 + CELL_SIZE * (BOARD_SIZE - 1)
const store = useRoomStore()
const { boardState, currentTurn, currentUserId, isPlayer, isPlaying } = storeToRefs(store)
const socket = inject<RoomSocket>('roomSocket')!
const canvasRef = ref<HTMLCanvasElement | null>(null)
const hoverPos = ref<{ row: number; col: number } | null>(null)
/** 是否轮到当前用户落子 */
const canPlace = computed(() => {
return isPlayer.value && isPlaying.value && currentTurn.value === currentUserId.value
})
onMounted(() => {
drawBoard()
})
/** 监听棋盘状态变化,触发重绘 */
watch(() => boardState.value, () => {
drawBoard()
}, { deep: true })
watch(hoverPos, () => {
drawBoard()
})
/**
* 完整绘制棋盘:背景 → 网格线 → 星位点 → 棋子 → 悬停预览 → 最后一手标记
*/
function drawBoard() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const size = canvasSize
ctx.clearRect(0, 0, size, size)
// ---- 木质背景 ----
ctx.fillStyle = '#DEB887'
ctx.fillRect(0, 0, size, size)
// ---- 网格线 ----
ctx.strokeStyle = '#8B7355'
ctx.lineWidth = 1
for (let i = 0; i < BOARD_SIZE; i++) {
const pos = PADDING + i * CELL_SIZE
ctx.beginPath()
ctx.moveTo(PADDING, pos)
ctx.lineTo(PADDING + CELL_SIZE * (BOARD_SIZE - 1), pos)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(pos, PADDING)
ctx.lineTo(pos, PADDING + CELL_SIZE * (BOARD_SIZE - 1))
ctx.stroke()
}
// ---- 星位点(天元 + 四角星) ----
const starPoints: [number, number][] = [
[3, 3], [3, 7], [3, 11],
[7, 3], [7, 7], [7, 11],
[11, 3], [11, 7], [11, 11]
]
ctx.fillStyle = '#6B4226'
for (const [r, c] of starPoints) {
ctx.beginPath()
ctx.arc(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, 3, 0, Math.PI * 2)
ctx.fill()
}
// ---- 已落棋子 ----
const board = boardState.value
if (board) {
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
const stone = board[r]?.[c]
if (stone) {
drawStone(ctx, r, c, stone === 1 ? '#1a1a1a' : '#f5f5f5')
}
}
}
}
// ---- 悬停预览 ----
if (hoverPos.value && canPlace.value) {
const { row, col } = hoverPos.value
const board = boardState.value
if (!board?.[row]?.[col]) {
// 根据已有步数判定当前玩家应下的颜色
const moveCount = store.moves.length
const color = moveCount % 2 === 0 ? '#1a1a1a' : '#f5f5f5'
drawStone(ctx, row, col, color, 0.4)
}
}
// ---- 最后一手标记(红色圆圈) ----
const moves = store.moves
if (moves.length > 0) {
const lastMove = moves[moves.length - 1]!
const x = PADDING + lastMove.col * CELL_SIZE
const y = PADDING + lastMove.row * CELL_SIZE
ctx.strokeStyle = '#E74C3C'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(x, y, STONE_RADIUS + 2, 0, Math.PI * 2)
ctx.stroke()
}
}
/**
* 绘制一颗棋子(包含阴影和径向高光)
* @param ctx Canvas 渲染上下文
* @param row 棋子行号0-based
* @param col 棋子列号0-based
* @param color 棋子主色
* @param alpha 透明度(用于悬停预览)
*/
function drawStone(ctx: CanvasRenderingContext2D, row: number, col: number, color: string, alpha = 1) {
const x = PADDING + col * CELL_SIZE
const y = PADDING + row * CELL_SIZE
ctx.save()
ctx.globalAlpha = alpha
// 棋子阴影
ctx.shadowColor = 'rgba(0,0,0,0.3)'
ctx.shadowBlur = 3
ctx.shadowOffsetX = 1
ctx.shadowOffsetY = 1
// 棋子底色
ctx.beginPath()
ctx.arc(x, y, STONE_RADIUS, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
// 径向高光
ctx.shadowColor = 'transparent'
const gradient = ctx.createRadialGradient(
x - STONE_RADIUS * 0.3, y - STONE_RADIUS * 0.3, STONE_RADIUS * 0.1,
x, y, STONE_RADIUS
)
if (color === '#1a1a1a') {
gradient.addColorStop(0, '#555')
gradient.addColorStop(1, '#1a1a1a')
} else {
gradient.addColorStop(0, '#fff')
gradient.addColorStop(1, '#d0d0d0')
}
ctx.fillStyle = gradient
ctx.fill()
ctx.restore()
}
/**
* 点击棋盘,在最近交点落子
*/
function handleClick(event: MouseEvent) {
if (!canPlace.value) return
const { row, col } = getBoardPos(event)
if (row === -1) return
// 校验该位置是否已有棋子
const board = boardState.value
if (board?.[row]?.[col]) return
socket.send('place_stone', { row, col })
}
/** 鼠标移动时更新悬停位置 */
function handleMouseMove(event: MouseEvent) {
hoverPos.value = getBoardPos(event)
}
/**
* 将鼠标事件坐标转换为棋盘交点坐标(带容差检测)
* @returns { row, col },超出范围或距离太远时返回 { -1, -1 }
*/
function getBoardPos(event: MouseEvent): { row: number; col: number } {
const canvas = canvasRef.value
if (!canvas) return { row: -1, col: -1 }
const rect = canvas.getBoundingClientRect()
// Canvas 物理尺寸与 CSS 尺寸可能存在缩放,需换算
const scaleX = canvasSize / rect.width
const scaleY = canvasSize / rect.height
const x = (event.clientX - rect.left) * scaleX
const y = (event.clientY - rect.top) * scaleY
const col = Math.round((x - PADDING) / CELL_SIZE)
const row = Math.round((y - PADDING) / CELL_SIZE)
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
return { row: -1, col: -1 }
}
// 校验点击位置是否接近交点(容差 = STONE_RADIUS
const cx = PADDING + col * CELL_SIZE
const cy = PADDING + row * CELL_SIZE
const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
if (dist > STONE_RADIUS) {
return { row: -1, col: -1 }
}
return { row, col }
}
</script>
<style scoped lang="scss">
.gomoku-board-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.gomoku-board {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
cursor: pointer;
image-rendering: auto;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="move-history">
<div v-if="store.moves.length === 0" class="move-history__empty">
暂无走棋记录
</div>
<div v-else class="move-history__list">
<div
v-for="move in store.moves"
:key="move.moveIndex"
class="move-history__item"
>
<span class="move-history__index">{{ move.moveIndex }}</span>
<span class="move-history__player">
{{ move.playerId === store.currentUserId ? '我' : getPlayerName(move.playerId) }}
</span>
<span class="move-history__coord">
({{ move.row }}, {{ move.col }})
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
const store = useRoomStore()
const { players } = storeToRefs(store)
/**
* 根据用户 ID 获取玩家名称
* @param userId 用户 ID
* @returns 玩家昵称或"未知"
*/
function getPlayerName(userId: string): string {
const player = players.value.find((p) => p.userId === userId)
return player?.nickname ?? '未知'
}
</script>
<style scoped lang="scss">
.move-history {
height: 100%;
overflow-y: auto;
padding: 10px;
}
.move-history__empty {
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
margin-top: 32px;
}
.move-history__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.move-history__item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
background-color: var(--bg-body);
&:nth-child(even) {
background-color: var(--bg-surface);
}
}
.move-history__index {
font-weight: 600;
color: var(--text-tertiary);
width: 24px;
}
.move-history__player {
flex: 1;
color: var(--text-primary);
font-weight: 500;
}
.move-history__coord {
color: var(--text-secondary);
font-family: monospace;
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<div class="room-actions">
<!-- 等待中准备/取消准备按钮 -->
<template v-if="store.status === 'waiting' && store.isPlayer">
<button
class="room-actions__btn"
:class="isReady ? 'room-actions__btn--cancel' : 'room-actions__btn--ready'"
@click="toggleReady"
>
{{ isReady ? '取消准备' : '准备' }}
</button>
<button
v-if="store.isOwner"
class="room-actions__btn room-actions__btn--start"
@click="startGame"
>
开始游戏
</button>
</template>
<!-- 对局中认输求和 -->
<template v-if="store.isPlaying && store.isPlayer">
<!-- 收到对手求和请求 -->
<template v-if="store.drawRequested && store.drawRequestFrom !== currentUserId">
<div class="room-actions__draw-notice">
{{ opponentName }} 向你发起求和请求
</div>
<button class="room-actions__btn room-actions__btn--accept" @click="respondDraw(true)">
同意求和
</button>
<button class="room-actions__btn room-actions__btn--reject" @click="respondDraw(false)">
拒绝求和
</button>
</template>
<!-- 正常操作 -->
<template v-else>
<button class="room-actions__btn room-actions__btn--danger" @click="resign">
认输
</button>
<button class="room-actions__btn room-actions__btn--warn" @click="requestDraw">
求和
</button>
</template>
</template>
<!-- 离开房间始终显示 -->
<button class="room-actions__btn room-actions__btn--leave" @click="leaveRoom">
离开房间
</button>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
import { ElMessageBox } from 'element-plus'
import type { RoomSocket } from '@/common/websocket/roomSocket'
const store = useRoomStore()
const { players, currentUserId } = storeToRefs(store)
const router = useRouter()
/** 通过 inject 获取 WS 连接(父组件 provide */
const socket = inject<RoomSocket>('roomSocket')!
/** 当前用户是否已准备 */
const isReady = computed(() => {
const me = players.value.find((p) => p.userId === currentUserId.value)
return me?.ready ?? false
})
/** 对手昵称 */
const opponentName = computed(() => {
const opponent = players.value.find((p) => p.userId !== currentUserId.value)
return opponent?.nickname ?? '对手'
})
function toggleReady() {
socket.send('ready')
}
function startGame() {
socket.send('start')
}
function resign() {
socket.send('resign')
}
function requestDraw() {
socket.send('draw_request')
}
function respondDraw(accept: boolean) {
socket.send('draw_response', { accept })
store.clearDrawRequest()
}
/** 离开房间(需二次确认) */
async function leaveRoom() {
const message =
store.isPlaying
? '对局进行中,离开将视为认输,确定要离开吗?'
: '确定要离开房间吗?'
try {
await ElMessageBox.confirm(message, '离开房间', {
confirmButtonText: '确定离开',
cancelButtonText: '取消',
type: store.isPlaying ? 'warning' : 'info'
})
} catch {
// 用户取消
return
}
socket.send('leave')
socket.close()
router.push('/')
}
</script>
<style scoped lang="scss">
.room-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
padding-top: 16px;
}
.room-actions__btn {
width: 100%;
padding: 10px 0;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&--ready {
background-color: var(--color-primary);
color: var(--text-inverse);
&:hover { background-color: var(--color-primary-hover); }
}
&--cancel {
background-color: var(--bg-surface-hover);
color: var(--text-secondary);
&:hover { background-color: var(--border-default); }
}
&--start {
background-color: var(--color-success);
color: white;
&:hover { opacity: 0.9; }
}
&--danger {
background-color: transparent;
color: var(--color-danger);
border: 1px solid var(--color-danger);
&:hover { background-color: var(--color-danger); color: white; }
}
&--warn {
background-color: transparent;
color: var(--color-warning);
border: 1px solid var(--color-warning);
&:hover { background-color: var(--color-warning); color: white; }
}
&--leave {
background-color: transparent;
color: var(--text-tertiary);
border: 1px solid var(--border-default);
&:hover { border-color: var(--text-secondary); color: var(--text-primary); }
}
&--accept {
background-color: var(--color-success);
color: white;
&:hover { opacity: 0.9; }
}
&--reject {
background-color: transparent;
color: var(--color-danger);
border: 1px solid var(--color-danger);
&:hover { background-color: var(--color-danger); color: white; }
}
}
.room-actions__draw-notice {
padding: 10px 12px;
background-color: rgba(250, 173, 20, 0.1);
border: 1px solid var(--color-warning);
border-radius: 8px;
font-size: 13px;
color: var(--color-warning);
text-align: center;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<div class="room-chat">
<!-- 消息列表 -->
<div ref="messagesContainer" class="room-chat__messages">
<div
v-for="(msg, idx) in store.messages"
:key="idx"
class="room-chat__msg"
:class="{ 'room-chat__msg--self': msg.userId === store.currentUserId }"
>
<div class="room-chat__msg-meta">
<span class="room-chat__msg-user">{{
msg.userId === store.currentUserId ? '我' : getUserName(msg.userId)
}}</span>
<span class="room-chat__msg-time">{{ formatTime(msg.timestamp) }}</span>
</div>
<div class="room-chat__msg-bubble">{{ msg.content }}</div>
</div>
<div v-if="store.messages.length === 0" class="room-chat__empty">暂无消息</div>
</div>
<!-- 输入区 -->
<div class="room-chat__input-area">
<input
v-model="input"
class="room-chat__input"
placeholder="输入消息..."
:maxlength="CHAT_MAX_LENGTH"
@keyup.enter="sendMessage"
/>
<span
class="room-chat__char-count"
:class="{ 'room-chat__char-count--warn': input.length > CHAT_MAX_LENGTH * 0.8 }"
>
{{ input.length }}/{{ CHAT_MAX_LENGTH }}
</span>
<button class="room-chat__send-btn" @click="sendMessage">发送</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, inject, nextTick, watch } from 'vue'
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
import type { RoomSocket } from '@/common/websocket/roomSocket'
/** 聊天最大字符数(与后端 CHAT_MAX_LENGTH 保持一致) */
const CHAT_MAX_LENGTH = 500
const store = useRoomStore()
const { messages, players } = storeToRefs(store)
const socket = inject<RoomSocket>('roomSocket')
/** 发送消息空安全socket 尚未建立时静默丢弃) */
function safeSend(type: string, data?: unknown) {
socket?.send(type, data)
}
/** 输入框内容 */
const input = ref('')
/** 消息列表容器引用,用于自动滚动 */
const messagesContainer = ref<HTMLElement>()
/**
* 监听消息列表变化,自动滚动到底部
*/
watch(
() => messages.value.length,
async () => {
await nextTick()
scrollToBottom()
}
)
/**
* 滚动到消息列表底部
*/
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
/** 发送聊天消息(乐观更新:先显示在本地,再通过 WS 发送) */
function sendMessage() {
const content = input.value.trim()
if (!content || content.length > CHAT_MAX_LENGTH) return
// 乐观更新:立即添加到本地消息列表
const localMsg = {
userId: store.currentUserId,
content,
timestamp: Date.now()
}
store.addMessage(localMsg)
input.value = ''
// 通过 WS 发送,广播回来自动去重
safeSend('chat', { content })
}
/**
* 根据用户 ID 获取显示名称
* @param userId 用户 ID
* @returns 用户昵称或"未知用户"
*/
function getUserName(userId: string): string {
const player = players.value.find((p) => p.userId === userId)
return player?.nickname ?? '观战者'
}
/**
* 格式化时间戳为 HH:mm
* @param ts 毫秒时间戳
* @returns 格式化后的时间字符串
*/
function formatTime(ts: number): string {
if (!ts) return ''
const d = new Date(ts)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${hh}:${mm}`
}
</script>
<style scoped lang="scss">
.room-chat {
display: flex;
flex-direction: column;
height: 100%;
}
.room-chat__messages {
flex: 1;
padding: 10px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.room-chat__msg {
display: flex;
flex-direction: column;
gap: 2px;
max-width: 85%;
&--self {
align-self: flex-end;
.room-chat__msg-bubble {
background-color: var(--color-primary);
color: var(--text-inverse);
border-radius: 10px 10px 2px 10px;
}
.room-chat__msg-meta {
flex-direction: row-reverse;
}
}
&:not(&--self) {
align-self: flex-start;
.room-chat__msg-bubble {
background-color: var(--bg-surface-hover);
color: var(--text-primary);
border-radius: 10px 10px 10px 2px;
}
}
}
.room-chat__msg-meta {
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px;
}
.room-chat__msg-user {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
}
.room-chat__msg-time {
font-size: 10px;
color: var(--text-tertiary);
}
.room-chat__msg-bubble {
padding: 6px 10px;
font-size: 13px;
line-height: 1.5;
word-break: break-word;
}
.room-chat__empty {
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
margin-top: 32px;
}
.room-chat__input-area {
display: flex;
align-items: center;
gap: 6px;
padding: 10px;
border-top: 1px solid var(--border-light);
}
.room-chat__input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-default);
border-radius: 6px;
font-size: 13px;
background-color: var(--bg-surface);
color: var(--text-primary);
outline: none;
&:focus {
border-color: var(--color-primary);
}
}
.room-chat__char-count {
font-size: 11px;
color: var(--text-tertiary);
min-width: 42px;
text-align: right;
transition: color 0.15s ease;
&--warn {
color: var(--color-warning);
}
}
.room-chat__send-btn {
padding: 6px 14px;
border: none;
border-radius: 6px;
background-color: var(--color-primary);
color: var(--text-inverse);
font-size: 13px;
cursor: pointer;
white-space: nowrap;
&:hover {
background-color: var(--color-primary-hover);
}
&:active {
transform: scale(0.97);
}
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="room-right-panel">
<div class="room-right-panel__tabs">
<button
:class="{ 'room-right-panel__tab--active': activeTab === 'chat' }"
class="room-right-panel__tab"
@click="activeTab = 'chat'"
>
聊天
</button>
<button
:class="{ 'room-right-panel__tab--active': activeTab === 'moves' }"
class="room-right-panel__tab"
@click="activeTab = 'moves'"
>
记录
</button>
</div>
<div class="room-right-panel__content">
<RoomChat v-if="activeTab === 'chat'" />
<MoveHistory v-else />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import RoomChat from './RoomChat.vue'
import MoveHistory from './MoveHistory.vue'
/** 当前选中的标签页 */
const activeTab = ref<'chat' | 'moves'>('chat')
</script>
<style scoped lang="scss">
.room-right-panel {
display: flex;
flex-direction: column;
width: 320px;
border-left: 1px solid var(--border-default);
background-color: var(--bg-sidebar);
flex-shrink: 0;
}
.room-right-panel__tabs {
display: flex;
border-bottom: 1px solid var(--border-light);
}
.room-right-panel__tab {
flex: 1;
padding: 10px 0;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
border-bottom: 2px solid transparent;
&:hover {
color: var(--text-primary);
}
&--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
.room-right-panel__content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div class="room-side-panel">
<!-- 对手区域非当前用户的第一个玩家 -->
<div v-if="opponent" class="room-side-panel__player-card">
<img :src="getAvatarUrl(opponent.avatar)" class="room-side-panel__avatar" />
<div class="room-side-panel__player-info">
<div class="room-side-panel__nickname">
{{ opponent.nickname }}
<span v-if="opponent.userId === store.creatorId" class="room-side-panel__crown">👑</span>
</div>
<div class="room-side-panel__role">
{{ opponentReady ? '✅ 已准备' : '⏳ 未准备' }}
</div>
</div>
</div>
<!-- 对局信息 -->
<div v-if="store.isPlaying || store.isFinished" class="room-side-panel__game-info">
<div class="room-side-panel__info-row">
<span>步数</span>
<span>{{ store.moves.length }}</span>
</div>
<div class="room-side-panel__info-row">
<span>当前轮次</span>
<span :class="{ 'room-side-panel__turn--me': isMyTurn }">
{{ isMyTurn ? '轮到你了' : '等待对手' }}
</span>
</div>
</div>
<!-- 我的信息 -->
<div v-if="me" class="room-side-panel__player-card room-side-panel__player-card--me">
<img :src="getAvatarUrl(me.avatar)" class="room-side-panel__avatar" />
<div class="room-side-panel__player-info">
<div class="room-side-panel__nickname">
{{ me.nickname }}
<span v-if="me.userId === store.creatorId" class="room-side-panel__crown">👑</span>
</div>
<div class="room-side-panel__role">
{{ meReady ? '✅ 已准备' : '⏳ 未准备' }}
</div>
</div>
</div>
<!-- 观战提示 -->
<div v-if="!store.isPlayer" class="room-side-panel__spectator-note">
👀 观战中
</div>
<!-- 对局结果 -->
<div v-if="store.isFinished && store.resultType" class="room-side-panel__result">
<div v-if="store.resultType === 'draw'" class="room-side-panel__result-text">
🤝 平局
</div>
<div
v-else
class="room-side-panel__result-text"
:class="isWinner ? 'room-side-panel__result-text--win' : 'room-side-panel__result-text--lose'"
>
{{ isWinner ? '🎉 你赢了!' : '💀 你输了' }}
</div>
</div>
<!-- 操作按钮组 -->
<RoomActions />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
import { getAvatarUrl } from '@/utils'
import RoomActions from './RoomActions.vue'
const store = useRoomStore()
const { players, currentUserId, currentTurn, winner } = storeToRefs(store)
/** 当前玩家自己 */
const me = computed(() => players.value.find((p) => p.userId === currentUserId.value) ?? null)
/** 对手(非当前用户的第一个玩家) */
const opponent = computed(() => players.value.find((p) => p.userId !== currentUserId.value) ?? null)
/** 当前用户是否已准备 */
const meReady = computed(() => me.value?.ready ?? false)
const opponentReady = computed(() => opponent.value?.ready ?? false)
/** 当前是否轮到我了 */
const isMyTurn = computed(() => currentTurn.value === currentUserId.value)
/** 当前用户是否赢了 */
const isWinner = computed(() => winner.value === currentUserId.value)
</script>
<style scoped lang="scss">
.room-side-panel {
display: flex;
flex-direction: column;
width: 240px;
padding: 16px;
gap: 16px;
background-color: var(--bg-sidebar);
border-right: 1px solid var(--border-default);
flex-shrink: 0;
overflow-y: auto;
}
.room-side-panel__player-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background-color: var(--bg-surface);
border-radius: 10px;
border: 1px solid var(--border-light);
&--me {
border-color: var(--color-primary-light);
background-color: var(--color-primary-light);
}
}
.room-side-panel__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.room-side-panel__player-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.room-side-panel__nickname {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 4px;
}
.room-side-panel__crown {
font-size: 12px;
}
.room-side-panel__role {
font-size: 12px;
color: var(--text-secondary);
}
.room-side-panel__game-info {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
background-color: var(--bg-surface);
border-radius: 8px;
border: 1px solid var(--border-light);
}
.room-side-panel__info-row {
display: flex;
justify-content: space-between;
font-size: 13px;
span:first-child {
color: var(--text-secondary);
}
span:last-child {
font-weight: 600;
color: var(--text-primary);
}
}
.room-side-panel__turn--me {
color: var(--color-primary) !important;
animation: pulse 1.5s ease-in-out infinite;
}
.room-side-panel__spectator-note {
text-align: center;
font-size: 14px;
color: var(--text-tertiary);
padding: 12px;
}
.room-side-panel__result {
text-align: center;
padding: 12px;
}
.room-side-panel__result-text {
font-size: 18px;
font-weight: 700;
&--win {
color: var(--color-success);
}
&--lose {
color: var(--color-danger);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<div class="room-topbar">
<button class="room-topbar__back" @click="goBack">
<span class="room-topbar__back-icon">&larr;</span>
<span>大厅</span>
</button>
<div class="room-topbar__info">
<span class="room-topbar__game-icon">{{ store.gameIcon }}</span>
<span class="room-topbar__game-name">{{ store.gameName }}</span>
<span class="room-topbar__divider">&middot;</span>
<span class="room-topbar__mode">{{ store.mode }}</span>
<span class="room-topbar__divider">&middot;</span>
<span>底注 {{ store.stakes }}</span>
</div>
<div class="room-topbar__status">
<span class="room-topbar__room-name">{{ store.roomName }}</span>
<span class="room-topbar__status-badge" :class="statusClass">
{{ statusLabel }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
const router = useRouter()
const store = useRoomStore()
const { status: roomStatus } = storeToRefs(store)
const statusClass = computed(() => {
switch (roomStatus.value) {
case 'waiting':
return 'room-topbar__status-badge--waiting'
case 'playing':
return 'room-topbar__status-badge--playing'
case 'finished':
return 'room-topbar__status-badge--finished'
default:
return ''
}
})
const statusLabel = computed(() => {
switch (roomStatus.value) {
case 'waiting':
return '等待中'
case 'playing':
return '对局中'
case 'finished':
return '已结束'
default:
return ''
}
})
function goBack() {
router.push('/')
}
</script>
<style scoped lang="scss">
.room-topbar {
display: flex;
align-items: center;
height: 48px;
padding: 0 16px;
background-color: var(--bg-surface);
border-bottom: 1px solid var(--border-default);
gap: 24px;
flex-shrink: 0;
}
.room-topbar__back {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid var(--border-default);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
color: var(--text-primary);
border-color: var(--color-primary);
background-color: var(--color-primary-light);
}
}
.room-topbar__back-icon {
font-size: 14px;
}
.room-topbar__info {
display: flex;
align-items: baseline;
gap: 6px;
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
.room-topbar__game-icon {
font-size: 16px;
}
.room-topbar__game-name {
font-size: 15px;
}
.room-topbar__divider {
color: var(--text-tertiary);
font-weight: 400;
}
.room-topbar__mode {
color: var(--text-secondary);
font-weight: 500;
}
.room-topbar__status {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.room-topbar__room-name {
font-size: 13px;
color: var(--text-secondary);
}
.room-topbar__status-badge {
padding: 3px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
&--waiting {
color: var(--color-success);
background-color: var(--color-success-light);
}
&--playing {
color: var(--color-warning);
background-color: var(--color-warning-light);
}
&--finished {
color: var(--text-tertiary);
background-color: var(--bg-body);
}
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="spectator-bar">
<span class="spectator-bar__icon">👀</span>
<span class="spectator-bar__text">
观战中{{ spectatorCount }}
</span>
</div>
</template>
<script setup lang="ts">
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
const store = useRoomStore()
const { spectatorCount } = storeToRefs(store)
</script>
<style scoped lang="scss">
.spectator-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
background-color: var(--bg-surface);
border-top: 1px solid var(--border-light);
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
.spectator-bar__icon {
font-size: 14px;
}
</style>