Compare commits

...

74 Commits

Author SHA1 Message Date
94dfa907f9 feat: 重构游戏房间架构 2026-06-30 10:19:41 +08:00
e1faf45683 feat: 刷新 Access Token(带并发锁) 2026-06-30 09:03:43 +08:00
b85c3cbd60 feat: 刷新 Access Token(带并发锁) 2026-06-30 09:03:26 +08:00
dd97f9cb70 feat: 刷新 Access Token(带并发锁) 2026-06-24 12:27:55 +08:00
7b3d31a57c feat: HTTP状态码 + 业务码 2026-06-24 11:14:27 +08:00
3f6d3f2761 feat: 重新实现SseClient 2026-06-24 09:33:38 +08:00
112ab3312d feat: 重新实现SseClient 2026-06-24 09:22:14 +08:00
9e36c91e13 feat: 重新实现SseClient 2026-06-24 09:14:51 +08:00
45f4d69446 fix: 暂存 2026-06-24 08:30:33 +08:00
7c12697579 fix: 暂存 2026-06-23 14:32:00 +08:00
0fae4feadb fix: 修改房间逻辑 2026-06-23 13:48:40 +08:00
f34dbeccb2 fix: 路由bug修改 2026-06-23 12:52:53 +08:00
88ab464d6a fix: 路由bug修改 2026-06-23 12:06:58 +08:00
47fb151ea8 feat: 游戏房间 2026-06-23 11:49:31 +08:00
f937b5cdb3 fix: 代码审查问题修复 — socket 代理提供 + inject 空安全 + 观战人数响应式 2026-06-23 11:28:27 +08:00
cf1a14bf24 feat: 创建房间新增观战开关,成功后跳转到房间页 2026-06-23 11:20:28 +08:00
78c172cce0 feat: 实现大厅到房间页面的导航跳转 2026-06-23 11:19:33 +08:00
eecec1aae1 feat: 修改房间卡片点击行为,卡片默认观战,空椅子入座 2026-06-23 11:18:55 +08:00
2a2c941d32 feat: 添加五子棋棋盘组件(Canvas 渲染) 2026-06-23 11:18:13 +08:00
5d45ad037f feat: 添加游戏画布容器组件 2026-06-23 11:17:19 +08:00
f91b76084a feat: 添加房间页面全屏容器和 WS 中枢 2026-06-23 11:16:40 +08:00
b435256157 feat: 添加底部观战栏组件 2026-06-23 11:15:11 +08:00
b99d397d1f feat: 添加右侧面板、聊天和走棋记录组件 2026-06-23 11:14:22 +08:00
c3c26f2935 feat: 添加房间侧边栏和操作按钮组件 2026-06-23 11:13:19 +08:00
6c7d1ce128 feat: 添加房间顶部信息栏组件 2026-06-23 11:12:12 +08:00
16d3670b5d feat: 添加房间路由和获取房间详情 API 2026-06-23 11:11:20 +08:00
0d76d7a6de feat: 添加 WebSocket 客户端工具 2026-06-23 11:10:34 +08:00
e2068ad95e feat: 添加房间状态 Store 2026-06-23 11:09:46 +08:00
205b60711a feat: 添加房间详情和 WebSocket 消息类型定义 2026-06-23 11:08:58 +08:00
0383572d39 feat: 使用@microsoft/fetch-event-source库 2026-06-23 10:13:53 +08:00
70c4bab3f6 feat: 使用mitt库 2026-06-22 14:36:20 +08:00
fe8918c06f fix: mitt v3 on() 返回 void,改用 off() 取消订阅 2026-06-22 14:02:23 +08:00
f8db0ea4d6 feat: App.vue 集成 SSE 连接管理,Lobby.vue 订阅实时房间事件 2026-06-22 13:47:15 +08:00
dfc54edd83 feat: 创建 useSse SSE 连接管理 composable 2026-06-22 13:46:36 +08:00
aa6f0a002e feat: 安装 mitt 并创建 sseBus 类型化事件总线 2026-06-22 13:46:03 +08:00
3354623dd9 feat: 用户信息修改 2026-06-22 12:57:31 +08:00
28b27bf127 feat: 头像裁剪 2026-06-22 12:42:41 +08:00
ec66afaa72 feat: 个人信息修改 2026-06-22 12:31:04 +08:00
1a175bfdd0 feat: user-bar 弹出修改 2026-06-22 11:31:43 +08:00
349a50339f feat: user-bar 修改 2026-06-22 11:19:11 +08:00
7d2d6707b7 feat: user-bar 修改 2026-06-22 10:32:53 +08:00
0b7a89c06b feat: 数据mock 2026-06-22 09:28:15 +08:00
676f4d5e2d feat: 数据mock 2026-06-18 16:36:37 +08:00
9bd85a4e43 feat: 数据mock 2026-06-18 13:10:09 +08:00
deae38fb05 feat: 五子棋图标更换 2026-06-18 12:57:09 +08:00
646bb92179 feat: 五子棋图标更换 2026-06-18 12:39:17 +08:00
da12f4f5ef feat: 拖拽侧边栏宽度 2026-06-18 12:37:57 +08:00
a2057da8fa feat: 游戏大厅 2026-06-18 12:20:11 +08:00
dd8157b723 feat: 添加交互效果 2026-06-18 11:40:51 +08:00
3bd6e972ea feat: 使用自定义主题色 2026-06-18 11:01:11 +08:00
c1e292ea18 feat: baselayoutStore 2026-06-18 10:37:13 +08:00
3bc3cce62a feat: 游戏列表组件+基础样式 2026-06-18 10:15:13 +08:00
33c2b31117 feat: 更换ui组件库,naiveui->element-plus 2026-06-18 09:58:36 +08:00
ab9e77669c fix: 暂存 2026-06-18 09:19:23 +08:00
a382f36461 fix: 安装@vicons/ionicons5图标库 2026-06-17 14:36:02 +08:00
18ff5cacfe fix: 修改路由功能 2026-06-17 12:23:23 +08:00
dedf172539 feat: 去除tailwindcss 2026-06-17 11:55:13 +08:00
09221c819a feat: BaseLayout 2026-06-17 11:48:43 +08:00
cb9687e472 feat: 双token 2026-06-16 18:19:48 +08:00
172c83ea73 feat: 双token 2026-06-16 16:16:47 +08:00
d9b8567b6d feat: 提示词 2026-06-16 12:12:33 +08:00
05fff65acf feat: 提示词 2026-06-16 12:02:05 +08:00
fc523e77f7 feat: 提示词 2026-06-16 12:01:13 +08:00
2dd1ef77cb feat: 1 2026-06-16 10:45:14 +08:00
5b53e33e27 feat: 添加vite的proxy代理设置 2026-06-09 14:03:42 +08:00
44b0de35df feat: 添加vite的proxy代理设置 2026-06-09 10:15:22 +08:00
ed86444fe6 feat: 网络请求+发布订阅模块 2026-06-09 10:12:09 +08:00
391def3c92 feat: 登录页面 2026-06-08 16:09:57 +08:00
fb60a6b7ee feat: 去除手动引用naive-ui组件,使用自动导入 2026-06-08 08:27:30 +08:00
116fe86f47 feat: 初始化 2026-06-02 13:38:43 +08:00
b5aaeb3962 1 2026-06-02 13:36:37 +08:00
76ea0e0840 1 2026-06-02 13:36:16 +08:00
4d9b763d18 清空 2026-06-02 13:34:54 +08:00
d7469b9622 feat: 暂存 2026-06-02 12:36:44 +08:00
108 changed files with 16352 additions and 1057 deletions

View File

@@ -1,5 +1,6 @@
{
"enabledPlugins": {
"superpowers@claude-plugins-official": true
"frontend-design@claude-plugins-official": true,
"context7@claude-plugins-official": true
}
}

3
.env.development Normal file
View File

@@ -0,0 +1,3 @@
# 后端服务地址(开发环境)
# 头像等静态资源通过此地址访问,生产环境留空即使用相对路径
VITE_BACKEND_URL=http://localhost:8080

17
.gitignore vendored
View File

@@ -8,17 +8,32 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

10
.idea/.gitignore generated vendored
View File

@@ -1,10 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitCommitMessageStorage">
<option name="messageStorage">
<MessageStorage />
</option>
</component>
</project>

7
.idea/OxcSettings.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="OxcSettings">
<option name="fixAllOnSave" value="true" />
<option name="runTrigger" value="ON_SAVE" />
</component>
</project>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="OxfmtSettings">
<option name="fixAllOnSave" value="true" />
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/web-game.iml" filepath="$PROJECT_DIR$/.idea/web-game.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

8
.idea/web-game.iml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true,
"trailingComma": "none"
}

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -1,3 +1,5 @@
# web-game
# 网页小游戏
自建一个网页的一些小游戏,用来学习锻炼编程能力
### Vue 3 + TypeScript + Vite
@@ -7,3 +9,4 @@
- 俄罗斯方块
- 五子棋
- 猜数字

10
auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

74
components.d.ts vendored Normal file
View File

@@ -0,0 +1,74 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import { GlobalComponents } from 'vue'
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']
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 TableCard: typeof import('./src/components/baseLayouts/TableCard.vue')['default']
const UserBar: typeof import('./src/components/baseLayouts/UserBar.vue')['default']
}

576
docs/openapi.json Normal file
View File

@@ -0,0 +1,576 @@
{
"openapi": "3.0.3",
"info": {
"title": "WebGame 后端接口文档",
"version": "1.0.0",
"description": "WebGame 游戏后端 REST API 接口文档。认证采用双 Token 机制Access Token30 分钟有效,每次请求携带) + Refresh Token30 天有效,仅用于刷新 Access Token。"
},
"servers": [
{
"url": "http://localhost:8080",
"description": "本地开发环境"
}
],
"tags": [
{ "name": "账号", "description": "账号相关接口(登录、注册、刷新令牌,无需认证)" },
{ "name": "用户", "description": "用户相关接口(需 Access Token 认证)" }
],
"paths": {
"/account/login": {
"post": {
"tags": ["账号"],
"summary": "用户登录",
"description": "使用用户名和密码进行登录认证,成功返回 Access Token 和 Refresh Token。该接口无需登录即可调用。",
"operationId": "login",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LoginRequest" },
"example": {
"username": "testuser",
"password": "123456"
}
}
}
},
"responses": {
"200": {
"description": "登录成功,返回双 Token",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponse" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/LoginResponse" }
}
}
]
},
"example": {
"code": 0,
"message": "success",
"data": {
"accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"500": { "$ref": "#/components/responses/InternalError" }
}
}
},
"/account/register": {
"post": {
"tags": ["账号"],
"summary": "用户注册",
"description": "注册新账号。用户名长度 3-32 位,密码长度 6-64 位。注册成功后自动登录并返回双 Token。该接口无需登录即可调用。",
"operationId": "register",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/RegisterRequest" },
"example": {
"username": "newuser",
"password": "abc123"
}
}
}
},
"responses": {
"200": {
"description": "注册成功,自动登录返回双 Token",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponse" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/LoginResponse" }
}
}
]
},
"example": {
"code": 0,
"message": "success",
"data": {
"accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"500": { "$ref": "#/components/responses/InternalError" }
}
}
},
"/account/refresh": {
"post": {
"tags": ["账号"],
"summary": "刷新 Access Token",
"description": "当 Access Token 过期30 分钟)后,使用 Refresh Token 换取新的 Access Token。Refresh Token 保持不变,仅在满 30 天或重新登录时更新。该接口无需登录即可调用。",
"operationId": "refresh",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/RefreshTokenRequest" },
"example": {
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
}
},
"responses": {
"200": {
"description": "刷新成功,返回新的 Access Token 和原 Refresh Token",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponse" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/LoginResponse" }
}
}
]
},
"example": {
"code": 0,
"message": "success",
"data": {
"accessToken": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"500": { "$ref": "#/components/responses/InternalError" }
}
}
},
"/user/info": {
"get": {
"tags": ["用户"],
"summary": "获取当前登录用户信息",
"description": "根据请求头中的 saTokenAccess Token获取当前登录用户的详细信息包括昵称、头像、个性签名、年龄、性别、生日。需登录认证。",
"operationId": "getUserInfo",
"security": [{ "saToken": [] }],
"responses": {
"200": {
"description": "查询成功,返回用户信息",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponse" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/UserInfoResponse" }
}
}
]
},
"example": {
"code": 0,
"message": "success",
"data": {
"nickName": "玩家001",
"avatar": "https://cdn.example.com/avatars/default.png",
"signature": "这个人很懒,什么都没写",
"age": 25,
"sex": 1,
"birthday": "2001-01-01"
}
}
}
}
},
"401": { "$ref": "#/components/responses/Unauthorized" },
"500": { "$ref": "#/components/responses/InternalError" }
}
}
}
},
"components": {
"securitySchemes": {
"saToken": {
"type": "apiKey",
"in": "header",
"name": "saToken",
"description": "Access Token登录/注册/刷新接口返回的 accessToken 值)。有效期 30 分钟,过期后使用 /account/refresh 接口获取新 Token。"
}
},
"schemas": {
"ApiResponse": {
"type": "object",
"description": "统一 API 响应信封。所有接口均使用此格式返回。",
"required": ["code", "message", "data"],
"properties": {
"code": {
"type": "integer",
"format": "int32",
"description": "业务状态码。0 表示成功,非 0 表示失败。详细错误码见 ErrorCode 枚举。"
},
"message": {
"type": "string",
"description": "响应消息。成功时固定为 \"success\",失败时为具体错误描述。"
},
"data": {
"nullable": true,
"description": "响应数据负载。成功时携带对应的业务数据对象,失败时为 null。"
}
}
},
"LoginRequest": {
"type": "object",
"description": "登录请求体",
"required": ["username", "password"],
"properties": {
"username": {
"type": "string",
"description": "用户名,不能为空",
"minLength": 1,
"example": "testuser"
},
"password": {
"type": "string",
"format": "password",
"description": "密码,不能为空",
"minLength": 1,
"example": "123456"
}
}
},
"RegisterRequest": {
"type": "object",
"description": "注册请求体",
"required": ["username", "password"],
"properties": {
"username": {
"type": "string",
"description": "用户名,长度限制 3-32 位,不能为空",
"minLength": 3,
"maxLength": 32,
"example": "newuser"
},
"password": {
"type": "string",
"format": "password",
"description": "密码,长度限制 6-64 位,不能为空",
"minLength": 6,
"maxLength": 64,
"example": "abc123"
}
}
},
"RefreshTokenRequest": {
"type": "object",
"description": "刷新令牌请求体",
"required": ["refreshToken"],
"properties": {
"refreshToken": {
"type": "string",
"description": "刷新令牌,登录/注册时返回的 refreshToken 值,不能为空",
"minLength": 1,
"example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
},
"LoginResponse": {
"type": "object",
"description": "登录、注册或刷新令牌成功时返回的认证令牌对",
"required": ["accessToken", "refreshToken"],
"properties": {
"accessToken": {
"type": "string",
"description": "Access TokenUUID 格式),有效期 30 分钟。后续请求需在 Header 中通过 saToken 字段携带此值。过期后使用 /account/refresh 接口刷新。",
"example": "12345678-1234-1234-1234-123456789abc"
},
"refreshToken": {
"type": "string",
"description": "Refresh Token64 位十六进制随机字符串),有效期 30 天。仅用于 /account/refresh 接口换取新的 Access Token。刷新时不变重新登录时替换。",
"example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
},
"UserInfoResponse": {
"type": "object",
"description": "当前登录用户的详细信息",
"properties": {
"nickName": {
"type": "string",
"description": "用户昵称",
"maxLength": 16,
"example": "玩家001"
},
"avatar": {
"type": "string",
"description": "头像图片 URL 地址,可能为 null",
"maxLength": 512,
"nullable": true,
"example": "https://cdn.example.com/avatars/default.png"
},
"signature": {
"type": "string",
"description": "个性签名,可能为 null",
"maxLength": 64,
"nullable": true,
"example": "这个人很懒,什么都没写"
},
"age": {
"type": "integer",
"format": "int32",
"description": "年龄,可能为 null",
"nullable": true,
"example": 25
},
"sex": {
"type": "integer",
"format": "int32",
"description": "性别。1=男2=女,可能为 null",
"nullable": true,
"enum": [1, 2],
"x-enum-descriptions": {
"1": "男",
"2": "女"
},
"example": 1
},
"birthday": {
"type": "string",
"format": "date",
"description": "生日ISO 8601 日期格式),可能为 null",
"nullable": true,
"example": "2001-01-01"
}
}
},
"ErrorResponse": {
"type": "object",
"description": "错误响应体模板。code 为具体错误码message 为错误描述data 恒为 null。",
"required": ["code", "message", "data"],
"properties": {
"code": {
"type": "integer",
"format": "int32",
"description": "错误码,详见 ErrorCode 枚举表"
},
"message": {
"type": "string",
"description": "人类可读的错误描述信息"
},
"data": {
"type": "null",
"description": "错误时恒为 null"
}
}
}
},
"responses": {
"BadRequest": {
"description": "请求参数校验失败或业务逻辑错误HTTP 200 但 code ≠ 0",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"examples": {
"validation": {
"summary": "参数校验失败",
"value": {
"code": 400,
"message": "username 用户名不能为空; password 密码长度6-64位",
"data": null
}
},
"usernameExists": {
"summary": "用户名已存在",
"value": {
"code": 1001,
"message": "用户名已存在",
"data": null
}
},
"badCredentials": {
"summary": "用户名或密码错误",
"value": {
"code": 1002,
"message": "用户名或密码错误",
"data": null
}
},
"refreshTokenInvalid": {
"summary": "刷新令牌无效或已过期",
"value": {
"code": 1003,
"message": "刷新令牌无效或已过期",
"data": null
}
},
"refreshTokenMissing": {
"summary": "缺少刷新令牌",
"value": {
"code": 1004,
"message": "缺少刷新令牌",
"data": null
}
},
"userNotFound": {
"summary": "用户不存在",
"value": {
"code": 2001,
"message": "用户不存在",
"data": null
}
}
}
}
}
},
"Unauthorized": {
"description": "未登录或 Access Token 无效/过期HTTP 200 但 code=401",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 401,
"message": "请先登录",
"data": null
}
}
}
},
"Forbidden": {
"description": "无权限或无角色访问HTTP 200 但 code=403",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 403,
"message": "无权限",
"data": null
}
}
}
},
"NotFound": {
"description": "请求的资源不存在",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 404,
"message": "资源不存在",
"data": null
}
}
}
},
"MethodNotAllowed": {
"description": "请求方法不支持(如 GET 访问 POST 接口)",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 405,
"message": "请求方法不支持",
"data": null
}
}
}
},
"InternalError": {
"description": "服务器内部错误HTTP 200 但 code=5000",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 5000,
"message": "服务器内部错误",
"data": null
}
}
}
}
},
"x-enums": {
"ErrorCode": {
"description": "业务错误码枚举com.webgame.webgamebackend.common.exception.ErrorCode",
"values": {
"SUCCESS": {
"code": 0,
"message": "success",
"description": "操作成功"
},
"BAD_REQUEST": {
"code": 400,
"message": "参数校验失败",
"description": "请求参数不符合校验规则"
},
"USERNAME_EXISTS": {
"code": 1001,
"message": "用户名已存在",
"description": "注册时用户名已被占用"
},
"BAD_CREDENTIALS": {
"code": 1002,
"message": "用户名或密码错误",
"description": "登录时用户名或密码不匹配"
},
"REFRESH_TOKEN_INVALID": {
"code": 1003,
"message": "刷新令牌无效或已过期",
"description": "Refresh Token 不存在或已过期30 天),需重新登录"
},
"REFRESH_TOKEN_MISSING": {
"code": 1004,
"message": "缺少刷新令牌",
"description": "请求体中的 refreshToken 字段为空"
},
"USER_NOT_FOUND": {
"code": 2001,
"message": "用户不存在",
"description": "查询的用户记录不存在"
},
"INTERNAL_ERROR": {
"code": 5000,
"message": "服务器内部错误",
"description": "未预期的服务器异常"
}
}
},
"Sex": {
"description": "性别枚举UserEntity.sex 字段)",
"values": {
"MALE": {
"code": 1,
"label": "男"
},
"FEMALE": {
"code": 2,
"label": "女"
}
}
}
}
}
}

8
docs/prompt.txt Normal file
View File

@@ -0,0 +1,8 @@
--- api接口文档提示词 ---
docs/openapi.json文件是后端提供的标准OpenAPI接口文档你需要完成
1. 生成统一axios请求封装
2. 自动生成全部接口的TS类型定义
3. 按模块拆分接口请求函数;
4. 严格遵循文档字段,不新增、不删减参数,区分必填/可选;
5. 按现有src/api/modules中的格式书写
6. 定义的TS类型名称interface以I开头命名type以T开头命名

6
env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** 是否启用 Mock 拦截器 */
readonly VITE_USE_MOCK: string
}

View File

@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web-game</title>
<title>小游戏</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,28 +0,0 @@
import { defineConfig } from "oxfmt";
export default defineConfig({
trailingComma: "none",
ignorePatterns: [
"dist",
"dev-dist",
".local",
".claude",
".agent",
".agents",
".codex",
".output.js",
"node_modules",
".nvmrc",
"coverage",
"CODEOWNERS",
".nitro",
".output",
"**/*.svg",
"**/*.sh",
"public",
".npmrc",
"*-lock.yaml",
"skills-lock.json",
"*.md"
]
});

View File

@@ -1,27 +0,0 @@
import { defineConfig } from "oxlint";
export default defineConfig({
ignorePatterns: [
"dist",
"dev-dist",
".local",
".claude",
".agent",
".agents",
".codex",
".output.js",
"node_modules",
".nvmrc",
"coverage",
"CODEOWNERS",
".nitro",
".output",
"**/*.svg",
"**/*.sh",
"public",
".npmrc",
"*-lock.yaml",
"skills-lock.json",
"*.md"
]
});

5629
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,47 @@
{
"name": "web-game",
"private": true,
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"lint:oxlint": "oxlint",
"format:oxfmt": "oxfmt"
"build-only": "vite build",
"type-check": "vue-tsc --build",
"format": "oxfmt src/"
},
"dependencies": {
"naive-ui": "^2.44.1",
"@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",
"vue": "^3.5.34",
"vue-router": "^5.1.0"
"qrcode": "^1.5.4",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3",
"@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",
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
"sass": "^1.100.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.45.0",
"sass-embedded": "^1.100.0",
"typescript": "~6.0.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

2769
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -1,10 +1,30 @@
<template>
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<ElConfigProvider :locale="zhCn">
<router-view />
</n-config-provider>
</ElConfigProvider>
</template>
<script setup lang="ts">
import { zhCN, dateZhCN } from "naive-ui";
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>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1 @@
export * from './login.ts'

View File

@@ -0,0 +1,103 @@
import { requestClient } from '@/api/request'
/**
* 登录请求参数
*
* 对应 OpenAPI: LoginRequest
*/
export interface LoginRequest {
/** 用户名,不能为空 */
username: string
/** 密码,不能为空 */
password: string
}
/**
* 注册请求参数
*
* 对应 OpenAPI: RegisterRequest
*/
export interface RegisterRequest {
/** 用户名,长度限制 3-32 位 */
username: string
/** 密码,长度限制 6-64 位 */
password: string
}
/**
* 刷新令牌请求参数
*
* 对应 OpenAPI: RefreshTokenRequest
*/
export interface RefreshTokenRequest {
/** 刷新令牌,登录/注册时返回的 refreshToken 值,不能为空 */
refreshToken: string
}
/**
* 登录/注册/刷新令牌响应
*
* 对应 OpenAPI: LoginResponse
* 采用双 Token 机制Access Token30 分钟) + Refresh Token30 天)
*/
export interface LoginResponse {
/** Access TokenUUID 格式),有效期 30 分钟,后续请求在 Header 中通过 saToken 携带 */
accessToken: string
/** Refresh Token64 位十六进制字符串),有效期 30 天,仅用于刷新 Access Token */
refreshToken: string
}
/**
* 用户登录
*
* 使用用户名和密码进行登录认证,成功返回双 Token。
* 该接口无需登录即可调用。
*
* 注意:设置 __skipRefresh=true避免密码错误返回 HTTP 401 时触发无意义的刷新流程。
*
* @param req - 登录请求参数
* @returns 包含 accessToken 和 refreshToken 的响应
*/
export function login(req: LoginRequest) {
return requestClient.post<LoginResponse>('/account/login', req, {
__skipRefresh: true
})
}
/**
* 用户注册
*
* 注册新账号。用户名长度 3-32 位,密码长度 6-64 位。
* 注册成功后自动登录并返回双 Token。
* 该接口无需登录即可调用。
*
* 注意:设置 __skipRefresh=true避免意外 401 时触发无意义的刷新流程。
*
* @param req - 注册请求参数
* @returns 包含 accessToken 和 refreshToken 的响应
*/
export function register(req: RegisterRequest) {
return requestClient.post<LoginResponse>('/account/register', req, {
__skipRefresh: true
})
}
/**
* 刷新 Access Token
*
* 当 Access Token 过期30 分钟)后,使用 Refresh Token 换取新的 Access Token。
* Refresh Token 保持不变,仅在满 30 天或重新登录时更新。
* 该接口无需登录即可调用。
*
* 注意:该接口设置 __skipRefresh=true避免刷新失败时自身触发 401 刷新逻辑
* 造成死循环。
*
* @param req - 刷新令牌请求参数
* @returns 包含新 accessToken 和原 refreshToken 的响应
*/
export function refresh(req: RefreshTokenRequest) {
return requestClient.post<LoginResponse>('/account/refresh', req, {
skipAuth: true,
__skipRefresh: true
})
}

View File

@@ -0,0 +1,3 @@
export * from './types'
export * from './list'
export * from './room'

View 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')
}

View 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}`)
}

View 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[][]
/** 当前轮到谁userIdnull 表示未开始 */
currentTurn?: string
/** 走棋记录 */
moves?: MoveItem[]
}
/** 房间内玩家详情(含座位号和准备状态) */
export interface RoomPlayerDetail {
userId: string
avatar: string
nickname: string
seatNumber: number
ready: boolean
}
/** 走棋记录项 */
export interface MoveItem {
moveIndex: number
playerId: string
row: number
col: number
}
/** 加入房间请求参数 */
export interface JoinRoomRequest {
/** 房间 ID */
roomId: string
/** 房间密码(可选,公开房间留空) */
password?: string
}
/** WebSocket 消息通用封装 */
export interface WsMessage<T = unknown> {
type: string
data: T
}
/** WebSocket 上行消息类型 */
export type WsInboundType =
| 'place_stone'
| 'chat'
| 'ready'
| 'resign'
| 'draw_request'
| 'draw_response'
| 'leave'
| 'start'
/** WebSocket 下行消息类型 */
export type WsOutboundType =
| 'board_sync'
| 'move'
| 'turn_change'
| 'chat_broadcast'
| 'game_over'
| 'player_join'
| 'player_leave'
| 'player_disconnected'
| 'player_reconnected'
| 'ready_change'
| 'move_history'
| 'error'

3
src/api/modules/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './account/index.ts'
export * from './user/index.ts'
export * from './game/index.ts'

View File

@@ -0,0 +1 @@
export * from './info.ts'

View File

@@ -0,0 +1,135 @@
import { requestClient } from '@/api/request'
/**
* 性别枚举
*
* 对应 OpenAPI: Sex (x-enums)
* 1 = 男2 = 女
*/
export const Sex = {
/** 男 */
MALE: 1,
/** 女 */
FEMALE: 2
} as const
/** 性别类型 */
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 */
avatar?: string | null
/** 个性签名,最长 64 位,可能为 null */
signature?: string | null
/** 年龄,可能为 null */
age?: number | null
/** 性别1=男2=女,可能为 null */
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 获取当前登录用户的详细信息。
* 需登录认证。
*
* @returns 当前登录用户的详细信息
*/
export function getUserInfo() {
return requestClient.get<UserInfoResponse>('/user/info')
}
/** 更新用户信息请求(所有字段可选,传什么更新什么) */
export interface UpdateUserInfoRequest {
/** 用户昵称,最长 16 位 */
nickName?: string
/** 头像图片 URL 地址 */
avatar?: string
/** 个性签名,最长 64 位 */
signature?: string
/** 年龄 */
age?: number
/** 性别1=男2=女 */
sex?: number
/** 生日ISO 8601 日期格式,如 "1998-06-15" */
birthday?: string
}
/**
* 更新当前登录用户信息
*
* 所有字段可选,传什么更新什么。
*
* @param data - 更新请求
* @returns 更新后的用户信息
*/
export function updateUserInfo(data: UpdateUserInfoRequest) {
return requestClient.put<UserInfoResponse>('/user/info', data)
}
/**
* 上传用户头像
*
* 接收裁剪后的图片文件,保存后返回更新后的用户信息。
*
* @param file - 头像文件JPEG/PNG/WebP/GIF≤2MB
* @returns 更新后的用户信息
*/
export function uploadAvatar(file: File) {
const formData = new FormData()
formData.append('file', file)
return requestClient.post<UserInfoResponse>('/user/avatar', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
} as any)
}
/**
* 删除用户头像
*
* 删除磁盘文件并将 DB 中 avatar 置空。
*
* @returns 更新后的用户信息avatar 为 null
*/
export function deleteAvatar() {
return requestClient.delete<UserInfoResponse>('/user/avatar')
}
/**
* 更新当前登录用户在线状态
*
* @param data - 状态更新请求
*/
export function updateStatus(data: UpdateStatusRequest) {
return requestClient.patch<void>('/user/status', data)
}

View File

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

5
src/api/request/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { requestClient } from './requestClient'
export type { ApiResponse, RequestErrorEvent } from './types'
/** 注册错误 UI 展示(副作用导入,应用启动时执行) */
import './errorUI'

View File

@@ -0,0 +1,110 @@
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios'
import type { ApiResponse, InternalRequestConfig } from './types'
/**
* 刷新拦截器配置
*/
interface RefreshInterceptorConfig {
/** axios 实例,用于重试请求 */
client: AxiosInstance
/** 刷新失败后的重新认证(清除状态 + 跳转登录页) */
doReAuthenticate: () => Promise<void>
/** 刷新 token返回新的 Access Token */
doRefreshToken: () => Promise<string>
/** 格式化请求头中的 token 值 */
formatToken: (token: string) => string
/** 是否正在刷新中(由 RequestClient 维护) */
isRefreshing: { value: boolean }
/** 刷新期间等待队列 */
refreshTokenQueue: Array<(token: string) => void>
}
/**
* 创建 Token 自动刷新的响应拦截器
*
* 防竞态机制(队列模式):
* 1. 收到 401 → 检查 isRefreshing
* 2. 未在刷新 → 标记 isRefreshing=true发起刷新
* 3. 已在刷新 → 将请求加入 refreshTokenQueue 等待
* 4. 刷新成功 → 用新 token 执行队列中所有等待请求 + 重试当前请求
* 5. 刷新失败 → 清空队列 + doReAuthenticate
*/
export function createRefreshTokenInterceptor(config: RefreshInterceptorConfig) {
const { client, doReAuthenticate, doRefreshToken, formatToken, isRefreshing, refreshTokenQueue } =
config
/**
* 透传成功响应HTTP 401 统一由 onRejected 处理
* <p>
* 后端改造后认证错误BAD_CREDENTIALS、REFRESH_TOKEN_INVALID 等)
* 返回 HTTP 401 而非 HTTP 200 + code=401因此无需在此将业务码转换为 HTTP 错误。
*/
async function onFulfilled(response: AxiosResponse<ApiResponse>) {
return response
}
/**
* 处理 HTTP 401 错误,启动队列式 Token 刷新机制
*/
async function onRejected(error: AxiosError) {
const { config: reqConfig, response } = error
const internalConfig = reqConfig as InternalRequestConfig | undefined
// 非 401 错误,直接抛出
if (response?.status !== 401) {
throw error
}
// 未启用刷新 或 已是重试请求 或 跳过刷新 → 直接重新认证
if (internalConfig?.__isRetryRequest || internalConfig?.__skipRefresh) {
await doReAuthenticate()
throw error
}
// 正在刷新中 → 将请求加入队列,等待刷新完成后重试
if (isRefreshing.value) {
return new Promise((resolve) => {
refreshTokenQueue.push((newToken: string) => {
reqConfig!.headers = reqConfig!.headers || {}
reqConfig!.headers.saToken = formatToken(newToken)
resolve(client.request(reqConfig!))
})
})
}
// === 开始刷新 ===
isRefreshing.value = true
// 标记当前请求为已重试,防止无限循环
internalConfig!.__isRetryRequest = true
try {
const newToken = await doRefreshToken()
console.log('[401] 刷新 token 成功', newToken)
if (!newToken) {
throw new Error('刷新 token 失败')
}
// 用新 token 重试当前请求
const retryPromise = client.request({
...reqConfig,
headers: { ...reqConfig?.headers, saToken: formatToken(newToken) }
})
// 处理队列中等待的请求
refreshTokenQueue.forEach((callback) => callback(newToken))
refreshTokenQueue.length = 0
return retryPromise
} catch (refreshError) {
// 刷新失败 → 清空队列 + 重新认证
refreshTokenQueue.forEach((callback) => callback(''))
refreshTokenQueue.length = 0
await doReAuthenticate()
throw refreshError
} finally {
isRefreshing.value = false
}
}
return { onFulfilled, onRejected }
}

View File

@@ -0,0 +1,221 @@
import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios'
import { useAccountStore } from '@/stores'
import { eventBus, AppEvents } from '@/utils'
import type { ApiResponse, InternalRequestConfig } from './types'
import { createRefreshTokenInterceptor } from './refreshInterceptor'
/**
* 替换 URL 中的路径参数占位符
*
* @example
* resolvePathParams('/user/:id/posts', { id: 1 }) // → '/user/1/posts'
*/
function resolvePathParams(url: string, pathParams?: Record<string, string | number>): string {
if (!pathParams) return url
return url.replace(/:(\w+)/g, (_, key) => {
const value = pathParams[key]
if (value === undefined) {
throw new Error(`路径参数 "${key}" 缺失`)
}
return String(value)
})
}
/**
* 基于 axios 封装的网络请求客户端
*
* 不依赖任何 UI 库,错误通过事件总线发布,由外部订阅处理。
*
* @example
* ```ts
* // GET 请求
* const res = await requestClient.get<UserListResult>('/users', { page: 1 })
*
* // POST 请求
* const res = await requestClient.post<LoginResult>('/auth/login', { username, password })
* ```
*/
class RequestClient {
/** axios 实例 */
public instance: AxiosInstance
/** 是否正在刷新 token */
isRefreshing = false
/** 刷新期间等待队列401 请求在刷新完成前暂存,刷新后用新 token 重试) */
refreshTokenQueue: Array<(token: string) => void> = []
constructor(config?: CreateAxiosDefaults) {
this.instance = axios.create({
baseURL: '/api',
timeout: 15000,
headers: {
'Content-Type': 'application/json'
},
...config
})
this.setupInterceptors()
}
// @ts-ignore
/** 注册请求和响应拦截器 */
private setupInterceptors(): void {
// === 请求拦截器 ===
this.instance.interceptors.request.use(
(config) => {
const reqConfig = config as InternalRequestConfig
// 替换路径参数 :param → 实际值
if (reqConfig.pathParams && config.url) {
config.url = resolvePathParams(config.url, reqConfig.pathParams)
}
// 跳过鉴权时不注入 saToken
if (!reqConfig?.skipAuth) {
const accountStore = useAccountStore()
if (accountStore.accessToken) {
config.headers.saToken = accountStore.accessToken
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// === 通用响应拦截器 ===
// 职责:解包 ApiResponse + 按 HTTP 状态码分类处理错误。
// HTTP 401 全部透传,由 refreshInterceptor 统一处理。
this.instance.interceptors.response.use(
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
(response) => {
const data = response.data as ApiResponse
if (data.code === 0) {
return data
}
// code !== 0 但 HTTP 2xx — 未改造端点或特殊场景,按业务错误处理
eventBus.emit(AppEvents.REQUEST_ERROR, {
type: 'business',
code: data.code,
httpStatus: response.status,
message: data.message || '请求失败'
})
return Promise.reject(data)
},
(error) => {
const status = error.response?.status
// HTTP 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: status,
httpStatus: status,
message: error.response ? `请求错误 (${status})` : '网络异常,请检查网络连接'
})
return Promise.reject(error)
}
)
// === Token 刷新拦截器 ===
// 通用拦截器透传 401 → 此处统一处理刷新/重试/重新认证
const { onFulfilled, onRejected } = createRefreshTokenInterceptor({
client: this.instance,
doReAuthenticate: async () => {
const accountStore = useAccountStore()
accountStore.logout()
eventBus.emit(AppEvents.UNAUTHORIZED)
},
doRefreshToken: () => {
const accountStore = useAccountStore()
return accountStore.refreshAccessToken()
},
formatToken: (token: string) => token,
isRefreshing: {
get value() {
return requestClient.isRefreshing
},
set value(v: boolean) {
requestClient.isRefreshing = v
}
},
refreshTokenQueue: this.refreshTokenQueue
})
// 拦截器解包 axiosResponse → ApiResponse
this.instance.interceptors.response.use(onFulfilled, onRejected)
}
/**
* 通用请求方法
*
* 适用于路径参数、混合参数等复杂场景。
*/
request<T = unknown>(config: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.request(config)
}
/** GET 请求 */
get<T = unknown>(
url: string,
params?: Record<string, unknown>,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.get(url, { params, ...config })
}
/** POST 请求 */
post<T = unknown>(
url: string,
data?: unknown,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.post(url, data, config)
}
/** PUT 请求 */
put<T = unknown>(
url: string,
data?: unknown,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.put(url, data, config)
}
/** DELETE 请求 */
delete<T = unknown>(url: string, config?: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.delete(url, config)
}
/** PATCH 请求 */
patch<T = unknown>(
url: string,
data?: unknown,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.patch(url, data, config)
}
}
/** 请求客户端单例 */
export const requestClient = new RequestClient()

35
src/api/request/types.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { AxiosRequestConfig } from 'axios'
/** 后端统一响应格式 */
export interface ApiResponse<T = unknown> {
/** 业务状态码0 表示成功 */
code: number
/** 响应消息 */
message: string
/** 响应数据 */
data: T
}
/** 扩展的请求配置,包含自定义选项 */
export interface InternalRequestConfig extends AxiosRequestConfig {
/** 路径参数,替换 URL 中的 :param 占位符(如 /user/:id → /user/1 */
pathParams?: Record<string, string | number>
/** 跳过鉴权注入(不携带 saToken 头) */
skipAuth?: boolean
/** 内部标记:是否已经是重试请求(防止 401 → 刷新 → 重试 → 401 死循环) */
__isRetryRequest?: boolean
/** 内部标记:跳过 401 自动刷新(刷新接口本身不能触发刷新逻辑) */
__skipRefresh?: boolean
}
/** 请求错误事件载荷 */
export interface RequestErrorEvent {
/** 错误类型 */
type: 'business' | 'http' | 'network'
/** 业务状态码business 类型时有效) */
code?: number
/** HTTP 状态码http/business 类型时有效) */
httpStatus?: number
/** 错误消息 */
message: string
}

1
src/assets/game.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780377441389" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5155" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M256 426.666667v-56.888889a28.444444 28.444444 0 0 1 56.888889 0v56.888889h56.888889a28.444444 28.444444 0 0 1 0 56.888889h-56.888889v56.888888a28.444444 28.444444 0 0 1-56.888889 0v-56.888888H199.111111a28.444444 28.444444 0 0 1 0-56.888889h56.888889z m324.750222-256c23.779556-35.157333 57.400889-59.363556 102.200889-68.892445 147.541333-31.345778 248.803556 131.84 295.139556 349.866667 46.336 217.998222-10.666667 414.833778-127.317334 439.637333-89.400889 19.000889-187.904-68.551111-249.912889-208.611555h-180.024888c-62.008889 140.060444-160.483556 227.612444-249.884445 208.611555-116.650667-24.803556-173.653333-221.639111-127.317333-439.637333C89.969778 233.614222 180.337778 68.124444 338.773333 101.774222c47.644444 10.154667 82.346667 34.218667 106.296889 68.892445h135.68z m-135.68 56.888889a56.888889 56.888889 0 0 1-46.791111-24.547556c-16.412444-23.751111-39.168-38.741333-71.338667-45.568-101.546667-21.589333-180.451556 83.911111-227.669333 306.005333-40.675556 191.374222 6.997333 355.925333 83.512889 372.167111 54.528 11.605333 132.266667-54.499556 186.026667-175.985777a56.888889 56.888889 0 0 1 52.053333-33.848889h179.996444a56.888889 56.888889 0 0 1 52.053334 33.848889c53.76 121.486222 131.498667 187.591111 186.026666 175.985777 76.515556-16.241778 124.188444-180.792889 83.512889-372.167111-45.340444-213.248-132.636444-326.229333-227.669333-306.005333-29.013333 6.172444-50.574222 20.906667-66.929778 45.084444a56.888889 56.888889 0 0 1-47.104 25.031112h-135.68zM654.222222 483.555556a28.444444 28.444444 0 1 1 0-56.888889 28.444444 28.444444 0 0 1 0 56.888889z m170.666667 0a28.444444 28.444444 0 1 1 0-56.888889 28.444444 28.444444 0 0 1 0 56.888889z m-85.333333-85.333334a28.444444 28.444444 0 1 1 0-56.888889 28.444444 28.444444 0 0 1 0 56.888889z m0 170.666667a28.444444 28.444444 0 1 1 0-56.888889 28.444444 28.444444 0 0 1 0 56.888889z" p-id="5156" fill="#1296db"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -1,7 +0,0 @@
import { create, NButton, NConfigProvider } from "naive-ui";
const useNaiveUi = create({
components: [NButton, NConfigProvider]
});
export default useNaiveUi;

56
src/assets/style/base.css Normal file
View File

@@ -0,0 +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;
}

View File

@@ -1,5 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

View File

@@ -1,5 +0,0 @@
/** 全局样式 */
@use "./global.scss";
/** 引入tailwindcss */
@import "./tailwind.css";

View File

@@ -0,0 +1,2 @@
import './variables.scss'
import './base.css'

View File

@@ -1,4 +0,0 @@
/**
* 引入 Tailwind CSS
*/
@import "tailwindcss";

View File

@@ -1 +1,65 @@
$bgColor: #ff0000;
// ============================================================
// 亮色主题 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; /* 滚动条轨道颜色 */
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

View 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 }
}

View File

@@ -1 +0,0 @@
// 占位

173
src/common/sse/SseClient.ts Normal file
View 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
View File

@@ -0,0 +1,2 @@
export * from './useSse.ts'
export * from './sseBus.ts'

30
src/common/sse/sseBus.ts Normal file
View 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
View 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
}
}
}

View File

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

View File

@@ -1,93 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import viteLogo from "../assets/vite.svg";
import heroImg from "../assets/hero.png";
import vueLogo from "../assets/vue.svg";
const count = ref(0);
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">Count is {{ count }}</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -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>

View 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>

View 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>

View 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>

7
src/env.d.ts vendored
View File

@@ -1,7 +0,0 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View File

@@ -0,0 +1,123 @@
<template>
<div class="auth-layout">
<div class="auth-layout__center">
<div class="auth-layout__bg"></div>
<div class="auth-layout__card">
<RouterView v-slot="{ Component, route }">
<Transition appear mode="out-in" name="slide-right">
<KeepAlive :include="['Login']">
<component :is="Component" :key="route.fullPath" class="auth-layout__content" />
</KeepAlive>
</Transition>
</RouterView>
<div class="auth-layout__footer">
<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>
</div>
</div>
</div>
</template>
<script setup lang="ts"></script>
<style scoped>
.auth-layout {
display: flex;
min-height: 100lvh;
flex: 1;
overflow-x: hidden;
user-select: none;
}
.auth-layout__center {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.auth-layout__bg {
position: absolute;
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 {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
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);
}
/* 平板端宽度 */
@media (min-width: 768px) {
.auth-layout__card {
width: 66.666%;
}
}
/* 桌面端宽度 */
@media (min-width: 1024px) {
.auth-layout__card {
width: 50%;
}
}
/* 大屏端宽度 */
@media (min-width: 1280px) {
.auth-layout__card {
width: 36%;
}
}
.auth-layout__content {
width: 100%;
}
@media (min-width: 640px) {
.auth-layout__content {
margin-left: auto;
margin-right: auto;
}
}
@media (min-width: 768px) {
.auth-layout__content {
max-width: 448px;
}
}
.auth-layout__footer {
position: absolute;
bottom: 12px;
display: flex;
text-align: center;
font-size: 12px;
}
.auth-layout__footer-link {
margin-left: 8px;
}
</style>

191
src/layouts/BaseLayout.vue Normal file
View File

@@ -0,0 +1,191 @@
<template>
<div class="base-layout">
<!-- 顶部导航栏 -->
<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" :style="{ width: sidebarWidth + 'px' }">
<!-- 侧边栏内容 -->
<div class="base-layout__sidebar-content">
<GameList />
</div>
<!-- 侧边栏底部固定区域 -->
<div class="base-layout__sidebar-bottom">
<div class="base-layout__sidebar-bottom-inner">
<UserBar />
</div>
</div>
<!-- 拖拽调整宽度的手柄 -->
<div class="base-layout__resize-handle" @mousedown="onResizeMouseDown"></div>
</div>
<!-- 主内容区域 -->
<div class="base-layout__content">
<Lobby />
</div>
</div>
</div>
</template>
<script setup lang="ts">
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">
.base-layout {
display: flex;
flex-direction: column;
height: 100lvh;
width: 100lvw;
overflow: hidden;
}
.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;
}
.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%;
min-height: 0; /* flex 子元素必须设置,允许收缩到内容以下 */
overflow: hidden;
}
.base-layout__sidebar {
display: flex;
flex-direction: column;
position: relative;
width: 400px;
height: 100%;
padding: var(--space-sm);
background-color: var(--bg-sidebar);
/* 右边框加粗 + 阴影,与内容区形成清晰分界 */
border-right: 2px solid var(--border-default);
z-index: 5;
}
.base-layout__sidebar-content {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
padding-bottom: 72px;
/* 内容区用白色卡片承载,与侧边栏底色拉开层次 */
background-color: var(--bg-surface);
border-radius: var(--radius-md);
overflow: hidden;
}
.base-layout__sidebar-bottom {
position: absolute;
display: flex;
flex-shrink: 0;
width: 100%;
height: 72px;
left: 0;
bottom: 0;
padding: var(--space-sm);
}
.base-layout__sidebar-bottom-inner {
display: flex;
flex: 1;
width: 100%;
height: 100%;
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: 6px;
height: 100%;
top: 0;
right: 0;
cursor: ew-resize;
z-index: 10;
background: transparent;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--color-primary-light);
}
&:active {
background-color: var(--color-primary);
}
}
.base-layout__content {
display: flex;
flex: 1;
width: 100%;
min-height: 0; /* flex 子元素必须设置,允许收缩 */
overflow: hidden;
background-color: var(--bg-body);
}
</style>

View File

@@ -1,11 +1,15 @@
import { createApp } from "vue";
import { initPinia } from "@/pinia";
import { router } from "@/router";
import useNaiveUi from "@/assets/naiveUI/useNaiveUi.ts";
import App from "./App.vue";
import './assets/style'
import 'element-plus/dist/index.css'
import "@/assets/style/index.scss";
import { createApp } from 'vue'
import { initPinia } from './stores'
const app = createApp(App);
app.use(initPinia());
app.use(router).use(useNaiveUi).mount("#app");
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(initPinia())
app.use(router)
app.mount('#app')

View File

@@ -1,2 +0,0 @@
export * from "./setup.ts";
export * from "./modules";

View File

@@ -1,11 +0,0 @@
import { defineStore } from "pinia";
export const useAccountStore = defineStore(
"account",
() => {
// TODO: 账号信息
},
{
persist: true
}
);

View File

@@ -1 +0,0 @@
export * from "./account.ts";

View File

@@ -1,9 +0,0 @@
import { createPinia } from "pinia";
import piniaPluginPersistedState from "pinia-plugin-persistedstate";
export function initPinia() {
const pinia = createPinia();
// 持久化存储
pinia.use(piniaPluginPersistedState);
return pinia;
}

View File

@@ -1,19 +1,74 @@
import type { Router } from "vue-router";
import type { Router } from 'vue-router'
import { useAccountStore } from '@/stores'
/** 无需登录即可访问的路由路径前缀 */
const PUBLIC_PATHS = ['/auth']
/**
* 判断目标路由是否为公开路由(无需登录)
* @param path - 目标路由路径
*/
function isPublicPath(path: string): boolean {
return PUBLIC_PATHS.some((prefix) => path.startsWith(prefix))
}
/**
* 通用路由守卫
* - 首次导航时验证 token 是否有效
* - 未登录用户访问非公开路由 → 跳转登录页
* - 已登录用户访问登录相关页面 → 跳转首页
*/
function setupCommonGuard(router: Router) {
// @ts-ignore
router.beforeEach(async (to, from) => {
// TODO: 路由守卫
return true;
});
router.beforeEach(async (to) => {
const accountStore = useAccountStore()
// 已登录用户访问公开路由(如登录页),直接跳转首页
if (!!accountStore.accessToken && isPublicPath(to.path)) {
return '/'
}
// 有 token 但未加载用户信息(页面刷新后 Pinia 只持久化了 token
if (accountStore.accessToken && !accountStore.accountInfo) {
try {
await accountStore.fetchUserInfo()
} catch {
// 获取失败token 过期等),由刷新拦截器处理,此处静默
}
}
// accessToken 检查
if (!accountStore.accessToken) {
// 明确声明忽略权限访问权限,则可以访问
if (to.meta.ignoreAccess) {
return true
}
// 未登录用户访问非公开路由,跳转登录页
if (!isPublicPath(to.path)) {
// 将原本要访问的路径作为 redirect 参数传递,登录后可跳回
return { path: '/auth/login', query: { redirect: to.fullPath }, replace: true }
}
// 没有访问权限,跳转登录页面(使用 to.path 而非 to.fullPath避免 query 参数导致无限重定向)
if (to.path !== '/auth/login') {
return {
path: '/auth/login',
query: { redirect: to.fullPath },
// 携带当前跳转的页面,登录后重新跳转该页面
replace: true
}
}
// 登录页面且已带 redirect 参数(由上面逻辑设置),允许进入
return true
}
return true
})
}
// 路由守卫
function createRouterGuard(router: Router) {
setupCommonGuard(router);
setupCommonGuard(router)
}
export { createRouterGuard };
export { createRouterGuard }

View File

@@ -1,16 +1,25 @@
import { createMemoryHistory, createRouter } from "vue-router";
import routes from "./routes";
import { createRouterGuard } from "@/router/guard.ts";
import { createRouter, createWebHistory } from 'vue-router'
import { eventBus, AppEvents } from '@/utils/eventBus'
import { useAccountStore } from '@/stores'
import { createRouterGuard } from './guard.ts'
import routes from './routes.ts'
/**
* 创建路由实例
*/
const router = createRouter({
history: createMemoryHistory(),
routes
});
history: createWebHistory(import.meta.env.BASE_URL),
routes: routes
})
// 创建路由守卫
createRouterGuard(router);
createRouterGuard(router)
export { router };
/** 订阅 401 未授权事件:清除认证状态并跳转登录页 */
eventBus.on(AppEvents.UNAUTHORIZED, () => {
const accountStore = useAccountStore()
accountStore.logout()
router.push('/auth/login')
})
export default router

45
src/router/routes.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Root',
component: () => import('@/layouts/BaseLayout.vue'),
children: []
},
{
path: '/room/:roomId',
name: 'Room',
component: () => import('@/views/game/RoomPage.vue'),
meta: { requiresAuth: true }
},
{
path: '/auth',
name: 'Authentication',
component: () => import('@/layouts/AuthPageLayout.vue'),
children: [
{
path: 'login',
name: 'Login',
component: () => import('@/views/auth/Login.vue')
},
{
path: 'register',
name: 'Register',
component: () => import('@/views/auth/Register.vue')
},
{
path: 'phone-login',
name: 'PhoneLogin',
component: () => import('@/views/auth/PhoneLogin.vue')
},
{
path: 'qrcode-login',
name: 'QRCodeLogin',
component: () => import('@/views/auth/QRCodeLogin.vue')
}
]
}
]
export default routes

View File

@@ -1,5 +0,0 @@
import HelloWorld from "@/components/HelloWorld.vue";
const routes = [{ path: "/", component: HelloWorld }];
export default routes;

2
src/stores/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './initPinia.ts'
export * from './modules'

9
src/stores/initPinia.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
export function initPinia() {
const pinia = createPinia()
// 持久化存储
pinia.use(piniaPluginPersistedState)
return pinia
}

View File

@@ -0,0 +1,210 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
login as loginApi,
register as registerApi,
refresh as refreshApi,
type LoginRequest,
type RegisterRequest
} from '@/api/modules/account'
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
/** 头像 */
avatar?: string | null
/** 个性签名 */
signature?: string | null
/** 年龄 */
age?: number | null
/** 性别1=男2=女 */
sex?: number | null
/** 生日 */
birthday?: string | null
/** 在线状态ONLINE / AWAY / DND / INVISIBLE */
status?: UserStatusType
}
export const useAccountStore = defineStore(
'account',
() => {
/** 账户信息 */
const accountInfo = ref<AccountState | null>(null)
/** Access Token30 分钟有效,每次请求通过 saToken 头携带) */
const accessToken = ref('')
/** Refresh Token30 天有效,仅用于刷新 Access Token */
const refreshToken = ref('')
/** token 是否已验证有效 */
const isTokenValid = ref(false)
/** 是否已完成初始验证(防止重复请求) */
const isInitialized = ref(false)
/**
* 用户登录
* 调用登录 API成功后存储双 Token 并获取用户信息
* @param params - 登录请求参数(用户名、密码)
*/
const login = async (params: LoginRequest) => {
const res = await loginApi(params)
accessToken.value = res.data.accessToken
refreshToken.value = res.data.refreshToken
await fetchUserInfo()
isTokenValid.value = true
}
/**
* 用户注册
* 调用注册 API成功后自动登录存储双 Token 并获取用户信息)
* @param params - 注册请求参数(用户名、密码)
*/
const register = async (params: RegisterRequest) => {
const res = await registerApi(params)
accessToken.value = res.data.accessToken
refreshToken.value = res.data.refreshToken
await fetchUserInfo()
isTokenValid.value = true
}
/**
* 退出登录
* 清除令牌、账户信息和验证状态
*/
const logout = () => {
clearAuth()
}
/**
* 获取当前登录用户信息
* 需要先有 Access Token 才能调用
*/
const fetchUserInfo = async () => {
const res = await getUserInfo()
accountInfo.value = res.data
}
/**
* 刷新 Access Token带并发锁
*
* 由刷新拦截器的 doRefreshToken 和 SSE 的 onUnauthorized 调用。
* 返回新的 Access Token 字符串,方便调用方直接使用。
*
* 并发控制:若已有刷新正在进行中,返回同一个 Promise
* 确保无论并发触发多少次,实际只发出一次 /account/refresh 请求。
*
* @returns 新的 Access Token
*/
/** 正在进行的刷新 Promisenull 表示无刷新进行中) */
let refreshPromise: Promise<string> | null = null
const refreshAccessToken = async (): Promise<string> => {
// 已有刷新进行中 → 复用同一个 Promise避免并发重复请求
if (refreshPromise) {
return refreshPromise
}
if (!refreshToken.value) {
throw new Error('无 Refresh Token无法刷新')
}
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 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 }
}
}
/** 清除所有认证状态 */
const clearAuth = () => {
accessToken.value = ''
refreshToken.value = ''
accountInfo.value = null
isTokenValid.value = false
isInitialized.value = false
}
console.log('useAccountStore', accessToken.value)
return {
accountInfo,
accessToken,
refreshToken,
login,
register,
logout,
refreshAccessToken,
fetchUserInfo,
updateProfile,
uploadAvatar,
removeAvatar,
updateUserStatus
}
},
{
persist: {
pick: ['accessToken', 'refreshToken']
}
}
)

View 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']
}
}
)

View File

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

177
src/stores/modules/room.ts Normal file
View 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
View File

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

37
src/utils/eventBus.ts Normal file
View File

@@ -0,0 +1,37 @@
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 = {
UNAUTHORIZED: 'app:unauthorized',
REQUEST_ERROR: 'app:request:error'
} as const
/**
* 应用级事件总线(基于 mitt
*
* @example
* ```ts
* // 注册事件
* eventBus.on(AppEvents.UNAUTHORIZED, () => {
* router.push('/auth/login')
* })
*
* // 发布事件
* eventBus.emit(AppEvents.UNAUTHORIZED)
* ```
*/
export const eventBus = mitt<AppEventsMap>()

3
src/utils/index.ts Normal file
View File

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

View File

@@ -0,0 +1,30 @@
import { customRef } from 'vue'
/**
* 创建防抖的响应式引用
* @param value - 初始值
* @param delay - 防抖延迟时间(毫秒),默认 200ms
* @returns 防抖的响应式引用
*/
export function useDebouncedRef<T>(value: T, delay: number = 300) {
let timeout: ReturnType<typeof setTimeout> | null = null
return customRef((track, trigger) => {
return {
get() {
track()
return value
},
set(newValue: T) {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
value = newValue
trigger()
}, delay)
}
}
})
}

242
src/views/auth/Login.vue Normal file
View File

@@ -0,0 +1,242 @@
<template>
<div class="login-wrapper">
<div class="login-wrapper__header">
<h2 class="login-wrapper__title">欢迎回来 👋🏻</h2>
<p>
<span style="color: var(--text-tertiary)">登录账号以进入对战</span>
</p>
</div>
<el-form
ref="formRef"
:model="loginModel"
:rules="rules"
hide-required-asterisk
class="login-wrapper__form"
>
<el-form-item prop="username">
<el-input
size="large"
v-model="loginModel.username"
placeholder="请输入账号"
:maxlength="20"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginModel.password"
size="large"
type="password"
show-password
placeholder="密码"
:maxlength="20"
/>
</el-form-item>
<el-form-item>
<div class="login-wrapper__actions">
<el-checkbox v-model="rememberAccount">记住账号</el-checkbox>
<a href="javascript:void(0)" style="color: var(--text-link)">忘记密码</a>
</div>
</el-form-item>
<el-form-item>
<el-button :loading="loading" style="width: 100%" type="primary" @click="userLoginFun"
>登录
</el-button>
</el-form-item>
<el-form-item>
<div class="login-wrapper__quick-btns">
<el-button class="login-wrapper__quick-btn" @click="router.push('/auth/phone-login')">
手机登录
</el-button>
<el-button class="login-wrapper__quick-btn" @click="router.push('/auth/qrcode-login')">
扫码登录
</el-button>
</div>
</el-form-item>
<el-form-item>
<div class="login-wrapper__register-link">
<span>没有账号</span>
<router-link to="/auth/register" style="color: var(--text-link)">创建账号</router-link>
</div>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, useTemplateRef, onMounted } from 'vue'
import type { FormRules, FormInstance } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useAccountStore } from '@/stores'
import { useRouter, useRoute } from 'vue-router'
/** 记住账号的 localStorage key */
const REMEMBER_KEY = 'login_remembered_username'
const router = useRouter()
const route = useRoute()
const accountStore = useAccountStore()
const formRef = useTemplateRef<FormInstance>('formRef')
const loginModel = reactive({
username: '',
password: ''
})
const loading = ref(false)
/** 是否记住账号 */
const rememberAccount = ref(false)
/** 页面初始化时恢复已记住的账号 */
onMounted(() => {
const savedUsername = localStorage.getItem(REMEMBER_KEY)
if (savedUsername) {
loginModel.username = savedUsername
rememberAccount.value = true
}
})
/** 账号验证规则 */
const validateUsername = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请输入登陆账号'))
} else if (value.length < 2) {
callback(new Error('账号长度不能小于2位'))
} else if (value.length > 20) {
callback(new Error('账号长度不能大于20位'))
} else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) {
callback(new Error('账号以字母开头,只能包含字母和数字'))
} else {
callback()
}
}
/** 密码验证规则 */
const validatePassword = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请输入密码'))
} else if (value.length < 8) {
callback(new Error('密码长度不能小于8位'))
} else if (value.length > 20) {
callback(new Error('密码长度不能大于20位'))
} else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(value)) {
callback(new Error('密码必须包含大小写字母和数字'))
} else {
callback()
}
}
const rules: FormRules = {
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
password: [{ required: true, trigger: 'blur', validator: validatePassword }]
}
/**
* 获取友好的错误提示信息
* @param error - 捕获的错误对象
* @returns 用户可读的错误文本
*/
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
// 网络错误
if (error.message?.includes('Network Error') || error.message?.includes('网络')) {
return '网络连接失败,请检查网络后重试'
}
return error.message
}
if (typeof error === 'object' && error !== null) {
const err = error as Record<string, unknown>
if (typeof err.message === 'string' && err.message) {
return err.message
}
}
return '登录失败,请稍后重试'
}
/** 用户登录 */
const userLoginFun = () => {
formRef.value?.validate((valid: boolean) => {
if (!valid) return
loading.value = true
accountStore
.login({
username: loginModel.username,
password: loginModel.password
})
.then(() => {
// 记住账号:将用户名存入 localStorage
if (rememberAccount.value) {
localStorage.setItem(REMEMBER_KEY, loginModel.username)
} else {
localStorage.removeItem(REMEMBER_KEY)
}
ElMessage.success('登录成功')
// 跳转到目标页面(有 redirect 参数则优先跳回原页面,否则去首页)
const redirectPath = (route.query.redirect as string) || '/'
router.push(redirectPath)
})
.catch((error: unknown) => {
ElMessage.error(getErrorMessage(error))
})
.finally(() => {
loading.value = false
})
})
}
</script>
<style scoped>
.login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.login-wrapper__header {
width: 100%;
}
.login-wrapper__title {
font-size: 30px;
line-height: 36px;
margin-bottom: 12px;
font-weight: bold;
}
@media (min-width: 1024px) {
.login-wrapper__title {
font-size: 36px;
}
}
.login-wrapper__form {
width: 100%;
margin-top: 20px;
}
.login-wrapper__actions {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
}
.login-wrapper__quick-btns {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
gap: 16px;
}
.login-wrapper__quick-btn {
flex: 1;
}
.login-wrapper__register-link {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<div class="phone-login-wrapper">
<div class="phone-login-wrapper__header">
<h2 class="phone-login-wrapper__title">欢迎回来 📲</h2>
<p>
<span style="color: var(--text-tertiary)">请输入手机号码登录账号以进入对战</span>
</p>
</div>
<el-form
ref="formRef"
:model="loginModel"
:rules="rules"
hide-required-asterisk
class="phone-login-wrapper__form"
>
<el-form-item prop="phoneNumber">
<el-input
size="large"
v-model="loginModel.phoneNumber"
placeholder="手机号码"
:maxlength="11"
/>
</el-form-item>
<el-form-item prop="code">
<div class="phone-login-wrapper__code-row">
<el-input-otp
v-model="loginModel.code"
:length="6"
:formatter="onlyAllowNumber"
style="flex: 2; --el-input-otp-gap: 4px"
>
<template #separator></template>
</el-input-otp>
<el-button style="flex: 1" @click="getPhoneCodeFun">获取验证码</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button :loading="loading" style="width: 100%" type="primary" @click="phoneLoginFun"
>登录
</el-button>
</el-form-item>
<el-form-item>
<div class="phone-login-wrapper__back-row">
<el-button class="phone-login-wrapper__back-btn" @click="router.push('/auth/login')">
返回
</el-button>
</div>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, useTemplateRef } from 'vue'
import type { FormRules, FormInstance } from 'element-plus'
import { useAccountStore } from '@/stores'
import { useRouter } from 'vue-router'
const router = useRouter()
const accountStore = useAccountStore()
const formRef = useTemplateRef<FormInstance>('formRef')
const loginModel = reactive({
phoneNumber: '',
code: ''
})
const loading = ref(false)
/** 仅允许输入数字字符 */
const onlyAllowNumber = (value: string) => value.replace(/\D/g, '')
/** 手机号验证规则 */
const validatePhoneNumber = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!/^1[3-9]\d{9}$/.test(value)) {
callback(new Error('请输入正确的手机号码'))
} else {
callback()
}
}
/** 验证码验证规则 */
const validateCode = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value || value.length !== 6) {
callback(new Error('请输入6位验证码'))
} else {
callback()
}
}
const rules: FormRules = {
phoneNumber: [{ required: true, trigger: 'blur', validator: validatePhoneNumber }],
code: [{ required: true, trigger: 'blur', validator: validateCode }]
}
const getPhoneCodeFun = () => {
formRef.value?.validateField('phoneNumber', (valid: boolean) => {
if (valid) {
console.log('获取验证码成功')
// TODO 获取验证码方法
}
})
}
const phoneLoginFun = () => {
formRef.value?.validate((valid: boolean) => {
if (valid) {
loading.value = true
console.log('登录成功')
// TODO 登录方法
accountStore.accessToken = '123456'
setTimeout(() => {
loading.value = false
}, 1000)
}
})
}
</script>
<style scoped>
.phone-login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.phone-login-wrapper__header {
width: 100%;
}
.phone-login-wrapper__title {
font-size: 30px;
line-height: 36px;
margin-bottom: 12px;
font-weight: bold;
}
@media (min-width: 1024px) {
.phone-login-wrapper__title {
font-size: 36px;
}
}
.phone-login-wrapper__form {
width: 100%;
margin-top: 20px;
}
.phone-login-wrapper__code-row {
display: flex;
flex: 1;
gap: 16px;
}
.phone-login-wrapper__back-row {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
gap: 16px;
}
.phone-login-wrapper__back-btn {
flex: 1;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<div class="qrcode-login-wrapper">
<div class="qrcode-login-wrapper__header">
<h2 class="qrcode-login-wrapper__title">欢迎回来 📱</h2>
<p>
<span style="color: var(--text-tertiary)">请用手机扫描二维码登录</span>
</p>
</div>
<el-form hide-required-asterisk class="qrcode-login-wrapper__form">
<el-form-item>
<div class="qrcode-login-wrapper__qrcode-area">
<canvas ref="qrcodeCanvas" width="180" height="180"></canvas>
<p>扫码后点击 '确认'即可完成登录</p>
</div>
</el-form-item>
<el-form-item>
<div class="qrcode-login-wrapper__back-row">
<el-button class="qrcode-login-wrapper__back-btn" @click="router.push('/auth/login')">
返回
</el-button>
</div>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, onMounted } from 'vue'
import QRCode from 'qrcode'
import { useRouter } from 'vue-router'
const router = useRouter()
const qrcodeCanvas = useTemplateRef<HTMLCanvasElement>('qrcodeCanvas')
/** 二维码内容TODO: 替换为实际登录凭证) */
const qrcodeValue = ref('https://example.com/login-qrcode')
onMounted(async () => {
if (qrcodeCanvas.value) {
await QRCode.toCanvas(qrcodeCanvas.value, qrcodeValue.value, {
width: 180,
margin: 2
})
}
})
</script>
<style scoped>
.qrcode-login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.qrcode-login-wrapper__header {
width: 100%;
}
.qrcode-login-wrapper__title {
font-size: 30px;
line-height: 36px;
margin-bottom: 12px;
font-weight: bold;
}
@media (min-width: 1024px) {
.qrcode-login-wrapper__title {
font-size: 36px;
}
}
.qrcode-login-wrapper__form {
width: 100%;
margin-top: 20px;
}
.qrcode-login-wrapper__qrcode-area {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.qrcode-login-wrapper__back-row {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
gap: 16px;
}
.qrcode-login-wrapper__back-btn {
flex: 1;
}
</style>

309
src/views/auth/Register.vue Normal file
View File

@@ -0,0 +1,309 @@
<template>
<div class="register-wrapper">
<div class="register-wrapper__header">
<h2 class="register-wrapper__title">创建一个账号 🚀</h2>
<p>
<span style="color: var(--text-tertiary)">创建账号进入对战</span>
</p>
</div>
<el-form
ref="formRef"
:model="registerModel"
:rules="rules"
hide-required-asterisk
class="register-wrapper__form"
>
<el-form-item prop="username">
<el-input
size="large"
v-model="registerModel.username"
placeholder="请输入账号"
:maxlength="20"
/>
</el-form-item>
<el-form-item prop="password">
<div class="register-wrapper__password-group">
<el-input
v-model="registerModel.password"
size="large"
type="password"
show-password
placeholder="密码"
:maxlength="20"
/>
<div class="register-wrapper__strength-bars">
<div v-for="level in 5" :key="level" class="register-wrapper__strength-bar-track">
<div
class="register-wrapper__strength-bar-fill"
:style="{ width: securityLevel >= level ? '100%' : '0' }"
></div>
</div>
</div>
<p class="register-wrapper__password-hint" style="color: var(--text-tertiary)">
使用 8 个或更多字符混合大小写字母数字
</p>
</div>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerModel.confirmPassword"
size="large"
type="password"
show-password
placeholder="确认密码"
:maxlength="20"
/>
</el-form-item>
<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: var(--text-link)">隐私政策 & 条款</a>
</div>
</el-form-item>
<el-form-item>
<el-button :loading="loading" style="width: 100%" type="primary" @click="userRegisterFun"
>注册
</el-button>
</el-form-item>
<el-form-item>
<div class="register-wrapper__login-link">
<span>已经有账号了</span>
<router-link to="/auth/login" style="color: var(--text-link)">去登录</router-link>
</div>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, useTemplateRef, watch } from 'vue'
import type { FormRules, FormInstance } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useAccountStore } from '@/stores'
import { useRouter } from 'vue-router'
const router = useRouter()
const accountStore = useAccountStore()
const formRef = useTemplateRef<FormInstance>('formRef')
const registerModel = reactive({
username: '',
password: '',
confirmPassword: '',
agreement: false
})
const loading = ref(false)
const securityLevel = ref(0)
watch(
() => registerModel.password,
(value) => {
checkPasswordStrength(value)
}
)
/** 检测密码强度0-5 级) */
const checkPasswordStrength = (password: string) => {
let strength = 0
if (password.length >= 8) {
strength++
}
if (/[a-z]/.test(password)) {
strength++
}
if (/[A-Z]/.test(password)) {
strength++
}
if (/[0-9]/.test(password)) {
strength++
}
strength = Math.min(strength, 5)
securityLevel.value = strength
}
/** 账号验证规则 */
const validateUsername = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请输入登陆账号'))
} else if (value.length < 2) {
callback(new Error('账号长度不能小于2位'))
} else if (value.length > 20) {
callback(new Error('账号长度不能大于20位'))
} else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) {
callback(new Error('账号以字母开头,只能包含字母和数字'))
} else {
callback()
}
}
/** 密码验证规则 */
const validatePassword = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请输入密码'))
} else if (value.length < 8) {
callback(new Error('密码长度不能小于8位'))
} else if (value.length > 20) {
callback(new Error('密码长度不能大于20位'))
} else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(value)) {
callback(new Error('密码必须包含大小写字母和数字'))
} else {
callback()
}
}
/** 确认密码验证规则 */
const validateConfirmPassword = (
_rule: unknown,
value: string,
callback: (error?: Error) => void
) => {
if (!value) {
callback(new Error('请再次输入密码'))
} else if (registerModel.password !== value) {
callback(new Error('密码不一致'))
} else {
callback()
}
}
/** 协议同意验证规则 */
const validateAgreement = (_rule: unknown, value: boolean, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请勾选同意协议'))
} else {
callback()
}
}
const rules: FormRules = {
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
password: [{ required: true, trigger: 'blur', validator: validatePassword }],
confirmPassword: [{ required: true, trigger: 'blur', validator: validateConfirmPassword }],
agreement: [{ required: true, trigger: 'change', validator: validateAgreement }]
}
/**
* 获取友好的错误提示信息
* @param error - 捕获的错误对象
* @returns 用户可读的错误文本
*/
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
if (error.message?.includes('Network Error') || error.message?.includes('网络')) {
return '网络连接失败,请检查网络后重试'
}
return error.message
}
if (typeof error === 'object' && error !== null) {
const err = error as Record<string, unknown>
if (typeof err.message === 'string' && err.message) {
return err.message
}
}
return '注册失败,请稍后重试'
}
/** 用户注册 */
const userRegisterFun = () => {
formRef.value?.validate((valid: boolean) => {
if (!valid) return
loading.value = true
accountStore
.register({
username: registerModel.username,
password: registerModel.password
})
.then(() => {
ElMessage.success('注册成功')
// 注册成功后跳转首页
router.push('/')
})
.catch((error: unknown) => {
ElMessage.error(getErrorMessage(error))
})
.finally(() => {
loading.value = false
})
})
}
</script>
<style scoped>
.register-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.register-wrapper__header {
width: 100%;
}
.register-wrapper__title {
font-size: 30px;
line-height: 36px;
margin-bottom: 12px;
font-weight: bold;
}
@media (min-width: 1024px) {
.register-wrapper__title {
font-size: 36px;
}
}
.register-wrapper__form {
width: 100%;
margin-top: 20px;
}
.register-wrapper__password-group {
display: flex;
flex-direction: column;
flex: 1;
}
.register-wrapper__strength-bars {
display: flex;
flex: 1;
gap: 4px;
margin-top: 8px;
}
.register-wrapper__strength-bar-track {
flex: 1;
height: 8px;
border-radius: 12px;
position: relative;
background-color: var(--border-default);
overflow: hidden;
}
.register-wrapper__strength-bar-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: var(--color-danger);
transition: all 500ms;
}
.register-wrapper__password-hint {
font-size: 12px;
margin-top: 8px;
margin-bottom: 0;
line-height: normal;
}
.register-wrapper__agreement {
display: flex;
flex: 1;
}
.register-wrapper__login-link {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
</style>

197
src/views/game/RoomPage.vue Normal file
View 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>

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More