Files
yunzerwebsiteallinone/uniapp/components/sub/SubFunctions.vue
T
2026-10-16 22:40:02 +08:00

358 lines
8.3 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.
<template>
<view class="page" :class="{ embedded }">
<view v-if="!embedded" class="head" :style="headPad">
<view class="head-row" @tap="goBackNav">
<FaIcon name="chevron-left" color="#1f2b48" :size="18" />
<text class="head-back">返回导航</text>
</view>
<text class="head-title">{{ sub.title }}</text>
</view>
<view class="body">
<view v-if="loadError" class="err-box" @tap="load(true)">
<FaIcon name="triangle-exclamation" color="#f04438" :size="18" />
<view class="err-body">
<text class="err-title">加载失败</text>
<text class="err-msg">{{ loadError }},点击重试</text>
</view>
</view>
<view v-else-if="groups.length === 0" class="empty-box">
<FaIcon name="table-cells" color="#c0c4cc" :size="30" />
<text class="empty-text">该子系统下暂无你可见的功能</text>
</view>
<!-- 功能分组:每个分组一个 card,组内为功能 card(一行 4 个,仅图标 + 名称) -->
<view v-else class="group-list">
<view v-for="group in groups" :key="group.id" class="group-card">
<view class="group-head">
<text class="group-title">{{ group.title }}</text>
</view>
<view class="grid">
<view
v-for="item in group.items"
:key="item.id"
class="cell"
@tap="openMenu(item, item.title)"
>
<view class="fn-card" :class="{ locked: item.locked }">
<view
class="tint-icon fn-icon"
:class="item.locked ? 'tint-off' : 'tint-' + (item.tintIndex % 8)"
>
<FaIcon :name="item.icon || fallbackIcon" color="#ffffff" :size="20" />
</view>
<text class="fn-name">{{ item.title }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="bottom-space" />
</view>
<AppTabbar v-if="!embedded" :group="slug" current="functions" />
</view>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { mpHeadPad } from '@/utils/mpHead.js'
const headPad = mpHeadPad()
import { onShow } from '@dcloudio/uni-app'
import { isLoggedIn } from '@/utils/auth.js'
import { openMenu, WEB_RULES } from '@/utils/nav.js'
import { useSubsystem } from '@/utils/subsystem.js'
import AppTabbar from '@/components/AppTabbar.vue'
const props = defineProps({
slug: { type: String, required: true },
embedded: { type: Boolean, default: false }
})
const emit = defineEmits(['switch-tab', 'back'])
const { sub, loading, loadError, allMenus, load } = useSubsystem(props.slug)
/** 模块 code → 图标,用于菜单自身没配图标时的兜底 */
const fallbackIcon = computed(() => {
const map = {
crm: 'handshake',
erp: 'boxes-stacked',
oa: 'clipboard-check',
cms: 'globe',
bianjiegongju: 'wrench'
}
return map[String(sub.module || '').toLowerCase()] || 'file-lines'
})
/** 判断菜单项是否命中 WEB_RULES(小程序端不再展示,由 Web 端处理) */
function isWebEntry(menu) {
if (!menu) return false
const title = String(menu.title || '')
const path = String(menu.path || '')
return WEB_RULES.some(kw => title.includes(kw) || path.includes(kw))
}
/**
* 按「一级分类目录」分组:
* 菜单层级形如 模块根 → 个人办公 → 通讯录,分组取一级目录(个人办公 / 人事管理 …),
* 组内为该目录下的全部可见功能。无分类归属的快捷功能不在功能页展示,由首页「常用功能」承载。
*/
const groups = computed(() => {
// 先把命中 WEB_RULES 的菜单项过滤掉(这些是 Web 端入口,小程序功能页不展示)
const menus = allMenus.value
.filter(m => !isWebEntry(m))
.slice()
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
const byId = {}
menus.forEach((m) => { byId[m.id] = m })
// 取该菜单所属的「一级分组」节点
function groupNodeOf(m) {
// 自底向上收集祖先链:chain[0] 为链上最顶层节点
const chain = [m]
let cur = m
let guard = 0
while (byId[cur.pid] && guard++ < 20) {
cur = byId[cur.pid]
chain.unshift(cur)
}
const top = chain[0]
if (top.pid === 0) {
// 模块根在列表内:跳过根,取它下一层(个人办公 / 人事管理 …)
if (chain.length >= 2) return chain[1]
// 顶层节点自身:目录视为模块容器跳过,叶子无分类归属
return top.leaf === false ? null : top
}
// 模块根不在列表内:链上顶层节点本身就是一级分组
return top
}
const groupMap = new Map()
menus.forEach((m) => {
const g = groupNodeOf(m)
if (!g) return
if (g.id === m.id) {
// 自身即分组节点:目录作组标题(不重复出现在组内),叶子无分类归属不展示
if (g.leaf === false && !groupMap.has(g.id)) {
groupMap.set(g.id, { id: g.id, title: g.title, sort: g.sort, items: [] })
}
return
}
if (!groupMap.has(g.id)) {
groupMap.set(g.id, { id: g.id, title: g.title, sort: g.sort, items: [] })
}
groupMap.get(g.id).items.push(m)
})
// 功能页只展示「归属分类目录且有可见功能」的分组;无归属功能由首页「常用功能」承载
const list = []
groupMap.forEach((g) => {
if (g.items.length) list.push(g)
})
list.sort((a, b) => (a.sort || 0) - (b.sort || 0))
// 颜色下标按展开顺序统一编号,避免每个分组都从 tint-0 开始
let colorIndex = 0
return list.map((group) => ({
...group,
items: group.items.map((item) => ({ ...item, tintIndex: colorIndex++ }))
}))
})
function goBackNav() {
if (props.embedded) {
emit('back')
return
}
uni.reLaunch({ url: '/pages/nav/nav' })
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
load()
})
onShow(() => {
if (isLoggedIn() && !loading.value) load()
})
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
@include page-shell;
// 嵌入模式下由父页(tabs.vue)的 scroll-view 统一滚动,这里改为自然高度
&.embedded {
height: auto;
}
}
.head {
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 20rpx) $page-padding-x 24rpx;
/* #ifdef MP-WEIXIN */
padding-top: calc(env(safe-area-inset-top, 25px) + 84rpx);
/* #endif */
background: $color-card;
box-shadow: $shadow-header;
}
.head-row {
display: flex;
align-items: center;
gap: 4rpx;
}
.head-back {
font-size: 24rpx;
color: $color-text-muted;
}
.head-title {
display: block;
margin-top: 14rpx;
font-size: 36rpx;
font-weight: 600;
color: $color-text;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 28rpx $page-padding-x 0;
}
.empty-box {
@include card;
padding: 80rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.empty-text {
font-size: 26rpx;
color: $color-text-muted;
}
.err-box {
display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx 22rpx;
background: #fef3f2;
border: 1rpx solid #fdd8d4;
border-radius: $radius-md;
}
.err-body {
flex: 1;
min-width: 0;
}
.err-title {
display: block;
font-size: 27rpx;
font-weight: 500;
color: $color-danger;
}
.err-msg {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #b42318;
word-break: break-all;
}
/* ============ 分组卡:白色卡片 + 一行 4 个功能宫格(图标在上、名称在下) ============
cell 占 25% 宽并自带右侧间隙,容器用负 margin 抵消末列间隙,
避免使用 calc 除法——小程序端 wxss 对复杂 calc 支持不稳。 */
.group-card {
@include card;
padding: 24rpx 20rpx 8rpx;
& + & {
margin-top: 24rpx;
}
}
.group-head {
display: flex;
align-items: center;
gap: 12rpx;
padding: 0 4rpx 20rpx;
}
.group-title {
@include section-title;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grid {
display: flex;
flex-wrap: wrap;
margin-right: -16rpx;
}
.cell {
width: 25%;
padding-right: 16rpx;
margin-bottom: 24rpx;
box-sizing: border-box;
}
.fn-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
transition: transform 0.15s ease;
&:active {
transform: scale(0.95);
}
}
.fn-icon {
@include tint-icon(84rpx, 24rpx);
}
.fn-name {
max-width: 100%;
font-size: 24rpx;
line-height: 1.3;
color: $color-text;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fn-card.locked {
.fn-name {
color: $color-text-muted;
}
}
.bottom-space {
height: 32rpx;
}
</style>