做统一认证登录

This commit is contained in:
2026-09-19 21:44:04 +08:00
parent 1a4471e34d
commit fa281363fb
78 changed files with 8127 additions and 1442 deletions
+47 -1
View File
@@ -3,7 +3,7 @@
*/
import { request, requestRaw } from '@/api/request.js'
/** 账号密码登录 */
/** 账号密码登录(旧:租户端直登) */
export function login(data) {
return request({
url: '/app/login',
@@ -12,6 +12,52 @@ export function login(data) {
})
}
/* ==================== 统一认证中心(UAC) ====================
* APP 不走浏览器跳转,直接用密码直连模式:
* POST /auth/login 拿 access + refresh,多企业时再调 switch-tenant。
* 开关:VITE_AUTH_MODE=sso 时启用(见 utils/auth.js 的 isSSOEnabled)
* ============================================================ */
/** 统一认证:账号密码登录 */
export function loginUAC(data) {
return request({
url: '/auth/login',
method: 'POST',
data: Object.assign({ client_id: 'yz-uniapp' }, data)
})
}
/** 统一认证:选择/切换企业(一人多企业) */
export function switchTenant(data) {
return request({
url: '/auth/switch-tenant',
method: 'POST',
data: Object.assign({ client_id: 'yz-uniapp' }, data)
})
}
/** 统一认证:刷新访问令牌 */
export function refreshTokenUAC(data) {
return request({
url: '/auth/token',
method: 'POST',
header: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: Object.assign({ grant_type: 'refresh_token', client_id: 'yz-uniapp' }, data),
silent: true
})
}
/** 统一认证:吊销令牌(登出) */
export function revokeUAC(data = {}) {
return request({
url: '/auth/revoke',
method: 'POST',
data,
silent: true,
timeout: 5000
})
}
/** 发送登录验证码(账号二次校验用,需后台开启验证) */
export function sendLoginCode(data) {
return request({
+69 -2
View File
@@ -2,7 +2,7 @@
* 统一请求封装:baseURL、Bearer Token、业务 code 处理。
* 接口地址统一从 .env 的 VITE_API_BASE_URL 读取(见 api/config.js)。
*/
import { getToken, logout } from '@/utils/auth.js'
import { getToken, logout, isSSOEnabled, getRefreshToken, setAuthTokens } from '@/utils/auth.js'
import { resolveBaseURL, getBaseURL } from '@/api/config.js'
export { getBaseURL }
@@ -11,6 +11,63 @@ function buildFullUrl(url) {
return resolveBaseURL().replace(/\/$/, '') + url
}
/**
* 用 refresh_token 换取新的访问令牌(独立封装,避免与 request 递归调用)。
* 仅在统一认证模式(VITE_AUTH_MODE=sso)下使用。
*/
function refreshAccessToken() {
return new Promise((resolve, reject) => {
const refresh = getRefreshToken()
if (!refresh) {
reject(new Error('无刷新令牌'))
return
}
uni.request({
url: buildFullUrl('/auth/token'),
method: 'POST',
header: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refresh)}&client_id=yz-uniapp`,
timeout: 10000,
success(res) {
const body = res.data || {}
if (res.statusCode === 200 && body.access_token) {
setAuthTokens(body)
resolve(body.access_token)
} else {
reject(new Error((body && (body.error || body.msg)) || '刷新失败'))
}
},
fail: reject
})
})
}
/** 续期后重放原请求,返回与 request 一致的 data 解包结果 */
async function replayRequest(options) {
const token = await refreshAccessToken()
const res = await new Promise((resolve, reject) => {
uni.request({
url: buildFullUrl(options.url),
method: (options.method || 'GET').toUpperCase(),
data: options.data || {},
header: Object.assign(
{ 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
options.header || {}
),
timeout: options.timeout || 30000,
success: (r) => resolve(r.data || {}),
fail: reject
})
})
if (typeof res.code !== 'undefined' && res.code !== 200) {
const err = new Error(res.msg || '操作失败')
err.code = res.code
err.response = res
throw err
}
return typeof res.data !== 'undefined' ? res.data : res
}
/**
* @param {{ url: string, method?: string, data?: object, header?: object, silent?: boolean }} options
* @returns {Promise<any>} 业务 data 字段(若无 data 则返回整包)
@@ -35,11 +92,21 @@ export function request(options = {}) {
data: data || {},
header: headers,
timeout,
success(res) {
async success(res) {
const status = res.statusCode
const body = res.data || {}
if (status === 401 || body.code === 401) {
// 统一认证模式:先用 refresh_token 静默续期并重放请求,
// 续期失败(令牌过期/被吊销/被踢下线)才真正登出。
if (isSSOEnabled()) {
try {
resolve(await replayRequest(options))
return
} catch (e) {
// 续期失败,继续走下面的登出流程
}
}
logout()
if (!silent) {
uni.showToast({ title: body.msg || '请重新登录', icon: 'none' })
+97 -5
View File
@@ -137,7 +137,9 @@ import {
login,
loginBySms,
sendLoginCode,
fetchVerifyConfig
fetchVerifyConfig,
loginUAC,
switchTenant
} from '@/api/auth.js'
import {
loginSuccess,
@@ -146,7 +148,11 @@ import {
getTenantName,
getRememberLogin,
saveRememberLogin,
clearRememberLogin
clearRememberLogin,
isSSOEnabled,
setAuthTokens,
setTid,
getToken
} from '@/utils/auth.js'
import { showGeetest4 } from '@/utils/geetest.js'
@@ -191,7 +197,6 @@ onMounted(async () => {
rememberMe.value = true
tenantName.value = remembered.tenantName
username.value = remembered.account
password.value = remembered.password
} else {
tenantName.value = getTenantName()
}
@@ -276,6 +281,84 @@ function navigateAfterLogin() {
}, 400)
}
/* ==================== 统一认证(UAC)登录 ====================
* APP 无浏览器回跳,走密码直连:
* 1) POST /auth/login 拿 access + refresh
* 2) 一人多企业时弹出企业列表让用户选,选完调 /auth/switch-tenant
* ============================================================ */
/** 选择要进入的企业(一人多企业场景) */
function chooseTenant(tenants) {
return new Promise((resolve, reject) => {
const names = (tenants || []).map(t => t.tenant_name || `企业${t.tid}`)
uni.showActionSheet({
itemList: names,
title: '选择要进入的企业',
success: (res) => resolve(tenants[res.tapIndex]),
fail: () => reject(new Error('已取消选择'))
})
})
}
/** 统一认证:账号密码登录 */
async function submitSSOLogin() {
const account = username.value.trim()
const pwd = password.value
if (!account) {
uni.showToast({ title: '请输入账号', icon: 'none' })
return false
}
if (!pwd) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return false
}
const data = await loginUAC({ account, password: pwd })
if (!data || data.code !== 200) {
uni.showToast({ title: (data && data.msg) || '登录失败', icon: 'none' })
return false
}
// 记住账号(不含密码)
if (rememberMe.value) {
saveRememberLogin({ tenantName: tenantName.value.trim(), account })
} else {
clearRememberLogin()
}
let currentTid = 0
if (data.need_choose_tenant) {
// 多企业:先记住令牌,再让用户选择
setAuthTokens(data.tokens || { access_token: data.access_token, refresh_token: data.refresh_token, sid: data.sid })
const picked = await chooseTenant(data.tenants)
const res = await switchTenant({ tid: picked.tid })
if (res && res.code === 200 && res.data && res.data.tokens) {
setAuthTokens(res.data.tokens)
currentTid = picked.tid
} else {
uni.showToast({ title: (res && res.msg) || '进入企业失败', icon: 'none' })
return false
}
} else {
setAuthTokens(data.tokens || {})
if (data.tenants && data.tenants.length > 0) currentTid = data.tenants[0].tid
}
setTid(currentTid)
loginSuccess({
token: getToken(),
user: {
id: data.identity ? data.identity.id : 0,
account,
name: (data.identity && (data.identity.nickname || data.identity.mobile)) || account,
tid: currentTid,
tenant_name: (data.tenants || []).map(t => t.tenant_name).join('、')
}
})
navigateAfterLogin()
return true
}
async function submitAccountLogin(geetestResult = null) {
if (!username.value.trim()) {
uni.showToast({ title: '请输入账号', icon: 'none' })
@@ -311,8 +394,7 @@ async function submitAccountLogin(geetestResult = null) {
if (rememberMe.value) {
saveRememberLogin({
tenantName: tenantName.value.trim(),
account: username.value.trim(),
password: password.value
account: username.value.trim()
})
} else {
clearRememberLogin()
@@ -326,6 +408,16 @@ async function handleLogin() {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return
}
// 统一认证模式:账号即手机/邮箱,无需填写租户名
if (isSSOEnabled()) {
submitting.value = true
try {
await submitSSOLogin()
} finally {
submitting.value = false
}
return
}
if (!tenantName.value.trim()) {
uni.showToast({ title: '请输入租户名称', icon: 'none' })
return
-39
View File
@@ -39,45 +39,6 @@
<view v-else class="schedule-list">
<view v-for="group in groupedList" :key="group.key" class="group">
<text class="group-title">{{ group.label }}</text>
<view
v-for="item in group.items"
:key="item.id"
class="schedule-card"
:class="{ done: item.completed }"
>
<view class="schedule-main" @tap="openDetail(item.id)">
<view class="schedule-head">
<view class="status-dot" :class="item.completed ? 'done' : 'pending'" />
<text class="schedule-title">{{ getScheduleSummary(item) }}</text>
</view>
<view class="schedule-meta">
<FaIcon name="clock" color="#909399" :size="12" />
<text>{{ formatDate(item.datetime, true) }}</text>
</view>
<view v-if="item.remindChannels?.length" class="channel-row">
<text
v-for="ch in item.remindChannels"
:key="ch"
class="channel-mini"
>{{ getRemindChannelLabel(ch) }}</text>
<text v-if="item.remindMinutes" class="remind-text">{{ getRemindMinutesLabel(item.remindMinutes) }}</text>
</view>
</view>
<view class="action-row">
<view
class="action-btn"
:class="{ disabled: item.completed }"
@tap="!item.completed && onToggleComplete(item)"
>
<FaIcon name="check" :color="item.completed ? '#c0c4cc' : '#3c9cff'" :size="14" />
<text>{{ item.completed ? '已完成' : '标记完成' }}</text>
</view>
<view v-if="!item.completed" class="action-btn" @tap="openEdit(item.id)">
<FaIcon name="pen-to-square" color="#3c9cff" :size="14" />
<text>编辑</text>
</view>
</view>
</view>
</view>
</view>
<view class="bottom-space" />
+47 -7
View File
@@ -1,6 +1,41 @@
const TOKEN_KEY = 'app_token'
const USER_KEY = 'app_user'
const TENANT_KEY = 'app_tenant_name'
const TID_KEY = 'app_tid'
const REFRESH_KEY = 'app_refresh_token'
const SID_KEY = 'app_sid'
/** 是否启用统一认证:.env 中配置 VITE_AUTH_MODE=sso */
export function isSSOEnabled() {
try {
return (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_AUTH_MODE) === 'sso'
} catch (e) {
return false
}
}
/** 统一认证:保存令牌与会话信息 */
export function setAuthTokens(data = {}) {
if (data.access_token) setToken(data.access_token)
if (data.refresh_token) uni.setStorageSync(REFRESH_KEY, data.refresh_token)
if (data.sid) uni.setStorageSync(SID_KEY, data.sid)
}
export function getRefreshToken() {
return uni.getStorageSync(REFRESH_KEY) || ''
}
export function getSid() {
return uni.getStorageSync(SID_KEY) || ''
}
export function setTid(tid) {
uni.setStorageSync(TID_KEY, tid || '')
}
export function getTid() {
return Number(uni.getStorageSync(TID_KEY) || 0)
}
const REMEMBER_KEY = 'app_remember_me'
const REMEMBER_TENANT_KEY = 'app_remember_tenant'
const REMEMBER_ACCOUNT_KEY = 'app_remember_account'
@@ -37,6 +72,8 @@ export function isLoggedIn() {
export function logout() {
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(USER_KEY)
uni.removeStorageSync(REFRESH_KEY)
uni.removeStorageSync(SID_KEY)
}
/** 是否开启记住账号登录信息 */
@@ -44,25 +81,28 @@ export function isRememberLogin() {
return uni.getStorageSync(REMEMBER_KEY) === '1'
}
/** 读取记住的账号登录信息 */
/**
* 读取记住的账号登录信息。
* 安全说明:不再保存/回填密码,仅记住租户名与账号。
*/
export function getRememberLogin() {
if (!isRememberLogin()) {
return { rememberMe: false, tenantName: '', account: '', password: '' }
return { rememberMe: false, tenantName: '', account: '' }
}
return {
rememberMe: true,
tenantName: uni.getStorageSync(REMEMBER_TENANT_KEY) || '',
account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || '',
password: uni.getStorageSync(REMEMBER_PASSWORD_KEY) || ''
account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || ''
}
}
/** 保存账号登录信息(租户、账号、密码) */
export function saveRememberLogin({ tenantName = '', account = '', password = '' } = {}) {
/** 保存账号登录信息(租户、账号;不含密码) */
export function saveRememberLogin({ tenantName = '', account = '' } = {}) {
uni.setStorageSync(REMEMBER_KEY, '1')
uni.setStorageSync(REMEMBER_TENANT_KEY, tenantName)
uni.setStorageSync(REMEMBER_ACCOUNT_KEY, account)
uni.setStorageSync(REMEMBER_PASSWORD_KEY, password)
// 历史版本可能已落盘明文密码,这里一并清理
uni.removeStorageSync(REMEMBER_PASSWORD_KEY)
}
/** 清除记住的账号登录信息 */