Files
yunzerwebsiteallinone/uniapp/pages/nav/nav.vue
T

545 lines
13 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="head" :style="headPad">
<view class="head-row">
<view>
<text class="head-title">工作空间</text>
<text class="head-caption">选择一个应用,开始今天的工作</text>
</view>
</view>
<view class="tenant-switcher" :class="{ selectable: tenants.length > 1 }" @tap="openTenantPicker">
<view class="tenant-mark"><FaIcon name="building" color="#3689d6" :size="17" /></view>
<view class="tenant-current">
<text class="tenant-label">当前公司</text>
<text class="head-sub">{{ user.tenant || '云泽平台' }}</text>
</view>
<text v-if="user.account" class="tenant-account">{{ user.account }}</text>
<FaIcon v-if="tenants.length > 1" name="chevron-right" color="#83a2be" :size="13" />
</view>
</view>
<view v-if="tenantPickerVisible" class="tenant-mask" @tap="closeTenantPicker">
<view class="tenant-sheet" @tap.stop>
<view class="sheet-head">
<text class="sheet-title">切换公司</text>
<text class="sheet-close" @tap="closeTenantPicker">完成</text>
</view>
<text class="sheet-sub">选择后将切换当前工作空间</text>
<view v-for="item in tenants" :key="item.tid" class="tenant-option" :class="{ current: Number(item.tid) === currentTid, disabled: switchingTenant }" @tap="changeTenant(item)">
<view class="tenant-option-icon"><FaIcon name="building" color="#398fe7" :size="16" /></view>
<view class="tenant-option-info"><text class="tenant-option-name">{{ item.tenant_name || `企业${item.tid}` }}</text><text class="tenant-option-meta">{{ Number(item.tid) === currentTid ? '当前工作空间' : '切换到此公司' }}</text></view>
<FaIcon v-if="Number(item.tid) === currentTid" name="circle-check" color="#398fe7" :size="18" />
</view>
</view>
</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>
<FaIcon name="rotate" color="#f04438" :size="16" />
</view>
<!-- 子系统分组卡:组内一行 4 个 -->
<view v-else-if="!loading && cards.length === 0" class="empty-box">
<FaIcon name="cube" color="#c0c4cc" :size="32" />
<text class="empty-text">暂无子系统,请联系管理员开通</text>
</view>
<view v-else class="grid">
<view
v-for="card in cards"
:key="card.code || card.id"
class="cell"
@tap="enter(card)"
>
<view class="portal-card" :class="{ locked: card.locked }">
<view class="tint-icon portal-icon" :class="card.tint">
<FaIcon :name="card.icon" color="#ffffff" :size="26" />
</view>
<text class="portal-name">{{ card.name }}</text>
</view>
</view>
</view>
<view class="bottom-space" />
</view>
</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, getTenants, getTid, setAuthTokens, setTid, setTenantName } from '@/utils/auth.js'
import { switchTenant } from '@/api/auth.js'
import { fetchHome } from '@/api/workspace.js'
import { openMenu } from '@/utils/nav.js'
import { getSubsystemByModule, goSubsystem } from '@/config/subsystems.js'
/** 模块 code → 子系统 slug → 进入其概览页;未注册返回 false */
function goSubsystemByModule(code) {
const sub = getSubsystemByModule(code)
return sub ? goSubsystem(sub.slug) : false
}
/** 模块 code → 端上图标(后端 icon 存的是 Element Plus 图标名,端上用不了) */
const MODULE_ICONS = {
crm: 'handshake',
erp: 'boxes-stacked',
oa: 'clipboard-check',
cms: 'globe',
ai: 'robot',
system: 'gear',
tool: 'wrench'
}
const loading = ref(true)
const loadError = ref('')
const user = ref({})
const modules = ref([])
const menus = ref([])
const tenants = ref([])
const currentTid = ref(0)
const tenantPickerVisible = ref(false)
const switchingTenant = ref(false)
/** 叶子菜单(可直接跳转的功能页),已锁定的过滤掉 */
const leafMenus = computed(() => menus.value.filter((m) => m.leaf !== false && !m.locked))
/** 子系统卡片:每个业务模块一张卡 */
const cards = computed(() => {
const byCode = {}
leafMenus.value.forEach((m) => {
if (!m.module) return
if (!byCode[m.module]) byCode[m.module] = []
byCode[m.module].push(m)
})
return modules.value
.filter((m) => m.type !== 2) // 2=系统配置,不作为子系统入口
.map((mod, index) => {
const code = String(mod.code || '').toLowerCase()
const items = byCode[mod.code] || []
const locked = mod.enabled === false
return {
id: mod.id,
code: mod.code,
path: mod.path || '',
name: mod.name || '未命名系统',
desc: locked
? '未开通该模块'
: mod.description || (items.length ? items.length + ' 个功能' : '暂无功能'),
icon: MODULE_ICONS[code] || 'cube',
tint: locked ? 'tint-off' : 'tint-' + (index % 8),
locked,
items,
total: items.length
}
})
})
const enabledCount = computed(() => cards.value.filter((c) => !c.locked).length)
/* ==================== 数据加载 ==================== */
async function load(force = false) {
loading.value = true
try {
const data = await fetchHome(force)
user.value = data.user || {}
tenants.value = getTenants()
currentTid.value = getTid()
modules.value = Array.isArray(data.modules) ? data.modules : []
menus.value = Array.isArray(data.menus) ? data.menus : []
loadError.value = ''
} catch (e) {
console.error('[nav] load failed:', e)
loadError.value = (e && e.message) || '接口请求失败'
} finally {
loading.value = false
}
}
/* ==================== 交互 ==================== */
/**
* 点击子系统卡片 → 进入该子系统独立页面组(带自己的 tabbar)。
* 未在 config/subsystems.js 注册的模块,降级为直接打开 Web 端该模块首页。
*/
function enter(card) {
if (card.locked) {
openMenu({ locked: true, title: card.name })
return
}
if (goSubsystemByModule(card.code)) return
if (card.items.length) {
openMenu(card.items[0], card.name)
return
}
if (card.path) {
openMenu({ title: card.name, path: card.path, leaf: true }, card.name)
return
}
uni.showToast({ title: card.name + '暂未开放', icon: 'none' })
}
function openTenantPicker() {
if (tenants.value.length < 2) return
tenantPickerVisible.value = true
}
function closeTenantPicker() {
if (!switchingTenant.value) tenantPickerVisible.value = false
}
async function changeTenant(item) {
const tid = Number(item && item.tid)
if (!tid || tid === currentTid.value || switchingTenant.value) {
tenantPickerVisible.value = false
return
}
switchingTenant.value = true
uni.showLoading({ title: '正在切换...', mask: true })
try {
const result = await switchTenant({ tid })
if (!result || !result.tokens) throw new Error((result && result.msg) || '切换公司失败')
setAuthTokens(result.tokens)
setTid(tid)
const tenantName = item.tenant_name || `企业${tid}`
setTenantName(tenantName)
user.value = Object.assign({}, user.value, { tenant: tenantName, tid })
currentTid.value = tid
tenantPickerVisible.value = false
await load(true)
uni.showToast({ title: '已切换公司', icon: 'success' })
} catch (err) {
uni.showToast({ title: (err && err.message) || '切换公司失败', icon: 'none' })
} finally {
uni.hideLoading()
switchingTenant.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;
}
/* ============ 顶部信息(融入页面背景) ============ */
.head {
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 32rpx) $page-padding-x 12rpx;
/* #ifdef MP-WEIXIN */
/* 小程序端 --status-bar-height 固定 25px,需真实状态栏(env)+胶囊避让 */
padding-top: calc(env(safe-area-inset-top, 25px) + 96rpx);
/* #endif */
background: $color-bg-page;
}
.head-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16rpx;
}
.head-title {
font-size: 46rpx;
font-weight: 700;
color: #193d61;
letter-spacing: 1rpx;
}
.head-caption {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: #8aa2b8;
}
.tenant-switcher {
margin-top: 28rpx;
padding: 20rpx 22rpx;
display: flex;
align-items: center;
gap: 16rpx;
border: 1rpx solid rgba(255, 255, 255, 0.92);
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 12rpx 32rpx rgba(81, 133, 176, 0.08);
&.selectable:active { background: #f4faff; }
}
.tenant-mark {
width: 68rpx;
height: 68rpx;
flex-shrink: 0;
border-radius: 20rpx;
background: #eaf5ff;
display: flex;
align-items: center;
justify-content: center;
}
.tenant-current {
flex: 1;
min-width: 0;
}
.tenant-label {
display: block;
margin-bottom: 5rpx;
font-size: 20rpx;
color: #94aabe;
}
.head-sub {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #294a68;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tenant-account {
max-width: 180rpx;
font-size: 21rpx;
color: #8da3b7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ============ 内容区 ============ */
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 8rpx $page-padding-x 0;
}
.tenant-mask {
position: fixed;
z-index: 99;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(18, 42, 68, 0.32);
display: flex;
align-items: flex-end;
}
.tenant-sheet {
width: 100%;
max-height: 72vh;
overflow-y: auto;
padding: 38rpx 32rpx calc(env(safe-area-inset-bottom, 0px) + 40rpx);
box-sizing: border-box;
border-radius: 36rpx 36rpx 0 0;
background: linear-gradient(180deg, #f5faff 0%, #ffffff 24%);
}
.sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.sheet-title {
font-size: 32rpx;
font-weight: 600;
color: #203b56;
}
.sheet-close {
font-size: 25rpx;
color: #398fe7;
padding: 10rpx;
}
.sheet-sub {
display: block;
margin: 8rpx 0 20rpx;
font-size: 23rpx;
color: #8aa0b4;
}
.tenant-option {
display: flex;
align-items: center;
gap: 18rpx;
padding: 20rpx 16rpx;
border-radius: 18rpx;
border: 1rpx solid #edf3f8;
margin-top: 12rpx;
&.current {
background: #f2f8ff;
border-color: #cfe5fa;
}
&.disabled { opacity: 0.6; }
}
.tenant-option-icon {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
background: #eaf5ff;
display: flex;
align-items: center;
justify-content: center;
}
.tenant-option-info {
flex: 1;
min-width: 0;
}
.tenant-option-name,
.tenant-option-meta { display: block; }
.tenant-option-name {
font-size: 27rpx;
font-weight: 500;
color: #294763;
}
.tenant-option-meta {
margin-top: 5rpx;
font-size: 22rpx;
color: #91a5b8;
}
/* ============ 失败提示 ============ */
.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;
}
.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;
}
/* ============ 子系统门户宫格:一行 2 个,仅图标 + 名称 ============ */
.grid {
display: flex;
flex-wrap: wrap;
margin-right: -20rpx;
padding-top: 16rpx;
}
.cell {
width: 50%;
padding-right: 20rpx;
margin-bottom: 20rpx;
box-sizing: border-box;
}
.portal-card {
@include card;
padding: 40rpx 16rpx 34rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
transition: transform 0.15s ease;
&:active {
transform: scale(0.97);
}
}
.portal-icon {
@include tint-icon(108rpx, 28rpx);
}
.portal-name {
max-width: 100%;
font-size: 28rpx;
font-weight: 600;
letter-spacing: 0.5rpx;
color: $color-text;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 未开通:灰底图标 + 灰字,不做角标,保持门户的干净 */
.portal-card.locked .portal-name {
color: $color-text-muted;
}
.bottom-space {
height: 48rpx;
}
</style>