Compare commits
19 Commits
fd569657d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4b0413e42 | ||
|
|
d1b2d981b5 | ||
|
|
54a5f96644 | ||
|
|
8ffbc10a4e | ||
|
|
7a24c6bfd0 | ||
|
|
5a7d56265c | ||
|
|
c7694df2cc | ||
|
|
7eb6a626ad | ||
|
|
8a28f8654c | ||
|
|
5ab82d4571 | ||
|
|
4ff3e92049 | ||
|
|
59379dff79 | ||
|
|
65fc29f777 | ||
|
|
faf4ea1155 | ||
|
|
c1d2958095 | ||
|
|
1ae0371c18 | ||
|
|
449cf1f6c7 | ||
|
|
0399e8ede4 | ||
|
|
e803ffa38c |
1
.env.development
Normal file
1
.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://192.168.0.136:8080
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://192.168.0.136:8080
|
||||||
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# TODO: 替换为正式环境 API 地址
|
||||||
|
VITE_API_BASE_URL=https://api.example.com
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -6,12 +6,21 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
doc
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
*.local
|
*.local
|
||||||
.omo
|
.omo
|
||||||
|
.clinerules
|
||||||
|
CLAUDE.md
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
.claude
|
||||||
|
.rtk
|
||||||
|
.workbuddy
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -1,10 +1,38 @@
|
|||||||
|
<h2 align="center">
|
||||||
|
Road Roller - 压路机智能监控管理系统
|
||||||
|
</h2>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
</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) - 就是快!
|
- ⚡️ [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 请求封装(拦截器、基础配置)
|
│ │ ├── request.ts # Axios 请求封装(拦截器、基础配置)
|
||||||
│ │ └── home/ # 首页相关接口
|
│ │ └── home/ # 首页相关接口
|
||||||
│ ├── components/ # 公共组件(自动加载)
|
│ ├── components/ # 公共组件(自动加载)
|
||||||
│ │ ├── AppFooter.vue
|
│ │ ├── home/ # 首页组件(进度、概览、项目列表等)
|
||||||
│ │ ├── AppLogos.vue
|
│ │ ├── settings/ # 设置组件(项目表单等)
|
||||||
│ │ ├── HiCounter.vue
|
│ │ └── ...
|
||||||
│ │ └── InputEntry.vue
|
|
||||||
│ ├── composables/ # 组合式函数(自动加载)
|
│ ├── composables/ # 组合式函数(自动加载)
|
||||||
│ │ ├── useCount.ts # 计数器逻辑
|
|
||||||
│ │ └── useQuery.ts # 查询参数解析
|
|
||||||
│ ├── layouts/ # 页面布局组件
|
│ ├── layouts/ # 页面布局组件
|
||||||
│ │ ├── default.vue # 默认布局
|
|
||||||
│ │ └── home.vue # 首页布局
|
|
||||||
│ ├── mock/ # 模拟数据
|
|
||||||
│ │ ├── index.ts # Mock 数据入口
|
|
||||||
│ │ └── time.ts # 时间相关 Mock
|
|
||||||
│ ├── pages/ # 页面文件(文件路由)
|
│ ├── pages/ # 页面文件(文件路由)
|
||||||
│ │ ├── home/ # 首页
|
│ │ ├── Ahome/ # 首页(项目概览、进度、告警)
|
||||||
│ │ ├── work/ # 工作页
|
│ │ ├── work/ # 工作页(作业记录、实时监控)
|
||||||
│ │ └── report/ # 报告页
|
│ │ ├── report/ # 报告页(历史报告、统计分析)
|
||||||
│ ├── static/ # 静态资源(图标、图片)
|
│ │ ├── project/ # 项目管理
|
||||||
|
│ │ ├── warning/ # 告警记录与详情
|
||||||
|
│ │ ├── heatmap/ # 热力图可视化
|
||||||
|
│ │ ├── rtk/ # RTK 轨迹追踪
|
||||||
|
│ │ ├── bluetooth/ # 蓝牙设备连接
|
||||||
|
│ │ ├── settings/ # 系统设置
|
||||||
|
│ │ └── message/ # 消息通知
|
||||||
|
│ ├── service/ # 数据库服务层(SQLite 表定义与 CRUD)
|
||||||
│ ├── store/ # Pinia 状态管理
|
│ ├── store/ # Pinia 状态管理
|
||||||
│ │ └── index.js
|
│ ├── utils/ # 工具函数(sqlite、bluetooth、storage 等)
|
||||||
│ ├── utils/ # 工具函数
|
|
||||||
│ ├── App.vue # 根组件
|
│ ├── App.vue # 根组件
|
||||||
│ ├── main.ts # 应用入口
|
│ ├── main.ts # 应用入口
|
||||||
│ ├── manifest.json # uni-app 应用配置
|
│ └── manifest.json # uni-app 应用配置
|
||||||
│ ├── pages.json # 页面路由配置(自动生成)
|
|
||||||
│ ├── theme.json # 主题变量(亮色/暗色)
|
|
||||||
│ └── uni.scss # uni-app 全局样式变量
|
|
||||||
├── eslint.config.js # ESLint 配置
|
├── eslint.config.js # ESLint 配置
|
||||||
├── manifest.config.ts # uni-app manifest 配置(源文件)
|
├── manifest.config.ts # uni-app manifest 配置(源文件)
|
||||||
├── pages.config.ts # 页面路由配置(源文件)
|
├── pages.config.ts # 页面路由配置(源文件)
|
||||||
├── unh.config.ts # uni-helper CLI 配置
|
|
||||||
├── uno.config.ts # UnoCSS 原子化 CSS 配置
|
├── uno.config.ts # UnoCSS 原子化 CSS 配置
|
||||||
├── vite.config.ts # Vite 构建配置
|
└── vite.config.ts # Vite 构建配置
|
||||||
└── vitest.config.ts # Vitest 测试配置
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 目录说明
|
### 目录说明
|
||||||
@@ -86,10 +107,17 @@ Road_roller/
|
|||||||
| `src/components` | 公共 Vue 组件,通过 vite-plugin-uni-components 自动加载 |
|
| `src/components` | 公共 Vue 组件,通过 vite-plugin-uni-components 自动加载 |
|
||||||
| `src/composables` | Vue 3 组合式函数,通过 unplugin-auto-import 自动导入 |
|
| `src/composables` | Vue 3 组合式函数,通过 unplugin-auto-import 自动导入 |
|
||||||
| `src/layouts` | 页面布局模板,通过 vite-plugin-uni-layouts 管理 |
|
| `src/layouts` | 页面布局模板,通过 vite-plugin-uni-layouts 管理 |
|
||||||
| `src/mock` | 开发环境模拟数据 |
|
|
||||||
| `src/pages` | 页面组件,文件即路由(vite-plugin-uni-pages) |
|
| `src/pages` | 页面组件,文件即路由(vite-plugin-uni-pages) |
|
||||||
| `src/static` | 静态资源,打包时原样复制到输出目录 |
|
| `src/service` | 数据库服务层,包含 SQLite 表定义、CRUD 操作和种子数据 |
|
||||||
| `src/store` | Pinia 状态管理,自动导入 |
|
| `src/store` | Pinia 状态管理,统一数据源 |
|
||||||
| `src/utils` | 工具函数,自动导入 |
|
| `src/utils` | 工具函数,包含 SQLite 封装、蓝牙通信、本地存储等 |
|
||||||
|
|
||||||
### 功能模块
|
### 数据库表结构
|
||||||
|
|
||||||
|
| 表名 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `project` | 项目信息(名称、范围、面积、进度、状态) |
|
||||||
|
| `work_record` | 作业记录(时间、时长、压实面积、碾压遍数) |
|
||||||
|
| `rtk_track` | RTK 轨迹点(经纬度、速度、航向、压实度) |
|
||||||
|
| `warning_record` | 告警记录(异常类型、级别、处置记录) |
|
||||||
|
| `history_report` | 历史报告(统计数据、合格率、平均指标) |
|
||||||
|
|||||||
@@ -90,6 +90,6 @@
|
|||||||
"pre-commit": "pnpm lint-staged"
|
"pre-commit": "pnpm lint-staged"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "eslint --fix"
|
"*.{js,jsx,ts,tsx,mjs,cjs,mts,cts,vue}": "eslint --fix"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -3240,8 +3240,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001737:
|
caniuse-lite@1.0.30001812:
|
||||||
resolution: {integrity: sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==}
|
resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==}
|
||||||
|
|
||||||
ccount@2.0.1:
|
ccount@2.0.1:
|
||||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||||
@@ -10260,7 +10260,7 @@ snapshots:
|
|||||||
autoprefixer@10.4.20(postcss@8.5.6):
|
autoprefixer@10.4.20(postcss@8.5.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.25.3
|
browserslist: 4.25.3
|
||||||
caniuse-lite: 1.0.30001737
|
caniuse-lite: 1.0.30001812
|
||||||
fraction.js: 4.3.7
|
fraction.js: 4.3.7
|
||||||
normalize-range: 0.1.2
|
normalize-range: 0.1.2
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
@@ -10410,7 +10410,7 @@ snapshots:
|
|||||||
|
|
||||||
browserslist@4.25.3:
|
browserslist@4.25.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
caniuse-lite: 1.0.30001737
|
caniuse-lite: 1.0.30001812
|
||||||
electron-to-chromium: 1.5.209
|
electron-to-chromium: 1.5.209
|
||||||
node-releases: 2.0.19
|
node-releases: 2.0.19
|
||||||
update-browserslist-db: 1.1.3(browserslist@4.25.3)
|
update-browserslist-db: 1.1.3(browserslist@4.25.3)
|
||||||
@@ -10482,7 +10482,7 @@ snapshots:
|
|||||||
|
|
||||||
camelcase@6.3.0: {}
|
camelcase@6.3.0: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001737: {}
|
caniuse-lite@1.0.30001812: {}
|
||||||
|
|
||||||
ccount@2.0.1: {}
|
ccount@2.0.1: {}
|
||||||
|
|
||||||
|
|||||||
20
src/App.vue
20
src/App.vue
@@ -1,5 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useDeviceAuth } from '@/composables/useDeviceAuth'
|
||||||
import { initTables } from '@/service'
|
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 () => {
|
onLaunch(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -9,6 +22,13 @@ onLaunch(async () => {
|
|||||||
catch (err) {
|
catch (err) {
|
||||||
console.error('数据库初始化失败:', err)
|
console.error('数据库初始化失败:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initAuth()
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('设备授权检测失败:', err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
54
src/api/device/index.ts
Normal file
54
src/api/device/index.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
101
src/api/report/index.ts
Normal file
101
src/api/report/index.ts
Normal 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}`
|
||||||
|
}
|
||||||
@@ -2,47 +2,26 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios'
|
|||||||
import { createUniAppAxiosAdapter } from '@uni-helper/axios-adapter'
|
import { createUniAppAxiosAdapter } from '@uni-helper/axios-adapter'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
// 创建自定义实例
|
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: 'https://api.example.com', // 你的基础域名
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
timeout: 10000,
|
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(
|
instance.interceptors.response.use(
|
||||||
(response: AxiosResponse) => {
|
(response: AxiosResponse) => {
|
||||||
// 直接返回解包后的 data,这样调用时就不需要写 res.data.data 了
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
// (error) => {
|
|
||||||
// // 统一错误处理
|
|
||||||
// uni.showToast({ title: error.message || '请求失败', icon: 'none' })
|
|
||||||
// return Promise.reject(error)
|
|
||||||
// },
|
|
||||||
(error: any) => {
|
(error: any) => {
|
||||||
let { message } = error
|
let { message } = error
|
||||||
if (message === 'Network Error') {
|
if (message === 'Network Error')
|
||||||
message = '后端接口连接异常'
|
message = '后端接口连接异常'
|
||||||
}
|
else if (message?.includes('timeout'))
|
||||||
else if (message.includes('timeout')) {
|
|
||||||
message = '系统接口请求超时'
|
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)}异常`
|
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)
|
return Promise.reject(error)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
20
src/api/types.ts
Normal file
20
src/api/types.ts
Normal 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
10
src/auto-imports.d.ts
vendored
@@ -40,6 +40,7 @@ declare global {
|
|||||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||||
|
const getDeviceId: typeof import('./utils/device')['getDeviceId']
|
||||||
const getDeviceInfoFromStorage: typeof import('./utils/device')['getDeviceInfoFromStorage']
|
const getDeviceInfoFromStorage: typeof import('./utils/device')['getDeviceInfoFromStorage']
|
||||||
const getDeviceTypeName: typeof import('./utils/device')['getDeviceTypeName']
|
const getDeviceTypeName: typeof import('./utils/device')['getDeviceTypeName']
|
||||||
const getOrInitDeviceInfo: typeof import('./utils/device')['getOrInitDeviceInfo']
|
const getOrInitDeviceInfo: typeof import('./utils/device')['getOrInitDeviceInfo']
|
||||||
@@ -104,6 +105,7 @@ declare global {
|
|||||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||||
const provide: typeof import('vue')['provide']
|
const provide: typeof import('vue')['provide']
|
||||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||||
|
const publishRtkPoint: typeof import('./composables/useRtkLive')['publishRtkPoint']
|
||||||
const reactify: typeof import('@vueuse/core')['reactify']
|
const reactify: typeof import('@vueuse/core')['reactify']
|
||||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||||
const reactive: typeof import('vue')['reactive']
|
const reactive: typeof import('vue')['reactive']
|
||||||
@@ -191,6 +193,7 @@ declare global {
|
|||||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
||||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
||||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
||||||
|
const useDeviceAuth: typeof import('./composables/useDeviceAuth')['useDeviceAuth']
|
||||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
||||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
||||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
||||||
@@ -261,10 +264,12 @@ declare global {
|
|||||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
||||||
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
||||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
||||||
|
const useProjectLifecycle: typeof import('./composables/useProjectLifecycle')['useProjectLifecycle']
|
||||||
const useQuery: typeof import('./composables/useQuery')['useQuery']
|
const useQuery: typeof import('./composables/useQuery')['useQuery']
|
||||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||||
|
const useRtkLive: typeof import('./composables/useRtkLive')['useRtkLive']
|
||||||
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
||||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||||
@@ -388,6 +393,7 @@ declare module 'vue' {
|
|||||||
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
|
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
|
||||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||||
|
readonly getDeviceId: UnwrapRef<typeof import('./utils/device')['getDeviceId']>
|
||||||
readonly getDeviceInfoFromStorage: UnwrapRef<typeof import('./utils/device')['getDeviceInfoFromStorage']>
|
readonly getDeviceInfoFromStorage: UnwrapRef<typeof import('./utils/device')['getDeviceInfoFromStorage']>
|
||||||
readonly getDeviceTypeName: UnwrapRef<typeof import('./utils/device')['getDeviceTypeName']>
|
readonly getDeviceTypeName: UnwrapRef<typeof import('./utils/device')['getDeviceTypeName']>
|
||||||
readonly getOrInitDeviceInfo: UnwrapRef<typeof import('./utils/device')['getOrInitDeviceInfo']>
|
readonly getOrInitDeviceInfo: UnwrapRef<typeof import('./utils/device')['getOrInitDeviceInfo']>
|
||||||
@@ -452,6 +458,7 @@ declare module 'vue' {
|
|||||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||||
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
|
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 reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
|
||||||
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
|
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
|
||||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||||
@@ -539,6 +546,7 @@ declare module 'vue' {
|
|||||||
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
|
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
|
||||||
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
|
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
|
||||||
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
|
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 useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
|
||||||
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
|
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
|
||||||
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
|
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
|
||||||
@@ -609,10 +617,12 @@ declare module 'vue' {
|
|||||||
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
|
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
|
||||||
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
|
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
|
||||||
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
|
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 useQuery: UnwrapRef<typeof import('./composables/useQuery')['useQuery']>
|
||||||
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
||||||
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
|
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
|
||||||
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
|
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 useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
|
||||||
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
|
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
|
||||||
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
|
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
|
||||||
|
|||||||
7
src/components.d.ts
vendored
7
src/components.d.ts
vendored
@@ -11,7 +11,13 @@ declare module 'vue' {
|
|||||||
BluetoothConnect: typeof import('./components/bluetooth/connect.vue')['default']
|
BluetoothConnect: typeof import('./components/bluetooth/connect.vue')['default']
|
||||||
DeviceCard: typeof import('./components/deviceCard.vue')['default']
|
DeviceCard: typeof import('./components/deviceCard.vue')['default']
|
||||||
DeviceInfo: typeof import('./components/deviceInfo.vue')['default']
|
DeviceInfo: typeof import('./components/deviceInfo.vue')['default']
|
||||||
|
ExpireNotice: typeof import('./components/expireNotice.vue')['default']
|
||||||
FullscreenNav: typeof import('./components/FullscreenNav.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']
|
HomeConstruction: typeof import('./components/home/construction.vue')['default']
|
||||||
HomeHomeNav: typeof import('./components/home/homeNav.vue')['default']
|
HomeHomeNav: typeof import('./components/home/homeNav.vue')['default']
|
||||||
HomeList: typeof import('./components/home/list.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']
|
SettingsSensorConfig: typeof import('./components/settings/sensorConfig.vue')['default']
|
||||||
SettingsWifiNetwork: typeof import('./components/settings/wifiNetwork.vue')['default']
|
SettingsWifiNetwork: typeof import('./components/settings/wifiNetwork.vue')['default']
|
||||||
SettingsWifiSettings: typeof import('./components/settings/wifiSettings.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']
|
WarningFilter: typeof import('./components/warning/filter.vue')['default']
|
||||||
WarningItem: typeof import('./components/warning/item.vue')['default']
|
WarningItem: typeof import('./components/warning/item.vue')['default']
|
||||||
WorkDashboard: typeof import('./components/work/dashboard.vue')['default']
|
WorkDashboard: typeof import('./components/work/dashboard.vue')['default']
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// RTK 轨迹实时监测 - 状态头部组件
|
import { useRtkLive } from '@/composables/useRtkLive'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
deviceName?: string
|
deviceName?: string
|
||||||
speed?: number
|
|
||||||
frequency?: number
|
|
||||||
amplitude?: number
|
|
||||||
rtkFixed?: boolean
|
rtkFixed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
deviceName: '徐工XS65H #01',
|
deviceName: '徐工XS65H #01',
|
||||||
speed: 4.2,
|
|
||||||
frequency: 28.5,
|
|
||||||
amplitude: 1.86,
|
|
||||||
rtkFixed: true,
|
rtkFixed: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { speedTextSpaced } = useRtkLive()
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
uni.switchTab({
|
uni.switchTab({
|
||||||
url: '/pages/work/index',
|
url: '/pages/work/index',
|
||||||
@@ -54,25 +50,25 @@ function handleToggle() {
|
|||||||
车速
|
车速
|
||||||
</text>
|
</text>
|
||||||
<text class="data-value">
|
<text class="data-value">
|
||||||
{{ speed }} km/h
|
{{ speedTextSpaced }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="data-item">
|
<!-- <view class="data-item">
|
||||||
<text class="data-label">
|
<text class="data-label">
|
||||||
振动频率
|
振动频率
|
||||||
</text>
|
</text>
|
||||||
<text class="data-value">
|
<text class="data-value">
|
||||||
{{ frequency }} HZ
|
{{ frequencyText }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view> -->
|
||||||
<view class="data-item">
|
<!-- <view class="data-item">
|
||||||
<text class="data-label">
|
<text class="data-label">
|
||||||
振幅
|
振幅
|
||||||
</text>
|
</text>
|
||||||
<text class="data-value">
|
<text class="data-value">
|
||||||
{{ amplitude }} mm
|
{{ amplitudeText }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view> -->
|
||||||
<view class="rtk-status">
|
<view class="rtk-status">
|
||||||
<view class="rtk-dot" :class="{ 'rtk-dot--active': rtkFixed }" />
|
<view class="rtk-dot" :class="{ 'rtk-dot--active': rtkFixed }" />
|
||||||
<text class="rtk-text">
|
<text class="rtk-text">
|
||||||
|
|||||||
285
src/components/expireNotice.vue
Normal file
285
src/components/expireNotice.vue
Normal 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>
|
||||||
209
src/components/heatmap/canvas.vue
Normal file
209
src/components/heatmap/canvas.vue
Normal 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>
|
||||||
67
src/components/heatmap/heatUtils.ts
Normal file
67
src/components/heatmap/heatUtils.ts
Normal 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)}°`
|
||||||
|
}
|
||||||
106
src/components/heatmap/layerPanel.vue
Normal file
106
src/components/heatmap/layerPanel.vue
Normal 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>
|
||||||
118
src/components/heatmap/legend.vue
Normal file
118
src/components/heatmap/legend.vue
Normal 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>
|
||||||
177
src/components/heatmap/pointDetail.vue
Normal file
177
src/components/heatmap/pointDetail.vue
Normal 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>
|
||||||
155
src/components/home/activateNotice.vue
Normal file
155
src/components/home/activateNotice.vue
Normal 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>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useCurrentProject } from '@/composables/useCurrentProject'
|
import { useCurrentProject } from '@/composables/useCurrentProject'
|
||||||
|
import { useDeviceAuth } from '@/composables/useDeviceAuth'
|
||||||
|
|
||||||
const { hasCurrentProject, currentProject } = useCurrentProject()
|
const { hasCurrentProject, currentProject } = useCurrentProject()
|
||||||
|
const { needAuthPrompt } = useDeviceAuth()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -12,7 +14,8 @@ const { hasCurrentProject, currentProject } = useCurrentProject()
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-body">
|
<view class="card-body">
|
||||||
<view class="construction-info">
|
<unauthorized v-if="needAuthPrompt" />
|
||||||
|
<view v-else class="construction-info">
|
||||||
<view class="icon-placeholder" />
|
<view class="icon-placeholder" />
|
||||||
<view class="info">
|
<view class="info">
|
||||||
<text class="segment">
|
<text class="segment">
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Project } from '@/service/project'
|
import type { Project } from '@/service/project'
|
||||||
|
import { useProjectLifecycle } from '@/composables/useProjectLifecycle'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: Project
|
item: Project
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { startProject, pauseProject, endProject } = useProjectLifecycle()
|
||||||
|
|
||||||
const statusMap: Record<number, string> = {
|
const statusMap: Record<number, string> = {
|
||||||
0: '已暂停',
|
0: '已暂停',
|
||||||
1: '已结束',
|
1: '已结束',
|
||||||
@@ -30,6 +33,21 @@ function formatArea(value?: number) {
|
|||||||
const text = Number.isInteger(area) ? String(area) : String(area)
|
const text = Number.isInteger(area) ? String(area) : String(area)
|
||||||
return `${text}m²`
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -74,14 +92,14 @@ function formatArea(value?: number) {
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="item.status !== 1" class="project-actions">
|
<view v-if="item.status !== 1" class="project-actions" @tap.stop>
|
||||||
<wd-button size="large" round>
|
<wd-button size="large" round @tap.stop="onStart">
|
||||||
开始项目
|
开始项目
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button type="warning" size="large" round>
|
<wd-button type="warning" size="large" round @tap.stop="onPause">
|
||||||
暂停项目
|
暂停项目
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button type="danger" size="large" round>
|
<wd-button type="danger" size="large" round @tap.stop="onEnd">
|
||||||
结束项目
|
结束项目
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -4,21 +4,27 @@ import { useCurrentProject } from '@/composables/useCurrentProject'
|
|||||||
import { useDbStore } from '@/store/modules/db'
|
import { useDbStore } from '@/store/modules/db'
|
||||||
|
|
||||||
const db = useDbStore()
|
const db = useDbStore()
|
||||||
const { hasCurrentProject } = useCurrentProject()
|
const { hasCurrentProject, currentProjectId } = useCurrentProject()
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`
|
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(() => {
|
const totalCompactionArea = computed(() => {
|
||||||
if (!hasCurrentProject.value)
|
if (!hasCurrentProject.value)
|
||||||
return null
|
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(() => {
|
const totalDuration = computed(() => {
|
||||||
if (!hasCurrentProject.value)
|
if (!hasCurrentProject.value)
|
||||||
return null
|
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(() => {
|
const onlineCount = computed(() => {
|
||||||
@@ -40,8 +46,9 @@ function formatCount(value: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!db.work_record?.length)
|
if (!hasCurrentProject.value || currentProjectId.value == null)
|
||||||
await db.loadAll()
|
return
|
||||||
|
await db.loadByProject(currentProjectId.value)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useCurrentProject } from '@/composables/useCurrentProject'
|
import { useCurrentProject } from '@/composables/useCurrentProject'
|
||||||
|
import { useDeviceAuth } from '@/composables/useDeviceAuth'
|
||||||
import { calcUncompactedArea } from '@/service/project'
|
import { calcUncompactedArea } from '@/service/project'
|
||||||
import { useDbStore } from '@/store/modules/db'
|
import { useDbStore } from '@/store/modules/db'
|
||||||
|
|
||||||
const db = useDbStore()
|
const db = useDbStore()
|
||||||
const { currentProject: storedProject, hasCurrentProject } = useCurrentProject()
|
const { hasCurrentProject, currentProjectId } = useCurrentProject()
|
||||||
|
const { isActivated, needAuthPrompt } = useDeviceAuth()
|
||||||
|
|
||||||
const currentProject = computed(() => {
|
const currentProject = computed(() => {
|
||||||
if (!hasCurrentProject.value)
|
if (!hasCurrentProject.value || currentProjectId.value == null)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const list = db.project || []
|
const list = db.project || []
|
||||||
const storedId = storedProject.value?.id
|
return list.find(item => item.id === currentProjectId.value) || null
|
||||||
if (storedId == null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
return list.find(item => item.id === storedId) || null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const progressPercent = computed(() => {
|
const progressPercent = computed(() => {
|
||||||
@@ -84,8 +82,9 @@ function formatPercent(value: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!db.project?.length)
|
if (!hasCurrentProject.value || currentProjectId.value == null)
|
||||||
await db.loadAll()
|
return
|
||||||
|
await db.loadByProject(currentProjectId.value)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -95,12 +94,13 @@ onMounted(async () => {
|
|||||||
<text class="card-title">
|
<text class="card-title">
|
||||||
项目作业进度
|
项目作业进度
|
||||||
</text>
|
</text>
|
||||||
<text class="card-sub">
|
<text v-if="isActivated" class="card-sub">
|
||||||
{{ currentProject?.stake_range || currentProject?.project_name || '暂无施工段' }}
|
{{ currentProject?.stake_range || currentProject?.project_name || '暂无施工段' }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-body">
|
<view class="card-body">
|
||||||
<view class="progress-content">
|
<unauthorized v-if="needAuthPrompt" />
|
||||||
|
<view v-else class="progress-content">
|
||||||
<view class="progress-circle">
|
<view class="progress-circle">
|
||||||
<wd-circle :model-value="progressPercent ?? 0" :size="120">
|
<wd-circle :model-value="progressPercent ?? 0" :size="120">
|
||||||
<view class="progress-circle-text">
|
<view class="progress-circle-text">
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Project } from '@/service/project'
|
import type { Project } from '@/service/project'
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useCurrentProject } from '@/composables/useCurrentProject'
|
import { useDeviceAuth } from '@/composables/useDeviceAuth'
|
||||||
import { useDbStore } from '@/store/modules/db'
|
import { useDbStore } from '@/store/modules/db'
|
||||||
|
|
||||||
const db = useDbStore()
|
const db = useDbStore()
|
||||||
const { hasCurrentProject } = useCurrentProject()
|
const { isActivated, needAuthPrompt } = useDeviceAuth()
|
||||||
|
|
||||||
/** 首页只展示最近几条 */
|
/** 首页展示最近 2 条,按 id 倒序 */
|
||||||
const list = computed(() => {
|
const list = computed(() => (db.project || []).slice().sort((a, b) => (b.id ?? 0) - (a.id ?? 0)).slice(0, 2))
|
||||||
if (!hasCurrentProject.value)
|
|
||||||
return []
|
|
||||||
return (db.project || []).slice(0, 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!db.project?.length)
|
if (!db.project?.length)
|
||||||
@@ -40,23 +36,23 @@ function goToDetail(item: Project) {
|
|||||||
<text class="card-title">
|
<text class="card-title">
|
||||||
项目列表
|
项目列表
|
||||||
</text>
|
</text>
|
||||||
<wd-button variant="text" @tap="goToProjectList">
|
<wd-button v-if="isActivated" variant="text" @tap="goToProjectList">
|
||||||
查看全部
|
查看全部
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-body">
|
<view class="card-body">
|
||||||
<view v-if="!hasCurrentProject" class="empty-tip">
|
<unauthorized v-if="needAuthPrompt" />
|
||||||
--
|
|
||||||
</view>
|
|
||||||
<view v-else-if="!list.length" class="empty-tip">
|
<view v-else-if="!list.length" class="empty-tip">
|
||||||
暂无项目
|
暂无项目
|
||||||
</view>
|
</view>
|
||||||
|
<template v-else>
|
||||||
<home-list
|
<home-list
|
||||||
v-for="item in list"
|
v-for="item in list"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:item="item"
|
:item="item"
|
||||||
@tap="goToDetail(item)"
|
@tap="goToDetail(item)"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useCurrentProject } from '@/composables/useCurrentProject'
|
import { useCurrentProject } from '@/composables/useCurrentProject'
|
||||||
|
import { useDeviceAuth } from '@/composables/useDeviceAuth'
|
||||||
|
import { useRtkLive } from '@/composables/useRtkLive'
|
||||||
|
|
||||||
const { hasCurrentProject } = useCurrentProject()
|
const { hasCurrentProject } = useCurrentProject()
|
||||||
|
const { needAuthPrompt } = useDeviceAuth()
|
||||||
|
const { speedText, tempText } = useRtkLive()
|
||||||
|
|
||||||
function goToWork() {
|
function goToWork() {
|
||||||
uni.switchTab({
|
uni.switchTab({
|
||||||
@@ -18,7 +22,8 @@ function goToWork() {
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-body">
|
<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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -37,28 +42,12 @@ function goToWork() {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-params">
|
<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">
|
<view class="param">
|
||||||
<text class="param-label">
|
<text class="param-label">
|
||||||
行驶速度
|
行驶速度
|
||||||
</text>
|
</text>
|
||||||
<text class="param-value">
|
<text class="param-value">
|
||||||
3km/h
|
{{ speedText }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="param">
|
<view class="param">
|
||||||
@@ -66,7 +55,7 @@ function goToWork() {
|
|||||||
钢轮温度
|
钢轮温度
|
||||||
</text>
|
</text>
|
||||||
<text class="param-value">
|
<text class="param-value">
|
||||||
8939°C
|
{{ tempText }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -2,24 +2,26 @@
|
|||||||
import type { WarningRecord } from '@/service/warningRecord'
|
import type { WarningRecord } from '@/service/warningRecord'
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useCurrentProject } from '@/composables/useCurrentProject'
|
import { useCurrentProject } from '@/composables/useCurrentProject'
|
||||||
|
import { useDeviceAuth } from '@/composables/useDeviceAuth'
|
||||||
import { useDbStore } from '@/store/modules/db'
|
import { useDbStore } from '@/store/modules/db'
|
||||||
|
|
||||||
const db = useDbStore()
|
const db = useDbStore()
|
||||||
const { hasCurrentProject, currentProject } = useCurrentProject()
|
const { hasCurrentProject, currentProjectId } = useCurrentProject()
|
||||||
|
const { isActivated, needAuthPrompt } = useDeviceAuth()
|
||||||
|
|
||||||
/** 首页只展示当前项目最近几条 */
|
/** 首页只展示当前项目最近几条 */
|
||||||
const list = computed(() => {
|
const list = computed(() => {
|
||||||
if (!hasCurrentProject.value)
|
if (!hasCurrentProject.value || currentProjectId.value == null)
|
||||||
return []
|
return []
|
||||||
const projectId = currentProject.value?.id
|
|
||||||
return (db.warning_record || [])
|
return (db.warning_record || [])
|
||||||
.filter(item => projectId == null || item.project_id === projectId)
|
.filter(item => item.project_id === currentProjectId.value)
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!db.warning_record?.length)
|
if (!hasCurrentProject.value || currentProjectId.value == null)
|
||||||
await db.loadAll()
|
return
|
||||||
|
await db.loadByProject(currentProjectId.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
function goToWarningList() {
|
function goToWarningList() {
|
||||||
@@ -43,23 +45,26 @@ function goToDetail(item: WarningRecord) {
|
|||||||
<text class="card-title">
|
<text class="card-title">
|
||||||
异常警告
|
异常警告
|
||||||
</text>
|
</text>
|
||||||
<wd-button variant="text" @tap="goToWarningList">
|
<wd-button v-if="isActivated" variant="text" @tap="goToWarningList">
|
||||||
查看全部
|
查看全部
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-body">
|
<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>
|
||||||
<view v-else-if="!list.length" class="empty-tip">
|
<view v-else-if="!list.length" class="empty-tip">
|
||||||
暂无异常警告
|
暂无异常警告
|
||||||
</view>
|
</view>
|
||||||
|
<template v-else>
|
||||||
<home-warning_list
|
<home-warning_list
|
||||||
v-for="item in list"
|
v-for="item in list"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:item="item"
|
:item="item"
|
||||||
@tap="goToDetail(item)"
|
@tap="goToDetail(item)"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { useProjectLifecycle } from '@/composables/useProjectLifecycle'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: number
|
||||||
projectName: string
|
projectName: string
|
||||||
stakeRange: string
|
stakeRange: string
|
||||||
status: number
|
status: number
|
||||||
@@ -8,6 +11,8 @@ defineProps<{
|
|||||||
rollerCount: string
|
rollerCount: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { startProject, pauseProject, endProject } = useProjectLifecycle()
|
||||||
|
|
||||||
function getStatusText(status: number) {
|
function getStatusText(status: number) {
|
||||||
const map: Record<number, string> = {
|
const map: Record<number, string> = {
|
||||||
0: '已暂停',
|
0: '已暂停',
|
||||||
@@ -25,6 +30,21 @@ function getStatusColor(status: number) {
|
|||||||
}
|
}
|
||||||
return map[status] || '#999999'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -80,13 +100,13 @@ function getStatusColor(status: number) {
|
|||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<view v-if="status !== 1" class="btn-row">
|
<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>
|
||||||
<wd-button size="large" type="warning" round>
|
<wd-button size="large" type="warning" round @tap="onPause">
|
||||||
暂停项目
|
暂停项目
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button size="large" type="danger" round>
|
<wd-button size="large" type="danger" round @tap="onEnd">
|
||||||
结束项目
|
结束项目
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -23,22 +23,22 @@ defineProps<{
|
|||||||
{{ avgSpeed }}
|
{{ avgSpeed }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="detail-row">
|
<!-- <view class="detail-row">
|
||||||
<text class="detail-label">
|
<text class="detail-label">
|
||||||
平均振动频率
|
平均振动频率
|
||||||
</text>
|
</text>
|
||||||
<text class="detail-value">
|
<text class="detail-value">
|
||||||
{{ avgFrequency }}
|
{{ avgFrequency }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view> -->
|
||||||
<view class="detail-row">
|
<!-- <view class="detail-row">
|
||||||
<text class="detail-label">
|
<text class="detail-label">
|
||||||
平均振幅
|
平均振幅
|
||||||
</text>
|
</text>
|
||||||
<text class="detail-value">
|
<text class="detail-value">
|
||||||
{{ avgAmplitude }}
|
{{ avgAmplitude }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view> -->
|
||||||
<view class="detail-row">
|
<view class="detail-row">
|
||||||
<text class="detail-label">
|
<text class="detail-label">
|
||||||
平均摊铺温度
|
平均摊铺温度
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import storage from '@/utils/storage'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id?: number
|
reportId?: number
|
||||||
title: string
|
title: string
|
||||||
deviceCode: string
|
deviceCode: string
|
||||||
status: string
|
status: string
|
||||||
@@ -14,25 +12,13 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
function goToDetail() {
|
function goToDetail() {
|
||||||
const key = `report_${Date.now()}`
|
if (props.reportId == null) {
|
||||||
storage.set(key, {
|
uni.showToast({ title: '报告ID缺失', icon: 'none' })
|
||||||
id: props.id,
|
return
|
||||||
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}`)
|
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/report/detail/index?${query.join('&')}`,
|
url: `/pages/report/detail/index?reportId=${props.reportId}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useCurrentProject } from '@/composables/useCurrentProject'
|
||||||
import { projectService } from '@/service'
|
import { projectService } from '@/service'
|
||||||
import storage from '@/utils/storage'
|
import { useDbStore } from '@/store/modules/db'
|
||||||
|
|
||||||
const PROJECT_KEY = 'current_project'
|
const db = useDbStore()
|
||||||
const currentProject = storage.get(PROJECT_KEY)
|
const { setCurrentProject, currentProjectId } = useCurrentProject()
|
||||||
|
|
||||||
const projectName = ref(currentProject?.project_name || '')
|
const projectName = ref('')
|
||||||
const startEndStation = ref(currentProject?.stake_range || '')
|
const startEndStation = ref('')
|
||||||
const totalArea = ref(currentProject?.total_area ? String(currentProject.total_area) : '')
|
const totalArea = ref('')
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!projectName.value) {
|
if (!projectName.value) {
|
||||||
@@ -16,21 +17,30 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = await projectService.insert({
|
const payload = {
|
||||||
project_name: projectName.value,
|
project_name: projectName.value,
|
||||||
stake_range: startEndStation.value,
|
stake_range: startEndStation.value,
|
||||||
total_area: Number(totalArea.value) || 0,
|
total_area: Number(totalArea.value) || 0,
|
||||||
status: 0,
|
status: 0 as const,
|
||||||
})
|
}
|
||||||
|
|
||||||
// 保存到 storage
|
// 始终新建项目,写入数据库
|
||||||
storage.set(PROJECT_KEY, {
|
const id = await projectService.insert(payload)
|
||||||
id,
|
if (!id) {
|
||||||
project_name: projectName.value,
|
uni.showToast({ title: '保存失败:未获取到项目ID', icon: 'none' })
|
||||||
stake_range: startEndStation.value,
|
return
|
||||||
total_area: Number(totalArea.value) || 0,
|
}
|
||||||
status: 0,
|
|
||||||
})
|
// 写入 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' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
}
|
}
|
||||||
|
|||||||
113
src/components/unauthorized.vue
Normal file
113
src/components/unauthorized.vue
Normal 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>
|
||||||
@@ -1,24 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useCurrentProject } from '@/composables/useCurrentProject'
|
import { useCurrentProject } from '@/composables/useCurrentProject'
|
||||||
|
import { useRtkLive } from '@/composables/useRtkLive'
|
||||||
|
|
||||||
const { hasCurrentProject } = useCurrentProject()
|
const { hasCurrentProject } = useCurrentProject()
|
||||||
|
const {
|
||||||
|
compactionValue: liveCompaction,
|
||||||
|
speed,
|
||||||
|
compactionText: liveCompactionText,
|
||||||
|
tempText: liveTempText,
|
||||||
|
passCountText: livePassCountText,
|
||||||
|
} = useRtkLive()
|
||||||
|
|
||||||
// 压实度 (假数据,后续替换)
|
const compactionValue = computed(() => {
|
||||||
const compactionValue = computed(() => (hasCurrentProject.value ? 25 : 0))
|
if (!hasCurrentProject.value || liveCompaction.value == null)
|
||||||
const compactionText = computed(() => (hasCurrentProject.value ? `${compactionValue.value}%` : '--'))
|
return 0
|
||||||
|
return Math.round(liveCompaction.value)
|
||||||
|
})
|
||||||
|
const compactionText = computed(() => (hasCurrentProject.value ? liveCompactionText.value : '--'))
|
||||||
|
|
||||||
// 车速 (假数据,范围 0-12,中间值 6)
|
const speedValue = computed(() => {
|
||||||
const speedValue = computed(() => (hasCurrentProject.value ? 4.2 : 0))
|
if (!hasCurrentProject.value || speed.value == null)
|
||||||
const speedText = computed(() => (hasCurrentProject.value ? `${speedValue.value}KM/H` : '--'))
|
return 0
|
||||||
|
return speed.value
|
||||||
|
})
|
||||||
|
const speedText = computed(() => {
|
||||||
|
if (!hasCurrentProject.value || speed.value == null)
|
||||||
|
return '--'
|
||||||
|
return `${speed.value}KM/H`
|
||||||
|
})
|
||||||
const speedMax = 12
|
const speedMax = 12
|
||||||
|
|
||||||
// 指针旋转角度:0km/h → 225°(左下),12km/h → -45°(右下),中间90°(正上)
|
/** 数学角(0=东,逆时针)→ CSS 旋转(0=上,顺时针) */
|
||||||
const needleRotation = computed(() => 225 - (speedValue.value / speedMax) * 270)
|
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(() => {
|
const overviewData = computed(() => {
|
||||||
if (!hasCurrentProject.value) {
|
if (!hasCurrentProject.value) {
|
||||||
return [
|
return [
|
||||||
@@ -60,7 +81,7 @@ const overviewData = computed(() => {
|
|||||||
</view>
|
</view>
|
||||||
<!-- 圆环进度(使用 wotui 的 wd-circle 组件) -->
|
<!-- 圆环进度(使用 wotui 的 wd-circle 组件) -->
|
||||||
<view class="ring-wrap">
|
<view class="ring-wrap">
|
||||||
<wd-circle :model-value="compactionValue" :text="compactionText" />
|
<wd-circle :model-value="compactionValue" :text="compactionText" :size="80" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -82,57 +103,38 @@ const overviewData = computed(() => {
|
|||||||
|
|
||||||
<!-- 右侧 -->
|
<!-- 右侧 -->
|
||||||
<view class="gauge-right">
|
<view class="gauge-right">
|
||||||
<!-- 振动频率 -->
|
<!-- 钢轮温度 -->
|
||||||
<view class="freq-item">
|
<view class="freq-item">
|
||||||
<text class="freq-label">
|
<text class="freq-label">
|
||||||
振动频率
|
钢轮温度
|
||||||
</text>
|
</text>
|
||||||
<text class="freq-value">
|
<text class="freq-value">
|
||||||
{{ frequencyText }}
|
{{ tempText }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="freq-item">
|
<view class="freq-item">
|
||||||
<text class="freq-label">
|
<text class="freq-label">
|
||||||
振动频率
|
压实遍数
|
||||||
</text>
|
</text>
|
||||||
<text class="freq-value">
|
<text class="freq-value">
|
||||||
{{ frequencyText }}
|
{{ passCountText }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 车速仪表 SVG 3/4圆弧 (270°) -->
|
<!-- 车速仪表:App 端不支持内联 SVG,改用 view + CSS -->
|
||||||
<view class="speed-gauge">
|
<view class="speed-gauge">
|
||||||
<svg viewBox="0 0 120 110" class="speed-svg">
|
<view class="gauge-clip gauge-clip--left">
|
||||||
<!-- 左半弧:蓝色 (0~6) 从225°到90° -->
|
<view class="gauge-ring gauge-ring--blue" />
|
||||||
<path
|
</view>
|
||||||
d="M 24.64 95.36 A 50 50 0 0 1 60 10"
|
<view class="gauge-clip gauge-clip--right">
|
||||||
fill="none"
|
<view class="gauge-ring gauge-ring--orange" />
|
||||||
stroke="#3b82f6"
|
</view>
|
||||||
stroke-width="10"
|
<view class="gauge-mask" />
|
||||||
stroke-linecap="round"
|
<view class="needle-wrap" :style="{ transform: `rotate(${needleRotation}deg)` }">
|
||||||
/>
|
<view class="needle" />
|
||||||
<!-- 右半弧:橙色 (6~12) 从90°到-45° -->
|
</view>
|
||||||
<path
|
<view class="needle-hub" />
|
||||||
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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -240,15 +242,88 @@ const overviewData = computed(() => {
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 车速 SVG 3/4圆仪表 ===== */
|
/* ===== 车速仪表(view+CSS,兼容 Android App) ===== */
|
||||||
.speed-gauge {
|
.speed-gauge {
|
||||||
|
position: relative;
|
||||||
width: 110px;
|
width: 110px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speed-svg {
|
.gauge-clip {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: 100%;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 分割线 ===== */
|
/* ===== 分割线 ===== */
|
||||||
|
|||||||
@@ -1,23 +1,285 @@
|
|||||||
<script setup lang="ts">
|
<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 { 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 { 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() {
|
function goToRtk() {
|
||||||
uni.navigateTo({
|
if (!props.navigateOnClick)
|
||||||
url: '/pages/rtk/index',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="rtk-canvas" @click="goToRtk">
|
<view class="rtk-canvas" :style="{ height: `${canvasHeight}px` }" @click="goToRtk">
|
||||||
<view class="canvas-placeholder">
|
<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">
|
<text class="placeholder-text">
|
||||||
{{ hasCurrentProject ? '轨迹动画区域' : '--' }}
|
--
|
||||||
</text>
|
|
||||||
<text v-if="hasCurrentProject" class="placeholder-hint">
|
|
||||||
(Canvas 绘制行驶轨迹 + 压实热力图)
|
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -25,30 +287,30 @@ function goToRtk() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.rtk-canvas {
|
.rtk-canvas {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
background: #ffffff;
|
||||||
background: #f0fdf4;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-canvas {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-placeholder {
|
.canvas-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 160px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
background: #f0fdf4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text {
|
.placeholder-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useCurrentProject } from '@/composables/useCurrentProject'
|
import { useCurrentProject } from '@/composables/useCurrentProject'
|
||||||
|
import { useRtkLive } from '@/composables/useRtkLive'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
deviceName?: string
|
deviceName?: string
|
||||||
speed?: number
|
|
||||||
frequency?: number
|
|
||||||
amplitude?: number
|
|
||||||
rtkFixed?: boolean
|
rtkFixed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
deviceName: '徐工XS65H #01',
|
deviceName: '徐工XS65H #01',
|
||||||
speed: 4.2,
|
|
||||||
frequency: 28.5,
|
|
||||||
amplitude: 1.86,
|
|
||||||
rtkFixed: true,
|
rtkFixed: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { hasCurrentProject } = useCurrentProject()
|
const { hasCurrentProject } = useCurrentProject()
|
||||||
|
const { speedTextSpaced } = useRtkLive()
|
||||||
|
|
||||||
const displayDeviceName = computed(() => (hasCurrentProject.value ? props.deviceName : '--'))
|
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 displayStatusText = computed(() => (hasCurrentProject.value ? '实时监测中' : '--'))
|
||||||
const displayRtkText = computed(() => (hasCurrentProject.value ? 'RTK固定' : '--'))
|
const displayRtkText = computed(() => (hasCurrentProject.value ? 'RTK固定' : '--'))
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// RTK轨迹实时监测 + 压实热力图 - 父组件(组合头部 + 画布 + 底部)
|
// RTK轨迹实时监测:整卡高度随画布内容撑开(不要 min-height:100% 锁死视口)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<!-- 状态头部 -->
|
<work-rtkHeader class="card-header" />
|
||||||
<work-rtkHeader />
|
<work-rtkCanvas class="card-canvas" />
|
||||||
|
<work-rtkFooter class="card-footer" />
|
||||||
<!-- 轨迹画布 -->
|
|
||||||
<work-rtkCanvas />
|
|
||||||
|
|
||||||
<!-- 底部信息 -->
|
|
||||||
<work-rtkFooter />
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.card {
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
/* 高度完全跟内容走,轨迹变长整卡变高 */
|
||||||
|
height: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header,
|
||||||
|
.card-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-canvas {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,28 +1,49 @@
|
|||||||
|
import type { Project } from '@/service/project'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import storage from '@/utils/storage'
|
import storage from '@/utils/storage'
|
||||||
|
|
||||||
const PROJECT_KEY = 'current_project'
|
const PROJECT_KEY = 'current_project'
|
||||||
|
|
||||||
|
/** 模块级共享,保证各页面读到同一当前项目 */
|
||||||
|
const currentProject = ref<Partial<Project> | undefined>(storage.get(PROJECT_KEY))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取本地当前项目;无项目时首页/压实作业数据统一展示为 `--`
|
* 读取本地当前项目;无项目时首页/压实作业数据统一展示为 `--`
|
||||||
|
* 有项目时,业务查询应以 currentProjectId 为条件
|
||||||
*/
|
*/
|
||||||
export function useCurrentProject() {
|
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() {
|
function refreshCurrentProject() {
|
||||||
currentProject.value = storage.get(PROJECT_KEY)
|
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(() => {
|
onShow(() => {
|
||||||
refreshCurrentProject()
|
refreshCurrentProject()
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentProject,
|
currentProject,
|
||||||
|
currentProjectId,
|
||||||
hasCurrentProject,
|
hasCurrentProject,
|
||||||
|
setCurrentProject,
|
||||||
refreshCurrentProject,
|
refreshCurrentProject,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
322
src/composables/useDeviceAuth.ts
Normal file
322
src/composables/useDeviceAuth.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/composables/useProjectLifecycle.ts
Normal file
235
src/composables/useProjectLifecycle.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/composables/useRtkLive.ts
Normal file
101
src/composables/useRtkLive.ts
Normal 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
8
src/env.d.ts
vendored
@@ -1 +1,9 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import formattedDate from './time'
|
import formattedDate from './time'
|
||||||
|
|
||||||
|
export { createMockRtkStream, generateSerpentineTrack } from './rtkTrack'
|
||||||
|
export type { MockRtkPoint, MockRtkStream, MockRtkStreamOptions } from './rtkTrack'
|
||||||
|
|
||||||
export const mockData = {
|
export const mockData = {
|
||||||
time: formattedDate,
|
time: formattedDate,
|
||||||
message: 'Hello, this is mock data!',
|
message: 'Hello, this is mock data!',
|
||||||
|
|||||||
207
src/mock/rtkTrack.ts
Normal file
207
src/mock/rtkTrack.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"navigationBarTitleText": "Road-Roller"
|
"navigationBarTitleText": "Road-Roller"
|
||||||
},
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
// GENERATED BY UNI-PAGES, PLATFORM: APP
|
// GENERATED BY UNI-PAGES, PLATFORM: APP || H5
|
||||||
{
|
{
|
||||||
"path": "pages/Ahome/index",
|
"path": "pages/Ahome/index",
|
||||||
"type": "home",
|
"type": "home",
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"color": "@tabFontColor",
|
"color": "@tabFontColor",
|
||||||
"selectedColor": "@tabSelectedColor",
|
"selectedColor": "@tabSelectedColor",
|
||||||
"list": [
|
"list": [
|
||||||
// GENERATED BY UNI-PAGES, PLATFORM: APP
|
// GENERATED BY UNI-PAGES, PLATFORM: APP || H5
|
||||||
{
|
{
|
||||||
"pagePath": "pages/Ahome/index",
|
"pagePath": "pages/Ahome/index",
|
||||||
"text": "首页",
|
"text": "首页",
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import { nextTick, onMounted } from 'vue'
|
||||||
|
import { useDeviceAuth } from '@/composables/useDeviceAuth'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
type: 'home',
|
type: 'home',
|
||||||
layout: false,
|
layout: false,
|
||||||
@@ -6,6 +10,28 @@ definePage({
|
|||||||
navigationStyle: 'custom',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -21,11 +47,14 @@ definePage({
|
|||||||
<home-projectList />
|
<home-projectList />
|
||||||
</view>
|
</view>
|
||||||
<view class="column-right">
|
<view class="column-right">
|
||||||
|
<home-activateNotice v-if="needAuthPrompt" />
|
||||||
<home-progress />
|
<home-progress />
|
||||||
<home-warning />
|
<home-warning />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<expire-notice />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,66 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { HeatmapLayerKey } from '@/components/heatmap/heatUtils'
|
||||||
|
import type { MockRtkPoint } from '@/mock/rtkTrack'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
layout: false,
|
layout: false,
|
||||||
style: {
|
style: {
|
||||||
navigationStyle: 'custom',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page" @tap="onBlankTap">
|
||||||
<!-- 顶部导航 -->
|
|
||||||
<FullscreenNav />
|
<FullscreenNav />
|
||||||
|
|
||||||
<!-- 占位内容 -->
|
<scroll-view class="content" scroll-y>
|
||||||
<view class="content">
|
<view class="card" @tap.stop>
|
||||||
<text class="placeholder-text">
|
<view class="toolbar">
|
||||||
压实热力图
|
<text class="tip">
|
||||||
|
点击地图查看点位详情
|
||||||
</text>
|
</text>
|
||||||
|
<heatmap-layerPanel
|
||||||
|
v-model="layer"
|
||||||
|
v-model:visible="layerVisible"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<heatmap-canvas
|
||||||
|
:layer="layer"
|
||||||
|
@select-point="onSelectPoint"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<heatmap-legend :layer="layer" />
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<heatmap-pointDetail
|
||||||
|
:visible="detailVisible"
|
||||||
|
:point="selectedPoint"
|
||||||
|
@close="closeDetail"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -25,19 +68,38 @@ definePage({
|
|||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: #EAEFF9;
|
background-color: #EAEFF9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 16px;
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
padding-top: 140px;
|
padding-top: 140px;
|
||||||
display: flex;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text {
|
.card {
|
||||||
font-size: 18px;
|
background: #fff;
|
||||||
color: #999;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const projects = computed(() => {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
.map(mapProjectItem)
|
.map(mapProjectItem)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -107,6 +109,7 @@ onMounted(async () => {
|
|||||||
</view>
|
</view>
|
||||||
<project-item
|
<project-item
|
||||||
v-for="item in projects"
|
v-for="item in projects"
|
||||||
|
:id="item.id"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:project-name="item.projectName"
|
:project-name="item.projectName"
|
||||||
:stake-range="item.stakeRange"
|
:stake-range="item.stakeRange"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ReportItem } from '@/api/report'
|
||||||
import type { HistoryReport } from '@/service/historyReport'
|
import type { HistoryReport } from '@/service/historyReport'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { getReportDetail, getReportDownloadUrl } from '@/api/report'
|
||||||
import { useDbStore } from '@/store/modules/db'
|
import { useDbStore } from '@/store/modules/db'
|
||||||
import storage from '@/utils/storage'
|
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
layout: false,
|
layout: false,
|
||||||
@@ -29,20 +30,24 @@ interface ReportDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = useDbStore()
|
const db = useDbStore()
|
||||||
|
const reportId = ref<number | null>(null)
|
||||||
const reportData = ref<ReportDetailView | 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)
|
if (!value)
|
||||||
return null
|
return null
|
||||||
const date = new Date(value.replace(/-/g, '/'))
|
const date = new Date(value.replace(/-/g, '/'))
|
||||||
return Number.isNaN(date.getTime()) ? null : date
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
}
|
}
|
||||||
|
|
||||||
function pad(value: number) {
|
function pad(value: number, length = 2) {
|
||||||
return String(value).padStart(2, '0')
|
return String(value).padStart(length, '0')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTimeRange(start?: string, end?: string) {
|
function formatDateTimeRange(start?: string | null, end?: string | null) {
|
||||||
const startDate = parseDate(start)
|
const startDate = parseDate(start)
|
||||||
const endDate = parseDate(end)
|
const endDate = parseDate(end)
|
||||||
if (!startDate)
|
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}`
|
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 hours = value ?? 0
|
||||||
const text = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
|
const text = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
|
||||||
return `${text}H`
|
return `${text}H`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatArea(value?: number) {
|
function formatArea(value?: number | null) {
|
||||||
return `${value ?? 0}m²`
|
return `${value ?? 0}m²`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRate(value?: number) {
|
function formatRate(value?: number | null) {
|
||||||
const rate = value ?? 0
|
const rate = value ?? 0
|
||||||
const text = Number.isInteger(rate) ? String(rate) : rate.toFixed(1)
|
const text = Number.isInteger(rate) ? String(rate) : rate.toFixed(1)
|
||||||
return `${text}%`
|
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 {
|
return {
|
||||||
deviceCode: item.device_id || '-',
|
deviceCode: item.device_id || '-',
|
||||||
time: formatDateTimeRange(item.start_time, item.end_time),
|
time: formatDateTimeRange(item.start_time, item.end_time),
|
||||||
@@ -85,62 +112,179 @@ function mapReportDetail(item: HistoryReport): ReportDetailView {
|
|||||||
areaCount: formatArea(item.compaction_area),
|
areaCount: formatArea(item.compaction_area),
|
||||||
passCount: `${item.pass_count ?? 0}遍`,
|
passCount: `${item.pass_count ?? 0}遍`,
|
||||||
passRate: formatRate(item.qualified_rate),
|
passRate: formatRate(item.qualified_rate),
|
||||||
avgSpeed: item.avg_speed != null ? `${item.avg_speed}km/h` : '-',
|
avgSpeed: formatMetric(item.avg_speed, 'km/h'),
|
||||||
avgFrequency: '-',
|
avgFrequency: '-',
|
||||||
avgAmplitude: '-',
|
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) : '-',
|
avgCompaction: item.avg_compaction != null ? formatRate(item.avg_compaction) : '-',
|
||||||
exceptionCount: String(item.exception_count ?? 0),
|
exceptionCount: String(item.exception_count ?? 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStoragePayload(data: Record<string, any>) {
|
/** 兼容 data 嵌套 / 扁平结构 */
|
||||||
reportData.value = {
|
function pickReportPayload(res: unknown): ReportItem | null {
|
||||||
deviceCode: data.deviceCode || '-',
|
if (!res || typeof res !== 'object')
|
||||||
time: data.time || '-',
|
return null
|
||||||
status: data.status || '作业已完成',
|
const raw = res as Record<string, unknown>
|
||||||
duration: data.duration || '-',
|
const nested = raw.data
|
||||||
areaCount: data.areaCount || '-',
|
if (nested && typeof nested === 'object' && 'reportId' in (nested as object))
|
||||||
passCount: data.passCount || '-',
|
return nested as ReportItem
|
||||||
passRate: data.passRate || '-',
|
if ('reportId' in raw)
|
||||||
avgSpeed: data.avgSpeed || '-',
|
return raw as unknown as ReportItem
|
||||||
avgFrequency: data.avgFrequency || '-',
|
return null
|
||||||
avgAmplitude: data.avgAmplitude || '-',
|
|
||||||
avgTemperature: data.avgTemperature || '-',
|
|
||||||
avgCompaction: data.avgCompaction || '-',
|
|
||||||
exceptionCount: data.exceptionCount || '-',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad(async (options) => {
|
async function loadLocalFallback(id: number) {
|
||||||
if (options?.key) {
|
if (!db.history_report.length || !db.project.length)
|
||||||
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)
|
|
||||||
await db.loadAll()
|
await db.loadAll()
|
||||||
|
|
||||||
const item = db.history_report.find(report => report.id === id)
|
const item = db.history_report.find(report => report.id === id)
|
||||||
if (item)
|
if (item) {
|
||||||
reportData.value = mapReportDetail(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() {
|
onShow(() => {
|
||||||
console.log('点击下载')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
<back-nav title="报告详情">
|
<back-nav title="报告详情">
|
||||||
<wd-button size="large" variant="soft" icon="download" @tap="handleDownload" />
|
<wd-button size="large" variant="soft" icon="download" :loading="downloading" :disabled="downloading" @tap="handleDownload" />
|
||||||
</back-nav>
|
</back-nav>
|
||||||
|
|
||||||
<view class="content">
|
<view class="content">
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ReportItem } from '@/api/report'
|
||||||
import type { HistoryReport } from '@/service/historyReport'
|
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'
|
import { useDbStore } from '@/store/modules/db'
|
||||||
|
|
||||||
definePage({
|
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()
|
const db = useDbStore()
|
||||||
|
|
||||||
|
/** 非 null 表示接口可用,优先用接口数据 */
|
||||||
|
const apiReports = ref<ReportItem[] | null>(null)
|
||||||
|
|
||||||
const datetimerange = ref<string[]>([])
|
const datetimerange = ref<string[]>([])
|
||||||
const reportStatus = ref('')
|
const reportStatus = ref('')
|
||||||
const area = ref('')
|
const area = ref('')
|
||||||
@@ -30,24 +52,48 @@ const reportStatusOptions = [
|
|||||||
{ value: 'pending', text: '待审核' },
|
{ 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 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 [
|
return [
|
||||||
{ value: 'all', text: '全部' },
|
{ value: 'all', text: '全部' },
|
||||||
...areas.map(text => ({ value: text, 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: '全部' },
|
{ value: 'all', text: '全部' },
|
||||||
...db.project.map(item => ({ value: String(item.id), text: item.project_name })),
|
...db.project.map(item => ({ value: String(item.id), text: item.project_name })),
|
||||||
])
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const reports = computed(() => {
|
const reports = computed(() => {
|
||||||
const query = appliedQuery.value
|
const query = appliedQuery.value
|
||||||
return db.history_report
|
return listRows.value.filter(item => matchReport(item, query))
|
||||||
.filter(item => matchReport(item, query))
|
|
||||||
.map(mapReportItem)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function isAll(value: string) {
|
function isAll(value: string) {
|
||||||
@@ -55,16 +101,18 @@ function isAll(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchReport(
|
function matchReport(
|
||||||
item: HistoryReport,
|
item: ReportListRow,
|
||||||
query: { datetimerange: string[], reportStatus: string, area: string, project: string },
|
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
|
return false
|
||||||
if (!isAll(query.area) && item.construction_area !== query.area)
|
if (!isAll(query.area) && item.area !== query.area)
|
||||||
return false
|
return false
|
||||||
if (query.reportStatus === 'pending')
|
if (query.reportStatus === 'pending')
|
||||||
return false
|
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 false
|
||||||
return true
|
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}`
|
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 hours = value ?? 0
|
||||||
const text = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
|
const text = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
|
||||||
return `${text}H`
|
return `${text}H`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatArea(value?: number) {
|
function formatArea(value?: number | null) {
|
||||||
return `${value ?? 0}m²`
|
return `${value ?? 0}m²`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRate(value?: number) {
|
function formatRate(value?: number | null) {
|
||||||
const rate = value ?? 0
|
const rate = value ?? 0
|
||||||
const text = Number.isInteger(rate) ? String(rate) : rate.toFixed(1)
|
const text = Number.isInteger(rate) ? String(rate) : rate.toFixed(1)
|
||||||
return `${text}%`
|
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 {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.report_name,
|
title: item.report_name,
|
||||||
@@ -136,6 +207,9 @@ function mapReportItem(item: HistoryReport) {
|
|||||||
duration: formatDuration(item.duration),
|
duration: formatDuration(item.duration),
|
||||||
areaCount: formatArea(item.compaction_area),
|
areaCount: formatArea(item.compaction_area),
|
||||||
passRate: formatRate(item.qualified_rate),
|
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 () => {
|
/** 优先拉接口;code 非 200 或请求失败则回退本地 SQLite */
|
||||||
if (!db.history_report.length)
|
async function loadReports() {
|
||||||
await db.loadAll()
|
// 本地库并行准备,不阻塞接口请求
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -226,8 +321,8 @@ onMounted(async () => {
|
|||||||
</view>
|
</view>
|
||||||
<report-item
|
<report-item
|
||||||
v-for="item in reports"
|
v-for="item in reports"
|
||||||
:id="item.id"
|
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
|
:report-id="item.id"
|
||||||
:title="item.title"
|
:title="item.title"
|
||||||
:device-code="item.deviceCode"
|
:device-code="item.deviceCode"
|
||||||
:status="item.status"
|
:status="item.status"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
definePage({
|
definePage({
|
||||||
layout: false,
|
layout: false,
|
||||||
style: {
|
style: {
|
||||||
// navigationBarTitleText: 'RTK轨迹',
|
|
||||||
navigationStyle: 'custom',
|
navigationStyle: 'custom',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -10,15 +9,17 @@ definePage({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
<!-- 顶部导航 -->
|
|
||||||
<FullscreenNav />
|
<FullscreenNav />
|
||||||
|
|
||||||
<!-- 占位内容 -->
|
<!-- 放大展示与作业页相同的 RTK 轨迹绘制 -->
|
||||||
<view class="content">
|
<scroll-view class="content" scroll-y>
|
||||||
<text class="placeholder-text">
|
<view class="canvas-card">
|
||||||
RTK轨迹
|
<work-rtkCanvas
|
||||||
</text>
|
canvas-id="rtkTrackCanvasFull"
|
||||||
|
:navigate-on-click="false"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -26,19 +27,24 @@ definePage({
|
|||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: #EAEFF9;
|
background-color: #EAEFF9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 16px;
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
padding-top: 140px;
|
padding-top: 140px;
|
||||||
display: flex;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text {
|
.canvas-card {
|
||||||
font-size: 18px;
|
background: #fff;
|
||||||
color: #999;
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
/* 全屏宽度绘制 → 比例尺更大,视觉上放大 */
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ definePage({
|
|||||||
padding-top: 72px;
|
padding-top: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 双列布局 */
|
/* 双列:最小一屏,内容变高时整页跟着撑开可滚动 */
|
||||||
.two-column {
|
.two-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
height: calc(100vh - 84px);
|
min-height: calc(100vh - 84px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-left {
|
.column-left {
|
||||||
@@ -52,11 +53,16 @@ definePage({
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
position: sticky;
|
||||||
|
top: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-right {
|
.column-right {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
/* 右侧白卡随轨迹变高,不锁成一屏高 */
|
||||||
|
height: auto;
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const tableSqls = [
|
|||||||
|
|
||||||
/** 当前代码期望的各表字段(用于检测旧库结构) */
|
/** 当前代码期望的各表字段(用于检测旧库结构) */
|
||||||
const expectedColumns: Record<string, string[]> = {
|
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'],
|
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'],
|
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'],
|
warning_record: ['id', 'project_id', 'device_name', 'exception_type', 'exception_level', 'occurred_at', 'site_description', 'disposal_record', 'disposal_type', 'is_read', 'created_at'],
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const projectTableSql = `CREATE TABLE IF NOT EXISTS project (
|
|||||||
qualified_rate REAL DEFAULT 0,
|
qualified_rate REAL DEFAULT 0,
|
||||||
progress REAL DEFAULT 0,
|
progress REAL DEFAULT 0,
|
||||||
uncompacted_area 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,
|
status INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
@@ -33,6 +36,9 @@ export interface Project {
|
|||||||
qualified_rate?: number // 压实合格率,默认 0
|
qualified_rate?: number // 压实合格率,默认 0
|
||||||
progress?: number // 完成进度,默认 0
|
progress?: number // 完成进度,默认 0
|
||||||
uncompacted_area?: 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
|
status: 0 | 1 | 2 // 项目状态,0:已暂停,1:已结束,2:作业中,默认 0
|
||||||
created_at?: string // 创建时间,默认当前时间
|
created_at?: string // 创建时间,默认当前时间
|
||||||
updated_at?: string // 更新时间,默认当前时间
|
updated_at?: string // 更新时间,默认当前时间
|
||||||
@@ -68,14 +74,23 @@ export async function selectByStatus(status: 0 | 1): Promise<Project[]> {
|
|||||||
return DB.selectByField(TABLE, 'status', status)
|
return DB.selectByField(TABLE, 'status', status)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增项目 */
|
/** 新增项目,返回新记录 id */
|
||||||
export async function insert(data: Omit<Project, 'id'>): Promise<number> {
|
export async function insert(data: Omit<Project, 'id'>): Promise<number> {
|
||||||
await DB.insert(TABLE, {
|
await DB.insert(TABLE, {
|
||||||
...data,
|
...data,
|
||||||
uncompacted_area: calcUncompactedArea(data.total_area, data.total_compaction_area),
|
uncompacted_area: calcUncompactedArea(data.total_area, data.total_compaction_area),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await DB.selectSql('SELECT last_insert_rowid() as id')
|
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 轨迹 */
|
/** 更新项目;标记为已完成(status=1)时清除该项目的作业记录与 RTK 轨迹 */
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ export async function selectByWorkRecordId(workRecordId: number): Promise<RtkTra
|
|||||||
return DB.selectByField(TABLE, 'work_record_id', workRecordId)
|
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> {
|
export async function insert(data: RtkTrack): Promise<any> {
|
||||||
return DB.insert(TABLE, data)
|
return DB.insert(TABLE, data)
|
||||||
|
|||||||
@@ -26,24 +26,36 @@ async function seedProject() {
|
|||||||
project_name: '白沟产业园场地',
|
project_name: '白沟产业园场地',
|
||||||
stake_range: '1号地块',
|
stake_range: '1号地块',
|
||||||
total_area: 5200.0,
|
total_area: 5200.0,
|
||||||
|
avg_speed: 5.1,
|
||||||
|
avg_paving_temp: 148.0,
|
||||||
|
avg_compaction: 93.2,
|
||||||
status: 1,
|
status: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
project_name: '大兴机场配套道路',
|
project_name: '大兴机场配套道路',
|
||||||
stake_range: '北侧路段',
|
stake_range: '北侧路段',
|
||||||
total_area: 8800.0,
|
total_area: 8800.0,
|
||||||
|
avg_speed: 4.9,
|
||||||
|
avg_paving_temp: 152.0,
|
||||||
|
avg_compaction: 94.1,
|
||||||
status: 1,
|
status: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
project_name: '保定城区改造工程',
|
project_name: '保定城区改造工程',
|
||||||
stake_range: '朝阳路 ~ 裕华路',
|
stake_range: '朝阳路 ~ 裕华路',
|
||||||
total_area: 12000.0,
|
total_area: 12000.0,
|
||||||
|
avg_speed: 5.3,
|
||||||
|
avg_paving_temp: 145.0,
|
||||||
|
avg_compaction: 91.8,
|
||||||
status: 1,
|
status: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
project_name: '沧州港区道路',
|
project_name: '沧州港区道路',
|
||||||
stake_range: 'B区 ~ D区',
|
stake_range: 'B区 ~ D区',
|
||||||
total_area: 9500.0,
|
total_area: 9500.0,
|
||||||
|
avg_speed: 4.7,
|
||||||
|
avg_paving_temp: 150.0,
|
||||||
|
avg_compaction: 95.3,
|
||||||
status: 1,
|
status: 1,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// 当日工作记录表
|
// 工作记录表
|
||||||
import DB from '@/utils/sqlite'
|
import DB from '@/utils/sqlite'
|
||||||
|
|
||||||
const TABLE = 'work_record'
|
const TABLE = 'work_record'
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
* import { useDbStore } from '@/store/modules/db'
|
* import { useDbStore } from '@/store/modules/db'
|
||||||
* const db = useDbStore()
|
* const db = useDbStore()
|
||||||
* await db.loadAll()
|
* await db.loadAll()
|
||||||
|
* await db.loadByProject(projectId) // 按当前项目加载关联数据
|
||||||
*
|
*
|
||||||
* db.project // 项目表
|
* db.project // 项目表
|
||||||
* db.work_record // 作业记录表
|
* db.work_record // 作业记录表
|
||||||
@@ -50,6 +51,25 @@ export const useDbStore = defineStore('db', () => {
|
|||||||
history_report.value = historyReports
|
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) {
|
async function loadWorkRecordByProject(projectId: number) {
|
||||||
work_record.value = await workRecordService.selectByProjectId(projectId)
|
work_record.value = await workRecordService.selectByProjectId(projectId)
|
||||||
@@ -65,9 +85,9 @@ export const useDbStore = defineStore('db', () => {
|
|||||||
warning_record.value = await warningRecordService.selectByProjectId(projectId)
|
warning_record.value = await warningRecordService.selectByProjectId(projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按项目筛选历史报告 */
|
/** 加载全部历史报告(不按项目过滤) */
|
||||||
async function loadHistoryReportByProject(projectId: number) {
|
async function loadHistoryReportAll() {
|
||||||
history_report.value = await historyReportService.selectByProjectId(projectId)
|
history_report.value = await historyReportService.selectAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -77,9 +97,10 @@ export const useDbStore = defineStore('db', () => {
|
|||||||
warning_record,
|
warning_record,
|
||||||
history_report,
|
history_report,
|
||||||
loadAll,
|
loadAll,
|
||||||
|
loadByProject,
|
||||||
loadWorkRecordByProject,
|
loadWorkRecordByProject,
|
||||||
loadRtkTrackByWorkRecord,
|
loadRtkTrackByWorkRecord,
|
||||||
loadWarningByProject,
|
loadWarningByProject,
|
||||||
loadHistoryReportByProject,
|
loadHistoryReportAll,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function generateUUID(): string {
|
|||||||
/**
|
/**
|
||||||
* 获取设备唯一标识
|
* 获取设备唯一标识
|
||||||
*/
|
*/
|
||||||
function getDeviceId(): string {
|
export function getDeviceId(): string {
|
||||||
// 从存储获取
|
// 从存储获取
|
||||||
const storedId = storage.get<string>(DEVICE_ID_KEY)
|
const storedId = storage.get<string>(DEVICE_ID_KEY)
|
||||||
if (storedId) {
|
if (storedId) {
|
||||||
@@ -195,6 +195,7 @@ export function getOrInitDeviceInfo(): SystemInfo {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
getSystemInfo,
|
getSystemInfo,
|
||||||
|
getDeviceId,
|
||||||
getDeviceTypeName,
|
getDeviceTypeName,
|
||||||
getOsTypeName,
|
getOsTypeName,
|
||||||
saveDeviceInfo,
|
saveDeviceInfo,
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
*
|
*
|
||||||
* // 获取所有key
|
* // 获取所有key
|
||||||
* storage.keys()
|
* storage.keys()
|
||||||
|
*
|
||||||
|
* // 打印全部数据(key: value 形式)
|
||||||
|
* storage.print()
|
||||||
|
*
|
||||||
|
* // 打印全部数据并返回对象
|
||||||
|
* const all = storage.all()
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface StorageItem<T = any> {
|
interface StorageItem<T = any> {
|
||||||
@@ -120,7 +126,86 @@ const storage = {
|
|||||||
* @returns key数组
|
* @returns key数组
|
||||||
*/
|
*/
|
||||||
keys(): string[] {
|
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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,78 @@ import AutoImport from 'unplugin-auto-import/vite'
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import UniPolyfill from 'vite-plugin-uni-polyfill'
|
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/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
css: {
|
css: {
|
||||||
@@ -20,6 +92,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// 必须放最前:在 uni:json / vite:json 之前剥掉 pages.json 里的注释
|
||||||
|
stripJsonComments(),
|
||||||
// https://uni-helper.js.org/vite-plugin-uni-manifest
|
// https://uni-helper.js.org/vite-plugin-uni-manifest
|
||||||
UniHelperManifest(),
|
UniHelperManifest(),
|
||||||
// https://uni-helper.js.org/vite-plugin-uni-pages
|
// https://uni-helper.js.org/vite-plugin-uni-pages
|
||||||
|
|||||||
Reference in New Issue
Block a user