Files
yunzerwebsiteallinone/uniapp/pages/features/features.vue
T
2026-10-03 23:46:55 +08:00

447 lines
10 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">
<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="list-card">
<view
v-for="item in searchResults"
:key="item.menu.id"
class="list-item"
@tap="openMenu(item.menu, item.menu.title)"
>
<view class="row-icon" :class="item.locked ? 'tint-off' : item.tint">
<FaIcon :name="item.icon" color="#ffffff" :size="16" />
</view>
<view class="row-body">
<text class="row-title">{{ item.menu.title }}</text>
<text class="row-sub">{{ item.moduleName }}</text>
</view>
<text v-if="item.locked" class="row-lock">未开通</text>
<FaIcon v-else name="chevron-right" color="#c8cfdf" :size="12" />
</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.code || group.id" class="group">
<view class="group-head">
<view class="tint-icon group-icon" :class="group.tint">
<FaIcon :name="group.icon" color="#ffffff" :size="18" />
</view>
<view class="group-meta">
<view class="group-title-row">
<text class="group-name">{{ group.name }}</text>
<text v-if="group.locked" class="group-lock">未开通</text>
</view>
<text class="group-count">{{ group.items.length }} 个功能</text>
</view>
</view>
<view class="list-card">
<view
v-for="item in group.items"
:key="item.id"
class="list-item"
@tap="openMenu(item, item.title)"
>
<view class="dot" :class="item.locked ? 'off' : 'primary'" />
<view class="row-body">
<text class="row-title">{{ item.title }}</text>
<text v-if="item.path" class="row-sub">{{ item.path }}</text>
</view>
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
</view>
<view v-if="group.items.length === 0" class="group-empty">
<text class="group-empty-text">该模块下暂无你可见的功能</text>
</view>
</view>
</view>
<view v-if="commonMenus.length" class="group">
<view class="group-head">
<view class="tint-icon group-icon tint-3">
<FaIcon name="wrench" color="#ffffff" :size="18" />
</view>
<view class="group-meta">
<view class="group-title-row">
<text class="group-name">协作工具</text>
</view>
<text class="group-count">{{ commonMenus.length }} 个功能</text>
</view>
</view>
<view class="list-card">
<view
v-for="item in commonMenus"
:key="item.id"
class="list-item"
@tap="openMenu(item, item.title)"
>
<view class="dot primary" />
<view class="row-body">
<text class="row-title">{{ item.title }}</text>
</view>
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
</view>
</view>
</view>
</block>
<view class="bottom-space" />
</view>
<AppTabbar current="features" />
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
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([])
/** 按模块 code 归组的全部菜单(含目录与锁定项,便于浏览) */
const groups = computed(() => {
const byCode = {}
menus.value.forEach((m) => {
if (!m.module) return
if (!byCode[m.module]) byCode[m.module] = []
byCode[m.module].push(m)
})
// 这里展示全部模块(含 type=2 系统设置),与工作台首页只展示业务模块形成分工
const list = modules.value.map((mod, index) => ({
id: mod.id,
code: mod.code,
name: mod.name || '未命名应用',
icon: mod.type === 2 ? 'gear' : MODULE_ICONS[String(mod.code || '').toLowerCase()] || 'cube',
tint: mod.enabled === false ? 'tint-off' : 'tint-' + (index % 8),
locked: mod.enabled === false,
items: (byCode[mod.code] || []).slice().sort((a, b) => a.sort - b.sort)
}))
// 兜底:模块表里没有对应记录(或已删除)的菜单
const known = {}
list.forEach((g) => {
known[String(g.code || '').toLowerCase()] = true
})
const orphan = []
menus.value.forEach((m) => {
const code = String(m.module || '').toLowerCase()
if (code && !known[code]) orphan.push(m)
})
if (orphan.length) {
list.push({
id: 'other',
code: 'other',
name: '其他功能',
icon: 'file-lines',
tint: 'tint-7',
locked: false,
items: orphan
})
}
return list
})
const commonMenus = computed(() => menus.value.filter((m) => !m.module))
/** 跨模块搜索:应用名 + 模块名 + 菜单标题 */
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,
moduleName: (mod && mod.name) || '协作工具',
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;
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;
}
.group {
margin-bottom: 32rpx;
}
.group-head {
display: flex;
align-items: center;
gap: 18rpx;
margin-bottom: 18rpx;
padding: 0 4rpx;
}
.group-icon {
@include tint-icon(72rpx, 18rpx);
}
.group-meta {
flex: 1;
min-width: 0;
}
.group-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.group-name {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.group-lock {
@include chip(#fef3f2, $color-danger);
}
.group-count {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: $color-text-muted;
}
.list-card {
@include card;
overflow: hidden;
}
.list-item {
display: flex;
align-items: center;
gap: 18rpx;
padding: 26rpx 24rpx;
& + & {
border-top: 1rpx solid $color-divider;
}
&:active {
background: $color-bg-page;
}
}
.row-icon {
@include tint-icon(60rpx, 16rpx);
}
.row-body {
flex: 1;
min-width: 0;
}
.row-title {
display: block;
font-size: 28rpx;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-lock {
@include chip(#fef3f2, $color-danger);
}
.dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
flex-shrink: 0;
background: $color-text-muted;
&.primary { background: $color-primary; }
&.off { background: $color-border; }
}
.group-empty {
padding: 40rpx 24rpx;
text-align: center;
}
.group-empty-text {
font-size: 24rpx;
color: $color-text-muted;
}
.bottom-space {
height: 40rpx;
}
</style>