feat: update Excel template management and export handling
This commit is contained in:
541
docs/excel-import-template-feature-design.md
Normal file
541
docs/excel-import-template-feature-design.md
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
# Excel 导入模板功能设计文档:单条模板 / 批量模板
|
||||||
|
|
||||||
|
## 1. 设计目标
|
||||||
|
|
||||||
|
当前项目已经具备“模板管理”能力,但需要进一步明确模板类型,避免把单条变量模板和从 Excel 导入的一整套变量混在一起。
|
||||||
|
|
||||||
|
新的设计目标是把模板管理拆成两类:
|
||||||
|
|
||||||
|
```text
|
||||||
|
单条模板管理:每次新增变量时,复制/套用一条变量模板。
|
||||||
|
批量模板管理:导入 Excel,读取 MES_基本变量原始数据,保存为一整套批量模板。
|
||||||
|
```
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `单条模板` 保留现在的使用方式,用于新增或编辑单条变量时快速填充字段。
|
||||||
|
- `批量模板` 用于把外部 Excel 中的 `MES_基本变量原始数据` 整批复制到当前设备模块。
|
||||||
|
- 批量模板只读取导入 Excel 的 `MES_基本变量原始数据` Sheet。
|
||||||
|
- 批量模板不读取、不解析、不使用 `设备类型对应表`。
|
||||||
|
- 批量模板不会根据外部 Excel 修改当前网页中的设备模块、工位、设备 IP、DB。
|
||||||
|
|
||||||
|
## 2. 模板管理分类
|
||||||
|
|
||||||
|
模板管理分为两个 Tab 或两个区域:
|
||||||
|
|
||||||
|
```text
|
||||||
|
单条模板管理 | 批量模板管理
|
||||||
|
```
|
||||||
|
|
||||||
|
| 类型 | 数据来源 | 使用方式 | 结果 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 单条模板管理 | 用户手动维护的一条变量字段模板 | 新增变量时选择并套用 | 新增 1 条变量 |
|
||||||
|
| 批量模板管理 | 外部 Excel 的 `MES_基本变量原始数据` | 选择批量模板并使用 | 一次新增多条变量 |
|
||||||
|
|
||||||
|
第一版不做设备模板、不做工位模板、不从 Excel 反向生成设备配置。
|
||||||
|
|
||||||
|
## 3. 单条模板管理
|
||||||
|
|
||||||
|
单条模板管理保留当前功能。
|
||||||
|
|
||||||
|
### 3.1 功能定位
|
||||||
|
|
||||||
|
单条模板用于减少单条变量录入时的重复填写。
|
||||||
|
|
||||||
|
例如用户新增变量时,可以选择:
|
||||||
|
|
||||||
|
- `Bool 信号模板`
|
||||||
|
- `Int 数值模板`
|
||||||
|
- `Real 浮点模板`
|
||||||
|
- `String 字符串模板`
|
||||||
|
|
||||||
|
然后自动填充:
|
||||||
|
|
||||||
|
- `TagTypeID`
|
||||||
|
- `TagLong`
|
||||||
|
- `变量类型`
|
||||||
|
- `是否启用`
|
||||||
|
- `是否监控`
|
||||||
|
- 其它常用字段
|
||||||
|
|
||||||
|
### 3.2 单条模板使用规则
|
||||||
|
|
||||||
|
新增变量时:
|
||||||
|
|
||||||
|
1. 用户点击 `新增变量`。
|
||||||
|
2. 系统打开变量编辑弹窗。
|
||||||
|
3. 用户选择一个单条模板。
|
||||||
|
4. 点击 `使用模板`。
|
||||||
|
5. 模板字段填入当前变量表单。
|
||||||
|
6. 用户继续补充 `TagID / TagName / 变量地址 / 测量项目` 等字段。
|
||||||
|
7. 保存后只新增一条共享变量。
|
||||||
|
|
||||||
|
### 3.3 单条模板保存内容
|
||||||
|
|
||||||
|
单条模板保存的是一条变量的部分字段,不需要保存完整 38 字段。
|
||||||
|
|
||||||
|
建议结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "tpl-bool-signal",
|
||||||
|
"templateName": "Bool 信号模板",
|
||||||
|
"description": "常用 Bool 开关量信号",
|
||||||
|
"fields": {
|
||||||
|
"TagTypeID": "11",
|
||||||
|
"TagLong": "1",
|
||||||
|
"变量类型": "Bool",
|
||||||
|
"是否启用": "1",
|
||||||
|
"是否监控": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 批量模板管理
|
||||||
|
|
||||||
|
批量模板管理是本次新增的重点。
|
||||||
|
|
||||||
|
### 4.1 功能定位
|
||||||
|
|
||||||
|
批量模板用于把外部 Excel 中的一整套基本变量原始数据保存成可复用模板。
|
||||||
|
|
||||||
|
用户后续可以选择这个批量模板,并一次性追加到当前设备模块的共享变量数据中。
|
||||||
|
|
||||||
|
效果类似之前版本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
直接复制 Excel 中的 MES_基本变量原始数据
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 批量模板来源
|
||||||
|
|
||||||
|
批量模板只来自外部 Excel 的:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MES_基本变量原始数据
|
||||||
|
```
|
||||||
|
|
||||||
|
即使导入 Excel 中存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
设备类型对应表
|
||||||
|
```
|
||||||
|
|
||||||
|
系统也不读取、不解析、不使用。
|
||||||
|
|
||||||
|
### 4.3 批量模板使用结果
|
||||||
|
|
||||||
|
用户使用批量模板后:
|
||||||
|
|
||||||
|
- 批量模板中的所有变量追加到当前选中的设备模块。
|
||||||
|
- 当前设备模块右侧 `MES_基本变量原始数据展开预览` 立即刷新。
|
||||||
|
- 如果当前设备模块有多个配置行,右侧按多个工位展开显示。
|
||||||
|
- 导出 Excel 时,新追加的变量会出现在 `MES_基本变量原始数据` 中。
|
||||||
|
|
||||||
|
## 5. Excel 导入规则
|
||||||
|
|
||||||
|
### 5.1 Sheet 读取规则
|
||||||
|
|
||||||
|
导入 Excel 时只查找并读取:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MES_基本变量原始数据
|
||||||
|
```
|
||||||
|
|
||||||
|
如果没有该 Sheet,提示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
未找到 MES_基本变量原始数据 Sheet,无法导入批量模板。
|
||||||
|
```
|
||||||
|
|
||||||
|
不会读取:
|
||||||
|
|
||||||
|
```text
|
||||||
|
设备类型对应表
|
||||||
|
```
|
||||||
|
|
||||||
|
也不会读取其它 Sheet。
|
||||||
|
|
||||||
|
### 5.2 表头规则
|
||||||
|
|
||||||
|
第一行作为表头。
|
||||||
|
|
||||||
|
字段以当前系统中的 `MES_HEADERS` 为准。
|
||||||
|
|
||||||
|
处理规则:
|
||||||
|
|
||||||
|
| 情况 | 处理方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| Excel 存在系统支持字段 | 读取该字段 |
|
||||||
|
| Excel 存在未知字段 | 忽略 |
|
||||||
|
| Excel 缺少系统字段 | 补为空 |
|
||||||
|
| Excel 空行 | 忽略 |
|
||||||
|
|
||||||
|
至少应存在:
|
||||||
|
|
||||||
|
- `TagID`
|
||||||
|
- `TagName`
|
||||||
|
|
||||||
|
建议必须填写:
|
||||||
|
|
||||||
|
- `TagTypeID`
|
||||||
|
- `TagLong`
|
||||||
|
|
||||||
|
### 5.3 字段保留规则
|
||||||
|
|
||||||
|
批量模板保存导入 Excel 中识别出的多条基本变量原始数据。
|
||||||
|
|
||||||
|
保存时字段仍按 38 列标准字段管理:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TagID
|
||||||
|
TagName
|
||||||
|
工位号
|
||||||
|
数据来源
|
||||||
|
IP
|
||||||
|
TagTypeID
|
||||||
|
TagLong
|
||||||
|
DbName
|
||||||
|
...
|
||||||
|
IsWebSocket
|
||||||
|
```
|
||||||
|
|
||||||
|
但导出和右侧预览时,以下字段仍以当前网页设备模块配置为准:
|
||||||
|
|
||||||
|
- `工位号`
|
||||||
|
- `数据来源`
|
||||||
|
- `IP`
|
||||||
|
- `DbName`
|
||||||
|
|
||||||
|
也就是说,外部 Excel 中这些字段可以被导入保存,但不会覆盖当前网页的设备模块配置。
|
||||||
|
|
||||||
|
### 5.4 TagID 规则
|
||||||
|
|
||||||
|
外部 Excel 中可能存在两种格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TAG_0000001
|
||||||
|
ST04_TAG_0000001
|
||||||
|
```
|
||||||
|
|
||||||
|
批量模板保存时建议统一保存为基础格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TAG_0000001
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
如果 TagID 中包含 TAG_,则从 TAG_ 开始截取。
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
| 导入值 | 模板保存值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `TAG_0000001` | `TAG_0000001` |
|
||||||
|
| `ST04_TAG_0000001` | `TAG_0000001` |
|
||||||
|
| `ST11_TAG_0000023` | `TAG_0000023` |
|
||||||
|
|
||||||
|
使用批量模板追加到设备模块后,页面预览和 Excel 导出再按当前工位展开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ST04_TAG_0000001
|
||||||
|
ST05_TAG_0000001
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 批量模板使用规则
|
||||||
|
|
||||||
|
### 6.1 使用入口
|
||||||
|
|
||||||
|
在模板管理中进入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
批量模板管理
|
||||||
|
```
|
||||||
|
|
||||||
|
选择一个批量模板后点击:
|
||||||
|
|
||||||
|
```text
|
||||||
|
使用批量模板
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 使用前提
|
||||||
|
|
||||||
|
必须先选择左侧设备模块。
|
||||||
|
|
||||||
|
如果未选择设备模块,提示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
请先选择设备模块,再使用批量模板。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 追加方式
|
||||||
|
|
||||||
|
使用批量模板时,不覆盖当前设备模块已有变量,而是追加。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
当前设备模块已有变量:3 条
|
||||||
|
批量模板变量:23 条
|
||||||
|
使用后当前设备模块变量:26 条
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 TagID 冲突处理
|
||||||
|
|
||||||
|
如果批量模板中的 `TagID` 与当前设备模块已有变量重复,推荐第一版自动重排。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
当前已有:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TAG_0000001
|
||||||
|
TAG_0000002
|
||||||
|
```
|
||||||
|
|
||||||
|
批量模板中有:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TAG_0000001
|
||||||
|
TAG_0000002
|
||||||
|
TAG_0000003
|
||||||
|
```
|
||||||
|
|
||||||
|
追加后生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TAG_0000003
|
||||||
|
TAG_0000004
|
||||||
|
TAG_0000005
|
||||||
|
```
|
||||||
|
|
||||||
|
这样可以避免同一设备模块中基础 `TagID` 重复。
|
||||||
|
|
||||||
|
### 6.5 展开预览
|
||||||
|
|
||||||
|
批量模板追加后,右侧表格按当前设备模块配置行展开。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
当前设备模块配置行:ST04、ST05
|
||||||
|
批量模板变量:23 条
|
||||||
|
右侧展开显示:46 行
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 数据结构建议
|
||||||
|
|
||||||
|
### 7.1 总配置结构
|
||||||
|
|
||||||
|
建议 OPFS/localStorage 中保存:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"updatedAt": "2026-06-02T10:00:00.000Z",
|
||||||
|
"devices": [],
|
||||||
|
"variableTemplates": [],
|
||||||
|
"batchVariableTemplates": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 单条模板结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "tpl-bool-signal",
|
||||||
|
"templateName": "Bool 信号模板",
|
||||||
|
"description": "常用 Bool 开关量信号",
|
||||||
|
"fields": {
|
||||||
|
"TagTypeID": "11",
|
||||||
|
"TagLong": "1",
|
||||||
|
"变量类型": "Bool"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 批量模板结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "batch-tpl-001",
|
||||||
|
"templateName": "放油标准批量模板",
|
||||||
|
"description": "从外部 Excel 导入",
|
||||||
|
"sourceFileName": "SignalTable_CZ.xlsx",
|
||||||
|
"createdAt": "2026-06-02T10:00:00.000Z",
|
||||||
|
"variables": [
|
||||||
|
{
|
||||||
|
"TagID": "TAG_0000001",
|
||||||
|
"TagName": "PLC_PC_工件到位",
|
||||||
|
"TagTypeID": "11",
|
||||||
|
"TagLong": "1",
|
||||||
|
"变量类型": "Bool",
|
||||||
|
"变量地址": "0",
|
||||||
|
"变量其它": ".0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 页面交互设计
|
||||||
|
|
||||||
|
### 8.1 模板管理弹窗
|
||||||
|
|
||||||
|
模板管理弹窗分为两个 Tab:
|
||||||
|
|
||||||
|
```text
|
||||||
|
单条模板管理 | 批量模板管理
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 单条模板管理
|
||||||
|
|
||||||
|
保留当前能力:
|
||||||
|
|
||||||
|
- 新增单条模板
|
||||||
|
- 编辑单条模板
|
||||||
|
- 删除单条模板
|
||||||
|
- 在新增变量弹窗中使用单条模板
|
||||||
|
- 从当前变量保存为单条模板
|
||||||
|
|
||||||
|
### 8.3 批量模板管理
|
||||||
|
|
||||||
|
新增能力:
|
||||||
|
|
||||||
|
- `导入 Excel`
|
||||||
|
- `预览批量模板`
|
||||||
|
- `使用批量模板`
|
||||||
|
- `删除批量模板`
|
||||||
|
|
||||||
|
批量模板列表展示:
|
||||||
|
|
||||||
|
- 模板名称
|
||||||
|
- 来源文件
|
||||||
|
- 变量数量
|
||||||
|
- 创建时间
|
||||||
|
|
||||||
|
### 8.4 导入 Excel 确认弹窗
|
||||||
|
|
||||||
|
导入 Excel 后先展示确认信息:
|
||||||
|
|
||||||
|
```text
|
||||||
|
来源文件:SignalTable_CZ.xlsx
|
||||||
|
读取 Sheet:MES_基本变量原始数据
|
||||||
|
有效变量数:23
|
||||||
|
忽略空行:0
|
||||||
|
忽略未知列:2
|
||||||
|
|
||||||
|
模板名称:[放油标准批量模板]
|
||||||
|
模板说明:[从外部 Excel 导入]
|
||||||
|
|
||||||
|
[取消] [保存为批量模板]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 测试方案
|
||||||
|
|
||||||
|
### 9.1 单条模板测试
|
||||||
|
|
||||||
|
1. 打开新增变量弹窗。
|
||||||
|
2. 选择一个单条模板。
|
||||||
|
3. 点击 `使用模板`。
|
||||||
|
4. 确认只填充当前变量表单。
|
||||||
|
5. 保存后只新增一条变量。
|
||||||
|
|
||||||
|
### 9.2 批量模板导入测试
|
||||||
|
|
||||||
|
1. 打开模板管理。
|
||||||
|
2. 进入 `批量模板管理`。
|
||||||
|
3. 点击 `导入 Excel`。
|
||||||
|
4. 选择包含 `MES_基本变量原始数据` 的 Excel。
|
||||||
|
5. 确认系统只读取该 Sheet。
|
||||||
|
6. 保存后生成一个批量模板。
|
||||||
|
|
||||||
|
### 9.3 非目标 Sheet 测试
|
||||||
|
|
||||||
|
1. 导入包含 `设备类型对应表` 的 Excel。
|
||||||
|
2. 确认系统不读取、不解析、不使用该 Sheet。
|
||||||
|
3. 当前设备模块配置不发生变化。
|
||||||
|
|
||||||
|
### 9.4 批量模板使用测试
|
||||||
|
|
||||||
|
1. 选择左侧一个设备模块。
|
||||||
|
2. 在批量模板管理中选择一个模板。
|
||||||
|
3. 点击 `使用批量模板`。
|
||||||
|
4. 当前设备模块一次性新增多条变量。
|
||||||
|
5. 右侧 `MES_基本变量原始数据展开预览` 立即刷新。
|
||||||
|
|
||||||
|
### 9.5 Excel 导出测试
|
||||||
|
|
||||||
|
1. 使用批量模板批量新增变量。
|
||||||
|
2. 点击 `生成 Excel`。
|
||||||
|
3. 导出的 `MES_基本变量原始数据` 包含新增变量。
|
||||||
|
4. 导出的 `工位号 / 数据来源 / IP / DbName` 使用当前网页设备模块配置。
|
||||||
|
5. 导出的 Excel 仍只包含两个 Sheet。
|
||||||
|
|
||||||
|
### 9.6 异常测试
|
||||||
|
|
||||||
|
需要验证:
|
||||||
|
|
||||||
|
- Excel 没有 `MES_基本变量原始数据` 时提示错误。
|
||||||
|
- Excel 表头缺少 `TagID` 或 `TagName` 时提示错误。
|
||||||
|
- Excel 空行被忽略。
|
||||||
|
- Excel 未知列被忽略。
|
||||||
|
- 批量模板保存后刷新页面仍存在。
|
||||||
|
|
||||||
|
## 10. 页面草图
|
||||||
|
|
||||||
|
### 10.1 模板管理
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 模板管理 │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [单条模板管理] [批量模板管理] │
|
||||||
|
├──────────────────────────────┬───────────────────────────────────────────────┤
|
||||||
|
│ 批量模板列表 │ 批量模板预览 │
|
||||||
|
│ [导入 Excel] │ 模板名称:放油标准批量模板 │
|
||||||
|
│ ┌──────────────────────────┐ │ 来源文件:SignalTable_CZ.xlsx │
|
||||||
|
│ │ 放油标准批量模板 23 条 │ │ 变量数量:23 │
|
||||||
|
│ │ 称重批量模板 12 条 │ │ │
|
||||||
|
│ └──────────────────────────┘ │ ┌───────────────────────────────────────────┐ │
|
||||||
|
│ │ │ TagID TagName TagTypeID │ │
|
||||||
|
│ │ │ TAG_0000001 PLC_PC_工件到位 11 │ │
|
||||||
|
│ │ │ TAG_0000002 PLC_PC_读取完成 11 │ │
|
||||||
|
│ │ └───────────────────────────────────────────┘ │
|
||||||
|
├──────────────────────────────┴───────────────────────────────────────────────┤
|
||||||
|
│ [删除模板] [使用批量模板] [关闭] │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 导入确认
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ 导入 Excel 为批量模板 │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ 来源文件:SignalTable_CZ.xlsx │
|
||||||
|
│ 读取 Sheet:MES_基本变量原始数据 │
|
||||||
|
│ 有效变量数:23 │
|
||||||
|
│ 忽略空行:0 │
|
||||||
|
│ 忽略未知列:2 │
|
||||||
|
│ │
|
||||||
|
│ 模板名称:[放油标准批量模板 ] │
|
||||||
|
│ 模板说明:[从外部 Excel 导入 ] │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ [取消] [保存为批量模板] │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 后续迭代方向
|
||||||
|
|
||||||
|
后续可以继续增强:
|
||||||
|
|
||||||
|
- 支持导入 Excel 后直接追加到当前设备模块,不先保存为批量模板。
|
||||||
|
- 支持批量模板内变量字段编辑。
|
||||||
|
- 支持批量模板导出为 JSON。
|
||||||
|
- 支持从当前设备模块变量保存为批量模板。
|
||||||
|
- 支持批量模板使用时选择 TagID 策略:自动重排、保留原值、覆盖重复。
|
||||||
|
- 支持导入前筛选变量,例如按 `变量类型`、`TagName`、`是否启用` 筛选。
|
||||||
|
|
||||||
74
src/utils/excelTemplateImporter.js
Normal file
74
src/utils/excelTemplateImporter.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import XLSX from 'xlsx'
|
||||||
|
import { MES_HEADERS } from './signalTableBuilder'
|
||||||
|
import { normalizeBatchTemplateVariables, validateUniqueTagIds } from './templateBuilder'
|
||||||
|
|
||||||
|
const MES_SHEET_NAME = 'MES_基本变量原始数据'
|
||||||
|
const REQUIRED_HEADERS = ['TagID', 'TagName']
|
||||||
|
|
||||||
|
function isEmptyRow(row) {
|
||||||
|
return !row.some(value => value !== undefined && value !== null && String(value).trim() !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importBatchTemplateFromExcel(file) {
|
||||||
|
const content = await file.arrayBuffer()
|
||||||
|
const workbook = XLSX.read(content, { type: 'array' })
|
||||||
|
const worksheet = workbook.Sheets[MES_SHEET_NAME]
|
||||||
|
|
||||||
|
if (!worksheet) {
|
||||||
|
throw new Error(`未找到 ${MES_SHEET_NAME} Sheet,无法导入批量模板。`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = XLSX.utils.sheet_to_json(worksheet, {
|
||||||
|
header: 1,
|
||||||
|
defval: '',
|
||||||
|
raw: false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
throw new Error(`${MES_SHEET_NAME} Sheet 为空,无法导入批量模板。`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = rows[0].map(value => String(value || '').trim())
|
||||||
|
const missingHeaders = REQUIRED_HEADERS.filter(header => !headers.includes(header))
|
||||||
|
if (missingHeaders.length) {
|
||||||
|
throw new Error(`缺少必需表头:${missingHeaders.join('、')}。`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignoredHeaders = headers.filter(header => header && !MES_HEADERS.includes(header))
|
||||||
|
const variables = []
|
||||||
|
let ignoredEmptyRows = 0
|
||||||
|
|
||||||
|
rows.slice(1).forEach(sourceRow => {
|
||||||
|
if (isEmptyRow(sourceRow)) {
|
||||||
|
ignoredEmptyRows += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const variable = {}
|
||||||
|
MES_HEADERS.forEach(header => {
|
||||||
|
const index = headers.indexOf(header)
|
||||||
|
variable[header] = index === -1 ? '' : sourceRow[index]
|
||||||
|
})
|
||||||
|
variables.push(variable)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!variables.length) {
|
||||||
|
throw new Error(`${MES_SHEET_NAME} Sheet 中没有可导入的变量数据。`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedVariables = normalizeBatchTemplateVariables(variables)
|
||||||
|
const duplicateTagIds = validateUniqueTagIds(normalizedVariables)
|
||||||
|
if (duplicateTagIds.length) {
|
||||||
|
throw new Error(`导入数据存在重复基础 TagID:${duplicateTagIds.join('、')}。`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceFileName: file.name,
|
||||||
|
sheetName: MES_SHEET_NAME,
|
||||||
|
variables: normalizedVariables,
|
||||||
|
variableCount: normalizedVariables.length,
|
||||||
|
ignoredEmptyRows,
|
||||||
|
ignoredUnknownColumns: ignoredHeaders.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export function createSignalTableWorkbook(data) {
|
|||||||
return workbook
|
return workbook
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportSignalTable(data, filename = 'SignalTable_CZ.xlsx') {
|
export async function exportSignalTable(data, filename = 'SignalTable.xlsx') {
|
||||||
const workbook = createSignalTableWorkbook(data)
|
const workbook = createSignalTableWorkbook(data)
|
||||||
const content = XLSX.write(workbook, {
|
const content = XLSX.write(workbook, {
|
||||||
bookType: 'xlsx',
|
bookType: 'xlsx',
|
||||||
|
|||||||
@@ -55,3 +55,23 @@ export async function openJsonFile() {
|
|||||||
if (!handles.length) return null
|
if (!handles.length) return null
|
||||||
return handles[0].getFile()
|
return handles[0].getFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openExcelFile() {
|
||||||
|
if (!isOpenPickerSupported()) return null
|
||||||
|
|
||||||
|
const handles = await window.showOpenFilePicker({
|
||||||
|
multiple: false,
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
description: 'Excel 工作簿',
|
||||||
|
accept: {
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||||
|
'application/vnd.ms-excel': ['.xls']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!handles.length) return null
|
||||||
|
return handles[0].getFile()
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ export function isOpfsSupported() {
|
|||||||
|
|
||||||
function wrapConfig(data) {
|
function wrapConfig(data) {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 3,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
devices: Array.isArray(data.devices) ? data.devices : [],
|
devices: Array.isArray(data.devices) ? data.devices : [],
|
||||||
variables: Array.isArray(data.variables) ? data.variables : [],
|
variables: Array.isArray(data.variables) ? data.variables : [],
|
||||||
variableTemplates: Array.isArray(data.variableTemplates) ? data.variableTemplates : []
|
variableTemplates: Array.isArray(data.variableTemplates) ? data.variableTemplates : [],
|
||||||
|
batchVariableTemplates: Array.isArray(data.batchVariableTemplates) ? data.batchVariableTemplates : []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,8 @@ export function normalizeData(data) {
|
|||||||
return {
|
return {
|
||||||
devices,
|
devices,
|
||||||
variables: templateVariables.length ? templateVariables : fallbackVariables.map(normalizeVariable),
|
variables: templateVariables.length ? templateVariables : fallbackVariables.map(normalizeVariable),
|
||||||
variableTemplates: Array.isArray(source.variableTemplates) ? cloneData(source.variableTemplates) : []
|
variableTemplates: Array.isArray(source.variableTemplates) ? cloneData(source.variableTemplates) : [],
|
||||||
|
batchVariableTemplates: Array.isArray(source.batchVariableTemplates) ? cloneData(source.batchVariableTemplates) : []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,45 +341,26 @@ export function buildMesRows(data) {
|
|||||||
;(device.stations || []).forEach(station => {
|
;(device.stations || []).forEach(station => {
|
||||||
;(device.variables || []).filter(hasMeaningfulVariableData).forEach(variable => {
|
;(device.variables || []).filter(hasMeaningfulVariableData).forEach(variable => {
|
||||||
const row = applyStationToVariable(variable, device, station)
|
const row = applyStationToVariable(variable, device, station)
|
||||||
rows.push(MES_HEADERS.map(header => row[header]))
|
rows.push(MES_HEADERS.map(header => row[header] == null ? '' : row[header]))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return rows
|
return normalizeRowLength(rows, MES_HEADERS.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeEmptyColumns(rows, cols = []) {
|
function normalizeRowLength(rows, length) {
|
||||||
if (!rows.length) return { rows, cols }
|
return (rows || []).map(row => {
|
||||||
const dataRows = rows.slice(1)
|
const next = Array.from({ length }, (_, index) => row[index] == null ? '' : row[index])
|
||||||
if (!dataRows.length) return { rows, cols }
|
return next
|
||||||
|
})
|
||||||
const keepIndexes = rows[0]
|
|
||||||
.map((header, index) => ({ header, index }))
|
|
||||||
.filter(({ index }) => {
|
|
||||||
return dataRows.some(row => {
|
|
||||||
const value = row[index]
|
|
||||||
return value !== undefined && value !== null && String(value).trim() !== ''
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map(item => item.index)
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows: rows.map(row => keepIndexes.map(index => row[index])),
|
|
||||||
cols: keepIndexes.map(index => cols[index]).filter(width => width !== undefined)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkbookSheets(data) {
|
export function buildWorkbookSheets(data) {
|
||||||
const mesSheet = removeEmptyColumns(
|
|
||||||
buildMesRows(data),
|
|
||||||
[18, 28, 16, 16, 12, 12, 9, 12, 12, 10, 10, 8, 8, 12, 24, 10, 14, 58, 8, 8, 10, 8, 10, 20, 10, 14, 12, 12, 12, 10, 12, 10, 10, 10, 10, 12, 12, 12]
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: 'MES_基本变量原始数据',
|
name: 'MES_基本变量原始数据',
|
||||||
rows: mesSheet.rows,
|
rows: buildMesRows(data),
|
||||||
cols: mesSheet.cols
|
cols: [18, 28, 16, 16, 12, 12, 9, 12, 12, 10, 10, 8, 8, 12, 24, 10, 14, 58, 8, 8, 10, 8, 10, 20, 10, 14, 12, 12, 12, 10, 12, 10, 10, 10, 10, 12, 12, 12]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '设备类型对应表',
|
name: '设备类型对应表',
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { cloneData, MES_HEADERS } from './signalTableBuilder'
|
|||||||
const SOURCE_FIELDS = new Set(['工位号', '数据来源', 'IP', 'DbName'])
|
const SOURCE_FIELDS = new Set(['工位号', '数据来源', 'IP', 'DbName'])
|
||||||
const NON_TEMPLATE_FIELDS = new Set(['TagID', 'TagName', ...SOURCE_FIELDS])
|
const NON_TEMPLATE_FIELDS = new Set(['TagID', 'TagName', ...SOURCE_FIELDS])
|
||||||
|
|
||||||
|
export function normalizeBaseTagId(tagId) {
|
||||||
|
const text = String(tagId || '').trim()
|
||||||
|
const tagIndex = text.indexOf('TAG_')
|
||||||
|
return tagIndex === -1 ? text : text.slice(tagIndex)
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeVariableTemplates(templates, fallbackTemplates = []) {
|
export function normalizeVariableTemplates(templates, fallbackTemplates = []) {
|
||||||
const source = Array.isArray(templates) && templates.length ? templates : fallbackTemplates
|
const source = Array.isArray(templates) && templates.length ? templates : fallbackTemplates
|
||||||
|
|
||||||
@@ -100,3 +106,82 @@ export function sanitizeTemplateForSave(template) {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeBatchTemplateVariables(variables) {
|
||||||
|
return (variables || []).map(variable => {
|
||||||
|
const row = {}
|
||||||
|
MES_HEADERS.forEach(header => {
|
||||||
|
row[header] = Object.prototype.hasOwnProperty.call(variable || {}, header) ? variable[header] : ''
|
||||||
|
})
|
||||||
|
row.TagID = normalizeBaseTagId(row.TagID)
|
||||||
|
row['工位号'] = 'OPNAME'
|
||||||
|
row['数据来源'] = 'OPNAME'
|
||||||
|
row.IP = 'IP'
|
||||||
|
row.DbName = 'DB'
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBatchVariableTemplates(templates) {
|
||||||
|
return (templates || []).map((template, index) => {
|
||||||
|
return {
|
||||||
|
id: template.id || `batch-tpl-${Date.now()}-${index}`,
|
||||||
|
templateName: template.templateName || `批量模板${index + 1}`,
|
||||||
|
description: template.description || '',
|
||||||
|
sourceFileName: template.sourceFileName || '',
|
||||||
|
createdAt: template.createdAt || new Date().toISOString(),
|
||||||
|
variables: normalizeBatchTemplateVariables(template.variables)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBatchVariableTemplate(candidate, templateName, description = '') {
|
||||||
|
return {
|
||||||
|
id: `batch-tpl-${Date.now()}`,
|
||||||
|
templateName: String(templateName || '').trim(),
|
||||||
|
description: String(description || '').trim(),
|
||||||
|
sourceFileName: candidate.sourceFileName || '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
variables: normalizeBatchTemplateVariables(candidate.variables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUniqueTagIds(variables) {
|
||||||
|
const counts = {}
|
||||||
|
;(variables || []).forEach(variable => {
|
||||||
|
const tagId = normalizeBaseTagId(variable.TagID)
|
||||||
|
if (!tagId) return
|
||||||
|
counts[tagId] = (counts[tagId] || 0) + 1
|
||||||
|
})
|
||||||
|
return Object.keys(counts).filter(tagId => counts[tagId] > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBatchTemplateTagIdConflicts(currentVariables, templateVariables) {
|
||||||
|
const existing = new Set((currentVariables || []).map(variable => normalizeBaseTagId(variable.TagID)).filter(Boolean))
|
||||||
|
return Array.from(new Set(
|
||||||
|
(templateVariables || [])
|
||||||
|
.map(variable => normalizeBaseTagId(variable.TagID))
|
||||||
|
.filter(tagId => tagId && existing.has(tagId))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchTemplateNameExists(templates, name, ignoreId = '') {
|
||||||
|
const target = String(name || '').trim().toLowerCase()
|
||||||
|
return (templates || []).some(template => {
|
||||||
|
return template.id !== ignoreId && String(template.templateName || '').trim().toLowerCase() === target
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCopyTemplateName(existingTemplates, baseName) {
|
||||||
|
const rawName = String(baseName || '模板').trim() || '模板'
|
||||||
|
const sourceName = rawName.replace(/COPY\d+$/i, '') || rawName
|
||||||
|
const names = new Set((existingTemplates || []).map(template => String(template.templateName || '').trim().toLowerCase()))
|
||||||
|
let index = 1
|
||||||
|
let nextName = `${sourceName}COPY${index}`
|
||||||
|
|
||||||
|
while (names.has(nextName.toLowerCase())) {
|
||||||
|
index += 1
|
||||||
|
nextName = `${sourceName}COPY${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextName
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<el-button icon="el-icon-upload2" @click="triggerImport">导入配置</el-button>
|
<el-button icon="el-icon-upload2" @click="triggerImport">导入配置</el-button>
|
||||||
<el-button icon="el-icon-document" @click="handleExportConfig">导出配置</el-button>
|
<el-button icon="el-icon-document" @click="handleExportConfig">导出配置</el-button>
|
||||||
<input ref="configFileInput" class="hidden-input" type="file" accept="application/json,.json" @change="handleImportConfig">
|
<input ref="configFileInput" class="hidden-input" type="file" accept="application/json,.json" @change="handleImportConfig">
|
||||||
|
<input ref="batchExcelFileInput" class="hidden-input" type="file" accept=".xlsx,.xls" @change="handleBatchExcelInput">
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -124,14 +125,15 @@
|
|||||||
:visible.sync="deviceDialogVisible"
|
:visible.sync="deviceDialogVisible"
|
||||||
width="1040px"
|
width="1040px"
|
||||||
>
|
>
|
||||||
<button class="dialog-plus" type="button" title="新增完整工位" @click="addStationToDeviceForm()">
|
|
||||||
<i class="el-icon-plus"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<section class="dialog-station-section">
|
<section class="dialog-station-section">
|
||||||
<div class="dialog-station-head">
|
<div class="dialog-station-head">
|
||||||
<span>设备模块配置</span>
|
<div class="dialog-station-title">
|
||||||
<small>点击左上角 + 新增一条完整配置;表格中每一行都会导出为设备类型对应表的一行。</small>
|
<span>设备模块配置</span>
|
||||||
|
<small>点击新增模块配置,表格会新增一行;每一行都会导出为设备类型对应表的一行。</small>
|
||||||
|
</div>
|
||||||
|
<el-button size="mini" type="primary" icon="el-icon-plus" @click="addStationToDeviceForm()">
|
||||||
|
新增模块配置
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table
|
<el-table
|
||||||
ref="deviceStationTable"
|
ref="deviceStationTable"
|
||||||
@@ -214,7 +216,6 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button size="small" :disabled="!variableTemplateSelectId" @click="applySelectedTemplateToVariable">使用模板</el-button>
|
<el-button size="small" :disabled="!variableTemplateSelectId" @click="applySelectedTemplateToVariable">使用模板</el-button>
|
||||||
<el-button size="small" @click="saveVariableFormAsTemplate">保存为模板</el-button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-form ref="variableForm" :model="variableForm" :rules="variableRules" label-width="126px">
|
<el-form ref="variableForm" :model="variableForm" :rules="variableRules" label-width="126px">
|
||||||
@@ -239,49 +240,133 @@
|
|||||||
width="1040px"
|
width="1040px"
|
||||||
class="template-dialog"
|
class="template-dialog"
|
||||||
>
|
>
|
||||||
<div class="template-manager">
|
<el-tabs v-model="templateManagerTab">
|
||||||
<aside class="template-list">
|
<el-tab-pane label="单条模板管理" name="single">
|
||||||
<div class="template-list-head">
|
<div class="template-manager">
|
||||||
<span>模板列表</span>
|
<aside class="template-list">
|
||||||
<el-button size="mini" type="primary" icon="el-icon-plus" @click="createTemplateInManager">新增模板</el-button>
|
<div class="template-list-head">
|
||||||
</div>
|
<span>单条模板列表</span>
|
||||||
<button
|
<el-button size="mini" type="primary" icon="el-icon-plus" @click="createTemplateInManager">新增模板</el-button>
|
||||||
v-for="template in variableTemplates"
|
</div>
|
||||||
:key="template.id"
|
<button
|
||||||
type="button"
|
v-for="template in variableTemplates"
|
||||||
class="template-row"
|
:key="template.id"
|
||||||
:class="{ active: selectedTemplateId === template.id }"
|
type="button"
|
||||||
@click="selectTemplateForEdit(template.id)"
|
class="template-row"
|
||||||
>
|
:class="{ active: selectedTemplateId === template.id }"
|
||||||
<strong>{{ template.templateName }}</strong>
|
@click="selectTemplateForEdit(template.id)"
|
||||||
<small>{{ template.description || '暂无说明' }}</small>
|
>
|
||||||
</button>
|
<strong>{{ template.templateName }}</strong>
|
||||||
<div v-if="!variableTemplates.length" class="template-empty">暂无模板</div>
|
<small>{{ template.description || '暂无说明' }}</small>
|
||||||
</aside>
|
</button>
|
||||||
|
<div v-if="!variableTemplates.length" class="template-empty">暂无模板</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<section class="template-editor">
|
<section class="template-editor">
|
||||||
<el-form label-width="104px">
|
<el-form label-width="104px">
|
||||||
<el-form-item label="模板名称" required>
|
<el-form-item label="模板名称" required>
|
||||||
<el-input v-model="templateForm.templateName" placeholder="请输入模板名称"/>
|
<el-input v-model="templateForm.templateName" placeholder="请输入模板名称"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="模板说明">
|
<el-form-item label="模板说明">
|
||||||
<el-input v-model="templateForm.description" placeholder="请输入模板说明"/>
|
<el-input v-model="templateForm.description" placeholder="请输入模板说明"/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="template-field-title">模板字段</div>
|
<div class="template-field-title">模板字段</div>
|
||||||
<div class="template-field-grid">
|
<div class="template-field-grid">
|
||||||
<label v-for="field in templateEditableFields" :key="field" class="template-field">
|
<label v-for="field in templateEditableFields" :key="field" class="template-field">
|
||||||
<span>{{ field }}</span>
|
<span>{{ field }}</span>
|
||||||
<el-input v-model="templateForm.fields[field]" size="mini"/>
|
<el-input v-model="templateForm.fields[field]" size="mini"/>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</el-tab-pane>
|
||||||
</div>
|
|
||||||
|
<el-tab-pane label="批量模板管理" name="batch">
|
||||||
|
<div class="template-manager batch-template-manager">
|
||||||
|
<aside class="template-list">
|
||||||
|
<div class="template-list-head">
|
||||||
|
<span>批量模板列表</span>
|
||||||
|
<el-button size="mini" type="primary" icon="el-icon-upload2" @click="triggerBatchExcelImport">导入 Excel</el-button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="template in batchVariableTemplates"
|
||||||
|
:key="template.id"
|
||||||
|
type="button"
|
||||||
|
class="template-row"
|
||||||
|
:class="{ active: selectedBatchTemplateId === template.id }"
|
||||||
|
@click="selectedBatchTemplateId = template.id"
|
||||||
|
>
|
||||||
|
<strong>{{ template.templateName }}</strong>
|
||||||
|
<small>{{ template.sourceFileName || '外部 Excel' }} / {{ template.variables.length }} 条</small>
|
||||||
|
</button>
|
||||||
|
<div v-if="!batchVariableTemplates.length" class="template-empty">暂无批量模板</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="template-editor batch-template-preview">
|
||||||
|
<template v-if="selectedBatchTemplate">
|
||||||
|
<div class="batch-template-summary">
|
||||||
|
<strong>{{ selectedBatchTemplate.templateName }}</strong>
|
||||||
|
<span>来源文件:{{ selectedBatchTemplate.sourceFileName || '未知' }}</span>
|
||||||
|
<span>创建时间:{{ formatTime(selectedBatchTemplate.createdAt) }}</span>
|
||||||
|
<span>变量数量:{{ selectedBatchTemplate.variables.length }}</span>
|
||||||
|
<span v-if="selectedBatchTemplate.description">模板说明:{{ selectedBatchTemplate.description }}</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="selectedBatchTemplate.variables" border size="mini" height="420">
|
||||||
|
<el-table-column prop="TagID" label="TagID" min-width="150" show-overflow-tooltip/>
|
||||||
|
<el-table-column prop="TagName" label="TagName" min-width="210" show-overflow-tooltip/>
|
||||||
|
<el-table-column prop="TagTypeID" label="TagTypeID" min-width="90"/>
|
||||||
|
<el-table-column prop="TagLong" label="TagLong" min-width="80"/>
|
||||||
|
<el-table-column prop="变量类型" label="变量类型" min-width="90"/>
|
||||||
|
<el-table-column prop="变量地址" label="变量地址" min-width="90"/>
|
||||||
|
<el-table-column prop="变量其它" label="变量其它" min-width="90"/>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<div v-else class="batch-preview-empty">请选择或导入一个批量模板</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
<span slot="footer">
|
<span slot="footer">
|
||||||
<el-button type="danger" plain :disabled="!selectedTemplateId" @click="deleteTemplateInManager">删除</el-button>
|
<template v-if="templateManagerTab === 'single'">
|
||||||
<el-button @click="templateDialogVisible = false">取消</el-button>
|
<el-button type="danger" plain :disabled="!selectedTemplateId" @click="deleteTemplateInManager">删除</el-button>
|
||||||
<el-button type="primary" @click="saveTemplateInManager">保存模板</el-button>
|
<el-button :disabled="!selectedTemplateId" @click="cloneSelectedTemplate">克隆模板</el-button>
|
||||||
|
<el-button @click="templateDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveTemplateInManager">保存模板</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-button type="danger" plain :disabled="!selectedBatchTemplate" @click="deleteSelectedBatchTemplate">删除模板</el-button>
|
||||||
|
<el-button :disabled="!selectedBatchTemplate" @click="cloneSelectedBatchTemplate">克隆模板</el-button>
|
||||||
|
<el-button @click="templateDialogVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedBatchTemplate" @click="applySelectedBatchTemplate">使用批量模板</el-button>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
title="导入 Excel 为批量模板"
|
||||||
|
:visible.sync="batchImportDialogVisible"
|
||||||
|
width="620px"
|
||||||
|
>
|
||||||
|
<div v-if="batchImportCandidate" class="batch-import-summary">
|
||||||
|
<span>来源文件:{{ batchImportCandidate.sourceFileName }}</span>
|
||||||
|
<span>读取 Sheet:{{ batchImportCandidate.sheetName }}</span>
|
||||||
|
<span>有效变量数:{{ batchImportCandidate.variableCount }}</span>
|
||||||
|
<span>忽略空行:{{ batchImportCandidate.ignoredEmptyRows }}</span>
|
||||||
|
<span>忽略未知列:{{ batchImportCandidate.ignoredUnknownColumns }}</span>
|
||||||
|
</div>
|
||||||
|
<el-form label-width="96px">
|
||||||
|
<el-form-item label="模板名称" required>
|
||||||
|
<el-input v-model="batchImportForm.templateName" placeholder="请输入批量模板名称"/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模板说明">
|
||||||
|
<el-input v-model="batchImportForm.description" placeholder="请输入模板说明"/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<span slot="footer">
|
||||||
|
<el-button @click="batchImportDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveImportedBatchTemplate">保存为批量模板</el-button>
|
||||||
</span>
|
</span>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,6 +375,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import defaultData from '@/data/default-signal-table.json'
|
import defaultData from '@/data/default-signal-table.json'
|
||||||
import defaultVariableTemplates from '@/data/default-variable-templates.json'
|
import defaultVariableTemplates from '@/data/default-variable-templates.json'
|
||||||
|
import { importBatchTemplateFromExcel } from '@/utils/excelTemplateImporter'
|
||||||
import { exportSignalTable } from '@/utils/excelWriter'
|
import { exportSignalTable } from '@/utils/excelWriter'
|
||||||
import {
|
import {
|
||||||
MES_HEADERS,
|
MES_HEADERS,
|
||||||
@@ -306,8 +392,13 @@ import {
|
|||||||
} from '@/utils/signalTableBuilder'
|
} from '@/utils/signalTableBuilder'
|
||||||
import {
|
import {
|
||||||
applyTemplateToVariableForm,
|
applyTemplateToVariableForm,
|
||||||
|
batchTemplateNameExists,
|
||||||
createEmptyTemplate,
|
createEmptyTemplate,
|
||||||
createTemplateFromVariable,
|
createBatchVariableTemplate,
|
||||||
|
findBatchTemplateTagIdConflicts,
|
||||||
|
getCopyTemplateName,
|
||||||
|
normalizeBatchTemplateVariables,
|
||||||
|
normalizeBatchVariableTemplates,
|
||||||
normalizeVariableTemplates,
|
normalizeVariableTemplates,
|
||||||
sanitizeTemplateForSave,
|
sanitizeTemplateForSave,
|
||||||
templateHasFields,
|
templateHasFields,
|
||||||
@@ -320,7 +411,7 @@ import {
|
|||||||
loadConfigFromOpfs,
|
loadConfigFromOpfs,
|
||||||
saveConfigToOpfs
|
saveConfigToOpfs
|
||||||
} from '@/utils/opfsStore'
|
} from '@/utils/opfsStore'
|
||||||
import { isAbortError, openJsonFile } from '@/utils/filePicker'
|
import { isAbortError, openExcelFile, openJsonFile } from '@/utils/filePicker'
|
||||||
|
|
||||||
function createEmptyDevice() {
|
function createEmptyDevice() {
|
||||||
return {
|
return {
|
||||||
@@ -359,6 +450,7 @@ function createTemplateEditorForm(template) {
|
|||||||
function normalizeConfigData(data) {
|
function normalizeConfigData(data) {
|
||||||
const normalized = normalizeData(data)
|
const normalized = normalizeData(data)
|
||||||
normalized.variableTemplates = normalizeVariableTemplates(normalized.variableTemplates, defaultVariableTemplates)
|
normalized.variableTemplates = normalizeVariableTemplates(normalized.variableTemplates, defaultVariableTemplates)
|
||||||
|
normalized.batchVariableTemplates = normalizeBatchVariableTemplates(normalized.batchVariableTemplates)
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,9 +483,17 @@ export default {
|
|||||||
variableColumns: MES_HEADERS,
|
variableColumns: MES_HEADERS,
|
||||||
variableGroups: VARIABLE_FIELD_GROUPS,
|
variableGroups: VARIABLE_FIELD_GROUPS,
|
||||||
templateDialogVisible: false,
|
templateDialogVisible: false,
|
||||||
|
templateManagerTab: 'single',
|
||||||
selectedTemplateId: normalized.variableTemplates[0] ? normalized.variableTemplates[0].id : '',
|
selectedTemplateId: normalized.variableTemplates[0] ? normalized.variableTemplates[0].id : '',
|
||||||
templateForm: createTemplateEditorForm(normalized.variableTemplates[0]),
|
templateForm: createTemplateEditorForm(normalized.variableTemplates[0]),
|
||||||
templateEditableFields: TEMPLATE_EDITABLE_FIELDS,
|
templateEditableFields: TEMPLATE_EDITABLE_FIELDS,
|
||||||
|
selectedBatchTemplateId: normalized.batchVariableTemplates[0] ? normalized.batchVariableTemplates[0].id : '',
|
||||||
|
batchImportDialogVisible: false,
|
||||||
|
batchImportCandidate: null,
|
||||||
|
batchImportForm: {
|
||||||
|
templateName: '',
|
||||||
|
description: ''
|
||||||
|
},
|
||||||
variableRules: {
|
variableRules: {
|
||||||
TagID: [{ required: true, message: '请输入TagID', trigger: 'blur' }],
|
TagID: [{ required: true, message: '请输入TagID', trigger: 'blur' }],
|
||||||
TagName: [{ required: true, message: '请输入TagName', trigger: 'blur' }],
|
TagName: [{ required: true, message: '请输入TagName', trigger: 'blur' }],
|
||||||
@@ -424,6 +524,12 @@ export default {
|
|||||||
variableTemplates() {
|
variableTemplates() {
|
||||||
return Array.isArray(this.tableData.variableTemplates) ? this.tableData.variableTemplates : []
|
return Array.isArray(this.tableData.variableTemplates) ? this.tableData.variableTemplates : []
|
||||||
},
|
},
|
||||||
|
batchVariableTemplates() {
|
||||||
|
return Array.isArray(this.tableData.batchVariableTemplates) ? this.tableData.batchVariableTemplates : []
|
||||||
|
},
|
||||||
|
selectedBatchTemplate() {
|
||||||
|
return this.batchVariableTemplates.find(template => template.id === this.selectedBatchTemplateId) || null
|
||||||
|
},
|
||||||
filteredDevices() {
|
filteredDevices() {
|
||||||
const key = this.keyword.trim().toLowerCase()
|
const key = this.keyword.trim().toLowerCase()
|
||||||
if (!key) return this.devices
|
if (!key) return this.devices
|
||||||
@@ -692,30 +798,6 @@ export default {
|
|||||||
})
|
})
|
||||||
this.$message.success('已使用模板填充空字段')
|
this.$message.success('已使用模板填充空字段')
|
||||||
},
|
},
|
||||||
saveVariableFormAsTemplate() {
|
|
||||||
this.$prompt('请输入模板名称', '保存为模板', {
|
|
||||||
confirmButtonText: '保存',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
inputPattern: /.+/,
|
|
||||||
inputErrorMessage: '模板名称不能为空'
|
|
||||||
}).then(async ({ value }) => {
|
|
||||||
const name = String(value || '').trim()
|
|
||||||
if (templateNameExists(this.variableTemplates, name)) {
|
|
||||||
this.$message.warning('模板名称已存在')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = createTemplateFromVariable(this.variableForm, name)
|
|
||||||
if (!templateHasFields(template)) {
|
|
||||||
this.$message.warning('当前变量没有可保存的模板字段')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tableData.variableTemplates.push(template)
|
|
||||||
this.variableTemplateSelectId = template.id
|
|
||||||
await this.persistConfigSafely(true)
|
|
||||||
}).catch(() => {})
|
|
||||||
},
|
|
||||||
openTemplateManager() {
|
openTemplateManager() {
|
||||||
if (!this.variableTemplates.length) {
|
if (!this.variableTemplates.length) {
|
||||||
this.selectedTemplateId = ''
|
this.selectedTemplateId = ''
|
||||||
@@ -726,6 +808,9 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.templateForm = createTemplateEditorForm(this.getTemplateById(this.selectedTemplateId))
|
this.templateForm = createTemplateEditorForm(this.getTemplateById(this.selectedTemplateId))
|
||||||
}
|
}
|
||||||
|
if (this.batchVariableTemplates.length && !this.selectedBatchTemplate) {
|
||||||
|
this.selectedBatchTemplateId = this.batchVariableTemplates[0].id
|
||||||
|
}
|
||||||
this.templateDialogVisible = true
|
this.templateDialogVisible = true
|
||||||
},
|
},
|
||||||
selectTemplateForEdit(id) {
|
selectTemplateForEdit(id) {
|
||||||
@@ -780,6 +865,166 @@ export default {
|
|||||||
this.persistConfigSafely(true)
|
this.persistConfigSafely(true)
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
},
|
},
|
||||||
|
async cloneSelectedTemplate() {
|
||||||
|
const template = this.getTemplateById(this.selectedTemplateId)
|
||||||
|
if (!template) return
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
id: `tpl-${Date.now()}`,
|
||||||
|
templateName: getCopyTemplateName(this.variableTemplates, template.templateName),
|
||||||
|
description: template.description || '',
|
||||||
|
fields: cloneData(template.fields || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tableData.variableTemplates.push(copy)
|
||||||
|
this.selectedTemplateId = copy.id
|
||||||
|
this.templateForm = createTemplateEditorForm(copy)
|
||||||
|
await this.persistConfigSafely(true)
|
||||||
|
},
|
||||||
|
async triggerBatchExcelImport() {
|
||||||
|
try {
|
||||||
|
const file = await openExcelFile()
|
||||||
|
if (file) {
|
||||||
|
await this.importBatchExcelFile(file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) return
|
||||||
|
this.$alert(error && error.message ? error.message : String(error), '打开 Excel 失败', {
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$refs.batchExcelFileInput.click()
|
||||||
|
},
|
||||||
|
async handleBatchExcelInput(event) {
|
||||||
|
const file = event.target.files && event.target.files[0]
|
||||||
|
event.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
await this.importBatchExcelFile(file)
|
||||||
|
},
|
||||||
|
async importBatchExcelFile(file) {
|
||||||
|
try {
|
||||||
|
const candidate = await importBatchTemplateFromExcel(file)
|
||||||
|
this.batchImportCandidate = candidate
|
||||||
|
this.batchImportForm = {
|
||||||
|
templateName: String(file.name || '批量模板').replace(/\.(xlsx|xls)$/i, ''),
|
||||||
|
description: '从外部 Excel 导入'
|
||||||
|
}
|
||||||
|
this.batchImportDialogVisible = true
|
||||||
|
} catch (error) {
|
||||||
|
this.$alert(error && error.message ? error.message : String(error), '导入 Excel 失败', {
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveImportedBatchTemplate() {
|
||||||
|
if (!this.batchImportCandidate) return
|
||||||
|
const name = String(this.batchImportForm.templateName || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
this.$message.warning('批量模板名称不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (batchTemplateNameExists(this.batchVariableTemplates, name)) {
|
||||||
|
this.$message.warning('批量模板名称不能重复')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = normalizeBatchTemplateVariables(this.batchImportCandidate.variables)
|
||||||
|
const duplicateTagIds = this.validateBatchTemplateUniqueTagIds(variables)
|
||||||
|
if (duplicateTagIds.length) {
|
||||||
|
this.$alert(`批量模板中存在重复基础 TagID:${duplicateTagIds.join('、')}`, '保存失败', {
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = createBatchVariableTemplate({
|
||||||
|
...this.batchImportCandidate,
|
||||||
|
variables
|
||||||
|
}, name, this.batchImportForm.description)
|
||||||
|
|
||||||
|
this.tableData.batchVariableTemplates.push(template)
|
||||||
|
this.selectedBatchTemplateId = template.id
|
||||||
|
this.batchImportDialogVisible = false
|
||||||
|
this.batchImportCandidate = null
|
||||||
|
this.templateManagerTab = 'batch'
|
||||||
|
await this.persistConfigSafely(true)
|
||||||
|
},
|
||||||
|
validateBatchTemplateUniqueTagIds(variables) {
|
||||||
|
const counts = {}
|
||||||
|
;(variables || []).forEach(variable => {
|
||||||
|
const tagId = String(variable.TagID || '').trim()
|
||||||
|
if (!tagId) return
|
||||||
|
counts[tagId] = (counts[tagId] || 0) + 1
|
||||||
|
})
|
||||||
|
return Object.keys(counts).filter(tagId => counts[tagId] > 1)
|
||||||
|
},
|
||||||
|
deleteSelectedBatchTemplate() {
|
||||||
|
if (!this.selectedBatchTemplate) return
|
||||||
|
this.$confirm(`确认删除批量模板「${this.selectedBatchTemplate.templateName}」?`, '提示', {
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
const index = this.batchVariableTemplates.findIndex(template => template.id === this.selectedBatchTemplateId)
|
||||||
|
if (index !== -1) this.tableData.batchVariableTemplates.splice(index, 1)
|
||||||
|
const next = this.batchVariableTemplates[0]
|
||||||
|
this.selectedBatchTemplateId = next ? next.id : ''
|
||||||
|
this.persistConfigSafely(true)
|
||||||
|
}).catch(() => {})
|
||||||
|
},
|
||||||
|
async cloneSelectedBatchTemplate() {
|
||||||
|
if (!this.selectedBatchTemplate) return
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
id: `batch-tpl-${Date.now()}`,
|
||||||
|
templateName: getCopyTemplateName(this.batchVariableTemplates, this.selectedBatchTemplate.templateName),
|
||||||
|
description: this.selectedBatchTemplate.description || '',
|
||||||
|
sourceFileName: this.selectedBatchTemplate.sourceFileName || '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
variables: normalizeBatchTemplateVariables(cloneData(this.selectedBatchTemplate.variables || []))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tableData.batchVariableTemplates.push(copy)
|
||||||
|
this.selectedBatchTemplateId = copy.id
|
||||||
|
await this.persistConfigSafely(true)
|
||||||
|
},
|
||||||
|
async applySelectedBatchTemplate() {
|
||||||
|
if (!this.selectedDevice) {
|
||||||
|
this.$message.warning('请先选择设备模块,再使用批量模板。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.selectedBatchTemplate) return
|
||||||
|
|
||||||
|
const deviceIndex = this.devices.findIndex(device => device.id === this.selectedDeviceId)
|
||||||
|
if (deviceIndex === -1) return
|
||||||
|
const variables = this.devices[deviceIndex].variables || []
|
||||||
|
const templateVariables = normalizeBatchTemplateVariables(this.selectedBatchTemplate.variables)
|
||||||
|
const duplicateTagIds = this.validateBatchTemplateUniqueTagIds(templateVariables)
|
||||||
|
if (duplicateTagIds.length) {
|
||||||
|
this.$alert(`批量模板中存在重复基础 TagID:${duplicateTagIds.join('、')}`, '无法使用批量模板', {
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = findBatchTemplateTagIdConflicts(variables, templateVariables)
|
||||||
|
const startIndex = conflicts.length ? 0 : variables.length
|
||||||
|
const importedVariables = templateVariables.map((variable, index) => ({
|
||||||
|
...variable,
|
||||||
|
'变量排序': String(startIndex + index)
|
||||||
|
}))
|
||||||
|
const nextVariables = conflicts.length ? importedVariables : variables.concat(importedVariables)
|
||||||
|
|
||||||
|
this.$set(this.tableData.devices[deviceIndex], 'variables', nextVariables)
|
||||||
|
this.selectedVariableIndex = startIndex
|
||||||
|
this.selectedDisplayVariable = null
|
||||||
|
this.$message.success(conflicts.length
|
||||||
|
? `检测到 TagID 冲突,已清空当前设备模块原变量并导入 ${templateVariables.length} 条变量`
|
||||||
|
: `已追加 ${templateVariables.length} 条变量`)
|
||||||
|
await this.persistConfigSafely(true)
|
||||||
|
},
|
||||||
applyChangedVariableFields(baseVariable, originalForm, currentForm) {
|
applyChangedVariableFields(baseVariable, originalForm, currentForm) {
|
||||||
const next = this.stripSharedVariablePreviewFields(baseVariable)
|
const next = this.stripSharedVariablePreviewFields(baseVariable)
|
||||||
|
|
||||||
@@ -886,7 +1131,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const exportData = buildSingleDeviceExportData(this.tableData, this.selectedDevice.id)
|
const exportData = buildSingleDeviceExportData(this.tableData, this.selectedDevice.id)
|
||||||
const result = await exportSignalTable(exportData)
|
const result = await exportSignalTable(exportData)
|
||||||
this.$message.success(result.method === 'picker' ? '已保存 SignalTable_CZ.xlsx' : '已下载 SignalTable_CZ.xlsx')
|
this.$message.success(result.method === 'picker' ? '已保存 SignalTable.xlsx' : '已下载 SignalTable.xlsx')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) return
|
if (isAbortError(error)) return
|
||||||
this.$alert(error && error.message ? error.message : String(error), '导出失败', {
|
this.$alert(error && error.message ? error.message : String(error), '导出失败', {
|
||||||
@@ -1295,6 +1540,37 @@ export default {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-template-manager {
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-template-preview {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-template-summary,
|
||||||
|
.batch-import-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #526273;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-template-summary strong {
|
||||||
|
color: #1f2d3d;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-empty {
|
||||||
|
height: 420px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #8b98a8;
|
||||||
|
}
|
||||||
|
|
||||||
.template-field-title {
|
.template-field-title {
|
||||||
margin: 8px 0 10px;
|
margin: 8px 0 10px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -1334,37 +1610,26 @@ export default {
|
|||||||
column-gap: 12px;
|
column-gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-plus {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
margin: -6px 0 12px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #f04b52;
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-plus:hover {
|
|
||||||
color: #d9363e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-station-section {
|
.dialog-station-section {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
border-top: 1px solid #e3e8ef;
|
|
||||||
padding-top: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-station-head {
|
.dialog-station-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
color: #1f2d3d;
|
color: #1f2d3d;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-station-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-station-head small {
|
.dialog-station-head small {
|
||||||
color: #8b98a8;
|
color: #8b98a8;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|||||||
Reference in New Issue
Block a user