feat: user-bar 修改

This commit is contained in:
2026-06-22 10:32:53 +08:00
parent 0b7a89c06b
commit 7d2d6707b7
10 changed files with 400 additions and 586 deletions

View File

@@ -1,3 +1,2 @@
# 开发环境启用 Mock 拦截器
# 后端接口就绪后设为 false 即可切换到真实请求
VITE_USE_MOCK=true
# 已切换为真实后端接口
VITE_USE_MOCK=false

2
components.d.ts vendored
View File

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

View File

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

View File

@@ -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')}`
}

View File

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

View File

@@ -23,6 +23,8 @@ export type SexType = (typeof Sex)[keyof typeof Sex]
* 所有字段均为可选,部分字段可能为 null
*/
export interface UserInfoResponse {
/** 用户唯一标识UUID */
userId?: string
/** 用户昵称,最长 16 位 */
nickName?: string
/** 头像图片 URL 地址,可能为 null */

View File

@@ -36,7 +36,7 @@ function resolvePathParams(url: string, pathParams?: Record<string, string | num
* ```
*/
class RequestClient {
/** axios 实例(暴露给 mock 等外部模块使用) */
/** axios 实例 */
public instance: AxiosInstance
/** 是否正在刷新 token */

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ import { getUserInfo } from '@/api/modules/user'
/** 账户信息 */
export interface AccountState {
/** 用户唯一标识UUID */
userId?: string
/** 用户昵称 */
nickName?: string
/** 头像 */