feat 激活码竞态处理
This commit is contained in:
@@ -31,17 +31,42 @@ export interface ReportItem {
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
/** POST /api/report/upload 请求体 */
|
||||
export interface UploadReportParams {
|
||||
/** 本地文件路径(uni.uploadFile) */
|
||||
filePath: string
|
||||
/** 本机出厂 SN */
|
||||
deviceId: string
|
||||
projectId: string
|
||||
/** 项目名称(文字,不是 ID);项目不存在时后端自动创建 */
|
||||
projectName: string
|
||||
/** 报告编号,全局唯一,如 RTP-20251215-001 */
|
||||
reportNo: string
|
||||
/** 作业开始时间 yyyy-MM-dd HH:mm:ss */
|
||||
workStartTime: string
|
||||
/** 作业结束时间 yyyy-MM-dd HH:mm:ss */
|
||||
workEndTime: string
|
||||
compactArea: string
|
||||
qualificationRate: string
|
||||
constructionSection: string
|
||||
/** 施工桩号,如 K1+200-K1+800 */
|
||||
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 报告列表 */
|
||||
@@ -61,38 +86,11 @@ export function getReportDetail(reportId: number | string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/report/upload 上传报告文件
|
||||
* 使用 uni.uploadFile,适用于 App / 小程序本地文件路径
|
||||
*/
|
||||
/** POST /api/report/upload 上传报告(JSON) */
|
||||
export function uploadReport(data: UploadReportParams) {
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || ''
|
||||
|
||||
return new Promise<ApiResult>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
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,
|
||||
})
|
||||
return request<ApiResult>({
|
||||
url: '/api/report/upload',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,13 +94,22 @@ let lastExpireShownAt = 0
|
||||
/**
|
||||
* 进入软件:先查本地激活码 → 无码再查授权状态并写入 storage
|
||||
* 激活成功后再查一次状态写入 storage,形成闭环
|
||||
* 仅未授权(0)或已过期(2)时展示 unauthorized / activateNotice
|
||||
* authStatus 为 1(已激活)时,unauthorized / activateNotice 一律不展示
|
||||
*/
|
||||
export function useDeviceAuth(options?: { watchShow?: boolean }) {
|
||||
const authStatus = computed(() => toAuthStatus(authInfo.value?.authStatus))
|
||||
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 deviceId = computed(() => authInfo.value?.deviceId || getDeviceId())
|
||||
const remainingDays = computed(() => {
|
||||
@@ -198,7 +207,10 @@ export function useDeviceAuth(options?: { watchShow?: boolean }) {
|
||||
activationCode.value = localCode.trim()
|
||||
const cached = readCachedAuthInfo()
|
||||
if (cached) {
|
||||
// 先用缓存让 UI 立即就绪
|
||||
authInfo.value = cached
|
||||
// 再异步向服务端核对一次,保证 authStatus 最新(如服务端已改为已激活)
|
||||
fetchAuthStatus()
|
||||
return authInfo.value
|
||||
}
|
||||
// 有激活码但无缓存信息,补拉一次状态
|
||||
@@ -257,9 +269,25 @@ export function useDeviceAuth(options?: { watchShow?: boolean }) {
|
||||
return res
|
||||
}
|
||||
|
||||
function refreshAuthStatus() {
|
||||
/**
|
||||
* 刷新授权状态:
|
||||
* 先同步本地缓存(立即反映变化),再异步向服务端查询一次,
|
||||
* 保证服务端已变为「已激活(1)」时,未授权提示组件能立即隐藏。
|
||||
*/
|
||||
async function refreshAuthStatus(): Promise<DeviceAuthStatus | null> {
|
||||
// 1) 先读本地缓存,保证 UI 即时响应
|
||||
authInfo.value = readCachedAuthInfo()
|
||||
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) {
|
||||
@@ -267,7 +295,6 @@ export function useDeviceAuth(options?: { watchShow?: boolean }) {
|
||||
refreshAuthStatus()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
authInfo,
|
||||
authStatus,
|
||||
|
||||
@@ -11,7 +11,7 @@ definePage({
|
||||
},
|
||||
})
|
||||
|
||||
const { needAuthPrompt, initAuth, openExpireNoticeIfNeeded } = useDeviceAuth({ watchShow: true })
|
||||
const { needAuthPrompt, initAuth, refreshAuthStatus, openExpireNoticeIfNeeded } = useDeviceAuth({ watchShow: true })
|
||||
|
||||
async function checkExpireNotice() {
|
||||
await nextTick()
|
||||
@@ -26,9 +26,11 @@ onMounted(async () => {
|
||||
await checkExpireNotice()
|
||||
})
|
||||
|
||||
/** 每次打开 / 回到首页:读 storage 判断是否弹到期提醒 */
|
||||
onShow(() => {
|
||||
checkExpireNotice()
|
||||
/** 每次打开 / 回到首页:重新查询授权状态 + 判断是否弹到期提醒 */
|
||||
onShow(async () => {
|
||||
// 查询到 authStatus 为 1 时,needAuthPrompt 会自动变 false,隐藏未授权组件
|
||||
await refreshAuthStatus()
|
||||
await checkExpireNotice()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import type { HistoryReport } from '@/service/historyReport'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import { uploadReport } from '@/api/report'
|
||||
import { useDbStore } from '@/store/modules/db'
|
||||
import { getDeviceId } from '@/utils/device'
|
||||
import storage from '@/utils/storage'
|
||||
|
||||
definePage({
|
||||
@@ -30,6 +32,8 @@ interface ReportDetailView {
|
||||
|
||||
const db = useDbStore()
|
||||
const reportData = ref<ReportDetailView | null>(null)
|
||||
const rawReport = ref<HistoryReport | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
function parseDate(value?: string) {
|
||||
if (!value)
|
||||
@@ -38,8 +42,8 @@ function parseDate(value?: string) {
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, '0')
|
||||
function pad(value: number, length = 2) {
|
||||
return String(value).padStart(length, '0')
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
/** 接口时间格式 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) {
|
||||
const hours = value ?? 0
|
||||
const text = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
|
||||
@@ -124,23 +141,88 @@ onLoad(async (options) => {
|
||||
if (!id)
|
||||
return
|
||||
|
||||
if (!db.history_report.length)
|
||||
if (!db.history_report.length || !db.project.length)
|
||||
await db.loadAll()
|
||||
|
||||
const item = db.history_report.find(report => report.id === id)
|
||||
if (item)
|
||||
if (item) {
|
||||
rawReport.value = item
|
||||
reportData.value = mapReportDetail(item)
|
||||
}
|
||||
})
|
||||
|
||||
function handleDownload() {
|
||||
console.log('点击下载')
|
||||
async function handleDownload() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<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>
|
||||
|
||||
<view class="content">
|
||||
|
||||
Reference in New Issue
Block a user