Compare commits
52 Commits
33c2b31117
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 94dfa907f9 | |||
| e1faf45683 | |||
| b85c3cbd60 | |||
| dd97f9cb70 | |||
| 7b3d31a57c | |||
| 3f6d3f2761 | |||
| 112ab3312d | |||
| 9e36c91e13 | |||
| 45f4d69446 | |||
| 7c12697579 | |||
| 0fae4feadb | |||
| f34dbeccb2 | |||
| 88ab464d6a | |||
| 47fb151ea8 | |||
| f937b5cdb3 | |||
| cf1a14bf24 | |||
| 78c172cce0 | |||
| eecec1aae1 | |||
| 2a2c941d32 | |||
| 5d45ad037f | |||
| f91b76084a | |||
| b435256157 | |||
| b99d397d1f | |||
| c3c26f2935 | |||
| 6c7d1ce128 | |||
| 16d3670b5d | |||
| 0d76d7a6de | |||
| e2068ad95e | |||
| 205b60711a | |||
| 0383572d39 | |||
| 70c4bab3f6 | |||
| fe8918c06f | |||
| f8db0ea4d6 | |||
| dfc54edd83 | |||
| aa6f0a002e | |||
| 3354623dd9 | |||
| 28b27bf127 | |||
| ec66afaa72 | |||
| 1a175bfdd0 | |||
| 349a50339f | |||
| 7d2d6707b7 | |||
| 0b7a89c06b | |||
| 676f4d5e2d | |||
| 9bd85a4e43 | |||
| deae38fb05 | |||
| 646bb92179 | |||
| da12f4f5ef | |||
| a2057da8fa | |||
| dd8157b723 | |||
| 3bd6e972ea | |||
| c1e292ea18 | |||
| 3bc3cce62a |
3
.env.development
Normal file
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# 后端服务地址(开发环境)
|
||||
# 头像等静态资源通过此地址访问,生产环境留空即使用相对路径
|
||||
VITE_BACKEND_URL=http://localhost:8080
|
||||
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"oxc.oxc-vscode"
|
||||
]
|
||||
}
|
||||
32
components.d.ts
vendored
32
components.d.ts
vendored
@@ -12,35 +12,63 @@ 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']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
UserBar: typeof import('./src/components/User/UserBar.vue')['default']
|
||||
TableCard: typeof import('./src/components/baseLayouts/TableCard.vue')['default']
|
||||
UserBar: typeof import('./src/components/baseLayouts/UserBar.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
// 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']
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const UserBar: typeof import('./src/components/User/UserBar.vue')['default']
|
||||
const TableCard: typeof import('./src/components/baseLayouts/TableCard.vue')['default']
|
||||
const UserBar: typeof import('./src/components/baseLayouts/UserBar.vue')['default']
|
||||
}
|
||||
5
env.d.ts
vendored
5
env.d.ts
vendored
@@ -1 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** 是否启用 Mock 拦截器 */
|
||||
readonly VITE_USE_MOCK: string
|
||||
}
|
||||
|
||||
150
package-lock.json
generated
150
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
121
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
19
src/App.vue
19
src/App.vue
@@ -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/sse'
|
||||
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>
|
||||
|
||||
@@ -53,11 +53,15 @@ export interface LoginResponse {
|
||||
* 使用用户名和密码进行登录认证,成功返回双 Token。
|
||||
* 该接口无需登录即可调用。
|
||||
*
|
||||
* 注意:设置 __skipRefresh=true,避免密码错误返回 HTTP 401 时触发无意义的刷新流程。
|
||||
*
|
||||
* @param req - 登录请求参数
|
||||
* @returns 包含 accessToken 和 refreshToken 的响应
|
||||
*/
|
||||
export function login(req: LoginRequest) {
|
||||
return requestClient.post<LoginResponse>('/account/login', req)
|
||||
return requestClient.post<LoginResponse>('/account/login', req, {
|
||||
__skipRefresh: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,11 +71,15 @@ export function login(req: LoginRequest) {
|
||||
* 注册成功后自动登录并返回双 Token。
|
||||
* 该接口无需登录即可调用。
|
||||
*
|
||||
* 注意:设置 __skipRefresh=true,避免意外 401 时触发无意义的刷新流程。
|
||||
*
|
||||
* @param req - 注册请求参数
|
||||
* @returns 包含 accessToken 和 refreshToken 的响应
|
||||
*/
|
||||
export function register(req: RegisterRequest) {
|
||||
return requestClient.post<LoginResponse>('/account/register', req)
|
||||
return requestClient.post<LoginResponse>('/account/register', req, {
|
||||
__skipRefresh: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
3
src/api/modules/game/index.ts
Normal file
3
src/api/modules/game/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './types'
|
||||
export * from './list'
|
||||
export * from './room'
|
||||
14
src/api/modules/game/list.ts
Normal file
14
src/api/modules/game/list.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { requestClient } from '@/api/request'
|
||||
import type { GetGameListResponse } from './types'
|
||||
|
||||
/**
|
||||
* 获取游戏列表
|
||||
*
|
||||
* 返回所有可用的游戏信息,包括图标、名称、描述和玩法介绍。
|
||||
* 该接口无需登录即可调用。
|
||||
*
|
||||
* @returns 游戏列表
|
||||
*/
|
||||
export function getGameList() {
|
||||
return requestClient.get<GetGameListResponse>('/game/list')
|
||||
}
|
||||
53
src/api/modules/game/room.ts
Normal file
53
src/api/modules/game/room.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { requestClient } from '@/api/request'
|
||||
import type {
|
||||
CreateRoomRequest,
|
||||
CreateRoomResponse,
|
||||
GetRoomListRequest,
|
||||
GetRoomListResponse,
|
||||
JoinRoomRequest,
|
||||
RoomDetailResponse,
|
||||
RoomItem
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* 获取指定游戏的房间列表
|
||||
*
|
||||
* @param params - 查询参数,包含 gameId 和可选的 status 筛选
|
||||
* @returns 房间列表
|
||||
*/
|
||||
export function getRoomList(params: GetRoomListRequest) {
|
||||
return requestClient.get<GetRoomListResponse>(
|
||||
'/room/list',
|
||||
params as unknown as Record<string, unknown>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建房间
|
||||
*
|
||||
* @param data - 创建房间参数
|
||||
* @returns 新建的房间信息
|
||||
*/
|
||||
export function createRoom(data: CreateRoomRequest) {
|
||||
return requestClient.post<CreateRoomResponse>('/room/create', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入房间
|
||||
*
|
||||
* @param data - 加入房间参数(roomId 和可选的 password)
|
||||
* @returns 更新后的房间信息
|
||||
*/
|
||||
export function joinRoom(data: JoinRoomRequest) {
|
||||
return requestClient.post<RoomItem>('/room/join', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间详情
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @returns 房间详情
|
||||
*/
|
||||
export function getRoomDetail(roomId: string) {
|
||||
return requestClient.get<RoomDetailResponse>(`/room/${roomId}`)
|
||||
}
|
||||
187
src/api/modules/game/types.ts
Normal file
187
src/api/modules/game/types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 游戏大厅相关类型定义
|
||||
*
|
||||
* 此文件为前后端接口契约,后端应按此结构提供数据。
|
||||
*/
|
||||
|
||||
/** 游戏信息 */
|
||||
export interface GameItem {
|
||||
/** 游戏唯一标识 */
|
||||
id: string
|
||||
/** 图标(emoji) */
|
||||
icon: string
|
||||
/** 游戏名称 */
|
||||
name: string
|
||||
/** 简短描述 */
|
||||
description: string
|
||||
/** 玩法介绍 */
|
||||
gameplay: string
|
||||
/** 游戏配置(模式、人数、底注等选项) */
|
||||
config: GameConfig
|
||||
}
|
||||
|
||||
/** 房间内玩家 */
|
||||
export interface RoomPlayer {
|
||||
/** 头像 URL */
|
||||
avatar: string
|
||||
/** 昵称 */
|
||||
nickname: string
|
||||
}
|
||||
|
||||
/** 游戏配置(随游戏列表接口一起返回) */
|
||||
export interface GameConfig {
|
||||
/** 最大玩家数 */
|
||||
maxPlayers: number
|
||||
/** 可选玩法模式列表 */
|
||||
modes: string[]
|
||||
/** 可选底注档位列表 */
|
||||
stakesOptions: number[]
|
||||
}
|
||||
|
||||
/** 房间状态 */
|
||||
export type RoomStatus = 'waiting' | 'playing' | 'finished'
|
||||
|
||||
/** 房间(桌台)信息 */
|
||||
export interface RoomItem {
|
||||
/** 房间唯一标识 */
|
||||
id: string
|
||||
/** 桌号显示名,如 "桌 01" */
|
||||
name: string
|
||||
/** 游戏图标 emoji */
|
||||
gameIcon: string
|
||||
/** 游戏 ID */
|
||||
gameId: string
|
||||
/** 最大玩家数 */
|
||||
maxPlayers: number
|
||||
/** 已入座玩家列表 */
|
||||
players: RoomPlayer[]
|
||||
/** 房间状态 */
|
||||
status: RoomStatus
|
||||
/** 状态展示文字 */
|
||||
statusText: string
|
||||
/** 底注金额 */
|
||||
stakes: number
|
||||
/** 玩法模式标签 */
|
||||
mode: string
|
||||
/** 房主昵称 */
|
||||
creatorName: string
|
||||
}
|
||||
|
||||
/** 获取游戏列表 — 无请求参数 */
|
||||
export interface GetGameListResponse {
|
||||
list: GameItem[]
|
||||
}
|
||||
|
||||
/** 获取房间列表请求参数 */
|
||||
export interface GetRoomListRequest {
|
||||
/** 游戏 ID */
|
||||
gameId: string
|
||||
/** 筛选状态 */
|
||||
status?: RoomStatus
|
||||
}
|
||||
|
||||
/** 获取房间列表响应 */
|
||||
export interface GetRoomListResponse {
|
||||
list: RoomItem[]
|
||||
}
|
||||
|
||||
/** 创建房间请求参数 */
|
||||
export interface CreateRoomRequest {
|
||||
/** 游戏 ID */
|
||||
gameId: string
|
||||
/** 房间名称(可选,留空由后端自动生成) */
|
||||
name?: string
|
||||
/** 房间密码(可选,留空为公开房间) */
|
||||
password?: string
|
||||
/** 玩法模式 */
|
||||
mode: string
|
||||
/** 底注金额 */
|
||||
stakes: number
|
||||
/** 最大玩家数 */
|
||||
maxPlayers: number
|
||||
}
|
||||
|
||||
/** 创建房间响应 */
|
||||
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
|
||||
players: RoomPlayerDetail[]
|
||||
/** 棋盘状态(对局中才有) */
|
||||
boardState?: number[][]
|
||||
/** 当前轮到谁(userId),null 表示未开始 */
|
||||
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'
|
||||
| 'player_disconnected'
|
||||
| 'player_reconnected'
|
||||
| 'ready_change'
|
||||
| 'move_history'
|
||||
| 'error'
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './account/index.ts'
|
||||
export * from './user/index.ts'
|
||||
export * from './game/index.ts'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -34,30 +34,17 @@ export function createRefreshTokenInterceptor(config: RefreshInterceptorConfig)
|
||||
config
|
||||
|
||||
/**
|
||||
* 将 401 业务错误转换为类 AxiosError 结构,统一由 rejected 处理
|
||||
* 透传成功响应,HTTP 401 统一由 onRejected 处理
|
||||
* <p>
|
||||
* 后端改造后,认证错误(BAD_CREDENTIALS、REFRESH_TOKEN_INVALID 等)
|
||||
* 返回 HTTP 401 而非 HTTP 200 + code=401,因此无需在此将业务码转换为 HTTP 错误。
|
||||
*/
|
||||
async function onFulfilled(response: AxiosResponse<ApiResponse>) {
|
||||
const data = response.data
|
||||
if (data.code !== 401) {
|
||||
return response
|
||||
}
|
||||
|
||||
const reqConfig = response.config as InternalRequestConfig
|
||||
// 跳过刷新或已重试过 → 终端 401,直接重新认证
|
||||
if (reqConfig.__isRetryRequest || reqConfig.__skipRefresh) {
|
||||
await doReAuthenticate()
|
||||
return Promise.reject(data)
|
||||
}
|
||||
// 构造类 AxiosError,统一由 rejected 走刷新流程
|
||||
const fakeError = {
|
||||
config: response.config,
|
||||
response: { status: 401, data }
|
||||
} as AxiosError
|
||||
return onRejected(fakeError)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 401(以及业务 401 转换后的类 AxiosError)
|
||||
* 处理 HTTP 401 错误,启动队列式 Token 刷新机制
|
||||
*/
|
||||
async function onRejected(error: AxiosError) {
|
||||
const { config: reqConfig, response } = error
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
@@ -37,7 +37,7 @@ function resolvePathParams(url: string, pathParams?: Record<string, string | num
|
||||
*/
|
||||
class RequestClient {
|
||||
/** axios 实例 */
|
||||
private instance: AxiosInstance
|
||||
public instance: AxiosInstance
|
||||
|
||||
/** 是否正在刷新 token */
|
||||
isRefreshing = false
|
||||
@@ -58,6 +58,7 @@ class RequestClient {
|
||||
this.setupInterceptors()
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
/** 注册请求和响应拦截器 */
|
||||
private setupInterceptors(): void {
|
||||
// === 请求拦截器 ===
|
||||
@@ -86,8 +87,8 @@ class RequestClient {
|
||||
)
|
||||
|
||||
// === 通用响应拦截器 ===
|
||||
// 职责:解包 ApiResponse + 发布非 401 的错误事件。
|
||||
// 401 全部透传,由 refreshInterceptor 统一处理。
|
||||
// 职责:解包 ApiResponse + 按 HTTP 状态码分类处理错误。
|
||||
// HTTP 401 全部透传,由 refreshInterceptor 统一处理。
|
||||
this.instance.interceptors.response.use(
|
||||
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
|
||||
(response) => {
|
||||
@@ -97,31 +98,42 @@ class RequestClient {
|
||||
return data
|
||||
}
|
||||
|
||||
// 401 透传给刷新拦截器,此处不处理
|
||||
if (data.code === 401) {
|
||||
return response
|
||||
}
|
||||
|
||||
// code !== 0 但 HTTP 2xx — 未改造端点或特殊场景,按业务错误处理
|
||||
eventBus.emit(AppEvents.REQUEST_ERROR, {
|
||||
type: 'business',
|
||||
code: data.code,
|
||||
httpStatus: response.status,
|
||||
message: data.message || '请求失败'
|
||||
} satisfies RequestErrorEvent)
|
||||
})
|
||||
return Promise.reject(data)
|
||||
},
|
||||
(error) => {
|
||||
const status = error.response?.status
|
||||
|
||||
// HTTP 401 透传给刷新拦截器
|
||||
if (error.response?.status === 401) {
|
||||
if (status === 401) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// HTTP 400/403/404/409/405/500 — 从 body 提取 ApiResponse
|
||||
const body = error.response?.data as ApiResponse | undefined
|
||||
if (body && status) {
|
||||
eventBus.emit(AppEvents.REQUEST_ERROR, {
|
||||
type: 'business',
|
||||
code: body.code,
|
||||
httpStatus: status,
|
||||
message: body.message || `请求错误 (${status})`
|
||||
})
|
||||
return Promise.reject(body)
|
||||
}
|
||||
|
||||
// 网络异常(无 response)
|
||||
eventBus.emit(AppEvents.REQUEST_ERROR, {
|
||||
type: error.response ? 'http' : 'network',
|
||||
code: error.response?.status,
|
||||
message: error.response
|
||||
? `请求错误 (${error.response.status})`
|
||||
: '网络异常,请检查网络连接'
|
||||
} satisfies RequestErrorEvent)
|
||||
code: status,
|
||||
httpStatus: status,
|
||||
message: error.response ? `请求错误 (${status})` : '网络异常,请检查网络连接'
|
||||
})
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
@@ -150,7 +162,7 @@ class RequestClient {
|
||||
},
|
||||
refreshTokenQueue: this.refreshTokenQueue
|
||||
})
|
||||
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
|
||||
// 拦截器解包 axiosResponse → ApiResponse
|
||||
this.instance.interceptors.response.use(onFulfilled, onRejected)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface RequestErrorEvent {
|
||||
type: 'business' | 'http' | 'network'
|
||||
/** 业务状态码(business 类型时有效) */
|
||||
code?: number
|
||||
/** HTTP 状态码(http/business 类型时有效) */
|
||||
httpStatus?: number
|
||||
/** 错误消息 */
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
/* ============================================================
|
||||
* 全局重置样式
|
||||
* ============================================================ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-body);
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
import './variables.scss'
|
||||
import './base.css'
|
||||
|
||||
65
src/assets/style/variables.scss
Normal file
65
src/assets/style/variables.scss
Normal file
@@ -0,0 +1,65 @@
|
||||
// ============================================================
|
||||
// 亮色主题 CSS 自定义属性
|
||||
// 所有颜色和样式变量集中定义在此,组件通过 var() 引用
|
||||
// ============================================================
|
||||
|
||||
:root {
|
||||
// ---- 品牌色 ----
|
||||
// 用于按钮、链接、选中态等需要突出品牌感的元素
|
||||
--color-primary: #4f46e5; /* 主色(靛蓝):主要按钮、链接文字、激活态 */
|
||||
--color-primary-hover: #4338ca; /* 主色悬停:按钮 hover / focus 时加深 */
|
||||
--color-primary-light: #eef2ff; /* 主色浅底:选中背景、标签、高亮条底色 */
|
||||
|
||||
// ---- 文字色 ----
|
||||
// 按重要性分三级,覆盖主要内容到辅助提示
|
||||
--text-primary: #0f172a; /* 一级文字:标题、正文、重要信息 */
|
||||
--text-secondary: #475569; /* 二级文字:描述、标签、次要信息 */
|
||||
--text-tertiary: #94a3b8; /* 三级文字:占位符、禁用态、水印 */
|
||||
--text-inverse: #ffffff; /* 反色文字:用于深色 / 品牌色背景上 */
|
||||
--text-link: #4f46e5; /* 链接文字色 */
|
||||
|
||||
// ---- 背景色 ----
|
||||
// 从底层到顶层依次变亮,形成自然层级
|
||||
--bg-body: #f1f5f9; /* 页面底色:最底层背景 */
|
||||
--bg-surface: #ffffff; /* 卡片/面板背景:浮于页面之上的容器 */
|
||||
--bg-surface-hover: #eef2ff; /* 卡片/列表项悬停:鼠标经过时带品牌色微调,明显但不刺眼 */
|
||||
--bg-sidebar: #f8fafc; /* 侧边栏背景:导航区域的专用底色 */
|
||||
--bg-topbar: #ffffff; /* 顶部栏背景:顶部导航条的底色 */
|
||||
--bg-elevated: #ffffff; /* 浮层背景:弹出菜单、下拉面板等最高层 */
|
||||
|
||||
// ---- 边框色 ----
|
||||
--border-default: #e2e8f0; /* 默认边框:卡片、输入框、分割线 */
|
||||
--border-light: #f1f5f9; /* 浅边框:表格行间、轻分割 */
|
||||
|
||||
// ---- 阴影 ----
|
||||
// 按海拔高度递进,越高的层级阴影越重
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); /* 微小阴影:卡片默认态 */
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07); /* 中等阴影:悬停卡片 */
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08); /* 大阴影:弹窗、抽屉 */
|
||||
|
||||
// ---- 圆角 ----
|
||||
--radius-sm: 6px; /* 小圆角:标签、小按钮 */
|
||||
--radius-md: 12px; /* 中圆角:卡片、面板、输入框 */
|
||||
--radius-lg: 24px; /* 大圆角:模态框、大卡片 */
|
||||
|
||||
// ---- 间距 ----
|
||||
--space-xs: 4px; /* 极小间距:图标与文字之间 */
|
||||
--space-sm: 8px; /* 小间距:组件内部紧凑排列 */
|
||||
--space-md: 16px; /* 中间距:卡片内边距、列表项间隙 */
|
||||
--space-lg: 24px; /* 大间距:区块之间 */
|
||||
|
||||
// ---- 语义色 ----
|
||||
// 传达状态和反馈,不与品牌色混淆
|
||||
--color-success: #10b981; /* 成功:通过、完成、在线 */
|
||||
--color-success-light: #ecfdf5; /* 成功浅底 */
|
||||
--color-warning: #f59e0b; /* 警告:需要注意、待处理 */
|
||||
--color-warning-light: #fffbeb; /* 警告浅底 */
|
||||
--color-danger: #ef4444; /* 危险:错误、删除、不可逆操作 */
|
||||
--color-danger-light: #fef2f2; /* 危险浅底 */
|
||||
--color-info: #3b82f6; /* 信息:提示、帮助、说明 */
|
||||
--color-info-light: #eff6ff; /* 信息浅底 */
|
||||
|
||||
// ---- 滚动条 ----
|
||||
--scrollbar-thumb: #cbd5e1; /* 滚动条滑块颜色 */
|
||||
--scrollbar-track: transparent; /* 滚动条轨道颜色 */
|
||||
}
|
||||
71
src/common/hooks/useResizeHandle.ts
Normal file
71
src/common/hooks/useResizeHandle.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { type Ref, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 拖拽调整宽度 Hook 的参数
|
||||
*/
|
||||
interface UseResizeHandleOptions {
|
||||
/** 当前宽度响应式引用 */
|
||||
width: Ref<number>
|
||||
/** 宽度变更回调,传入新宽度 */
|
||||
onWidthChange: (width: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 将侧边栏拖拽调整宽度的逻辑封装为可复用的 composable
|
||||
*
|
||||
* 在 mousedown 时绑定 document 级 mousemove/mouseup 监听,
|
||||
* 拖拽过程中通过 onWidthChange 回调通知外部更新宽度(由外部负责约束范围),
|
||||
* 拖拽结束后自动清理事件监听和全局样式。
|
||||
*
|
||||
* @param options - 配置项,包含宽度 ref 和变更回调
|
||||
* @returns 返回 mousedown 事件处理器,绑定到拖拽手柄元素
|
||||
*/
|
||||
export function useResizeHandle(options: UseResizeHandleOptions): {
|
||||
onMouseDown: (event: MouseEvent) => void
|
||||
} {
|
||||
const { onWidthChange } = options
|
||||
|
||||
/** 是否正在拖拽 */
|
||||
let isResizing = false
|
||||
|
||||
/**
|
||||
* 处理 mousemove,计算新宽度并通知外部
|
||||
*/
|
||||
const onMouseMove = (event: MouseEvent): void => {
|
||||
if (!isResizing) return
|
||||
onWidthChange(event.clientX)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 mouseup,结束拖拽并清理
|
||||
*/
|
||||
const onMouseUp = (): void => {
|
||||
isResizing = false
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 mousedown,开始拖拽
|
||||
*/
|
||||
const onMouseDown = (event: MouseEvent): void => {
|
||||
event.preventDefault()
|
||||
isResizing = true
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'ew-resize'
|
||||
}
|
||||
|
||||
/** 组件卸载时兜底清理 */
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
})
|
||||
|
||||
return { onMouseDown }
|
||||
}
|
||||
173
src/common/sse/SseClient.ts
Normal file
173
src/common/sse/SseClient.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/**
|
||||
* SSE 连接客户端
|
||||
*
|
||||
* 只负责连接生命周期(建连、断开、重连、心跳),不包含任何业务逻辑。
|
||||
* 收到消息后通过 onMessage 回调抛出,由业务层自行分发。
|
||||
*/
|
||||
export class SseClient {
|
||||
private abortController: AbortController | null = null
|
||||
private retryCount = 0
|
||||
private _connected = false
|
||||
private _connecting = false
|
||||
|
||||
/** 重连配置 */
|
||||
private readonly maxRetries: number
|
||||
private readonly baseRetryDelayMs: number
|
||||
private readonly maxRetryDelayMs: number
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly getToken: () => string | null,
|
||||
private readonly callbacks: SseClientCallbacks,
|
||||
opts?: SseClientOptions
|
||||
) {
|
||||
this.maxRetries = opts?.maxRetries ?? 5
|
||||
this.baseRetryDelayMs = opts?.baseRetryDelayMs ?? 3000
|
||||
this.maxRetryDelayMs = opts?.maxRetryDelayMs ?? 30000
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
get connecting(): boolean {
|
||||
return this._connecting
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 SSE 连接
|
||||
*
|
||||
* 幂等调用:已连接或正在连接则跳过。
|
||||
* 连接断开时自动指数退避重连,最多 maxRetries 次。
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
const token = this.getToken()
|
||||
if (!token) {
|
||||
this.callbacks.onError?.('缺少 accessToken')
|
||||
return
|
||||
}
|
||||
|
||||
// 幂等:相同 token 已连接或正在连接则跳过
|
||||
if (this._connected || this._connecting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.disconnect()
|
||||
|
||||
this._connecting = true
|
||||
this.abortController = new AbortController()
|
||||
|
||||
try {
|
||||
await fetchEventSource(this.url, {
|
||||
headers: { saToken: token },
|
||||
signal: this.abortController.signal,
|
||||
openWhenHidden: true,
|
||||
|
||||
onopen: async (response) => {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new SseHttpError('SSE 鉴权失败', response)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SseHttpError(`SSE 连接失败: ${response.status}`, response)
|
||||
}
|
||||
if (!contentType.includes('text/event-stream')) {
|
||||
throw new SseHttpError(`非法的响应类型: ${contentType || '空'}`, response)
|
||||
}
|
||||
|
||||
this._connecting = false
|
||||
this._connected = true
|
||||
this.retryCount = 0
|
||||
this.callbacks.onConnected?.()
|
||||
},
|
||||
|
||||
onmessage: (message) => {
|
||||
if (!message.event || !message.data) {
|
||||
return
|
||||
}
|
||||
this.callbacks.onMessage(message.event, message.data)
|
||||
},
|
||||
|
||||
onclose: () => {
|
||||
this._connecting = false
|
||||
this._connected = false
|
||||
},
|
||||
|
||||
onerror: (error) => {
|
||||
this._connecting = false
|
||||
this._connected = false
|
||||
|
||||
// 401 → 通知外部刷新 token
|
||||
if (isSseHttpError(error) && error.response.status === 401) {
|
||||
this.callbacks.onUnauthorized?.()
|
||||
throw error
|
||||
}
|
||||
|
||||
this.retryCount += 1
|
||||
if (this.retryCount >= this.maxRetries) {
|
||||
this.callbacks.onError?.('实时连接异常,请刷新页面')
|
||||
throw error
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
this.baseRetryDelayMs * Math.pow(2, this.retryCount - 1),
|
||||
this.maxRetryDelayMs
|
||||
)
|
||||
return delay
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// fetchEventSource 仅在重连耗尽或 401 时 throw,状态已在 onerror 中处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 断开连接(主动关闭,不重连) */
|
||||
disconnect(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort()
|
||||
this.abortController = null
|
||||
}
|
||||
this.retryCount = 0
|
||||
this._connected = false
|
||||
this._connecting = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface SseClientCallbacks {
|
||||
/** 连接成功 */
|
||||
onConnected?: () => void
|
||||
/** 收到消息 (eventName, rawData) */
|
||||
onMessage: (event: string, data: string) => void
|
||||
/** 连接异常(重连耗尽或 token 缺失) */
|
||||
onError?: (message: string) => void
|
||||
/** 收到 401,需刷新 token */
|
||||
onUnauthorized?: () => void
|
||||
}
|
||||
|
||||
export interface SseClientOptions {
|
||||
maxRetries?: number
|
||||
baseRetryDelayMs?: number
|
||||
maxRetryDelayMs?: number
|
||||
}
|
||||
|
||||
// ==================== 内部工具 ====================
|
||||
|
||||
class SseHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly response: Response
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SseHttpError'
|
||||
}
|
||||
}
|
||||
|
||||
function isSseHttpError(error: unknown): error is SseHttpError {
|
||||
return error instanceof SseHttpError
|
||||
}
|
||||
2
src/common/sse/index.ts
Normal file
2
src/common/sse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useSse.ts'
|
||||
export * from './sseBus.ts'
|
||||
30
src/common/sse/sseBus.ts
Normal file
30
src/common/sse/sseBus.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import mitt from 'mitt'
|
||||
import type { RoomItem } from '@/api/modules/game'
|
||||
|
||||
/**
|
||||
* SSE 事件定义
|
||||
*/
|
||||
export type SseEvents = {
|
||||
connected: undefined
|
||||
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 }
|
||||
/** 自定义事件类型,用于测试或任意消息推送,数据格式由调用方决定 */
|
||||
custom: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 SSE 事件总线(发布-订阅模式)
|
||||
*
|
||||
* 连接层只负责解析 SSE 消息并 emit 到总线,业务模块自行订阅处理。
|
||||
* 示例:
|
||||
* ```ts
|
||||
* import { sseBus } from '@/stores/sseBus'
|
||||
* sseBus.on('room_updated', (room) => { ... })
|
||||
* ```
|
||||
*/
|
||||
export const sseBus = mitt<SseEvents>()
|
||||
77
src/common/sse/useSse.ts
Normal file
77
src/common/sse/useSse.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ref } from 'vue'
|
||||
import { SseClient } from './SseClient.ts'
|
||||
import type { SseEvents } from './sseBus.ts'
|
||||
import { sseBus } from './sseBus.ts'
|
||||
import { useAccountStore } from '@/stores'
|
||||
|
||||
/** SSE 连接 URL */
|
||||
const SSE_URL = '/api/sse/subscribe'
|
||||
|
||||
// ==================== 模块级单例 ====================
|
||||
|
||||
const isConnected = ref(false)
|
||||
|
||||
const client = new SseClient(SSE_URL, () => useAccountStore().accessToken, {
|
||||
onConnected() {
|
||||
isConnected.value = true
|
||||
sseBus.emit('connected', undefined)
|
||||
},
|
||||
onMessage(event: string, data: string) {
|
||||
try {
|
||||
const payload = JSON.parse(data)
|
||||
sseBus.emit(event as keyof SseEvents, payload)
|
||||
} catch {
|
||||
console.warn('[SSE] 事件数据解析失败:', event, data)
|
||||
}
|
||||
},
|
||||
onError(message: string) {
|
||||
sseBus.emit('error', { message, retryCount: 0 })
|
||||
},
|
||||
onUnauthorized() {
|
||||
void refreshTokenAndReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Token 刷新 ====================
|
||||
|
||||
/**
|
||||
* SSE 收到 401 → 调用 store 的 refreshAccessToken() 刷新 token 后重连
|
||||
* <p>
|
||||
* 无需维护独立并发锁:accountStore.refreshAccessToken() 内部已有 Promise 级锁,
|
||||
* 若 HTTP 刷新拦截器已在刷新中,此调用会复用同一个 Promise,确保只发一次请求。
|
||||
*/
|
||||
async function refreshTokenAndReconnect(): Promise<void> {
|
||||
const accountStore = useAccountStore()
|
||||
if (!accountStore.refreshToken) {
|
||||
sseBus.emit('error', { message: '登录已过期,请重新登录', retryCount: 0 })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const newToken = await accountStore.refreshAccessToken()
|
||||
if (newToken) {
|
||||
await client.connect()
|
||||
}
|
||||
} catch {
|
||||
sseBus.emit('error', { message: '实时连接异常,请刷新页面', retryCount: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Composable ====================
|
||||
|
||||
/**
|
||||
* 全局 SSE 连接管理
|
||||
*
|
||||
* SseClient 只管连接生命周期,业务消息通过 sseBus 分发。
|
||||
* 组件只需 `useSse().connect()` / `useSse().disconnect()`。
|
||||
*/
|
||||
export function useSse() {
|
||||
return {
|
||||
isConnected,
|
||||
connect: () => client.connect(),
|
||||
disconnect: () => {
|
||||
client.disconnect()
|
||||
isConnected.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
196
src/common/websocket/roomSocket.ts
Normal file
196
src/common/websocket/roomSocket.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
<template>
|
||||
<div class="user-bar">
|
||||
<div class="container">
|
||||
<el-popover trigger="click" :width="260">
|
||||
<template #reference>
|
||||
<div class="user-info">
|
||||
<el-badge is-dot :offset="[-7, 29]" class="user-badge">
|
||||
<el-avatar shape="circle" src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg" />
|
||||
</el-badge>
|
||||
<div class="user-name">
|
||||
<div class="user-title">MarchGlow</div>
|
||||
<div class="user-state">在线</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span>I wish they all could be California girls</span>
|
||||
</el-popover>
|
||||
|
||||
<div class="feature">
|
||||
<el-icon color="#fff" :size="26"><Setting /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Setting } from '@element-plus/icons-vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-badge :deep(.el-badge__content) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.user-badge :deep(.el-badge__content.is-dot) {
|
||||
background-color: #008000;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 34px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.user-title {
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-state {
|
||||
line-height: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
279
src/components/baseLayouts/AvatarCropper.vue
Normal file
279
src/components/baseLayouts/AvatarCropper.vue
Normal 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>
|
||||
191
src/components/baseLayouts/CreateRoomDialog.vue
Normal file
191
src/components/baseLayouts/CreateRoomDialog.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<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>
|
||||
|
||||
<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
|
||||
}
|
||||
|
||||
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: ''
|
||||
})
|
||||
|
||||
/** 初始化默认值:取当前游戏配置的第一个选项 */
|
||||
function initDefaults() {
|
||||
if (gameConfig.value) {
|
||||
form.mode = gameConfig.value.modes[0] ?? ''
|
||||
form.stakes = gameConfig.value.stakesOptions[0]
|
||||
}
|
||||
form.name = ''
|
||||
form.password = ''
|
||||
}
|
||||
|
||||
/** 弹窗打开时设置默认值 */
|
||||
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
|
||||
})
|
||||
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>
|
||||
978
src/components/baseLayouts/EditProfileDialog.vue
Normal file
978
src/components/baseLayouts/EditProfileDialog.vue
Normal 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>
|
||||
219
src/components/baseLayouts/GameList.vue
Normal file
219
src/components/baseLayouts/GameList.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="game-list">
|
||||
<!-- 左侧游戏图标列表 -->
|
||||
<div class="game-list__icons">
|
||||
<el-tooltip
|
||||
v-for="game in games"
|
||||
:key="game.id"
|
||||
:content="game.name"
|
||||
placement="right"
|
||||
:show-after="300"
|
||||
>
|
||||
<div
|
||||
class="game-list__icon-item"
|
||||
:class="{ 'game-list__icon-item--active': activeGameId === game.id }"
|
||||
@click="selectGame(game.id)"
|
||||
>
|
||||
<span class="game-list__icon-emoji">{{ game.icon }}</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 右侧游戏详情 -->
|
||||
<div class="game-list__detail">
|
||||
<template v-if="activeGame">
|
||||
<h3 class="game-list__detail-title">{{ activeGame.name }}</h3>
|
||||
<p class="game-list__detail-desc">{{ activeGame.description }}</p>
|
||||
<div class="game-list__detail-gameplay">
|
||||
<h4>玩法介绍</h4>
|
||||
<p>{{ activeGame.gameplay }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="game-list__detail-empty">
|
||||
<p>请选择一个游戏</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useBaseLayoutStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { getGameList, type GameItem } from '@/api/modules/game'
|
||||
|
||||
/** 游戏列表 */
|
||||
const games = ref<GameItem[]>([])
|
||||
/** 加载状态 */
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
/** 获取游戏列表 */
|
||||
async function fetchGameList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getGameList()
|
||||
games.value = res.data.list
|
||||
// 优先恢复已选中的游戏,否则默认选第一个
|
||||
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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchGameList()
|
||||
})
|
||||
|
||||
const baseLayoutStore = useBaseLayoutStore()
|
||||
const { activeGameId } = storeToRefs(baseLayoutStore)
|
||||
|
||||
/** 当前选中的游戏对象 */
|
||||
const activeGame = computed(() => games.value.find((g) => g.id === activeGameId.value) ?? null)
|
||||
|
||||
/** 选择游戏 */
|
||||
function selectGame(id: string) {
|
||||
const game = games.value.find((g) => g.id === id)
|
||||
if (game) {
|
||||
baseLayoutStore.setActiveGame(game.id, game.icon, game.name, game.config)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.game-list {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== 左侧图标列表 ===== */
|
||||
.game-list__icons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 4px;
|
||||
overflow-y: auto;
|
||||
/* 加粗分隔线,图标区与详情区边界分明 */
|
||||
border-right: 2px solid var(--border-default);
|
||||
}
|
||||
|
||||
.game-list__icon-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--color-primary-light);
|
||||
/* 选中态加左侧色条,视觉更突出 */
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 25%;
|
||||
height: 50%;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.game-list__icon-emoji {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ===== 右侧详情面板 ===== */
|
||||
.game-list__detail {
|
||||
flex: 1;
|
||||
padding: 20px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.game-list__detail-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-list__detail-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.game-list__detail-gameplay {
|
||||
/* 玩法介绍卡片 — 浅底圆角,从详情区中独立出来 */
|
||||
background-color: var(--bg-body);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.game-list__detail-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
|
||||
/* 空状态图标 */
|
||||
&::before {
|
||||
content: '🎮';
|
||||
font-size: 40px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
321
src/components/baseLayouts/Lobby.vue
Normal file
321
src/components/baseLayouts/Lobby.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="lobby">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="lobby__toolbar">
|
||||
<div class="lobby__filters">
|
||||
<button
|
||||
v-for="f in statusFilters"
|
||||
:key="f.key"
|
||||
class="lobby__filter-btn"
|
||||
:class="{ 'lobby__filter-btn--active': activeFilter === f.key }"
|
||||
@click="activeFilter = f.key"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="lobby__create-btn" @click="handleCreateRoom">
|
||||
<span class="lobby__create-icon">+</span>
|
||||
创建房间
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 桌子网格 -->
|
||||
<div class="lobby__grid">
|
||||
<TableCard
|
||||
v-for="room in filteredRooms"
|
||||
:key="room.id"
|
||||
:room="room"
|
||||
@join="handleJoin"
|
||||
@join-seat="handleJoinSeat"
|
||||
@spectate="handleSpectate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="filteredRooms.length === 0" class="lobby__empty">
|
||||
<span class="lobby__empty-icon">🪑</span>
|
||||
<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, 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, joinRoom } from '@/api/modules/game'
|
||||
import { sseBus } from '@/common/sse'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/** 状态筛选选项 */
|
||||
const statusFilters = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'waiting', label: '等待中' },
|
||||
{ key: 'playing', label: '进行中' }
|
||||
]
|
||||
const activeFilter = ref('all')
|
||||
|
||||
const router = useRouter()
|
||||
const baseLayoutStore = useBaseLayoutStore()
|
||||
const { activeGameId } = storeToRefs(baseLayoutStore)
|
||||
|
||||
/** 房间列表 */
|
||||
const rooms = ref<RoomItem[]>([])
|
||||
/** 加载状态 */
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
/** 获取房间列表 */
|
||||
async function fetchRoomList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRoomList({ gameId: activeGameId.value })
|
||||
rooms.value = res.data.list
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换游戏时重新获取房间列表并重置筛选 */
|
||||
watch(activeGameId, () => {
|
||||
activeFilter.value = 'all'
|
||||
fetchRoomList()
|
||||
})
|
||||
|
||||
/**
|
||||
* 首次加载:HTTP 全量拉取(SSE 可能尚未连接,HTTP 是最可靠的基线)
|
||||
*
|
||||
* 后续增量由 SSE 事件(room_created/room_updated/room_removed)驱动,
|
||||
* SSE 重连时通过 connected 事件触发全量覆盖作为数据补偿。
|
||||
*/
|
||||
fetchRoomList()
|
||||
|
||||
/** 是否已完成首次加载(用于区分 SSE 首次 connected 和重连) */
|
||||
let initialLoadDone = false
|
||||
|
||||
// ===== 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 重连 → 全量拉取房间列表覆盖增量丢失
|
||||
*
|
||||
* 首次 connected 跳过(已由上面的 fetchRoomList() 处理),
|
||||
* 仅重连时触发全量覆盖,避免初始重复请求。
|
||||
* 重连期间的增量事件可能丢失,HTTP 全量拉取作为补偿。
|
||||
*/
|
||||
function onSseConnected() {
|
||||
if (!initialLoadDone) {
|
||||
initialLoadDone = true
|
||||
return
|
||||
}
|
||||
fetchRoomList()
|
||||
}
|
||||
|
||||
// ===== SSE 实时事件订阅 =====
|
||||
onMounted(() => {
|
||||
sseBus.on('room_created', onRoomCreated)
|
||||
sseBus.on('room_updated', onRoomUpdated)
|
||||
sseBus.on('room_removed', onRoomRemoved)
|
||||
sseBus.on('connected', onSseConnected)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
sseBus.off('room_created', onRoomCreated)
|
||||
sseBus.off('room_updated', onRoomUpdated)
|
||||
sseBus.off('room_removed', onRoomRemoved)
|
||||
sseBus.off('connected', onSseConnected)
|
||||
})
|
||||
|
||||
/** 筛选后的房间列表 */
|
||||
const filteredRooms = computed(() => {
|
||||
const list = rooms.value
|
||||
if (activeFilter.value === 'all') return list
|
||||
if (activeFilter.value === 'waiting') return list.filter((r) => r.status === 'waiting')
|
||||
if (activeFilter.value === 'playing') return list.filter((r) => r.status === 'playing')
|
||||
return list
|
||||
})
|
||||
|
||||
/** 加入房间(需先调用后端 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 加入指定座位(后端自动分配座位,调用 join API 即可) */
|
||||
async function handleJoinSeat(roomId: string, _seatIndex: number) {
|
||||
await handleJoin(roomId)
|
||||
}
|
||||
|
||||
/** 观战(不调用 join API,仅建立 WebSocket 连接接收对局数据) */
|
||||
function handleSpectate(roomId: string) {
|
||||
router.push(`/room/${roomId}`)
|
||||
}
|
||||
|
||||
/** 创建房间对话框可见性 */
|
||||
const showCreateDialog = ref(false)
|
||||
|
||||
/** 创建房间 */
|
||||
function handleCreateRoom() {
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lobby {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-md);
|
||||
/* 深色底板模拟地板,让白色卡片"浮"在上面 */
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 0%, rgba(79, 70, 229, 0.03) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #e8ecf1 0%, #e2e6ed 100%);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ===== 顶部操作栏 ===== */
|
||||
.lobby__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lobby__filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.lobby__filter-btn {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--text-inverse);
|
||||
background-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-inverse);
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lobby__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-primary);
|
||||
color: var(--text-inverse);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.lobby__create-icon {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ===== 桌子网格 ===== */
|
||||
.lobby__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(252px, 1fr));
|
||||
gap: var(--space-md);
|
||||
justify-items: center;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.lobby__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.lobby__empty-icon {
|
||||
font-size: 56px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lobby__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lobby__empty-hint {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
448
src/components/baseLayouts/TableCard.vue
Normal file
448
src/components/baseLayouts/TableCard.vue
Normal file
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div
|
||||
class="table-card"
|
||||
:class="{
|
||||
'table-card--playing': room.status === 'playing',
|
||||
'table-card--full': room.players.length >= room.maxPlayers
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- 桌子+椅子可视区域 -->
|
||||
<div class="table-card__stage">
|
||||
<!-- 椅子:以圆形环绕桌子排布 -->
|
||||
<div
|
||||
v-for="(pos, idx) in chairPositions"
|
||||
:key="idx"
|
||||
class="table-card__chair"
|
||||
:class="{
|
||||
'table-card__chair--occupied': idx < room.players.length,
|
||||
'table-card__chair--empty': idx >= room.players.length
|
||||
}"
|
||||
:style="{ top: pos.top, left: pos.left }"
|
||||
@click.stop="handleChairClick(idx)"
|
||||
>
|
||||
<!-- 有人:头像 + tooltip -->
|
||||
<el-tooltip
|
||||
v-if="room.players[idx]"
|
||||
:content="chairTooltip(idx)"
|
||||
placement="top"
|
||||
:show-after="400"
|
||||
>
|
||||
<div class="table-card__chair-seat">
|
||||
<img
|
||||
:src="getAvatarUrl(room.players[idx].avatar)"
|
||||
:alt="room.players[idx].nickname"
|
||||
class="table-card__chair-avatar"
|
||||
/>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 空椅子 -->
|
||||
<div v-else class="table-card__chair-seat">
|
||||
<span class="table-card__chair-plus"></span>
|
||||
</div>
|
||||
|
||||
<!-- 房主皇冠标识 -->
|
||||
<span v-if="isHost(idx)" class="table-card__chair-crown" title="房主">👑</span>
|
||||
</div>
|
||||
|
||||
<!-- 圆桌 -->
|
||||
<div class="table-card__table">
|
||||
<span class="table-card__table-icon">{{ room.gameIcon }}</span>
|
||||
<span class="table-card__table-name">{{ room.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息区 -->
|
||||
<div class="table-card__info">
|
||||
<div class="table-card__info-row">
|
||||
<span class="table-card__player-count">
|
||||
<span class="table-card__dot" :class="statusDotClass"></span>
|
||||
{{ room.players.length }}/{{ room.maxPlayers }}人
|
||||
</span>
|
||||
<span class="table-card__status" :class="statusLabelClass">{{ room.statusText }}</span>
|
||||
</div>
|
||||
<div class="table-card__info-row table-card__info-row--secondary">
|
||||
<span>底注 {{ room.stakes }}</span>
|
||||
<span>{{ room.mode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { RoomItem } from '@/api/modules/game'
|
||||
import { getAvatarUrl } from '@/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
room: RoomItem
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'join-seat', roomId: string, seatIndex: number): void
|
||||
(e: 'spectate', roomId: string): void
|
||||
}>()
|
||||
|
||||
/** 舞台中心坐标(px) */
|
||||
const STAGE_CENTER_X = 124 // 卡片宽度 248px 的一半
|
||||
const STAGE_CENTER_Y = 105 // 舞台高度 210px 的一半
|
||||
/** 椅子环绕半径(px) */
|
||||
const ORBIT_RADIUS = 76
|
||||
|
||||
/**
|
||||
* 按角度环绕计算椅子位置
|
||||
* 角度从顶部(12 点钟方向)开始,顺时针递增
|
||||
* @returns 各椅子 {top, left} 像素值数组
|
||||
*/
|
||||
const chairPositions = computed(() => {
|
||||
const n = props.room.maxPlayers
|
||||
const positions: Array<{ top: string; left: string }> = []
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
// 从顶部开始,顺时针均分
|
||||
const angleDeg = (360 / n) * i
|
||||
const angleRad = (angleDeg * Math.PI) / 180
|
||||
|
||||
const left = STAGE_CENTER_X + ORBIT_RADIUS * Math.sin(angleRad)
|
||||
const top = STAGE_CENTER_Y - ORBIT_RADIUS * Math.cos(angleRad)
|
||||
|
||||
positions.push({
|
||||
left: `${left}px`,
|
||||
top: `${top}px`
|
||||
})
|
||||
}
|
||||
|
||||
return positions
|
||||
})
|
||||
|
||||
/** 状态指示点样式 */
|
||||
const statusDotClass = computed(() => {
|
||||
switch (props.room.status) {
|
||||
case 'waiting':
|
||||
return 'table-card__dot--waiting'
|
||||
case 'playing':
|
||||
return 'table-card__dot--playing'
|
||||
case 'finished':
|
||||
return 'table-card__dot--finished'
|
||||
default:
|
||||
return 'table-card__dot--waiting'
|
||||
}
|
||||
})
|
||||
|
||||
/** 状态标签样式 */
|
||||
const statusLabelClass = computed(() => {
|
||||
switch (props.room.status) {
|
||||
case 'waiting':
|
||||
return 'table-card__status--waiting'
|
||||
case 'playing':
|
||||
return 'table-card__status--playing'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
/** 点击卡片 → 观战(不直接加入) */
|
||||
function handleClick() {
|
||||
emit('spectate', props.room.id)
|
||||
}
|
||||
|
||||
/** 点击空椅子 → 指定座位加入 */
|
||||
function handleChairClick(idx: number) {
|
||||
if (idx >= props.room.players.length && props.room.status === 'waiting') {
|
||||
emit('join-seat', props.room.id, idx)
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断指定座位是否为房主 */
|
||||
function isHost(idx: number): boolean {
|
||||
const player = props.room.players[idx]
|
||||
return !!player && player.nickname === props.room.creatorName
|
||||
}
|
||||
|
||||
/** 椅子 hover 提示文字 */
|
||||
function chairTooltip(idx: number): string {
|
||||
const player = props.room.players[idx]
|
||||
if (!player) return ''
|
||||
return player.nickname === props.room.creatorName ? `${player.nickname}(房主)` : player.nickname
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 卡片容器 ===== */
|
||||
.table-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 248px;
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* 进行中的房间 — 暖色左边条 */
|
||||
&--playing {
|
||||
border-left: 3px solid var(--color-warning);
|
||||
}
|
||||
|
||||
/* 满员 — 降低饱和度 */
|
||||
&--full {
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 舞台区域 ===== */
|
||||
.table-card__stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
flex-shrink: 0;
|
||||
/* 中心微光,突出桌子 */
|
||||
background: radial-gradient(
|
||||
ellipse 65% 55% at 50% 50%,
|
||||
rgba(79, 70, 229, 0.028) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== 椅子(圆形环绕) ===== */
|
||||
.table-card__chair {
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table-card__chair-seat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* ---- 空椅子 ---- */
|
||||
.table-card__chair--empty .table-card__chair-seat {
|
||||
border: 2px dashed var(--border-default);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.table-card__chair-plus {
|
||||
/* 使用 CSS 伪元素画十字,保证几何居中 */
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: var(--text-tertiary);
|
||||
border-radius: 1px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* 横线 */
|
||||
&::before {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* 竖线 */
|
||||
&::after {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.table-card__chair--empty:hover .table-card__chair-seat {
|
||||
transform: scale(1.25);
|
||||
border-style: solid;
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary-light);
|
||||
box-shadow: 0 0 0 5px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.table-card__chair--empty:hover .table-card__chair-plus {
|
||||
&::before,
|
||||
&::after {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 有人椅子 ---- */
|
||||
.table-card__chair--occupied .table-card__chair-seat {
|
||||
border: 2px solid var(--color-primary-light);
|
||||
background-color: var(--bg-surface);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table-card__chair-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ---- 房主皇冠 ---- */
|
||||
.table-card__chair-crown {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== 圆桌(居中) ===== */
|
||||
.table-card__table {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
/* 木质桌面 */
|
||||
background: linear-gradient(145deg, #e8d5b0 0%, #d4a574 40%, #c49060 100%);
|
||||
box-shadow:
|
||||
0 3px 12px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.table-card__table-icon {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.table-card__table-name {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* ===== 底部信息区 ===== */
|
||||
.table-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 14px 12px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.table-card__info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&--secondary {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.table-card__player-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---- 状态指示点 ---- */
|
||||
.table-card__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--waiting {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
&--playing {
|
||||
background-color: var(--color-warning);
|
||||
animation: dot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&--finished {
|
||||
background-color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 状态标签 ---- */
|
||||
.table-card__status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
|
||||
&--waiting {
|
||||
color: var(--color-success);
|
||||
background-color: var(--color-success-light);
|
||||
}
|
||||
|
||||
&--playing {
|
||||
color: var(--color-warning);
|
||||
background-color: var(--color-warning-light);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
485
src/components/baseLayouts/UserBar.vue
Normal file
485
src/components/baseLayouts/UserBar.vue
Normal file
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<div class="user-bar">
|
||||
<div class="container">
|
||||
<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="userAvatar" />
|
||||
</el-badge>
|
||||
<div class="user-name">
|
||||
<div class="user-title">{{ displayName }}</div>
|
||||
<div class="user-state">{{ currentStatus.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 弹出面板内容 -->
|
||||
<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 { 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%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
display: flex;
|
||||
}
|
||||
.user-badge :deep(.el-badge__content) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.user-badge :deep(.el-badge__content.is-dot) {
|
||||
background-color: v-bind('currentStatus.color');
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-title {
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-state {
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
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);
|
||||
|
||||
: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>
|
||||
@@ -11,8 +11,8 @@
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<div class="auth-layout__footer">
|
||||
<span style="color: #71717a">Copyright © 2024</span>
|
||||
<a href="javascript:void(0)" class="auth-layout__footer-link" style="color: #2080f0"
|
||||
<span style="color: var(--text-tertiary)">Copyright © 2024</span>
|
||||
<a href="javascript:void(0)" class="auth-layout__footer-link" style="color: var(--text-link)"
|
||||
>MarchGlow</a
|
||||
>
|
||||
</div>
|
||||
@@ -42,17 +42,16 @@
|
||||
|
||||
.auth-layout__bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
154deg,
|
||||
#07070915 30%,
|
||||
color-mix(in srgb, var(--el-color-primary) 30%, transparent) 48%,
|
||||
#07070915 64%
|
||||
);
|
||||
filter: blur(100px);
|
||||
top: -20%;
|
||||
left: -20%;
|
||||
width: 140%;
|
||||
height: 140%;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 20%, rgba(79, 70, 229, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 80%, rgba(59, 130, 246, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(16, 185, 129, 0.04) 0%, transparent 40%);
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auth-layout__card {
|
||||
@@ -61,10 +60,15 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
border-radius: 24px;
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 40px 24px 40px 24px;
|
||||
width: 100%;
|
||||
/* 卡片浮起效果 */
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.06),
|
||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* 平板端宽度 */
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<template>
|
||||
<div class="base-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<div class="base-layout__topbar"></div>
|
||||
<div class="base-layout__topbar">
|
||||
<div class="base-layout__topbar-title">
|
||||
<span class="base-layout__topbar-game-icon">{{ activeGameIcon }}</span>
|
||||
<span class="base-layout__topbar-game-name">{{ activeGameName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体区域 -->
|
||||
<div class="base-layout__main">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="base-layout__sidebar">
|
||||
<div class="base-layout__sidebar" :style="{ width: sidebarWidth + 'px' }">
|
||||
<!-- 侧边栏内容 -->
|
||||
<div class="base-layout__sidebar-content"></div>
|
||||
<div class="base-layout__sidebar-content">
|
||||
<GameList />
|
||||
</div>
|
||||
<!-- 侧边栏底部固定区域 -->
|
||||
<div class="base-layout__sidebar-bottom">
|
||||
<div class="base-layout__sidebar-bottom-inner">
|
||||
@@ -16,17 +23,33 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 拖拽调整宽度的手柄 -->
|
||||
<div class="base-layout__resize-handle"></div>
|
||||
<div class="base-layout__resize-handle" @mousedown="onResizeMouseDown"></div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="base-layout__content"></div>
|
||||
<div class="base-layout__content">
|
||||
<Lobby />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UserBar from '@/components/User/UserBar.vue'
|
||||
import UserBar from '@/components/baseLayouts/UserBar.vue'
|
||||
import GameList from '@/components/baseLayouts/GameList.vue'
|
||||
import Lobby from '@/components/baseLayouts/Lobby.vue'
|
||||
import { useBaseLayoutStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useResizeHandle } from '@/common/hooks/useResizeHandle'
|
||||
|
||||
const baseLayoutStore = useBaseLayoutStore()
|
||||
const { activeGameIcon, activeGameName, sidebarWidth } = storeToRefs(baseLayoutStore)
|
||||
|
||||
/** 拖拽手柄事件处理器 */
|
||||
const { onMouseDown: onResizeMouseDown } = useResizeHandle({
|
||||
width: sidebarWidth,
|
||||
onWidthChange: (width) => baseLayoutStore.setSidebarWidth(width)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -40,16 +63,50 @@ import UserBar from '@/components/User/UserBar.vue'
|
||||
|
||||
.base-layout__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background-color: var(--bg-topbar);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
/* 置顶,确保阴影覆盖下方内容 */
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.base-layout__topbar-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
background-color: #fde68a; // amber-200
|
||||
}
|
||||
|
||||
.base-layout__topbar-game-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.base-layout__topbar-game-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
height: 100%;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.base-layout__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0; /* flex 子元素必须设置,允许收缩到内容以下 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.base-layout__sidebar {
|
||||
@@ -58,8 +115,11 @@ import UserBar from '@/components/User/UserBar.vue'
|
||||
position: relative;
|
||||
width: 400px;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
background-color: #fcd34d; // amber-300
|
||||
padding: var(--space-sm);
|
||||
background-color: var(--bg-sidebar);
|
||||
/* 右边框加粗 + 阴影,与内容区形成清晰分界 */
|
||||
border-right: 2px solid var(--border-default);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.base-layout__sidebar-content {
|
||||
@@ -68,7 +128,10 @@ import UserBar from '@/components/User/UserBar.vue'
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-bottom: 72px;
|
||||
background-color: #f59e0b; // amber-500
|
||||
/* 内容区用白色卡片承载,与侧边栏底色拉开层次 */
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.base-layout__sidebar-bottom {
|
||||
@@ -79,7 +142,7 @@ import UserBar from '@/components/User/UserBar.vue'
|
||||
height: 72px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
padding: 8px;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.base-layout__sidebar-bottom-inner {
|
||||
@@ -87,20 +150,33 @@ import UserBar from '@/components/User/UserBar.vue'
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
background-color: #92400e; // amber-800
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-surface);
|
||||
/* 加重的阴影 + 微妙的品牌色顶边,让用户栏从侧边栏中"浮起" */
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border-top: 2px solid var(--color-primary-light);
|
||||
border-left: 1px solid var(--border-light);
|
||||
border-right: 1px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.base-layout__resize-handle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #d1d5db; // gray-300
|
||||
background-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +184,8 @@ import UserBar from '@/components/User/UserBar.vue'
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #fbbf24; // amber-400
|
||||
min-height: 0; /* flex 子元素必须设置,允许收缩 */
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-body);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -83,26 +95,85 @@ export const useAccountStore = defineStore(
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Access Token
|
||||
* 刷新 Access Token(带并发锁)
|
||||
*
|
||||
* 由刷新拦截器的 doRefreshToken 调用。
|
||||
* 返回新的 Access Token 字符串,方便拦截器直接使用。
|
||||
* 由刷新拦截器的 doRefreshToken 和 SSE 的 onUnauthorized 调用。
|
||||
* 返回新的 Access Token 字符串,方便调用方直接使用。
|
||||
*
|
||||
* 并发控制:若已有刷新正在进行中,返回同一个 Promise,
|
||||
* 确保无论并发触发多少次,实际只发出一次 /account/refresh 请求。
|
||||
*
|
||||
* @returns 新的 Access Token
|
||||
*/
|
||||
/** 正在进行的刷新 Promise(null 表示无刷新进行中) */
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
// 已有刷新进行中 → 复用同一个 Promise,避免并发重复请求
|
||||
if (refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
if (!refreshToken.value) {
|
||||
throw new Error('无 Refresh Token,无法刷新')
|
||||
}
|
||||
|
||||
const res = await refreshApi({ refreshToken: refreshToken.value })
|
||||
accessToken.value = res.data.accessToken
|
||||
if (res.data.refreshToken) {
|
||||
refreshToken.value = res.data.refreshToken
|
||||
}
|
||||
isTokenValid.value = true
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await refreshApi({ refreshToken: refreshToken.value })
|
||||
accessToken.value = res.data.accessToken
|
||||
if (res.data.refreshToken) {
|
||||
refreshToken.value = res.data.refreshToken
|
||||
}
|
||||
isTokenValid.value = true
|
||||
return accessToken.value
|
||||
} finally {
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return accessToken.value
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前用户的个人资料
|
||||
* 调用后端接口持久化,并同步更新本地 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 }
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除所有认证状态 */
|
||||
@@ -124,7 +195,11 @@ export const useAccountStore = defineStore(
|
||||
register,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
fetchUserInfo
|
||||
fetchUserInfo,
|
||||
updateProfile,
|
||||
uploadAvatar,
|
||||
removeAvatar,
|
||||
updateUserStatus
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
74
src/stores/modules/baseLayout.ts
Normal file
74
src/stores/modules/baseLayout.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { GameConfig } from '@/api/modules/game'
|
||||
|
||||
/** 侧边栏最小宽度 */
|
||||
export const MIN_SIDEBAR_WIDTH = 200
|
||||
/** 侧边栏最大宽度 */
|
||||
export const MAX_SIDEBAR_WIDTH = 650
|
||||
/** 侧边栏默认宽度 */
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 400
|
||||
|
||||
export const useBaseLayoutStore = defineStore(
|
||||
'baseLayout',
|
||||
() => {
|
||||
/** 顶部导航栏标题 */
|
||||
const topbarTitle = ref<string>('')
|
||||
/** 当前选中的游戏 ID */
|
||||
const activeGameId = ref<string>('snake')
|
||||
/** 当前选中的游戏图标 */
|
||||
const activeGameIcon = ref<string>('🐍')
|
||||
/** 当前选中的游戏名称 */
|
||||
const activeGameName = ref<string>('贪吃蛇')
|
||||
/** 当前选中游戏的配置(模式、底注、人数等选项) */
|
||||
const activeGameConfig = ref<GameConfig | null>(null)
|
||||
/** 侧边栏当前宽度 */
|
||||
const sidebarWidth = ref<number>(DEFAULT_SIDEBAR_WIDTH)
|
||||
|
||||
/**
|
||||
* 设置顶部导航栏标题
|
||||
* @param title - 标题文本
|
||||
*/
|
||||
const setTopbarTitle = (title: string): void => {
|
||||
topbarTitle.value = title
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前选中的游戏信息
|
||||
* @param id - 游戏 ID
|
||||
* @param icon - 游戏图标
|
||||
* @param name - 游戏名称
|
||||
*/
|
||||
const setActiveGame = (id: string, icon: string, name: string, config: GameConfig): void => {
|
||||
activeGameId.value = id
|
||||
activeGameIcon.value = icon
|
||||
activeGameName.value = name
|
||||
activeGameConfig.value = config
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置侧边栏宽度,自动约束在最小和最大值之间
|
||||
* @param width - 新宽度(px)
|
||||
*/
|
||||
const setSidebarWidth = (width: number): void => {
|
||||
sidebarWidth.value = Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), MAX_SIDEBAR_WIDTH)
|
||||
}
|
||||
|
||||
return {
|
||||
topbarTitle,
|
||||
activeGameId,
|
||||
activeGameIcon,
|
||||
activeGameName,
|
||||
activeGameConfig,
|
||||
sidebarWidth,
|
||||
setTopbarTitle,
|
||||
setActiveGame,
|
||||
setSidebarWidth
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
pick: ['activeGameId', 'activeGameIcon', 'activeGameName']
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1 +1,3 @@
|
||||
export * from './account.ts'
|
||||
export * from './baseLayout.ts'
|
||||
export * from './room.ts'
|
||||
|
||||
177
src/stores/modules/room.ts
Normal file
177
src/stores/modules/room.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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 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>('')
|
||||
/** 当前用户是否为房间中的玩家 */
|
||||
const isPlayer = computed(() =>
|
||||
players.value.some((p) => p.userId === currentUserId.value)
|
||||
)
|
||||
|
||||
// ===== 求和状态 =====
|
||||
const drawRequested = ref<boolean>(false)
|
||||
const drawRequestFrom = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 从房间详情 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
|
||||
players.value = detail.players
|
||||
currentUserId.value = userId
|
||||
boardState.value = detail.boardState ?? []
|
||||
currentTurn.value = detail.currentTurn ?? null
|
||||
moves.value = detail.moves ?? []
|
||||
}
|
||||
|
||||
/** 更新棋盘状态(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 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,
|
||||
players, isOwner, currentUserId, isPlayer,
|
||||
boardState, currentTurn, moves, winner, resultType,
|
||||
isPlaying, isFinished,
|
||||
messages,
|
||||
drawRequested, drawRequestFrom,
|
||||
initFromDetail, applyMove, setCurrentTurn, addMessage,
|
||||
updateReady, addPlayer, removePlayer, setGameOver,
|
||||
receiveDrawRequest, clearDrawRequest, reset
|
||||
}
|
||||
})
|
||||
33
src/utils/avatar.ts
Normal file
33
src/utils/avatar.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 头像 URL 工具
|
||||
*
|
||||
* 将后端返回的相对头像路径拼接为完整可访问的 URL。
|
||||
* 开发环境使用 VITE_BACKEND_URL(如 http://localhost:8080),
|
||||
* 生产环境使用相对路径(前后端同域)。
|
||||
*/
|
||||
|
||||
/** 后端基础 URL(Vite 环境变量,开发时为 http://localhost:8080,生产为空) */
|
||||
const BACKEND_BASE = import.meta.env.VITE_BACKEND_URL || ''
|
||||
|
||||
/**
|
||||
* 获取完整的头像展示 URL
|
||||
*
|
||||
* - 空值/undefined 返回空字符串,前端显示默认头像
|
||||
* - data URI(注册默认头像)直接返回
|
||||
* - 已是完整 URL(http/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
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
/** 事件处理器类型 */
|
||||
type EventHandler = (...args: unknown[]) => void
|
||||
import mitt from 'mitt'
|
||||
|
||||
/** 应用级事件类型定义 */
|
||||
type AppEventsMap = {
|
||||
/** 401 未授权 */
|
||||
'app:unauthorized': void
|
||||
/** 请求错误(业务错误 / HTTP 错误 / 网络错误) */
|
||||
'app:request:error': {
|
||||
type: 'business' | 'http' | 'network'
|
||||
/** HTTP 状态码(http/business 类型时有效) */
|
||||
httpStatus?: number
|
||||
code?: number
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用级事件常量 */
|
||||
export const AppEvents = {
|
||||
/** 401 未授权 */
|
||||
UNAUTHORIZED: 'app:unauthorized',
|
||||
/** 请求错误(业务错误 / HTTP 错误 / 网络错误) */
|
||||
REQUEST_ERROR: 'app:request:error'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 发布订阅事件总线
|
||||
* 应用级事件总线(基于 mitt)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
@@ -23,69 +34,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>()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './eventBus.ts'
|
||||
export * from './useDebouncedRef.ts'
|
||||
export * from './avatar.ts'
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<template>
|
||||
<main>
|
||||
<div>你好呀</div>
|
||||
</main>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="login-wrapper__header">
|
||||
<h2 class="login-wrapper__title">欢迎回来 👋🏻</h2>
|
||||
<p>
|
||||
<span style="color: #71717a">登录账号以进入对战</span>
|
||||
<span style="color: var(--text-tertiary)">登录账号以进入对战</span>
|
||||
</p>
|
||||
</div>
|
||||
<el-form
|
||||
@@ -34,7 +34,7 @@
|
||||
<el-form-item>
|
||||
<div class="login-wrapper__actions">
|
||||
<el-checkbox v-model="rememberAccount">记住账号</el-checkbox>
|
||||
<a href="javascript:void(0)" style="color: #2080f0">忘记密码?</a>
|
||||
<a href="javascript:void(0)" style="color: var(--text-link)">忘记密码?</a>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
@@ -55,7 +55,7 @@
|
||||
<el-form-item>
|
||||
<div class="login-wrapper__register-link">
|
||||
<span>没有账号?</span>
|
||||
<router-link to="/auth/register" style="color: #2080f0">创建账号</router-link>
|
||||
<router-link to="/auth/register" style="color: var(--text-link)">创建账号</router-link>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="phone-login-wrapper__header">
|
||||
<h2 class="phone-login-wrapper__title">欢迎回来 📲</h2>
|
||||
<p>
|
||||
<span style="color: #71717a">请输入手机号码登录账号以进入对战</span>
|
||||
<span style="color: var(--text-tertiary)">请输入手机号码登录账号以进入对战</span>
|
||||
</p>
|
||||
</div>
|
||||
<el-form
|
||||
@@ -27,7 +27,7 @@
|
||||
v-model="loginModel.code"
|
||||
:length="6"
|
||||
:formatter="onlyAllowNumber"
|
||||
style="flex: 2; --el-input-otp-gap: 0"
|
||||
style="flex: 2; --el-input-otp-gap: 4px"
|
||||
>
|
||||
<template #separator>•</template>
|
||||
</el-input-otp>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="qrcode-login-wrapper__header">
|
||||
<h2 class="qrcode-login-wrapper__title">欢迎回来 📱</h2>
|
||||
<p>
|
||||
<span style="color: #71717a">请用手机扫描二维码登录</span>
|
||||
<span style="color: var(--text-tertiary)">请用手机扫描二维码登录</span>
|
||||
</p>
|
||||
</div>
|
||||
<el-form hide-required-asterisk class="qrcode-login-wrapper__form">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="register-wrapper__header">
|
||||
<h2 class="register-wrapper__title">创建一个账号 🚀</h2>
|
||||
<p>
|
||||
<span style="color: #71717a">创建账号进入对战</span>
|
||||
<span style="color: var(--text-tertiary)">创建账号进入对战</span>
|
||||
</p>
|
||||
</div>
|
||||
<el-form
|
||||
@@ -39,7 +39,7 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="register-wrapper__password-hint" style="color: #71717a">
|
||||
<p class="register-wrapper__password-hint" style="color: var(--text-tertiary)">
|
||||
使用 8 个或更多字符,混合大小写字母、数字
|
||||
</p>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
<el-form-item prop="agreement">
|
||||
<div class="register-wrapper__agreement">
|
||||
<el-checkbox v-model="registerModel.agreement">我同意</el-checkbox>
|
||||
<a href="javascript:void(0)" style="color: #2080f0">隐私政策 & 条款</a>
|
||||
<a href="javascript:void(0)" style="color: var(--text-link)">隐私政策 & 条款</a>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
@@ -68,7 +68,7 @@
|
||||
<el-form-item>
|
||||
<div class="register-wrapper__login-link">
|
||||
<span>已经有账号了?</span>
|
||||
<router-link to="/auth/login" style="color: #2080f0">去登录</router-link>
|
||||
<router-link to="/auth/login" style="color: var(--text-link)">去登录</router-link>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -275,7 +275,7 @@ const userRegisterFun = () => {
|
||||
height: 8px;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
background-color: #e5e7eb;
|
||||
background-color: var(--border-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ const userRegisterFun = () => {
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: #ef4444;
|
||||
background-color: var(--color-danger);
|
||||
transition: all 500ms;
|
||||
}
|
||||
|
||||
|
||||
197
src/views/game/RoomPage.vue
Normal file
197
src/views/game/RoomPage.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="room-page">
|
||||
<!-- 顶部信息栏 -->
|
||||
<RoomTopBar />
|
||||
|
||||
<!-- 主体区域 -->
|
||||
<div class="room-page__main">
|
||||
<!-- 左侧面板 -->
|
||||
<RoomSidePanel />
|
||||
|
||||
<!-- 中央游戏画布 -->
|
||||
<div class="room-page__canvas">
|
||||
<GameCanvas />
|
||||
</div>
|
||||
|
||||
<!-- 右侧面板 -->
|
||||
<RoomRightPanel />
|
||||
</div>
|
||||
</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'
|
||||
|
||||
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 'player_disconnected': {
|
||||
break
|
||||
}
|
||||
case 'player_reconnected': {
|
||||
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>
|
||||
33
src/views/game/components/GameCanvas.vue
Normal file
33
src/views/game/components/GameCanvas.vue
Normal 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>
|
||||
254
src/views/game/components/GomokuBoard.vue
Normal file
254
src/views/game/components/GomokuBoard.vue
Normal 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>
|
||||
92
src/views/game/components/MoveHistory.vue
Normal file
92
src/views/game/components/MoveHistory.vue
Normal 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>
|
||||
200
src/views/game/components/RoomActions.vue
Normal file
200
src/views/game/components/RoomActions.vue
Normal 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>
|
||||
259
src/views/game/components/RoomChat.vue
Normal file
259
src/views/game/components/RoomChat.vue
Normal 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>
|
||||
79
src/views/game/components/RoomRightPanel.vue
Normal file
79
src/views/game/components/RoomRightPanel.vue
Normal 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>
|
||||
212
src/views/game/components/RoomSidePanel.vue
Normal file
212
src/views/game/components/RoomSidePanel.vue
Normal 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>
|
||||
161
src/views/game/components/RoomTopBar.vue
Normal file
161
src/views/game/components/RoomTopBar.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="room-topbar">
|
||||
<button class="room-topbar__back" @click="goBack">
|
||||
<span class="room-topbar__back-icon">←</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">·</span>
|
||||
<span class="room-topbar__mode">{{ store.mode }}</span>
|
||||
<span class="room-topbar__divider">·</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>
|
||||
Reference in New Issue
Block a user