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