Files
hfb_sys/frontend/src/layouts/AppLayout.vue
T

831 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowDown,
Bell,
ChatDotRound,
CirclePlus,
Coin,
DocumentChecked,
EditPen,
Finished,
House,
InfoFilled,
Search,
Service,
Shop,
SwitchButton,
Tickets,
UserFilled,
Van,
VideoPlay,
Wallet,
} from '@element-plus/icons-vue'
import { ensureSupportChat } from '@/features/chats/api/chats'
import { useSessionStore } from '@/stores/session'
const session = useSessionStore()
const router = useRouter()
const navItems = [
{ label: '首页', to: '/', icon: House },
{ label: '我的订单', to: '/orders', icon: Tickets },
{ label: '消息', to: '/messages', icon: ChatDotRound },
{ label: '钱包', to: '/wallet', icon: Wallet },
{ label: '公告', to: '/announcements', icon: Bell },
]
const buyerServiceItems = [
{ label: '待支付', icon: Coin, to: { path: '/orders', query: { tab: 'pending_payment' } } },
{ label: '待交接', icon: Van, to: { path: '/orders', query: { tab: 'pending_handoff' } } },
{ label: '使用中', icon: VideoPlay, to: { path: '/orders', query: { tab: 'renting' } } },
{ label: '已完成', icon: Finished, to: { path: '/orders', query: { tab: 'completed' } } },
]
const sellerServiceItems = [
{ label: '发布商品', icon: CirclePlus, to: '/seller/listings/create' },
{ label: '我的商品', icon: Shop, to: '/seller/listings' },
{ label: '提现/账单', icon: Wallet, to: '/wallet' },
]
const showUserDropdown = ref(false)
const supportLoading = ref(false)
const realnameBadge = computed(() => {
switch (session.realnameStatus) {
case 'verified':
return { text: '已实名', tone: 'verified' }
case 'pending':
return { text: '认证中', tone: 'pending' }
case 'rejected':
return { text: '认证失败', tone: 'rejected' }
default:
return { text: '未实名', tone: 'unverified' }
}
})
function handleLogout() {
session.logout()
showUserDropdown.value = false
router.replace('/')
}
async function handleSupportClick() {
if (!session.isLoggedIn) {
router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
return
}
if (supportLoading.value) return
supportLoading.value = true
try {
const chat = await ensureSupportChat()
router.push(`/messages/${chat.id}`)
} catch {
ElMessage.error('联系客服失败,请稍后重试')
} finally {
supportLoading.value = false
}
}
</script>
<template>
<div class="pc-app-shell">
<header class="pc-topbar">
<RouterLink class="pc-brand" to="/">
<span class="pc-brand-mark"></span>
<div class="pc-brand-info">
<span class="pc-brand-text">大锤商行</span>
<small class="pc-brand-sub">哈夫币租号</small>
</div>
</RouterLink>
<nav class="pc-nav">
<RouterLink v-for="item in navItems" :key="item.to" class="pc-nav-link" :to="item.to">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</RouterLink>
</nav>
<div class="pc-search">
<el-icon><Search /></el-icon>
<input placeholder="搜索区服 / 段位 / 哈夫币数量 / 皮肤" />
</div>
<div class="pc-user-actions">
<button
class="pc-icon-link service-link"
type="button"
title="联系客服"
@click="handleSupportClick"
>
<el-icon><Service /></el-icon>
<span>{{ supportLoading ? '接入中' : '客服' }}</span>
</button>
<RouterLink
class="pc-post-link"
:to="session.isLoggedIn ? '/seller/listings/create' : '/login'"
>
<el-icon><CirclePlus /></el-icon>
<span>发布账号</span>
</RouterLink>
<!-- 未登录显示登录/注册按钮 -->
<template v-if="!session.isLoggedIn">
<RouterLink class="pc-login-link" to="/login">
<el-icon><UserFilled /></el-icon>
<span>登录/注册</span>
</RouterLink>
</template>
<!-- 已登录显示用户头像+昵称 + 下拉菜单 -->
<template v-else>
<div class="pc-user-block" @mouseleave="showUserDropdown = false">
<button
class="pc-user-trigger"
@mouseenter="showUserDropdown = true"
@click="showUserDropdown = !showUserDropdown"
:class="{ active: showUserDropdown }"
>
<span class="pc-avatar-chip">
<img
v-if="session.avatarUrl"
:src="session.avatarUrl"
alt=""
class="pc-avatar-img"
/>
<span v-else class="pc-avatar-text">{{ session.avatarText }}</span>
</span>
<span class="pc-user-name">{{ session.displayName }}</span>
<span class="pc-realname-badge" :class="`is-${realnameBadge.tone}`">
{{ realnameBadge.text }}
</span>
<el-icon class="arrow-icon" :class="{ rotated: showUserDropdown }"
><ArrowDown
/></el-icon>
</button>
<Transition name="dropdown">
<div v-if="showUserDropdown" class="pc-user-dropdown-container">
<div class="pc-user-dropdown">
<RouterLink class="dropdown-item" to="/profile" @click="showUserDropdown = false">
<el-icon><EditPen /></el-icon>
<span>个人资料</span>
</RouterLink>
<RouterLink class="dropdown-item" to="/orders" @click="showUserDropdown = false">
<el-icon><Tickets /></el-icon>
<span>我的订单</span>
</RouterLink>
<RouterLink class="dropdown-item" to="/wallet" @click="showUserDropdown = false">
<el-icon><Wallet /></el-icon>
<span>我的钱包</span>
</RouterLink>
<RouterLink
class="dropdown-item"
to="/seller/listings/create"
@click="showUserDropdown = false"
>
<el-icon><CirclePlus /></el-icon>
<span>发布账号</span>
</RouterLink>
<RouterLink
class="dropdown-item"
to="/realname"
@click="showUserDropdown = false"
>
<el-icon><DocumentChecked /></el-icon>
<span>实名认证</span>
<span class="dropdown-status" :class="`is-${realnameBadge.tone}`">
{{ realnameBadge.text }}
</span>
</RouterLink>
<RouterLink
class="dropdown-item"
to="/post-rental-notice"
@click="showUserDropdown = false"
>
<el-icon><InfoFilled /></el-icon>
<span>租后须知</span>
</RouterLink>
<div class="dropdown-divider"></div>
<section class="dropdown-service-group">
<div class="dropdown-service-head">
<span>买家服务</span>
<RouterLink
class="dropdown-service-more"
:to="{ path: '/orders', query: { tab: 'all' } }"
@click="showUserDropdown = false"
>
全部订单
</RouterLink>
</div>
<div class="dropdown-service-grid">
<RouterLink
v-for="item in buyerServiceItems"
:key="item.label"
class="dropdown-service-card"
:to="item.to"
@click="showUserDropdown = false"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</RouterLink>
</div>
</section>
<section class="dropdown-service-group">
<div class="dropdown-service-head">
<span>卖家服务</span>
</div>
<div class="dropdown-seller-list">
<RouterLink
v-for="item in sellerServiceItems"
:key="item.label"
class="dropdown-seller-item"
:to="item.to"
@click="showUserDropdown = false"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</RouterLink>
</div>
</section>
<div class="dropdown-divider"></div>
<button class="dropdown-item logout" @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</button>
</div>
</div>
</Transition>
</div>
</template>
</div>
</header>
<main class="pc-main">
<slot />
</main>
</div>
</template>
<style scoped>
/* ========== 顶部栏 ========== */
.pc-app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
max-width: 100vw;
overflow-x: clip;
background: #f5f6f8;
}
.pc-topbar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
max-width: 100%;
height: 64px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid #eef1f5;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
/* ========== 品牌 ========== */
.pc-brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
flex-shrink: 0;
}
.pc-brand-mark {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 12px;
background: linear-gradient(135deg, #fff3e8 0%, #ffe4cc 100%);
color: #ff6a00;
font-size: 20px;
font-weight: 900;
}
.pc-brand-info {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.pc-brand-text {
font-size: 16px;
font-weight: 800;
color: #17233d;
}
.pc-brand-sub {
font-size: 11px;
color: #8b9cb5;
font-weight: 600;
}
/* ========== 导航 ========== */
.pc-nav {
display: flex;
align-items: center;
gap: 4px;
margin-left: 10px;
min-width: 0;
height: 100%;
}
.pc-nav-link {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 14px;
border-radius: 10px;
color: #5a6577;
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition: all 0.2s;
}
.pc-nav-link:hover,
.pc-nav-link.router-link-active {
background: #fff3e8;
color: #ff6a00;
}
/* ========== 搜索 ========== */
.pc-search {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
padding: 0 14px;
height: 40px;
border-radius: 12px;
background: #f4f6f8;
border: 1px solid transparent;
max-width: 320px;
min-width: 180px;
flex: 1;
transition: all 0.2s;
}
.pc-search:focus-within {
background: #fff;
border-color: #ff6a00;
box-shadow: 0 0 0 4px rgba(255, 106, 0, 0.08);
}
.pc-search input {
border: none;
outline: none;
background: transparent;
color: #17233d;
font-size: 13px;
width: 100%;
}
.pc-search input::placeholder {
color: #a0aab6;
}
.pc-search .el-icon {
color: #a0aab6;
font-size: 16px;
flex-shrink: 0;
}
/* ========== 用户操作区 ========== */
.pc-user-actions {
display: flex;
align-items: center;
gap: 12px;
margin-left: 12px;
min-width: 0;
flex-shrink: 0;
height: 100%;
}
.pc-icon-link {
display: flex;
align-items: center;
gap: 10px;
padding: 0 22px;
height: 40px;
border-radius: 12px;
border: 1px solid #eef1f5;
background: #ffffff;
color: #5a6577;
font-size: 14px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.pc-icon-link:hover {
background: #fff3e8;
border-color: #ff6a00;
color: #ff6a00;
box-shadow: 0 4px 12px rgba(255, 106, 0, 0.05);
}
.service-link {
gap: 7px;
min-width: 92px;
justify-content: center;
padding: 0 18px;
border-color: rgba(255, 106, 0, 0.32);
border-radius: 10px;
background: #fff8f2;
color: #ff6a00;
font-size: 14px;
font-weight: 800;
}
.service-link .el-icon {
width: 18px;
height: 18px;
font-size: 18px;
stroke-width: 2.2;
}
.service-link:hover {
background: #fff3e8;
border-color: #ff6a00;
box-shadow: 0 6px 16px rgba(255, 106, 0, 0.1);
transform: translateY(-1px);
}
.pc-login-link {
display: flex;
align-items: center;
gap: 6px;
padding: 0 16px;
height: 40px;
border-radius: 10px;
background: #f4f6f8;
color: #17233d;
font-size: 13px;
font-weight: 700;
text-decoration: none;
transition: all 0.2s;
}
.pc-login-link:hover {
background: #eaeff5;
}
.pc-post-link {
display: flex;
align-items: center;
gap: 6px;
padding: 0 18px;
height: 40px;
border-radius: 10px;
background: #ff6a00;
color: #fff;
font-size: 14px;
font-weight: 800;
text-decoration: none;
box-shadow: 0 6px 16px rgba(255, 106, 0, 0.2);
transition: all 0.2s;
}
.pc-post-link:hover {
background: #e55d00;
transform: translateY(-1px);
}
/* ========== 已登录 - 用户块 ========== */
.pc-user-block {
position: relative;
height: 40px;
display: flex;
align-items: center;
}
.pc-user-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px 4px 4px;
height: 36px;
border: none;
border-radius: 999px;
background: #f4f6f8;
cursor: pointer;
transition: all 0.2s;
}
.pc-user-trigger:hover,
.pc-user-trigger.active {
background: #eaeff5;
}
.pc-avatar-chip {
display: grid;
width: 28px;
height: 28px;
place-items: center;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #ff6a00 0%, #e55d00 100%);
color: #fff;
font-size: 13px;
font-weight: 800;
flex-shrink: 0;
}
.pc-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pc-avatar-text {
line-height: 1;
}
.pc-user-name {
max-width: 100px;
overflow: hidden;
color: #17233d;
font-size: 14px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.pc-realname-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 48px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
.pc-realname-badge.is-verified,
.dropdown-status.is-verified {
background: #ecfdf3;
color: #16a34a;
}
.pc-realname-badge.is-pending,
.dropdown-status.is-pending {
background: #fff7ed;
color: #f97316;
}
.pc-realname-badge.is-rejected,
.dropdown-status.is-rejected {
background: #fff1f2;
color: #e11d48;
}
.pc-realname-badge.is-unverified,
.dropdown-status.is-unverified {
background: #f1f5f9;
color: #64748b;
}
.arrow-icon {
font-size: 12px;
color: #8b9cb5;
transition: transform 0.3s;
}
.arrow-icon.rotated {
transform: rotate(180deg);
}
/* ========== 下拉菜单容器 (Hover Bridge) ========== */
.pc-user-dropdown-container {
position: absolute;
top: 100%;
right: 0;
z-index: 200;
padding-top: 12px;
}
.pc-user-dropdown {
width: 324px;
padding: 10px;
border-radius: 14px;
background: #fff;
border: 1px solid #eef1f5;
box-shadow: 0 10px 30px rgba(23, 35, 61, 0.15);
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
border: none;
border-radius: 10px;
background: none;
color: #475569;
font-size: 14px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
}
.dropdown-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 54px;
height: 22px;
margin-left: auto;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
.dropdown-item:hover {
background: #fff3e8;
color: #ff6a00;
transform: translateX(4px);
}
.dropdown-item .el-icon {
font-size: 16px;
color: #94a3b8;
transition: color 0.2s;
}
.dropdown-item:hover .el-icon {
color: #ff6a00;
}
.dropdown-item.logout {
color: #ef4444;
}
.dropdown-item.logout .el-icon {
color: #ef4444;
}
.dropdown-item.logout:hover {
background: #fff1f0;
}
.dropdown-divider {
height: 1px;
margin: 6px 10px;
background: #f1f5f9;
}
.dropdown-service-group {
padding: 6px 4px 4px;
}
.dropdown-service-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 8px;
color: #17233d;
font-size: 13px;
font-weight: 900;
}
.dropdown-service-more {
color: #64748b;
font-size: 12px;
font-weight: 800;
text-decoration: none;
}
.dropdown-service-more:hover {
color: #ff6a00;
}
.dropdown-service-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.dropdown-service-card,
.dropdown-seller-item {
text-decoration: none;
transition: all 0.2s;
}
.dropdown-service-card {
display: grid;
min-width: 0;
min-height: 68px;
place-items: center;
gap: 6px;
padding: 8px 4px;
border-radius: 10px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 800;
text-align: center;
}
.dropdown-service-card .el-icon {
color: #ff6a00;
font-size: 18px;
}
.dropdown-service-card:hover {
background: #fff3e8;
color: #ff6a00;
transform: translateY(-1px);
}
.dropdown-seller-list {
display: grid;
gap: 6px;
}
.dropdown-seller-item {
display: flex;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 10px;
border-radius: 10px;
background: #f8fafc;
color: #475569;
font-size: 13px;
font-weight: 800;
}
.dropdown-seller-item .el-icon {
color: #94a3b8;
font-size: 16px;
}
.dropdown-seller-item:hover {
background: #fff3e8;
color: #ff6a00;
}
.dropdown-seller-item:hover .el-icon {
color: #ff6a00;
}
/* 下拉动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.dropdown-enter-from {
opacity: 0;
transform: translateY(10px);
}
.dropdown-leave-to {
opacity: 0;
transform: translateY(5px);
}
/* ========== 主体 ---------- */
.pc-main {
flex: 1;
min-width: 0;
padding: 20px 24px;
overflow-x: clip;
}
@media (max-width: 1280px) {
.pc-realname-badge {
display: none;
}
}
</style>