feat: user-bar 修改
This commit is contained in:
@@ -1,3 +1,2 @@
|
||||
# 开发环境启用 Mock 拦截器
|
||||
# 后端接口就绪后设为 false 即可切换到真实请求
|
||||
VITE_USE_MOCK=true
|
||||
# 已切换为真实后端接口
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
2
components.d.ts
vendored
2
components.d.ts
vendored
@@ -26,6 +26,7 @@ declare module 'vue' {
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
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']
|
||||
@@ -52,6 +53,7 @@ declare global {
|
||||
const ElOption: typeof import('element-plus/es')['ElOption']
|
||||
const ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
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']
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { GameItem } from '../modules/game/types'
|
||||
|
||||
/** 游戏列表 Mock 数据 */
|
||||
export const mockGameList: GameItem[] = [
|
||||
{
|
||||
id: 'snake',
|
||||
icon: '🐍',
|
||||
name: '贪吃蛇',
|
||||
description: '经典贪吃蛇竞技,在狭小空间内争夺生存权',
|
||||
gameplay: '方向键控制蛇的移动,吃到食物变长。撞墙或其他蛇的身体即死亡,最后存活的玩家获胜。',
|
||||
config: {
|
||||
maxPlayers: 4,
|
||||
modes: ['经典模式', '道具模式', '加速模式'],
|
||||
stakesOptions: [100, 200, 300, 400, 500]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tetris',
|
||||
icon: '🧊',
|
||||
name: '俄罗斯方块',
|
||||
description: '经典下落消除,与对手一较高下',
|
||||
gameplay: '方向键移动和旋转方块,填满横排即可消除。同时向对手发送干扰行,坚持到最后的玩家获胜。',
|
||||
config: {
|
||||
maxPlayers: 2,
|
||||
modes: ['1v1对战', '竞速模式'],
|
||||
stakesOptions: [150, 200, 300, 400, 500]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'gomoku',
|
||||
icon: '⚫',
|
||||
name: '五子棋',
|
||||
description: '黑白对弈,先连成五子者胜',
|
||||
gameplay: '鼠标点击落子,黑白双方轮流在棋盘上放置棋子。先在横、竖、斜任意方向连成五子的一方获胜。',
|
||||
config: {
|
||||
maxPlayers: 2,
|
||||
modes: ['无禁手', '有禁手'],
|
||||
stakesOptions: [100, 200, 300, 400, 500]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guess-number',
|
||||
icon: '🔢',
|
||||
name: '猜数字',
|
||||
description: '逻辑推理对战,猜中对方的秘密数字',
|
||||
gameplay: '每轮输入猜测的数字组合,系统会提示位置和数字正确的数量。率先猜中对手数字组合的玩家获胜。',
|
||||
config: {
|
||||
maxPlayers: 4,
|
||||
modes: ['经典模式', '限时模式'],
|
||||
stakesOptions: [100, 150, 200, 300, 500]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,365 +0,0 @@
|
||||
import type { RoomItem } from '../modules/game/types'
|
||||
|
||||
/** 根据名字生成头像 URL */
|
||||
function avatarUrl(name: string): string {
|
||||
return `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
/** 各游戏的房间 Mock 数据 */
|
||||
export const mockRoomList: Record<string, RoomItem[]> = {
|
||||
/* ---- 贪吃蛇 ---- */
|
||||
snake: [
|
||||
{
|
||||
id: 'snake-001',
|
||||
name: '桌 01',
|
||||
gameIcon: '🐍',
|
||||
gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('小龙'), nickname: '小龙' },
|
||||
{ avatar: avatarUrl('阿蟒'), nickname: '阿蟒' }
|
||||
],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 300,
|
||||
mode: '经典模式',
|
||||
creatorName: '小龙'
|
||||
},
|
||||
{
|
||||
id: 'snake-002',
|
||||
name: '桌 02',
|
||||
gameIcon: '🐍',
|
||||
gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('闪电'), nickname: '闪电' },
|
||||
{ avatar: avatarUrl('疾风'), nickname: '疾风' },
|
||||
{ avatar: avatarUrl('游侠'), nickname: '游侠' },
|
||||
{ avatar: avatarUrl('幻影'), nickname: '幻影' }
|
||||
],
|
||||
status: 'playing',
|
||||
statusText: '进行中',
|
||||
stakes: 500,
|
||||
mode: '道具模式',
|
||||
creatorName: '闪电'
|
||||
},
|
||||
{
|
||||
id: 'snake-003',
|
||||
name: '桌 03',
|
||||
gameIcon: '🐍',
|
||||
gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [{ avatar: avatarUrl('青蛇'), nickname: '青蛇' }],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 200,
|
||||
mode: '加速模式',
|
||||
creatorName: '青蛇'
|
||||
},
|
||||
{
|
||||
id: 'snake-004',
|
||||
name: '桌 04',
|
||||
gameIcon: '🐍',
|
||||
gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 100,
|
||||
mode: '经典模式',
|
||||
creatorName: '毒牙'
|
||||
},
|
||||
{
|
||||
id: 'snake-005',
|
||||
name: '桌 05',
|
||||
gameIcon: '🐍',
|
||||
gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('巨蟒'), nickname: '巨蟒' },
|
||||
{ avatar: avatarUrl('灵蛇'), nickname: '灵蛇' },
|
||||
{ avatar: avatarUrl('毒牙'), nickname: '毒牙' }
|
||||
],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 400,
|
||||
mode: '道具模式',
|
||||
creatorName: '巨蟒'
|
||||
}
|
||||
],
|
||||
|
||||
/* ---- 俄罗斯方块 ---- */
|
||||
tetris: [
|
||||
{
|
||||
id: 'tetris-001',
|
||||
name: '桌 01',
|
||||
gameIcon: '🧊',
|
||||
gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [{ avatar: avatarUrl('方块大师'), nickname: '方块大师' }],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 300,
|
||||
mode: '1v1 对战',
|
||||
creatorName: '方块大师'
|
||||
},
|
||||
{
|
||||
id: 'tetris-002',
|
||||
name: '桌 02',
|
||||
gameIcon: '🧊',
|
||||
gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('极速下落'), nickname: '极速下落' },
|
||||
{ avatar: avatarUrl('消行高手'), nickname: '消行高手' }
|
||||
],
|
||||
status: 'playing',
|
||||
statusText: '进行中',
|
||||
stakes: 500,
|
||||
mode: '竞速模式',
|
||||
creatorName: '极速下落'
|
||||
},
|
||||
{
|
||||
id: 'tetris-003',
|
||||
name: '桌 03',
|
||||
gameIcon: '🧊',
|
||||
gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 150,
|
||||
mode: '1v1 对战',
|
||||
creatorName: '堆叠王者'
|
||||
},
|
||||
{
|
||||
id: 'tetris-004',
|
||||
name: '桌 04',
|
||||
gameIcon: '🧊',
|
||||
gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [{ avatar: avatarUrl('T旋大师'), nickname: 'T旋大师' }],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 200,
|
||||
mode: '1v1 对战',
|
||||
creatorName: 'T旋大师'
|
||||
},
|
||||
{
|
||||
id: 'tetris-005',
|
||||
name: '桌 05',
|
||||
gameIcon: '🧊',
|
||||
gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('连击王'), nickname: '连击王' },
|
||||
{ avatar: avatarUrl('消除者'), nickname: '消除者' }
|
||||
],
|
||||
status: 'playing',
|
||||
statusText: '进行中',
|
||||
stakes: 400,
|
||||
mode: '竞速模式',
|
||||
creatorName: '连击王'
|
||||
}
|
||||
],
|
||||
|
||||
/* ---- 五子棋 ---- */
|
||||
gomoku: [
|
||||
{
|
||||
id: 'gomoku-001',
|
||||
name: '桌 01',
|
||||
gameIcon: '⚫',
|
||||
gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [{ avatar: avatarUrl('棋圣'), nickname: '棋圣' }],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 200,
|
||||
mode: '无禁手',
|
||||
creatorName: '棋圣'
|
||||
},
|
||||
{
|
||||
id: 'gomoku-002',
|
||||
name: '桌 02',
|
||||
gameIcon: '⚫',
|
||||
gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('黑白子'), nickname: '黑白子' },
|
||||
{ avatar: avatarUrl('五连珠'), nickname: '五连珠' }
|
||||
],
|
||||
status: 'playing',
|
||||
statusText: '进行中',
|
||||
stakes: 400,
|
||||
mode: '有禁手',
|
||||
creatorName: '黑白子'
|
||||
},
|
||||
{
|
||||
id: 'gomoku-003',
|
||||
name: '桌 03',
|
||||
gameIcon: '⚫',
|
||||
gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 100,
|
||||
mode: '无禁手',
|
||||
creatorName: '落子无悔'
|
||||
},
|
||||
{
|
||||
id: 'gomoku-004',
|
||||
name: '桌 04',
|
||||
gameIcon: '⚫',
|
||||
gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [{ avatar: avatarUrl('天元'), nickname: '天元' }],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 300,
|
||||
mode: '有禁手',
|
||||
creatorName: '天元'
|
||||
},
|
||||
{
|
||||
id: 'gomoku-005',
|
||||
name: '桌 05',
|
||||
gameIcon: '⚫',
|
||||
gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('先手必胜'), nickname: '先手必胜' },
|
||||
{ avatar: avatarUrl('后手无敌'), nickname: '后手无敌' }
|
||||
],
|
||||
status: 'playing',
|
||||
statusText: '进行中',
|
||||
stakes: 500,
|
||||
mode: '有禁手',
|
||||
creatorName: '先手必胜'
|
||||
}
|
||||
],
|
||||
|
||||
/* ---- 猜数字 ---- */
|
||||
'guess-number': [
|
||||
{
|
||||
id: 'guess-001',
|
||||
name: '桌 01',
|
||||
gameIcon: '🔢',
|
||||
gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('逻辑怪'), nickname: '逻辑怪' },
|
||||
{ avatar: avatarUrl('推理王'), nickname: '推理王' }
|
||||
],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 200,
|
||||
mode: '经典模式',
|
||||
creatorName: '逻辑怪'
|
||||
},
|
||||
{
|
||||
id: 'guess-002',
|
||||
name: '桌 02',
|
||||
gameIcon: '🔢',
|
||||
gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('秒猜'), nickname: '秒猜' },
|
||||
{ avatar: avatarUrl('心算'), nickname: '心算' },
|
||||
{ avatar: avatarUrl('直觉'), nickname: '直觉' }
|
||||
],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 300,
|
||||
mode: '限时模式',
|
||||
creatorName: '秒猜'
|
||||
},
|
||||
{
|
||||
id: 'guess-003',
|
||||
name: '桌 03',
|
||||
gameIcon: '🔢',
|
||||
gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [{ avatar: avatarUrl('数字猎人'), nickname: '数字猎人' }],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 150,
|
||||
mode: '经典模式',
|
||||
creatorName: '数字猎人'
|
||||
},
|
||||
{
|
||||
id: 'guess-004',
|
||||
name: '桌 04',
|
||||
gameIcon: '🔢',
|
||||
gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('密码破解'), nickname: '密码破解' },
|
||||
{ avatar: avatarUrl('枚举大师'), nickname: '枚举大师' },
|
||||
{ avatar: avatarUrl('二分法'), nickname: '二分法' },
|
||||
{ avatar: avatarUrl('穷举王'), nickname: '穷举王' }
|
||||
],
|
||||
status: 'playing',
|
||||
statusText: '进行中',
|
||||
stakes: 500,
|
||||
mode: '限时模式',
|
||||
creatorName: '密码破解'
|
||||
},
|
||||
{
|
||||
id: 'guess-005',
|
||||
name: '桌 05',
|
||||
gameIcon: '🔢',
|
||||
gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes: 100,
|
||||
mode: '经典模式',
|
||||
creatorName: '神秘数字'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化各游戏当前最大桌号计数器
|
||||
*
|
||||
* 从现有 mock 数据中扫描每个游戏最大桌号作为初始值。
|
||||
* 后续创建房间时从该值 +1 开始递增。
|
||||
*/
|
||||
function initRoomCounters(): Record<string, number> {
|
||||
const counters: Record<string, number> = {}
|
||||
for (const [gameId, rooms] of Object.entries(mockRoomList)) {
|
||||
let max = 0
|
||||
for (const room of rooms) {
|
||||
const match = room.name.match(/^桌\s*(\d+)$/)
|
||||
if (match) {
|
||||
// @ts-ignore
|
||||
const num = parseInt(match[1], 10)
|
||||
if (num > max) max = num
|
||||
}
|
||||
}
|
||||
counters[gameId] = max
|
||||
}
|
||||
return counters
|
||||
}
|
||||
|
||||
/** 各游戏当前最大桌号计数器 */
|
||||
export const roomCounters: Record<string, number> = initRoomCounters()
|
||||
|
||||
/**
|
||||
* 获取下一个可用桌号
|
||||
*
|
||||
* 格式:"桌 01"、"桌 02" … 两位数补零。
|
||||
* 每次调用后计数器自增。
|
||||
*
|
||||
* @param gameId - 游戏 ID
|
||||
* @returns 格式化的桌号字符串,如 "桌 06"
|
||||
*/
|
||||
export function nextRoomTableNumber(gameId: string): string {
|
||||
if (!roomCounters[gameId]) {
|
||||
roomCounters[gameId] = 0
|
||||
}
|
||||
roomCounters[gameId]++
|
||||
const num = roomCounters[gameId]
|
||||
return `桌 ${String(num).padStart(2, '0')}`
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import { requestClient } from '@/api/request'
|
||||
import { mockGameList } from './game-list'
|
||||
import { mockRoomList, nextRoomTableNumber } from './game-room'
|
||||
import type { CreateRoomRequest, GetRoomListRequest, RoomItem } from '../modules/game/types'
|
||||
|
||||
/**
|
||||
* Mock 处理器函数类型
|
||||
*
|
||||
* 接收 axios 配置,返回 mock 响应数据体(ApiResponse.data 部分)
|
||||
*/
|
||||
type MockHandler = (config: InternalAxiosRequestConfig) => unknown
|
||||
|
||||
/** Mock 路由条目 */
|
||||
interface MockRoute {
|
||||
/** 请求方法,小写 */
|
||||
method: string
|
||||
/** URL 路径(支持精确匹配和前缀匹配) */
|
||||
url: string
|
||||
/** 模拟网络延迟(毫秒),默认 150ms */
|
||||
delay?: number
|
||||
/** 处理器 */
|
||||
handler: MockHandler
|
||||
}
|
||||
|
||||
/** Mock 路由表 */
|
||||
const mockRoutes: MockRoute[] = [
|
||||
{
|
||||
method: 'get',
|
||||
url: '/game/list',
|
||||
delay: 120,
|
||||
handler: () => ({ list: mockGameList })
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
url: '/game/room/list',
|
||||
delay: 150,
|
||||
handler: (config) => {
|
||||
const params = config.params as GetRoomListRequest | undefined
|
||||
const gameId = params?.gameId ?? 'snake'
|
||||
const statusFilter = params?.status
|
||||
let rooms = mockRoomList[gameId] ?? []
|
||||
if (statusFilter) {
|
||||
rooms = rooms.filter((r) => r.status === statusFilter)
|
||||
}
|
||||
return { list: rooms }
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
url: '/game/room/create',
|
||||
delay: 200,
|
||||
handler: (config) => {
|
||||
const body = JSON.parse(config.data as string) as CreateRoomRequest
|
||||
const { gameId, name, maxPlayers, mode, stakes } = body
|
||||
|
||||
const tableName = nextRoomTableNumber(gameId)
|
||||
// 从现有最大 ID 编号 +1 生成新 ID
|
||||
const existingRooms = mockRoomList[gameId] ?? []
|
||||
let maxIdNum = 0
|
||||
for (const room of existingRooms) {
|
||||
const match = room.id.match(/-(\d+)$/)
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10)
|
||||
if (num > maxIdNum) maxIdNum = num
|
||||
}
|
||||
}
|
||||
const newId = `${gameId}-${String(maxIdNum + 1).padStart(3, '0')}`
|
||||
|
||||
const newRoom: RoomItem = {
|
||||
id: newId,
|
||||
name: name || tableName,
|
||||
gameIcon: mockGameList.find((g) => g.id === gameId)?.icon ?? '🎮',
|
||||
gameId,
|
||||
maxPlayers,
|
||||
players: [],
|
||||
status: 'waiting',
|
||||
statusText: '等待中',
|
||||
stakes,
|
||||
mode,
|
||||
creatorName: '当前玩家'
|
||||
}
|
||||
|
||||
// 将新房间插入到列表头部
|
||||
if (!mockRoomList[gameId]) {
|
||||
mockRoomList[gameId] = []
|
||||
}
|
||||
mockRoomList[gameId].unshift(newRoom)
|
||||
|
||||
return { room: newRoom }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 安装 Mock 拦截器
|
||||
*
|
||||
* 在 axios 请求拦截器链中注入 mock 逻辑:
|
||||
* 当请求 URL + method 匹配 mock 路由表时,将请求适配器替换为 mock 处理器,
|
||||
* 绕开真实 HTTP 请求,直接返回模拟数据。
|
||||
*
|
||||
* 仅在开发环境且 VITE_USE_MOCK=true 时启用。
|
||||
*/
|
||||
export function setupMock(): void {
|
||||
// 仅在 mock 模式启用时注册拦截器
|
||||
if (import.meta.env.VITE_USE_MOCK !== 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
requestClient.instance.interceptors.request.use((config) => {
|
||||
const method = (config.method ?? 'get').toLowerCase()
|
||||
const url = config.url ?? ''
|
||||
|
||||
// 查找匹配的 mock 路由
|
||||
const route = mockRoutes.find((r) => r.method === method && url === r.url)
|
||||
if (!route) return config
|
||||
|
||||
// 替换适配器,使请求不发出 HTTP 调用,直接返回 mock 数据
|
||||
config.adapter = async (adapterConfig: AxiosRequestConfig) => {
|
||||
// 模拟网络延迟
|
||||
const delay = route.delay ?? 150
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
|
||||
const mockData = route.handler(adapterConfig as InternalAxiosRequestConfig)
|
||||
|
||||
// 构造符合 ApiResponse 格式的响应
|
||||
const response: AxiosResponse = {
|
||||
data: { code: 0, message: 'ok', data: mockData },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: adapterConfig as InternalAxiosRequestConfig,
|
||||
request: {}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新导出 mock 数据,供开发调试使用
|
||||
*/
|
||||
export { mockGameList, mockRoomList }
|
||||
@@ -23,6 +23,8 @@ export type SexType = (typeof Sex)[keyof typeof Sex]
|
||||
* 所有字段均为可选,部分字段可能为 null
|
||||
*/
|
||||
export interface UserInfoResponse {
|
||||
/** 用户唯一标识(UUID) */
|
||||
userId?: string
|
||||
/** 用户昵称,最长 16 位 */
|
||||
nickName?: string
|
||||
/** 头像图片 URL 地址,可能为 null */
|
||||
|
||||
@@ -36,7 +36,7 @@ function resolvePathParams(url: string, pathParams?: Record<string, string | num
|
||||
* ```
|
||||
*/
|
||||
class RequestClient {
|
||||
/** axios 实例(暴露给 mock 等外部模块使用) */
|
||||
/** axios 实例 */
|
||||
public instance: AxiosInstance
|
||||
|
||||
/** 是否正在刷新 token */
|
||||
|
||||
@@ -1,23 +1,117 @@
|
||||
<template>
|
||||
<div class="user-bar">
|
||||
<div class="container">
|
||||
<el-popover trigger="click" :width="260">
|
||||
<el-popover
|
||||
trigger="click"
|
||||
:width="280"
|
||||
:show-arrow="false"
|
||||
:offset="14"
|
||||
placement="top"
|
||||
popper-class="user-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="user-info">
|
||||
<el-badge is-dot :offset="[-4, 20]" class="user-badge">
|
||||
<el-avatar
|
||||
:size="28"
|
||||
shape="circle"
|
||||
src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
|
||||
/>
|
||||
<el-avatar :size="28" shape="circle" :src="userAvatar" />
|
||||
</el-badge>
|
||||
<div class="user-name">
|
||||
<div class="user-title">MarchGlow</div>
|
||||
<div class="user-state">在线</div>
|
||||
<div class="user-title">{{ displayName }}</div>
|
||||
<div class="user-state">{{ currentStatus.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span>I wish they all could be California girls</span>
|
||||
|
||||
<!-- 弹出面板内容 -->
|
||||
<div class="user-panel">
|
||||
<!-- 顶部:大头像 + 昵称 + 在线状态 -->
|
||||
<div class="user-panel__header">
|
||||
<el-avatar :size="56" shape="circle" :src="userAvatar" class="user-panel__avatar" />
|
||||
<div class="user-panel__identity">
|
||||
<div class="user-panel__nickname">{{ displayName }}</div>
|
||||
<div class="user-panel__status">
|
||||
<span
|
||||
class="user-panel__status-dot"
|
||||
:style="{ backgroundColor: currentStatus.color }"
|
||||
></span>
|
||||
<span>{{ currentStatus.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 个性签名 -->
|
||||
<div class="user-panel__signature">
|
||||
{{ signatureText }}
|
||||
</div>
|
||||
|
||||
<div class="user-panel__divider"></div>
|
||||
|
||||
<!-- 详细设置区:编辑个人资料 + 在线状态 -->
|
||||
<div class="user-panel__section">
|
||||
<div class="user-panel__item" @click="handleEditProfile">
|
||||
<el-icon :size="18"><EditPen /></el-icon>
|
||||
<span>编辑个人资料</span>
|
||||
</div>
|
||||
|
||||
<!-- 在线状态(父项 + 二级菜单) -->
|
||||
<div class="user-panel__status-group">
|
||||
<div class="user-panel__item" @click="toggleStatusMenu">
|
||||
<span
|
||||
class="user-panel__status-dot"
|
||||
:style="{ backgroundColor: currentStatus.color }"
|
||||
></span>
|
||||
<span>在线状态</span>
|
||||
<span class="user-panel__status-current">{{ currentStatus.label }}</span>
|
||||
<el-icon
|
||||
:size="14"
|
||||
class="user-panel__chevron"
|
||||
:class="{ 'is-open': showStatusMenu }"
|
||||
>
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 二级菜单:状态选项列表 -->
|
||||
<Transition name="status-menu">
|
||||
<div v-show="showStatusMenu" class="user-panel__status-submenu">
|
||||
<div
|
||||
v-for="status in statusOptions"
|
||||
:key="status.key"
|
||||
class="user-panel__status-option"
|
||||
:class="{ 'is-active': currentStatus.key === status.key }"
|
||||
@click="selectStatus(status)"
|
||||
>
|
||||
<span
|
||||
class="user-panel__status-dot"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
></span>
|
||||
<span>{{ status.label }}</span>
|
||||
<el-icon
|
||||
v-if="currentStatus.key === status.key"
|
||||
:size="14"
|
||||
class="user-panel__check"
|
||||
>
|
||||
<Check />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</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">
|
||||
@@ -28,10 +122,111 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Setting } from '@element-plus/icons-vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Setting,
|
||||
EditPen,
|
||||
ArrowRight,
|
||||
Check,
|
||||
DocumentCopy,
|
||||
SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAccountStore } from '@/stores'
|
||||
|
||||
/** 在线状态选项 */
|
||||
interface StatusOption {
|
||||
key: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: StatusOption[] = [
|
||||
{ key: 'online', label: '在线', color: 'var(--color-success)' },
|
||||
{ key: 'away', label: '离开', color: 'var(--color-warning)' },
|
||||
{ key: 'dnd', label: '请勿打扰', color: 'var(--color-danger)' },
|
||||
{ key: 'invisible', label: '隐身', color: 'var(--text-tertiary)' }
|
||||
]
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { accountInfo } = storeToRefs(accountStore)
|
||||
|
||||
/** 当前在线状态(默认:在线) */
|
||||
const currentStatus = ref<StatusOption>(STATUS_OPTIONS[0]!)
|
||||
/** 状态二级菜单是否展开 */
|
||||
const showStatusMenu = ref(false)
|
||||
/** 在线状态选项列表 */
|
||||
const statusOptions = STATUS_OPTIONS
|
||||
|
||||
/** 用户头像(优先使用 store 中的头像,否则使用默认占位) */
|
||||
const userAvatar = computed(() => {
|
||||
return accountInfo.value?.avatar || ''
|
||||
})
|
||||
|
||||
/** 显示昵称(未登录或未加载时显示默认文案) */
|
||||
const displayName = computed(() => {
|
||||
return accountInfo.value?.nickName || '未登录'
|
||||
})
|
||||
|
||||
/** 个性签名(无签名时显示默认文案) */
|
||||
const signatureText = computed(() => {
|
||||
return accountInfo.value?.signature || '这个人很懒,什么都没写...'
|
||||
})
|
||||
|
||||
/**
|
||||
* 编辑个人资料
|
||||
* 预留功能入口,后续可弹出编辑弹窗或跳转到设置页
|
||||
*/
|
||||
const handleEditProfile = () => {
|
||||
ElMessage.info('编辑个人资料功能开发中')
|
||||
}
|
||||
|
||||
/** 展开/收起在线状态二级菜单 */
|
||||
const toggleStatusMenu = () => {
|
||||
showStatusMenu.value = !showStatusMenu.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择一个在线状态
|
||||
* @param status - 选中的状态选项
|
||||
*/
|
||||
const selectStatus = (status: StatusOption) => {
|
||||
currentStatus.value = status
|
||||
showStatusMenu.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制账号ID到剪贴板
|
||||
* 复制用户的 UUID 标识符
|
||||
*/
|
||||
const handleCopyUserId = async () => {
|
||||
const userId = accountInfo.value?.userId
|
||||
if (!userId) {
|
||||
ElMessage.warning('账号ID获取失败,请刷新后重试')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(userId)
|
||||
ElMessage.success('账号ID已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* 调用 store 清除认证状态,后续可跳转至登录页
|
||||
*/
|
||||
const handleLogout = () => {
|
||||
accountStore.logout()
|
||||
ElMessage.success('已退出登录')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ========== user-bar 容器 ========== */
|
||||
.user-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -47,9 +242,8 @@ import { Setting } from '@element-plus/icons-vue'
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.user-badge :deep(.el-badge__content.is-dot) {
|
||||
background-color: var(--color-success);
|
||||
background-color: v-bind('currentStatus.color');
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -80,7 +274,6 @@ import { Setting } from '@element-plus/icons-vue'
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
/* 默认内容宽度,超长时上限 160px 触发省略 */
|
||||
max-width: 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -122,16 +315,199 @@ import { Setting } from '@element-plus/icons-vue'
|
||||
background-color: var(--bg-surface-hover);
|
||||
color: var(--text-primary);
|
||||
|
||||
/* 移入时缓动旋转 180°,完成后保持 */
|
||||
:deep(.el-icon) {
|
||||
transition: transform 0.6s cubic-bezier(0.6, 0, 1, 1);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移出时无过渡,瞬间复位 */
|
||||
:deep(.el-icon) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 弹出面板 ========== */
|
||||
.user-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- 顶部头像区 ---- */
|
||||
.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__status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 签名区 ---- */
|
||||
.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__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__status-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 当前选中状态标签(显示在父项右侧) */
|
||||
.user-panel__status-current {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 父项中的箭头图标 */
|
||||
.user-panel__chevron {
|
||||
color: var(--text-tertiary);
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 二级菜单容器 ---- */
|
||||
.user-panel__status-submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px 0 4px 28px; /* 左侧缩进,与父项文字对齐 */
|
||||
}
|
||||
|
||||
/* 二级菜单单项 */
|
||||
.user-panel__status-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* 当前选中项的对勾图标 */
|
||||
.user-panel__check {
|
||||
margin-left: auto;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ---- 二级菜单展开/收起动画 ---- */
|
||||
.status-menu-enter-active {
|
||||
transition:
|
||||
max-height 0.25s ease,
|
||||
opacity 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.status-menu-leave-active {
|
||||
transition:
|
||||
max-height 0.2s ease,
|
||||
opacity 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.status-menu-enter-from,
|
||||
.status-menu-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.status-menu-enter-to,
|
||||
.status-menu-leave-from {
|
||||
max-height: 200px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 覆盖 el-popover 默认样式(非 scoped,因为 popover 渲染在 body 下) -->
|
||||
<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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,14 +3,10 @@ import 'element-plus/dist/index.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { initPinia } from './stores'
|
||||
import { setupMock } from './api/mock'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
/** 开发环境启用 Mock 数据拦截 */
|
||||
setupMock()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(initPinia())
|
||||
|
||||
@@ -11,6 +11,8 @@ import { getUserInfo } from '@/api/modules/user'
|
||||
|
||||
/** 账户信息 */
|
||||
export interface AccountState {
|
||||
/** 用户唯一标识(UUID) */
|
||||
userId?: string
|
||||
/** 用户昵称 */
|
||||
nickName?: string
|
||||
/** 头像 */
|
||||
|
||||
Reference in New Issue
Block a user