merge: branch 'huangzhenbao-admin' into comclib-analytics, keeping local RPC integration versions
This commit is contained in:
@@ -21,16 +21,18 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="aside-footer" @click="onToggle">
|
||||
<!-- 1:1 复刻 CRMEB: 一级侧边栏通常不单独折叠,由顶部汉堡菜单控制整体 -->
|
||||
<!-- <view class="aside-footer" @click="onToggle">
|
||||
<view class="toggle-btn">
|
||||
<text class="toggle-icon">{{ collapsed ? '>' : '<' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import type { TopMenu } from '@/layouts/admin/router/adminRoutes.uts'
|
||||
import { isMobile } from '@/layouts/admin/store/adminNavStore.uts'
|
||||
|
||||
const props = defineProps<{
|
||||
collapsed: boolean
|
||||
@@ -86,7 +88,7 @@ function onLogoClick(): void {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s ease;
|
||||
z-index: 1000;
|
||||
z-index: 1002; /* 确保在遮罩层之上 */
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
@@ -107,45 +109,34 @@ function onLogoClick(): void {
|
||||
|
||||
.aside-menu {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
padding: 0; /* CRMEB typically no padding here */
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
height: 60px;
|
||||
height: 50px; /* 1:1 CRMEB columnsAside height */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1890ff;
|
||||
background: #1890ff; /* CRMEB 主色蓝 */
|
||||
color: #fff;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 18px; /* CRMEB icons are smaller than 24px usually */
|
||||
margin-bottom: 2px;
|
||||
|
||||
.icon-text {
|
||||
display: block;
|
||||
@@ -154,6 +145,7 @@ function onLogoClick(): void {
|
||||
|
||||
.menu-title {
|
||||
font-size: 12px;
|
||||
transform: scale(0.9); /* CRMEB text is tiny */
|
||||
text-align: center;
|
||||
|
||||
text {
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
<template>
|
||||
<view class="header">
|
||||
<view class="header-left">
|
||||
<text class="crumb" v-for="(item, index) in breadcrumb" :key="item.id">
|
||||
{{ item.title }}
|
||||
<text v-if="index < breadcrumb.length - 1" class="separator"> / </text>
|
||||
</text>
|
||||
<!-- 移动端菜单切换按钮 (CSS 控制显隐) -->
|
||||
<view class="menu-toggle mobile-only" @click="onToggleSubSider">
|
||||
<text class="menu-icon">☰</text>
|
||||
</view>
|
||||
|
||||
<!-- Desktop/Tablet Hamburger (1:1 复刻 CRMEB 切换二级侧边栏) -->
|
||||
<view class="menu-toggle desktop-only" @click="onToggleSubSider">
|
||||
<text class="menu-icon">☰</text>
|
||||
</view>
|
||||
|
||||
<view class="breadcrumb-container desktop-only">
|
||||
<text class="crumb" v-for="(item, index) in breadcrumb" :key="item.id">
|
||||
{{ item.title }}
|
||||
<text v-if="index < breadcrumb.length - 1" class="separator"> / </text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 移动端简单标题 (CSS 控制显隐) -->
|
||||
<text class="mobile-title mobile-only">{{ currentTitle }}</text>
|
||||
</view>
|
||||
|
||||
<view class="header-right">
|
||||
<view class="hbtn" @click="$emit('search')"><text>🔍</text></view>
|
||||
<view class="hbtn" @click="$emit('refresh')"><text>⟳</text></view>
|
||||
<view v-if="!isMobile" class="hbtn" @click="$emit('refresh')"><text>⟳</text></view>
|
||||
<view class="hbtn" @click="$emit('notify')">
|
||||
<text>🔔</text>
|
||||
<view class="dot" v-if="hasNotification"></view>
|
||||
@@ -19,16 +34,50 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
defineProps<{
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
toggleSubSider,
|
||||
showSubSider,
|
||||
layoutMode,
|
||||
isOverlayVisible,
|
||||
isMobileMenuOpen
|
||||
} from '@/layouts/admin/store/adminNavStore.uts'
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumb: Array<{id: string, title: string}>
|
||||
hasNotification: boolean
|
||||
isMobile: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e:'search'): void
|
||||
(e:'refresh'): void
|
||||
(e:'notify'): void
|
||||
(e:'toggle-mobile-menu'): void
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 核心切换逻辑:
|
||||
* 1. Desktop: 切换 showSubSider (Dock状态)
|
||||
* 2. Tablet: 切换 isOverlayVisible (Overlay状态)
|
||||
* 3. Mobile: 切换 isMobileMenuOpen (Mobile Aside)
|
||||
*/
|
||||
function onToggleSubSider(): void {
|
||||
if (layoutMode.value === 'desktop') {
|
||||
toggleSubSider()
|
||||
} else if (layoutMode.value === 'tablet') {
|
||||
isOverlayVisible.value = !isOverlayVisible.value
|
||||
} else {
|
||||
isMobileMenuOpen.value = !isMobileMenuOpen.value
|
||||
}
|
||||
}
|
||||
|
||||
const currentTitle = computed((): string => {
|
||||
if (props.breadcrumb.length > 0) {
|
||||
return props.breadcrumb[props.breadcrumb.length - 1].title
|
||||
}
|
||||
return '管理后台'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -49,11 +98,54 @@ defineEmits<{
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
margin-right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.breadcrumb-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 响应式控制 */
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
.mobile-only {
|
||||
display: flex !important;
|
||||
}
|
||||
.header-right {
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #d1d5db;
|
||||
margin: 0 8px;
|
||||
|
||||
53
layouts/admin/components/AdminPageLoading.uvue
Normal file
53
layouts/admin/components/AdminPageLoading.uvue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<view class="loading-overlay">
|
||||
<view class="loading-content">
|
||||
<view class="spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
// 管理后台统一加载动画组件
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #2d8cf0;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,52 +1,160 @@
|
||||
<template>
|
||||
<view class="admin-subsider" :style="{ left: asideWidth + 'px', width: siderWidth + 'px' }">
|
||||
<view class="subsider-header">
|
||||
<text class="header-title">{{ topMenuTitle }}</text>
|
||||
<view
|
||||
class="admin-subsider"
|
||||
:class="{ 'is-hidden': !visible, 'sub-sider-overlay': isOverlay || layoutMode === 'mobile' }"
|
||||
:style="{ left: asideWidth + 'px', width: width + 'px' }"
|
||||
>
|
||||
<!-- 头部模块标题 (1:1 复刻 CRMEB) -->
|
||||
<view v-if="menuTitle" class="subsider-cat-name">
|
||||
<text class="cat-title">{{ menuTitle }}</text>
|
||||
</view>
|
||||
|
||||
<view class="subsider-menu">
|
||||
<view v-for="group in groups" :key="group.id" class="menu-group">
|
||||
<view class="group-title">
|
||||
<text>{{ group.title }}</text>
|
||||
</view>
|
||||
<!-- 菜单列表滚动 -->
|
||||
<scroll-view class="subsider-content" scroll-y="true" show-scrollbar="false">
|
||||
<view class="menu-list">
|
||||
<!-- 分级手动多层渲染 (UTS 下直接循环比递归更稳定) -->
|
||||
<template v-for="level1 in menuTree" :key="level1.id">
|
||||
<!-- 一级层级 (通常是分组或顶级页面) -->
|
||||
<view
|
||||
class="menu-row level-1"
|
||||
:class="{ 'is-active': isActive(level1), 'is-open': isOpen(level1.id, 1) }"
|
||||
@click="handleNodeClick(level1, 1)"
|
||||
>
|
||||
<text class="menu-text">{{ level1.title }}</text>
|
||||
<text v-if="level1.type === 'group'" class="chevron">▶</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="route in getGroupRoutes(group.id)"
|
||||
:key="route.id"
|
||||
class="menu-item"
|
||||
:class="{ active: route.id === activeRouteId }"
|
||||
@click="onRouteClick(route.id)"
|
||||
>
|
||||
<text class="item-title">{{ route.title }}</text>
|
||||
</view>
|
||||
<!-- 二级子菜单容器 -->
|
||||
<transition name="expand">
|
||||
<view v-if="level1.type === 'group' && isOpen(level1.id, 1)" class="sub-menu-container">
|
||||
<template v-for="level2 in level1.children" :key="level2.id">
|
||||
<view
|
||||
class="menu-row level-2"
|
||||
:class="{ 'is-active': isActive(level2), 'is-open': isOpen(level2.id, 2) }"
|
||||
@click="handleNodeClick(level2, 2, level1.id)"
|
||||
>
|
||||
<text class="menu-text">{{ level2.title }}</text>
|
||||
<text v-if="level2.type === 'group'" class="chevron">▶</text>
|
||||
</view>
|
||||
|
||||
<!-- 三级子菜单容器 -->
|
||||
<transition name="expand">
|
||||
<view v-if="level2.type === 'group' && isOpen(level2.id, 2)" class="sub-menu-container">
|
||||
<template v-for="level3 in level2.children" :key="level3.id">
|
||||
<view
|
||||
class="menu-row level-3"
|
||||
:class="{ 'is-active': isActive(level3) }"
|
||||
@click="handleNodeClick(level3, 3, level2.id)"
|
||||
>
|
||||
<text class="menu-text">{{ level3.title }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</transition>
|
||||
</template>
|
||||
</view>
|
||||
</transition>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import type { MenuGroup, RouteRecord } from '@/layouts/admin/router/adminRoutes.uts'
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import type { MenuNode } from '../router/settingSubSiderMenu.uts'
|
||||
|
||||
const props = defineProps<{
|
||||
topMenuTitle: string
|
||||
groups: MenuGroup[]
|
||||
routes: Map<string, RouteRecord[]>
|
||||
activeRouteId: string
|
||||
visible: boolean
|
||||
menuTitle: string
|
||||
menuTree: MenuNode[]
|
||||
activeId: string
|
||||
currentPath: string
|
||||
asideWidth: number
|
||||
siderWidth: number
|
||||
width: number
|
||||
isOverlay?: boolean
|
||||
layoutMode?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'route-click', routeId: string): void
|
||||
(e: 'sub-click', payload: { id: string, path: string }): void
|
||||
}>()
|
||||
|
||||
function getGroupRoutes(groupId: string): RouteRecord[] {
|
||||
return props.routes.get(groupId) || []
|
||||
// --- 展开状态管理 ---
|
||||
const openIdsByLevel = reactive<Record<number, string>>({
|
||||
1: '',
|
||||
2: '',
|
||||
3: ''
|
||||
})
|
||||
|
||||
/** 判断项是否激活 (页面级) */
|
||||
function isActive(node: MenuNode): boolean {
|
||||
// 只有非分组项才能被视为“激活”变色 (1:1 复刻 screenshot 效果)
|
||||
if (node.type === 'group') return false
|
||||
|
||||
if (props.activeId != '' && node.id === props.activeId) return true
|
||||
if (props.currentPath != '' && node.path === props.currentPath) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function onRouteClick(routeId: string): void {
|
||||
emit('route-click', routeId)
|
||||
/** 判断分组是否展开 */
|
||||
function isOpen(id: string, level: number): boolean {
|
||||
return openIdsByLevel[level] === id
|
||||
}
|
||||
|
||||
/** 统一点击处理 */
|
||||
function handleNodeClick(node: MenuNode, level: number, parentId: string = ''): void {
|
||||
if (node.type === 'group') {
|
||||
// 手风琴逻辑
|
||||
if (openIdsByLevel[level] === node.id) {
|
||||
openIdsByLevel[level] = ''
|
||||
} else {
|
||||
openIdsByLevel[level] = node.id
|
||||
}
|
||||
} else {
|
||||
// 页面跳转
|
||||
emit('sub-click', { id: node.id, path: node.path || '' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动展开逻辑:根据当前激活项回溯父级展开状态 */
|
||||
function autoExpand() {
|
||||
const targetId = props.activeId || ''
|
||||
const targetPath = props.currentPath || ''
|
||||
|
||||
if (targetId === '' && targetPath === '') return
|
||||
|
||||
const findPath = (nodes: MenuNode[]): string[] | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'page' && ((targetId != '' && node.id === targetId) || (targetPath != '' && node.path === targetPath))) {
|
||||
return [node.id]
|
||||
}
|
||||
if (node.children != null) {
|
||||
const childPath = findPath(node.children!)
|
||||
if (childPath != null) {
|
||||
return [node.id, ...childPath]
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const path = findPath(props.menuTree)
|
||||
if (path != null) {
|
||||
// path 包含了从祖先到叶子的所有 ID
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
openIdsByLevel[i + 1] = path[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.activeId, () => autoExpand())
|
||||
watch(() => props.currentPath, () => autoExpand())
|
||||
watch(() => props.menuTree, () => autoExpand(), { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
autoExpand()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -54,79 +162,122 @@ function onRouteClick(routeId: string): void {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 999;
|
||||
z-index: 1000;
|
||||
transition: transform 300ms ease, opacity 250ms ease;
|
||||
}
|
||||
|
||||
.subsider-header {
|
||||
height: 64px;
|
||||
.admin-subsider.is-hidden {
|
||||
transform: translate3d(-100%, 0, 0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sub-sider-overlay {
|
||||
z-index: 1010;
|
||||
box-shadow: 6px 0 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.subsider-cat-name {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background-color: #fff;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
.cat-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.subsider-menu {
|
||||
.subsider-content {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.menu-group {
|
||||
margin-bottom: 16px;
|
||||
.menu-list {
|
||||
padding: 0; /* 移除 8px 边距,确保菜单项紧贴顶部/标题栏 */
|
||||
}
|
||||
|
||||
.group-title {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
|
||||
text {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
height: 36px;
|
||||
padding: 0 16px 0 24px;
|
||||
.menu-row {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: #1890ff;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid transparent; /* 保持布局稳定 */
|
||||
|
||||
&.is-active {
|
||||
background-color: #e7f0ff;
|
||||
.menu-text {
|
||||
color: #2f65ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.item-title {
|
||||
|
||||
.menu-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 1:1 复刻 CRMEB 缩进逻辑 */
|
||||
&.level-1 {
|
||||
padding-left: 20px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
&.level-2 {
|
||||
padding-left: 44px; /* 二级缩进 */
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
&.level-3 {
|
||||
padding-left: 64px; /* 三级进一步缩进 */
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 10px;
|
||||
color: #c0c4cc;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 旋转动画 */
|
||||
.is-open .chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sub-menu-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 展开收起动画 */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
max-height: 1000px; /* 足够大的预设值 */
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +1,122 @@
|
||||
<template>
|
||||
<view class="tags">
|
||||
<scroll-view class="tags-scroll" scroll-x="true" show-scrollbar="false">
|
||||
<view class="tags-row">
|
||||
<transition-group name="tag-list" tag="view" class="tags-row">
|
||||
<view
|
||||
v-for="t in tabs"
|
||||
:key="t.id"
|
||||
class="tag"
|
||||
:class="{ active: activeTabId === t.id }"
|
||||
@click="$emit('tab-click', t)"
|
||||
@click="onTabClick(t)"
|
||||
@contextmenu.prevent="openContextMenu($event, t)"
|
||||
>
|
||||
<text class="tag-text">{{ t.title }}</text>
|
||||
<view class="tag-close" @click.stop="$emit('tab-close', t.id)">
|
||||
<view v-if="!t.isAffix" class="tag-close" @click.stop="$emit('tab-close', t.id)">
|
||||
<text class="tag-close-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</transition-group>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 右键菜单 (带动画) -->
|
||||
<transition name="menu-fade">
|
||||
<view
|
||||
v-if="menuVisible"
|
||||
class="context-menu"
|
||||
:style="{ top: menuY + 'px', left: menuX + 'px' }"
|
||||
@click.stop=""
|
||||
>
|
||||
<view class="menu-item" @click="handleAction('refresh')">
|
||||
<text class="menu-icon">↻</text>
|
||||
<text class="menu-text">刷新</text>
|
||||
</view>
|
||||
<view class="menu-item" v-if="!selectedTab?.isAffix" @click="handleAction('close')">
|
||||
<text class="menu-icon">×</text>
|
||||
<text class="menu-text">关闭</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="handleAction('close-other')">
|
||||
<text class="menu-icon">↸</text>
|
||||
<text class="menu-text">关闭其他</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="handleAction('close-all')">
|
||||
<text class="menu-icon">⊘</text>
|
||||
<text class="menu-text">全部关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
</transition>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import type { TabItem } from '../types.uts'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { TabItem } from '../store/adminNavStore.uts'
|
||||
|
||||
defineProps<{
|
||||
tabs: TabItem[]
|
||||
activeTabId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
(e:'tab-click', tab: TabItem): void
|
||||
(e:'tab-close', tabId: string): void
|
||||
(e:'close-other', tabId: string): void
|
||||
(e:'close-all'): void
|
||||
(e:'refresh'): void
|
||||
}>()
|
||||
|
||||
// 右键菜单状态
|
||||
const menuVisible = ref(false)
|
||||
const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
const selectedTab = ref<TabItem | null>(null)
|
||||
|
||||
function onTabClick(tab: TabItem) {
|
||||
closeMenu()
|
||||
emit('tab-click', tab)
|
||||
}
|
||||
|
||||
function openContextMenu(e: MouseEvent, tab: TabItem) {
|
||||
selectedTab.value = tab
|
||||
menuX.value = e.clientX
|
||||
menuY.value = e.clientY
|
||||
|
||||
// 边缘检测
|
||||
if (menuX.value + 100 > window.innerWidth) {
|
||||
menuX.value = window.innerWidth - 110
|
||||
}
|
||||
|
||||
menuVisible.value = true
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuVisible.value = false
|
||||
}
|
||||
|
||||
function handleAction(type: string) {
|
||||
if (!selectedTab.value) return
|
||||
|
||||
const id = selectedTab.value!.id
|
||||
|
||||
if (type === 'refresh') {
|
||||
emit('refresh')
|
||||
} else if (type === 'close') {
|
||||
emit('tab-close', id)
|
||||
} else if (type === 'close-other') {
|
||||
emit('close-other', id)
|
||||
} else if (type === 'close-all') {
|
||||
emit('close-all')
|
||||
}
|
||||
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', closeMenu)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('click', closeMenu)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -41,6 +127,7 @@ defineEmits<{
|
||||
display:flex;
|
||||
flex-direction:row;
|
||||
align-items:center;
|
||||
position: relative;
|
||||
}
|
||||
.tags-scroll{ width: 100%; height: 44px; }
|
||||
.tags-row{
|
||||
@@ -63,6 +150,11 @@ defineEmits<{
|
||||
flex-direction: row;
|
||||
align-items:center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tag:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.tag.active{
|
||||
border-color:#1677ff;
|
||||
@@ -78,5 +170,80 @@ defineEmits<{
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
}
|
||||
.tag-close:hover {
|
||||
background: rgba(0,0,0,0.1);
|
||||
}
|
||||
.tag-close-text{ font-size:14px; color:#6b7280; line-height:14px; }
|
||||
|
||||
/* 右键菜单样式 */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
|
||||
padding: 5px 0;
|
||||
border-radius: 4px;
|
||||
min-width: 100px;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
color: #606266;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 标签列表动画 - 平滑平移和缩放 */
|
||||
.tag-list-enter-active,
|
||||
.tag-list-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.tag-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) scale(0.9);
|
||||
}
|
||||
|
||||
.tag-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.8);
|
||||
}
|
||||
|
||||
/* 确保列表项在移动过程中位置平滑切换 */
|
||||
.tag-list-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 右键菜单动画 - 类似 CRMEB 的缩放渐入 */
|
||||
.menu-fade-enter-active,
|
||||
.menu-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.5, 1);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.menu-fade-enter-from,
|
||||
.menu-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user