Files
yunzerwebsiteallinone/uniapp/pages/features/features.vue
T
2026-10-11 23:25:36 +08:00

415 lines
9.7 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">
<!-- 吸顶搜索 -->
<view class="search-wrap" :style="headPad">
<view class="search-bar">
<FaIcon name="magnifying-glass" color="#8a94ad" :size="16" />
<input
v-model="keyword"
class="search-input"
type="text"
placeholder="搜索应用或功能名称"
placeholder-class="search-placeholder"
confirm-type="search"
/>
<view v-if="keyword" class="search-clear" @tap="keyword = ''">
<FaIcon name="circle-xmark" color="#c0c4cc" :size="16" />
</view>
</view>
</view>
<view class="body">
<!-- 搜索结果:跨模块扁平列表 -->
<block v-if="keyword.trim()">
<view class="result-hint">
<text class="result-hint-text">找到 {{ searchResults.length }} 个结果</text>
</view>
<view v-if="searchResults.length === 0" class="empty-box">
<FaIcon name="magnifying-glass" color="#c0c4cc" :size="32" />
<text class="empty-text">没有找到「{{ keyword.trim() }}」相关功能</text>
</view>
<view v-else class="group-card">
<view class="grid">
<view
v-for="item in searchResults"
:key="item.menu.id"
class="cell"
@tap="openMenu(item.menu, item.menu.title)"
>
<view class="fn-card" :class="{ locked: item.locked }">
<view class="tint-icon fn-icon" :class="item.tint">
<FaIcon :name="item.icon" color="#ffffff" :size="20" />
</view>
<text class="fn-name">{{ item.menu.title }}</text>
</view>
</view>
</view>
</view>
</block>
<!-- 默认:按业务域分组浏览 -->
<block v-else>
<view v-if="groups.length === 0" class="empty-box">
<FaIcon name="cube" color="#c0c4cc" :size="32" />
<text class="empty-text">尚未配置应用模块</text>
</view>
<view v-for="group in groups" :key="group.id" class="group-card">
<view class="group-head">
<text class="group-title">{{ group.name }}</text>
<text v-if="group.locked" class="group-lock">未开通</text>
</view>
<view v-if="group.items.length" 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: group.locked || item.locked }">
<view
class="tint-icon fn-icon"
:class="group.locked || item.locked ? 'tint-off' : 'tint-' + (item.tintIndex % 8)"
>
<FaIcon :name="item.icon || group.icon" color="#ffffff" :size="20" />
</view>
<text class="fn-name">{{ item.title }}</text>
</view>
</view>
</view>
<view v-else class="group-empty">
<text class="group-empty-text">该模块下暂无你可见的功能</text>
</view>
</view>
</block>
<view class="bottom-space" />
</view>
<AppTabbar current="features" />
</view>
</template>
<script setup>
import { ref, 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 { fetchHome } from '@/api/workspace.js'
import { openMenu } from '@/utils/nav.js'
import AppTabbar from '@/components/MainTabbar.vue'
const MODULE_ICONS = {
crm: 'handshake',
erp: 'boxes-stacked',
oa: 'clipboard-check',
cms: 'globe',
ai: 'robot',
system: 'gear',
tool: 'wrench'
}
const keyword = ref('')
const loading = ref(true)
const modules = ref([])
const menus = ref([])
/**
* 按「一级分类目录」归组,与子系统功能页(SubFunctions.vue)保持一致:
* 菜单层级形如 模块根 → 个人办公 → 通讯录,分组取一级目录名(个人办公 / 人事管理 …);
* 模块根仅作容器被跳过;无分类归属的顶层功能统一归入「常用功能」。
*/
const groups = computed(() => {
const byId = {}
menus.value.forEach((m) => {
byId[m.id] = m
})
// 只有叶子菜单才是可点功能;目录只作为分组标题,避免二级目录被当成卡片塞进组内
const leaves = menus.value
.filter((m) => m.leaf !== false)
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
// 取该菜单所属的「一级分组」节点(自底向上找到链上最顶层节点)
function groupNodeOf(m) {
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()
const common = { id: 'common', name: '常用功能', sort: -1, icon: 'star', items: [] }
leaves.forEach((m) => {
const g = groupNodeOf(m)
if (!g) return
if (g.id === m.id) {
// 无分类归属的顶层散装功能
common.items.push(m)
return
}
if (!groupMap.has(g.id)) {
groupMap.set(g.id, { id: g.id, name: g.title, sort: g.sort, icon: g.icon, items: [] })
}
groupMap.get(g.id).items.push(m)
})
const dirGroups = []
groupMap.forEach((g) => dirGroups.push(g))
dirGroups.sort((a, b) => (a.sort || 0) - (b.sort || 0))
const all = []
if (common.items.length) all.push(common)
all.push(...dirGroups)
// 组级「未开通」:整组菜单均被锁定;卡片浅底色与图标色按展开顺序统一编号轮换
let colorIndex = 0
return all.map((group) => {
const locked = group.items.length > 0 && group.items.every((it) => it.locked === true)
return {
...group,
icon: group.icon || 'file-lines',
locked,
items: group.items.map((it) => ({ ...it, tintIndex: colorIndex++ }))
}
})
})
/** 跨模块搜索:应用名 + 模块名 + 菜单标题 */
const searchResults = computed(() => {
const q = keyword.value.trim().toLowerCase()
if (!q) return []
const moduleName = {}
modules.value.forEach((m) => {
moduleName[String(m.code || '').toLowerCase()] = m.name
})
return menus.value
.filter((m) => {
const mod = modules.value.find((x) => x.code === m.module)
const hay = [m.title, m.path, mod && mod.name].join(' ').toLowerCase()
return hay.indexOf(q) > -1
})
.map((m, i) => {
const mod = modules.value.find((x) => x.code === m.module)
return {
menu: m,
icon: MODULE_ICONS[String(m.module || '').toLowerCase()] || 'file-lines',
tint: mod && mod.enabled === false ? 'tint-off' : 'tint-' + (i % 8),
locked: m.locked === true || (mod && mod.enabled === false)
}
})
})
async function load() {
loading.value = true
try {
const data = await fetchHome()
modules.value = Array.isArray(data.modules) ? data.modules : []
menus.value = Array.isArray(data.menus) ? data.menus : []
} catch (e) {
console.error('[apps] load failed:', e)
} finally {
loading.value = false
}
}
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;
}
.search-wrap {
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 16rpx) $page-padding-x 20rpx;
/* #ifdef MP-WEIXIN */
padding-top: calc(env(safe-area-inset-top, 25px) + 80rpx);
/* #endif */
background: $color-card;
box-shadow: $shadow-header;
}
.search-bar {
display: flex;
align-items: center;
gap: 12rpx;
height: 76rpx;
padding: 0 24rpx;
background: $color-bg-page;
border-radius: $radius-full;
}
.search-input {
flex: 1;
min-width: 0;
height: 76rpx;
font-size: 28rpx;
color: $color-text;
}
.search-placeholder {
color: $color-text-muted;
font-size: 28rpx;
}
.search-clear {
display: flex;
align-items: center;
padding: 8rpx;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 24rpx $page-padding-x 0;
}
.result-hint {
padding: 0 4rpx 16rpx;
}
.result-hint-text {
font-size: 24rpx;
color: $color-text-muted;
}
.empty-box {
@include card;
margin-top: 24rpx;
padding: 80rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18rpx;
}
.empty-text {
font-size: 26rpx;
color: $color-text-muted;
}
/* ============ 分组卡:白色卡片 + 一行 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: 14rpx;
padding: 0 4rpx 20rpx;
}
.group-title {
@include section-title;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-lock {
@include chip(#fef3f2, $color-danger);
flex-shrink: 0;
}
.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;
}
.group-empty {
padding: 20rpx 4rpx 16rpx;
}
.group-empty-text {
font-size: 24rpx;
color: $color-text-muted;
}
.bottom-space {
height: 40rpx;
}
</style>