refactor:重构功能导航模块
parent
b849d644ee
commit
af78c2ffa3
@ -0,0 +1,16 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getUserNavMenuList() {
|
||||
return request({
|
||||
url: '/admin-api/system/user-nav-menu/list',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function updateUserNavMenuList(data) {
|
||||
return request({
|
||||
url: '/admin-api/system/user-nav-menu/update-list',
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<view class="custom-tabbar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
|
||||
<view class="tabbar-inner">
|
||||
<view
|
||||
v-for="(item, index) in tabList"
|
||||
:key="index"
|
||||
class="tabbar-item"
|
||||
:class="{ active: activeIndex === index }"
|
||||
@click="handleTabClick(item, index)"
|
||||
>
|
||||
<image
|
||||
:src="activeIndex === index ? item.selectedIcon : item.icon"
|
||||
class="tabbar-icon"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<text class="tabbar-text" :style="{ color: activeIndex === index ? activeColor : color }">
|
||||
{{ item.text }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import useUserStore from '@/store/modules/user'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { getDynamicTabMenus } from '@/utils/permissionMenu'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const { menus } = storeToRefs(userStore)
|
||||
|
||||
const color = '#666666'
|
||||
const activeColor = '#1a3a5c'
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const homeIcon = '/static/images/tabbar/home.png'
|
||||
const homeSelectedIcon = '/static/images/tabbar/home_.png'
|
||||
const reportIcon = '/static/images/tabbar/report.png'
|
||||
const reportSelectedIcon = '/static/images/tabbar/report_.png'
|
||||
const workIcon = '/static/images/tabbar/work.png'
|
||||
const workSelectedIcon = '/static/images/tabbar/work_.png'
|
||||
const mineIcon = '/static/images/tabbar/mine.png'
|
||||
const mineSelectedIcon = '/static/images/tabbar/mine_.png'
|
||||
|
||||
const routeMap = {
|
||||
'pages/index': 0,
|
||||
'pages/report': 1,
|
||||
'pages/work': 2,
|
||||
'pages/mine': 3
|
||||
}
|
||||
|
||||
function getCurrentActiveIndex() {
|
||||
const pages = getCurrentPages()
|
||||
if (pages && pages.length > 0) {
|
||||
const route = pages[pages.length - 1].route
|
||||
return routeMap[route] !== undefined ? routeMap[route] : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const tabList = computed(() => {
|
||||
const dynamicMenus = getDynamicTabMenus(menus.value)
|
||||
|
||||
return [
|
||||
{
|
||||
text: t('nav.home'),
|
||||
icon: homeIcon,
|
||||
selectedIcon: homeSelectedIcon,
|
||||
path: '/pages/index'
|
||||
},
|
||||
{
|
||||
text: dynamicMenus[0]?.name || t('tab.report'),
|
||||
icon: reportIcon,
|
||||
selectedIcon: reportSelectedIcon,
|
||||
path: '/pages/report'
|
||||
},
|
||||
{
|
||||
text: dynamicMenus[1]?.name || dynamicMenus[0]?.name || t('tab.work'),
|
||||
icon: workIcon,
|
||||
selectedIcon: workSelectedIcon,
|
||||
path: '/pages/work'
|
||||
},
|
||||
{
|
||||
text: t('nav.mine'),
|
||||
icon: mineIcon,
|
||||
selectedIcon: mineSelectedIcon,
|
||||
path: '/pages/mine'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const safeAreaBottom = computed(() => {
|
||||
const model = (systemInfo.model || '').toLowerCase()
|
||||
const hasNotch = model.includes('iphone x') || model.includes('iphone 1') || (systemInfo.safeAreaInsets && systemInfo.safeAreaInsets.bottom > 20)
|
||||
return hasNotch ? 0 : 0
|
||||
})
|
||||
const safeAreaInsetBottom = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
activeIndex.value = getCurrentActiveIndex()
|
||||
const insets = systemInfo.safeAreaInsets
|
||||
if (insets && insets.bottom > 0) {
|
||||
safeAreaInsetBottom.value = insets.bottom
|
||||
}
|
||||
})
|
||||
|
||||
function handleTabClick(item, index) {
|
||||
if (activeIndex.value === index) return
|
||||
activeIndex.value = index
|
||||
uni.reLaunch({
|
||||
url: item.path
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => useUserStore().menus, () => {
|
||||
activeIndex.value = getCurrentActiveIndex()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tabbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.tabbar-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.tabbar-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.tabbar-text {
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,634 @@
|
||||
<template>
|
||||
<view v-if="visible" class="nav-menu-editor-mask" @click="handleClose">
|
||||
<view class="nav-menu-editor" @click.stop @touchmove.stop.prevent="handleDragMove" @touchend.stop="handleDragEnd"
|
||||
@touchcancel.stop="handleDragEnd">
|
||||
<view class="editor-header">
|
||||
<text class="editor-title">{{ t('dashboard.editNavMenu') }}</text>
|
||||
<view class="editor-close" @click="handleClose">
|
||||
<uni-icons type="close" size="24" color="#999"></uni-icons>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="editor-content">
|
||||
<view class="nav-section">
|
||||
<text class="nav-section-title">{{ t('dashboard.configuredNav') }}</text>
|
||||
<view id="configured-zone" class="nav-grid" :class="{ 'nav-grid-target': isSectionDropTarget('configured') }">
|
||||
<view v-for="item in configuredMenuList" :key="item.id" :id="`configured-item-${item.id}`" class="nav-item"
|
||||
:class="{
|
||||
'is-drop-target': isItemDropTarget('configured', item.id),
|
||||
'is-dragging-source': draggingMenu?.id === item.id
|
||||
}" @click="handleConfiguredClick(item)"
|
||||
@longpress.stop.prevent="handleItemLongPress(item, 'configured', $event)">
|
||||
<view class="nav-icon" :style="{ background: hexToRgba(item.accentColor, 0.1) }">
|
||||
<uni-icons v-if="isUniIcon(item.icon)" :type="getUniIconName(item.icon)" size="24" :color="item.accentColor" />
|
||||
<u-icon v-else-if="isUviewIcon(item.icon)" :name="getUviewIconName(item.icon)" size="24" :color="item.accentColor"></u-icon>
|
||||
<text v-else class="nav-icon-text">{{ item.symbol }}</text>
|
||||
</view>
|
||||
<text class="nav-text">{{ item.displayName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="divider-line"></view>
|
||||
|
||||
<view class="nav-section">
|
||||
<text class="nav-section-title">{{ t('dashboard.unconfiguredNav') }}</text>
|
||||
<view id="unconfigured-zone" class="nav-grid"
|
||||
:class="{ 'nav-grid-target': isSectionDropTarget('unconfigured') }">
|
||||
<view v-for="item in unconfiguredMenuList" :key="item.id" :id="`unconfigured-item-${item.id}`"
|
||||
class="nav-item nav-item-disabled" :class="{
|
||||
'is-drop-target': isItemDropTarget('unconfigured', item.id),
|
||||
'is-dragging-source': draggingMenu?.id === item.id
|
||||
}" @click="handleUnconfiguredClick(item)"
|
||||
@longpress.stop.prevent="handleItemLongPress(item, 'unconfigured', $event)">
|
||||
<view class="nav-icon nav-icon-disabled" :style="{ background: hexToRgba(item.accentColor, 0.1) }">
|
||||
<uni-icons v-if="isUniIcon(item.icon)" :type="getUniIconName(item.icon)" size="24" :color="item.accentColor" />
|
||||
<u-icon v-else-if="isUviewIcon(item.icon)" :name="getUviewIconName(item.icon)" size="24" :color="item.accentColor"></u-icon>
|
||||
<text v-else class="nav-icon-text">{{ item.symbol }}</text>
|
||||
</view>
|
||||
<text class="nav-text nav-text-disabled">{{ item.displayName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="click-hint">{{ t('dashboard.dragHint') }}</text>
|
||||
<text class="click-hint secondary-hint">{{ t('dashboard.clickHint') }}</text>
|
||||
</view>
|
||||
|
||||
<view class="editor-footer">
|
||||
<view class="btn-reset" @click="handleReset">
|
||||
<text>{{ t('common.reset') }}</text>
|
||||
</view>
|
||||
<view class="btn-confirm" @click="handleConfirm">
|
||||
<text>{{ t('common.complete') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="draggingMenu" class="drag-ghost" :style="dragGhostStyle">
|
||||
<view class="nav-icon" :style="{ background: hexToRgba(draggingMenu.accentColor, 0.1) }">
|
||||
<uni-icons v-if="isUniIcon(draggingMenu.icon)" :type="getUniIconName(draggingMenu.icon)" size="24" :color="draggingMenu.accentColor" />
|
||||
<u-icon v-else-if="isUviewIcon(draggingMenu.icon)" :name="getUviewIconName(draggingMenu.icon)" size="24" :color="draggingMenu.accentColor"></u-icon>
|
||||
<text v-else class="nav-icon-text">{{ draggingMenu.symbol }}</text>
|
||||
</view>
|
||||
<text class="nav-text">{{ draggingMenu.displayName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, getCurrentInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import useUserStore from '@/store/modules/user'
|
||||
import { getUserNavMenuList, updateUserNavMenuList } from '@/api/system/userNavMenu'
|
||||
import { getNavPermissionInfo } from '@/api/login'
|
||||
import { buildNavMenuViewModels } from '@/utils/permissionMenu'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
const value = String(hex || '').replace('#', '')
|
||||
if (value.length !== 6) {
|
||||
return `rgba(45, 90, 135, ${alpha})`
|
||||
}
|
||||
const red = parseInt(value.slice(0, 2), 16)
|
||||
const green = parseInt(value.slice(2, 4), 16)
|
||||
const blue = parseInt(value.slice(4, 6), 16)
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
||||
}
|
||||
|
||||
function isUniIcon(icon) {
|
||||
return String(icon || '').startsWith('uni-icons:')
|
||||
}
|
||||
|
||||
function isUviewIcon(icon) {
|
||||
return String(icon || '').startsWith('uview-plus:')
|
||||
}
|
||||
|
||||
function getUniIconName(icon) {
|
||||
return String(icon || '').replace(/^uni-icons:/, '').trim()
|
||||
}
|
||||
|
||||
function getUviewIconName(icon) {
|
||||
return String(icon || '').replace(/^uview-plus:/, '').trim()
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'update'])
|
||||
|
||||
const userMenuList = ref([])
|
||||
const configuredRecords = ref([])
|
||||
const originConfIds = ref([])
|
||||
const configuredIds = ref([])
|
||||
const dragTargets = ref([])
|
||||
const dropTarget = ref(null)
|
||||
const draggingState = ref(null)
|
||||
const lastDragEndAt = ref(0)
|
||||
|
||||
const menuMap = computed(() => {
|
||||
const map = {}
|
||||
userMenuList.value.forEach(item => {
|
||||
map[item.id] = item
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const draggingMenu = computed(() => {
|
||||
if (!draggingState.value) {
|
||||
return null
|
||||
}
|
||||
return menuMap.value[draggingState.value.id] || null
|
||||
})
|
||||
|
||||
const dragGhostStyle = computed(() => {
|
||||
if (!draggingState.value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${draggingState.value.x - 48}px`,
|
||||
top: `${draggingState.value.y - 48}px`
|
||||
}
|
||||
})
|
||||
|
||||
const configuredMenuList = computed(() => {
|
||||
return configuredIds.value
|
||||
.map(id => menuMap.value[id])
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const unconfiguredMenuList = computed(() => {
|
||||
return userMenuList.value
|
||||
.filter(item => !configuredIds.value.includes(item.id))
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const menuRes = await getNavPermissionInfo()
|
||||
userMenuList.value = buildNavMenuViewModels(menuRes?.data?.menus)
|
||||
const navRes = await getUserNavMenuList()
|
||||
configuredRecords.value = Array.isArray(navRes?.data) ? [...navRes.data] : []
|
||||
configuredRecords.value.sort((left, right) => Number(left?.sort || 0) - Number(right?.sort || 0))
|
||||
|
||||
configuredIds.value = configuredRecords.value
|
||||
.map(item => item.menuId)
|
||||
.filter(menuId => !!menuMap.value[menuId])
|
||||
|
||||
originConfIds.value = [...configuredIds.value]
|
||||
await nextTick()
|
||||
collectDropTargets()
|
||||
} catch (error) {
|
||||
console.error('加载菜单数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfiguredClick(item) {
|
||||
if (shouldIgnoreClick()) {
|
||||
return
|
||||
}
|
||||
configuredIds.value = configuredIds.value.filter(id => id !== item.id)
|
||||
}
|
||||
|
||||
function handleUnconfiguredClick(item) {
|
||||
if (shouldIgnoreClick()) {
|
||||
return
|
||||
}
|
||||
if (!configuredIds.value.includes(item.id)) {
|
||||
configuredIds.value.push(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
configuredIds.value = [...originConfIds.value]
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
const userId = userStore.userId
|
||||
|
||||
const data = configuredIds.value.map((menuId, index) => ({
|
||||
menuId,
|
||||
userId,
|
||||
sort: index,
|
||||
status: 1
|
||||
}))
|
||||
|
||||
await updateUserNavMenuList(data)
|
||||
uni.showToast({ title: t('common.updateSuccess'), icon: 'success' })
|
||||
emit('update')
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
uni.showToast({ title: t('common.saveFailed'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetDragState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function shouldIgnoreClick() {
|
||||
return Date.now() - lastDragEndAt.value < 300
|
||||
}
|
||||
|
||||
function getTouchPoint(event) {
|
||||
const touch = event?.changedTouches?.[0] || event?.touches?.[0]
|
||||
if (touch) {
|
||||
return {
|
||||
x: touch.clientX ?? touch.pageX ?? 0,
|
||||
y: touch.clientY ?? touch.pageY ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof event?.detail?.x === 'number' && typeof event?.detail?.y === 'number') {
|
||||
return {
|
||||
x: event.detail.x,
|
||||
y: event.detail.y
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function collectDropTargets() {
|
||||
if (!instance?.proxy) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectors = [
|
||||
{ selector: '#configured-zone', kind: 'section', section: 'configured' },
|
||||
{ selector: '#unconfigured-zone', kind: 'section', section: 'unconfigured' }
|
||||
]
|
||||
|
||||
configuredMenuList.value.forEach((item) => {
|
||||
selectors.push({
|
||||
selector: `#configured-item-${item.id}`,
|
||||
kind: 'item',
|
||||
section: 'configured',
|
||||
id: item.id
|
||||
})
|
||||
})
|
||||
|
||||
unconfiguredMenuList.value.forEach((item) => {
|
||||
selectors.push({
|
||||
selector: `#unconfigured-item-${item.id}`,
|
||||
kind: 'item',
|
||||
section: 'unconfigured',
|
||||
id: item.id
|
||||
})
|
||||
})
|
||||
|
||||
const query = uni.createSelectorQuery().in(instance.proxy)
|
||||
selectors.forEach((item) => {
|
||||
query.select(item.selector).boundingClientRect()
|
||||
})
|
||||
|
||||
query.exec((rects = []) => {
|
||||
dragTargets.value = rects
|
||||
.map((rect, index) => {
|
||||
if (!rect) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...selectors[index],
|
||||
...rect
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
|
||||
function updateDropTarget(point) {
|
||||
const itemTarget = dragTargets.value.find((item) => {
|
||||
return item.kind === 'item'
|
||||
&& point.x >= item.left
|
||||
&& point.x <= item.right
|
||||
&& point.y >= item.top
|
||||
&& point.y <= item.bottom
|
||||
})
|
||||
|
||||
if (itemTarget) {
|
||||
dropTarget.value = {
|
||||
section: itemTarget.section,
|
||||
id: itemTarget.id,
|
||||
position: point.y < itemTarget.top + itemTarget.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sectionTarget = dragTargets.value.find((item) => {
|
||||
return item.kind === 'section'
|
||||
&& point.x >= item.left
|
||||
&& point.x <= item.right
|
||||
&& point.y >= item.top
|
||||
&& point.y <= item.bottom
|
||||
})
|
||||
|
||||
dropTarget.value = sectionTarget
|
||||
? { section: sectionTarget.section, id: null, position: 'end' }
|
||||
: null
|
||||
}
|
||||
|
||||
async function handleItemLongPress(item, section, event) {
|
||||
const point = getTouchPoint(event) || { x: 0, y: 0 }
|
||||
draggingState.value = {
|
||||
id: item.id,
|
||||
section,
|
||||
x: point.x,
|
||||
y: point.y
|
||||
}
|
||||
dropTarget.value = {
|
||||
section,
|
||||
id: item.id,
|
||||
position: 'after'
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
collectDropTargets()
|
||||
updateDropTarget(point)
|
||||
}
|
||||
|
||||
function handleDragMove(event) {
|
||||
if (!draggingState.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const point = getTouchPoint(event)
|
||||
if (!point) {
|
||||
return
|
||||
}
|
||||
|
||||
draggingState.value = {
|
||||
...draggingState.value,
|
||||
x: point.x,
|
||||
y: point.y
|
||||
}
|
||||
updateDropTarget(point)
|
||||
}
|
||||
|
||||
function applyDrop() {
|
||||
if (!draggingState.value || !dropTarget.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dropTarget.value.section === 'unconfigured') {
|
||||
configuredIds.value = configuredIds.value.filter(id => id !== draggingState.value.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (dropTarget.value.section !== 'configured') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextIds = configuredIds.value.filter(id => id !== draggingState.value.id)
|
||||
let insertIndex = nextIds.length
|
||||
|
||||
if (dropTarget.value.id != null) {
|
||||
const targetIndex = nextIds.indexOf(dropTarget.value.id)
|
||||
if (targetIndex !== -1) {
|
||||
insertIndex = dropTarget.value.position === 'before' ? targetIndex : targetIndex + 1
|
||||
}
|
||||
}
|
||||
|
||||
nextIds.splice(insertIndex, 0, draggingState.value.id)
|
||||
configuredIds.value = nextIds
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
if (!draggingState.value) {
|
||||
return
|
||||
}
|
||||
|
||||
applyDrop()
|
||||
lastDragEndAt.value = Date.now()
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
function resetDragState() {
|
||||
dropTarget.value = null
|
||||
draggingState.value = null
|
||||
}
|
||||
|
||||
function isItemDropTarget(section, itemId) {
|
||||
return dropTarget.value?.section === section && dropTarget.value?.id === itemId
|
||||
}
|
||||
|
||||
function isSectionDropTarget(section) {
|
||||
return dropTarget.value?.section === section && dropTarget.value?.id == null
|
||||
}
|
||||
|
||||
watch(() => props.visible, async (val) => {
|
||||
if (val) {
|
||||
await loadData()
|
||||
return
|
||||
}
|
||||
|
||||
resetDragState()
|
||||
})
|
||||
|
||||
watch([configuredMenuList, unconfiguredMenuList], async () => {
|
||||
if (!props.visible) {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
collectDropTargets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nav-menu-editor-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding-bottom: calc(100rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-menu-editor {
|
||||
width: 100%;
|
||||
max-height: 66.6vh;
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 32rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.editor-close {
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
height: 1rpx;
|
||||
background: #f0f0f0;
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
margin-bottom: 20rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-height: 120rpx;
|
||||
border-radius: 24rpx;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-grid-target {
|
||||
background: rgba(34, 72, 110, 0.06);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 16rpx;
|
||||
border-radius: 16rpx;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.nav-item-disabled {
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
.is-drop-target {
|
||||
background: rgba(34, 72, 110, 0.08);
|
||||
}
|
||||
|
||||
.is-dragging-source {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
&.nav-icon-disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.nav-icon-text {
|
||||
font-size: 28rpx;
|
||||
color: #1a3a5c;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
|
||||
&.nav-text-disabled {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.click-hint {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.secondary-hint {
|
||||
padding-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.btn-reset,
|
||||
.btn-confirm {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background: #22486e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.drag-ghost {
|
||||
position: fixed;
|
||||
z-index: 1002;
|
||||
width: 96rpx;
|
||||
margin-left: -8rpx;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<view v-if="visible" class="nav-menu-more-mask" @click="handleClose">
|
||||
<view class="nav-menu-more" @click.stop>
|
||||
<view class="more-header">
|
||||
<text class="more-title">{{ t('dashboard.allNavMenu') }}</text>
|
||||
<view class="more-actions">
|
||||
<view class="action-btn edit-btn" @click="handleEdit">
|
||||
<uni-icons type="compose" size="22" color="#22486e"></uni-icons>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="more-content">
|
||||
<view v-if="displayMenuList.length === 0" class="empty-state">
|
||||
<text class="empty-text">{{ t('dashboard.clickHint') }}</text>
|
||||
</view>
|
||||
<view v-else class="nav-grid">
|
||||
<view
|
||||
v-for="item in displayMenuList"
|
||||
:key="item.id"
|
||||
class="nav-item"
|
||||
@click="handleNavClick(item)"
|
||||
>
|
||||
<view class="nav-icon" :style="{ background: hexToRgba(item.accentColor, 0.1) }">
|
||||
<uni-icons v-if="isUniIcon(item.icon)" :type="getUniIconName(item.icon)" size="24" :color="item.accentColor" />
|
||||
<u-icon v-else-if="isUviewIcon(item.icon)" :name="getUviewIconName(item.icon)" size="24" :color="item.accentColor"></u-icon>
|
||||
<text v-else class="nav-icon-text">{{ item.symbol }}</text>
|
||||
</view>
|
||||
<text class="nav-text">{{ item.displayName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
const value = String(hex || '').replace('#', '')
|
||||
if (value.length !== 6) {
|
||||
return `rgba(45, 90, 135, ${alpha})`
|
||||
}
|
||||
const red = parseInt(value.slice(0, 2), 16)
|
||||
const green = parseInt(value.slice(2, 4), 16)
|
||||
const blue = parseInt(value.slice(4, 6), 16)
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
||||
}
|
||||
|
||||
function isUniIcon(icon) {
|
||||
return String(icon || '').startsWith('uni-icons:')
|
||||
}
|
||||
|
||||
function isUviewIcon(icon) {
|
||||
return String(icon || '').startsWith('uview-plus:')
|
||||
}
|
||||
|
||||
function getUniIconName(icon) {
|
||||
return String(icon || '').replace(/^uni-icons:/, '').trim()
|
||||
}
|
||||
|
||||
function getUviewIconName(icon) {
|
||||
return String(icon || '').replace(/^uview-plus:/, '').trim()
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
menuList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'edit'])
|
||||
|
||||
const displayMenuList = computed(() => Array.isArray(props.menuList) ? props.menuList : [])
|
||||
|
||||
function handleNavClick(item) {
|
||||
if (item.route) {
|
||||
uni.navigateTo({ url: item.route })
|
||||
} else {
|
||||
uni.showToast({ title: t('common.moduleBuilding'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
emit('edit')
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nav-menu-more-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding-bottom: calc(100rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-menu-more {
|
||||
width: 100%;
|
||||
max-height: 66.6vh;
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.more-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 32rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.more-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.more-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 20rpx;
|
||||
|
||||
&.edit-btn {
|
||||
background: rgba(34, 72, 110, 0.1);
|
||||
border-radius: 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: #22486e;
|
||||
}
|
||||
|
||||
&.close-btn {
|
||||
padding: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.more-content {
|
||||
flex: 1;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 220rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 20%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
padding: 16rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
.nav-icon-text {
|
||||
font-size: 28rpx;
|
||||
color: #1a3a5c;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue