docs(design): 删除磁盘监控设计文档并更新前端页面结构规范

- 删除 frontend/src/views/systemMonitor/2026-04-22-disk-monitor-design.md 设计文档
- 删除 frontend/src/views/tools/addLedger/API_DEBUG.md 调试文档
- 在 AGENTS.md 中新增前端页面结构归档章节,规范复杂工具页结构
- 明确 index.vue、components/、utils/ 职责边界和拆分原则
- 规定页面级类型和 contract 脚本管理方式
- 统一复杂页面拆分优先顺序和注意事项
This commit is contained in:
2026-05-14 09:17:25 +08:00
parent 5b3ca264c4
commit f7d297decf
72 changed files with 5125 additions and 3028 deletions

View File

@@ -42,6 +42,8 @@ import type { AddData } from '@/api/tools/addData/interface'
import AddDataTaskPanel from './components/AddDataTaskPanel.vue'
import AddDataTaskStatusCard from './components/AddDataTaskStatusCard.vue'
import AddDataTemplateTable from './components/AddDataTemplateTable.vue'
import { normalizePreview, normalizeTaskStatus, normalizeTemplateItem, resolveText } from './utils/normalize'
import { buildPayloadSignature, buildTaskPayload as buildTaskRequestPayload } from './utils/taskPayload'
defineOptions({
name: 'AddDataView'
@@ -82,125 +84,13 @@ const handleTaskFormChange = (nextForm: AddData.TaskFormModel) => {
taskForm.intervalMinutes = nextForm.intervalMinutes
}
const normalizeLineIds = (lineIds: string[]) => {
return Array.from(
new Set(
(lineIds || [])
.map(item => item?.trim())
.filter((item): item is string => Boolean(item))
)
)
}
const parseLineIds = (lineIds: string[]) => {
return normalizeLineIds(lineIds)
}
const resetPreview = () => {
previewSummary.value = null
previewSignature.value = ''
}
const resolveNumber = (...values: unknown[]) => {
for (const value of values) {
if (value === null || value === undefined || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) {
return parsed
}
}
return 0
}
const resolveText = (...values: unknown[]) => {
for (const value of values) {
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) {
return text
}
}
return ''
}
const resolveDisplayRule = (value: unknown, fallback = '--') => {
if (typeof value === 'boolean') {
return value ? '显示' : '不显示'
}
if (typeof value === 'number') {
return value ? '显示' : '不显示'
}
const text = resolveText(value)
return text || fallback
}
const normalizePreview = (data?: AddData.PreviewResponse | null): AddData.NormalizedPreview => {
const tableStats = (Array.isArray(data?.tableStats) ? data.tableStats : [])
.map(item => ({
tableName: resolveText(item.tableName) || '--',
timePointCount: resolveNumber(item.timePointCount),
phaseCount: resolveNumber(item.phaseCount),
rowCount: resolveNumber(item.rowCount)
}))
.sort((left, right) => right.rowCount - left.rowCount)
return {
lineCount: resolveNumber(data?.lineCount),
intervalMinutes: resolveNumber(data?.intervalMinutes),
totalRowCount: resolveNumber(data?.totalRowCount),
tableStats
}
}
const normalizeTaskStatus = (data?: AddData.TaskStatusResponse | null): AddData.NormalizedTaskStatus => {
return {
taskId: resolveText(data?.taskId),
status: (resolveText(data?.status) || 'WAITING') as AddData.TaskStatus,
currentTableName: resolveText(data?.currentTableName),
currentBatchInfo: resolveText(data?.currentBatchInfo),
insertedCount: resolveNumber(data?.insertedCount),
skippedCount: resolveNumber(data?.skippedCount),
failedCount: resolveNumber(data?.failedCount),
failureReason: resolveText(data?.failureReason),
hourlyTimeResults: Array.isArray(data?.hourlyTimeResults) ? data.hourlyTimeResults.filter(Boolean) : [],
startTime: resolveText(data?.startTime),
endTime: resolveText(data?.endTime)
}
}
const normalizeTemplateItem = (item: AddData.TemplateItem): AddData.NormalizedTemplateItem => {
const decimalScale = resolveText(item.decimalScale)
return {
parameterName: resolveText(item.parameterName) || '--',
tableName: resolveText(item.tableName) || '--',
phaseDisplay: resolveText(item.phaseDisplay) || '--',
phaseCodesText: Array.isArray(item.phaseCodes) && item.phaseCodes.length ? item.phaseCodes.join(' / ') : '--',
displayText: resolveDisplayRule(item.display),
showQualifiedText: resolveDisplayRule(item.showQualified),
maxValueRule: resolveText(item.maxValueRule) || '--',
minValueRule: resolveText(item.minValueRule) || '--',
averageValueRule: resolveText(item.averageValueRule) || '--',
cp95ValueRule: resolveText(item.cp95ValueRule) || '--',
decimalScaleText: decimalScale ? `${decimalScale} 位小数` : '--'
}
}
const buildTaskPayload = (): AddData.TaskRequestParams => {
return {
lineIds: parseLineIds(taskForm.lineIds),
startTime: taskForm.startTime,
endTime: taskForm.endTime,
intervalMinutes: taskForm.intervalMinutes
}
}
const buildPayloadSignature = (payload: AddData.TaskRequestParams) => {
return JSON.stringify(payload)
return buildTaskRequestPayload(taskForm)
}
const buildPreviewDependencySignature = () => {

View File

@@ -0,0 +1,90 @@
import type { AddData } from '@/api/tools/addData/interface'
export const resolveNumber = (...values: unknown[]) => {
for (const value of values) {
if (value === null || value === undefined || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) {
return parsed
}
}
return 0
}
export const resolveText = (...values: unknown[]) => {
for (const value of values) {
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) {
return text
}
}
return ''
}
const resolveDisplayRule = (value: unknown, fallback = '--') => {
if (typeof value === 'boolean') {
return value ? '显示' : '不显示'
}
if (typeof value === 'number') {
return value ? '显示' : '不显示'
}
const text = resolveText(value)
return text || fallback
}
export const normalizePreview = (data?: AddData.PreviewResponse | null): AddData.NormalizedPreview => {
const tableStats = (Array.isArray(data?.tableStats) ? data.tableStats : [])
.map(item => ({
tableName: resolveText(item.tableName) || '--',
timePointCount: resolveNumber(item.timePointCount),
phaseCount: resolveNumber(item.phaseCount),
rowCount: resolveNumber(item.rowCount)
}))
.sort((left, right) => right.rowCount - left.rowCount)
return {
lineCount: resolveNumber(data?.lineCount),
intervalMinutes: resolveNumber(data?.intervalMinutes),
totalRowCount: resolveNumber(data?.totalRowCount),
tableStats
}
}
export const normalizeTaskStatus = (data?: AddData.TaskStatusResponse | null): AddData.NormalizedTaskStatus => {
return {
taskId: resolveText(data?.taskId),
status: (resolveText(data?.status) || 'WAITING') as AddData.TaskStatus,
currentTableName: resolveText(data?.currentTableName),
currentBatchInfo: resolveText(data?.currentBatchInfo),
insertedCount: resolveNumber(data?.insertedCount),
skippedCount: resolveNumber(data?.skippedCount),
failedCount: resolveNumber(data?.failedCount),
failureReason: resolveText(data?.failureReason),
hourlyTimeResults: Array.isArray(data?.hourlyTimeResults) ? data.hourlyTimeResults.filter(Boolean) : [],
startTime: resolveText(data?.startTime),
endTime: resolveText(data?.endTime)
}
}
export const normalizeTemplateItem = (item: AddData.TemplateItem): AddData.NormalizedTemplateItem => {
const decimalScale = resolveText(item.decimalScale)
return {
parameterName: resolveText(item.parameterName) || '--',
tableName: resolveText(item.tableName) || '--',
phaseDisplay: resolveText(item.phaseDisplay) || '--',
phaseCodesText: Array.isArray(item.phaseCodes) && item.phaseCodes.length ? item.phaseCodes.join(' / ') : '--',
displayText: resolveDisplayRule(item.display),
showQualifiedText: resolveDisplayRule(item.showQualified),
maxValueRule: resolveText(item.maxValueRule) || '--',
minValueRule: resolveText(item.minValueRule) || '--',
averageValueRule: resolveText(item.averageValueRule) || '--',
cp95ValueRule: resolveText(item.cp95ValueRule) || '--',
decimalScaleText: decimalScale ? `${decimalScale} 位小数` : '--'
}
}

View File

@@ -0,0 +1,24 @@
import type { AddData } from '@/api/tools/addData/interface'
const normalizeLineIds = (lineIds: string[]) => {
return Array.from(
new Set(
(lineIds || [])
.map(item => item?.trim())
.filter((item): item is string => Boolean(item))
)
)
}
export const buildTaskPayload = (form: AddData.TaskFormModel): AddData.TaskRequestParams => {
return {
lineIds: normalizeLineIds(form.lineIds),
startTime: form.startTime,
endTime: form.endTime,
intervalMinutes: form.intervalMinutes
}
}
export const buildPayloadSignature = (payload: AddData.TaskRequestParams) => {
return JSON.stringify(payload)
}

View File

@@ -1,484 +0,0 @@
# add-ledger API 调试文档
## 1. 基础信息
| 项 | 值 |
|---|---|
| 服务模块 | `tools/add-ledger` |
| 默认本地地址 | `http://127.0.0.1:18192` |
| 接口前缀 | `/addLedger` |
| 数据格式 | `application/json;charset=UTF-8` |
| 认证方式 | 走全局认证过滤器,除登录和 Swagger 外均需携带访问令牌 |
请求头:
```http
Authorization: Bearer ${accessToken}
Content-Type: application/json;charset=UTF-8
```
说明:
- `Authorization` 的真实 Header 名称和前缀来自公共组件 `SecurityConstants`,当前过滤器按 `SecurityConstants.AUTHORIZATION_KEY``SecurityConstants.AUTHORIZATION_PREFIX` 校验。
- 以下响应示例只固定 `data` 结构;`code``message``HttpResultUtil``CommonResponseEnum.SUCCESS` 的运行时序列化结果为准。
- 编辑项目、设备、测点时不支持搬迁父节点;如果请求中传父级 ID后端仍以当前台账节点已有父级关系为准。
- 删除父节点前端必须二次确认;后端收到删除请求后直接执行级联软删除。
统一成功响应结构示例:
```json
{
"code": "SUCCESS_CODE",
"message": "SUCCESS_MESSAGE",
"data": {}
}
```
## 2. 调试顺序建议
1. 调用登录接口获取 `accessToken`
2. 新增工程。
3. 使用工程 ID 新增项目。
4. 使用项目 ID 新增设备。
5. 查询设备可用线路号。
6. 使用设备 ID 和可用线路号新增测点。
7. 调用台账树和详情接口核对层级与字段。
8. 调用删除接口验证软删除和级联软删除。
## 3. 接口总览
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/addLedger/tree` | 查询台账树 |
| `GET` | `/addLedger/detail` | 查询节点详情 |
| `POST` | `/addLedger/engineering/save` | 新增或保存工程 |
| `POST` | `/addLedger/project/save` | 新增或保存项目 |
| `POST` | `/addLedger/equipment/save` | 新增或保存设备 |
| `POST` | `/addLedger/line/save` | 新增或保存测点 |
| `GET` | `/addLedger/line/availableLineNos` | 查询设备可用线路号 |
| `DELETE` | `/addLedger/node` | 删除节点并级联软删除子节点 |
## 4. 查询台账树
### 4.1 请求
```http
GET /addLedger/tree?keyword=测试
```
Query 参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `keyword` | `String` | 否 | 按 `cs_ledger.Name` 模糊查询 |
curl
```bash
curl -X GET "http://127.0.0.1:18192/addLedger/tree?keyword=测试" \
-H "Authorization: Bearer ${accessToken}"
```
### 4.2 响应
```json
{
"code": "SUCCESS_CODE",
"message": "SUCCESS_MESSAGE",
"data": [
{
"id": "engineeringId",
"parentId": "0",
"parentIds": "0",
"name": "测试工程",
"level": 0,
"sort": 0,
"children": [
{
"id": "projectId",
"parentId": "engineeringId",
"parentIds": "0,engineeringId",
"name": "测试项目",
"level": 1,
"sort": 0,
"children": []
}
]
}
]
}
```
## 5. 查询节点详情
### 5.1 请求
```http
GET /addLedger/detail?id=engineeringId&level=0
```
Query 参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `String` | 是 | 节点 ID与业务表主键一致 |
| `level` | `Integer` | 是 | `0` 工程,`1` 项目,`2` 设备,`3` 测点 |
curl
```bash
curl -X GET "http://127.0.0.1:18192/addLedger/detail?id=engineeringId&level=0" \
-H "Authorization: Bearer ${accessToken}"
```
### 5.2 响应
工程详情:
```json
{
"code": "SUCCESS_CODE",
"message": "SUCCESS_MESSAGE",
"data": {
"id": "engineeringId",
"level": 0,
"parentId": "0",
"parentIds": "0",
"name": "测试工程",
"sort": 0,
"province": "320000000000",
"city": "320100000000",
"description": "工程描述"
}
}
```
## 6. 新增或保存工程
### 6.1 请求
```http
POST /addLedger/engineering/save
```
Body 参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `String` | 否 | 为空时新增;非空时编辑 |
| `name` | `String` | 是 | 工程名称,同步为树节点名称 |
| `province` | `String` | 否 | 省 |
| `city` | `String` | 否 | 市 |
| `description` | `String` | 否 | 描述 |
新增示例:
```bash
curl -X POST "http://127.0.0.1:18192/addLedger/engineering/save" \
-H "Authorization: Bearer ${accessToken}" \
-H "Content-Type: application/json;charset=UTF-8" \
-d "{\"name\":\"测试工程\",\"province\":\"320000000000\",\"city\":\"320100000000\",\"description\":\"工程描述\"}"
```
编辑示例:
```json
{
"id": "engineeringId",
"name": "测试工程-修改",
"province": "320000000000",
"city": "320100000000",
"description": "工程描述修改"
}
```
### 6.2 响应
```json
{
"code": "SUCCESS_CODE",
"message": "SUCCESS_MESSAGE",
"data": {
"id": "engineeringId",
"level": 0,
"parentId": "0",
"parentIds": "0",
"name": "测试工程",
"province": "320000000000",
"city": "320100000000",
"description": "工程描述"
}
}
```
## 7. 新增或保存项目
### 7.1 请求
```http
POST /addLedger/project/save
```
Body 参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `String` | 否 | 为空时新增;非空时编辑 |
| `engineeringId` | `String` | 新增必填 | 父级工程 ID |
| `name` | `String` | 是 | 项目名称,同步为树节点名称 |
| `area` | `String` | 否 | 相对位置 |
| `description` | `String` | 否 | 项目描述 |
新增示例:
```json
{
"engineeringId": "engineeringId",
"name": "测试项目",
"area": "灿能园区",
"description": "项目描述"
}
```
curl
```bash
curl -X POST "http://127.0.0.1:18192/addLedger/project/save" \
-H "Authorization: Bearer ${accessToken}" \
-H "Content-Type: application/json;charset=UTF-8" \
-d "{\"engineeringId\":\"engineeringId\",\"name\":\"测试项目\",\"area\":\"灿能园区\",\"description\":\"项目描述\"}"
```
## 8. 新增或保存设备
### 8.1 请求
```http
POST /addLedger/equipment/save
```
Body 参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `String` | 否 | 为空时新增;非空时编辑 |
| `projectId` | `String` | 新增必填 | 父级项目 ID |
| `name` | `String` | 是 | 装置名称,同步为树节点名称 |
| `ndid` | `String` | 是 | 网络设备 ID |
| `mac` | `String` | 是 | 装置 MAC 地址 |
| `devType` | `String` | 否 | 装置类型,保存字典数据 ID |
| `devModel` | `String` | 是 | 装置型号,保存字典数据 ID |
| `devAccessMethod` | `String` | 否 | 装置接入方式 |
| `nodeId` | `String` | 否 | 前置服务器 IP |
| `nodeProcess` | `Integer` | 否 | 前置进程号 |
| `upgrade` | `Integer` | 否 | 是否支持升级,`0` 否,`1` 是,默认 `0` |
新增示例:
```json
{
"projectId": "projectId",
"name": "测试设备",
"ndid": "00B78D000001",
"mac": "00:B7:8D:00:00:01",
"devType": "dictDataIdForDeviceType",
"devModel": "dictDataIdForDeviceModel",
"devAccessMethod": "MQTT",
"nodeId": "127.0.0.1",
"nodeProcess": 1,
"upgrade": 0
}
```
curl
```bash
curl -X POST "http://127.0.0.1:18192/addLedger/equipment/save" \
-H "Authorization: Bearer ${accessToken}" \
-H "Content-Type: application/json;charset=UTF-8" \
-d "{\"projectId\":\"projectId\",\"name\":\"测试设备\",\"ndid\":\"00B78D000001\",\"mac\":\"00:B7:8D:00:00:01\",\"devModel\":\"dictDataIdForDeviceModel\",\"devAccessMethod\":\"MQTT\",\"upgrade\":0}"
```
### 8.2 默认值
新增设备时后端默认填充:
| 字段 | 默认值 |
|---|---|
| `run_status` | `1` |
| `status` | `1` |
| `process` | `4` |
| `usage_status` | `1` |
| `sort` | `0` |
| `upgrade` | 未传时为 `0` |
## 9. 查询设备可用线路号
### 9.1 请求
```http
GET /addLedger/line/availableLineNos?deviceId=equipmentId&lineId=lineId
```
Query 参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `deviceId` | `String` | 是 | 设备 ID |
| `lineId` | `String` | 否 | 编辑测点时传入当前测点 ID |
curl
```bash
curl -X GET "http://127.0.0.1:18192/addLedger/line/availableLineNos?deviceId=equipmentId" \
-H "Authorization: Bearer ${accessToken}"
```
### 9.2 响应
```json
{
"code": "SUCCESS_CODE",
"message": "SUCCESS_MESSAGE",
"data": [1, 2, 3, 4, 5]
}
```
## 10. 新增或保存测点
### 10.1 请求
```http
POST /addLedger/line/save
```
Body 参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `lineId` | `String` | 否 | 为空时新增;非空时编辑 |
| `deviceId` | `String` | 新增必填 | 父级设备 ID |
| `name` | `String` | 是 | 监测点名,同步为树节点名称 |
| `lineNo` | `Integer` | 是 | 设备线路,范围 `1-20` |
| `conType` | `Integer` | 是 | 接线方式,`0` 星型,`1` 角型,`2` V 型 |
| `volGrade` | `Decimal` | 是 | 电压等级,仅支持 `0.38``10``35``110``220``500` |
| `position` | `String` | 否 | 安装位置 |
| `ctRatio` | `Decimal` | 是 | CT 一次额定值,非负数 |
| `ct2Ratio` | `Decimal` | 是 | CT 二次额定值,非负数 |
| `ptRatio` | `Decimal` | 是 | PT 一次额定值,非负数 |
| `pt2Ratio` | `Decimal` | 是 | PT 二次额定值,非负数 |
| `shortCircuitCapacity` | `Decimal` | 否 | 最小短路容量,非负数 |
| `basicCapacity` | `Decimal` | 否 | 基准短路容量,非负数 |
| `protocolCapacity` | `Decimal` | 否 | 用户协议容量,非负数 |
| `devCapacity` | `Decimal` | 否 | 供电设备容量,非负数 |
| `monitorObj` | `String` | 否 | 监测对象类型 |
| `isGovern` | `Integer` | 否 | 是否治理,默认 `0` |
| `monitorUser` | `String` | 否 | 敏感用户 ID |
| `isImportant` | `Integer` | 否 | 是否主要监测点,默认 `0` |
新增示例:
```json
{
"deviceId": "equipmentId",
"name": "测试测点1",
"lineNo": 1,
"conType": 0,
"volGrade": 10,
"position": "positionDictDataId",
"ctRatio": 200,
"ct2Ratio": 1,
"ptRatio": 10,
"pt2Ratio": 0.1,
"shortCircuitCapacity": 10,
"basicCapacity": 10,
"protocolCapacity": 10,
"devCapacity": 10,
"monitorObj": "monitorObjDictDataId",
"isGovern": 0,
"monitorUser": "",
"isImportant": 0
}
```
curl
```bash
curl -X POST "http://127.0.0.1:18192/addLedger/line/save" \
-H "Authorization: Bearer ${accessToken}" \
-H "Content-Type: application/json;charset=UTF-8" \
-d "{\"deviceId\":\"equipmentId\",\"name\":\"测试测点1\",\"lineNo\":1,\"conType\":0,\"volGrade\":10,\"ctRatio\":200,\"ct2Ratio\":1,\"ptRatio\":10,\"pt2Ratio\":0.1,\"isGovern\":0,\"isImportant\":0}"
```
### 10.2 校验规则
- `lineNo` 必须在 `1-20` 之间。
- 同设备下正常测点的 `lineNo` 不可重复。
- `conType` 只能是 `0``1``2`
- `volGrade` 只能是 `0.38``10``35``110``220``500`
- CT/PT 字段必填且不能为负数。
- 容量字段传入时不能为负数。
## 11. 删除节点
### 11.1 请求
```http
DELETE /addLedger/node?id=engineeringId&level=0
```
Query 参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `String` | 是 | 节点 ID |
| `level` | `Integer` | 是 | `0` 工程,`1` 项目,`2` 设备,`3` 测点 |
curl
```bash
curl -X DELETE "http://127.0.0.1:18192/addLedger/node?id=engineeringId&level=0" \
-H "Authorization: Bearer ${accessToken}"
```
### 11.2 响应
```json
{
"code": "SUCCESS_CODE",
"message": "SUCCESS_MESSAGE",
"data": true
}
```
### 11.3 删除影响范围
| 删除层级 | 后端处理 |
|---|---|
| 工程 | 软删除工程、下属项目、设备、测点和对应台账节点 |
| 项目 | 软删除项目、下属设备、测点和对应台账节点 |
| 设备 | 软删除设备、下属测点和对应台账节点 |
| 测点 | 软删除测点和对应台账节点 |
软删除字段:
| 表 | 字段 | 删除值 |
|---|---|---|
| `cs_engineering` | `status` | `0` |
| `cs_project` | `status` | `0` |
| `cs_equipment_delivery` | `run_status` | `0` |
| `cs_line` | `status` | `0` |
| `cs_ledger` | `State` | `0` |
## 12. 常见错误
| 场景 | 典型错误信息 |
|---|---|
| 未传 token 或 token 格式错误 | 全局过滤器返回 token 解析失败 |
| 新增项目未传有效工程 ID | `台账节点不存在或已删除` |
| 新增设备未传有效项目 ID | `台账节点不存在或已删除` |
| 新增测点未传有效设备 ID | `台账节点不存在或已删除` |
| 测点线路号重复 | `同设备下 line_no 不可重复` |
| 测点线路号超范围 | `line_no 必须在 1-20 之间` |
| 接线方式非法 | `conType 只能是 0、1、2` |
| 电压等级非法 | `vol_grade 只能是 0.38、10、35、110、220、500` |

View File

@@ -92,6 +92,22 @@ import type { AddLedger } from '@/api/tools/addLedger/interface'
import { useDictStore } from '@/stores/modules/dict'
import LedgerTreePanel from './components/LedgerTreePanel.vue'
import LedgerContextPanel from './components/LedgerContextPanel.vue'
import {
buildLineNoOptions,
createEmptyEngineeringForm,
createEmptyEquipmentForm,
createEmptyLineForm,
createEmptyProjectForm,
findNodePath,
generateGuidText,
normalizeEngineeringDetail,
normalizeEquipmentDetail,
normalizeLineDetail,
normalizeProjectDetail,
normalizeTreeNode,
resolveContextFromPath,
type LedgerContextIds
} from './utils/ledgerData'
defineOptions({
name: 'AddLedgerView'
@@ -101,12 +117,6 @@ type LedgerContextPanelExpose = {
validateActiveForm: (level: AddLedger.NodeLevel | null) => Promise<boolean>
}
type LedgerContextIds = {
engineeringId: string
projectId: string
deviceId: string
}
type LedgerContextItem<T> = {
id: string
label: string
@@ -182,71 +192,6 @@ const emptyStateText = computed(() =>
treeData.value.length === 0 ? '台账树为空,请先新增一个工程。' : '从左侧台账树选择工程、项目、设备或监测点。'
)
function createEmptyEngineeringForm(): AddLedger.EngineeringForm {
return {
id: '',
name: '',
province: '',
city: '',
description: ''
}
}
function createEmptyProjectForm(parentEngineeringId = ''): AddLedger.ProjectForm {
return {
id: '',
engineeringId: parentEngineeringId,
parentId: parentEngineeringId,
name: '',
area: '',
description: ''
}
}
function createEmptyEquipmentForm(parentProjectId = '', parentEngineeringId = ''): AddLedger.EquipmentForm {
return {
id: '',
engineeringId: parentEngineeringId,
projectId: parentProjectId,
parentId: parentProjectId,
name: '',
ndid: '',
mac: '',
dev_type: '',
dev_model: '',
dev_access_method: '',
node_id: '',
node_process: '',
upgrade: 0
}
}
function createEmptyLineForm(parentDeviceId = ''): AddLedger.LineForm {
return {
id: '',
line_id: generateGuidText(),
deviceId: parentDeviceId,
parentId: parentDeviceId,
name: '',
line_no: undefined,
conType: undefined,
vol_grade: undefined,
position: '',
ct_ratio: undefined,
ct2_ratio: undefined,
pt_ratio: undefined,
pt2_ratio: undefined,
short_circuit_capacity: undefined,
basic_capacity: undefined,
protocol_capacity: undefined,
dev_capacity: undefined,
monitor_obj: '',
is_govern: 0,
monitor_user: '',
is_important: 0
}
}
const resolveFallbackDictOptions = (code: LedgerDictCode) =>
code === 'ledger_device_type' ? fallbackDeviceTypeOptions : fallbackDeviceModelOptions
@@ -311,192 +256,17 @@ const loadLedgerDictOptions = async () => {
loadLedgerDictOptionsByCode('ledger_device_model')
}
const resolveString = (data: Record<string, unknown>, ...keys: string[]) => {
for (const key of keys) {
const value = data[key]
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
const resolveNumber = (data: Record<string, unknown>, ...keys: string[]) => {
for (const key of keys) {
const value = data[key]
if (value === null || value === undefined || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
const normalizeLevel = (value: unknown): AddLedger.NodeLevel => {
const level = Number(value)
if (level === 0 || level === 1 || level === 2 || level === 3) {
return level
}
return 0
}
const normalizeTreeNode = (node: AddLedger.LedgerTreeNode): AddLedger.NormalizedTreeNode => {
const rawNode = node as Record<string, unknown>
const id = resolveString(rawNode, 'id', 'Id')
const children = Array.isArray(node.children) ? node.children.map(normalizeTreeNode).filter(item => item.id) : []
return {
id,
pid: resolveString(rawNode, 'parentId', 'pid', 'Pid'),
pids: resolveString(rawNode, 'parentIds', 'pids', 'Pids'),
name: resolveString(rawNode, 'name', 'Name') || id || '--',
level: normalizeLevel(rawNode.level ?? rawNode.Level),
children,
raw: node
}
}
const findNodePath = (
nodes: AddLedger.NormalizedTreeNode[],
id: string,
path: AddLedger.NormalizedTreeNode[] = []
): AddLedger.NormalizedTreeNode[] => {
for (const node of nodes) {
const nextPath = [...path, node]
if (node.id === id) return nextPath
const matchedPath = findNodePath(node.children, id, nextPath)
if (matchedPath.length) return matchedPath
}
return []
}
const getCurrentPath = () => {
if (!selectedNode.value?.id) return []
return findNodePath(treeData.value, selectedNode.value.id)
}
const resolveContextFromPath = (path: AddLedger.NormalizedTreeNode[]): LedgerContextIds => {
return {
engineeringId: path.find(item => item.level === 0)?.id || '',
projectId: path.find(item => item.level === 1)?.id || '',
deviceId: path.find(item => item.level === 2)?.id || ''
}
}
const resolveContext = () => {
const path = getCurrentPath()
return resolveContextFromPath(path)
}
function generateGuidText() {
return window.crypto.randomUUID().replace(/-/g, '')
}
const normalizeEngineeringDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode
): AddLedger.EngineeringForm => {
const data = (detail || {}) as Record<string, unknown>
return {
id: resolveString(data, 'id', 'engineeringId') || node?.id || '',
name: resolveString(data, 'name') || node?.name || '',
province: resolveString(data, 'province'),
city: resolveString(data, 'city'),
description: resolveString(data, 'description')
}
}
const normalizeProjectDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode,
context = resolveContext()
): AddLedger.ProjectForm => {
const data = (detail || {}) as Record<string, unknown>
return {
id: resolveString(data, 'id', 'projectId') || node?.id || '',
engineeringId: resolveString(data, 'engineeringId', 'associated_engineering') || context.engineeringId,
parentId: context.engineeringId,
name: resolveString(data, 'name') || node?.name || '',
area: resolveString(data, 'area'),
description: resolveString(data, 'description')
}
}
const normalizeEquipmentDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode,
context = resolveContext()
): AddLedger.EquipmentForm => {
const data = (detail || {}) as Record<string, unknown>
return {
id: resolveString(data, 'id', 'equipmentId') || node?.id || '',
engineeringId: resolveString(data, 'engineeringId', 'associated_engineering') || context.engineeringId,
projectId: resolveString(data, 'projectId', 'associated_project') || context.projectId,
parentId: context.projectId,
name: resolveString(data, 'name') || node?.name || '',
ndid: resolveString(data, 'ndid'),
mac: resolveString(data, 'mac'),
dev_type: resolveString(data, 'dev_type', 'devType'),
dev_model: resolveString(data, 'dev_model', 'devModel'),
dev_access_method: resolveString(data, 'dev_access_method', 'devAccessMethod'),
node_id: resolveString(data, 'node_id', 'nodeId'),
node_process: resolveString(data, 'node_process', 'nodeProcess'),
upgrade: resolveNumber(data, 'upgrade') ?? 0
}
}
const normalizeLineDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode,
context = resolveContext()
): AddLedger.LineForm => {
const data = (detail || {}) as Record<string, unknown>
const lineId = resolveString(data, 'line_id', 'lineId', 'id') || node?.id || generateGuidText()
return {
id: resolveString(data, 'id') || lineId,
line_id: lineId,
deviceId: resolveString(data, 'deviceId', 'device_id') || context.deviceId,
parentId: context.deviceId,
name: resolveString(data, 'name') || node?.name || '',
line_no: resolveNumber(data, 'line_no', 'lineNo'),
conType: resolveNumber(data, 'conType'),
vol_grade: resolveNumber(data, 'vol_grade', 'volGrade'),
position: resolveString(data, 'position'),
ct_ratio: resolveNumber(data, 'ct_ratio', 'ctRatio'),
ct2_ratio: resolveNumber(data, 'ct2_ratio', 'ct2Ratio'),
pt_ratio: resolveNumber(data, 'pt_ratio', 'ptRatio'),
pt2_ratio: resolveNumber(data, 'pt2_ratio', 'pt2Ratio'),
short_circuit_capacity: resolveNumber(data, 'short_circuit_capacity', 'shortCircuitCapacity'),
basic_capacity: resolveNumber(data, 'basic_capacity', 'basicCapacity'),
protocol_capacity: resolveNumber(data, 'protocol_capacity', 'protocolCapacity'),
dev_capacity: resolveNumber(data, 'dev_capacity', 'devCapacity'),
monitor_obj: resolveString(data, 'monitor_obj', 'monitorObj'),
is_govern: resolveNumber(data, 'is_govern', 'isGovern') ?? 0,
monitor_user: resolveString(data, 'monitor_user', 'monitorUser'),
is_important: resolveNumber(data, 'is_important', 'isImportant') ?? 0
}
}
const buildLineNoOptions = (lineNos: number[], currentLineNo?: number) => {
const values = new Set(lineNos.filter(item => Number.isInteger(item) && item >= 1 && item <= 20))
if (currentLineNo && currentLineNo >= 1 && currentLineNo <= 20) {
values.add(currentLineNo)
}
return Array.from(values)
.sort((left, right) => left - right)
.map(item => ({ label: `${item} 号线路`, value: item }))
}
const allLineNoOptions = computed(() => buildLineNoOptions(Array.from({ length: 20 }, (_item, index) => index + 1)))
const loadAvailableLineNoOptions = async (deviceId: string, lineId = '', currentLineNo?: number) => {

View File

@@ -0,0 +1,247 @@
import type { AddLedger } from '@/api/tools/addLedger/interface'
export type LedgerContextIds = {
engineeringId: string
projectId: string
deviceId: string
}
export function generateGuidText() {
return window.crypto.randomUUID().replace(/-/g, '')
}
export function createEmptyEngineeringForm(): AddLedger.EngineeringForm {
return {
id: '',
name: '',
province: '',
city: '',
description: ''
}
}
export function createEmptyProjectForm(parentEngineeringId = ''): AddLedger.ProjectForm {
return {
id: '',
engineeringId: parentEngineeringId,
parentId: parentEngineeringId,
name: '',
area: '',
description: ''
}
}
export function createEmptyEquipmentForm(parentProjectId = '', parentEngineeringId = ''): AddLedger.EquipmentForm {
return {
id: '',
engineeringId: parentEngineeringId,
projectId: parentProjectId,
parentId: parentProjectId,
name: '',
ndid: '',
mac: '',
dev_type: '',
dev_model: '',
dev_access_method: '',
node_id: '',
node_process: '',
upgrade: 0
}
}
export function createEmptyLineForm(parentDeviceId = ''): AddLedger.LineForm {
return {
id: '',
line_id: generateGuidText(),
deviceId: parentDeviceId,
parentId: parentDeviceId,
name: '',
line_no: undefined,
conType: undefined,
vol_grade: undefined,
position: '',
ct_ratio: undefined,
ct2_ratio: undefined,
pt_ratio: undefined,
pt2_ratio: undefined,
short_circuit_capacity: undefined,
basic_capacity: undefined,
protocol_capacity: undefined,
dev_capacity: undefined,
monitor_obj: '',
is_govern: 0,
monitor_user: '',
is_important: 0
}
}
const resolveString = (data: Record<string, unknown>, ...keys: string[]) => {
for (const key of keys) {
const value = data[key]
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
const resolveNumber = (data: Record<string, unknown>, ...keys: string[]) => {
for (const key of keys) {
const value = data[key]
if (value === null || value === undefined || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
const normalizeLevel = (value: unknown): AddLedger.NodeLevel => {
const level = Number(value)
if (level === 0 || level === 1 || level === 2 || level === 3) {
return level
}
return 0
}
export const normalizeTreeNode = (node: AddLedger.LedgerTreeNode): AddLedger.NormalizedTreeNode => {
const rawNode = node as Record<string, unknown>
const id = resolveString(rawNode, 'id', 'Id')
const children = Array.isArray(node.children) ? node.children.map(normalizeTreeNode).filter(item => item.id) : []
return {
id,
pid: resolveString(rawNode, 'parentId', 'pid', 'Pid'),
pids: resolveString(rawNode, 'parentIds', 'pids', 'Pids'),
name: resolveString(rawNode, 'name', 'Name') || id || '--',
level: normalizeLevel(rawNode.level ?? rawNode.Level),
children,
raw: node
}
}
export const findNodePath = (
nodes: AddLedger.NormalizedTreeNode[],
id: string,
path: AddLedger.NormalizedTreeNode[] = []
): AddLedger.NormalizedTreeNode[] => {
for (const node of nodes) {
const nextPath = [...path, node]
if (node.id === id) return nextPath
const matchedPath = findNodePath(node.children, id, nextPath)
if (matchedPath.length) return matchedPath
}
return []
}
export const resolveContextFromPath = (path: AddLedger.NormalizedTreeNode[]): LedgerContextIds => {
return {
engineeringId: path.find(item => item.level === 0)?.id || '',
projectId: path.find(item => item.level === 1)?.id || '',
deviceId: path.find(item => item.level === 2)?.id || ''
}
}
export const normalizeEngineeringDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode
): AddLedger.EngineeringForm => {
const data = (detail || {}) as Record<string, unknown>
return {
id: resolveString(data, 'id', 'engineeringId') || node?.id || '',
name: resolveString(data, 'name') || node?.name || '',
province: resolveString(data, 'province'),
city: resolveString(data, 'city'),
description: resolveString(data, 'description')
}
}
export const normalizeProjectDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode,
context: LedgerContextIds = { engineeringId: '', projectId: '', deviceId: '' }
): AddLedger.ProjectForm => {
const data = (detail || {}) as Record<string, unknown>
return {
id: resolveString(data, 'id', 'projectId') || node?.id || '',
engineeringId: resolveString(data, 'engineeringId', 'associated_engineering') || context.engineeringId,
parentId: context.engineeringId,
name: resolveString(data, 'name') || node?.name || '',
area: resolveString(data, 'area'),
description: resolveString(data, 'description')
}
}
export const normalizeEquipmentDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode,
context: LedgerContextIds = { engineeringId: '', projectId: '', deviceId: '' }
): AddLedger.EquipmentForm => {
const data = (detail || {}) as Record<string, unknown>
return {
id: resolveString(data, 'id', 'equipmentId') || node?.id || '',
engineeringId: resolveString(data, 'engineeringId', 'associated_engineering') || context.engineeringId,
projectId: resolveString(data, 'projectId', 'associated_project') || context.projectId,
parentId: context.projectId,
name: resolveString(data, 'name') || node?.name || '',
ndid: resolveString(data, 'ndid'),
mac: resolveString(data, 'mac'),
dev_type: resolveString(data, 'dev_type', 'devType'),
dev_model: resolveString(data, 'dev_model', 'devModel'),
dev_access_method: resolveString(data, 'dev_access_method', 'devAccessMethod'),
node_id: resolveString(data, 'node_id', 'nodeId'),
node_process: resolveString(data, 'node_process', 'nodeProcess'),
upgrade: resolveNumber(data, 'upgrade') ?? 0
}
}
export const normalizeLineDetail = (
detail: AddLedger.NodeDetail | null,
node?: AddLedger.NormalizedTreeNode,
context: LedgerContextIds = { engineeringId: '', projectId: '', deviceId: '' }
): AddLedger.LineForm => {
const data = (detail || {}) as Record<string, unknown>
const lineId = resolveString(data, 'line_id', 'lineId', 'id') || node?.id || generateGuidText()
return {
id: resolveString(data, 'id') || lineId,
line_id: lineId,
deviceId: resolveString(data, 'deviceId', 'device_id') || context.deviceId,
parentId: context.deviceId,
name: resolveString(data, 'name') || node?.name || '',
line_no: resolveNumber(data, 'line_no', 'lineNo'),
conType: resolveNumber(data, 'conType'),
vol_grade: resolveNumber(data, 'vol_grade', 'volGrade'),
position: resolveString(data, 'position'),
ct_ratio: resolveNumber(data, 'ct_ratio', 'ctRatio'),
ct2_ratio: resolveNumber(data, 'ct2_ratio', 'ct2Ratio'),
pt_ratio: resolveNumber(data, 'pt_ratio', 'ptRatio'),
pt2_ratio: resolveNumber(data, 'pt2_ratio', 'pt2Ratio'),
short_circuit_capacity: resolveNumber(data, 'short_circuit_capacity', 'shortCircuitCapacity'),
basic_capacity: resolveNumber(data, 'basic_capacity', 'basicCapacity'),
protocol_capacity: resolveNumber(data, 'protocol_capacity', 'protocolCapacity'),
dev_capacity: resolveNumber(data, 'dev_capacity', 'devCapacity'),
monitor_obj: resolveString(data, 'monitor_obj', 'monitorObj'),
is_govern: resolveNumber(data, 'is_govern', 'isGovern') ?? 0,
monitor_user: resolveString(data, 'monitor_user', 'monitorUser'),
is_important: resolveNumber(data, 'is_important', 'isImportant') ?? 0
}
}
export const buildLineNoOptions = (lineNos: number[], currentLineNo?: number) => {
const values = new Set(lineNos.filter(item => Number.isInteger(item) && item >= 1 && item <= 20))
if (currentLineNo && currentLineNo >= 1 && currentLineNo <= 20) {
values.add(currentLineNo)
}
return Array.from(values)
.sort((left, right) => left - right)
.map(item => ({ label: `${item} 号线路`, value: item }))
}

View File

@@ -1,756 +0,0 @@
{
"ReportList": [
{
"desc": "统计数据",
"inst": "01",
"TrgOps": "96",
"Select": "DataStatFileMap",
"DataSetList": [
"dsStatisticData",
"dsStHarm",
"dsStIHarm",
"dsStMMXU",
"dsStMSQI"
],
"LnInstList": [
"最大值",
"最小值",
"平均值",
"95值",
"方均根值",
"间谐波最大值",
"间谐波最小值",
"间谐波平均值",
"间谐波95值",
"间谐波方均根值"
]
},
{
"desc": "波动闪变",
"inst": "01",
"TrgOps": "96",
"Select": "FlickerFileMap",
"DataSetList": [
"dsFlickerData",
"dsPST"
],
"LnInstList": [
"波动闪变值"
]
},
{
"desc": "实时数据",
"inst": "01",
"TrgOps": "40",
"Select": "DataRealFileMap",
"DataSetList": [
"dsRealTimeData",
"dsRtHarm",
"dsRtIHarm",
"dsRtMMXU",
"dsRtMSQI",
"dsRtFre"
],
"LnInstList": [
"实时数据",
"间谐波实时数据"
]
},
{
"desc": "暂态事件",
"inst": "01",
"TrgOps": "96",
"Select": "QVVR",
"DataSetList": [
"dsEveQVVR"
],
"LnInstList": [
"电压变动A",
"电压变动B",
"电压变动C"
]
},
{
"desc": "录波状态",
"inst": "01",
"TrgOps": "96",
"Select": "RDRE",
"DataSetList": [
"dsEveRDRE"
],
"LnInstList": [
"录波文件"
]
}
],
"LnClassList": [
{
"desc": "基本数据",
"nameList": [
"MMXU"
]
},
{
"desc": "序分量值",
"nameList": [
"MSQI"
]
},
{
"desc": "谐波/间谐波数据",
"nameList": [
"MHAI"
]
},
{
"desc": "波动闪变",
"nameList": [
"MFLK"
]
},
{
"desc": "电压变动",
"nameList": [
"QVVR"
]
}
],
"PhaseList": [
{
"desc": "无相别",
"nameList": [
"null"
]
},
{
"desc": "正序",
"nameList": [
"c1"
]
},
{
"desc": "负序",
"nameList": [
"c2"
]
},
{
"desc": "零序",
"nameList": [
"c3"
]
},
{
"desc": "A相",
"nameList": [
"phsA",
"phsAHar"
]
},
{
"desc": "B相",
"nameList": [
"phsB",
"phsBHar"
]
},
{
"desc": "C相",
"nameList": [
"phsC",
"phsCHar"
]
},
{
"desc": "AB线",
"nameList": [
"phsAB",
"phsABHar"
]
},
{
"desc": "BC线",
"nameList": [
"phsBC",
"phsBCHar"
]
},
{
"desc": "CA线",
"nameList": [
"phsCA",
"phsCAHar"
]
}
],
"MultiplierList": [
{
"multiplier": 1,
"nameList": [
"null"
]
},
{
"multiplier": 1000,
"nameList": [
"k"
]
}
],
"UnitList": [
{
"desc": "other",
"nameList": [
"null"
]
},
{
"desc": "v",
"nameList": [
"V"
]
},
{
"desc": "a",
"nameList": [
"A"
]
},
{
"desc": "w",
"nameList": [
"W",
"VAr",
"VA"
]
}
],
"TypeList": [
{
"desc": "值",
"nameList": [
"mag"
]
},
{
"desc": "角度",
"nameList": [
"ang"
]
}
],
"DataObjectsList": [
{
"desc": "非间谐波数据",
"LnInstList": [
"最大值",
"最小值",
"平均值",
"95值",
"实时数据"
],
"ObjectList": [
{
"desc": "频率",
"nameList": [
"Hz"
]
},
{
"desc": "线电压总有效值",
"nameList": [
"PPV"
]
},
{
"desc": "相电压总有效值",
"nameList": [
"PhV"
]
},
{
"desc": "电流总有效值",
"nameList": [
"A"
]
},
{
"desc": "有功功率",
"nameList": [
"W"
]
},
{
"desc": "无功功率",
"nameList": [
"VAr"
]
},
{
"desc": "视在功率",
"nameList": [
"VA"
]
},
{
"desc": "功率因数",
"nameList": [
"PF"
]
},
{
"desc": "位移功率因数",
"nameList": [
"DF"
]
},
{
"desc": "三相总有功功率",
"nameList": [
"TotW"
]
},
{
"desc": "三相总无功功率",
"nameList": [
"TotVAr"
]
},
{
"desc": "三相总视在功率",
"nameList": [
"TotVA"
]
},
{
"desc": "三相功率因数",
"nameList": [
"TotPF"
]
},
{
"desc": "三相位移功率因数",
"nameList": [
"TotDF"
]
},
{
"desc": "频率偏差",
"nameList": [
"HzDev"
]
},
{
"desc": "相电压偏差",
"nameList": [
"PhVDev"
]
},
{
"desc": "线电压偏差",
"nameList": [
"PPVDev"
]
},
{
"desc": "正序负序和零序电压",
"nameList": [
"SeqV"
]
},
{
"desc": "正序负序和零序电流",
"nameList": [
"SeqA"
]
},
{
"desc": "电压负序不平衡度",
"nameList": [
"ImbNgV"
]
},
{
"desc": "电流负序不平衡度",
"nameList": [
"ImbNgA"
]
},
{
"desc": "电压零序不平衡度",
"nameList": [
"ImbZroV"
]
},
{
"desc": "电流零序不平衡度",
"nameList": [
"ImbZroA"
]
},
{
"desc": "相电压谐波总畸变率",
"nameList": [
"ThdPhV"
]
},
{
"desc": "相电压总偶次谐波畸变率",
"nameList": [
"ThdEvnPhV"
]
},
{
"desc": "相电压总奇次谐波畸变率",
"nameList": [
"ThdOddPhV"
]
},
{
"desc": "线电压谐波总畸变率",
"nameList": [
"ThdPPV"
]
},
{
"desc": "线电压总偶次谐波畸变率",
"nameList": [
"ThdEvnPPV"
]
},
{
"desc": "线电压总奇次谐波畸变率",
"nameList": [
"ThdOddPPV"
]
},
{
"desc": "相电压谐波含有率序列",
"baseflag": 1,
"basecount": 49,
"nameList": [
"HRPhV",
"HPhVMag"
]
},
{
"desc": "线电压谐波含有率序列",
"baseflag": 1,
"basecount": 49,
"nameList": [
"HRPPV"
]
},
{
"desc": "电流总谐波畸变率",
"nameList": [
"ThdA"
]
},
{
"desc": "电流总偶次谐波畸变率",
"nameList": [
"ThdEvnA"
]
},
{
"desc": "电流总奇次谐波畸变率",
"nameList": [
"ThdOddA"
]
},
{
"desc": "谐波电流有效值序列",
"baseflag": 1,
"basecount": 49,
"nameList": [
"HA",
"HAMag"
]
},
{
"desc": "谐波电压有效值序列",
"baseflag": 1,
"basecount": 49,
"nameList": [
"HPhV"
]
},
{
"desc": "2~50次谐波有功功率序列",
"baseflag": 1,
"basecount": 49,
"nameList": [
"HW"
]
},
{
"desc": "2~50次谐波无功功率序列",
"baseflag": 1,
"basecount": 49,
"nameList": [
"HVAr"
]
},
{
"desc": "2~50次谐波视在功率序列",
"baseflag": 1,
"basecount": 49,
"nameList": [
"HVA"
]
},
{
"desc": "三相总谐波视在功率",
"nameList": [
"TotHVA"
]
},
{
"desc": "三相总谐波无功功率",
"nameList": [
"TotHVAr"
]
},
{
"desc": "三相总谐波有功功率",
"nameList": [
"TotHW"
]
},
{
"desc": "相电压基波有效值",
"baseflag": 2,
"queuecount": 49,
"nameList": [
"HFundPhV",
"FundPhV"
],
"queueList":[
"HPhV"
]
},
{
"desc": "线电压基波有效值",
"baseflag": 2,
"queuecount": 49,
"nameList": [
"HFundPPV"
],
"queueList":[
"HPPV"
]
},
{
"desc": "电流基波有效值",
"baseflag": 2,
"queuecount": 49,
"nameList": [
],
"queueList":[
"HA"
]
},
{
"desc": "基波有功功率",
"baseflag": 2,
"queuecount": 49,
"nameList": [
],
"queueList":[
"HW"
]
},
{
"desc": "基波无功功率",
"baseflag": 2,
"queuecount": 49,
"nameList": [
],
"queueList":[
"HVAr"
]
},
{
"desc": "基波视在功率",
"baseflag": 2,
"queuecount": 49,
"nameList": [
],
"queueList":[
"HVA"
]
}
]
},
{
"desc": "间谐波数据",
"LnInstList": [
"间谐波最大值",
"间谐波最小值",
"间谐波平均值",
"间谐波95值",
"间谐波实时数据"
],
"ObjectList": [
{
"desc": "相电压间谐波含有率序列",
"baseflag": 1,
"basecount": 50,
"nameList": [
"HPhV"
]
},
{
"desc": "线电压间谐波含有率序列",
"baseflag": 1,
"basecount": 50,
"nameList": [
"HRPPV"
]
},
{
"desc": "间谐波电流有效值序列",
"baseflag": 1,
"basecount": 50,
"nameList": [
"HA"
]
},
{
"desc": "间谐波电压有效值序列",
"baseflag": 1,
"basecount": 50,
"nameList": [
"HRPhV"
]
}
]
},
{
"desc": "电压变动",
"LnInstList": [
"电压变动A",
"电压变动B",
"电压变动C"
],
"ObjectList": [
{
"desc": "电压扰动事件启动",
"nameList": [
"VarStr"
]
},
{
"desc": "电压暂降事件启动",
"nameList": [
"DipStr"
]
},
{
"desc": "电压暂升事件启动",
"nameList": [
"SwlStr"
]
},
{
"desc": "电压中断事件启动",
"nameList": [
"IntrStr"
]
},
{
"desc": "电压扰动事件特征幅值",
"nameList": [
"VVa"
]
},
{
"desc": "电压扰动事件持续时间",
"nameList": [
"VVaTm"
]
},
{
"desc": "电压暂降启动定值",
"nameList": [
"DipStrVal"
]
},
{
"desc": "电压暂升启动定值",
"nameList": [
"SwlStrVal"
]
},
{
"desc": "电压中断启动定值",
"nameList": [
"IntrStrVal"
]
}
]
},
{
"desc": "其余数据",
"LnInstList": [
"波动闪变值",
"录波文件"
],
"ObjectList": [
{
"desc": "线电压短时闪变值",
"nameList": [
"PPPst"
]
},
{
"desc": "相电压短时闪变值",
"nameList": [
"PhPst"
]
},
{
"desc": "线电压长时闪变值",
"nameList": [
"PPPlt"
]
},
{
"desc": "相电压长时闪变值",
"nameList": [
"PhPlt"
]
},
{
"desc": "线电压电压变动幅值",
"nameList": [
"PPFluc"
]
},
{
"desc": "相电压电压变动幅值",
"nameList": [
"PhFluc"
]
},
{
"desc": "线电压电压变动频度",
"nameList": [
"PPFlucf"
]
},
{
"desc": "相电压电压变动频度",
"nameList": [
"PhFlucf"
]
}
]
}
]
}

View File

@@ -0,0 +1,44 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const viewFile = path.join(currentDir, 'index.vue')
const viewSource = fs.readFileSync(viewFile, 'utf8')
const expectations = [
['download data waits for image capture', /case\s+'download-data':[\s\S]*await\s+downloadTrendData\(\)/],
['downloadTrendData is async', /const\s+downloadTrendData\s*=\s*async\s*\(\)\s*=>/],
['trend export captures images for both metrics', /captureTrendExportImages[\s\S]*instant[\s\S]*rms/],
['export uses mhtml multipart workbook', /multipart\/related;\s*boundary=/],
['export workbook declares data worksheet source', /<x:Name>数据<\/x:Name>[\s\S]*<x:WorksheetSource\s+HRef="data\.htm"\/>/],
['export workbook declares image worksheet source', /<x:Name>图片<\/x:Name>[\s\S]*<x:WorksheetSource\s+HRef="image\.htm"\/>/],
['export writes content location header', /`Content-Location:\s*\$\{contentLocation\}`/],
['export contains data html part', /'data\.htm'[\s\S]*buildTrendExportDataSheet/],
['export contains image html part', /'image\.htm'[\s\S]*buildTrendExportImageSheet/],
['export contains merged image resource', /Content-Location:\s*trend-images\.png/],
['export combines metric screenshots before embedding', /mergeTrendExportImages[\s\S]*loadedImages\.forEach[\s\S]*drawImage/],
['image worksheet contains instant chart title', /瞬时图/],
['image worksheet contains rms chart title', /RMS图/],
['captured image draws trend metric title', /drawTrendExportImageTitle[\s\S]*fillText\([\s\S]*title/],
['instant captured image uses instant value title', /captureTrendExportTargetImage\(trendExportMetricLabelMap\[metric\]\)/],
['image worksheet embeds merged resource image', /<img\s+src="trend-images\.png"/],
['export no longer embeds per-metric image resources', /trendExportImageLocationMap/, true],
['export no longer uses inline worksheet div markers', /mso-element:worksheet/, true]
]
const failures = expectations.filter(([, pattern, shouldBeMissing]) => {
const exists = pattern.test(viewSource)
return shouldBeMissing ? exists : !exists
})
if (failures.length) {
console.error('waveform trend export contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('waveform trend export contract check passed')

View File

@@ -0,0 +1,27 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const lineChartFile = path.join(currentDir, '..', '..', '..', 'components', 'echarts', 'line', 'index.vue')
const lineChartSource = fs.readFileSync(lineChartFile, 'utf8')
const expectations = [
['line chart reads toolbox dataZoom startValue', /zoomPayload\?\.startValue/],
['line chart reads toolbox dataZoom endValue', /zoomPayload\?\.endValue/],
['line chart falls back to current dataZoom option range', /getOption\?\.\(\)\?\.dataZoom/],
['line chart emits normalized chart zoom range', /emit\('chart-data-zoom'[\s\S]*start:[\s\S]*end:/]
]
const failures = expectations.filter(([, pattern]) => !pattern.test(lineChartSource))
if (failures.length) {
console.error('waveform trend zoom contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('waveform trend zoom contract check passed')

View File

@@ -7,11 +7,13 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url))
const viewFile = path.join(currentDir, 'index.vue')
const toolbarFile = path.join(currentDir, 'components', 'WaveformToolbar.vue')
const trendPanelFile = path.join(currentDir, 'components', 'WaveformTrendPanel.vue')
const optionsFile = path.join(currentDir, 'utils', 'options.ts')
const interfaceFile = path.join(currentDir, '..', '..', '..', 'api', 'tools', 'waveform', 'interface', 'index.ts')
const viewSource = fs.readFileSync(viewFile, 'utf8')
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
const trendPanelSource = fs.readFileSync(trendPanelFile, 'utf8')
const optionsSource = fs.readFileSync(optionsFile, 'utf8')
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
const expectations = [
@@ -124,10 +126,16 @@ const expectations = [
/key:\s*'marker-view'[\s\S]*mark[\s\S]*fullscreen[\s\S]*key:\s*'export'/
],
['trend tools third group contains image and data downloads', /key:\s*'export'[\s\S]*download-image[\s\S]*download-data/],
['parse type supports image display value', /export\s+type\s+ParseType\s*=\s*0\s*\|\s*1\s*\|\s*2\s*\|\s*3\s*\|\s*4/],
['parse type options include image display', /label:\s*'图片展示'\s*,\s*value:\s*4/],
['parse type change accepts image display value', /value\s*===\s*0[\s\S]*value\s*===\s*1[\s\S]*value\s*===\s*2[\s\S]*value\s*===\s*3[\s\S]*value\s*===\s*4/],
['trend tool group separator is dashed vertical line', /\.trend-tool-group\s*\+\s*\.trend-tool-group[\s\S]*border-left:\s*1px\s+dashed/]
]
const combinedSource = `${viewSource}\n${toolbarSource}\n${trendPanelSource}\n${interfaceSource}`
const typesFile = path.join(currentDir, 'components', 'types.ts')
const typesSource = fs.readFileSync(typesFile, 'utf8')
const combinedSource = `${viewSource}\n${toolbarSource}\n${trendPanelSource}\n${optionsSource}\n${interfaceSource}\n${typesSource}`
const failures = expectations.filter(([, pattern, shouldBeMissing]) => {
const exists = pattern.test(combinedSource)
return shouldBeMissing ? exists : !exists

View File

@@ -247,7 +247,7 @@ const handleValueModeChange = (value: string | number | boolean | undefined) =>
}
const handleParseTypeChange = (value: string | number | boolean | undefined) => {
if (value === 0 || value === 1 || value === 2 || value === 3) {
if (value === 0 || value === 1 || value === 2 || value === 3 || value === 4) {
emit('update:activeParseType', value)
}
}

View File

@@ -2,7 +2,7 @@ export type TrendTabValue = 'instant' | 'rms'
export type ValueMode = 'primary' | 'secondary'
export type DisplayMode = 'single-channel' | 'multi-channel'
export type ChannelSelectValue = number | 'all'
export type ParseType = 0 | 1 | 2 | 3
export type ParseType = 0 | 1 | 2 | 3 | 4
export type TrendToolAction =
| 'x-zoom-in'
| 'x-zoom-out'
@@ -77,3 +77,33 @@ export interface TrendChartClickPayload {
dataIndex: number
axisValue: string | number
}
export interface WaveformSeriesItem {
name: string
data: number[]
}
export interface WaveformTrendPayload {
timeLabels: string[]
unit: string
min?: number
max?: number
series: WaveformSeriesItem[]
}
export interface TrendChartLayoutOptions {
showTimeAxis?: boolean
showMarkerLabel?: boolean
yAxisSplitCount?: number
}
export interface TrendZoomRange {
start: number
end: number
}
export interface TrendMarker {
name: 'T1' | 'T2'
dataIndex: number
axisValue: string | number
}

View File

@@ -57,7 +57,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import dayjs from 'dayjs'
import html2canvas from 'html2canvas'
import { ElMessage } from 'element-plus'
import { parseComtradeApi, parseComtradeVectorApi } from '@/api/tools/waveform'
@@ -76,47 +75,38 @@ import type {
SummaryItem,
TrendChartClickPayload,
TrendChartZoomPayload,
TrendChartLayoutOptions,
TrendMarker,
TrendToolAction,
TrendTabValue,
TrendZoomRange,
ValueMode,
WaveformDetailOption,
WaveformRatioValue
WaveformRatioValue,
WaveformSeriesItem,
WaveformTrendPayload
} from './components/types'
import {
displayModeOptions,
parseTypeOptions,
trendLabelMap,
trendTabs,
valueModeLabelMap,
valueModeOptions
} from './utils/options'
import { axisLineColor, axisTextColor, defaultPhaseColors } from './utils/theme'
import {
formatNumber,
formatTrendTimeLabel,
formatWaveformTime,
getWaveformParseErrorMessage,
safeNumber
} from './utils/format'
defineOptions({
name: 'WaveformView'
})
interface WaveformSeriesItem {
name: string
data: number[]
}
interface WaveformTrendPayload {
timeLabels: string[]
unit: string
min?: number
max?: number
series: WaveformSeriesItem[]
}
interface TrendChartLayoutOptions {
showTimeAxis?: boolean
showMarkerLabel?: boolean
yAxisSplitCount?: number
}
interface TrendZoomRange {
start: number
end: number
}
interface TrendMarker {
name: 'T1' | 'T2'
dataIndex: number
axisValue: string | number
}
const activeTrendTab = ref<TrendTabValue>('instant')
const activeValueMode = ref<ValueMode>('primary')
const activeDisplayMode = ref<DisplayMode>('single-channel')
@@ -140,76 +130,11 @@ const trendYZoomScale = ref(1)
const activeTrendInteractionMode = ref<'none' | 'box-zoom' | 'pan' | 'mark'>('none')
const trendMarkers = ref<TrendMarker[]>([])
const trendTabs: LabelValueOption<TrendTabValue>[] = [
{ value: 'instant', label: '瞬时波形' },
{ value: 'rms', label: 'RMS 波形' }
]
const valueModeOptions: LabelValueOption<ValueMode>[] = [
{ label: '一次值', value: 'primary' },
{ label: '二次值', value: 'secondary' }
]
const displayModeOptions: LabelValueOption<DisplayMode>[] = [
{ label: '单通道', value: 'single-channel' },
{ label: '多通道', value: 'multi-channel' }
]
const parseTypeOptions: LabelValueOption<ParseType>[] = [
{ label: '高级算法(32-128)', value: 0 },
{ label: '普通展示(多采样率取最小)', value: 1 },
{ label: 'App抽点(32)', value: 2 },
{ label: '原始波形', value: 3 }
]
const trendLabelMap: Record<TrendTabValue, string> = {
instant: '瞬时波形',
rms: 'RMS 波形'
}
const valueModeLabelMap: Record<ValueMode, string> = {
primary: '一次值',
secondary: '二次值'
}
const readThemeColor = (name: string, fallback: string) => {
if (typeof window === 'undefined') return fallback
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
}
const phaseColors = {
a: readThemeColor('--cn-color-phase-a', '#daa520'),
b: readThemeColor('--cn-color-phase-b', '#2e8b57'),
c: readThemeColor('--cn-color-phase-c', '#a52a2a')
}
const defaultPhaseColors = [phaseColors.a, phaseColors.b, phaseColors.c]
const axisTextColor = readThemeColor('--el-text-color-regular', '#606266')
const axisLineColor = readThemeColor('--el-border-color', '#dcdfe6')
const selectedWaveformFileName = computed(() => {
const fileNames = [selectedCfgFile.value?.name, selectedDatFile.value?.name].filter(Boolean)
return fileNames.join(' / ')
})
const getWaveformParseErrorMessage = (error: unknown) => {
if (!error || typeof error !== 'object') {
return '波形解析失败,请检查 cfg 和 dat 文件内容'
}
const businessError = error as {
message?: string
response?: {
data?: {
message?: string
}
}
}
return businessError.response?.data?.message || businessError.message || '波形解析失败,请检查 cfg 和 dat 文件内容'
}
const hasParsedWaveform = computed(() => !!waveformParseResult.value?.waveData)
const buildSeriesPoints = (list: number[][] | undefined, valueIndex: number) => {
@@ -333,37 +258,6 @@ const getValueScale = (detail: Waveform.WaveDataDetail | null) => {
const activeValueScale = computed(() => getValueScale(activeWaveDetail.value))
const safeNumber = (value: unknown) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const formatNumber = (value: unknown, fractionDigits = 3) => {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) return '--'
if (Number.isInteger(numberValue)) return `${numberValue}`
return `${Number(numberValue.toFixed(fractionDigits))}`
}
const formatTrendTimeLabel = (value: unknown) => {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) {
return value === undefined || value === null || value === '' ? '--' : `${value}`
}
return numberValue.toFixed(3)
}
const formatWaveformTime = (value?: string) => {
if (!value) return '--'
const parsedValue = dayjs(value)
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm:ss.SSS') : value
}
const buildChannelLabel = (detail: Waveform.WaveDataDetail, index: number) => {
if (detail.title) return detail.title
if (detail.channelName && detail.unit) return `${detail.channelName} (${detail.unit})`
@@ -1192,7 +1086,7 @@ const handleTrendToolAction = async (action: TrendToolAction) => {
await downloadTrendImage()
break
case 'download-data':
downloadTrendData()
await downloadTrendData()
break
default:
break
@@ -1375,9 +1269,7 @@ const buildTrendExportFileName = (extension: string, includeTrendLabel = true) =
}
const downloadTrendImage = async () => {
await nextTick()
const targetElement = document.querySelector('.waveform-trend-export-target') as HTMLElement | null
const targetElement = await waitTrendExportTargetElement()
if (!targetElement) {
ElMessage.warning('未找到可导出的趋势图区域')
@@ -1410,6 +1302,16 @@ interface TrendExportPayloadGroup {
type TrendExportMetric = 'instant' | 'rms'
interface TrendExportImage {
metric: TrendExportMetric
title: string
imageUrl: string
}
interface TrendExportMergedImage {
imageUrl: string
}
interface TrendExportColumn {
metric: TrendExportMetric
channelName: string
@@ -1428,10 +1330,141 @@ const trendExportMetricLabelMap: Record<TrendExportMetric, string> = {
rms: 'RMS值'
}
const trendExportImageTitleMap: Record<TrendExportMetric, string> = {
instant: '瞬时图',
rms: 'RMS图'
}
const hasTrendExportSeries = (group: TrendExportPayloadGroup) => {
return group.instantPayload.series.length > 0 || group.rmsPayload.series.length > 0
}
const waitAnimationFrame = () => {
return new Promise<void>(resolve => {
requestAnimationFrame(() => resolve())
})
}
const waitTrendExportTargetElement = async () => {
await nextTick()
await waitAnimationFrame()
return document.querySelector('.waveform-trend-export-target') as HTMLElement | null
}
const drawTrendExportImageTitle = (sourceCanvas: HTMLCanvasElement, title: string) => {
const titleHeight = 52
const targetCanvas = document.createElement('canvas')
const context = targetCanvas.getContext('2d')
targetCanvas.width = sourceCanvas.width
targetCanvas.height = sourceCanvas.height + titleHeight
if (!context) return sourceCanvas.toDataURL('image/png')
context.fillStyle = '#ffffff'
context.fillRect(0, 0, targetCanvas.width, targetCanvas.height)
context.fillStyle = '#303133'
context.font = '600 24px sans-serif'
context.textBaseline = 'middle'
context.fillText(title, 24, titleHeight / 2)
context.drawImage(sourceCanvas, 0, titleHeight)
return targetCanvas.toDataURL('image/png')
}
const captureTrendExportTargetImage = async (title: string) => {
const targetElement = await waitTrendExportTargetElement()
if (!targetElement) return ''
const canvas = await html2canvas(targetElement, {
backgroundColor: '#ffffff',
scale: window.devicePixelRatio || 1,
useCORS: true
})
return drawTrendExportImageTitle(canvas, title)
}
const captureTrendExportImages = async (): Promise<TrendExportImage[]> => {
const previousTrendTab = activeTrendTab.value
const previousXZoomRange = { ...trendXZoomRange.value }
const previousYZoomScale = trendYZoomScale.value
const previousInteractionMode = activeTrendInteractionMode.value
const previousMarkers = [...trendMarkers.value]
const metrics: TrendExportMetric[] = ['instant', 'rms']
try {
const images: TrendExportImage[] = []
for (const metric of metrics) {
// 导出数据时临时切换趋势页签截图保证“图片”sheet 同时包含瞬时图和 RMS 图。
activeTrendTab.value = metric
const imageUrl = await captureTrendExportTargetImage(trendExportMetricLabelMap[metric])
if (!imageUrl) continue
images.push({
metric,
title: trendExportImageTitleMap[metric],
imageUrl
})
}
return images
} finally {
activeTrendTab.value = previousTrendTab
await nextTick()
trendXZoomRange.value = previousXZoomRange
trendYZoomScale.value = previousYZoomScale
activeTrendInteractionMode.value = previousInteractionMode
trendMarkers.value = previousMarkers
}
}
const loadTrendExportImage = (imageUrl: string) => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('趋势图图片加载失败'))
image.src = imageUrl
})
}
const mergeTrendExportImages = async (images: TrendExportImage[]): Promise<TrendExportMergedImage | null> => {
const loadedImages = await Promise.all(images.map(item => loadTrendExportImage(item.imageUrl)))
if (!loadedImages.length) return null
const imageGap = 24
const targetCanvas = document.createElement('canvas')
const context = targetCanvas.getContext('2d')
const targetWidth = Math.max(...loadedImages.map(item => item.naturalWidth || item.width))
const targetHeight =
loadedImages.reduce((height, item) => height + (item.naturalHeight || item.height), 0) +
imageGap * Math.max(loadedImages.length - 1, 0)
targetCanvas.width = targetWidth
targetCanvas.height = targetHeight
if (!context) return null
context.fillStyle = '#ffffff'
context.fillRect(0, 0, targetCanvas.width, targetCanvas.height)
let offsetTop = 0
loadedImages.forEach((image, index) => {
context.drawImage(image, 0, offsetTop)
offsetTop += (image.naturalHeight || image.height) + (index === loadedImages.length - 1 ? 0 : imageGap)
})
return {
imageUrl: targetCanvas.toDataURL('image/png')
}
}
const buildTrendExportPayloadGroup = (detail: Waveform.WaveDataDetail | null, index: number) => {
const scale = getValueScale(detail)
@@ -1596,7 +1629,20 @@ const buildTrendExportRows = (timeLabels: string[], columns: TrendExportColumn[]
.join('')
}
const buildTrendExportExcelHtml = (timeLabels: string[], columns: TrendExportColumn[]) => {
const buildTrendExportWorkbookXml = () => {
return [
'<xml>',
'<x:ExcelWorkbook>',
'<x:ExcelWorksheets>',
'<x:ExcelWorksheet><x:Name>数据</x:Name><x:WorksheetSource HRef="data.htm"/><x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet>',
'<x:ExcelWorksheet><x:Name>图片</x:Name><x:WorksheetSource HRef="image.htm"/><x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet>',
'</x:ExcelWorksheets>',
'</x:ExcelWorkbook>',
'</xml>'
].join('')
}
const buildTrendExportDataSheet = (timeLabels: string[], columns: TrendExportColumn[]) => {
return [
'<!DOCTYPE html>',
'<html>',
@@ -1628,7 +1674,102 @@ const buildTrendExportExcelHtml = (timeLabels: string[], columns: TrendExportCol
].join('')
}
const downloadTrendData = () => {
const buildTrendExportImageSheet = () => {
return [
'<!DOCTYPE html>',
'<html>',
'<head>',
'<meta charset="UTF-8" />',
'<style>',
'table{border-collapse:collapse;}',
'th,td{border:1px solid #999;padding:4px 8px;white-space:nowrap;}',
'th{background:#f5f7fa;font-weight:700;text-align:left;font-size:16px;}',
'.image-cell{padding:12px;text-align:left;}',
'.image-cell img{width:1100px;height:auto;display:block;}',
'</style>',
'</head>',
'<body>',
'<table class="image-table">',
'<tbody>',
'<tr><td class="image-cell"><img src="trend-images.png" alt="瞬时图和RMS图" /></td></tr>',
'</tbody>',
'</table>',
'</body>',
'</html>'
].join('')
}
const buildTrendExportWorkbookHtml = () => {
return [
'<!DOCTYPE html>',
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">',
'<head>',
'<meta charset="UTF-8" />',
buildTrendExportWorkbookXml(),
'</head>',
'<body></body>',
'</html>'
].join('')
}
const getTrendExportImageBase64 = (imageUrl: string) => {
return imageUrl.replace(/^data:image\/png;base64,/, '')
}
const splitBase64Lines = (value: string) => {
return value.replace(/(.{76})/g, '$1\r\n')
}
const buildTrendExportMhtmlPart = (boundary: string, contentType: string, contentLocation: string, content: string) => {
return [`--${boundary}`, `Content-Type: ${contentType}`, `Content-Location: ${contentLocation}`, '', content].join(
'\r\n'
)
}
const buildTrendExportImagePart = (boundary: string, image: TrendExportMergedImage) => {
return [
`--${boundary}`,
'Content-Type: image/png',
'Content-Transfer-Encoding: base64',
'Content-Location: trend-images.png',
'',
splitBase64Lines(getTrendExportImageBase64(image.imageUrl))
].join('\r\n')
}
const buildTrendExportExcelHtml = (
timeLabels: string[],
columns: TrendExportColumn[],
image: TrendExportMergedImage
) => {
const boundary = `----=_NextPart_${Date.now()}_${Math.random().toString(16).slice(2)}`
const parts = [
buildTrendExportMhtmlPart(boundary, 'text/html; charset="utf-8"', 'workbook.htm', buildTrendExportWorkbookHtml()),
buildTrendExportMhtmlPart(
boundary,
'text/html; charset="utf-8"',
'data.htm',
buildTrendExportDataSheet(timeLabels, columns)
),
buildTrendExportMhtmlPart(
boundary,
'text/html; charset="utf-8"',
'image.htm',
buildTrendExportImageSheet()
),
buildTrendExportImagePart(boundary, image),
`--${boundary}--`
]
return [
'MIME-Version: 1.0',
`Content-Type: multipart/related; boundary="${boundary}"; type="text/html"`,
'',
...parts
].join('\r\n')
}
const downloadTrendData = async () => {
if (!hasWaveformData.value) {
ElMessage.warning('暂无可导出的波形数据')
return
@@ -1650,8 +1791,22 @@ const downloadTrendData = () => {
return
}
const excelContent = buildTrendExportExcelHtml(timeLabels, exportColumns)
const blob = new Blob([`\uFEFF${excelContent}`], { type: 'application/vnd.ms-excel;charset=utf-8;' })
const exportImages = await captureTrendExportImages()
if (exportImages.length < 2) {
ElMessage.warning('趋势图图片生成失败,请稍后重试')
return
}
const exportImage = await mergeTrendExportImages(exportImages)
if (!exportImage) {
ElMessage.warning('趋势图图片生成失败,请稍后重试')
return
}
const excelContent = buildTrendExportExcelHtml(timeLabels, exportColumns, exportImage)
const blob = new Blob([excelContent], { type: 'application/vnd.ms-excel;charset=utf-8;' })
const blobUrl = URL.createObjectURL(blob)
const exportFile = document.createElement('a')
const fileName = buildTrendExportFileName('xls', false)

View File

@@ -0,0 +1,49 @@
import dayjs from 'dayjs'
export const getWaveformParseErrorMessage = (error: unknown) => {
if (!error || typeof error !== 'object') {
return '波形解析失败,请检查 cfg 和 dat 文件内容'
}
const businessError = error as {
message?: string
response?: {
data?: {
message?: string
}
}
}
return businessError.response?.data?.message || businessError.message || '波形解析失败,请检查 cfg 和 dat 文件内容'
}
export const safeNumber = (value: unknown) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
export const formatNumber = (value: unknown, fractionDigits = 3) => {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) return '--'
if (Number.isInteger(numberValue)) return `${numberValue}`
return `${Number(numberValue.toFixed(fractionDigits))}`
}
export const formatTrendTimeLabel = (value: unknown) => {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) {
return value === undefined || value === null || value === '' ? '--' : `${value}`
}
return numberValue.toFixed(3)
}
export const formatWaveformTime = (value?: string) => {
if (!value) return '--'
const parsedValue = dayjs(value)
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm:ss.SSS') : value
}

View File

@@ -0,0 +1,34 @@
import type { DisplayMode, LabelValueOption, ParseType, TrendTabValue, ValueMode } from '../components/types'
export const trendTabs: LabelValueOption<TrendTabValue>[] = [
{ value: 'instant', label: '瞬时波形' },
{ value: 'rms', label: 'RMS 波形' }
]
export const valueModeOptions: LabelValueOption<ValueMode>[] = [
{ label: '一次值', value: 'primary' },
{ label: '二次值', value: 'secondary' }
]
export const displayModeOptions: LabelValueOption<DisplayMode>[] = [
{ label: '单通道', value: 'single-channel' },
{ label: '多通道', value: 'multi-channel' }
]
export const parseTypeOptions: LabelValueOption<ParseType>[] = [
{ label: '高级算法(32-128)', value: 0 },
{ label: '普通展示(多采样率取最小)', value: 1 },
{ label: 'App抽点(32)', value: 2 },
{ label: '原始波形', value: 3 },
{ label: '图片展示', value: 4 }
]
export const trendLabelMap: Record<TrendTabValue, string> = {
instant: '瞬时波形',
rms: 'RMS 波形'
}
export const valueModeLabelMap: Record<ValueMode, string> = {
primary: '一次值',
secondary: '二次值'
}

View File

@@ -0,0 +1,15 @@
const readThemeColor = (name: string, fallback: string) => {
if (typeof window === 'undefined') return fallback
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
}
const phaseColors = {
a: readThemeColor('--cn-color-phase-a', '#daa520'),
b: readThemeColor('--cn-color-phase-b', '#2e8b57'),
c: readThemeColor('--cn-color-phase-c', '#a52a2a')
}
export const defaultPhaseColors = [phaseColors.a, phaseColors.b, phaseColors.c]
export const axisTextColor = readThemeColor('--el-text-color-regular', '#606266')
export const axisLineColor = readThemeColor('--el-border-color', '#dcdfe6')