Files
yunzerwebsiteallinone/uniapp/pages/nav/nav.vue
T
2026-11-11 18:42:09 +08:00

1070 lines
31 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">WENXIN WORKSPACE</text>
<text class="head-title">工作台</text>
</view>
<view v-if="!guest" class="account-chip" @tap="goProfile"><FaIcon name="user" color="#3478f6" :size="15" /><text>{{ user.name || user.nickname || '未设置姓名' }}</text></view>
<button v-else class="account-chip account-chip-button" open-type="getPhoneNumber" @getphonenumber="onCardPhone($event)"><FaIcon name="user" color="#3478f6" :size="15" /><text>游客</text></button>
</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">
<!-- #ifdef MP-WEIXIN -->
<!-- 未授权手机号:卡片本身就是微信授权按钮,点一下直接拉起手机号授权 -->
<button
v-for="card in cards"
v-show="guest"
:key="'phone-' + (card.code || card.id)"
class="cell cell-button"
open-type="getPhoneNumber"
@getphonenumber="onCardPhone($event, 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>
</button>
<!-- #endif -->
<!-- 已授权(注册即登录):普通卡片,按模块是否需要企业分流 -->
<view
v-for="card in cards"
v-show="!guest || !isMp"
: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>
<!-- #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 -->
<!-- 企业选择:自绘底部抽屉。原生 actionSheet 只有一行居中文本,长企业名会挤在一起,
也无法标注「当前企业」;这里给每项独立卡片 + 两行标题 + 状态标识 -->
<view v-if="tenantPickerVisible" class="tenant-picker-mask" @tap="closeTenantPicker">
<view class="tenant-picker-sheet" @tap.stop>
<view class="picker-grabber" />
<view class="picker-head">
<view class="picker-head-icon"><FaIcon name="building" color="#ffffff" :size="18" /></view>
<view class="picker-head-copy">
<text class="picker-title">{{ tenantPickerMode === 'login' ? '选择要进入的企业' : '切换企业' }}</text>
<text class="picker-sub">{{ tenantPickerMode === 'login' ? '该账号已加入多家企业,请选择工作空间' : '选择一家企业,工作台将随之刷新' }}</text>
</view>
<view v-if="!tenantPickerRequired" class="picker-close" @tap="closeTenantPicker">
<FaIcon name="xmark" color="#94a3b8" :size="14" />
</view>
</view>
<scroll-view class="picker-list" scroll-y :show-scrollbar="false">
<view
v-for="(item, index) in tenantPickerList"
:key="item.tid"
class="tenant-option"
:class="{ current: isCurrentTenant(item), disabled: isPickingAny() }"
@tap="pickTenant(item)"
>
<view class="tenant-option-badge" :class="'badge-' + (index % 4)">
<FaIcon name="building" color="#ffffff" :size="18" />
</view>
<view class="tenant-option-copy">
<text class="tenant-option-name">{{ item.tenant_name || '企业' + item.tid }}</text>
<text class="tenant-option-meta">{{ item.name ? item.name + ' · ' : '' }}企业 ID {{ item.tid }}</text>
</view>
<text v-if="isCurrentTenant(item)" class="tenant-current-tag">当前</text>
<text v-else-if="isPickingTenant(item)" class="tenant-option-loading">进入中…</text>
<FaIcon v-else name="chevron-right" color="#cbd5e1" :size="14" />
</view>
</scroll-view>
<view class="picker-foot">
<view v-if="tenantPickerRequired" class="picker-hint">
<FaIcon name="circle-info" color="#94a3b8" :size="12" />
<text>选择企业后才能继续使用工作台</text>
</view>
<view v-else class="picker-cancel" @tap="closeTenantPicker"><text>取消</text></view>
</view>
</view>
</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, setTenants, setTid, setTenantName, getUser, setUser } from '@/utils/auth.js'
import { switchTenant, loginByMiniapp } from '@/api/auth.js'
import { fetchHome, fetchPublicModules, fetchPublicMenus, clearHomeCache } from '@/api/workspace.js'
import { onboardingPhone, getOnboardingStatus } from '@/api/onboarding.js'
import { setPendingCard, takePendingCard } from '@/utils/onboarding.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)
const phoneBusy = ref(false)
// 非微信端没有 getPhoneNumber 能力:卡片退化为普通点击并给出提示
let isMp = false
// #ifdef MP-WEIXIN
isMp = true
// #endif
/** 仅微信小程序可拿手机号动态凭证 */
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' })
}
/* ==================== 企业选择抽屉(替代 uni.showActionSheet) ==================== */
// 原生 actionSheet 只有一行居中文本:长企业名挤成一片、无法标注「当前企业」。
// 登录后的首次选择必须选一家(不可关闭),主动切换可取消,两者共用这一套抽屉。
const tenantPickerVisible = ref(false)
const tenantPickerRequired = ref(false)
const tenantPickerMode = ref('login') // login=登录后的首次选择 / switch=主动切换
const tenantPickerList = ref([])
const tenantPickingTid = ref('')
function openTenantPicker(list, mode, required) {
tenantPickerList.value = Array.isArray(list) ? list : []
tenantPickerMode.value = mode
tenantPickerRequired.value = required
tenantPickingTid.value = ''
tenantPickerVisible.value = true
}
function closeTenantPicker() {
if (tenantPickingTid.value) return
if (tenantPickerRequired.value) {
uni.showToast({ title: '请选择一家企业以继续', icon: 'none' })
return
}
tenantPickerVisible.value = false
}
function isCurrentTenant(item) {
return tenantPickerMode.value === 'switch' && item && String(item.tid) === String(getTid())
}
function isPickingAny() {
return tenantPickingTid.value !== ''
}
function isPickingTenant(item) {
return item && tenantPickingTid.value === String(item.tid)
}
/** 待选状态下必须选一家企业才能继续 */
function askChooseTenant(list) {
openTenantPicker(list, 'login', true)
}
async function pickTenant(item) {
if (!item || isPickingAny() || isCurrentTenant(item)) return
const isSwitch = tenantPickerMode.value === 'switch'
tenantPickingTid.value = String(item.tid)
switchingTenant.value = true
uni.showLoading({ title: isSwitch ? '正在切换...' : '登录中...', mask: true })
try {
const res = await switchTenant({ tid: item.tid })
if (!res || !res.tokens) throw new Error((res && res.msg) || (isSwitch ? '切换企业失败' : '进入企业失败'))
setAuthTokens(res.tokens)
setTenants(tenantPickerList.value)
setTid(item.tid)
setTenantName(item.tenant_name || '')
if (isSwitch) {
const profile = getUser() || {}
const nextUser = { ...profile, tid: item.tid, tenant_name: item.tenant_name || '' }
setUser(nextUser)
user.value = { ...user.value, tenant: item.tenant_name || '', account: nextUser.account || user.value.account }
}
// 成功才关闭;失败保留抽屉让用户直接重试
tenantPickerRequired.value = false
tenantPickerVisible.value = false
// 先游客后转正:企业落定后把游客数据并入正式账号
await mergeGuestIfAny()
await load(true)
uni.showToast({ title: isSwitch ? '已切换企业' : '已登录,欢迎回来', icon: isSwitch ? 'success' : 'none' })
} catch (e) {
console.error('[nav] switch tenant failed:', e)
uni.showToast({ title: (e && e.message) || '进入失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
tenantPickingTid.value = ''
switchingTenant.value = false
}
}
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 needTenant = mod.require_login !== 0
return {
id: mod.id,
code: mod.code,
path: mod.path || '',
name: mod.name || '未命名系统',
desc: mod.description || (needTenant ? '加入企业后可用' : '可直接使用'),
icon: MODULE_ICONS[code] || 'cube',
tint: 'tint-' + (index % 8),
locked: false,
needTenant,
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,
needTenant: mod.require_login !== 0,
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
}
// 新流程:不再自动弹授权,点任意模块卡片时才拉起微信手机号授权
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
}
}
/* ==================== 交互 ==================== */
/** 点击右上角用户名 → 进入个人中心修改资料(游客态 chip 不绑定此函数) */
function goProfile() {
uni.navigateTo({ url: '/pages/profile/profile' })
}
function chooseTenant() {
const list = tenants.value
if (list.length <= 1) { uni.showToast({ title: list.length ? '当前账号仅绑定一个企业' : '暂无可切换的企业', icon: 'none' })
return
}
if (switchingTenant.value || tenantPickerVisible.value) return
openTenantPicker(list, 'switch', false)
}
/* ==================== 手机号授权 / 注册 / 企业分流 ==================== */
/** 手机号授权统一入口:card 为空表示只做识别/注册(如点「游客」),不进入模块 */
async function onCardPhone(e, card = null) {
const detail = (e && e.detail) || {}
if (String(detail.errMsg || '').indexOf('ok') === -1 || !detail.code) {
uni.showToast({ title: '需要授权手机号才能使用', icon: 'none' })
return
}
if (phoneBusy.value) return
phoneBusy.value = true
uni.showLoading({ title: '验证中...', mask: true })
try {
const code = await navUniLoginCode()
let appid = ''
try {
appid = (uni.getAccountInfoSync().miniProgram || {}).appId || ''
} catch (err) {
appid = ''
}
if (card) setPendingCard(card)
const res = await onboardingPhone({ code, phoneCode: detail.code, appid })
if (res && res.need_register) {
uni.hideLoading()
uni.navigateTo({
url: '/pages/onboarding/register?token=' + encodeURIComponent(res.register_token || '') +
'&mobile_mask=' + encodeURIComponent(res.mobile_mask || '')
})
return
}
// 已有账号:复用小程序一键登录(后端按手机号认人并绑定 openid)
const ready = await silentLogin(code, detail.code)
if (!ready) return
await load(true)
if (card) {
await enter(card)
takePendingCard()
}
} catch (err) {
console.error('[nav] onboarding phone failed:', err)
uni.showToast({ title: (err && err.message) || '授权失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
phoneBusy.value = false
}
}
/** 已有账号静默登录;无企业时后端签发 tid=0 令牌,多企业时拉起企业选择抽屉 */
async function silentLogin(code, phoneCode) {
const res = await loginByMiniapp({ code, phoneCode })
if (!res || !res.tokens || !res.tokens.access_token) {
throw new Error((res && res.msg) || '登录失败,请重试')
}
setAuthTokens(res.tokens)
clearHomeCache()
const list = Array.isArray(res.tenants) ? res.tenants : []
setTenants(list)
if (res.need_choose_tenant && list.length > 1) {
askChooseTenant(list)
return false
}
const t0 = list[0] || {}
setTid(t0.tid || 0)
setTenantName(t0.tenant_name || '')
await mergeGuestIfAny()
return true
}
/**
* 点击子系统卡片 → 进入该子系统独立页面组(带自己的 tabbar)。
* 需企业模块:查审核状态,未通过则引导创建 / 挂靠企业;通过后刷新企业令牌再进入。
*/
async function enter(card) {
if (card.locked) {
openMenu({ locked: true, title: card.name })
return
}
if (guest.value) {
uni.showToast({ title: '请先授权手机号', icon: 'none' })
return
}
if (card.needTenant) {
let st = null
try {
st = await getOnboardingStatus()
} catch (e) {
uni.showToast({ title: '状态获取失败,请重试', icon: 'none' })
return
}
if (st && st.has_tenant) {
// 审核刚通过时本地令牌可能仍是 tid=0,重新静默登录拿企业令牌
if (!getTid()) {
try {
const code = await navUniLoginCode()
const ready = await silentLogin(code, '')
if (!ready) return
await load(true)
} catch (e) {
uni.showToast({ title: '登录态刷新失败,请重试', icon: 'none' })
return
}
}
} else {
// 不暂存卡片:避免企业申请页返回工作台时 onShow 再次自动跳回,形成循环
uni.navigateTo({ url: '/pages/onboarding/company' })
return
}
}
if (goSubsystemByModule(card.code)) return
const items = card.items || []
if (items.length) {
openMenu(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' })
}
/* ==================== 生命周期 ==================== */
onMounted(() => {
load()
})
onShow(() => {
// 注册 / 企业申请页回来后登录态可能变化,重新判定并加载
if (!loading.value) load()
// 注册完成后自动继续进入用户刚才点击的模块
const pending = takePendingCard()
if (pending && isLoggedIn()) {
setTimeout(() => enter(pending), 600)
}
})
</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;
}
/* 游客态 chip 是微信手机号授权按钮:抹平 button 默认样式,外观与普通 chip 一致 */
.account-chip-button {
display: flex;
align-items: center;
gap: 9rpx;
margin: 0;
padding: 12rpx 17rpx;
border: none;
border-radius: 30rpx;
background: rgba(255, 255, 255, .85);
color: #526b8d;
font-size: 22rpx;
line-height: 1.4;
text-align: left;
&::after {
border: none;
}
}
.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;
}
/* 未授权时卡片是微信授权按钮:抹平 button 默认样式,保持与 view 卡片一致 */
.cell-button {
display: block;
margin: 0 0 20rpx 0;
padding: 0 20rpx 0 0;
background: transparent;
border: none;
border-radius: 0;
line-height: normal;
text-align: left;
&::after {
border: none;
}
}
.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;
}
/* ============ 企业选择抽屉(自绘,替代原生 actionSheet) ============ */
.tenant-picker-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: flex-end;
background: rgba(15, 23, 42, .48);
@include mask-responsive;
}
.tenant-picker-sheet {
width: 100%;
max-height: 80vh;
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, .12);
padding-bottom: env(safe-area-inset-bottom);
animation: tenant-sheet-in .22s ease-out;
@include sheet-responsive;
}
@keyframes tenant-sheet-in {
from { transform: translateY(60rpx); opacity: .6; }
to { transform: translateY(0); opacity: 1; }
}
.picker-grabber {
width: 72rpx;
height: 8rpx;
margin: 16rpx auto 4rpx;
border-radius: $radius-full;
background: #dbe3ef;
}
.picker-head {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx $page-padding-x 24rpx;
border-bottom: 1rpx solid $color-divider;
}
.picker-head-icon {
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20rpx;
background: linear-gradient(135deg, #60a5fa, #2563eb);
box-shadow: 0 8rpx 18rpx rgba(37, 99, 235, .25);
}
.picker-head-copy { flex: 1; min-width: 0; }
.picker-title { display: block; font-size: 32rpx; font-weight: 700; letter-spacing: .5rpx; color: $color-text; }
.picker-sub { display: block; margin-top: 8rpx; font-size: 22rpx; line-height: 1.5; color: $color-text-muted; }
.picker-close {
width: 56rpx;
height: 56rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-full;
background: $color-bg-page;
}
.picker-list {
flex: 1;
min-height: 0;
max-height: 52vh;
padding: 24rpx $page-padding-x 4rpx;
box-sizing: border-box;
}
.tenant-option {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 20rpx;
padding: 24rpx 22rpx;
border: 1rpx solid #e6edf8;
border-radius: 20rpx;
background: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(38, 86, 150, .05);
transition: transform .15s ease, border-color .15s ease, background .15s ease;
&:active { transform: scale(.985); background: #f7faff; border-color: #b9d2ff; }
&.current { border-color: #bfd4ff; background: #f5f9ff; }
&.disabled { opacity: .55; }
}
.tenant-option-badge {
width: 76rpx;
height: 76rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20rpx;
background: linear-gradient(135deg, #60a5fa, #2563eb);
&.badge-1 { background: linear-gradient(135deg, #34d399, #0ea5a4); }
&.badge-2 { background: linear-gradient(135deg, #a78bfa, #6366f1); }
&.badge-3 { background: linear-gradient(135deg, #fbbf6e, #f97316); }
}
.tenant-option-copy { flex: 1; min-width: 0; }
.tenant-option-name {
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 28rpx;
font-weight: 600;
line-height: 1.45;
color: $color-text;
}
.tenant-option-meta {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tenant-current-tag {
flex-shrink: 0;
padding: 6rpx 16rpx;
border-radius: $radius-full;
background: $color-primary-bg;
font-size: 20rpx;
color: $color-primary;
}
.tenant-option-loading { flex-shrink: 0; font-size: 22rpx; color: $color-primary; }
.picker-foot { padding: 8rpx $page-padding-x 20rpx; }
.picker-cancel {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-full;
background: #f1f5f9;
font-size: 28rpx;
font-weight: 500;
color: $color-text-secondary;
&:active { background: #e6ecf5; }
}
.picker-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
height: 72rpx;
font-size: 22rpx;
color: $color-text-muted;
}
</style>