优化
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
- `pages/scanner-test/scanner-test`:Android 硬件扫描广播诊断页;
|
||||
- `pages/picking/picking`:集装箱拣货完整交互。
|
||||
|
||||
登录会话集中在 `stores/session.js`,拣货会话集中在 `stores/picking.js`;当前实现仍是内存态,不写入设备。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
|
||||
登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
|
||||
|
||||
拣货原型会在本次登录会话内按箱保存本地草稿和最近扫码结果。已确认的正式流程是:进入任务即由后端标记“拣货中”;扫码只更新本地草稿,员工点击提交时才将整批明细写入后端,不逐码提交。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。当前草稿尚未持久化到设备,这是实现缺口,不代表允许离线作业。
|
||||
|
||||
|
||||
+10
-1
@@ -1,11 +1,13 @@
|
||||
import { API_ROOT } from '@/config/runtime.js'
|
||||
|
||||
function authRequest(path, data) {
|
||||
function authRequest(path, data, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${API_ROOT}${path}`,
|
||||
method: 'POST',
|
||||
data,
|
||||
header: options.header,
|
||||
timeout: options.timeout || 15000,
|
||||
success(response) {
|
||||
const body = response.data || {}
|
||||
if (String(body.code ?? response.statusCode) !== '200') {
|
||||
@@ -34,3 +36,10 @@ export function login(username, password) {
|
||||
export function refresh(refreshToken) {
|
||||
return authRequest('/auth/refresh', { refreshToken })
|
||||
}
|
||||
|
||||
export function logout(refreshToken, accessToken) {
|
||||
return authRequest('/auth/logout', { refreshToken }, {
|
||||
header: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { refresh as refreshRequest } from '@/api/auth.js'
|
||||
import { isAccessTokenExpiring, session, signOut, updateTokenPair } from '@/stores/session.js'
|
||||
import { requireOnline } from '@/utils/network.js'
|
||||
|
||||
let refreshPromise = null
|
||||
let redirectingToLogin = false
|
||||
|
||||
function responseCode(response) {
|
||||
return String(response?.data?.code ?? response?.statusCode ?? '')
|
||||
}
|
||||
|
||||
function isUnauthorized(responseOrError) {
|
||||
const statusCode = Number(responseOrError?.statusCode ?? responseOrError?.response?.statusCode ?? 0)
|
||||
const code = String(responseOrError?.code ?? responseCode(responseOrError))
|
||||
return statusCode === 401 || code === '401' || code === 'A0401'
|
||||
}
|
||||
|
||||
function sessionExpiredError(source) {
|
||||
const error = new Error('登录已过期,请重新登录')
|
||||
error.code = source?.code || 'A0401'
|
||||
error.statusCode = source?.statusCode || 401
|
||||
return error
|
||||
}
|
||||
|
||||
function redirectAfterSessionExpiry() {
|
||||
if (redirectingToLogin) return
|
||||
redirectingToLogin = true
|
||||
if (typeof uni !== 'undefined' && typeof uni.showToast === 'function') {
|
||||
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none', duration: 2200 })
|
||||
}
|
||||
if (typeof uni !== 'undefined' && typeof uni.reLaunch === 'function') {
|
||||
uni.reLaunch({
|
||||
url: '/pages/login/login',
|
||||
complete() { setTimeout(() => { redirectingToLogin = false }, 1000) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function expireSession(source, observedRefreshToken = session.refreshToken) {
|
||||
if (observedRefreshToken && session.refreshToken && observedRefreshToken !== session.refreshToken) return session.token
|
||||
const hadSession = Boolean(session.currentUser || session.token || session.refreshToken)
|
||||
signOut()
|
||||
if (hadSession) redirectAfterSessionExpiry()
|
||||
throw sessionExpiredError(source)
|
||||
}
|
||||
|
||||
function networkError(error, fallback = '网络连接失败') {
|
||||
const next = new Error(error?.errMsg || fallback)
|
||||
next.network = true
|
||||
return next
|
||||
}
|
||||
|
||||
function rawRequest(options, accessToken) {
|
||||
const baseUrl = session.apiBase || '/api/platform/v1'
|
||||
const headers = { ...(options.header || {}) }
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
url: `${baseUrl}${options.url}`,
|
||||
header: headers,
|
||||
success: resolve,
|
||||
fail: (error) => reject(networkError(error))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const observedRefreshToken = session.refreshToken
|
||||
if (!observedRefreshToken) return expireSession(null, observedRefreshToken)
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
if (session.refreshToken !== observedRefreshToken && session.token) return session.token
|
||||
try {
|
||||
const tokens = await refreshRequest(observedRefreshToken)
|
||||
if (session.refreshToken !== observedRefreshToken) {
|
||||
if (session.token) return session.token
|
||||
throw sessionExpiredError()
|
||||
}
|
||||
updateTokenPair(tokens)
|
||||
redirectingToLogin = false
|
||||
return session.token
|
||||
} catch (error) {
|
||||
if (isUnauthorized(error)) return expireSession(error, observedRefreshToken)
|
||||
throw error
|
||||
}
|
||||
})().finally(() => { refreshPromise = null })
|
||||
}
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
async function validAccessToken() {
|
||||
if (!session.token || isAccessTokenExpiring(session.token)) return refreshAccessToken()
|
||||
return session.token
|
||||
}
|
||||
|
||||
export function ensureAccessToken() {
|
||||
return validAccessToken()
|
||||
}
|
||||
|
||||
async function runAuthenticated(execute) {
|
||||
requireOnline()
|
||||
let accessToken = await validAccessToken()
|
||||
let response = await execute(accessToken)
|
||||
if (!isUnauthorized(response)) return response
|
||||
accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken()
|
||||
const activeRefreshToken = session.refreshToken
|
||||
response = await execute(accessToken)
|
||||
if (isUnauthorized(response)) expireSession(response, activeRefreshToken)
|
||||
return response
|
||||
}
|
||||
|
||||
export function apiRequest(options) {
|
||||
return runAuthenticated((accessToken) => rawRequest(options, accessToken)).then((response) => {
|
||||
const body = response.data || {}
|
||||
const code = responseCode(response)
|
||||
if (code !== '200') {
|
||||
const error = new Error(body.message || '服务端请求失败')
|
||||
error.code = body.code || response.statusCode
|
||||
error.statusCode = response.statusCode
|
||||
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
|
||||
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
|
||||
throw error
|
||||
}
|
||||
return body.data
|
||||
})
|
||||
}
|
||||
|
||||
export function apiDownload(options) {
|
||||
return runAuthenticated((accessToken) => new Promise((resolve, reject) => {
|
||||
const headers = { ...(options.header || {}) }
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
||||
uni.downloadFile({ ...options, header: headers, success: resolve, fail: (error) => reject(networkError(error, '文件下载失败')) })
|
||||
}))
|
||||
}
|
||||
|
||||
export function apiUpload(options, onProgress) {
|
||||
return runAuthenticated((accessToken) => new Promise((resolve, reject) => {
|
||||
const headers = { ...(options.header || {}) }
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
||||
const task = uni.uploadFile({ ...options, header: headers, success: resolve, fail: (error) => reject(networkError(error)) })
|
||||
if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress))
|
||||
}))
|
||||
}
|
||||
|
||||
export { isUnauthorized }
|
||||
+14
-70
@@ -1,5 +1,6 @@
|
||||
import { session } from '@/stores/session.js'
|
||||
import { requireOnline } from '@/utils/network.js'
|
||||
import { apiDownload, apiRequest, apiUpload } from '@/api/http.js'
|
||||
|
||||
const EVIDENCE_TYPES = Object.freeze([
|
||||
'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'
|
||||
@@ -23,41 +24,7 @@ function debugLog(event, payload = {}) {
|
||||
try { console.info(`[PDA] ${event}`, payload) } catch (_) { /* console may be unavailable on a device */ }
|
||||
}
|
||||
|
||||
function request(options) {
|
||||
requireOnline()
|
||||
const baseUrl = session.apiBase || '/api/platform/v1'
|
||||
const headers = { ...(options.header || {}) }
|
||||
if (session.token) headers.Authorization = `Bearer ${session.token}`
|
||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
url: `${baseUrl}${options.url}`,
|
||||
header: headers,
|
||||
success(response) {
|
||||
const body = response.data || {}
|
||||
const code = String(body.code ?? response.statusCode)
|
||||
|
||||
if (code !== '200') {
|
||||
const error = new Error(body.message || '服务端请求失败')
|
||||
error.code = body.code || response.statusCode
|
||||
error.statusCode = response.statusCode
|
||||
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
|
||||
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(body.data)
|
||||
},
|
||||
fail(error) {
|
||||
const next = new Error(error.errMsg || '网络连接失败')
|
||||
next.network = true
|
||||
reject(next)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
const request = apiRequest
|
||||
|
||||
function idempotencyKey(operation, inboundId) {
|
||||
return `pda-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
@@ -260,15 +227,8 @@ export function downloadEvidencePreview(previewUrl) {
|
||||
const origin = baseUrl.match(/^(https?:\/\/[^/]+)/i)?.[1] || ''
|
||||
url = `${origin}${url.startsWith('/') ? '' : '/'}${url}`
|
||||
}
|
||||
const headers = {}
|
||||
if (session.token) headers.Authorization = `Bearer ${session.token}`
|
||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
||||
debugLog('evidence.preview.start', { previewPath: String(previewUrl), resolvedUrl: url })
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.downloadFile({
|
||||
url,
|
||||
header: headers,
|
||||
success(response) {
|
||||
return apiDownload({ url }).then((response) => {
|
||||
const responseHeaders = response.header || {}
|
||||
const contentType = responseHeaders['Content-Type'] || responseHeaders['content-type'] || ''
|
||||
const requestId = responseHeaders['X-Request-Id'] || responseHeaders['x-request-id'] || ''
|
||||
@@ -279,8 +239,7 @@ export function downloadEvidencePreview(previewUrl) {
|
||||
error.statusCode = response.statusCode
|
||||
error.requestId = requestId
|
||||
error.traceId = traceId
|
||||
reject(error)
|
||||
return
|
||||
throw error
|
||||
}
|
||||
if (contentType && !/^image\//i.test(contentType) && !/^application\/octet-stream/i.test(contentType)) {
|
||||
const error = new Error('服务端未返回有效照片')
|
||||
@@ -288,18 +247,12 @@ export function downloadEvidencePreview(previewUrl) {
|
||||
error.contentType = contentType
|
||||
error.requestId = requestId
|
||||
error.traceId = traceId
|
||||
reject(error)
|
||||
return
|
||||
throw error
|
||||
}
|
||||
resolve(response.tempFilePath)
|
||||
},
|
||||
fail(error) {
|
||||
debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.errMsg || '' })
|
||||
const next = new Error(error.errMsg || '装柜照片下载失败')
|
||||
next.network = true
|
||||
reject(next)
|
||||
}
|
||||
})
|
||||
return response.tempFilePath
|
||||
}).catch((error) => {
|
||||
debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.message || error.errMsg || '' })
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -331,28 +284,19 @@ export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId,
|
||||
if (!localPath) throw new Error('缺少待上传的照片')
|
||||
const tenant = tenantQuery(tenantId)
|
||||
const baseUrl = session.apiBase || '/api/platform/v1'
|
||||
const headers = {}
|
||||
if (session.token) headers.Authorization = `Bearer ${session.token}`
|
||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
||||
debugLog('evidence.upload.start', { inboundId, evidenceType, tenantId: tenant, hasLocalPath: Boolean(localPath) })
|
||||
return new Promise((resolve, reject) => {
|
||||
const task = uni.uploadFile({
|
||||
return apiUpload({
|
||||
url: `${baseUrl}/files/loading-evidence/${encodeURIComponent(tenant)}/${encodeURIComponent(inboundId)}?evidenceType=${encodeURIComponent(evidenceType)}`,
|
||||
filePath: localPath,
|
||||
name: 'file',
|
||||
header: headers,
|
||||
success(response) {
|
||||
}, onProgress).then((response) => {
|
||||
let body = response.data
|
||||
try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ }
|
||||
const code = String(body?.code ?? response.statusCode)
|
||||
debugLog('evidence.upload.response', { inboundId, evidenceType, statusCode: response.statusCode, code, message: body?.message || '', requestId: body?.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || '', traceId: body?.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || '', fileId: body?.data?.fileId || null })
|
||||
if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; reject(error); return }
|
||||
resolve(body?.data || body)
|
||||
},
|
||||
fail(error) { debugLog('evidence.upload.error', { inboundId, evidenceType, message: error.errMsg || '' }); const next = new Error(error.errMsg || '网络连接失败'); next.network = true; reject(next) }
|
||||
})
|
||||
if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress))
|
||||
})
|
||||
if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; throw error }
|
||||
return body?.data || body
|
||||
}).catch((error) => { debugLog('evidence.upload.error', { inboundId, evidenceType, message: error.message || error.errMsg || '' }); throw error })
|
||||
}
|
||||
|
||||
export function completeLoading(inboundId, payload, tenantId) {
|
||||
|
||||
+25
-3
@@ -7,6 +7,7 @@
|
||||
<view class="login-card">
|
||||
<view class="login-field"><text class="login-field-label">工号 / 用户名</text><input v-model="form.userId" class="login-field-input" aria-label="工号 / 用户名" placeholder="请输入工号" confirm-type="next" @focus="error = ''" /></view>
|
||||
<view class="login-field"><text class="login-field-label">密码</text><input v-model="form.password" class="login-field-input" aria-label="密码" type="password" placeholder="请输入密码" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
|
||||
<view class="login-remember-row" @tap="toggleRememberAccount"><text class="login-remember-label">记住账号</text><switch :checked="rememberAccount" color="#1A73E8" aria-label="记住账号" @change="handleRememberChange" @tap.stop /></view>
|
||||
<text v-if="error" class="login-error" role="alert">{{ error }}</text>
|
||||
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
|
||||
</view>
|
||||
@@ -15,12 +16,32 @@
|
||||
|
||||
<script>
|
||||
import { login as loginRequest } from '@/api/auth.js'
|
||||
import { session, restoreSession, signInWithTokens } from '@/stores/session.js'
|
||||
import { ensureAccessToken } from '@/api/http.js'
|
||||
import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js'
|
||||
import { requireOnline } from '@/utils/network.js'
|
||||
export default {
|
||||
data() { return { form: { userId: 'platform_admin', password: 'ChangeMe123!' }, error: '', submitting: false } },
|
||||
onLoad() { if (session.currentUser || restoreSession()) uni.reLaunch({ url: '/pages/home/home' }) },
|
||||
data() { return { form: { userId: 'platform_admin', password: 'ChangeMe123!' }, rememberAccount: true, error: '', submitting: false } },
|
||||
async onLoad() {
|
||||
const preference = loadLoginPreference()
|
||||
this.rememberAccount = preference.rememberAccount
|
||||
if (preference.account) this.form.userId = preference.account
|
||||
if (!session.currentUser && !restoreSession()) return
|
||||
try {
|
||||
await ensureAccessToken()
|
||||
uni.reLaunch({ url: '/pages/home/home' })
|
||||
} catch (error) {
|
||||
if (error.network) this.error = '当前网络不可用,暂时无法恢复登录状态'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleRememberChange(event) {
|
||||
this.rememberAccount = Boolean(event.detail.value)
|
||||
if (!this.rememberAccount) saveLoginPreference(false)
|
||||
},
|
||||
toggleRememberAccount() {
|
||||
this.rememberAccount = !this.rememberAccount
|
||||
if (!this.rememberAccount) saveLoginPreference(false)
|
||||
},
|
||||
async login() {
|
||||
const userId = this.form.userId.trim()
|
||||
if (!userId) return void (this.error = '请输入工号')
|
||||
@@ -31,6 +52,7 @@
|
||||
requireOnline('当前网络不可用,无法登录')
|
||||
const tokens = await loginRequest(userId, this.form.password)
|
||||
signInWithTokens(tokens)
|
||||
saveLoginPreference(this.rememberAccount, userId)
|
||||
uni.reLaunch({ url: '/pages/home/home' })
|
||||
} catch (error) {
|
||||
this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">0.0.1</text></view></view><button class="profile-action" @tap="showSettings">系统设置 <text>›</text></button><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
|
||||
</template>
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'; import PopupHeader from '@/components/PopupHeader.vue'; import { session, requireSession, signOut } from '@/stores/session.js'
|
||||
export default { components: { PopupHeader, PortalHeader }, data() { return { user: session.currentUser, logoutOpen: false, toast: false } }, onShow() { if (!requireSession()) return; this.user = session.currentUser }, beforeUnmount() { clearTimeout(this._toastTimer) }, onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true }, methods: { openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) }, showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) }, logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) } } }
|
||||
import { logout as logoutRequest } from '@/api/auth.js'; import PortalHeader from '@/components/PortalHeader.vue'; import PopupHeader from '@/components/PopupHeader.vue'; import { session, requireSession, signOut } from '@/stores/session.js'
|
||||
export default { components: { PopupHeader, PortalHeader }, data() { return { user: session.currentUser, logoutOpen: false, toast: false } }, onShow() { if (!requireSession()) return; this.user = session.currentUser }, beforeUnmount() { clearTimeout(this._toastTimer) }, onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true }, methods: { openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) }, showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) }, logout() { const refreshToken = session.refreshToken; const accessToken = session.token; signOut(); uni.reLaunch({ url: '/pages/login/login' }); if (refreshToken) logoutRequest(refreshToken, accessToken).catch(() => {}) } } }
|
||||
</script>
|
||||
|
||||
+50
-1
@@ -3,6 +3,7 @@ import { resetPickingSession } from '@/stores/picking.js'
|
||||
import { PLATFORM_API_ROOT } from '@/config/runtime.js'
|
||||
|
||||
const SESSION_KEY = 'pda-auth-session'
|
||||
const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
|
||||
|
||||
export const session = reactive({ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: PLATFORM_API_ROOT })
|
||||
|
||||
@@ -24,10 +25,34 @@ export function decodeAccessToken(token) {
|
||||
}
|
||||
}
|
||||
|
||||
export function isAccessTokenExpiring(token = session.token, skewSeconds = 30) {
|
||||
try {
|
||||
const expiresAt = Number(decodeAccessToken(token).exp || 0) * 1000
|
||||
return !expiresAt || expiresAt <= Date.now() + skewSeconds * 1000
|
||||
} catch (_) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function storageAvailable() {
|
||||
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
|
||||
}
|
||||
|
||||
export function loadLoginPreference() {
|
||||
if (!storageAvailable()) return { rememberAccount: true, account: '' }
|
||||
const saved = uni.getStorageSync(LOGIN_PREFERENCE_KEY)
|
||||
if (!saved || typeof saved !== 'object') return { rememberAccount: true, account: '' }
|
||||
return { rememberAccount: saved.rememberAccount !== false, account: String(saved.account || '') }
|
||||
}
|
||||
|
||||
export function saveLoginPreference(rememberAccount, account = '') {
|
||||
if (!storageAvailable()) return
|
||||
uni.setStorageSync(LOGIN_PREFERENCE_KEY, {
|
||||
rememberAccount: Boolean(rememberAccount),
|
||||
account: rememberAccount ? String(account || '').trim() : ''
|
||||
})
|
||||
}
|
||||
|
||||
function persistSession() {
|
||||
if (!storageAvailable()) return
|
||||
uni.setStorageSync(SESSION_KEY, {
|
||||
@@ -51,6 +76,7 @@ export function signIn(account, warehouse, options = {}) {
|
||||
|
||||
export function signInWithTokens(tokens) {
|
||||
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
|
||||
if (!tokens?.refreshToken) throw new Error('登录响应缺少 refreshToken')
|
||||
const claims = decodeAccessToken(tokens.accessToken)
|
||||
if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
|
||||
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期')
|
||||
@@ -66,16 +92,39 @@ export function signInWithTokens(tokens) {
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTokenPair(tokens) {
|
||||
if (!tokens?.accessToken) throw new Error('刷新响应缺少 accessToken')
|
||||
if (!tokens?.refreshToken) throw new Error('刷新响应缺少 refreshToken')
|
||||
const claims = decodeAccessToken(tokens.accessToken)
|
||||
if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
|
||||
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('刷新后的登录状态已过期')
|
||||
const previousUser = session.currentUser || {}
|
||||
session.currentUser = {
|
||||
...previousUser,
|
||||
id: String(claims.user_id ?? previousUser.id ?? ''),
|
||||
name: claims.username || previousUser.name || '仓库作业员',
|
||||
role: Array.isArray(claims.roles) ? claims.roles.join('、') : (previousUser.role || ''),
|
||||
roles: claims.roles || previousUser.roles || []
|
||||
}
|
||||
session.token = tokens.accessToken
|
||||
session.refreshToken = tokens.refreshToken
|
||||
session.tenantId = claims.tenant_id == null ? session.tenantId : String(claims.tenant_id)
|
||||
persistSession()
|
||||
return session.token
|
||||
}
|
||||
|
||||
export function restoreSession() {
|
||||
if (!storageAvailable()) return false
|
||||
const saved = uni.getStorageSync(SESSION_KEY)
|
||||
if (!saved?.token || !saved?.currentUser) return false
|
||||
try {
|
||||
const claims = decodeAccessToken(saved.token)
|
||||
if (claims.exp && claims.exp * 1000 <= Date.now()) return false
|
||||
if (claims.app_code !== 'PLATFORM') throw new Error('登录域无效')
|
||||
if (claims.exp && claims.exp * 1000 <= Date.now() && !saved.refreshToken) throw new Error('登录状态已过期')
|
||||
Object.assign(session, saved, { apiBase: saved.apiBase || PLATFORM_API_ROOT })
|
||||
return true
|
||||
} catch (_) {
|
||||
signOut()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,20 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-remember-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 48px;
|
||||
margin: -8px 0 10px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.login-remember-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
test('authenticated PDA requests refresh once and replay once after unauthorized', async () => {
|
||||
const http = await read('../api/http.js')
|
||||
assert.match(http, /let refreshPromise = null/)
|
||||
assert.match(http, /refreshPromise = \(async \(\) =>/)
|
||||
assert.match(http, /session\.token && session\.token !== accessToken \? session\.token : await refreshAccessToken\(\)/)
|
||||
assert.match(http, /response = await execute\(accessToken\)[\s\S]*?isUnauthorized\(response\)[\s\S]*?response = await execute\(accessToken\)/)
|
||||
assert.match(http, /apiRequest/)
|
||||
assert.match(http, /apiDownload/)
|
||||
assert.match(http, /apiUpload/)
|
||||
})
|
||||
|
||||
test('refresh rotation persists the complete token pair and preserves operator context', async () => {
|
||||
const [session, auth] = await Promise.all([read('../stores/session.js'), read('../api/auth.js')])
|
||||
assert.match(session, /export function updateTokenPair\(tokens\)/)
|
||||
assert.match(session, /session\.refreshToken = tokens\.refreshToken/)
|
||||
assert.match(session, /const previousUser = session\.currentUser \|\| \{\}/)
|
||||
assert.match(session, /persistSession\(\)/)
|
||||
assert.match(session, /isAccessTokenExpiring/)
|
||||
assert.match(auth, /authRequest\('\/auth\/refresh', \{ refreshToken \}\)/)
|
||||
})
|
||||
|
||||
test('app launch restores and validates persisted tokens before entering the workbench', async () => {
|
||||
const [login, http] = await Promise.all([read('../pages/login/login.vue'), read('../api/http.js')])
|
||||
assert.match(login, /async onLoad\(\)[\s\S]*?restoreSession\(\)[\s\S]*?await ensureAccessToken\(\)[\s\S]*?pages\/home\/home/)
|
||||
assert.match(http, /export function ensureAccessToken\(\)/)
|
||||
assert.match(login, /暂时无法恢复登录状态/)
|
||||
})
|
||||
|
||||
test('confirmed session expiry clears sensitive state and redirects only once', async () => {
|
||||
const http = await read('../api/http.js')
|
||||
assert.match(http, /observedRefreshToken !== session\.refreshToken/)
|
||||
assert.match(http, /signOut\(\)[\s\S]*?redirectAfterSessionExpiry\(\)/)
|
||||
assert.match(http, /if \(redirectingToLogin\) return/)
|
||||
assert.match(http, /url: '\/pages\/login\/login'/)
|
||||
assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/)
|
||||
})
|
||||
|
||||
test('manual logout revokes the refresh token without delaying local cleanup', async () => {
|
||||
const [profile, auth] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/auth.js')])
|
||||
assert.match(auth, /export function logout\(refreshToken, accessToken\)/)
|
||||
assert.match(profile, /const refreshToken = session\.refreshToken; const accessToken = session\.token; signOut\(\); uni\.reLaunch/)
|
||||
assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/)
|
||||
})
|
||||
|
||||
test('development login remembers only the account preference, never the entered password', async () => {
|
||||
const [login, session] = await Promise.all([read('../pages/login/login.vue'), read('../stores/session.js')])
|
||||
assert.match(login, /form: \{ userId: 'platform_admin', password: 'ChangeMe123!' \}/)
|
||||
assert.match(session, /export function saveLoginPreference\(rememberAccount, account = ''\)/)
|
||||
const savePreference = session.match(/export function saveLoginPreference[\s\S]*?\n\}/)?.[0] || ''
|
||||
assert.match(savePreference, /account: rememberAccount/)
|
||||
assert.doesNotMatch(savePreference, /password/)
|
||||
assert.match(login, /if \(!this\.rememberAccount\) saveLoginPreference\(false\)/)
|
||||
})
|
||||
@@ -5,10 +5,11 @@ import { readFile } from 'node:fs/promises'
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
test('remote PDA mutations use the shared online gate', async () => {
|
||||
const [api, network, page] = await Promise.all([
|
||||
read('../api/pda-loading.js'), read('../utils/network.js'), read('../pages/picking/picking.vue')
|
||||
const [api, http, network, page] = await Promise.all([
|
||||
read('../api/pda-loading.js'), read('../api/http.js'), read('../utils/network.js'), read('../pages/picking/picking.vue')
|
||||
])
|
||||
assert.match(api, /function request\(options\) \{[\s\S]*?requireOnline\(\)/)
|
||||
assert.match(api, /const request = apiRequest/)
|
||||
assert.match(http, /async function runAuthenticated\(execute\) \{[\s\S]*?requireOnline\(\)/)
|
||||
assert.match(api, /uploadEvidenceFile[\s\S]*?requireOnline\('当前网络不可用,无法上传装柜照片'\)/)
|
||||
assert.match(network, /uni\.onNetworkStatusChange/)
|
||||
assert.match(network, /result\.networkType !== 'none'/)
|
||||
|
||||
@@ -31,7 +31,12 @@ test('login uses the real auth endpoint and relaunches to the modular workbench'
|
||||
assert.match(auth, /\/auth\/login/)
|
||||
assert.match(login, /loginRequest\(userId, this\.form\.password\)/)
|
||||
assert.match(login, /signInWithTokens\(tokens\)/)
|
||||
assert.match(login, /userId: 'platform_admin', password: 'ChangeMe123!'/)
|
||||
assert.match(login, /记住账号/)
|
||||
assert.match(login, /loadLoginPreference\(\)/)
|
||||
assert.match(login, /saveLoginPreference\(this\.rememberAccount, userId\)/)
|
||||
assert.match(session, /pda-auth-session/)
|
||||
assert.match(session, /pda-login-account-preference/)
|
||||
assert.match(session, /claims\.app_code !== 'PLATFORM'/)
|
||||
assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/)
|
||||
assert.doesNotMatch(runtime, /typeof\s+import\.meta/)
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const source = await readFile(new URL('../api/pda-loading.js', import.meta.url), 'utf8')
|
||||
const http = await readFile(new URL('../api/http.js', import.meta.url), 'utf8')
|
||||
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('PDA loading adapter keeps C04 read and write paths', () => {
|
||||
@@ -17,7 +18,8 @@ test('PDA loading adapter keeps C04 read and write paths', () => {
|
||||
assert.match(source, /'Idempotency-Key'/)
|
||||
assert.match(source, /PHOTO_STEP_1[\s\S]*PHOTO_STEP_4[\s\S]*SEAL/)
|
||||
for (const label of ['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照']) assert.match(source, new RegExp(label))
|
||||
assert.match(source, /uni\.uploadFile/)
|
||||
assert.match(source, /apiUpload\(/)
|
||||
assert.match(http, /uni\.uploadFile/)
|
||||
assert.match(source, /\/files\/loading-evidence\//)
|
||||
assert.match(source, /loading\/evidence\/pda\?tenantId=/)
|
||||
assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void\/pda\?tenantId=/)
|
||||
@@ -27,8 +29,8 @@ test('PDA loading adapter keeps C04 read and write paths', () => {
|
||||
})
|
||||
|
||||
test('PDA adapter does not treat non-200 response bodies or network failures as success', () => {
|
||||
assert.match(source, /if \(code !== '200'\)/)
|
||||
assert.match(source, /next\.network = true/)
|
||||
assert.match(http, /if \(code !== '200'\)/)
|
||||
assert.match(http, /next\.network = true/)
|
||||
assert.match(source, /throw new Error\('缺少客户隔离标识 tenantId/)
|
||||
})
|
||||
|
||||
@@ -81,8 +83,9 @@ test('PDA downloads authenticated evidence previews returned by the backend', ()
|
||||
assert.match(source, /export function getEvidencePreviewUrl\(inboundId, evidenceId, tenantId\)/)
|
||||
assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\/pda\?tenantId=/)
|
||||
assert.match(source, /export function downloadEvidencePreview\(previewUrl\)/)
|
||||
assert.match(source, /uni\.downloadFile\(/)
|
||||
assert.match(source, /headers\.Authorization = `Bearer \$\{session\.token\}`/)
|
||||
assert.match(source, /apiDownload\(\{ url \}\)/)
|
||||
assert.match(http, /uni\.downloadFile\(/)
|
||||
assert.match(http, /headers\.Authorization = `Bearer \$\{accessToken\}`/)
|
||||
assert.match(source, /response\.tempFilePath/)
|
||||
assert.match(source, /evidence\.preview\.response/)
|
||||
assert.match(source, /\^image\\\//)
|
||||
|
||||
Reference in New Issue
Block a user