Compare commits

..

19 Commits

Author SHA1 Message Date
Ironsp
f4b0413e42 feat 对齐接口字段 2026-09-27 15:38:27 +08:00
Ironsp
d1b2d981b5 feat 联调下载pdf接口 2026-09-27 15:27:17 +08:00
Ironsp
54a5f96644 feat 联调报告上传接口 2026-09-27 15:21:39 +08:00
Ironsp
8ffbc10a4e feat 激活码竞态处理 2026-09-27 11:37:23 +08:00
Ironsp
7a24c6bfd0 feat 打印storage 2026-09-27 09:55:18 +08:00
Ironsp
5a7d56265c feat 查询添加版本号新字段 2026-09-27 08:28:44 +08:00
Ironsp
c7694df2cc feat 联调查询激活码接口 2026-09-24 15:12:38 +08:00
Ironsp
7eb6a626ad feat 联调 2026-09-24 14:10:01 +08:00
Ironsp
8a28f8654c feat 开发环隔离 2026-09-24 11:32:02 +08:00
Ironsp
5ab82d4571 feat 授权激活 2026-09-24 11:17:39 +08:00
Ironsp
4ff3e92049 feat 接口api 2026-09-23 10:52:28 +08:00
Ironsp
59379dff79 fix 修复安卓不展示svg的问题 2026-09-17 16:50:24 +08:00
Ironsp
65fc29f777 feat 数据流模拟 2026-09-17 16:33:54 +08:00
Ironsp
faf4ea1155 feat 对齐数据 2026-09-17 16:26:30 +08:00
Ironsp
c1d2958095 feat RTK热力图绘制 2026-09-17 16:20:57 +08:00
Ironsp
1ae0371c18 feat RTK轨迹绘制 2026-09-17 15:26:32 +08:00
Ironsp
449cf1f6c7 feat 联调历史报告表 2026-09-17 11:12:53 +08:00
Ironsp
0399e8ede4 feat 联调项目表 2026-09-17 10:41:07 +08:00
Ironsp
e803ffa38c feat readme.md 2026-09-17 09:19:24 +08:00
63 changed files with 3693 additions and 399 deletions

1
.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://192.168.0.136:8080

1
.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://192.168.0.136:8080

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
# TODO: 替换为正式环境 API 地址
VITE_API_BASE_URL=https://api.example.com

9
.gitignore vendored
View File

@@ -6,12 +6,21 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
doc
vite.config.ts.timestamp-*
node_modules
.DS_Store
dist
*.local
.omo
.clinerules
CLAUDE.md
.github
.vscode
.claude
.rtk
.workbuddy
# Editor directories and files
.idea

View File

@@ -1,10 +1,38 @@
<h2 align="center">
Road Roller - 压路机智能监控管理系统
</h2>
<p align="center">
<img src="https://github.com/uni-helper/vitesse-uni-app/raw/main/.github/images/preview.png" width="300"/>
基于 uni-app 的压路机作业监控与管理应用,支持实时 RTK 轨迹追踪、热力图可视化、蓝牙设备通信及本地数据持久化。
</p>
<h2 align="center">
uni-app
</h2>
## 项目简介
Road Roller 是一款面向道路施工场景的压路机智能监控应用,帮助施工团队实时掌握碾压作业状态、设备运行数据和施工质量。
### 核心功能
| 模块 | 说明 |
|------|------|
| **项目管理** | 创建/切换施工项目,跟踪项目进度、压实面积、合格率等核心指标 |
| **作业监控** | 实时记录碾压遍数、行驶速度、作业时长,支持多工作记录管理 |
| **RTK 轨迹** | 基于 RTK 定位的高精度轨迹追踪,支持轨迹回放与分析 |
| **热力图** | 将压实数据可视化为热力图,直观展示碾压覆盖情况 |
| **告警系统** | 数据中断、高温异常、定位异常等多类型告警,支持处置记录 |
| **历史报告** | 自动生成碾压报告,包含合格率、平均速度、平均压实度等统计 |
| **蓝牙通信** | 通过蓝牙连接压路机设备,实时采集传感器数据 |
| **本地存储** | SQLite 本地数据库,离线可用,数据自动同步 |
### 技术栈
- **框架**: uni-app (Vue 3 + TypeScript)
- **构建**: Vite + pnpm
- **UI**: Wot Design UI + UnoCSS
- **状态管理**: Pinia
- **数据库**: SQLite (plus.sqlite)
- **通信**: 蓝牙 (plus.Bluetooth)
- **定位**: GPS/RTK (plus.Geolocation)
## 特性
- ⚡️ [Vue 3](https://github.com/vuejs/core), [Vite](https://github.com/vitejs/vite), [pnpm](https://pnpm.io/), [esbuild](https://github.com/evanw/esbuild) - 就是快!
@@ -42,40 +70,33 @@ Road_roller/
│ │ ├── request.ts # Axios 请求封装(拦截器、基础配置)
│ │ └── home/ # 首页相关接口
│ ├── components/ # 公共组件(自动加载)
│ │ ├── AppFooter.vue
│ │ ├── AppLogos.vue
│ │ ├── HiCounter.vue
│ │ └── InputEntry.vue
│ │ ├── home/ # 首页组件(进度、概览、项目列表等)
│ │ ├── settings/ # 设置组件(项目表单等)
│ │ └── ...
│ ├── composables/ # 组合式函数(自动加载)
│ │ ├── useCount.ts # 计数器逻辑
│ │ └── useQuery.ts # 查询参数解析
│ ├── layouts/ # 页面布局组件
│ │ ├── default.vue # 默认布局
│ │ └── home.vue # 首页布局
│ ├── mock/ # 模拟数据
│ │ ├── index.ts # Mock 数据入口
│ │ └── time.ts # 时间相关 Mock
│ ├── pages/ # 页面文件(文件路由)
│ │ ├── home/ # 首页
│ │ ├── work/ # 工作页
│ │ └── report/ # 报告页
│ ├── static/ # 静态资源(图标、图片)
│ │ ├── Ahome/ # 首页(项目概览、进度、告警)
│ │ ├── work/ # 工作页(作业记录、实时监控)
│ │ ├── report/ # 报告页(历史报告、统计分析)
│ │ ├── project/ # 项目管理
│ │ ├── warning/ # 告警记录与详情
│ │ ├── heatmap/ # 热力图可视化
│ │ ├── rtk/ # RTK 轨迹追踪
│ │ ├── bluetooth/ # 蓝牙设备连接
│ │ ├── settings/ # 系统设置
│ │ └── message/ # 消息通知
│ ├── service/ # 数据库服务层(SQLite 表定义与 CRUD)
│ ├── store/ # Pinia 状态管理
│ │ └── index.js
│ ├── utils/ # 工具函数
│ ├── utils/ # 工具函数(sqlite、bluetooth、storage 等)
│ ├── App.vue # 根组件
│ ├── main.ts # 应用入口
│ ├── manifest.json # uni-app 应用配置
│ ├── pages.json # 页面路由配置(自动生成)
│ ├── theme.json # 主题变量(亮色/暗色)
│ └── uni.scss # uni-app 全局样式变量
│ └── manifest.json # uni-app 应用配置
├── eslint.config.js # ESLint 配置
├── manifest.config.ts # uni-app manifest 配置(源文件)
├── pages.config.ts # 页面路由配置(源文件)
├── unh.config.ts # uni-helper CLI 配置
├── uno.config.ts # UnoCSS 原子化 CSS 配置
├── vite.config.ts # Vite 构建配置
└── vitest.config.ts # Vitest 测试配置
└── vite.config.ts # Vite 构建配置
```
### 目录说明
@@ -86,10 +107,17 @@ Road_roller/
| `src/components` | 公共 Vue 组件,通过 vite-plugin-uni-components 自动加载 |
| `src/composables` | Vue 3 组合式函数,通过 unplugin-auto-import 自动导入 |
| `src/layouts` | 页面布局模板,通过 vite-plugin-uni-layouts 管理 |
| `src/mock` | 开发环境模拟数据 |
| `src/pages` | 页面组件,文件即路由(vite-plugin-uni-pages) |
| `src/static` | 静态资源,打包时原样复制到输出目录 |
| `src/store` | Pinia 状态管理,自动导入 |
| `src/utils` | 工具函数,自动导入 |
| `src/service` | 数据库服务层,包含 SQLite 表定义、CRUD 操作和种子数据 |
| `src/store` | Pinia 状态管理,统一数据源 |
| `src/utils` | 工具函数,包含 SQLite 封装、蓝牙通信、本地存储等 |
### 功能模块
### 数据库表结构
| 表名 | 说明 |
|------|------|
| `project` | 项目信息(名称、范围、面积、进度、状态) |
| `work_record` | 作业记录(时间、时长、压实面积、碾压遍数) |
| `rtk_track` | RTK 轨迹点(经纬度、速度、航向、压实度) |
| `warning_record` | 告警记录(异常类型、级别、处置记录) |
| `history_report` | 历史报告(统计数据、合格率、平均指标) |

View File

@@ -90,6 +90,6 @@
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
"*.{js,jsx,ts,tsx,mjs,cjs,mts,cts,vue}": "eslint --fix"
}
}

10
pnpm-lock.yaml generated
View File

@@ -3240,8 +3240,8 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001737:
resolution: {integrity: sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==}
caniuse-lite@1.0.30001812:
resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -10260,7 +10260,7 @@ snapshots:
autoprefixer@10.4.20(postcss@8.5.6):
dependencies:
browserslist: 4.25.3
caniuse-lite: 1.0.30001737
caniuse-lite: 1.0.30001812
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
@@ -10410,7 +10410,7 @@ snapshots:
browserslist@4.25.3:
dependencies:
caniuse-lite: 1.0.30001737
caniuse-lite: 1.0.30001812
electron-to-chromium: 1.5.209
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.3)
@@ -10482,7 +10482,7 @@ snapshots:
camelcase@6.3.0: {}
caniuse-lite@1.0.30001737: {}
caniuse-lite@1.0.30001812: {}
ccount@2.0.1: {}

View File

@@ -1,5 +1,18 @@
<script setup lang="ts">
import { useDeviceAuth } from '@/composables/useDeviceAuth'
import { initTables } from '@/service'
import storage from '@/utils/storage'
const { initAuth } = useDeviceAuth()
// 开发环境把 storage 调试方法挂到全局,控制台可直接调用
// 用法:storagePrint() / storageAll()
if (import.meta.env.DEV) {
;(globalThis as any).storagePrint = () => storage.print()
;(globalThis as any).storageAll = () => storage.all()
// 启动时自动打印一次,不想看就把这行注释掉
storage.print()
}
onLaunch(async () => {
try {
@@ -9,6 +22,13 @@ onLaunch(async () => {
catch (err) {
console.error('数据库初始化失败:', err)
}
try {
await initAuth()
}
catch (err) {
console.error('设备授权检测失败:', err)
}
})
</script>

54
src/api/device/index.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { ApiResult, AuthStatus } from '../types'
import { request } from '../request'
export interface DeviceAuthStatus {
deviceId: string
deviceName: string
authStatus: AuthStatus | string
startDate: string
endDate: string
remainingDays: number
latestVersionNo: string
}
export interface ActivateDeviceResult extends ApiResult {
endDate: string
deviceId: string
}
export interface RenewDeviceParams {
activationCode: string
deviceId: string
}
export interface RenewDeviceResult extends ApiResult {
endDate: string
deviceId: string
}
/** POST /api/device/activate 激活设备 */
export function activateDevice(params: { activationCode?: string, deviceId?: string }) {
return request<ActivateDeviceResult>({
url: '/api/device/activate',
method: 'POST',
params,
})
}
/** GET /api/device/status 查询设备授权状态 */
export function getDeviceStatus(deviceId?: string) {
return request<ApiResult<DeviceAuthStatus>>({
url: '/api/device/status',
method: 'GET',
params: deviceId ? { deviceId } : undefined,
})
}
/** POST /api/device/renew 续期 */
export function renewDevice(data: RenewDeviceParams) {
return request<RenewDeviceResult>({
url: '/api/device/renew',
method: 'POST',
data,
})
}

View File

101
src/api/report/index.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { ApiResult, ReportStatus } from '../types'
import { request } from '../request'
export interface ReportItem {
reportId: number
reportNo: string
deviceId: string
projectId: number
projectName: string
constructionSection: string
compactArea: number
qualificationRate: number
reportStatus: ReportStatus | string
workStartTime: string
workEndTime: string
workDuration?: number | null
avgAmplitude?: number | null
avgCompaction?: number | null
avgFrequency?: number | null
avgSpeed?: number | null
avgTemperature?: number | null
passCount?: number | null
exceptionCount?: number | null
conclusion?: string | null
filePath?: string | null
generateTime?: string | null
createBy?: string | null
createTime?: string | null
updateBy?: string | null
updateTime?: string | null
remark?: string | null
}
/** POST /api/report/upload 请求体 */
export interface UploadReportParams {
/** 本机出厂 SN */
deviceId: 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
/** 施工桩号,如 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 报告列表 */
export function getReportList() {
return request({
url: '/api/report/list',
method: 'GET',
})
}
/** GET /api/report/{reportId} 报告详情 */
export function getReportDetail(reportId: number | string) {
return request<ApiResult<ReportItem>>({
url: `/api/report/${reportId}`,
method: 'GET',
})
}
/** POST /api/report/upload 上传报告数据(JSON) */
export function uploadReport(data: UploadReportParams) {
return request<ApiResult>({
url: '/api/report/upload',
method: 'POST',
data,
})
}
/** GET /api/report/download/{reportId} 下载地址 */
export function getReportDownloadUrl(reportId: number | string) {
const baseURL = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '')
return `${baseURL}/api/report/download/${reportId}`
}

View File

@@ -2,47 +2,26 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios'
import { createUniAppAxiosAdapter } from '@uni-helper/axios-adapter'
import axios from 'axios'
// 创建自定义实例
const instance = axios.create({
baseURL: 'https://api.example.com', // 你的基础域名
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
adapter: createUniAppAxiosAdapter(), // 指定 uniapp 适配器
adapter: createUniAppAxiosAdapter(),
})
// 请求拦截器(守卫)
instance.interceptors.request.use(
(config) => {
// 例如:在这里统一添加 Token
// const token = uni.getStorageSync('token');
// if (token) config.headers.Authorization = `Bearer ${token}`;
return config
},
error => Promise.reject(error),
)
// 响应拦截器(守卫)
instance.interceptors.response.use(
(response: AxiosResponse) => {
// 直接返回解包后的 data,这样调用时就不需要写 res.data.data 了
return response.data
},
// (error) => {
// // 统一错误处理
// uni.showToast({ title: error.message || '请求失败', icon: 'none' })
// return Promise.reject(error)
// },
(error: any) => {
let { message } = error
if (message === 'Network Error') {
if (message === 'Network Error')
message = '后端接口连接异常'
}
else if (message.includes('timeout')) {
else if (message?.includes('timeout'))
message = '系统接口请求超时'
}
else if (message.includes('Request failed with status code')) {
else if (message?.includes('Request failed with status code'))
message = `系统接口${message.substr(message.length - 3)}异常`
}
ElMessage({ message, type: 'error', duration: 5 * 1000 })
uni.showToast({ title: message || '请求失败', icon: 'none', duration: 3000 })
return Promise.reject(error)
},
)

20
src/api/types.ts Normal file
View File

@@ -0,0 +1,20 @@
/** 通用接口响应 */
export interface ApiResult<T = unknown> {
code: number
msg: string
data?: T
}
/** 分页列表响应 */
export interface PageResult<T = unknown> {
code: number
msg: string
total: number
rows: T[]
}
/** 授权状态:0=未激活,1=已激活,2=已过期,3=已禁用 */
export type AuthStatus = '0' | '1' | '2' | '3'
/** 报告状态:0=已完成,1=有异常 */
export type ReportStatus = '0' | '1'

10
src/auto-imports.d.ts vendored
View File

@@ -40,6 +40,7 @@ declare global {
const extendRef: typeof import('@vueuse/core')['extendRef']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getDeviceId: typeof import('./utils/device')['getDeviceId']
const getDeviceInfoFromStorage: typeof import('./utils/device')['getDeviceInfoFromStorage']
const getDeviceTypeName: typeof import('./utils/device')['getDeviceTypeName']
const getOrInitDeviceInfo: typeof import('./utils/device')['getOrInitDeviceInfo']
@@ -104,6 +105,7 @@ declare global {
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const publishRtkPoint: typeof import('./composables/useRtkLive')['publishRtkPoint']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
@@ -191,6 +193,7 @@ declare global {
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceAuth: typeof import('./composables/useDeviceAuth')['useDeviceAuth']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
@@ -261,10 +264,12 @@ declare global {
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useProjectLifecycle: typeof import('./composables/useProjectLifecycle')['useProjectLifecycle']
const useQuery: typeof import('./composables/useQuery')['useQuery']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRtkLive: typeof import('./composables/useRtkLive')['useRtkLive']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
@@ -388,6 +393,7 @@ declare module 'vue' {
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getDeviceId: UnwrapRef<typeof import('./utils/device')['getDeviceId']>
readonly getDeviceInfoFromStorage: UnwrapRef<typeof import('./utils/device')['getDeviceInfoFromStorage']>
readonly getDeviceTypeName: UnwrapRef<typeof import('./utils/device')['getDeviceTypeName']>
readonly getOrInitDeviceInfo: UnwrapRef<typeof import('./utils/device')['getOrInitDeviceInfo']>
@@ -452,6 +458,7 @@ declare module 'vue' {
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly publishRtkPoint: UnwrapRef<typeof import('./composables/useRtkLive')['publishRtkPoint']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
@@ -539,6 +546,7 @@ declare module 'vue' {
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
readonly useDeviceAuth: UnwrapRef<typeof import('./composables/useDeviceAuth')['useDeviceAuth']>
readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
@@ -609,10 +617,12 @@ declare module 'vue' {
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useProjectLifecycle: UnwrapRef<typeof import('./composables/useProjectLifecycle')['useProjectLifecycle']>
readonly useQuery: UnwrapRef<typeof import('./composables/useQuery')['useQuery']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRtkLive: UnwrapRef<typeof import('./composables/useRtkLive')['useRtkLive']>
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>

7
src/components.d.ts vendored
View File

@@ -11,7 +11,13 @@ declare module 'vue' {
BluetoothConnect: typeof import('./components/bluetooth/connect.vue')['default']
DeviceCard: typeof import('./components/deviceCard.vue')['default']
DeviceInfo: typeof import('./components/deviceInfo.vue')['default']
ExpireNotice: typeof import('./components/expireNotice.vue')['default']
FullscreenNav: typeof import('./components/FullscreenNav.vue')['default']
HeatmapCanvas: typeof import('./components/heatmap/canvas.vue')['default']
HeatmapLayerPanel: typeof import('./components/heatmap/layerPanel.vue')['default']
HeatmapLegend: typeof import('./components/heatmap/legend.vue')['default']
HeatmapPointDetail: typeof import('./components/heatmap/pointDetail.vue')['default']
HomeActivateNotice: typeof import('./components/home/activateNotice.vue')['default']
HomeConstruction: typeof import('./components/home/construction.vue')['default']
HomeHomeNav: typeof import('./components/home/homeNav.vue')['default']
HomeList: typeof import('./components/home/list.vue')['default']
@@ -35,6 +41,7 @@ declare module 'vue' {
SettingsSensorConfig: typeof import('./components/settings/sensorConfig.vue')['default']
SettingsWifiNetwork: typeof import('./components/settings/wifiNetwork.vue')['default']
SettingsWifiSettings: typeof import('./components/settings/wifiSettings.vue')['default']
Unauthorized: typeof import('./components/unauthorized.vue')['default']
WarningFilter: typeof import('./components/warning/filter.vue')['default']
WarningItem: typeof import('./components/warning/item.vue')['default']
WorkDashboard: typeof import('./components/work/dashboard.vue')['default']

View File

@@ -1,22 +1,18 @@
<script setup lang="ts">
// RTK 轨迹实时监测 - 状态头部组件
import { useRtkLive } from '@/composables/useRtkLive'
interface Props {
deviceName?: string
speed?: number
frequency?: number
amplitude?: number
rtkFixed?: boolean
}
withDefaults(defineProps<Props>(), {
deviceName: '徐工XS65H #01',
speed: 4.2,
frequency: 28.5,
amplitude: 1.86,
rtkFixed: true,
})
const { speedTextSpaced } = useRtkLive()
function handleToggle() {
uni.switchTab({
url: '/pages/work/index',
@@ -54,25 +50,25 @@ function handleToggle() {
车速
</text>
<text class="data-value">
{{ speed }} km/h
{{ speedTextSpaced }}
</text>
</view>
<view class="data-item">
<!-- <view class="data-item">
<text class="data-label">
振动频率
</text>
<text class="data-value">
{{ frequency }} HZ
{{ frequencyText }}
</text>
</view>
<view class="data-item">
</view> -->
<!-- <view class="data-item">
<text class="data-label">
振幅
</text>
<text class="data-value">
{{ amplitude }} mm
{{ amplitudeText }}
</text>
</view>
</view> -->
<view class="rtk-status">
<view class="rtk-dot" :class="{ 'rtk-dot--active': rtkFixed }" />
<text class="rtk-text">

View File

@@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useDeviceAuth } from '@/composables/useDeviceAuth'
const deviceAuth = useDeviceAuth()
const { authInfo, remainingDays, closeExpireNotice } = deviceAuth
/** 确保 App/H5 上 v-model 能正确读写弹窗显隐 */
const visible = computed({
get: () => deviceAuth.expireNoticeVisible.value,
set: (value: boolean) => {
deviceAuth.expireNoticeVisible.value = value
},
})
const days = computed(() => {
const value = remainingDays.value
return value != null && value >= 0 ? value : 0
})
const deviceName = computed(() => authInfo.value?.deviceName || '--')
const deviceIdText = computed(() => authInfo.value?.deviceId || '--')
const endDateText = computed(() => formatEndDate(authInfo.value?.endDate))
function formatEndDate(value?: string) {
if (!value)
return '--'
const date = new Date(value.replace(/-/g, '/'))
if (Number.isNaN(date.getTime())) {
if (/年/.test(value))
return value
return value
}
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
}
function handleClose() {
closeExpireNotice()
}
</script>
<template>
<wd-popup
v-model="visible"
position="center"
close-on-click-modal
root-portal
:z-index="999"
@close="handleClose"
>
<view class="expire-panel">
<view class="expire-header">
<view class="header-left">
<wd-icon name="warning" size="22px" color="#F04848" />
<text class="header-title">
设备到期提醒
</text>
</view>
<view class="header-close" @tap.stop="handleClose">
<wd-icon name="close" size="20px" color="#999999" />
</view>
</view>
<view class="expire-body">
<view class="warn-box">
<view class="warn-header">
<view class="lock">
<view class="lock-shackle" />
<view class="lock-body">
<view class="lock-dot" />
<view class="lock-slot" />
</view>
</view>
<view class="warn-main">
<text class="warn-title">
设备授权即将到期
</text>
<view class="days-badge">
<text class="days-text">
剩余{{ days }}天
</text>
</view>
</view>
</view>
<text class="warn-desc">
设备授权将于 {{ days }} 天后到期,到期后将锁定全部作业功能,请及时续期以免影响正常作业
</text>
</view>
<view class="info-row">
<text class="info-name">
{{ deviceName }}
</text>
<text class="info-id">
设备ID: {{ deviceIdText }}
</text>
</view>
<view class="info-row info-row--expire">
<text class="expire-label">
授权到期日期
</text>
<text class="expire-value">
{{ endDateText }}
</text>
</view>
</view>
</view>
</wd-popup>
</template>
<style scoped>
.expire-panel {
width: 560px;
max-width: 90vw;
background-color: #ffffff;
border-radius: 16px;
overflow: hidden;
box-sizing: border-box;
}
.expire-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 0;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.header-close {
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.expire-body {
text-align: left;
width: 100%;
padding: 12px 16px 16px;
box-sizing: border-box;
}
.warn-box {
background-color: #FFF1F0;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
}
.warn-header {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
}
.lock {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
margin-top: 2px;
}
.lock-shackle {
width: 14px;
height: 10px;
border: 2px solid #F04848;
border-bottom: none;
border-radius: 8px 8px 0 0;
box-sizing: border-box;
}
.lock-body {
width: 20px;
height: 14px;
border: 2px solid #F04848;
border-radius: 3px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: -1px;
}
.lock-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background-color: #F04848;
}
.lock-slot {
width: 1.5px;
height: 3px;
background-color: #F04848;
border-radius: 1px;
margin-top: -0.5px;
}
.warn-main {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.warn-title {
font-size: 16px;
font-weight: bold;
color: #F04848;
line-height: 22px;
}
.days-badge {
background-color: #FFE4E1;
border-radius: 20px;
padding: 2px 10px;
}
.days-text {
font-size: 12px;
color: #F04848;
line-height: 18px;
}
.warn-desc {
display: block;
font-size: 13px;
color: #F04848;
line-height: 20px;
}
.info-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
background-color: #f5f5f5;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 8px;
}
.info-row--expire {
margin-bottom: 0;
}
.info-name {
display: block;
font-size: 14px;
font-weight: bold;
color: #333333;
}
.info-id {
display: block;
font-size: 12px;
color: #999999;
}
.expire-label,
.expire-value {
display: block;
font-size: 14px;
color: #F04848;
}
.expire-value {
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,209 @@
<script setup lang="ts">
import type { HeatmapLayerKey } from './heatUtils'
/**
* 压实热力图网格:
* - 36 列固定;行数 = ceil(数据量 / 36),随数据渐进增加
* - 每个数据占一格;格子间距固定一致
* - 色阶:0–90 绿,90–95 黄,95–100 红
*/
import type { MockRtkPoint } from '@/mock/rtkTrack'
import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { publishRtkPoint } from '@/composables/useRtkLive'
import { createMockRtkStream } from '@/mock/rtkTrack'
import { colorByScore, layerScore } from './heatUtils'
const props = defineProps<{
layer: HeatmapLayerKey
}>()
const emit = defineEmits<{
selectPoint: [point: MockRtkPoint]
}>()
const instance = getCurrentInstance()?.proxy
const CANVAS_ID = 'heatmapCanvas'
const COLS = 36
const GAP = 3
const PADDING = 12
const MIN_ROWS = 8
const MIN_H = 200
const canvasHeight = ref(MIN_H)
interface HeatCell {
point: MockRtkPoint
score: number
col: number
row: number
}
const cells: HeatCell[] = []
let cssWidth = 0
let cssHeight = MIN_H
let ready = false
let stream = createMockRtkStream({ intervalMs: 50, loop: false, rows: 40, pointsPerRow: 36 })
function rowCount() {
if (!cells.length)
return MIN_ROWS
return Math.max(MIN_ROWS, Math.ceil(cells.length / COLS))
}
function layoutMetrics() {
const rows = rowCount()
const innerW = Math.max(cssWidth - PADDING * 2, 1)
const cellW = (innerW - GAP * (COLS - 1)) / COLS
const cellH = cellW
const contentH = PADDING * 2 + rows * cellH + GAP * Math.max(0, rows - 1)
return { rows, cellW, cellH, contentH }
}
function syncHeight() {
if (cssWidth <= 0)
return
const { contentH } = layoutMetrics()
const next = Math.max(MIN_H, Math.round(contentH))
if (next !== cssHeight) {
cssHeight = next
canvasHeight.value = next
}
}
function cellRect(col: number, row: number, cellW: number, cellH: number) {
return {
x: PADDING + col * (cellW + GAP),
y: PADDING + row * (cellH + GAP),
w: cellW,
h: cellH,
}
}
function draw() {
if (!ready || cssWidth <= 0)
return
const ctx = uni.createCanvasContext(CANVAS_ID, instance as any)
const { cellW, cellH } = layoutMetrics()
ctx.setFillStyle('#f8fafc')
ctx.fillRect(0, 0, cssWidth, cssHeight)
for (const cell of cells) {
const { x, y, w, h } = cellRect(cell.col, cell.row, cellW, cellH)
ctx.setFillStyle(colorByScore(cell.score))
ctx.fillRect(x, y, w, h)
}
ctx.draw()
}
function appendPoint(point: MockRtkPoint) {
const index = cells.length
cells.push({
point,
score: layerScore(props.layer, point),
col: index % COLS,
row: Math.floor(index / COLS),
})
syncHeight()
nextTick(() => draw())
}
function refreshScores() {
for (const c of cells)
c.score = layerScore(props.layer, c.point)
draw()
}
function startStream() {
stream.stop()
stream = createMockRtkStream({ intervalMs: 50, loop: false, rows: 40, pointsPerRow: 36 })
cells.length = 0
syncHeight()
draw()
stream.start((point) => {
appendPoint(point)
publishRtkPoint(point)
})
}
function measureAndStart() {
uni.createSelectorQuery()
.in(instance as any)
.select('.heatmap-canvas')
.boundingClientRect((rect) => {
const box = Array.isArray(rect) ? rect[0] : rect
const w = Number(box?.width) || 0
if (w <= 0) {
setTimeout(() => measureAndStart(), 50)
return
}
cssWidth = w
ready = true
startStream()
})
.exec()
}
function onTap(e: any) {
if (!cells.length || cssWidth <= 0)
return
const x = Number(e?.detail?.x ?? e?.touches?.[0]?.x ?? 0)
const y = Number(e?.detail?.y ?? e?.touches?.[0]?.y ?? 0)
const { cellW, cellH } = layoutMetrics()
const col = Math.floor((x - PADDING) / (cellW + GAP))
const row = Math.floor((y - PADDING) / (cellH + GAP))
if (col < 0 || col >= COLS || row < 0)
return
// 点落在 gap 上则忽略
const localX = (x - PADDING) - col * (cellW + GAP)
const localY = (y - PADDING) - row * (cellH + GAP)
if (localX > cellW || localY > cellH)
return
const found = cells.find(c => c.col === col && c.row === row)
if (found)
emit('selectPoint', found.point)
}
watch(() => props.layer, () => refreshScores())
onMounted(() => {
nextTick(() => measureAndStart())
})
onUnmounted(() => {
stream.stop()
ready = false
})
</script>
<template>
<view class="heatmap-canvas" :style="{ height: `${canvasHeight}px` }" @tap="onTap">
<canvas
:id="CANVAS_ID"
:canvas-id="CANVAS_ID"
class="canvas"
:style="{ width: '100%', height: `${canvasHeight}px` }"
/>
</view>
</template>
<style scoped>
.heatmap-canvas {
width: 100%;
/* background: #f8fafc; */
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.canvas {
width: 100%;
display: block;
}
</style>

View File

@@ -0,0 +1,67 @@
/** 热力图图层与色阶工具(字段与 RtkTrack / MockRtkPoint 对齐) */
import type { MockRtkPoint } from '@/mock/rtkTrack'
export type HeatmapLayerKey = 'compaction_value' | 'speed' | 'steel_wheel_temp' | 'pass_count'
export const HEATMAP_LAYERS: { key: HeatmapLayerKey, label: string }[] = [
{ key: 'compaction_value', label: '压实度' },
{ key: 'speed', label: '压实速度' },
{ key: 'steel_wheel_temp', label: '温度' },
{ key: 'pass_count', label: '碾压遍数' },
]
/** 色阶阈值:0–90 绿,90–95 黄,95–100 红 */
export const HEAT_THRESHOLD_YELLOW = 90
export const HEAT_THRESHOLD_RED = 95
export function colorByScore(score: number): string {
if (score >= HEAT_THRESHOLD_RED)
return '#ef4444'
if (score >= HEAT_THRESHOLD_YELLOW)
return '#eab308'
return '#22c55e'
}
/** 各图层原始值映射到 0–100 分,统一用 90/95 分色 */
export function layerScore(layer: HeatmapLayerKey, point: MockRtkPoint): number {
switch (layer) {
case 'compaction_value':
return point.compaction_value
case 'speed': {
const ideal = 4.2
const diff = Math.abs(point.speed - ideal)
return Math.max(0, Math.min(100, 100 - diff * 18))
}
case 'steel_wheel_temp': {
const ideal = 150
const diff = Math.abs(point.steel_wheel_temp - ideal)
return Math.max(0, Math.min(100, 100 - diff * 2.5))
}
case 'pass_count':
return Math.max(0, Math.min(100, (point.pass_count / 6) * 100))
default:
return 0
}
}
export function layerRawText(layer: HeatmapLayerKey, point: MockRtkPoint): string {
switch (layer) {
case 'compaction_value':
return `${point.compaction_value}%`
case 'speed':
return `${point.speed}km/h`
case 'steel_wheel_temp':
return `${point.steel_wheel_temp}℃`
case 'pass_count':
return `${point.pass_count}遍`
default:
return '-'
}
}
export function headingText(heading: number): string {
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const idx = Math.round(heading / 45) % 8
return `${dirs[idx]} ${heading.toFixed(1)}°`
}

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { HeatmapLayerKey } from './heatUtils'
import { HEATMAP_LAYERS } from './heatUtils'
const props = defineProps<{
modelValue: HeatmapLayerKey
visible: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [key: HeatmapLayerKey]
'update:visible': [v: boolean]
}>()
function select(key: HeatmapLayerKey) {
emit('update:modelValue', key)
emit('update:visible', false)
}
function toggle() {
emit('update:visible', !props.visible)
}
</script>
<template>
<view class="layer-wrap">
<view class="layer-btn" @tap.stop="toggle">
<text class="layer-btn-text">
图层切换
</text>
</view>
<view v-if="visible" class="layer-panel" @tap.stop>
<text class="panel-title">
图层切换
</text>
<view
v-for="item in HEATMAP_LAYERS"
:key="item.key"
class="layer-item"
:class="{ active: modelValue === item.key }"
@tap="select(item.key)"
>
<text class="layer-item-text">
{{ item.label }}
</text>
</view>
</view>
</view>
</template>
<style scoped>
.layer-wrap {
position: relative;
/* z-index: 20; */
}
.layer-btn {
background: #fff;
border-radius: 8px;
padding: 10px 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.layer-btn-text {
font-size: 14px;
color: #333;
font-weight: 600;
}
.layer-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
min-width: 140px;
background: #fff;
border-radius: 10px;
padding: 10px 0;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.panel-title {
display: block;
padding: 4px 16px 10px;
font-size: 13px;
/* color: #999; */
}
.layer-item {
padding: 10px 16px;
}
.layer-item.active {
background: #eff6ff;
}
.layer-item-text {
font-size: 15px;
color: #333;
}
.layer-item.active .layer-item-text {
color: #2563eb;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { HeatmapLayerKey } from './heatUtils'
import { computed } from 'vue'
import { HEATMAP_LAYERS } from './heatUtils'
const props = defineProps<{
layer: HeatmapLayerKey
}>()
const title = computed(() => {
const found = HEATMAP_LAYERS.find(i => i.key === props.layer)
return `${found?.label || '压实度'}色阶`
})
</script>
<template>
<view class="legend">
<text class="legend-title">
{{ title }}
</text>
<view class="bar-row">
<view class="bar">
<view class="seg seg-green" />
<view class="seg seg-yellow" />
<view class="seg seg-red" />
</view>
</view>
<view class="marks">
<text class="mark">
0
</text>
<text class="mark mark-90">
90
</text>
<text class="mark mark-95">
95
</text>
<text class="mark">
100
</text>
</view>
</view>
</template>
<style scoped>
.legend {
padding: 12px 4px 4px;
}
.legend-title {
font-size: 13px;
color: #666;
margin-bottom: 8px;
display: block;
}
.bar-row {
width: 100%;
}
.bar {
display: flex;
height: 12px;
border-radius: 6px;
overflow: hidden;
}
.seg {
height: 100%;
}
.seg-green {
flex: 90;
background: #22c55e;
}
.seg-yellow {
flex: 5;
background: #eab308;
}
.seg-red {
flex: 5;
background: #ef4444;
}
.marks {
position: relative;
height: 18px;
margin-top: 4px;
}
.mark {
position: absolute;
font-size: 11px;
color: #999;
transform: translateX(-50%);
}
.mark:first-child {
left: 0;
transform: none;
}
.mark:last-child {
right: 0;
left: auto;
transform: none;
}
.mark-90 {
left: 90%;
}
.mark-95 {
left: 95%;
}
</style>

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import type { MockRtkPoint } from '@/mock/rtkTrack'
import { headingText } from './heatUtils'
defineProps<{
visible: boolean
point: MockRtkPoint | null
}>()
const emit = defineEmits<{
close: []
}>()
</script>
<template>
<view v-if="visible && point" class="mask" @tap="emit('close')">
<view class="card" @tap.stop>
<view class="card-head">
<view class="pin" />
<view class="head-text">
<text class="title">
点位详情
</text>
<text class="sub">
点击地图任意位置查看
</text>
</view>
</view>
<view class="row">
<text class="label">
点位坐标
</text>
<text class="value">
{{ point.longitude.toFixed(4) }}°E, {{ point.latitude.toFixed(4) }}°N
</text>
</view>
<view class="row">
<text class="label">
航向角
</text>
<text class="value">
{{ headingText(point.heading) }}
</text>
</view>
<view class="row">
<text class="label">
压实速度
</text>
<text class="value">
{{ point.speed }}km/h
</text>
</view>
<view class="row">
<text class="label">
压实度
</text>
<text class="value">
{{ point.compaction_value }}%
</text>
</view>
<view class="row">
<text class="label">
温度
</text>
<text class="value">
{{ point.steel_wheel_temp }}℃
</text>
</view>
<view class="row">
<text class="label">
碾压遍数
</text>
<text class="value">
{{ point.pass_count }}遍
</text>
</view>
<view class="row">
<text class="label">
合格率
</text>
<text class="value">
{{ point.qualified_rate }}%
</text>
</view>
<text class="hint">
点击空白处关闭
</text>
</view>
</view>
</template>
<style scoped>
.mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.25);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.card {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 12px;
padding: 16px;
}
.card-head {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
}
.pin {
width: 18px;
height: 18px;
margin-top: 2px;
border-radius: 50% 50% 50% 0;
background: #2563eb;
transform: rotate(-45deg);
}
.head-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
}
.sub {
font-size: 12px;
color: #999;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
}
.label {
font-size: 14px;
color: #666;
}
.value {
font-size: 14px;
color: #111;
font-weight: 600;
}
.hint {
display: block;
text-align: right;
margin-top: 12px;
font-size: 12px;
color: #999;
}
</style>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import { useDialog, useToast } from '@wot-ui/ui'
import { ref } from 'vue'
import { useDeviceAuth } from '@/composables/useDeviceAuth'
const dialog = useDialog('activate-dialog')
const toast = useToast('activate-toast')
const { deviceId, activate } = useDeviceAuth()
const activationCode = ref('')
function goActivate() {
activationCode.value = ''
dialog
.show({
title: '设备授权激活',
showClose: true,
showCancelButton: false,
confirmButtonText: '激活',
closeOnClickModal: true,
beforeConfirm: async () => {
const code = activationCode.value.trim()
if (!code) {
toast.show('请输入授权码')
return false
}
try {
await activate(code)
toast.success('激活成功')
return true
}
catch (error: unknown) {
// Axios 错误已由 request 拦截器提示,此处只提示业务/校验错误
const isAxios = typeof error === 'object' && error !== null && 'config' in error
if (!isAxios && error instanceof Error)
toast.show(error.message)
return false
}
},
})
.catch(() => {})
}
</script>
<template>
<view class="card">
<text class="card-title">
设备未授权激活
</text>
<view class="notice-box">
<text class="notice-title">
检测到 1 台新设备待激活
</text>
<text class="notice-desc">
请输入设备ID完成授权激活后,方可获取该设备ID获取压实数据及位置信息
</text>
</view>
<view class="card-footer">
<wd-button type="primary" size="large" round @tap="goActivate">
立即激活
</wd-button>
</view>
<wd-dialog selector="activate-dialog">
<view class="activate-body">
<text class="device-label">
设备ID
</text>
<view class="device-id-box">
<text class="device-id-text">
{{ deviceId }}
</text>
</view>
<wd-input
v-model="activationCode"
prefix-icon="unlock"
clearable
/>
</view>
</wd-dialog>
<wd-toast selector="activate-toast" />
</view>
</template>
<style scoped>
.card {
background-color: #ffffff;
border-radius: 12px;
margin-bottom: 12px;
padding: 16px;
overflow: hidden;
}
.card-title {
display: block;
font-size: 22px;
font-weight: bold;
color: #333333;
margin-bottom: 12px;
}
.notice-box {
background-color: #E8F3FF;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.notice-title {
display: block;
font-size: 16px;
font-weight: bold;
color: #2B6DE5;
margin-bottom: 8px;
line-height: 24px;
}
.notice-desc {
display: block;
font-size: 14px;
color: #5B8DEF;
line-height: 22px;
}
.card-footer {
display: flex;
justify-content: flex-end;
}
.activate-body {
text-align: left;
}
.device-label {
display: block;
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 8px;
}
.device-id-box {
background-color: #f5f5f5;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 24px;
}
.device-id-text {
font-size: 16px;
font-weight: bold;
color: #333333;
word-break: break-all;
}
</style>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { useCurrentProject } from '@/composables/useCurrentProject'
import { useDeviceAuth } from '@/composables/useDeviceAuth'
const { hasCurrentProject, currentProject } = useCurrentProject()
const { needAuthPrompt } = useDeviceAuth()
</script>
<template>
@@ -12,7 +14,8 @@ const { hasCurrentProject, currentProject } = useCurrentProject()
</text>
</view>
<view class="card-body">
<view class="construction-info">
<unauthorized v-if="needAuthPrompt" />
<view v-else class="construction-info">
<view class="icon-placeholder" />
<view class="info">
<text class="segment">

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import type { Project } from '@/service/project'
import { useProjectLifecycle } from '@/composables/useProjectLifecycle'
const props = defineProps<{
item: Project
}>()
const { startProject, pauseProject, endProject } = useProjectLifecycle()
const statusMap: Record<number, string> = {
0: '已暂停',
1: '已结束',
@@ -30,6 +33,21 @@ function formatArea(value?: number) {
const text = Number.isInteger(area) ? String(area) : String(area)
return `${text}m²`
}
function onStart() {
if (props.item.id)
startProject(props.item.id)
}
function onPause() {
if (props.item.id)
pauseProject(props.item.id)
}
function onEnd() {
if (props.item.id)
endProject(props.item.id)
}
</script>
<template>
@@ -74,14 +92,14 @@ function formatArea(value?: number) {
</text>
</view>
</view>
<view v-if="item.status !== 1" class="project-actions">
<wd-button size="large" round>
<view v-if="item.status !== 1" class="project-actions" @tap.stop>
<wd-button size="large" round @tap.stop="onStart">
开始项目
</wd-button>
<wd-button type="warning" size="large" round>
<wd-button type="warning" size="large" round @tap.stop="onPause">
暂停项目
</wd-button>
<wd-button type="danger" size="large" round>
<wd-button type="danger" size="large" round @tap.stop="onEnd">
结束项目
</wd-button>
</view>

View File

@@ -4,21 +4,27 @@ import { useCurrentProject } from '@/composables/useCurrentProject'
import { useDbStore } from '@/store/modules/db'
const db = useDbStore()
const { hasCurrentProject } = useCurrentProject()
const { hasCurrentProject, currentProjectId } = useCurrentProject()
const now = new Date()
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`
const projectWorkRecords = computed(() => {
if (!hasCurrentProject.value || currentProjectId.value == null)
return []
return (db.work_record || []).filter(item => item.project_id === currentProjectId.value)
})
const totalCompactionArea = computed(() => {
if (!hasCurrentProject.value)
return null
return (db.work_record || []).reduce((sum, item) => sum + (item.compaction_area ?? 0), 0)
return projectWorkRecords.value.reduce((sum, item) => sum + (item.compaction_area ?? 0), 0)
})
const totalDuration = computed(() => {
if (!hasCurrentProject.value)
return null
return (db.work_record || []).reduce((sum, item) => sum + (item.duration ?? 0), 0)
return projectWorkRecords.value.reduce((sum, item) => sum + (item.duration ?? 0), 0)
})
const onlineCount = computed(() => {
@@ -40,8 +46,9 @@ function formatCount(value: number | null) {
}
onMounted(async () => {
if (!db.work_record?.length)
await db.loadAll()
if (!hasCurrentProject.value || currentProjectId.value == null)
return
await db.loadByProject(currentProjectId.value)
})
</script>

View File

@@ -1,22 +1,20 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useCurrentProject } from '@/composables/useCurrentProject'
import { useDeviceAuth } from '@/composables/useDeviceAuth'
import { calcUncompactedArea } from '@/service/project'
import { useDbStore } from '@/store/modules/db'
const db = useDbStore()
const { currentProject: storedProject, hasCurrentProject } = useCurrentProject()
const { hasCurrentProject, currentProjectId } = useCurrentProject()
const { isActivated, needAuthPrompt } = useDeviceAuth()
const currentProject = computed(() => {
if (!hasCurrentProject.value)
if (!hasCurrentProject.value || currentProjectId.value == null)
return null
const list = db.project || []
const storedId = storedProject.value?.id
if (storedId == null)
return null
return list.find(item => item.id === storedId) || null
return list.find(item => item.id === currentProjectId.value) || null
})
const progressPercent = computed(() => {
@@ -84,8 +82,9 @@ function formatPercent(value: number | null) {
}
onMounted(async () => {
if (!db.project?.length)
await db.loadAll()
if (!hasCurrentProject.value || currentProjectId.value == null)
return
await db.loadByProject(currentProjectId.value)
})
</script>
@@ -95,12 +94,13 @@ onMounted(async () => {
<text class="card-title">
项目作业进度
</text>
<text class="card-sub">
<text v-if="isActivated" class="card-sub">
{{ currentProject?.stake_range || currentProject?.project_name || '暂无施工段' }}
</text>
</view>
<view class="card-body">
<view class="progress-content">
<unauthorized v-if="needAuthPrompt" />
<view v-else class="progress-content">
<view class="progress-circle">
<wd-circle :model-value="progressPercent ?? 0" :size="120">
<view class="progress-circle-text">

View File

@@ -1,18 +1,14 @@
<script setup lang="ts">
import type { Project } from '@/service/project'
import { computed, onMounted } from 'vue'
import { useCurrentProject } from '@/composables/useCurrentProject'
import { useDeviceAuth } from '@/composables/useDeviceAuth'
import { useDbStore } from '@/store/modules/db'
const db = useDbStore()
const { hasCurrentProject } = useCurrentProject()
const { isActivated, needAuthPrompt } = useDeviceAuth()
/** 首页只展示最近几条 */
const list = computed(() => {
if (!hasCurrentProject.value)
return []
return (db.project || []).slice(0, 2)
})
/** 首页展示最近 2 条,按 id 倒序 */
const list = computed(() => (db.project || []).slice().sort((a, b) => (b.id ?? 0) - (a.id ?? 0)).slice(0, 2))
onMounted(async () => {
if (!db.project?.length)
@@ -40,23 +36,23 @@ function goToDetail(item: Project) {
<text class="card-title">
项目列表
</text>
<wd-button variant="text" @tap="goToProjectList">
<wd-button v-if="isActivated" variant="text" @tap="goToProjectList">
查看全部
</wd-button>
</view>
<view class="card-body">
<view v-if="!hasCurrentProject" class="empty-tip">
--
</view>
<unauthorized v-if="needAuthPrompt" />
<view v-else-if="!list.length" class="empty-tip">
暂无项目
</view>
<template v-else>
<home-list
v-for="item in list"
:key="item.id"
:item="item"
@tap="goToDetail(item)"
/>
</template>
</view>
</view>
</template>

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import { useCurrentProject } from '@/composables/useCurrentProject'
import { useDeviceAuth } from '@/composables/useDeviceAuth'
import { useRtkLive } from '@/composables/useRtkLive'
const { hasCurrentProject } = useCurrentProject()
const { needAuthPrompt } = useDeviceAuth()
const { speedText, tempText } = useRtkLive()
function goToWork() {
uni.switchTab({
@@ -18,7 +22,8 @@ function goToWork() {
</text>
</view>
<view class="card-body">
<view v-if="!hasCurrentProject" class="empty-tip">
<unauthorized v-if="needAuthPrompt" />
<view v-else-if="!hasCurrentProject" class="empty-tip">
--
</view>
<template v-else>
@@ -37,28 +42,12 @@ function goToWork() {
</view>
</view>
<view class="status-params">
<view class="param">
<text class="param-label">
振动频率
</text>
<text class="param-value">
8939HZ
</text>
</view>
<view class="param">
<text class="param-label">
振幅
</text>
<text class="param-value">
8939mm
</text>
</view>
<view class="param">
<text class="param-label">
行驶速度
</text>
<text class="param-value">
3km/h
{{ speedText }}
</text>
</view>
<view class="param">
@@ -66,7 +55,7 @@ function goToWork() {
钢轮温度
</text>
<text class="param-value">
8939°C
{{ tempText }}
</text>
</view>
</view>

View File

@@ -2,24 +2,26 @@
import type { WarningRecord } from '@/service/warningRecord'
import { computed, onMounted } from 'vue'
import { useCurrentProject } from '@/composables/useCurrentProject'
import { useDeviceAuth } from '@/composables/useDeviceAuth'
import { useDbStore } from '@/store/modules/db'
const db = useDbStore()
const { hasCurrentProject, currentProject } = useCurrentProject()
const { hasCurrentProject, currentProjectId } = useCurrentProject()
const { isActivated, needAuthPrompt } = useDeviceAuth()
/** 首页只展示当前项目最近几条 */
const list = computed(() => {
if (!hasCurrentProject.value)
if (!hasCurrentProject.value || currentProjectId.value == null)
return []
const projectId = currentProject.value?.id
return (db.warning_record || [])
.filter(item => projectId == null || item.project_id === projectId)
.filter(item => item.project_id === currentProjectId.value)
.slice(0, 3)
})
onMounted(async () => {
if (!db.warning_record?.length)
await db.loadAll()
if (!hasCurrentProject.value || currentProjectId.value == null)
return
await db.loadByProject(currentProjectId.value)
})
function goToWarningList() {
@@ -43,23 +45,26 @@ function goToDetail(item: WarningRecord) {
<text class="card-title">
异常警告
</text>
<wd-button variant="text" @tap="goToWarningList">
<wd-button v-if="isActivated" variant="text" @tap="goToWarningList">
查看全部
</wd-button>
</view>
<view class="card-body">
<view v-if="!hasCurrentProject" class="empty-tip">
<unauthorized v-if="needAuthPrompt" />
<view v-else-if="!hasCurrentProject" class="empty-tip">
--
</view>
<view v-else-if="!list.length" class="empty-tip">
暂无异常警告
</view>
<template v-else>
<home-warning_list
v-for="item in list"
:key="item.id"
:item="item"
@tap="goToDetail(item)"
/>
</template>
</view>
</view>
</template>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
defineProps<{
import { useProjectLifecycle } from '@/composables/useProjectLifecycle'
const props = defineProps<{
id: number
projectName: string
stakeRange: string
status: number
@@ -8,6 +11,8 @@ defineProps<{
rollerCount: string
}>()
const { startProject, pauseProject, endProject } = useProjectLifecycle()
function getStatusText(status: number) {
const map: Record<number, string> = {
0: '已暂停',
@@ -25,6 +30,21 @@ function getStatusColor(status: number) {
}
return map[status] || '#999999'
}
function onStart() {
if (props.id)
startProject(props.id)
}
function onPause() {
if (props.id)
pauseProject(props.id)
}
function onEnd() {
if (props.id)
endProject(props.id)
}
</script>
<template>
@@ -80,13 +100,13 @@ function getStatusColor(status: number) {
<!-- 操作按钮 -->
<view v-if="status !== 1" class="btn-row">
<wd-button size="large" type="primary" round>
<wd-button size="large" type="primary" round @tap="onStart">
开始项目
</wd-button>
<wd-button size="large" type="warning" round>
<wd-button size="large" type="warning" round @tap="onPause">
暂停项目
</wd-button>
<wd-button size="large" type="danger" round>
<wd-button size="large" type="danger" round @tap="onEnd">
结束项目
</wd-button>
</view>

View File

@@ -23,22 +23,22 @@ defineProps<{
{{ avgSpeed }}
</text>
</view>
<view class="detail-row">
<!-- <view class="detail-row">
<text class="detail-label">
平均振动频率
</text>
<text class="detail-value">
{{ avgFrequency }}
</text>
</view>
<view class="detail-row">
</view> -->
<!-- <view class="detail-row">
<text class="detail-label">
平均振幅
</text>
<text class="detail-value">
{{ avgAmplitude }}
</text>
</view>
</view> -->
<view class="detail-row">
<text class="detail-label">
平均摊铺温度

View File

@@ -1,8 +1,6 @@
<script setup lang="ts">
import storage from '@/utils/storage'
const props = defineProps<{
id?: number
reportId?: number
title: string
deviceCode: string
status: string
@@ -14,25 +12,13 @@ const props = defineProps<{
}>()
function goToDetail() {
const key = `report_${Date.now()}`
storage.set(key, {
id: props.id,
title: props.title,
deviceCode: props.deviceCode,
status: props.status,
time: props.time,
area: props.area,
duration: props.duration,
areaCount: props.areaCount,
passRate: props.passRate,
})
const query = [`key=${key}`]
if (props.id != null)
query.push(`id=${props.id}`)
if (props.reportId == null) {
uni.showToast({ title: '报告ID缺失', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/report/detail/index?${query.join('&')}`,
url: `/pages/report/detail/index?reportId=${props.reportId}`,
})
}
</script>

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { useCurrentProject } from '@/composables/useCurrentProject'
import { projectService } from '@/service'
import storage from '@/utils/storage'
import { useDbStore } from '@/store/modules/db'
const PROJECT_KEY = 'current_project'
const currentProject = storage.get(PROJECT_KEY)
const db = useDbStore()
const { setCurrentProject, currentProjectId } = useCurrentProject()
const projectName = ref(currentProject?.project_name || '')
const startEndStation = ref(currentProject?.stake_range || '')
const totalArea = ref(currentProject?.total_area ? String(currentProject.total_area) : '')
const projectName = ref('')
const startEndStation = ref('')
const totalArea = ref('')
async function handleSave() {
if (!projectName.value) {
@@ -16,21 +17,30 @@ async function handleSave() {
}
try {
const id = await projectService.insert({
const payload = {
project_name: projectName.value,
stake_range: startEndStation.value,
total_area: Number(totalArea.value) || 0,
status: 0,
})
status: 0 as const,
}
// 保存到 storage
storage.set(PROJECT_KEY, {
id,
project_name: projectName.value,
stake_range: startEndStation.value,
total_area: Number(totalArea.value) || 0,
status: 0,
})
// 始终新建项目,写入数据库
const id = await projectService.insert(payload)
if (!id) {
uni.showToast({ title: '保存失败:未获取到项目ID', icon: 'none' })
return
}
// 写入 storage,后续业务按该项目 id 查询
const project = await projectService.selectById(id)
if (!project) {
uni.showToast({ title: '保存失败:项目不存在', icon: 'none' })
return
}
setCurrentProject(project)
await db.loadByProject(id)
console.log('id22222', id, 'currentProjectId.value', currentProjectId.value)
uni.showToast({ title: '保存成功', icon: 'success' })
}

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
withDefaults(defineProps<{
title?: string
tipLine1?: string
tipLine2?: string
}>(), {
title: '设备未授权激活',
tipLine1: '请点击右上角设置图标',
tipLine2: '复制设备 ID 获取授权码完成激活',
})
</script>
<template>
<view class="unauthorized">
<view class="icon-wrap">
<view class="lock">
<view class="lock-shackle" />
<view class="lock-body">
<view class="lock-dot" />
<view class="lock-slot" />
</view>
</view>
</view>
<text class="title">
{{ title }}
</text>
<text class="tip">
{{ tipLine1 }}
</text>
<text class="tip">
{{ tipLine2 }}
</text>
</view>
</template>
<style scoped>
.unauthorized {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.icon-wrap {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: #FFECEC;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.lock {
display: flex;
flex-direction: column;
align-items: center;
}
.lock-shackle {
width: 22px;
height: 16px;
border: 3px solid #F04848;
border-bottom: none;
border-radius: 11px 11px 0 0;
box-sizing: border-box;
}
.lock-body {
width: 32px;
height: 24px;
border: 3px solid #F04848;
border-radius: 4px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: -2px;
}
.lock-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background-color: #F04848;
}
.lock-slot {
width: 2px;
height: 5px;
background-color: #F04848;
border-radius: 1px;
margin-top: -1px;
}
.title {
font-size: 18px;
font-weight: bold;
color: #333333;
margin-bottom: 12px;
text-align: center;
}
.tip {
font-size: 14px;
color: #999999;
line-height: 22px;
text-align: center;
}
</style>

View File

@@ -1,24 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useCurrentProject } from '@/composables/useCurrentProject'
import { useRtkLive } from '@/composables/useRtkLive'
const { hasCurrentProject } = useCurrentProject()
const {
compactionValue: liveCompaction,
speed,
compactionText: liveCompactionText,
tempText: liveTempText,
passCountText: livePassCountText,
} = useRtkLive()
// 压实度 (假数据,后续替换)
const compactionValue = computed(() => (hasCurrentProject.value ? 25 : 0))
const compactionText = computed(() => (hasCurrentProject.value ? `${compactionValue.value}%` : '--'))
const compactionValue = computed(() => {
if (!hasCurrentProject.value || liveCompaction.value == null)
return 0
return Math.round(liveCompaction.value)
})
const compactionText = computed(() => (hasCurrentProject.value ? liveCompactionText.value : '--'))
// 车速 (假数据,范围 0-12,中间值 6)
const speedValue = computed(() => (hasCurrentProject.value ? 4.2 : 0))
const speedText = computed(() => (hasCurrentProject.value ? `${speedValue.value}KM/H` : '--'))
const speedValue = computed(() => {
if (!hasCurrentProject.value || speed.value == null)
return 0
return speed.value
})
const speedText = computed(() => {
if (!hasCurrentProject.value || speed.value == null)
return '--'
return `${speed.value}KM/H`
})
const speedMax = 12
// 指针旋转角度:0km/h → 225°(左下),12km/h → -45°(右下),中间90°(正上)
const needleRotation = computed(() => 225 - (speedValue.value / speedMax) * 270)
/** 数学角(0=东,逆时针)→ CSS 旋转(0=上,顺时针) */
const needleRotation = computed(() => {
const mathDeg = 225 - (speedValue.value / speedMax) * 270
return 90 - mathDeg
})
const frequencyText = computed(() => (hasCurrentProject.value ? '8939HZ' : '--'))
const tempText = computed(() => (hasCurrentProject.value ? liveTempText.value : '--'))
const passCountText = computed(() => (hasCurrentProject.value ? livePassCountText.value : '--'))
// 今日作业概览数据
const overviewData = computed(() => {
if (!hasCurrentProject.value) {
return [
@@ -60,7 +81,7 @@ const overviewData = computed(() => {
</view>
<!-- 圆环进度(使用 wotui 的 wd-circle 组件) -->
<view class="ring-wrap">
<wd-circle :model-value="compactionValue" :text="compactionText" />
<wd-circle :model-value="compactionValue" :text="compactionText" :size="80" />
</view>
</view>
@@ -82,57 +103,38 @@ const overviewData = computed(() => {
<!-- 右侧 -->
<view class="gauge-right">
<!-- 振动频率 -->
<!-- 钢轮温度 -->
<view class="freq-item">
<text class="freq-label">
振动频率
钢轮温度
</text>
<text class="freq-value">
{{ frequencyText }}
{{ tempText }}
</text>
</view>
<view class="freq-item">
<text class="freq-label">
振动频率
压实遍数
</text>
<text class="freq-value">
{{ frequencyText }}
{{ passCountText }}
</text>
</view>
<!-- 车速仪表 SVG 3/4圆弧 (270°) -->
<!-- 车速仪表:App 端不支持内联 SVG,改用 view + CSS -->
<view class="speed-gauge">
<svg viewBox="0 0 120 110" class="speed-svg">
<!-- 左半弧:蓝色 (0~6) 从225°到90° -->
<path
d="M 24.64 95.36 A 50 50 0 0 1 60 10"
fill="none"
stroke="#3b82f6"
stroke-width="10"
stroke-linecap="round"
/>
<!-- 右半弧:橙色 (6~12) 从90°到-45° -->
<path
d="M 60 10 A 50 50 0 0 1 95.36 95.36"
fill="none"
stroke="#f97316"
stroke-width="10"
stroke-linecap="round"
/>
<!-- 指针 -->
<line
x1="60"
y1="60"
:x2="60 + 38 * Math.cos(needleRotation * Math.PI / 180)"
:y2="60 - 38 * Math.sin(needleRotation * Math.PI / 180)"
stroke="#f97316"
stroke-width="3"
stroke-linecap="round"
/>
<!-- 中心圆点 -->
<circle cx="60" cy="60" r="5" fill="#f97316" />
</svg>
<view class="gauge-clip gauge-clip--left">
<view class="gauge-ring gauge-ring--blue" />
</view>
<view class="gauge-clip gauge-clip--right">
<view class="gauge-ring gauge-ring--orange" />
</view>
<view class="gauge-mask" />
<view class="needle-wrap" :style="{ transform: `rotate(${needleRotation}deg)` }">
<view class="needle" />
</view>
<view class="needle-hub" />
</view>
</view>
</view>
@@ -240,15 +242,88 @@ const overviewData = computed(() => {
color: #333;
}
/* ===== 车速 SVG 3/4圆仪表 ===== */
/* ===== 车速仪表(view+CSS,兼容 Android App) ===== */
.speed-gauge {
position: relative;
width: 110px;
height: 100px;
margin-top: 4px;
}
.speed-svg {
width: 100%;
height: 100%;
.gauge-clip {
position: absolute;
top: 0;
width: 50px;
height: 100px;
overflow: hidden;
}
.gauge-clip--left {
left: 5px;
}
.gauge-clip--right {
left: 55px;
}
.gauge-ring {
width: 90px;
height: 90px;
margin-top: 5px;
border-radius: 50%;
border-style: solid;
border-width: 10px;
box-sizing: border-box;
}
/* 左半:蓝弧约 225°→90° */
.gauge-ring--blue {
margin-left: 5px;
border-color: #3b82f6 transparent transparent #3b82f6;
transform: rotate(-45deg);
}
/* 右半:橙弧约 90°→-45° */
.gauge-ring--orange {
margin-left: -45px;
border-color: #f97316 #f97316 transparent transparent;
transform: rotate(45deg);
}
.gauge-mask {
position: absolute;
left: 5px;
bottom: 0;
width: 100px;
height: 42px;
background: #fff;
}
.needle-wrap {
position: absolute;
left: 52px;
top: 12px;
width: 6px;
height: 38px;
transform-origin: 50% 100%;
}
.needle {
width: 4px;
height: 36px;
margin: 0 auto;
border-radius: 2px;
background: #f97316;
}
.needle-hub {
position: absolute;
left: 50px;
top: 45px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #f97316;
}
/* ===== 分割线 ===== */

View File

@@ -1,23 +1,285 @@
<script setup lang="ts">
/**
* 固定比例尺绘制:轨迹变长只加高画布/白卡,图案不缩小
*/
import type { MockRtkPoint } from '@/mock/rtkTrack'
import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useCurrentProject } from '@/composables/useCurrentProject'
import { publishRtkPoint } from '@/composables/useRtkLive'
import { createMockRtkStream } from '@/mock/rtkTrack'
const props = withDefaults(defineProps<{
/** canvas-id,同页多实例时需唯一 */
canvasId?: string
/** 是否点击跳转全屏 RTK 页(全屏页内应关闭) */
navigateOnClick?: boolean
}>(), {
canvasId: 'rtkTrackCanvas',
navigateOnClick: true,
})
const { hasCurrentProject } = useCurrentProject()
const instance = getCurrentInstance()?.proxy
const CANVAS_ID = props.canvasId
const PADDING = 28
const ROLLER_WIDTH = 14
const MIN_H = 160
const canvasHeight = ref(MIN_H)
const trail: MockRtkPoint[] = []
let finished = false
let cssWidth = 0
let cssHeight = MIN_H
let ready = false
let initTries = 0
let stream = createMockRtkStream({ intervalMs: 80, loop: true, rows: 80, pointsPerRow: 40 })
/** 固定东西向范围与比例尺(米/像素),避免越画越小 */
let fixedMinLon = 0
let fixedMaxLon = 0
let fixedMaxLat = 0 // 北端固定(起点一侧)
let pxPerMeter = 1
function goToRtk() {
uni.navigateTo({
url: '/pages/rtk/index',
if (!props.navigateOnClick)
return
uni.navigateTo({ url: '/pages/rtk/index' })
}
function compactionColor(value: number) {
const t = Math.min(1, Math.max(0, (value - 75) / 25))
const r = Math.round(200 - t * 90)
const g = Math.round(230 - t * 40)
const b = Math.round(170 - t * 70)
return `rgba(${r}, ${g}, ${b}, 0.75)`
}
function metersPerDeg(lat: number) {
return {
lat: 111320,
lon: 111320 * Math.cos((lat * Math.PI) / 180),
}
}
/** 用完整假路径定死东西跨度与比例尺;高度只随已画南北跨度增加 */
function initFixedScale(allPoints: MockRtkPoint[]) {
if (!allPoints.length || cssWidth <= 0)
return
let minLat = Infinity
let maxLat = -Infinity
let minLon = Infinity
let maxLon = -Infinity
for (const p of allPoints) {
minLat = Math.min(minLat, p.latitude)
maxLat = Math.max(maxLat, p.latitude)
minLon = Math.min(minLon, p.longitude)
maxLon = Math.max(maxLon, p.longitude)
}
const padLon = (maxLon - minLon) * 0.08 || 0.00001
fixedMinLon = minLon - padLon
fixedMaxLon = maxLon + padLon
fixedMaxLat = maxLat + ((maxLat - minLat) * 0.08 || 0.00001)
const midLat = (minLat + maxLat) / 2
const { lon: mLon } = metersPerDeg(midLat)
const lonSpanM = Math.max((fixedMaxLon - fixedMinLon) * mLon, 1e-3)
pxPerMeter = (cssWidth - PADDING * 2) / lonSpanM
}
function trailMinLat() {
let minLat = fixedMaxLat
for (const p of trail)
minLat = Math.min(minLat, p.latitude)
const pad = 0.00001
return minLat - pad
}
/** 按已绘制南北跨度撑高(比例尺不变) */
function updateHeightFromTrail() {
if (cssWidth <= 0 || !trail.length || pxPerMeter <= 0)
return
const minLat = trailMinLat()
const { lat: mLat } = metersPerDeg((fixedMaxLat + minLat) / 2)
const latSpanM = Math.max((fixedMaxLat - minLat) * mLat, 0)
const next = Math.max(MIN_H, Math.round(PADDING * 2 + latSpanM * pxPerMeter))
if (next !== cssHeight) {
cssHeight = next
canvasHeight.value = next
}
}
function toXY(point: MockRtkPoint) {
const midLat = (fixedMaxLat + point.latitude) / 2
const { lat: mLat, lon: mLon } = metersPerDeg(midLat)
return {
x: PADDING + (point.longitude - fixedMinLon) * mLon * pxPerMeter,
y: PADDING + (fixedMaxLat - point.latitude) * mLat * pxPerMeter,
}
}
function draw() {
if (!ready || cssWidth <= 0)
return
const ctx = uni.createCanvasContext(CANVAS_ID, instance as any)
const path = trail
ctx.setFillStyle('#ffffff')
ctx.fillRect(0, 0, cssWidth, cssHeight)
if (!path.length) {
ctx.draw()
return
}
const coords = path.map(p => toXY(p))
ctx.setLineCap('round')
ctx.setLineJoin('round')
ctx.setLineWidth(ROLLER_WIDTH)
for (let i = 1; i < coords.length; i++) {
ctx.beginPath()
ctx.setStrokeStyle(compactionColor(path[i].compaction_value))
ctx.moveTo(coords[i - 1].x, coords[i - 1].y)
ctx.lineTo(coords[i].x, coords[i].y)
ctx.stroke()
}
ctx.beginPath()
ctx.setLineWidth(2.5)
ctx.setStrokeStyle('#2563eb')
coords.forEach((c, i) => {
if (i === 0)
ctx.moveTo(c.x, c.y)
else
ctx.lineTo(c.x, c.y)
})
ctx.stroke()
const start = coords[0]
ctx.beginPath()
ctx.setFillStyle('#22c55e')
ctx.arc(start.x, start.y, 10, 0, Math.PI * 2)
ctx.fill()
ctx.setFillStyle('#166534')
ctx.setFontSize(10)
ctx.setTextAlign('center')
ctx.fillText('起点', start.x, start.y - 14)
const last = coords[coords.length - 1]
const cur = path[path.length - 1]
if (finished) {
ctx.beginPath()
ctx.setFillStyle('#ef4444')
ctx.moveTo(last.x, last.y - 14)
ctx.lineTo(last.x + 10, last.y)
ctx.lineTo(last.x, last.y + 4)
ctx.lineTo(last.x - 10, last.y)
ctx.closePath()
ctx.fill()
ctx.setFillStyle('#991b1b')
ctx.fillText('结束', last.x, last.y + 18)
}
else {
ctx.beginPath()
ctx.setFillStyle('#f97316')
ctx.arc(last.x, last.y, 7, 0, Math.PI * 2)
ctx.fill()
const heading = (cur.heading * Math.PI) / 180
ctx.beginPath()
ctx.setStrokeStyle('#ea580c')
ctx.setLineWidth(2)
ctx.moveTo(last.x, last.y)
ctx.lineTo(last.x + Math.sin(heading) * 12, last.y - Math.cos(heading) * 12)
ctx.stroke()
}
ctx.draw()
}
function measureAndReady(onReady?: () => void) {
uni.createSelectorQuery()
.in(instance as any)
.select('.rtk-canvas')
.boundingClientRect((rect) => {
const box = Array.isArray(rect) ? rect[0] : rect
const w = Number(box?.width) || 0
if (w <= 0) {
initTries += 1
if (initTries < 20)
setTimeout(() => measureAndReady(onReady), 50)
return
}
cssWidth = w
cssHeight = MIN_H
canvasHeight.value = MIN_H
ready = true
onReady?.()
})
.exec()
}
function startStream() {
stream.stop()
stream = createMockRtkStream({ intervalMs: 80, loop: true, rows: 80, pointsPerRow: 40 })
trail.length = 0
finished = false
initFixedScale(stream.points)
cssHeight = MIN_H
canvasHeight.value = MIN_H
draw()
stream.start((point, index) => {
trail.push(point)
publishRtkPoint(point)
finished = index >= stream.points.length - 1
updateHeightFromTrail()
nextTick(() => draw())
})
}
function stopStream() {
stream.stop()
trail.length = 0
finished = false
cssHeight = MIN_H
canvasHeight.value = MIN_H
if (ready)
draw()
}
function boot() {
if (!hasCurrentProject.value) {
stopStream()
return
}
initTries = 0
measureAndReady(() => startStream())
}
watch(hasCurrentProject, () => nextTick(() => boot()))
onMounted(() => nextTick(() => boot()))
onUnmounted(() => stream.stop())
</script>
<template>
<view class="rtk-canvas" @click="goToRtk">
<view class="canvas-placeholder">
<view class="rtk-canvas" :style="{ height: `${canvasHeight}px` }" @click="goToRtk">
<canvas
v-if="hasCurrentProject"
:id="CANVAS_ID"
:canvas-id="CANVAS_ID"
class="track-canvas"
:style="{ width: '100%', height: `${canvasHeight}px` }"
/>
<view v-else class="canvas-placeholder">
<text class="placeholder-text">
{{ hasCurrentProject ? '轨迹动画区域' : '--' }}
</text>
<text v-if="hasCurrentProject" class="placeholder-hint">
(Canvas 绘制行驶轨迹 + 压实热力图)
--
</text>
</view>
</view>
@@ -25,30 +287,30 @@ function goToRtk() {
<style scoped>
.rtk-canvas {
position: relative;
width: 100%;
height: 300px;
background: #f0fdf4;
background: #ffffff;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.track-canvas {
width: 100%;
display: block;
}
.canvas-placeholder {
width: 100%;
height: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: #f0fdf4;
}
.placeholder-text {
font-size: 14px;
color: #666;
}
.placeholder-hint {
font-size: 12px;
color: #999;
}
</style>

View File

@@ -1,27 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useCurrentProject } from '@/composables/useCurrentProject'
import { useRtkLive } from '@/composables/useRtkLive'
interface Props {
deviceName?: string
speed?: number
frequency?: number
amplitude?: number
rtkFixed?: boolean
}
const props = withDefaults(defineProps<Props>(), {
deviceName: '徐工XS65H #01',
speed: 4.2,
frequency: 28.5,
amplitude: 1.86,
rtkFixed: true,
})
const { hasCurrentProject } = useCurrentProject()
const { speedTextSpaced } = useRtkLive()
const displayDeviceName = computed(() => (hasCurrentProject.value ? props.deviceName : '--'))
const displaySpeed = computed(() => (hasCurrentProject.value ? `${props.speed} km/h` : '--'))
const displaySpeed = computed(() => (hasCurrentProject.value ? speedTextSpaced.value : '--'))
const displayStatusText = computed(() => (hasCurrentProject.value ? '实时监测中' : '--'))
const displayRtkText = computed(() => (hasCurrentProject.value ? 'RTK固定' : '--'))

View File

@@ -1,24 +1,34 @@
<script setup lang="ts">
// RTK轨迹实时监测 + 压实热力图 - 父组件(组合头部 + 画布 + 底部)
// RTK轨迹实时监测:整卡高度随画布内容撑开(不要 min-height:100% 锁死视口)
</script>
<template>
<view class="card">
<!-- 状态头部 -->
<work-rtkHeader />
<!-- 轨迹画布 -->
<work-rtkCanvas />
<!-- 底部信息 -->
<work-rtkFooter />
<work-rtkHeader class="card-header" />
<work-rtkCanvas class="card-canvas" />
<work-rtkFooter class="card-footer" />
</view>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 12px;
padding: 16px;
/* 高度完全跟内容走,轨迹变长整卡变高 */
height: auto;
box-sizing: border-box;
}
.card-header,
.card-footer {
flex-shrink: 0;
}
.card-canvas {
flex: 0 0 auto;
width: 100%;
}
</style>

View File

@@ -1,28 +1,49 @@
import type { Project } from '@/service/project'
import { onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import storage from '@/utils/storage'
const PROJECT_KEY = 'current_project'
/** 模块级共享,保证各页面读到同一当前项目 */
const currentProject = ref<Partial<Project> | undefined>(storage.get(PROJECT_KEY))
/**
* 读取本地当前项目;无项目时首页/压实作业数据统一展示为 `--`
* 有项目时,业务查询应以 currentProjectId 为条件
*/
export function useCurrentProject() {
const currentProject = ref(storage.get(PROJECT_KEY))
const currentProjectId = computed(() => {
const id = currentProject.value?.id
return id == null ? null : Number(id)
})
const hasCurrentProject = computed(() => currentProject.value?.id != null)
const hasCurrentProject = computed(() => currentProjectId.value != null && currentProjectId.value > 0)
function refreshCurrentProject() {
currentProject.value = storage.get(PROJECT_KEY)
}
/** 写入 storage 并同步内存中的当前项目 */
function setCurrentProject(project: Partial<Project> | null | undefined) {
if (!project?.id) {
storage.remove(PROJECT_KEY)
currentProject.value = undefined
return
}
storage.set(PROJECT_KEY, project)
currentProject.value = project
}
onShow(() => {
refreshCurrentProject()
})
return {
currentProject,
currentProjectId,
hasCurrentProject,
setCurrentProject,
refreshCurrentProject,
}
}

View File

@@ -0,0 +1,322 @@
import type { DeviceAuthStatus } from '@/api/device'
import type { AuthStatus } from '@/api/types'
import { onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { activateDevice, getDeviceStatus } from '@/api/device'
import { getDeviceId } from '@/utils/device'
import storage from '@/utils/storage'
const AUTH_INFO_KEY = 'device_auth_info'
const ACTIVATION_CODE_KEY = 'device_activation_code'
function readCachedAuthInfo(): DeviceAuthStatus | null {
const cached = storage.get<DeviceAuthStatus>(AUTH_INFO_KEY)
if (cached && typeof cached === 'object')
return cached
// 兼容异常写入:直接读原始 storage
try {
const raw = uni.getStorageSync(AUTH_INFO_KEY)
if (!raw)
return null
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
const value = parsed?.value ?? parsed
if (value && typeof value === 'object' && 'remainingDays' in value)
return value as DeviceAuthStatus
}
catch {
// ignore
}
return null
}
function toAuthStatus(value: AuthStatus | string | number | null | undefined): AuthStatus | null {
if (value == null || value === '')
return null
const status = String(value) as AuthStatus
if (status === '0' || status === '1' || status === '2' || status === '3')
return status
return null
}
/** 兼容 data 嵌套 / 扁平结构 / authStatus 为数字 */
function pickAuthPayload(res: unknown): Partial<DeviceAuthStatus> | null {
if (!res || typeof res !== 'object')
return null
const raw = res as Record<string, unknown>
const nested = raw.data
if (nested && typeof nested === 'object')
return nested as Partial<DeviceAuthStatus>
if ('authStatus' in raw)
return raw as Partial<DeviceAuthStatus>
return null
}
function normalizeAuthInfo(payload: Partial<DeviceAuthStatus> | null | undefined): DeviceAuthStatus | null {
if (!payload)
return null
const status = toAuthStatus(payload.authStatus as AuthStatus | string | number | undefined)
if (!status)
return null
return {
deviceId: payload.deviceId ?? getDeviceId(),
deviceName: payload.deviceName ?? '',
authStatus: status,
startDate: payload.startDate ?? '',
endDate: payload.endDate ?? '',
remainingDays: Number(payload.remainingDays ?? 0),
latestVersionNo: payload.latestVersionNo ?? '',
}
}
function createUnauthorizedInfo(): DeviceAuthStatus {
return {
deviceId: getDeviceId(),
deviceName: '',
authStatus: '0',
startDate: '',
endDate: '',
// -1 表示无有效剩余天数,避免误触发到期提醒
remainingDays: -1,
latestVersionNo: '',
}
}
/** 模块级共享,保证各页面读到同一授权信息 */
const authInfo = ref<DeviceAuthStatus | null>(readCachedAuthInfo())
const activationCode = ref<string>(storage.get<string>(ACTIVATION_CODE_KEY, '') ?? '')
const loading = ref(false)
const authReady = ref(false)
/** 到期提醒弹窗显示状态(App/H5 共用,避免页面 ref 调用失效) */
const expireNoticeVisible = ref(false)
let lastExpireShownAt = 0
/**
* 进入软件:先查本地激活码 → 无码再查授权状态并写入 storage
* 激活成功后再查一次状态写入 storage,形成闭环
* authStatus 为 1(已激活)时,unauthorized / activateNotice 一律不展示
*/
export function useDeviceAuth(options?: { watchShow?: boolean }) {
const authStatus = computed(() => toAuthStatus(authInfo.value?.authStatus))
const isActivated = computed(() => authStatus.value === '1')
/**
* 是否需要展示未授权提示组件
*
* 只有 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(() => {
const days = authInfo.value?.remainingDays
return days == null || Number.isNaN(Number(days)) ? null : Number(days)
})
const latestVersionNo = computed(() => authInfo.value?.latestVersionNo || '')
/**
* 从当前授权信息判断是否需到期提醒:
* storage 中 remainingDays 为 0~7(含)时弹出
*/
const needExpireNotice = computed(() => {
const days = remainingDays.value
return days != null && days >= 0 && days <= 7
})
/** 每次打开软件:先读 storage,再判断 remainingDays ≤ 7 */
function shouldShowExpireNoticeFromStorage(): boolean {
const cached = readCachedAuthInfo()
authInfo.value = cached
if (!cached)
return false
const days = Number(cached.remainingDays)
if (Number.isNaN(days))
return false
return days >= 0 && days <= 7
}
/** 打开到期提醒(由首页 onShow/onMounted 调用) */
function openExpireNoticeIfNeeded() {
if (!shouldShowExpireNoticeFromStorage()) {
console.log('[expireNotice] skip, storage=', authInfo.value)
return false
}
const now = Date.now()
if (now - lastExpireShownAt < 1500)
return false
lastExpireShownAt = now
expireNoticeVisible.value = true
console.log('[expireNotice] open, remainingDays=', authInfo.value?.remainingDays)
return true
}
function closeExpireNotice() {
expireNoticeVisible.value = false
}
/** 将完整授权信息写入内存 + storage */
function saveAuthInfo(info: DeviceAuthStatus) {
authInfo.value = info
storage.set(AUTH_INFO_KEY, info)
}
function setActivationCode(code: string) {
const trimmed = code.trim()
activationCode.value = trimmed
if (trimmed)
storage.set(ACTIVATION_CODE_KEY, trimmed)
else
storage.remove(ACTIVATION_CODE_KEY)
}
/** 查询设备授权状态,并完整落库;失败时返回 null */
async function fetchAuthStatus() {
loading.value = true
try {
const res = await getDeviceStatus(getDeviceId())
const info = normalizeAuthInfo(pickAuthPayload(res))
if (info) {
saveAuthInfo(info)
return info
}
console.warn('[deviceAuth] 授权状态响应无法解析:', res)
return null
}
catch (err) {
console.warn('[deviceAuth] 查询授权状态失败:', err)
return null
}
finally {
loading.value = false
}
}
/**
* 启动时初始化授权:
* 1. 本地已有激活码 → 读取 storage 中的授权信息
* 2. 无激活码 → 查询设备授权状态;查不到则按未授权处理并展示提示
*/
async function initAuth() {
loading.value = true
try {
const localCode = storage.get<string>(ACTIVATION_CODE_KEY, '') ?? ''
if (localCode.trim()) {
activationCode.value = localCode.trim()
const cached = readCachedAuthInfo()
if (cached) {
// 先用缓存让 UI 立即就绪
authInfo.value = cached
// 再异步向服务端核对一次,保证 authStatus 最新(如服务端已改为已激活)
fetchAuthStatus()
return authInfo.value
}
// 有激活码但无缓存信息,补拉一次状态
const info = await fetchAuthStatus()
if (info)
return info
saveAuthInfo({ ...createUnauthorizedInfo(), authStatus: '1' })
return authInfo.value
}
// 无激活码:以服务端为准;失败或无法解析 → 未授权,展示提示组件
const info = await fetchAuthStatus()
if (info)
return info
saveAuthInfo(createUnauthorizedInfo())
return authInfo.value
}
finally {
loading.value = false
authReady.value = true
}
}
/**
* 提交授权码激活 → 缓存激活码 → 再查授权状态落库(闭环)
*/
async function activate(code: string) {
const trimmed = code.trim()
if (!trimmed)
throw new Error('请输入授权码')
const res = await activateDevice({
activationCode: trimmed,
deviceId: getDeviceId(),
})
const ok = res.code === 200 || res.code === 0
if (!ok)
throw new Error(res.msg || '激活失败')
setActivationCode(trimmed)
const info = await fetchAuthStatus()
if (!info) {
saveAuthInfo({
deviceId: res.deviceId || getDeviceId(),
deviceName: authInfo.value?.deviceName ?? '',
authStatus: '1',
startDate: authInfo.value?.startDate ?? '',
endDate: res.endDate || authInfo.value?.endDate || '',
remainingDays: authInfo.value?.remainingDays ?? 0,
latestVersionNo: authInfo.value?.latestVersionNo ?? '',
})
}
return res
}
/**
* 刷新授权状态:
* 先同步本地缓存(立即反映变化),再异步向服务端查询一次,
* 保证服务端已变为「已激活(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) {
onShow(() => {
refreshAuthStatus()
})
}
return {
authInfo,
authStatus,
activationCode,
isActivated,
needAuthPrompt,
needExpireNotice,
hasActivationCode,
deviceId,
remainingDays,
latestVersionNo,
loading,
authReady,
expireNoticeVisible,
saveAuthInfo,
setActivationCode,
fetchAuthStatus,
initAuth,
activate,
refreshAuthStatus,
shouldShowExpireNoticeFromStorage,
openExpireNoticeIfNeeded,
closeExpireNotice,
}
}

View File

@@ -0,0 +1,235 @@
import type { Project } from '@/service/project'
import { uploadReport } from '@/api/report'
import { useCurrentProject } from '@/composables/useCurrentProject'
import { historyReportService, projectService, workRecordService } from '@/service'
import { useDbStore } from '@/store/modules/db'
import { getDeviceId } from '@/utils/device'
import storage from '@/utils/storage'
/** 本地时间字符串,格式与 seed 一致:YYYY-MM-DD HH:mm:ss */
function nowDateTime() {
return new Date().toISOString().replace('T', ' ').slice(0, 19)
}
/** 作业时长(小时),保留一位小数 */
function calcDurationHours(start?: string, end?: string) {
if (!start || !end)
return 0
const startMs = new Date(start.replace(/-/g, '/')).getTime()
const endMs = new Date(end.replace(/-/g, '/')).getTime()
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs)
return 0
return Math.round((endMs - startMs) / (1000 * 60 * 60) * 10) / 10
}
async function findOpenWorkRecord(projectId: number) {
const records = await workRecordService.selectByProjectId(projectId)
return records.find(item => item.id != null && !item.end_time) ?? null
}
/**
* 项目开始 / 暂停 / 结束:
* - 开始:status=2,新增工作记录(project_id + start_time)
* - 暂停:status=0,补全当前工作记录 end_time 与 duration
* - 结束:status=1,关闭未结束工作记录,写入历史报告
*/
export function useProjectLifecycle() {
const db = useDbStore()
const { currentProjectId, setCurrentProject } = useCurrentProject()
let busy = false
async function refreshAfterChange(projectId: number) {
await db.loadAll()
if (currentProjectId.value === projectId) {
const latest = await projectService.selectById(projectId)
if (latest && latest.status !== 1)
setCurrentProject(latest)
else if (!latest || latest.status === 1)
setCurrentProject(null)
}
}
async function withBusy(fn: () => Promise<void>) {
if (busy)
return
busy = true
try {
await fn()
}
finally {
busy = false
}
}
/** 开始项目 → 作业中 */
async function startProject(projectId: number) {
await withBusy(async () => {
const project = await projectService.selectById(projectId)
if (!project) {
uni.showToast({ title: '项目不存在', icon: 'none' })
return
}
if (project.status === 1) {
uni.showToast({ title: '项目已结束', icon: 'none' })
return
}
if (project.status === 2) {
uni.showToast({ title: '项目已在作业中', icon: 'none' })
return
}
const open = await findOpenWorkRecord(projectId)
if (open) {
uni.showToast({ title: '存在未结束的作业记录', icon: 'none' })
return
}
const startTime = nowDateTime()
await workRecordService.insert({
project_id: projectId,
start_time: startTime,
})
const payload: Partial<Project> = { status: 2 }
if (!project.start_time)
payload.start_time = startTime
await projectService.update(projectId, payload)
setCurrentProject({ ...project, ...payload })
await refreshAfterChange(projectId)
uni.showToast({ title: '已开始项目', icon: 'success' })
})
}
/** 暂停项目 → 已暂停,并结束当前工作记录 */
async function pauseProject(projectId: number) {
await withBusy(async () => {
const project = await projectService.selectById(projectId)
if (!project) {
uni.showToast({ title: '项目不存在', icon: 'none' })
return
}
if (project.status !== 2) {
uni.showToast({ title: '仅作业中可暂停', icon: 'none' })
return
}
const endTime = nowDateTime()
const open = await findOpenWorkRecord(projectId)
if (open?.id) {
await workRecordService.update(open.id, {
end_time: endTime,
duration: calcDurationHours(open.start_time, endTime),
})
}
await projectService.update(projectId, { status: 0 })
await refreshAfterChange(projectId)
uni.showToast({ title: '已暂停项目', icon: 'success' })
})
}
/** 结束项目 → 先上传报告,成功后再改本地状态并生成历史报告 */
async function endProject(projectId: number) {
await withBusy(async () => {
const project = await projectService.selectById(projectId)
if (!project) {
uni.showToast({ title: '项目不存在', icon: 'none' })
return
}
if (project.status === 1) {
uni.showToast({ title: '项目已经结束', icon: 'none' })
return
}
const latest = await projectService.selectById(projectId)
if (!latest) {
uni.showToast({ title: '项目不存在', icon: 'none' })
return
}
const deviceId = getDeviceId() || storage.get<string>('device_unique_id') || ''
if (!deviceId || !latest.project_name) {
uni.showToast({ title: '上传失败,请重新点击结束项目', icon: 'none' })
return
}
const endTime = nowDateTime()
const workStartTime = latest.start_time || endTime
const workRecords = await workRecordService.selectByProjectId(projectId)
const exceptionCount = workRecords.reduce((sum, item) => sum + (item.exception_count ?? 0), 0)
let uploaded = false
try {
const res = await uploadReport({
deviceId,
projectName: latest.project_name,
reportNo: `RTK-${projectId}`,
workStartTime,
workEndTime: endTime,
constructionSection: latest.stake_range,
compactArea: latest.total_compaction_area ?? 0,
qualificationRate: latest.qualified_rate ?? 0,
workDuration: latest.total_duration ?? 0,
passCount: latest.total_pass_count ?? 0,
avgSpeed: latest.avg_speed ?? 0,
avgTemperature: latest.avg_paving_temp ?? 0,
avgCompaction: latest.avg_compaction ?? 0,
exceptionCount,
})
uploaded = res.code === 200
}
catch {
uploaded = false
}
if (!uploaded) {
uni.showToast({ title: '上传失败,请重新点击结束项目', icon: 'none' })
return
}
// 上传成功后再改本地状态
const open = await findOpenWorkRecord(projectId)
if (open?.id) {
await workRecordService.update(open.id, {
end_time: endTime,
duration: calcDurationHours(open.start_time, endTime),
})
}
await historyReportService.insert({
project_id: projectId,
report_name: `${latest.project_name} 碾压报告`,
device_id: deviceId,
construction_area: latest.stake_range,
start_time: workStartTime,
end_time: endTime,
duration: latest.total_duration ?? 0,
compaction_area: latest.total_compaction_area ?? 0,
pass_count: latest.total_pass_count ?? 0,
qualified_rate: latest.qualified_rate ?? 0,
avg_speed: latest.avg_speed ?? 0,
avg_paving_temp: latest.avg_paving_temp ?? 0,
avg_compaction: latest.avg_compaction ?? 0,
exception_count: exceptionCount,
})
await projectService.update(projectId, {
status: 1,
end_time: endTime,
})
if (currentProjectId.value === projectId)
setCurrentProject(null)
await refreshAfterChange(projectId)
uni.showToast({ title: '项目已经结束', icon: 'success' })
})
}
return {
startProject,
pauseProject,
endProject,
}
}

View File

@@ -0,0 +1,101 @@
/**
* RTK 实时遥测:各页面共享最新轨迹点,随假数据/真数据推送刷新
*/
import type { MockRtkPoint } from '@/mock/rtkTrack'
import { computed, onUnmounted, ref } from 'vue'
import { createMockRtkStream } from '@/mock/rtkTrack'
const latestPoint = ref<MockRtkPoint | null>(null)
let stream: ReturnType<typeof createMockRtkStream> | null = null
let subscribers = 0
function startBackgroundStream() {
if (stream)
return
stream = createMockRtkStream({
intervalMs: 80,
loop: true,
rows: 80,
pointsPerRow: 40,
})
stream.start((point) => {
latestPoint.value = point
})
}
function stopBackgroundStream() {
stream?.stop()
stream = null
}
/** 轨迹画布 / 热力图推送最新点(可与后台流并存,以后者为准) */
export function publishRtkPoint(point: MockRtkPoint) {
latestPoint.value = point
}
export function useRtkLive() {
subscribers += 1
startBackgroundStream()
onUnmounted(() => {
subscribers -= 1
if (subscribers <= 0)
stopBackgroundStream()
})
const speed = computed(() => latestPoint.value?.speed ?? null)
const steelWheelTemp = computed(() => latestPoint.value?.steel_wheel_temp ?? null)
const compactionValue = computed(() => latestPoint.value?.compaction_value ?? null)
const passCount = computed(() => latestPoint.value?.pass_count ?? null)
const qualifiedRate = computed(() => latestPoint.value?.qualified_rate ?? null)
const heading = computed(() => latestPoint.value?.heading ?? null)
const latitude = computed(() => latestPoint.value?.latitude ?? null)
const longitude = computed(() => latestPoint.value?.longitude ?? null)
/** UI 仍展示的振动频率:由速度衍生的演示值 */
const vibrationHz = computed(() => {
const s = speed.value
if (s == null)
return null
return Number((26 + s * 0.6).toFixed(1))
})
/** UI 仍展示的振幅:由压实度衍生的演示值 */
const amplitudeMm = computed(() => {
const c = compactionValue.value
if (c == null)
return null
return Number((1.5 + (c - 85) * 0.04).toFixed(2))
})
const speedText = computed(() => (speed.value == null ? '--' : `${speed.value}km/h`))
const speedTextSpaced = computed(() => (speed.value == null ? '--' : `${speed.value} km/h`))
const tempText = computed(() => (steelWheelTemp.value == null ? '--' : `${steelWheelTemp.value}°C`))
const compactionText = computed(() => (compactionValue.value == null ? '--' : `${Math.round(compactionValue.value)}%`))
const passCountText = computed(() => (passCount.value == null ? '--' : `${passCount.value}遍`))
const frequencyText = computed(() => (vibrationHz.value == null ? '--' : `${vibrationHz.value}HZ`))
const amplitudeText = computed(() => (amplitudeMm.value == null ? '--' : `${amplitudeMm.value} mm`))
return {
latestPoint,
speed,
steelWheelTemp,
compactionValue,
passCount,
qualifiedRate,
heading,
latitude,
longitude,
vibrationHz,
amplitudeMm,
speedText,
speedTextSpaced,
tempText,
compactionText,
passCountText,
frequencyText,
amplitudeText,
publishRtkPoint,
}
}

8
src/env.d.ts vendored
View File

@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -1,5 +1,8 @@
import formattedDate from './time'
export { createMockRtkStream, generateSerpentineTrack } from './rtkTrack'
export type { MockRtkPoint, MockRtkStream, MockRtkStreamOptions } from './rtkTrack'
export const mockData = {
time: formattedDate,
message: 'Hello, this is mock data!',

207
src/mock/rtkTrack.ts Normal file
View File

@@ -0,0 +1,207 @@
/**
* RTK 轨迹假数据:蛇形碾压路径 + 持续推送
* 字段与 `@/service/rtkTrack` 的 RtkTrack 对齐
*/
/** 假数据点:与 rtk_track 表结构一致(坐标类字段必填便于绘制) */
export interface MockRtkPoint {
latitude: number // 纬度
longitude: number // 经度
speed: number // 行驶速度 km/h
heading: number // 航向角度(度,0=北,顺时针)
steel_wheel_temp: number // 钢轮温度 ℃
compaction_value: number // 压实度 %
pass_count: number // 压实遍数
qualified_rate: number // 压实合格率 %
work_record_id?: number // 所属作业记录 ID,关联 work_record.id
created_at?: string // 创建时间
}
export interface MockRtkStreamOptions {
/** 推送间隔 ms,默认 120 */
intervalMs?: number
/** 结束后是否循环,默认 true */
loop?: boolean
/** 横向行数,默认 80 */
rows?: number
/** 每行采样点数,默认 40 */
pointsPerRow?: number
/** 关联作业记录 id,默认 1 */
workRecordId?: number
}
export interface MockRtkStream {
readonly points: MockRtkPoint[]
start: (onPoint: (point: MockRtkPoint, index: number) => void) => void
stop: () => void
reset: () => void
getIndex: () => number
}
function normalizeHeading(deg: number) {
return ((deg % 360) + 360) % 360
}
function makeCompactionValue(seed: number) {
const wave = Math.sin(seed / 4) * 4
return Number(Math.min(98, Math.max(78, 88 + wave + (Math.random() - 0.5) * 3)).toFixed(1))
}
function makeSpeed(seed: number) {
return Number((3.2 + Math.sin(seed / 5) * 1.2 + Math.random() * 0.8).toFixed(1))
}
function makeSteelWheelTemp(seed: number) {
return Number((148 + Math.sin(seed / 7) * 8 + (Math.random() - 0.5) * 6).toFixed(0))
}
function makePassCount(seed: number) {
return Math.max(1, Math.min(8, Math.round(2 + (seed % 9) / 2 + Math.random())))
}
function makeQualifiedRate(compaction: number) {
return Number(Math.min(100, Math.max(70, compaction - 2 + (Math.random() - 0.5) * 4)).toFixed(1))
}
function nowDateTime() {
return new Date().toISOString().replace('T', ' ').slice(0, 19)
}
function buildPoint(
lat: number,
lon: number,
heading: number,
seed: number,
workRecordId: number,
): MockRtkPoint {
const compaction_value = makeCompactionValue(seed)
return {
work_record_id: workRecordId,
latitude: Number(lat.toFixed(7)),
longitude: Number(lon.toFixed(7)),
speed: makeSpeed(seed),
heading,
steel_wheel_temp: makeSteelWheelTemp(seed),
compaction_value,
pass_count: makePassCount(seed),
qualified_rate: makeQualifiedRate(compaction_value),
created_at: nowDateTime(),
}
}
function pushUTurnTriangle(
points: MockRtkPoint[],
startLat: number,
startLon: number,
fromHeading: number,
latStep: number,
eastward: boolean,
seedRef: { value: number },
workRecordId: number,
) {
const nextLat = startLat + latStep
const midLat = startLat + latStep / 2
const tipLatOffset = Math.abs(latStep) / 2
const metersPerDegLat = 111320
const metersPerDegLon = 111320 * Math.cos((startLat * Math.PI) / 180)
const tipLonOffset = (tipLatOffset * metersPerDegLat) / metersPerDegLon
const tipLon = startLon + (eastward ? tipLonOffset : -tipLonOffset)
const turnSign = eastward ? 1 : -1
points.push(buildPoint(
midLat,
tipLon,
normalizeHeading(fromHeading + 60 * turnSign),
seedRef.value++,
workRecordId,
))
points.push(buildPoint(
nextLat,
startLon,
normalizeHeading(fromHeading + 180 * turnSign),
seedRef.value++,
workRecordId,
))
}
/** 生成蛇形碾压路径假数据 */
export function generateSerpentineTrack(options: MockRtkStreamOptions = {}): MockRtkPoint[] {
const rows = options.rows ?? 80
const pointsPerRow = options.pointsPerRow ?? 40
const workRecordId = options.workRecordId ?? 1
const originLat = 39.0425
const originLon = 115.8620
const lonStep = 0.00012
const latStep = -0.000045
const points: MockRtkPoint[] = []
const seedRef = { value: 0 }
for (let row = 0; row < rows; row++) {
const eastward = row % 2 === 0
const heading = eastward ? 90 : 270
const lat = originLat + row * latStep
for (let col = 0; col < pointsPerRow; col++) {
const t = eastward ? col : (pointsPerRow - 1 - col)
const lon = originLon + t * lonStep
points.push(buildPoint(lat, lon, heading, seedRef.value++, workRecordId))
}
if (row < rows - 1) {
const endT = eastward ? (pointsPerRow - 1) : 0
const endLon = originLon + endT * lonStep
pushUTurnTriangle(points, lat, endLon, heading, latStep, eastward, seedRef, workRecordId)
}
}
return points
}
/** 持续推送假轨迹点 */
export function createMockRtkStream(options: MockRtkStreamOptions = {}): MockRtkStream {
const points = generateSerpentineTrack(options)
const intervalMs = options.intervalMs ?? 120
const loop = options.loop ?? true
let index = 0
let timer: ReturnType<typeof setInterval> | null = null
function stop() {
if (timer != null) {
clearInterval(timer)
timer = null
}
}
function reset() {
stop()
index = 0
}
function start(onPoint: (point: MockRtkPoint, index: number) => void) {
stop()
timer = setInterval(() => {
if (index >= points.length) {
if (loop) {
index = 0
}
else {
stop()
return
}
}
onPoint(points[index], index)
index += 1
}, intervalMs)
}
return {
points,
start,
stop,
reset,
getIndex: () => index,
}
}

View File

@@ -16,7 +16,7 @@
"navigationBarTitleText": "Road-Roller"
},
"pages": [
// GENERATED BY UNI-PAGES, PLATFORM: APP
// GENERATED BY UNI-PAGES, PLATFORM: APP || H5
{
"path": "pages/Ahome/index",
"type": "home",
@@ -121,7 +121,7 @@
"color": "@tabFontColor",
"selectedColor": "@tabSelectedColor",
"list": [
// GENERATED BY UNI-PAGES, PLATFORM: APP
// GENERATED BY UNI-PAGES, PLATFORM: APP || H5
{
"pagePath": "pages/Ahome/index",
"text": "首页",

View File

@@ -1,4 +1,8 @@
<script setup lang="ts">
import { onShow } from '@dcloudio/uni-app'
import { nextTick, onMounted } from 'vue'
import { useDeviceAuth } from '@/composables/useDeviceAuth'
definePage({
type: 'home',
layout: false,
@@ -6,6 +10,28 @@ definePage({
navigationStyle: 'custom',
},
})
const { needAuthPrompt, initAuth, refreshAuthStatus, openExpireNoticeIfNeeded } = useDeviceAuth({ watchShow: true })
async function checkExpireNotice() {
await nextTick()
// App 端渲染稍慢,延迟再读 storage 弹窗
setTimeout(() => {
openExpireNoticeIfNeeded()
}, 300)
}
onMounted(async () => {
await initAuth()
await checkExpireNotice()
})
/** 每次打开 / 回到首页:重新查询授权状态 + 判断是否弹到期提醒 */
onShow(async () => {
// 查询到 authStatus 为 1 时,needAuthPrompt 会自动变 false,隐藏未授权组件
await refreshAuthStatus()
await checkExpireNotice()
})
</script>
<template>
@@ -21,11 +47,14 @@ definePage({
<home-projectList />
</view>
<view class="column-right">
<home-activateNotice v-if="needAuthPrompt" />
<home-progress />
<home-warning />
</view>
</view>
</view>
<expire-notice />
</view>
</template>

View File

@@ -1,23 +1,66 @@
<script lang="ts" setup>
import type { HeatmapLayerKey } from '@/components/heatmap/heatUtils'
import type { MockRtkPoint } from '@/mock/rtkTrack'
import { ref } from 'vue'
definePage({
layout: false,
style: {
navigationStyle: 'custom',
},
})
const layer = ref<HeatmapLayerKey>('compaction_value')
const layerVisible = ref(false)
const detailVisible = ref(false)
const selectedPoint = ref<MockRtkPoint | null>(null)
function onSelectPoint(point: MockRtkPoint) {
selectedPoint.value = point
detailVisible.value = true
layerVisible.value = false
}
function closeDetail() {
detailVisible.value = false
}
function onBlankTap() {
if (layerVisible.value)
layerVisible.value = false
}
</script>
<template>
<view class="page">
<!-- 顶部导航 -->
<view class="page" @tap="onBlankTap">
<FullscreenNav />
<!-- 占位内容 -->
<view class="content">
<text class="placeholder-text">
压实热力图
<scroll-view class="content" scroll-y>
<view class="card" @tap.stop>
<view class="toolbar">
<text class="tip">
点击地图查看点位详情
</text>
<heatmap-layerPanel
v-model="layer"
v-model:visible="layerVisible"
/>
</view>
<heatmap-canvas
:layer="layer"
@select-point="onSelectPoint"
/>
<heatmap-legend :layer="layer" />
</view>
</scroll-view>
<heatmap-pointDetail
:visible="detailVisible"
:point="selectedPoint"
@close="closeDetail"
/>
</view>
</template>
@@ -25,19 +68,38 @@ definePage({
.page {
min-height: 100vh;
background-color: #EAEFF9;
display: flex;
flex-direction: column;
}
.content {
padding: 16px;
flex: 1;
height: 0;
padding: 12px 16px;
padding-top: 140px;
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 120px);
box-sizing: border-box;
}
.placeholder-text {
font-size: 18px;
color: #999;
.card {
background: #fff;
border-radius: 12px;
padding: 12px;
}
.toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.tip {
flex: 1;
font-size: 13px;
color: #666;
/* background: #f8fafc; */
border-radius: 8px;
padding: 10px 12px;
}
</style>

View File

@@ -38,6 +38,8 @@ const projects = computed(() => {
}
return true
})
.slice()
.reverse()
.map(mapProjectItem)
})
@@ -107,6 +109,7 @@ onMounted(async () => {
</view>
<project-item
v-for="item in projects"
:id="item.id"
:key="item.id"
:project-name="item.projectName"
:stake-range="item.stakeRange"

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { ReportItem } from '@/api/report'
import type { HistoryReport } from '@/service/historyReport'
import { onLoad } from '@dcloudio/uni-app'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { getReportDetail, getReportDownloadUrl } from '@/api/report'
import { useDbStore } from '@/store/modules/db'
import storage from '@/utils/storage'
definePage({
layout: false,
@@ -29,20 +30,24 @@ interface ReportDetailView {
}
const db = useDbStore()
const reportId = ref<number | null>(null)
const reportData = ref<ReportDetailView | null>(null)
const apiReport = ref<ReportItem | null>(null)
const rawReport = ref<HistoryReport | null>(null)
const downloading = ref(false)
function parseDate(value?: string) {
function parseDate(value?: string | null) {
if (!value)
return null
const date = new Date(value.replace(/-/g, '/'))
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) {
function formatDateTimeRange(start?: string | null, end?: string | null) {
const startDate = parseDate(start)
const endDate = parseDate(end)
if (!startDate)
@@ -60,23 +65,45 @@ function formatDateTimeRange(start?: string, end?: string) {
return `${datePart}${startHm}-${endDate.getFullYear()}年${pad(endDate.getMonth() + 1)}月${pad(endDate.getDate())}日${endHm}`
}
function formatDuration(value?: number) {
function formatDuration(value?: number | null) {
const hours = value ?? 0
const text = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
return `${text}H`
}
function formatArea(value?: number) {
function formatArea(value?: number | null) {
return `${value ?? 0}m²`
}
function formatRate(value?: number) {
function formatRate(value?: number | null) {
const rate = value ?? 0
const text = Number.isInteger(rate) ? String(rate) : rate.toFixed(1)
return `${text}%`
}
function mapReportDetail(item: HistoryReport): ReportDetailView {
function formatMetric(value: number | null | undefined, unit: string) {
return value != null ? `${value}${unit}` : '-'
}
function mapApiReportDetail(item: ReportItem): ReportDetailView {
return {
deviceCode: item.deviceId || '-',
time: formatDateTimeRange(item.workStartTime, item.workEndTime),
status: String(item.reportStatus) === '1' ? '有异常' : '作业已完成',
duration: formatDuration(item.workDuration),
areaCount: formatArea(item.compactArea),
passCount: `${item.passCount ?? 0}遍`,
passRate: formatRate(item.qualificationRate),
avgSpeed: formatMetric(item.avgSpeed, 'km/h'),
avgFrequency: formatMetric(item.avgFrequency, 'Hz'),
avgAmplitude: formatMetric(item.avgAmplitude, 'mm'),
avgTemperature: formatMetric(item.avgTemperature, '℃'),
avgCompaction: item.avgCompaction != null ? formatRate(item.avgCompaction) : '-',
exceptionCount: String(item.exceptionCount ?? 0),
}
}
function mapLocalReportDetail(item: HistoryReport): ReportDetailView {
return {
deviceCode: item.device_id || '-',
time: formatDateTimeRange(item.start_time, item.end_time),
@@ -85,62 +112,179 @@ function mapReportDetail(item: HistoryReport): ReportDetailView {
areaCount: formatArea(item.compaction_area),
passCount: `${item.pass_count ?? 0}遍`,
passRate: formatRate(item.qualified_rate),
avgSpeed: item.avg_speed != null ? `${item.avg_speed}km/h` : '-',
avgSpeed: formatMetric(item.avg_speed, 'km/h'),
avgFrequency: '-',
avgAmplitude: '-',
avgTemperature: item.avg_paving_temp != null ? `${item.avg_paving_temp}℃` : '-',
avgTemperature: formatMetric(item.avg_paving_temp, '℃'),
avgCompaction: item.avg_compaction != null ? formatRate(item.avg_compaction) : '-',
exceptionCount: String(item.exception_count ?? 0),
}
}
function applyStoragePayload(data: Record<string, any>) {
reportData.value = {
deviceCode: data.deviceCode || '-',
time: data.time || '-',
status: data.status || '作业已完成',
duration: data.duration || '-',
areaCount: data.areaCount || '-',
passCount: data.passCount || '-',
passRate: data.passRate || '-',
avgSpeed: data.avgSpeed || '-',
avgFrequency: data.avgFrequency || '-',
avgAmplitude: data.avgAmplitude || '-',
avgTemperature: data.avgTemperature || '-',
avgCompaction: data.avgCompaction || '-',
exceptionCount: data.exceptionCount || '-',
}
/** 兼容 data 嵌套 / 扁平结构 */
function pickReportPayload(res: unknown): ReportItem | null {
if (!res || typeof res !== 'object')
return null
const raw = res as Record<string, unknown>
const nested = raw.data
if (nested && typeof nested === 'object' && 'reportId' in (nested as object))
return nested as ReportItem
if ('reportId' in raw)
return raw as unknown as ReportItem
return null
}
onLoad(async (options) => {
if (options?.key) {
const data = storage.get(options.key)
if (data)
applyStoragePayload(data)
storage.remove(options.key)
}
const id = Number(options?.id)
if (!id)
return
if (!db.history_report.length)
async function loadLocalFallback(id: number) {
if (!db.history_report.length || !db.project.length)
await db.loadAll()
const item = db.history_report.find(report => report.id === id)
if (item)
reportData.value = mapReportDetail(item)
if (item) {
rawReport.value = item
apiReport.value = null
reportData.value = mapLocalReportDetail(item)
}
}
/** 优先请求详情接口,失败回退本地库 */
async function loadReportDetail() {
const id = reportId.value
if (!id)
return
try {
const res = await getReportDetail(id)
if (res.code === 200) {
const payload = pickReportPayload(res)
if (payload) {
apiReport.value = payload
rawReport.value = null
reportData.value = mapApiReportDetail(payload)
return
}
}
}
catch {
// 接口不通,走本地
}
await loadLocalFallback(id)
}
onLoad((options) => {
const id = Number(options?.reportId || options?.id)
reportId.value = id || null
})
function handleDownload() {
console.log('点击下载')
onShow(() => {
loadReportDetail()
})
function resolveDownloadFileName(id: number) {
const reportNo = apiReport.value?.reportNo
const ext = apiReport.value?.filePath?.match(/\.[^./\\]+$/)?.[0] || '.pdf'
return `${reportNo || `report_${id}`}${ext.startsWith('.') ? ext : `.${ext}`}`
}
/** H5:浏览器直接下载 */
function downloadOnH5(url: string, fileName: string) {
const a = document.createElement('a')
a.href = url
a.download = fileName
a.target = '_blank'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
/** App:下载到本地文件夹并提示路径 */
function downloadOnApp(url: string, fileName: string) {
return new Promise<void>((resolve, reject) => {
const relativePath = `_downloads/RoadRoller/${fileName}`
const task = plus.downloader.createDownload(url, { filename: relativePath }, (download, status) => {
if (status !== 200) {
reject(new Error('下载失败'))
return
}
const localPath = download.filename || relativePath
let displayPath = localPath
try {
displayPath = plus.io.convertLocalFileSystemURL(localPath) || localPath
}
catch {
// 转换失败时展示原始路径
}
uni.showModal({
title: '下载成功',
content: `文件已保存至:\n${displayPath}`,
showCancel: false,
})
resolve()
})
task.start()
})
}
async function handleDownload() {
const id = reportId.value
if (!id) {
uni.showToast({ title: '报告ID缺失', icon: 'none' })
return
}
if (downloading.value)
return
const url = getReportDownloadUrl(id)
const fileName = resolveDownloadFileName(id)
downloading.value = true
uni.showLoading({ title: '下载中...', mask: true })
try {
// #ifdef H5
downloadOnH5(url, fileName)
uni.showToast({ title: '已开始下载', icon: 'success' })
// #endif
// #ifdef APP-PLUS
await downloadOnApp(url, fileName)
// #endif
// #ifndef H5 || APP-PLUS
await new Promise<void>((resolve, reject) => {
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode !== 200) {
reject(new Error('下载失败'))
return
}
uni.openDocument({
filePath: res.tempFilePath,
showMenu: true,
fail: () => uni.showToast({ title: '文件已下载', icon: 'success' }),
})
resolve()
},
fail: reject,
})
})
// #endif
}
catch (e: any) {
uni.showToast({ title: e?.message || '下载失败', icon: 'none' })
}
finally {
downloading.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="downloading" :disabled="downloading" @tap="handleDownload" />
</back-nav>
<view class="content">

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import type { ReportItem } from '@/api/report'
import type { HistoryReport } from '@/service/historyReport'
import { computed, onMounted, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { getReportList } from '@/api/report'
import { useDbStore } from '@/store/modules/db'
definePage({
@@ -10,8 +13,27 @@ definePage({
},
})
/** 列表统一结构,便于接口 / 本地双数据源筛选展示 */
interface ReportListRow {
id?: number
title: string
deviceCode: string
status: string
time: string
area: string
duration: string
areaCount: string
passRate: string
projectId: string
startTime?: string
reportStatus: string
}
const db = useDbStore()
/** 非 null 表示接口可用,优先用接口数据 */
const apiReports = ref<ReportItem[] | null>(null)
const datetimerange = ref<string[]>([])
const reportStatus = ref('')
const area = ref('')
@@ -30,24 +52,48 @@ const reportStatusOptions = [
{ value: 'pending', text: '待审核' },
]
const listRows = computed<ReportListRow[]>(() => {
if (apiReports.value) {
return apiReports.value
.slice()
.sort((a, b) => (b.reportId ?? 0) - (a.reportId ?? 0))
.map(mapApiReportItem)
}
return db.history_report
.slice()
.sort((a, b) => (b.id ?? 0) - (a.id ?? 0))
.map(mapLocalReportItem)
})
const areaOptions = computed(() => {
const areas = [...new Set(db.history_report.map(item => item.construction_area).filter(Boolean))] as string[]
const areas = [...new Set(listRows.value.map(item => item.area).filter(v => v && v !== '-'))]
return [
{ value: 'all', text: '全部' },
...areas.map(text => ({ value: text, text })),
]
})
const projectOptions = computed(() => [
const projectOptions = computed(() => {
if (apiReports.value) {
const map = new Map<string, string>()
for (const item of apiReports.value) {
if (item.projectId != null)
map.set(String(item.projectId), item.projectName || String(item.projectId))
}
return [
{ value: 'all', text: '全部' },
...[...map.entries()].map(([value, text]) => ({ value, text })),
]
}
return [
{ value: 'all', text: '全部' },
...db.project.map(item => ({ value: String(item.id), text: item.project_name })),
])
]
})
const reports = computed(() => {
const query = appliedQuery.value
return db.history_report
.filter(item => matchReport(item, query))
.map(mapReportItem)
return listRows.value.filter(item => matchReport(item, query))
})
function isAll(value: string) {
@@ -55,16 +101,18 @@ function isAll(value: string) {
}
function matchReport(
item: HistoryReport,
item: ReportListRow,
query: { datetimerange: string[], reportStatus: string, area: string, project: string },
) {
if (!isAll(query.project) && String(item.project_id) !== query.project)
if (!isAll(query.project) && item.projectId !== query.project)
return false
if (!isAll(query.area) && item.construction_area !== query.area)
if (!isAll(query.area) && item.area !== query.area)
return false
if (query.reportStatus === 'pending')
return false
if (!matchDateRange(item.start_time, query.datetimerange))
if (query.reportStatus === 'completed' && item.reportStatus === '1')
return false
if (!matchDateRange(item.startTime, query.datetimerange))
return false
return true
}
@@ -109,23 +157,46 @@ function formatDateTimeRange(start?: string, end?: string) {
return `${datePart}${startHm}-${endDate.getFullYear()}年${pad(endDate.getMonth() + 1)}月${pad(endDate.getDate())}日${endHm}`
}
function formatDuration(value?: number) {
function formatDuration(value?: number | null) {
const hours = value ?? 0
const text = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
return `${text}H`
}
function formatArea(value?: number) {
function formatArea(value?: number | null) {
return `${value ?? 0}m²`
}
function formatRate(value?: number) {
function formatRate(value?: number | null) {
const rate = value ?? 0
const text = Number.isInteger(rate) ? String(rate) : rate.toFixed(1)
return `${text}%`
}
function mapReportItem(item: HistoryReport) {
function mapReportStatus(status?: ReportItem['reportStatus']) {
if (String(status) === '1')
return '有异常'
return '已完成'
}
function mapApiReportItem(item: ReportItem): ReportListRow {
return {
id: item.reportId,
title: item.constructionSection ? `${item.constructionSection}压实作业报告` : '压实作业报告',
deviceCode: item.deviceId || '-',
status: mapReportStatus(item.reportStatus),
time: formatDateTimeRange(item.workStartTime, item.workEndTime),
area: item.constructionSection || '-',
duration: formatDuration(item.workDuration),
areaCount: formatArea(item.compactArea),
passRate: formatRate(item.qualificationRate),
projectId: item.projectId != null ? String(item.projectId) : '',
startTime: item.workStartTime,
reportStatus: String(item.reportStatus ?? '0'),
}
}
function mapLocalReportItem(item: HistoryReport): ReportListRow {
return {
id: item.id,
title: item.report_name,
@@ -136,6 +207,9 @@ function mapReportItem(item: HistoryReport) {
duration: formatDuration(item.duration),
areaCount: formatArea(item.compaction_area),
passRate: formatRate(item.qualified_rate),
projectId: item.project_id != null ? String(item.project_id) : '',
startTime: item.start_time,
reportStatus: '0',
}
}
@@ -148,9 +222,30 @@ function handleQuery() {
}
}
onMounted(async () => {
if (!db.history_report.length)
await db.loadAll()
/** 优先拉接口;code 非 200 或请求失败则回退本地 SQLite */
async function loadReports() {
// 本地库并行准备,不阻塞接口请求
const localTask = (!db.history_report.length || !db.project.length)
? db.loadAll().catch(() => undefined)
: Promise.resolve()
try {
const res = await getReportList()
if (res.code === 200 && Array.isArray(res.rows)) {
apiReports.value = res.rows
return
}
}
catch {
// 接口不通,走本地
}
apiReports.value = null
await localTask
}
onShow(() => {
loadReports()
})
</script>
@@ -226,8 +321,8 @@ onMounted(async () => {
</view>
<report-item
v-for="item in reports"
:id="item.id"
:key="item.id"
:report-id="item.id"
:title="item.title"
:device-code="item.deviceCode"
:status="item.status"

View File

@@ -2,7 +2,6 @@
definePage({
layout: false,
style: {
// navigationBarTitleText: 'RTK轨迹',
navigationStyle: 'custom',
},
})
@@ -10,15 +9,17 @@ definePage({
<template>
<view class="page">
<!-- 顶部导航 -->
<FullscreenNav />
<!-- 占位内容 -->
<view class="content">
<text class="placeholder-text">
RTK轨迹
</text>
<!-- 放大展示与作业页相同的 RTK 轨迹绘制 -->
<scroll-view class="content" scroll-y>
<view class="canvas-card">
<work-rtkCanvas
canvas-id="rtkTrackCanvasFull"
:navigate-on-click="false"
/>
</view>
</scroll-view>
</view>
</template>
@@ -26,19 +27,24 @@ definePage({
.page {
min-height: 100vh;
background-color: #EAEFF9;
display: flex;
flex-direction: column;
}
.content {
padding: 16px;
flex: 1;
height: 0;
padding: 12px 16px;
padding-top: 140px;
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 120px);
box-sizing: border-box;
}
.placeholder-text {
font-size: 18px;
color: #999;
.canvas-card {
background: #fff;
border-radius: 12px;
padding: 12px;
/* 全屏宽度绘制 → 比例尺更大,视觉上放大 */
width: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -40,11 +40,12 @@ definePage({
padding-top: 72px;
}
/* 双列布局 */
/* 双列:最小一屏,内容变高时整页跟着撑开可滚动 */
.two-column {
display: flex;
align-items: flex-start;
gap: 12px;
height: calc(100vh - 84px);
min-height: calc(100vh - 84px);
}
.column-left {
@@ -52,11 +53,16 @@ definePage({
display: flex;
flex-direction: column;
gap: 12px;
position: sticky;
top: 72px;
}
.column-right {
flex: 1;
display: flex;
flex-direction: column;
/* 右侧白卡随轨迹变高,不锁成一屏高 */
height: auto;
align-self: stretch;
}
</style>

View File

@@ -28,7 +28,7 @@ const tableSqls = [
/** 当前代码期望的各表字段(用于检测旧库结构) */
const expectedColumns: Record<string, string[]> = {
project: ['id', 'project_name', 'stake_range', 'total_area', 'start_time', 'end_time', 'total_duration', 'total_compaction_area', 'total_pass_count', 'qualified_rate', 'progress', 'uncompacted_area', 'status', 'created_at', 'updated_at'],
project: ['id', 'project_name', 'stake_range', 'total_area', 'start_time', 'end_time', 'total_duration', 'total_compaction_area', 'total_pass_count', 'qualified_rate', 'progress', 'uncompacted_area', 'avg_speed', 'avg_paving_temp', 'avg_compaction', 'status', 'created_at', 'updated_at'],
work_record: ['id', 'project_id', 'start_time', 'end_time', 'duration', 'compaction_area', 'pass_count', 'exception_count', 'created_at'],
rtk_track: ['id', 'work_record_id', 'latitude', 'longitude', 'speed', 'heading', 'steel_wheel_temp', 'compaction_value', 'pass_count', 'qualified_rate', 'created_at'],
warning_record: ['id', 'project_id', 'device_name', 'exception_type', 'exception_level', 'occurred_at', 'site_description', 'disposal_record', 'disposal_type', 'is_read', 'created_at'],

View File

@@ -15,6 +15,9 @@ export const projectTableSql = `CREATE TABLE IF NOT EXISTS project (
qualified_rate REAL DEFAULT 0,
progress REAL DEFAULT 0,
uncompacted_area REAL DEFAULT 0,
avg_speed REAL DEFAULT 0,
avg_paving_temp REAL DEFAULT 0,
avg_compaction REAL DEFAULT 0,
status INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -33,6 +36,9 @@ export interface Project {
qualified_rate?: number // 压实合格率,默认 0
progress?: number // 完成进度,默认 0
uncompacted_area?: number // 未压实面积 = 总面积 - 压实面积,默认 0
avg_speed?: number // 平均行驶速度,默认 0
avg_paving_temp?: number // 平均温度,默认 0
avg_compaction?: number // 平均压实度,默认 0
status: 0 | 1 | 2 // 项目状态,0:已暂停,1:已结束,2:作业中,默认 0
created_at?: string // 创建时间,默认当前时间
updated_at?: string // 更新时间,默认当前时间
@@ -68,14 +74,23 @@ export async function selectByStatus(status: 0 | 1): Promise<Project[]> {
return DB.selectByField(TABLE, 'status', status)
}
/** 新增项目 */
/** 新增项目,返回新记录 id */
export async function insert(data: Omit<Project, 'id'>): Promise<number> {
await DB.insert(TABLE, {
...data,
uncompacted_area: calcUncompactedArea(data.total_area, data.total_compaction_area),
})
const result = await DB.selectSql('SELECT last_insert_rowid() as id')
return result?.[0]?.id || 0
let id = Number(result?.[0]?.id) || 0
// 部分端上 last_insert_rowid 不稳定,回退取最新一条
if (!id) {
const latest = await DB.select(TABLE, '*', undefined, 'id DESC', 1)
id = Number(latest?.[0]?.id) || 0
}
return id
}
/** 更新项目;标记为已完成(status=1)时清除该项目的作业记录与 RTK 轨迹 */

View File

@@ -46,6 +46,15 @@ export async function selectByWorkRecordId(workRecordId: number): Promise<RtkTra
return DB.selectByField(TABLE, 'work_record_id', workRecordId)
}
/** 按项目 ID 查询(通过 work_record 关联) */
export async function selectByProjectId(projectId: number): Promise<RtkTrack[]> {
return DB.selectSql(
`SELECT t.* FROM ${TABLE} t
INNER JOIN work_record w ON t.work_record_id = w.id
WHERE w.project_id = ${projectId}`,
)
}
/** 新增轨迹点 */
export async function insert(data: RtkTrack): Promise<any> {
return DB.insert(TABLE, data)

View File

@@ -26,24 +26,36 @@ async function seedProject() {
project_name: '白沟产业园场地',
stake_range: '1号地块',
total_area: 5200.0,
avg_speed: 5.1,
avg_paving_temp: 148.0,
avg_compaction: 93.2,
status: 1,
},
{
project_name: '大兴机场配套道路',
stake_range: '北侧路段',
total_area: 8800.0,
avg_speed: 4.9,
avg_paving_temp: 152.0,
avg_compaction: 94.1,
status: 1,
},
{
project_name: '保定城区改造工程',
stake_range: '朝阳路 ~ 裕华路',
total_area: 12000.0,
avg_speed: 5.3,
avg_paving_temp: 145.0,
avg_compaction: 91.8,
status: 1,
},
{
project_name: '沧州港区道路',
stake_range: 'B区 ~ D区',
total_area: 9500.0,
avg_speed: 4.7,
avg_paving_temp: 150.0,
avg_compaction: 95.3,
status: 1,
},
]

View File

@@ -1,4 +1,4 @@
// 当日工作记录表
// 工作记录表
import DB from '@/utils/sqlite'
const TABLE = 'work_record'

View File

@@ -20,6 +20,7 @@ import {
* import { useDbStore } from '@/store/modules/db'
* const db = useDbStore()
* await db.loadAll()
* await db.loadByProject(projectId) // 按当前项目加载关联数据
*
* db.project // 项目表
* db.work_record // 作业记录表
@@ -50,6 +51,25 @@ export const useDbStore = defineStore('db', () => {
history_report.value = historyReports
}
/**
* 按项目加载关联数据(作业 / 轨迹 / 告警)
* 项目列表、历史报告始终全量加载,不按项目 id 过滤
*/
async function loadByProject(projectId: number) {
const [projects, workRecords, rtkTracks, warningRecords, historyReports] = await Promise.all([
projectService.selectAll(),
workRecordService.selectByProjectId(projectId),
rtkTrackService.selectByProjectId(projectId),
warningRecordService.selectByProjectId(projectId),
historyReportService.selectAll(),
])
project.value = projects
work_record.value = workRecords
rtk_track.value = rtkTracks
warning_record.value = warningRecords
history_report.value = historyReports
}
/** 按项目筛选作业记录 */
async function loadWorkRecordByProject(projectId: number) {
work_record.value = await workRecordService.selectByProjectId(projectId)
@@ -65,9 +85,9 @@ export const useDbStore = defineStore('db', () => {
warning_record.value = await warningRecordService.selectByProjectId(projectId)
}
/** 按项目筛选历史报告 */
async function loadHistoryReportByProject(projectId: number) {
history_report.value = await historyReportService.selectByProjectId(projectId)
/** 加载全部历史报告(不按项目过滤) */
async function loadHistoryReportAll() {
history_report.value = await historyReportService.selectAll()
}
return {
@@ -77,9 +97,10 @@ export const useDbStore = defineStore('db', () => {
warning_record,
history_report,
loadAll,
loadByProject,
loadWorkRecordByProject,
loadRtkTrackByWorkRecord,
loadWarningByProject,
loadHistoryReportByProject,
loadHistoryReportAll,
}
})

View File

@@ -83,7 +83,7 @@ function generateUUID(): string {
/**
* 获取设备唯一标识
*/
function getDeviceId(): string {
export function getDeviceId(): string {
// 从存储获取
const storedId = storage.get<string>(DEVICE_ID_KEY)
if (storedId) {
@@ -195,6 +195,7 @@ export function getOrInitDeviceInfo(): SystemInfo {
export default {
getSystemInfo,
getDeviceId,
getDeviceTypeName,
getOsTypeName,
saveDeviceInfo,

View File

@@ -23,6 +23,12 @@
*
* // 获取所有key
* storage.keys()
*
* // 打印全部数据(key: value 形式)
* storage.print()
*
* // 打印全部数据并返回对象
* const all = storage.all()
*/
interface StorageItem<T = any> {
@@ -120,7 +126,86 @@ const storage = {
* @returns key数组
*/
keys(): string[] {
return Object.keys(uni.getStorageInfoSync().keys)
return uni.getStorageInfoSync().keys || []
},
/**
* 获取全部数据(key: value 形式的普通对象)
* 会自动过滤已过期的 key
* @returns 键值对对象
*/
all(): Record<string, any> {
const result: Record<string, any> = {}
for (const key of this.keys()) {
const raw = uni.getStorageSync(key)
if (!raw)
continue
try {
const item: StorageItem = JSON.parse(raw)
// 已过期则顺手清掉,不放进结果
if (item.expire && item.expire < Date.now()) {
this.remove(key)
continue
}
result[key] = item.value
}
catch {
// 非法 JSON(早期版本或其它代码直接写的裸数据)原样返回
result[key] = raw
}
}
return result
},
/**
* 打印全部数据到控制台(key: value 形式)
* H5 直接看浏览器控制台,App / 小程序看 HBuilderX 的控制台
*
* 注意:App 端真机的 console 会过滤多参数、合并日志,
* 所以这里用「一条完整字符串」输出,保证一定能看到。
*
* @returns 键值对对象,便于链式调试
*/
print(): Record<string, any> {
const data = this.all()
const keys = Object.keys(data)
if (!keys.length) {
console.log('[storage] 空')
return data
}
// 拼成单条字符串,避免 App 端多参数日志被截断或吞掉
const lines = [`[storage] 共 ${keys.length} 项`]
keys.forEach((key) => {
lines.push(` ${key}: ${this.format(data[key])}`)
})
lines.push(`[storage] 完整对象: ${JSON.stringify(data)}`)
console.log(lines.join('\n'))
return data
},
/**
* 把值格式化成单行字符串,避免对象打印成 [object Object]
*/
format(value: any): string {
if (value === null)
return 'null'
if (value === undefined)
return 'undefined'
if (typeof value === 'string')
return value
if (typeof value === 'object') {
try {
return JSON.stringify(value)
}
catch {
return String(value)
}
}
return String(value)
},
}

View File

@@ -9,6 +9,78 @@ import AutoImport from 'unplugin-auto-import/vite'
import { defineConfig } from 'vite'
import UniPolyfill from 'vite-plugin-uni-polyfill'
/**
* 兜底处理 uni:json 插件对 pages.json 的解析失败
*
* 背景:vite-plugin-uni-pages 会用 comment-json 往 pages.json 里写入
* `// GENERATED BY UNI-PAGES...` 注释。uni-app 的 uni:json 插件本应用
* jsonc-parser 吃掉注释,但当它拿到半截文件内容(HBuilderX 写入与读取竞态)
* 时 parse 会返回 undefined,导致插件没产出 code,随后 Vite 内置 vite:json
* 用严格 JSON.parse 接手,撞上注释直接报错:
* Failed to parse JSON file, invalid JSON syntax found at position -1
*
* 这个插件挂在最前面(enforce: 'pre'),保证 vite:json 拿到的一定是无注释的
* 合法 JSON,从而绕开该问题。
*/
function stripJsonComments() {
return {
name: 'strip-json-comments',
enforce: 'pre' as const,
transform(code: string, id: string) {
// 只处理 src 下的 pages.json / manifest.json
if (!/[\\/](?:pages|manifest)\.json(?:\?.*)?$/.test(id))
return null
// 本身就不含注释,交回后续插件处理
if (!code.includes('//'))
return null
let inString = false
let escaped = false
let result = ''
for (let i = 0; i < code.length; i++) {
const ch = code[i]
if (inString) {
result += ch
if (escaped)
escaped = false
else if (ch === '\\')
escaped = true
else if (ch === '"')
inString = false
continue
}
if (ch === '"') {
inString = true
result += ch
continue
}
// 行注释:跳到行尾
if (ch === '/' && code[i + 1] === '/') {
while (i < code.length && code[i] !== '\n') i++
result += '\n'
continue
}
// 块注释:跳到结束
if (ch === '/' && code[i + 1] === '*') {
i += 2
while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++
i++
continue
}
result += ch
}
return { code: result, map: null }
},
}
}
// https://vitejs.dev/config/
export default defineConfig({
css: {
@@ -20,6 +92,8 @@ export default defineConfig({
},
},
plugins: [
// 必须放最前:在 uni:json / vite:json 之前剥掉 pages.json 里的注释
stripJsonComments(),
// https://uni-helper.js.org/vite-plugin-uni-manifest
UniHelperManifest(),
// https://uni-helper.js.org/vite-plugin-uni-pages