feat: user-bar 修改
This commit is contained in:
8
components.d.ts
vendored
8
components.d.ts
vendored
@@ -23,9 +23,13 @@ declare module 'vue' {
|
|||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
||||||
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSubmenu: typeof import('element-plus/es')['ElSubmenu']
|
||||||
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
|
GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
|
||||||
@@ -50,9 +54,13 @@ declare global {
|
|||||||
const ElIcon: typeof import('element-plus/es')['ElIcon']
|
const ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
const ElInput: typeof import('element-plus/es')['ElInput']
|
const ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
const ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
const ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
||||||
|
const ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
|
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
const ElOption: typeof import('element-plus/es')['ElOption']
|
const ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
const ElPopover: typeof import('element-plus/es')['ElPopover']
|
const ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
const ElSelect: typeof import('element-plus/es')['ElSelect']
|
const ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
const ElSubmenu: typeof import('element-plus/es')['ElSubmenu']
|
||||||
|
const ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
const ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
const ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
const GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
|
const GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ export type SexType = (typeof Sex)[keyof typeof Sex]
|
|||||||
* 对应 OpenAPI: UserInfoResponse
|
* 对应 OpenAPI: UserInfoResponse
|
||||||
* 所有字段均为可选,部分字段可能为 null
|
* 所有字段均为可选,部分字段可能为 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 {
|
export interface UserInfoResponse {
|
||||||
/** 用户唯一标识(UUID) */
|
/** 用户唯一标识(UUID) */
|
||||||
userId?: string
|
userId?: string
|
||||||
@@ -37,13 +48,20 @@ export interface UserInfoResponse {
|
|||||||
sex?: SexType | null
|
sex?: SexType | null
|
||||||
/** 生日(ISO 8601 日期格式),可能为 null */
|
/** 生日(ISO 8601 日期格式),可能为 null */
|
||||||
birthday?: string | null
|
birthday?: string | null
|
||||||
|
/** 在线状态:ONLINE / AWAY / DND / INVISIBLE */
|
||||||
|
status?: UserStatusType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新在线状态请求 */
|
||||||
|
export interface UpdateStatusRequest {
|
||||||
|
/** 状态值:ONLINE、AWAY、DND、INVISIBLE */
|
||||||
|
status: UserStatusType
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前登录用户信息
|
* 获取当前登录用户信息
|
||||||
*
|
*
|
||||||
* 根据请求头中的 saToken 获取当前登录用户的详细信息,
|
* 根据请求头中的 saToken 获取当前登录用户的详细信息。
|
||||||
* 包括昵称、头像、个性签名、年龄、性别、生日。
|
|
||||||
* 需登录认证。
|
* 需登录认证。
|
||||||
*
|
*
|
||||||
* @returns 当前登录用户的详细信息
|
* @returns 当前登录用户的详细信息
|
||||||
@@ -51,3 +69,12 @@ export interface UserInfoResponse {
|
|||||||
export function getUserInfo() {
|
export function getUserInfo() {
|
||||||
return requestClient.get<UserInfoResponse>('/user/info')
|
return requestClient.get<UserInfoResponse>('/user/info')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新当前登录用户在线状态
|
||||||
|
*
|
||||||
|
* @param data - 状态更新请求
|
||||||
|
*/
|
||||||
|
export function updateStatus(data: UpdateStatusRequest) {
|
||||||
|
return requestClient.patch<void>('/user/status', data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export function createRefreshTokenInterceptor(config: RefreshInterceptorConfig)
|
|||||||
*/
|
*/
|
||||||
async function onFulfilled(response: AxiosResponse<ApiResponse>) {
|
async function onFulfilled(response: AxiosResponse<ApiResponse>) {
|
||||||
const data = response.data
|
const data = response.data
|
||||||
if (data.code !== 401) {
|
// 前一个拦截器在 code !== 401 时已将 AxiosResponse 解包为 ApiResponse,
|
||||||
|
// 此时 response.data 是业务数据本身,可能为 null(如 updateStatus 返回 data: null)
|
||||||
|
if (!data || data.code !== 401) {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,72 +45,65 @@
|
|||||||
|
|
||||||
<div class="user-panel__divider"></div>
|
<div class="user-panel__divider"></div>
|
||||||
|
|
||||||
<!-- 详细设置区:编辑个人资料 + 在线状态 -->
|
<!-- 菜单区:编辑个人资料 + 在线状态(hover 触发展开) -->
|
||||||
<div class="user-panel__section">
|
<el-menu mode="vertical" class="user-panel__menu" menu-trigger="hover">
|
||||||
<div class="user-panel__item" @click="handleEditProfile">
|
<el-menu-item index="edit" @click="handleEditProfile">
|
||||||
<el-icon :size="18"><EditPen /></el-icon>
|
<el-icon :size="18"><EditPen /></el-icon>
|
||||||
<span>编辑个人资料</span>
|
<span>编辑个人资料</span>
|
||||||
</div>
|
</el-menu-item>
|
||||||
|
|
||||||
<!-- 在线状态(父项 + 二级菜单) -->
|
<el-sub-menu index="status" popper-class="status-submenu-popper" :popper-offset="12">
|
||||||
<div class="user-panel__status-group">
|
<template #title>
|
||||||
<div class="user-panel__item" @click="toggleStatusMenu">
|
<el-icon :size="18">
|
||||||
<span
|
<span
|
||||||
class="user-panel__status-dot"
|
class="user-panel__status-dot"
|
||||||
:style="{ backgroundColor: currentStatus.color }"
|
:style="{ backgroundColor: currentStatus.color }"
|
||||||
></span>
|
></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>
|
</el-icon>
|
||||||
</div>
|
<span>{{ currentStatus.label }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 二级菜单:状态选项列表 -->
|
<el-menu-item
|
||||||
<Transition name="status-menu">
|
v-for="status in statusOptions"
|
||||||
<div v-show="showStatusMenu" class="user-panel__status-submenu">
|
:key="status.key"
|
||||||
<div
|
:index="'status-' + status.key"
|
||||||
v-for="status in statusOptions"
|
@click="selectStatus(status)"
|
||||||
:key="status.key"
|
>
|
||||||
class="user-panel__status-option"
|
<el-icon :size="18">
|
||||||
:class="{ 'is-active': currentStatus.key === status.key }"
|
<span
|
||||||
@click="selectStatus(status)"
|
class="user-panel__status-dot"
|
||||||
>
|
:style="{ backgroundColor: status.color }"
|
||||||
<span
|
></span>
|
||||||
class="user-panel__status-dot"
|
</el-icon>
|
||||||
:style="{ backgroundColor: status.color }"
|
<span>{{ status.label }}</span>
|
||||||
></span>
|
<el-icon
|
||||||
<span>{{ status.label }}</span>
|
v-if="currentStatus.key === status.key"
|
||||||
<el-icon
|
:size="14"
|
||||||
v-if="currentStatus.key === status.key"
|
class="user-panel__check"
|
||||||
:size="14"
|
>
|
||||||
class="user-panel__check"
|
<Check />
|
||||||
>
|
</el-icon>
|
||||||
<Check />
|
</el-menu-item>
|
||||||
</el-icon>
|
</el-sub-menu>
|
||||||
</div>
|
</el-menu>
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="user-panel__divider"></div>
|
<div class="user-panel__divider"></div>
|
||||||
|
|
||||||
<!-- 操作区:复制账号ID + 退出登录 -->
|
<!-- 菜单区:复制账号ID + 退出登录 -->
|
||||||
<div class="user-panel__section">
|
<el-menu mode="vertical" class="user-panel__menu">
|
||||||
<div class="user-panel__item" @click="handleCopyUserId">
|
<el-menu-item index="copy-id" @click="handleCopyUserId">
|
||||||
<el-icon :size="18"><DocumentCopy /></el-icon>
|
<el-icon :size="18"><DocumentCopy /></el-icon>
|
||||||
<span>复制账号ID</span>
|
<span>复制账号ID</span>
|
||||||
</div>
|
</el-menu-item>
|
||||||
<div class="user-panel__item user-panel__item--danger" @click="handleLogout">
|
<el-menu-item
|
||||||
|
index="logout"
|
||||||
|
class="user-panel__menu-item--danger"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
<el-icon :size="18"><SwitchButton /></el-icon>
|
<el-icon :size="18"><SwitchButton /></el-icon>
|
||||||
<span>退出登录</span>
|
<span>退出登录</span>
|
||||||
</div>
|
</el-menu-item>
|
||||||
</div>
|
</el-menu>
|
||||||
</div>
|
</div>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
|
|
||||||
@@ -122,83 +115,78 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import {
|
import { Setting, EditPen, Check, DocumentCopy, SwitchButton } from '@element-plus/icons-vue'
|
||||||
Setting,
|
|
||||||
EditPen,
|
|
||||||
ArrowRight,
|
|
||||||
Check,
|
|
||||||
DocumentCopy,
|
|
||||||
SwitchButton
|
|
||||||
} from '@element-plus/icons-vue'
|
|
||||||
import { useAccountStore } from '@/stores'
|
import { useAccountStore } from '@/stores'
|
||||||
|
import { UserStatus, type UserStatusType } from '@/api/modules/user'
|
||||||
|
|
||||||
/** 在线状态选项 */
|
/** 在线状态选项 */
|
||||||
interface StatusOption {
|
interface StatusOption {
|
||||||
key: string
|
key: string
|
||||||
|
/** 对应后端 UserStatus 值 */
|
||||||
|
value: UserStatusType
|
||||||
label: string
|
label: string
|
||||||
color: string
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS: StatusOption[] = [
|
const STATUS_OPTIONS: StatusOption[] = [
|
||||||
{ key: 'online', label: '在线', color: 'var(--color-success)' },
|
{ key: 'online', value: UserStatus.ONLINE, label: '在线', color: 'var(--color-success)' },
|
||||||
{ key: 'away', label: '离开', color: 'var(--color-warning)' },
|
{ key: 'away', value: UserStatus.AWAY, label: '离开', color: 'var(--color-warning)' },
|
||||||
{ key: 'dnd', label: '请勿打扰', color: 'var(--color-danger)' },
|
{ key: 'dnd', value: UserStatus.DND, label: '请勿打扰', color: 'var(--color-danger)' },
|
||||||
{ key: 'invisible', label: '隐身', color: 'var(--text-tertiary)' }
|
{ key: 'invisible', value: UserStatus.INVISIBLE, label: '隐身', color: 'var(--text-tertiary)' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** 后端状态值 → StatusOption 映射 */
|
||||||
|
const statusMap = new Map<UserStatusType, StatusOption>(STATUS_OPTIONS.map((s) => [s.value, s]))
|
||||||
|
|
||||||
const accountStore = useAccountStore()
|
const accountStore = useAccountStore()
|
||||||
const { accountInfo } = storeToRefs(accountStore)
|
const { accountInfo } = storeToRefs(accountStore)
|
||||||
|
|
||||||
/** 当前在线状态(默认:在线) */
|
|
||||||
const currentStatus = ref<StatusOption>(STATUS_OPTIONS[0]!)
|
|
||||||
/** 状态二级菜单是否展开 */
|
|
||||||
const showStatusMenu = ref(false)
|
|
||||||
/** 在线状态选项列表 */
|
/** 在线状态选项列表 */
|
||||||
const statusOptions = STATUS_OPTIONS
|
const statusOptions = STATUS_OPTIONS
|
||||||
|
|
||||||
/** 用户头像(优先使用 store 中的头像,否则使用默认占位) */
|
/** 当前在线状态(从 store 读取,默认在线) */
|
||||||
|
const currentStatus = computed<StatusOption>(() => {
|
||||||
|
const backendStatus = accountInfo.value?.status
|
||||||
|
return backendStatus ? (statusMap.get(backendStatus) ?? STATUS_OPTIONS[0]!) : STATUS_OPTIONS[0]!
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 用户头像 */
|
||||||
const userAvatar = computed(() => {
|
const userAvatar = computed(() => {
|
||||||
return accountInfo.value?.avatar || ''
|
return accountInfo.value?.avatar || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 显示昵称(未登录或未加载时显示默认文案) */
|
/** 显示昵称 */
|
||||||
const displayName = computed(() => {
|
const displayName = computed(() => {
|
||||||
return accountInfo.value?.nickName || '未登录'
|
return accountInfo.value?.nickName || '未登录'
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 个性签名(无签名时显示默认文案) */
|
/** 个性签名 */
|
||||||
const signatureText = computed(() => {
|
const signatureText = computed(() => {
|
||||||
return accountInfo.value?.signature || '这个人很懒,什么都没写...'
|
return accountInfo.value?.signature || '这个人很懒,什么都没写...'
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/** 编辑个人资料(预留入口) */
|
||||||
* 编辑个人资料
|
|
||||||
* 预留功能入口,后续可弹出编辑弹窗或跳转到设置页
|
|
||||||
*/
|
|
||||||
const handleEditProfile = () => {
|
const handleEditProfile = () => {
|
||||||
ElMessage.info('编辑个人资料功能开发中')
|
ElMessage.info('编辑个人资料功能开发中')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 展开/收起在线状态二级菜单 */
|
|
||||||
const toggleStatusMenu = () => {
|
|
||||||
showStatusMenu.value = !showStatusMenu.value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选择一个在线状态
|
* 选择一个在线状态,调用后端接口持久化
|
||||||
* @param status - 选中的状态选项
|
* @param option - 选中的状态选项
|
||||||
*/
|
*/
|
||||||
const selectStatus = (status: StatusOption) => {
|
const selectStatus = async (option: StatusOption) => {
|
||||||
currentStatus.value = status
|
try {
|
||||||
showStatusMenu.value = false
|
await accountStore.updateUserStatus(option.value)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('状态更新失败,请重试')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复制账号ID到剪贴板
|
* 复制账号ID到剪贴板
|
||||||
* 复制用户的 UUID 标识符
|
|
||||||
*/
|
*/
|
||||||
const handleCopyUserId = async () => {
|
const handleCopyUserId = async () => {
|
||||||
const userId = accountInfo.value?.userId
|
const userId = accountInfo.value?.userId
|
||||||
@@ -215,10 +203,7 @@ const handleCopyUserId = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 退出登录 */
|
||||||
* 退出登录
|
|
||||||
* 调用 store 清除认证状态,后续可跳转至登录页
|
|
||||||
*/
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
accountStore.logout()
|
accountStore.logout()
|
||||||
ElMessage.success('已退出登录')
|
ElMessage.success('已退出登录')
|
||||||
@@ -330,6 +315,15 @@ const handleLogout = () => {
|
|||||||
.user-panel {
|
.user-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 状态圆点(复用,放在 el-icon 内) ---- */
|
||||||
|
.user-panel__status-dot {
|
||||||
|
display: block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 顶部头像区 ---- */
|
/* ---- 顶部头像区 ---- */
|
||||||
@@ -369,13 +363,6 @@ const handleLogout = () => {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-panel__status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 签名区 ---- */
|
/* ---- 签名区 ---- */
|
||||||
.user-panel__signature {
|
.user-panel__signature {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
@@ -392,122 +379,133 @@ const handleLogout = () => {
|
|||||||
margin: 0;
|
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 {
|
.user-panel__check {
|
||||||
margin-left: auto;
|
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 二级菜单展开/收起动画 ---- */
|
/* ========== el-menu 样式覆盖 ========== */
|
||||||
.status-menu-enter-active {
|
.user-panel__menu {
|
||||||
transition:
|
border-right: none !important;
|
||||||
max-height 0.25s ease,
|
background: transparent !important;
|
||||||
opacity 0.2s ease;
|
|
||||||
overflow: hidden;
|
/* 普通菜单项 */
|
||||||
|
:deep(.el-menu-item) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-surface-hover) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移除默认选中高亮(底部 border + 品牌色文字) */
|
||||||
|
&.is-active {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子菜单标题 */
|
||||||
|
:deep(.el-sub-menu__title) {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-surface-hover) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子菜单展开态 */
|
||||||
|
:deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.status-menu-leave-active {
|
|
||||||
transition:
|
/* 退出登录危险项 */
|
||||||
max-height 0.2s ease,
|
.user-panel__menu-item--danger {
|
||||||
opacity 0.15s ease;
|
color: var(--color-danger) !important;
|
||||||
overflow: hidden;
|
|
||||||
}
|
&:hover {
|
||||||
.status-menu-enter-from,
|
background-color: var(--color-danger-light) !important;
|
||||||
.status-menu-leave-to {
|
color: var(--color-danger) !important;
|
||||||
max-height: 0;
|
}
|
||||||
opacity: 0;
|
|
||||||
}
|
:deep(.el-icon) {
|
||||||
.status-menu-enter-to,
|
color: var(--color-danger);
|
||||||
.status-menu-leave-from {
|
}
|
||||||
max-height: 200px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- 覆盖 el-popover 默认样式(非 scoped,因为 popover 渲染在 body 下) -->
|
<!-- 非 scoped 样式:popover 和子菜单 popper -->
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
/* 主弹出面板 */
|
||||||
.user-popover {
|
.user-popover {
|
||||||
padding: 8px 12px !important;
|
padding: 8px 12px !important;
|
||||||
border-radius: var(--radius-md) !important;
|
border-radius: var(--radius-md) !important;
|
||||||
box-shadow: var(--shadow-lg) !important;
|
box-shadow: var(--shadow-lg) !important;
|
||||||
border: 1px solid var(--border-default) !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;
|
||||||
|
|
||||||
|
.el-menu--popup {
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-surface-hover) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ function isPublicPath(path: string): boolean {
|
|||||||
*/
|
*/
|
||||||
function setupCommonGuard(router: Router) {
|
function setupCommonGuard(router: Router) {
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
return true
|
|
||||||
const accountStore = useAccountStore()
|
const accountStore = useAccountStore()
|
||||||
|
|
||||||
// 已登录用户访问公开路由(如登录页),直接跳转首页
|
// 已登录用户访问公开路由(如登录页),直接跳转首页
|
||||||
@@ -28,6 +27,15 @@ function setupCommonGuard(router: Router) {
|
|||||||
return '/'
|
return '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 有 token 但未加载用户信息(页面刷新后 Pinia 只持久化了 token)
|
||||||
|
if (accountStore.accessToken && !accountStore.accountInfo) {
|
||||||
|
try {
|
||||||
|
await accountStore.fetchUserInfo()
|
||||||
|
} catch {
|
||||||
|
// 获取失败(token 过期等),由刷新拦截器处理,此处静默
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// accessToken 检查
|
// accessToken 检查
|
||||||
if (!accountStore.accessToken) {
|
if (!accountStore.accessToken) {
|
||||||
// 明确声明忽略权限访问权限,则可以访问
|
// 明确声明忽略权限访问权限,则可以访问
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type LoginRequest,
|
type LoginRequest,
|
||||||
type RegisterRequest
|
type RegisterRequest
|
||||||
} from '@/api/modules/account'
|
} from '@/api/modules/account'
|
||||||
import { getUserInfo } from '@/api/modules/user'
|
import { getUserInfo, updateStatus as updateStatusApi, type UserStatusType } from '@/api/modules/user'
|
||||||
|
|
||||||
/** 账户信息 */
|
/** 账户信息 */
|
||||||
export interface AccountState {
|
export interface AccountState {
|
||||||
@@ -25,6 +25,8 @@ export interface AccountState {
|
|||||||
sex?: number | null
|
sex?: number | null
|
||||||
/** 生日 */
|
/** 生日 */
|
||||||
birthday?: string | null
|
birthday?: string | null
|
||||||
|
/** 在线状态:ONLINE / AWAY / DND / INVISIBLE */
|
||||||
|
status?: UserStatusType
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAccountStore = defineStore(
|
export const useAccountStore = defineStore(
|
||||||
@@ -50,7 +52,7 @@ export const useAccountStore = defineStore(
|
|||||||
const res = await loginApi(params)
|
const res = await loginApi(params)
|
||||||
accessToken.value = res.data.accessToken
|
accessToken.value = res.data.accessToken
|
||||||
refreshToken.value = res.data.refreshToken
|
refreshToken.value = res.data.refreshToken
|
||||||
// await fetchUserInfo()
|
await fetchUserInfo()
|
||||||
isTokenValid.value = true
|
isTokenValid.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ export const useAccountStore = defineStore(
|
|||||||
const res = await registerApi(params)
|
const res = await registerApi(params)
|
||||||
accessToken.value = res.data.accessToken
|
accessToken.value = res.data.accessToken
|
||||||
refreshToken.value = res.data.refreshToken
|
refreshToken.value = res.data.refreshToken
|
||||||
// await fetchUserInfo()
|
await fetchUserInfo()
|
||||||
isTokenValid.value = true
|
isTokenValid.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +109,18 @@ export const useAccountStore = defineStore(
|
|||||||
return accessToken.value
|
return accessToken.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新当前用户的在线状态
|
||||||
|
* 调用后端接口持久化,并同步更新本地 accountInfo
|
||||||
|
* @param status - 新状态值
|
||||||
|
*/
|
||||||
|
const updateUserStatus = async (status: UserStatusType) => {
|
||||||
|
await updateStatusApi({ status })
|
||||||
|
if (accountInfo.value) {
|
||||||
|
accountInfo.value = { ...accountInfo.value, status }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 清除所有认证状态 */
|
/** 清除所有认证状态 */
|
||||||
const clearAuth = () => {
|
const clearAuth = () => {
|
||||||
accessToken.value = ''
|
accessToken.value = ''
|
||||||
@@ -126,7 +140,8 @@ export const useAccountStore = defineStore(
|
|||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
fetchUserInfo
|
fetchUserInfo,
|
||||||
|
updateUserStatus
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user