This commit is contained in:
hero920103 committed 2026-11-03 11:13:59 +08:00
1 parent 012f8edf8b
commit 36ff960fbd
33 files changed
+1644 -344

No files matched your search

+8 -2
View File
@@ -1,10 +1,12 @@
<script>
import { getBaseURL } from '@/api/request.js'
import { isLoggedIn } from '@/utils/auth.js'
import { getGuestToken } from '@/utils/guest.js'
const LOGIN_PAGE = '/pages/login/login'
// 不需要登录即可停留的页面:登录 / 注册 / 忘记密码 / 人机验证 / 小程序码确认页 / 启动跳板页
// 不需要登录即可停留的页面:门户首页(默认落地)/ 登录 / 注册 / 忘记密码 / 人机验证 / 小程序码确认页 / 启动跳板页
const PUBLIC_ROUTES = [
'pages/nav/nav',
'pages/login/login',
'pages/login/register',
'pages/login/forget',
@@ -25,11 +27,15 @@
}
}
/** 未登录一律回登录页:覆盖「切后台回来」「从其他入口直接进内页」等场景 */
/** 未登录一律回登录页:覆盖「切后台回来」「从其他入口直接进内页」等场景。
* 游客令牌(手机号授权签发)可停留任意页面:免登录范围由菜单级配置决定,
* 能进哪些页、能调哪些接口由后端公开菜单与控制器 guest 分支兜底;
* 无令牌时回门户 nav 触发授权引导,而不是直接甩进登录页。 */
function ensureLogin() {
const route = currentRoute()
if (!route || PUBLIC_ROUTES.indexOf(route) !== -1) return
if (isLoggedIn()) return
if (getGuestToken()) return
uni.reLaunch({ url: LOGIN_PAGE })
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 游客轻身份接口(后端 controllers/guest.go)
* 失败均由调用方静默处理,不弹全局 toast。
*/
import { request } from '@/api/request.js'
/**
* 游客登录:微信手机号动态凭证换游客令牌
* @param {{ code: string, phoneCode: string, appid?: string }} params code=uni.login 的登录凭证
* @returns {Promise<{ token: string, guest_id: number, phone_mask: string, expires_in: number }>}
*/
export function guestMiniLogin({ code = '', phoneCode = '', appid = '' } = {}) {
return request({
url: '/api/guest/mini-login',
method: 'POST',
data: { code, phone_code: phoneCode, appid },
silent: true
})
}
/**
* 转正合并:正式登录令牌调用,把该手机号下的游客数据并入账号
* @returns {Promise<object>} 各模块合并条数,如 { notebook: 3, schedule: 1, passwordStore: 2 }
*/
export function guestMerge() {
return request({
url: '/api/guest/merge',
method: 'POST',
data: {},
silent: true
})
}
+45 -10
View File
@@ -2,7 +2,8 @@
* 统一请求封装:baseURL、Bearer Token、业务 code 处理。
* 接口地址统一从 .env 的 VITE_API_BASE_URL 读取(见 api/config.js)。
*/
import { getToken, logout, isSSOEnabled, getRefreshToken, setAuthTokens } from '@/utils/auth.js'
import { getToken, logout, isSSOEnabled, getRefreshToken, setAuthTokens, isLoggedIn } from '@/utils/auth.js'
import { getGuestToken, clearGuestToken } from '@/utils/guest.js'
import { resolveBaseURL, getBaseURL } from '@/api/config.js'
export { getBaseURL }
@@ -74,14 +75,11 @@ async function replayRequest(options) {
*/
export function request(options = {}) {
const { url, method = 'GET', data, header = {}, silent = false, timeout = 30000 } = options
const token = getToken()
const headers = {
'Content-Type': 'application/json',
...header
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
applyAuth(headers)
const fullUrl = buildFullUrl(url)
@@ -97,6 +95,19 @@ export function request(options = {}) {
const body = res.data || {}
if (status === 401 || body.code === 401) {
// 游客令牌过期/被吊销:只清游客态并回门户重新授权,
// 不进登录引导(免登录模块对游客令牌报 403 端不匹配,不会走到这里)。
if (!isLoggedIn() && getGuestToken()) {
clearGuestToken()
if (!silent) {
uni.showToast({ title: '免登录授权已过期,请重新授权', icon: 'none' })
}
setTimeout(() => {
uni.reLaunch({ url: '/pages/nav/nav' })
}, 400)
reject(new Error(body.msg || '未授权'))
return
}
// 统一认证模式:先用 refresh_token 静默续期并重放请求,
// 续期失败(令牌过期/被吊销/被踢下线)才真正登出。
if (isSSOEnabled()) {
@@ -111,8 +122,19 @@ export function request(options = {}) {
if (!silent) {
uni.showToast({ title: body.msg || '请重新登录', icon: 'none' })
}
// 门户首页 nav 是默认落地页,允许游客停留。
// 若 401 发生在 nav(多为残留/过期令牌触发的 /api/home),
// 登出后回 nav 以游客态重载,而不是强跳统一认证登录页;
// 业务内页(需登录)仍照常引导到登录。
const onNav = (() => {
const ps = (typeof getCurrentPages === 'function' && getCurrentPages()) || []
const cur = ps.length ? ps[ps.length - 1] : null
if (!cur) return false
const route = cur.route || (cur.$page && cur.$page.path) || ''
return String(route).replace(/^\//, '') === 'pages/nav/nav'
})()
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/login' })
uni.reLaunch({ url: onNav ? '/pages/nav/nav' : '/pages/login/login' })
}, 400)
reject(new Error(body.msg || '未授权'))
return
@@ -157,17 +179,30 @@ export function request(options = {}) {
})
}
/**
* 注入 Authorization:正式令牌优先;未登录但持有游客令牌(手机号授权签发)时带游客令牌,
* 由后端各控制器自行决定是否放行 guest 端(不放行者报 403,不影响游客态浏览)。
*/
function applyAuth(headers) {
const token = getToken()
if (token) {
headers.Authorization = `Bearer ${token}`
return
}
const guestToken = getGuestToken()
if (guestToken) {
headers.Authorization = `Bearer ${guestToken}`
}
}
/** 返回完整响应体(含 code/msg),用于需要自行判断 code 的场景 */
export function requestRaw(options = {}) {
const { url, method = 'GET', data, header = {} } = options
const token = getToken()
const headers = {
'Content-Type': 'application/json',
...header
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
applyAuth(headers)
const fullUrl = buildFullUrl(url)
+29
View File
@@ -33,3 +33,32 @@ export function clearHomeCache() {
cache = null
cacheAt = 0
}
/**
* 免登录模块宫格:GET /api/modules/public(公开接口,无需 token)。
* 返回 [{ id, code, name, icon, path, description, require_login }]。
* 供未登录用户渲染 nav 首页,按 require_login 决定点击是「进登录引导」还是「直接进入」。
*/
export function fetchPublicModules() {
return request({
url: '/api/modules/public',
method: 'GET',
silent: true,
timeout: 15000
})
}
/**
* 免登录菜单:GET /api/menus/public(公开接口,无需 token)。
* 返回 [{ id, pid, title, path, icon, type, sort, module, leaf }],
* 菜单级 require_login=0 的集合:游客(持游客令牌)可用功能的唯一入口清单,
* nav 宫格据此判定模块是否可免登录进入,子系统据此渲染游客功能列表。
*/
export function fetchPublicMenus() {
return request({
url: '/api/menus/public',
method: 'GET',
silent: true,
timeout: 15000
})
}
+3 -6
View File
@@ -62,7 +62,6 @@ import { mpHeadPad } from '@/utils/mpHead.js'
const headPad = mpHeadPad()
import { onShow } from '@dcloudio/uni-app'
import { isLoggedIn } from '@/utils/auth.js'
import { openMenu, WEB_RULES } from '@/utils/nav.js'
import { useSubsystem } from '@/utils/subsystem.js'
import AppTabbar from '@/components/AppTabbar.vue'
@@ -173,15 +172,13 @@ function goBackNav() {
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
// 菜单:登录走 /api/home,游客走公开免登录菜单(菜单级 require_login 控制),
// 无免登录菜单的子系统游客看到空列表,不强制跳登录
load()
})
onShow(() => {
if (isLoggedIn() && !loading.value) load()
if (!loading.value) load()
})
</script>
+5 -5
View File
@@ -225,6 +225,7 @@ import { mpHeadPad } from '@/utils/mpHead.js'
const headPad = mpHeadPad()
import { onShow } from '@dcloudio/uni-app'
import { isLoggedIn } from '@/utils/auth.js'
import { getGuestToken } from '@/utils/guest.js'
import { request } from '@/api/request.js'
import { clearHomeCache } from '@/api/workspace.js'
import { openMenu } from '@/utils/nav.js'
@@ -406,18 +407,17 @@ function goNoticeDetail(id) {
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
// 菜单:登录走 /api/home,游客走公开免登录菜单(菜单级 require_login 控制);
// 待办等受保护数据需登录或持游客令牌(手机号授权)才拉,失败不阻断外壳
load()
if (!isLoggedIn() && !getGuestToken()) return
loadTodo()
loadNotices()
loadDashboard()
})
onShow(() => {
if (isLoggedIn() && !loading.value) {
if ((isLoggedIn() || getGuestToken()) && !loading.value) {
loadTodo()
loadNotices()
loadDashboard()
+2 -4
View File
@@ -206,10 +206,8 @@ function handleLogout() {
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
// 游客进入免登录模块:停留外壳,不拉受保护数据,也不强制跳登录
if (!isLoggedIn()) return
load()
loadStats()
})
+2 -4
View File
@@ -116,10 +116,8 @@ function goSchedules() {
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
// 游客进入免登录模块:停留外壳,不拉受保护数据,也不强制跳登录
if (!isLoggedIn()) return
load()
loadTodo()
})
+65 -4
View File
@@ -64,6 +64,12 @@
<FaIcon name="chevron-right" color="#94a3b8" :size="12" />
<view class="alt-line" />
</view>
<!-- 返回门户:不登录也能先用免登录功能 -->
<view class="back-portal" @tap="goPortal">
<FaIcon name="chevron-left" color="#94a3b8" :size="12" />
<text class="back-portal-text">返回门户,先使用免登录功能</text>
</view>
</block>
<!-- ============ 账号密码登录(独立视图) ============ -->
@@ -133,6 +139,12 @@
<view class="back-row">
<text class="tip-text">没有账号?请联系企业管理员开通</text>
</view>
<!-- 返回门户:不登录也能先用免登录功能 -->
<view class="back-portal" @tap="goPortal">
<FaIcon name="chevron-left" color="#94a3b8" :size="12" />
<text class="back-portal-text">返回门户,先使用免登录功能</text>
</view>
</block>
<!-- ============ 选择企业(一人多企业) ============ -->
@@ -221,6 +233,8 @@ import {
getToken
} from '@/utils/auth.js'
import { showGeetest4 } from '@/utils/geetest.js'
import { getGuestToken, clearGuestToken } from '@/utils/guest.js'
import { guestMerge } from '@/api/guest.js'
import FaIcon from '@/components/FaIcon.vue'
/** 运行端检测:h5 / apk(app-plus) / mp-weixin,决定登录方式优先级 */
@@ -286,10 +300,10 @@ const brandPadTop = computed(() => {
return `${statusBarHeight.value + uni.upx2px(88)}px`
})
/** 是否存在可返回的上级视图(账号密码视图仅在小程序端可退回主视图) */
/** 是否显示可返回的上级箭头(账号视图无论能否退回主视图都可见:退不了就回门户) */
const canGoBack = computed(() => {
if (step.value === 'tenant') return true
if (step.value === 'account') return showMiniLogin.value
if (step.value === 'account') return true
return false
})
@@ -308,13 +322,30 @@ function goMain() {
errorMsg.value = ''
}
/** 顶部导航返回 */
/** 顶部导航返回:登录内部层级优先逐级退;无可退上级时返回门户(免登录页) */
function onNavBack() {
if (step.value === 'tenant') {
backToAccount()
return
}
goMain()
if (step.value === 'account' && showMiniLogin.value) {
goMain()
return
}
// 无登录内部上级 → 返回门户,继续用免登录功能
goPortal()
}
/**
* 返回门户首页(nav)。
* 多数从 nav 点「需登录」模块进来,直接 navigateBack 回上一页(保留游客宫格);
* 若是被 401 兑回登录页等无栈场景,回落到 reLaunch nav 以游客态重载。
*/
function goPortal() {
if (submitting.value) return
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || []
if (pages.length > 1) uni.navigateBack()
else uni.reLaunch({ url: '/pages/nav/nav' })
}
onMounted(async () => {
@@ -508,6 +539,17 @@ async function finishLogin(tokens, tenantList, tid, fallbackAccount = '') {
// 微信端:登录成功后静默绑定 openid,认证中/无手机号能力时一键登录靠它认人
await silentBindMiniapp()
// 游客数据转正:持游客令牌(手机号授权)时静默并入账号;
// 失败不阻断跳转(令牌保留,下次登录自动重试;令牌失效则由 401 自动清理)
if (getGuestToken()) {
try {
await guestMerge()
clearGuestToken()
} catch (e) {
console.warn('[login] guest merge failed:', e)
}
}
uni.hideLoading()
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
@@ -1082,6 +1124,25 @@ function goForget() {
margin-top: 32rpx;
}
/* 返回门户:不登录也能先浏览免登录模块 */
.back-portal {
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
margin-top: 28rpx;
padding: 8rpx 0;
}
.back-portal:active {
opacity: 0.6;
}
.back-portal-text {
font-size: 24rpx;
color: $color-text-muted;
}
.link {
font-size: 26rpx;
color: $color-primary;
+241 -13
View File
@@ -7,14 +7,15 @@
<text class="head-eyebrow">YUNZE WORKSPACE</text>
<text class="head-title">工作台</text>
</view>
<view class="account-chip"><FaIcon name="user" color="#3478f6" :size="15" /><text>{{ user.account || '我的账号' }}</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 class="tenant-switch" @tap="chooseTenant">
<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">选择一个业务系统,开始高效工作</text>
<text class="head-sub">{{ guest ? '免登录可逛部分功能,登录后使用完整工作台' : '选择一个业务系统,开始高效工作' }}</text>
</view>
<view class="body">
@@ -46,12 +47,27 @@
<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">
<view class="guest-auth-card">
<text class="guest-auth-title">开启免登录保存</text>
<text class="guest-auth-desc">授权手机号后,记事本、日程提醒、密码库等免登录功能的数据即可保存;正式登录后会自动并入账号,不会丢失。</text>
<button class="guest-auth-btn" open-type="getPhoneNumber" :disabled="guestAuthLoading" @getphonenumber="onGuestPhone">
{{ guestAuthLoading ? '授权中…' : '立即授权' }}
</button>
<text class="guest-auth-skip" @tap="skipGuestAuth">暂不授权(使用将无法保存数据)</text>
</view>
</view>
<!-- #endif -->
</view>
</template>
@@ -63,9 +79,80 @@ const headPad = mpHeadPad()
import { onShow } from '@dcloudio/uni-app'
import { isLoggedIn, getTenants, getTid, setAuthTokens, setTid, setTenantName, getUser, setUser } from '@/utils/auth.js'
import { switchTenant } from '@/api/auth.js'
import { fetchHome } from '@/api/workspace.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 } from '@/utils/guest.js'
import { guestMiniLogin } 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.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
}
}
function skipGuestAuth() {
guestAuthDismissed.value = true
guestAuthVisible.value = false
}
/** 模块 code → 子系统 slug → 进入其概览页;未注册返回 false */
function goSubsystemByModule(code) {
@@ -91,12 +178,43 @@ 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
@@ -132,6 +250,25 @@ const cards = computed(() => {
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 || {}
@@ -188,12 +325,17 @@ function chooseTenant() {
/**
* 点击子系统卡片 → 进入该子系统独立页面组(带自己的 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)
@@ -206,6 +348,14 @@ function enter(card) {
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' })
}
function openTenantPicker() {
if (tenants.value.length < 2) return
tenantPickerVisible.value = true
@@ -246,19 +396,12 @@ async function changeTenant(item) {
/* ==================== 生命周期 ==================== */
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
// 默认落地页:未登录不再强制回登录页,而是以游客态渲染公开模块宫格
load()
})
onShow(() => {
// 未登录(含后台切回来时令牌被清)直接回登录页,不再停留在首页
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
// 登录态可能在登录页/其他入口发生变化,每次展示重新判定并加载
if (!loading.value) load()
})
</script>
@@ -458,7 +601,92 @@ onShow(() => {
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;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 0 56rpx;
}
.guest-auth-card {
width: 100%;
max-width: 560rpx;
background: #ffffff;
border-radius: 28rpx;
padding: 44rpx 36rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
box-shadow: 0 24rpx 64rpx rgba(15, 23, 42, 0.22);
}
.guest-auth-title {
font-size: 34rpx;
font-weight: 700;
color: #17345f;
}
.guest-auth-desc {
font-size: 24rpx;
line-height: 1.65;
color: #64748b;
text-align: left;
}
.guest-auth-btn {
width: 100%;
margin-top: 8rpx;
background: linear-gradient(145deg, #69a5ff, #3478f6);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
border-radius: 44rpx;
line-height: 2.6;
border: none;
&[disabled] {
opacity: 0.6;
}
}
.guest-auth-skip {
font-size: 22rpx;
color: #94a3b8;
padding: 8rpx 16rpx;
}
</style>
+5 -3
View File
@@ -69,6 +69,7 @@ import { onShow } from '@dcloudio/uni-app'
import { fetchNotes, deleteNote, toggleNotePin } from '@/api/note.js'
import { formatRelativeTime } from '@/utils/date.js'
import { isLoggedIn } from '@/utils/auth.js'
import { getGuestToken } from '@/utils/guest.js'
const keyword = ref('')
const notes = ref([])
@@ -78,15 +79,16 @@ const loading = ref(false)
const pinnedCount = computed(() => notes.value.filter(item => item.pinned).length)
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
// 免登录页面:登录态或游客令牌均可用;两者皆无回门户触发授权/登录
if (!isLoggedIn() && !getGuestToken()) {
uni.reLaunch({ url: '/pages/nav/nav' })
return
}
loadNotes()
})
onShow(() => {
if (isLoggedIn()) loadNotes()
if (isLoggedIn() || getGuestToken()) loadNotes()
})
async function loadNotes() {
+5 -4
View File
@@ -115,6 +115,7 @@ import { onShow } from '@dcloudio/uni-app'
import { fetchSchedules, getRemindChannelLabel, getRemindMinutesLabel } from '@/api/schedule.js'
import { pad, startOfDay, isToday } from '@/utils/date.js'
import { isLoggedIn } from '@/utils/auth.js'
import { getGuestToken } from '@/utils/guest.js'
/** 周一为一周第一天 */
const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日']
@@ -282,8 +283,9 @@ function openCreate() {
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
// 免登录页面:登录态或游客令牌均可用;两者皆无回门户触发授权/登录
if (!isLoggedIn() && !getGuestToken()) {
uni.reLaunch({ url: '/pages/nav/nav' })
return
}
loadReminders()
@@ -291,8 +293,7 @@ onMounted(() => {
// 新增/编辑返回后刷新列表
onShow(() => {
if (!isLoggedIn()) return
if (schedules.value.length || !loading.value) loadReminders()
if (isLoggedIn() || getGuestToken()) loadReminders()
})
async function loadReminders() {
+9 -1
View File
@@ -12,7 +12,15 @@
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false
"useCompilerPlugins": false,
"compileWorklet": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"condition": false
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
+8 -1
View File
@@ -10,6 +10,13 @@
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true,
"useGlassEaselForWxml": false
"useGlassEaselForWxml": false,
"useApiHook": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* 游客轻身份(免登录模块的数据临时存储)
*
* 小程序冷启动进入 nav 游客态时,引导用户授权微信手机号,
* 由 /api/guest/mini-login 签发游客令牌(30 天有效)。
* 携带该令牌可直接使用记事本 / 日程提醒 / 密码库等免登录功能,
* 数据落在后端 yz_guest_data;正式登录后调 /api/guest/merge 并入账号。
*/
import { getToken } from '@/utils/auth.js'
const GUEST_TOKEN_KEY = 'app_guest_token'
export function setGuestToken(token) {
uni.setStorageSync(GUEST_TOKEN_KEY, token || '')
}
export function getGuestToken() {
return uni.getStorageSync(GUEST_TOKEN_KEY) || ''
}
export function clearGuestToken() {
uni.removeStorageSync(GUEST_TOKEN_KEY)
}
/** 游客模式:未登录但持有手机号授权签发的游客令牌 */
export function isGuestMode() {
return !getToken() && !!getGuestToken()
}
+32 -1
View File
@@ -7,9 +7,29 @@
* 这里统一封装,避免三个页面各写一遍。
*/
import { ref, computed } from 'vue'
import { fetchHome } from '@/api/workspace.js'
import { fetchHome, fetchPublicMenus } from '@/api/workspace.js'
import { isLoggedIn } from '@/utils/auth.js'
import { getSubsystem } from '@/config/subsystems.js'
/**
* 游客(未登录)菜单源:公开接口 /api/menus/public(菜单级 require_login=0)。
* 免登录控制按菜单粒度在 Platform 菜单管理配置,后端下发什么游客就能用什么;
* 多个 Sub 组件共享一次拉取(会话级缓存,失败不缓存以便重试)。
*/
let guestMenusPromise = null
function loadGuestMenus() {
if (!guestMenusPromise) {
guestMenusPromise = fetchPublicMenus()
.then((list) => (Array.isArray(list) ? list : []))
.catch((e) => {
guestMenusPromise = null
console.error('[subsystem] public menus failed:', e)
return []
})
}
return guestMenusPromise
}
/**
* @param {string} slug 子系统目录名(crm / erp / oa / cms / tools)
* @param {import('vue').Ref<string>} currentTab 当前 tab,用于按需拉数据
@@ -47,6 +67,17 @@ export function useSubsystem(slug) {
async function load(force = false) {
loading.value = true
// 游客(未登录)不拉需鉴权的 /api/home,避免 401 触发登出回环;
// 改用公开菜单接口,按 module 归组后渲染免登录入口(菜单级 require_login 控制)。
if (!isLoggedIn()) {
try {
menus.value = await loadGuestMenus()
} catch (e) {
menus.value = []
}
loading.value = false
return
}
try {
const data = await fetchHome(force)
user.value = data.user || {}