Files
yunzerwebsiteallinone/uniapp/pages/nav/nav.vue
T
2026-11-03 14:55:26 +08:00

696 lines
20 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-eyebrow">YUNZE WORKSPACE</text>
<text class="head-title">工作台</text>
</view>
<view v-if="!guest" class="account-chip"><FaIcon name="user" color="#3478f6" :size="15" /><text>{{ user.account || '我的账号' }}</text></view>
<view v-else class="account-chip" @tap="goLogin"><FaIcon name="right-to-bracket" color="#3478f6" :size="15" /><text>登录</text></view>
</view>
<view v-if="!guest" class="tenant-switch" @tap="chooseTenant">
<view class="tenant-mark"><FaIcon name="building" color="#ffffff" :size="19" /></view>
<view class="tenant-copy"><text class="tenant-label">当前租户</text><text class="tenant-name">{{ user.tenant || '云泽平台' }}</text></view>
<view class="tenant-action"><text>切换</text><FaIcon name="chevron-down" color="#3478f6" :size="12" /></view>
</view>
<text class="head-sub">{{ guest ? '免登录可逛部分功能,登录后使用完整工作台' : '选择一个业务系统,开始高效工作' }}</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>
<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>
<text v-if="guest" class="portal-tag" :class="card.needLogin ? 'need' : 'free'">{{ card.needLogin ? '需登录' : '免登录' }}</text>
</view>
</view>
</view>
<view class="bottom-space" />
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- 游客无感授权:无弹窗,全屏透明按钮——首次点按即拉起微信原生手机号确认层;
后端按手机号自动判定:绑定过租户→直接登录,否则→游客令牌 -->
<view v-if="guestAuthVisible" class="guest-auth-mask">
<button class="guest-auth-full" open-type="getPhoneNumber" :disabled="guestAuthLoading" @getphonenumber="onGuestPhone" />
<text class="guest-auth-later" @tap="skipGuestAuth">暂不,先浏览</text>
</view>
<!-- #endif -->
</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, setTenants, setTid, setTenantName, getUser, setUser } from '@/utils/auth.js'
import { switchTenant } from '@/api/auth.js'
import { fetchHome, fetchPublicModules, fetchPublicMenus } from '@/api/workspace.js'
import { openMenu } from '@/utils/nav.js'
import { getSubsystemByModule, goSubsystem } from '@/config/subsystems.js'
import { getGuestToken, setGuestToken, clearGuestToken } from '@/utils/guest.js'
import { guestMiniLogin, guestMerge } from '@/api/guest.js'
/* ==================== 游客手机号授权(免登录保存) ==================== */
const guestAuthVisible = ref(false)
const guestAuthLoading = ref(false)
// 本次停留内跳过:关闭后不在 nav 反复弹窗,下次冷启动再引导
const guestAuthDismissed = ref(false)
/** 仅微信小程序可拿手机号动态凭证 */
function canRequestGuestPhone() {
// #ifdef MP-WEIXIN
return true
// #endif
// eslint-disable-next-line no-unreachable
return false
}
function maybeAskGuestAuth() {
if (!guest.value || getGuestToken() || guestAuthDismissed.value || !canRequestGuestPhone()) {
guestAuthVisible.value = false
return
}
guestAuthVisible.value = true
}
/** 取 uni.login 临时登录凭证(配合手机号动态 code 换游客令牌) */
function navUniLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => (res && res.code ? resolve(res.code) : reject(new Error('未获取到微信登录凭证'))),
fail: () => reject(new Error('获取微信登录凭证失败'))
})
})
}
async function onGuestPhone(e) {
const detail = (e && e.detail) || {}
if (String(detail.errMsg || '').indexOf('ok') === -1 || !detail.code) {
uni.showToast({ title: '未获取到手机号授权', icon: 'none' })
return
}
if (guestAuthLoading.value) return
guestAuthLoading.value = true
try {
const code = await navUniLoginCode()
let appid = ''
try {
appid = (uni.getAccountInfoSync().miniProgram || {}).appId || ''
} catch (err) {
appid = ''
}
const res = await guestMiniLogin({ code, phoneCode: detail.code, appid })
// 已注册手机号:后端已签发正式令牌,直接按登录处理(不再当作游客)
if (res && res.type === 'user') {
guestAuthVisible.value = false
await completeUserLogin(res)
return
}
if (!res || !res.token) throw new Error('未返回游客令牌')
setGuestToken(res.token)
guestAuthVisible.value = false
uni.showToast({ title: '授权成功,数据可保存了', icon: 'none' })
} catch (err) {
console.error('[nav] guest mini-login failed:', err)
uni.showToast({ title: (err && err.message) || '授权失败,请重试', icon: 'none' })
} finally {
guestAuthLoading.value = false
}
}
/** 手机号识别为正式账号:落登录态;多企业先选企业再进入 */
async function completeUserLogin(res) {
const list = Array.isArray(res.tenants) ? res.tenants : []
setAuthTokens(res.tokens || {})
if (res.need_choose_tenant && list.length > 1) {
askChooseTenant(list)
return
}
const t0 = list[0] || {}
setTenants(list)
if (t0.tid) {
setTid(t0.tid)
setTenantName(t0.tenant_name || '')
}
// 先游客后转正:企业落定后把游客数据移植到正式账号
await mergeGuestIfAny()
await load(true)
uni.showToast({ title: '已登录,欢迎回来', icon: 'none' })
}
/** 待选状态下必须选一个企业才能继续,关闭选择器后再次拉起 */
function askChooseTenant(list) {
uni.showActionSheet({
itemList: list.map((t) => t.tenant_name || `企业${t.tid}`),
success: async ({ tapIndex }) => {
const tenant = list[tapIndex]
if (!tenant) return
uni.showLoading({ title: '登录中...', mask: true })
try {
const sw = await switchTenant({ tid: tenant.tid })
if (!sw || !sw.tokens) throw new Error((sw && sw.msg) || '进入企业失败')
setAuthTokens(sw.tokens)
setTenants(list)
setTid(tenant.tid)
setTenantName(tenant.tenant_name || '')
// 先游客后转正:选定企业后移植游客数据
await mergeGuestIfAny()
await load(true)
uni.showToast({ title: '已登录,欢迎回来', icon: 'none' })
} catch (e) {
uni.showToast({ title: (e && e.message) || '进入失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
}
},
fail: () => {
uni.showToast({ title: '请选择要进入的企业', icon: 'none' })
setTimeout(() => askChooseTenant(list), 800)
}
})
}
function skipGuestAuth() {
guestAuthDismissed.value = true
guestAuthVisible.value = false
}
/** 存有游客令牌时静默移植数据(登录链路已带正式令牌);失败保留令牌下次登录重试 */
async function mergeGuestIfAny() {
if (!getGuestToken()) return
try {
await guestMerge()
clearGuestToken()
} catch (e) {
console.warn('[nav] guest merge failed:', e)
}
}
/** 模块 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(getTenants())
const switchingTenant = ref(false)
// 游客态:未登录时默认落在 nav,渲染公开模块宫格,按菜单级免登录配置分流。
const guest = ref(!isLoggedIn())
const publicModules = ref([])
// 免登录菜单(/api/menus/public,require_login=0):模块宫格是否可免登录进入的唯一依据
const publicMenus = ref([])
/** 含免登录菜单的模块 code 集合(小写) */
const guestMenuModuleSet = computed(
() => new Set(publicMenus.value.map((m) => String(m.module || '').toLowerCase()).filter(Boolean))
)
/** 叶子菜单(可直接跳转的功能页),已锁定的过滤掉 */
const leafMenus = computed(() => menus.value.filter((m) => m.leaf !== false && !m.locked))
/** 子系统卡片:每个业务模块一张卡 */
const cards = computed(() => {
// 游客:模块可免登录 = 该模块下存在免登录菜单(菜单级 require_login 控制)
if (guest.value) {
return publicModules.value.map((mod, index) => {
const code = String(mod.code || '').toLowerCase()
const needLogin = !guestMenuModuleSet.value.has(code)
return {
id: mod.id,
code: mod.code,
path: mod.path || '',
name: mod.name || '未命名系统',
desc: mod.description || (needLogin ? '需登录使用' : '免登录'),
icon: MODULE_ICONS[code] || 'cube',
tint: 'tint-' + (index % 8),
locked: false,
needLogin,
items: [],
total: 0
}
})
}
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
}
})
})
/* ==================== 数据加载 ==================== */
async function load(force = false) {
loading.value = true
guest.value = !isLoggedIn()
// 游客:公开模块宫格 + 免登录菜单;模块可点的判定下沉到菜单级
// (存在 require_login=0 的菜单即免登录,模块级 require_login 不再作为依据)
if (guest.value) {
try {
const [list, menuList] = await Promise.all([fetchPublicModules(), fetchPublicMenus()])
publicModules.value = Array.isArray(list) ? list : []
publicMenus.value = Array.isArray(menuList) ? menuList : []
loadError.value = ''
} catch (e) {
console.error('[nav] public modules load failed:', e)
loadError.value = (e && e.message) || '接口请求失败'
} finally {
loading.value = false
}
// 进小程序即引导授权:游客无令牌时弹手机号授权窗
maybeAskGuestAuth()
return
}
try {
const data = await fetchHome(force)
user.value = data.user || {}
tenants.value = getTenants()
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
}
}
/* ==================== 交互 ==================== */
function chooseTenant() {
const list = tenants.value
if (list.length <= 1) { uni.showToast({ title: list.length ? '当前账号仅绑定一个租户' : '暂无可切换的租户', icon: 'none' })
return
}
if (switchingTenant.value) return
uni.showActionSheet({
itemList: list.map((tenant) => tenant.tenant_name || `企业${tenant.tid}`),
success: async ({ tapIndex }) => {
const tenant = list[tapIndex]
if (!tenant || String(tenant.tid) === String(getTid())) return
switchingTenant.value = true
uni.showLoading({ title: '正在切换...', mask: true })
try {
const res = await switchTenant({ tid: tenant.tid })
if (!res || !res.tokens) throw new Error((res && res.msg) || '切换租户失败')
setAuthTokens(res.tokens)
setTid(tenant.tid)
setTenantName(tenant.tenant_name || '')
const profile = getUser() || {}
const nextUser = { ...profile, tid: tenant.tid, tenant_name: tenant.tenant_name || '' }
setUser(nextUser)
user.value = { ...user.value, tenant: tenant.tenant_name || '', account: nextUser.account || user.value.account }
await load(true)
uni.showToast({ title: '已切换租户', icon: 'success' })
} catch (e) {
uni.showToast({ title: (e && e.message) || '切换失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
switchingTenant.value = false
}
}
})
}
/**
* 点击子系统卡片 → 进入该子系统独立页面组(带自己的 tabbar)。
* 未在 config/subsystems.js 注册的模块,降级为直接打开 Web 端该模块首页。
* 游客:命中「必须登录」模块时不直接进,先引导登录(登录后回 nav)。
*/
function enter(card) {
if (card.locked) {
openMenu({ locked: true, title: card.name })
return
}
if (guest.value && card.needLogin) {
goLogin(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' })
}
/** 引导登录:登录页登录成功后会回 nav(见 login.vue finishLogin) */
function goLogin(name = '') {
if (name) {
uni.showToast({ title: '「' + name + '」需登录后使用', icon: 'none' })
}
uni.navigateTo({ url: '/pages/login/login' })
}
/* ==================== 生命周期 ==================== */
onMounted(() => {
// 默认落地页:未登录不再强制回登录页,而是以游客态渲染公开模块宫格
load()
})
onShow(() => {
// 登录态可能在登录页/其他入口发生变化,每次展示重新判定并加载
if (!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 28rpx;
/* #ifdef MP-WEIXIN */
padding-top: calc(env(safe-area-inset-top, 25px) + 96rpx);
/* #endif */
background: linear-gradient(145deg, #eaf3ff 0%, #f5f9ff 58%, #ffffff 100%);
border-bottom: 1rpx solid #e5efff;
}
.head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.head-eyebrow {
display: block;
margin-bottom: 8rpx;
font-size: 18rpx;
font-weight: 700;
letter-spacing: 3rpx;
color: #6e9de5;
}
.head-title {
display: block;
font-size: 44rpx;
font-weight: 700;
color: #17345f;
letter-spacing: 1rpx;
}
.account-chip {
display: flex;
align-items: center;
gap: 9rpx;
padding: 12rpx 17rpx;
border-radius: 30rpx;
background: rgba(255,255,255,.85);
color: #526b8d;
font-size: 22rpx;
}
.tenant-switch {
display: flex;
align-items: center;
margin-top: 28rpx;
padding: 20rpx 22rpx;
border: 1rpx solid #dceaff;
border-radius: 18rpx;
background: #ffffff;
box-shadow: 0 8rpx 24rpx rgba(47, 110, 196, .08);
}
.tenant-mark {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #69a5ff, #3478f6);
}
.tenant-copy { flex: 1; min-width: 0; margin-left: 16rpx; }
.tenant-label { display: block; font-size: 20rpx; color: #8a9bb3; }
.tenant-name { display: block; margin-top: 5rpx; font-size: 27rpx; font-weight: 600; color: #203b62; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tenant-action { display: flex; align-items: center; gap: 8rpx; color: #3478f6; font-size: 22rpx; font-weight: 600; }
.head-sub {
display: block;
margin-top: 22rpx;
font-size: 23rpx;
color: #8295b0;
}
/* ============ 内容区 ============ */
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 20rpx $page-padding-x 0;
background: #f5f8fd;
}
/* ============ 失败提示 ============ */
.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: 34rpx 16rpx 30rpx;
border: 1rpx solid #e6eef9;
box-shadow: 0 6rpx 20rpx rgba(38, 86, 150, .045);
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;
}
/* 游客态:卡片左上角标出免登录 / 需登录 */
.portal-card {
position: relative;
}
.portal-tag {
position: absolute;
top: 14rpx;
right: 14rpx;
padding: 2rpx 12rpx;
border-radius: 20rpx;
font-size: 18rpx;
line-height: 1.6;
}
.portal-tag.free {
color: #16a34a;
background: rgba(22, 163, 74, 0.12);
}
.portal-tag.need {
color: #94a3b8;
background: rgba(148, 163, 184, 0.16);
}
.bottom-space {
height: 48rpx;
}
/* ============ 游客无感授权层(无弹窗,首次点按即拉起微信原生确认) ============ */
.guest-auth-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 99;
}
.guest-auth-full {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
border: none;
border-radius: 0;
background: transparent;
opacity: 0;
&::after {
border: none;
}
}
.guest-auth-later {
position: absolute;
left: 0;
right: 0;
bottom: 64rpx;
z-index: 100;
text-align: center;
font-size: 24rpx;
color: #94a3b8;
padding: 16rpx;
}
</style>