feat 激活码竞态处理

This commit is contained in:
Ironsp
2026-09-27 11:37:23 +08:00
parent 7a24c6bfd0
commit 8ffbc10a4e
4 changed files with 163 additions and 54 deletions

View File

@@ -31,17 +31,42 @@ export interface ReportItem {
remark?: string | null remark?: string | null
} }
/** POST /api/report/upload 请求体 */
export interface UploadReportParams { export interface UploadReportParams {
/** 本地文件路径(uni.uploadFile) */ /** 本机出厂 SN */
filePath: string
deviceId: string deviceId: string
projectId: string /** 项目名称(文字,不是 ID);项目不存在时后端自动创建 */
projectName: string
/** 报告编号,全局唯一,如 RTP-20251215-001 */
reportNo: string reportNo: string
/** 作业开始时间 yyyy-MM-dd HH:mm:ss */
workStartTime: string workStartTime: string
/** 作业结束时间 yyyy-MM-dd HH:mm:ss */
workEndTime: string workEndTime: string
compactArea: string /** 施工桩号,如 K1+200-K1+800 */
qualificationRate: string constructionSection?: string
constructionSection: string /** 压实面积(㎡) */
compactArea?: number
/** 压实合格率(%) */
qualificationRate?: number
/** 作业时长(小时) */
workDuration?: number
/** 碾压遍数 */
passCount?: number
/** 平均行驶速度(KM/h) */
avgSpeed?: number
/** 平均振动频率(Hz) */
avgFrequency?: number
/** 平均振幅(mm) */
avgAmplitude?: number
/** 平均温度(℃) */
avgTemperature?: number
/** 平均压实度 */
avgCompaction?: number
/** 异常次数 */
exceptionCount?: number
/** 检测结论 */
conclusion?: string
} }
/** GET /api/report/list 报告列表 */ /** GET /api/report/list 报告列表 */
@@ -61,38 +86,11 @@ export function getReportDetail(reportId: number | string) {
}) })
} }
/** /** POST /api/report/upload 上传报告(JSON) */
* POST /api/report/upload 上传报告文件
* 使用 uni.uploadFile,适用于 App / 小程序本地文件路径
*/
export function uploadReport(data: UploadReportParams) { export function uploadReport(data: UploadReportParams) {
const baseURL = import.meta.env.VITE_API_BASE_URL || '' return request<ApiResult>({
url: '/api/report/upload',
return new Promise<ApiResult>((resolve, reject) => { method: 'POST',
uni.uploadFile({ data,
url: `${baseURL}/api/report/upload`,
filePath: data.filePath,
name: 'file',
formData: {
deviceId: data.deviceId,
projectId: data.projectId,
reportNo: data.reportNo,
workStartTime: data.workStartTime,
workEndTime: data.workEndTime,
compactArea: data.compactArea,
qualificationRate: data.qualificationRate,
constructionSection: data.constructionSection,
},
success: (res) => {
try {
const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
resolve(result)
}
catch (e) {
reject(e)
}
},
fail: reject,
})
}) })
} }

View File

@@ -94,13 +94,22 @@ let lastExpireShownAt = 0
/** /**
* 进入软件:先查本地激活码 → 无码再查授权状态并写入 storage * 进入软件:先查本地激活码 → 无码再查授权状态并写入 storage
* 激活成功后再查一次状态写入 storage,形成闭环 * 激活成功后再查一次状态写入 storage,形成闭环
* 仅未授权(0)或已过期(2)时展示 unauthorized / activateNotice * authStatus 为 1(已激活)时,unauthorized / activateNotice 一律不展示
*/ */
export function useDeviceAuth(options?: { watchShow?: boolean }) { export function useDeviceAuth(options?: { watchShow?: boolean }) {
const authStatus = computed(() => toAuthStatus(authInfo.value?.authStatus)) const authStatus = computed(() => toAuthStatus(authInfo.value?.authStatus))
const isActivated = computed(() => authStatus.value === '1') const isActivated = computed(() => authStatus.value === '1')
/** 是否需要展示未授权提示组件 */ /**
const needAuthPrompt = computed(() => authStatus.value === '0' || authStatus.value === '2') * 是否需要展示未授权提示组件
*
* 只有 authStatus === '1'(已激活)才隐藏,其余状态(0 未授权 / 2 已过期 /
* 3 未知 / null 未查到)一律展示提示,避免服务端返回未预期状态时
* 未激活组件仍然可见。
*
* 注意:authStatus 为 null 表示「查询尚未完成」,此时也视为需要提示
* (与改造前行为一致,不会出现空白)。首次查询结束后 authReady 为 true。
*/
const needAuthPrompt = computed(() => authStatus.value !== '1')
const hasActivationCode = computed(() => !!activationCode.value.trim()) const hasActivationCode = computed(() => !!activationCode.value.trim())
const deviceId = computed(() => authInfo.value?.deviceId || getDeviceId()) const deviceId = computed(() => authInfo.value?.deviceId || getDeviceId())
const remainingDays = computed(() => { const remainingDays = computed(() => {
@@ -198,7 +207,10 @@ export function useDeviceAuth(options?: { watchShow?: boolean }) {
activationCode.value = localCode.trim() activationCode.value = localCode.trim()
const cached = readCachedAuthInfo() const cached = readCachedAuthInfo()
if (cached) { if (cached) {
// 先用缓存让 UI 立即就绪
authInfo.value = cached authInfo.value = cached
// 再异步向服务端核对一次,保证 authStatus 最新(如服务端已改为已激活)
fetchAuthStatus()
return authInfo.value return authInfo.value
} }
// 有激活码但无缓存信息,补拉一次状态 // 有激活码但无缓存信息,补拉一次状态
@@ -257,9 +269,25 @@ export function useDeviceAuth(options?: { watchShow?: boolean }) {
return res return res
} }
function refreshAuthStatus() { /**
* 刷新授权状态:
* 先同步本地缓存(立即反映变化),再异步向服务端查询一次,
* 保证服务端已变为「已激活(1)」时,未授权提示组件能立即隐藏。
*/
async function refreshAuthStatus(): Promise<DeviceAuthStatus | null> {
// 1) 先读本地缓存,保证 UI 即时响应
authInfo.value = readCachedAuthInfo() authInfo.value = readCachedAuthInfo()
activationCode.value = storage.get<string>(ACTIVATION_CODE_KEY, '') ?? '' activationCode.value = storage.get<string>(ACTIVATION_CODE_KEY, '') ?? ''
// 2) 再查服务端,查到就覆盖(saveAuthInfo 内部会写回 storage)
const info = await fetchAuthStatus()
if (info)
return info
// 3) 查询失败:若本地没有任何信息,按未授权兜底,避免一直停在初始态
if (!authInfo.value)
saveAuthInfo(createUnauthorizedInfo())
return authInfo.value
} }
if (options?.watchShow) { if (options?.watchShow) {
@@ -267,7 +295,6 @@ export function useDeviceAuth(options?: { watchShow?: boolean }) {
refreshAuthStatus() refreshAuthStatus()
}) })
} }
return { return {
authInfo, authInfo,
authStatus, authStatus,

View File

@@ -11,7 +11,7 @@ definePage({
}, },
}) })
const { needAuthPrompt, initAuth, openExpireNoticeIfNeeded } = useDeviceAuth({ watchShow: true }) const { needAuthPrompt, initAuth, refreshAuthStatus, openExpireNoticeIfNeeded } = useDeviceAuth({ watchShow: true })
async function checkExpireNotice() { async function checkExpireNotice() {
await nextTick() await nextTick()
@@ -26,9 +26,11 @@ onMounted(async () => {
await checkExpireNotice() await checkExpireNotice()
}) })
/** 每次打开 / 回到首页:读 storage 判断是否弹到期提醒 */ /** 每次打开 / 回到首页:重新查询授权状态 + 判断是否弹到期提醒 */
onShow(() => { onShow(async () => {
checkExpireNotice() // 查询到 authStatus 为 1 时,needAuthPrompt 会自动变 false,隐藏未授权组件
await refreshAuthStatus()
await checkExpireNotice()
}) })
</script> </script>

View File

@@ -2,7 +2,9 @@
import type { HistoryReport } from '@/service/historyReport' import type { HistoryReport } from '@/service/historyReport'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue' import { ref } from 'vue'
import { uploadReport } from '@/api/report'
import { useDbStore } from '@/store/modules/db' import { useDbStore } from '@/store/modules/db'
import { getDeviceId } from '@/utils/device'
import storage from '@/utils/storage' import storage from '@/utils/storage'
definePage({ definePage({
@@ -30,6 +32,8 @@ interface ReportDetailView {
const db = useDbStore() const db = useDbStore()
const reportData = ref<ReportDetailView | null>(null) const reportData = ref<ReportDetailView | null>(null)
const rawReport = ref<HistoryReport | null>(null)
const uploading = ref(false)
function parseDate(value?: string) { function parseDate(value?: string) {
if (!value) if (!value)
@@ -38,8 +42,8 @@ function parseDate(value?: string) {
return Number.isNaN(date.getTime()) ? null : date return Number.isNaN(date.getTime()) ? null : date
} }
function pad(value: number) { function pad(value: number, length = 2) {
return String(value).padStart(2, '0') return String(value).padStart(length, '0')
} }
function formatDateTimeRange(start?: string, end?: string) { function formatDateTimeRange(start?: string, end?: string) {
@@ -60,6 +64,19 @@ function formatDateTimeRange(start?: string, end?: string) {
return `${datePart}${startHm}-${endDate.getFullYear()}年${pad(endDate.getMonth() + 1)}月${pad(endDate.getDate())}日${endHm}` return `${datePart}${startHm}-${endDate.getFullYear()}年${pad(endDate.getMonth() + 1)}月${pad(endDate.getDate())}日${endHm}`
} }
/** 接口时间格式 yyyy-MM-dd HH:mm:ss */
function formatApiDateTime(value?: string) {
const date = parseDate(value)
if (!date)
return ''
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/** 报告编号:RTK-项目id */
function buildReportNo(item: HistoryReport) {
return `RTK-${item.project_id}`
}
function formatDuration(value?: number) { function formatDuration(value?: number) {
const hours = value ?? 0 const hours = value ?? 0
const text = Number.isInteger(hours) ? String(hours) : hours.toFixed(1) const text = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
@@ -124,23 +141,88 @@ onLoad(async (options) => {
if (!id) if (!id)
return return
if (!db.history_report.length) if (!db.history_report.length || !db.project.length)
await db.loadAll() await db.loadAll()
const item = db.history_report.find(report => report.id === id) const item = db.history_report.find(report => report.id === id)
if (item) if (item) {
rawReport.value = item
reportData.value = mapReportDetail(item) reportData.value = mapReportDetail(item)
}
}) })
function handleDownload() { async function handleDownload() {
console.log('点击下载') if (uploading.value)
return
const item = rawReport.value
if (!item) {
uni.showToast({ title: '暂无报告数据', icon: 'none' })
return
}
if (!db.project.length)
await db.loadAll()
const project = db.project.find(p => p.id === item.project_id)
const projectName = project?.project_name
if (!projectName) {
uni.showToast({ title: '项目信息不完整', icon: 'none' })
return
}
const workStartTime = formatApiDateTime(item.start_time)
const workEndTime = formatApiDateTime(item.end_time)
if (!workStartTime || !workEndTime) {
uni.showToast({ title: '作业时间不完整', icon: 'none' })
return
}
const deviceId = getDeviceId() || item.device_id || ''
if (!deviceId) {
uni.showToast({ title: '设备号缺失', icon: 'none' })
return
}
uploading.value = true
uni.showLoading({ title: '上传中...', mask: true })
try {
const res = await uploadReport({
deviceId,
projectName,
reportNo: buildReportNo(item),
workStartTime,
workEndTime,
constructionSection: item.construction_area,
compactArea: item.compaction_area,
qualificationRate: item.qualified_rate,
workDuration: item.duration,
passCount: item.pass_count,
avgSpeed: item.avg_speed,
avgTemperature: item.avg_paving_temp,
avgCompaction: item.avg_compaction,
exceptionCount: item.exception_count,
})
const ok = res.code === 200 || res.code === 0
if (!ok)
throw new Error(res.msg || '上传失败')
uni.showToast({ title: '上传成功', icon: 'success' })
}
catch (e: any) {
uni.showToast({ title: e?.message || '上传失败', icon: 'none' })
}
finally {
uploading.value = false
uni.hideLoading()
}
} }
</script> </script>
<template> <template>
<view class="page"> <view class="page">
<back-nav title="报告详情"> <back-nav title="报告详情">
<wd-button size="large" variant="soft" icon="download" @tap="handleDownload" /> <wd-button size="large" variant="soft" icon="download" :loading="uploading" :disabled="uploading" @tap="handleDownload" />
</back-nav> </back-nav>
<view class="content"> <view class="content">