This commit is contained in:
@@ -39,6 +39,8 @@ func main() {
|
|||||||
must(e)
|
must(e)
|
||||||
_, e = db.Exec(ctx, migrations.PostgreSQLGroups)
|
_, e = db.Exec(ctx, migrations.PostgreSQLGroups)
|
||||||
must(e)
|
must(e)
|
||||||
|
_, e = db.Exec(ctx, migrations.PostgreSQLWritePoints)
|
||||||
|
must(e)
|
||||||
db.Close()
|
db.Close()
|
||||||
pgStore, e := pg.Open(ctx, cfg.PostgresDSN)
|
pgStore, e := pg.Open(ctx, cfg.PostgresDSN)
|
||||||
must(e)
|
must(e)
|
||||||
|
|||||||
@@ -77,3 +77,84 @@
|
|||||||
- 采集列表将“地址 / 类型”拆成“地址”和“类型”;新建/编辑表单移除有效最小值和有效最大值。
|
- 采集列表将“地址 / 类型”拆成“地址”和“类型”;新建/编辑表单移除有效最小值和有效最大值。
|
||||||
- S7 REAL 解析统一四舍五入到小数点后最多 3 位;采集质量不再执行有效范围判断。
|
- S7 REAL 解析统一四舍五入到小数点后最多 3 位;采集质量不再执行有效范围判断。
|
||||||
- 真实数据库验证:分组新增、改名、删除级联均通过;删除后采集点查询总数为 0;现场 REAL 值示例 `1411.719`。
|
- 真实数据库验证:分组新增、改名、删除级联均通过;删除后采集点查询总数为 0;现场 REAL 值示例 `1411.719`。
|
||||||
|
|
||||||
|
## 2026-07-12|设备与采集点弹窗复选框优化
|
||||||
|
|
||||||
|
- 设备新增弹窗的“启用设备”与采集点新增弹窗的“启用采集”“存储历史”统一为整行可点击的文字卡片控件。
|
||||||
|
- 复选框采用隐藏原生控件 + 自定义勾选方块,提供未选中、荧光绿色选中和键盘聚焦态;保留原有 `v-model` 数据绑定,不改变接口语义。
|
||||||
|
- 收紧通用表单输入选择器,避免复选框继承文本输入框的 `width: 100%` 导致位置异常。
|
||||||
|
- `npm run build`:通过(Vite 仍提示 ECharts 主包超过 500 kB 的既有拆包建议)。
|
||||||
|
- Playwright Chromium 回归:设备弹窗 1 个复选框、采集点弹窗 2 个复选框均正常渲染;设备默认勾选后点击可取消,采集点“存储历史”点击后可取消;两个弹窗无控制台错误。
|
||||||
|
- 回归截图:`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-device-checkbox-final.png`、`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-collection-checkbox-final.png`。
|
||||||
|
|
||||||
|
## 2026-07-12|数据写入分组与回读展示调整
|
||||||
|
|
||||||
|
- 数据写入页新增与数据采集一致的左侧分组栏,分组名称、添加、改名和删除均复用 `collection_groups` 接口;后端改为同步维护采集点和写入点的分组归属,删除分组时两类点位一起逻辑删除。
|
||||||
|
- 写入点列表将“地址 / 类型”拆为独立列,移除回读容差列,改为展示该写入点最近一次写入日志中的 `readback_value`;无回读记录时显示“—”。
|
||||||
|
- 写入点数据库迁移 `000003_write_points_simplify.up.sql` 合并 `enabled` 与 `write_enabled` 为单一 `write_enabled`,移除 `readback_tolerance`;迁移具备重启幂等性,旧数据先保留原有效权限再删除冗余列。
|
||||||
|
- 写入点新增/编辑表单移除回读容差和启用字段,仅保留统一的“允许写入”自定义复选框;CSV 导入/导出同步移除冗余字段。
|
||||||
|
- REAL 回读验证保留固定协议精度 `0.0001`,不再作为点位配置或 API 字段暴露。
|
||||||
|
- 真实环境回归:`GET /api/v1/write-points` 返回不含 `enabled`、`readback_tolerance`,分组接口同时返回 `collection_count` 与 `write_count`;写入点 CSV 表头已更新为 `write_enabled`。
|
||||||
|
- Playwright Chromium 1600×1000:写入页列表、共享分组筛选和新增弹窗均正常;弹窗无“启用/回读容差”,允许写入卡片可切换;控制台无 warning/error。未执行现场 PLC 写入,避免改变控制状态。
|
||||||
|
- 回归截图:`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-write-point-table-final.png`、`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-write-point-final.png`。
|
||||||
|
|
||||||
|
## 2026-07-12|三类数据页面刷新与采集点编辑修复
|
||||||
|
|
||||||
|
- 修复采集点编辑无法更换所属设备的问题:移除 PostgreSQL 更新语句中对 `history_started_at`、旧设备和旧数据类型的限制,编辑请求现在可以正常提交新的 `device_id`;前端保存失败时显示明确错误提示。
|
||||||
|
- 数据采集列表在质量列后新增“更新时间”,取点位 `updated_at` 并按中文本地时间格式展示。
|
||||||
|
- 数据采集、设备管理、数据写入均新增“刷新数据”按钮,刷新按钮执行期间禁用并显示旋转图标。
|
||||||
|
- 数据采集刷新同时重新加载采集点分组;采集点新增/编辑/删除及 CSV 导入后刷新列表和分组;设备新增/编辑/删除及 CSV 导入后刷新列表;写入点新增/编辑/删除、执行写入及 CSV 导入后刷新列表和共享分组。
|
||||||
|
- Playwright Chromium 1600×1000 回归:三个页面刷新按钮各 1 个;采集列表包含“更新时间”;编辑弹窗包含设备选择器;三个页面刷新交互无控制台 warning/error。现场 PLC 写入未执行。
|
||||||
|
- 回归截图:`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-collection-refresh-final.png`。
|
||||||
|
- 更正“更新时间”字段:列表现在展示实时采集数据 `latest_value.ts`,不再展示配置的 `updated_at`。
|
||||||
|
|
||||||
|
## 2026-07-12|分组删除改为点位迁移
|
||||||
|
|
||||||
|
- 删除非 `default` 分组时,后端事务先将该分组下未逻辑删除的采集点和写入点统一迁移到 `default`,再删除分组记录;不再逻辑删除任何点位。
|
||||||
|
- 事务内确保 `default` 分组存在,`default` 分组仍禁止删除。
|
||||||
|
- 数据采集和数据写入页面的删除确认提示同步改为“自动转移到 default 分组”。
|
||||||
|
- 开发验证:使用临时写入点执行“建组→建点→删组”回归,点位保留且 `group_name` 已迁移为 `default`,随后清理临时点;Go 测试和静态检查、前端构建均通过,未执行现场 PLC 写入。
|
||||||
|
# 2026-07-12 · 历史数据增强与归档清理
|
||||||
|
|
||||||
|
## 需求分析
|
||||||
|
|
||||||
|
- 历史点位树支持分组折叠/展开、分组全选/取消;点位、时间范围和表格间隔变化后自动查询,保留最多 20 个点位限制。
|
||||||
|
- 曲线横轴必须与系统时间一致并按选择范围固定起止点;无数据序列不显示;bad 质量值使用虚线;图例状态保留并控制对应曲线;游标采用非吸附、带动画的轴指示器。
|
||||||
|
- 曲线高度提升,游标明细最多显示约 5 行并在点位较多时纵向滚动;表格点位列较多时横向滚动。
|
||||||
|
- 已逻辑删除点位的历史子表提供显式清理入口,禁用但仍可恢复的点位不纳入清理。
|
||||||
|
|
||||||
|
## 技术方案与变更
|
||||||
|
|
||||||
|
- PostgreSQL 新增只读查询已删除采集点 UUID 的方法;TDengine 新增按 UUID 派生子表的安全删除方法,API 增加 `POST /api/v1/history/archive/cleanup`,前端增加清理确认和结果反馈。
|
||||||
|
- TDengine REST 驱动返回的本地墙上时间改为显式重建为 `Asia/Shanghai`,避免重复施加时区偏移;表格时间列统一输出上海时区。
|
||||||
|
- 历史页面增加自动查询防抖和请求序列保护,曲线传入选定时间范围并动态设置时间刻度;bad 数据拆分为虚线序列,且共享逻辑图例名称。
|
||||||
|
|
||||||
|
## 验证记录
|
||||||
|
|
||||||
|
- `go test ./...`:通过。
|
||||||
|
- `npm run build`:通过;仅保留既有 ECharts 体积提示。
|
||||||
|
- `GET /api/v1/health`:真实 PostgreSQL/TDengine 配置下返回 `code=0`。
|
||||||
|
- Playwright Chromium 1600×1000:验证分组全选、点位变更自动曲线查询、曲线/表格切换自动查询、间隔变化自动表格查询;曲线高度 520px;控制台无错误。截图:`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-history-curve-final.png`、`C:\Users\Administrator\AppData\Local\Temp\aquacontrol-history-final.png`。
|
||||||
|
- 归档清理接口真实回归时识别并清理了 13 个已逻辑删除点位的历史子表;活跃点位和仅禁用点位未清理。清理为不可逆操作,前端已增加二次确认。
|
||||||
|
|
||||||
|
## 2026-07-12 · 自定义时间与任意时刻游标
|
||||||
|
|
||||||
|
- 修复固定快捷范围切换后编辑自定义时间可能被旧范围覆盖的问题:时间输入在 `input` 和 `change` 阶段均同步,先保存被编辑的端点,再校验起止时间,允许用户分两步完成暂时不完整的自定义范围。
|
||||||
|
- 游标改为基于鼠标在时间轴上的实际像素位置换算时间,不再使用最近存储点作为游标时间;对每条曲线按前后有效样本进行线性插值,游标表格中的所有曲线使用同一时间戳,边界使用最近有效值,质量戳为 bad 或跨越 bad 样本时保留 bad 标识。
|
||||||
|
- 插值结果在游标表格中使用 `≈` 标记,ECharts 轴指示器继续关闭吸附并保留平滑动画;图例隐藏的曲线不参与游标结果。
|
||||||
|
|
||||||
|
## 2026-07-12 · 本轮验证
|
||||||
|
|
||||||
|
- Playwright 验证固定范围后编辑起止时间保持 `custom` 状态;鼠标移动到非存储时刻时,两条曲线游标显示相同时间 `2026/7/12 23:08:03`,无控制台错误。
|
||||||
|
|
||||||
|
## 2026-07-13 · 游标视觉跟随与离开区域保持
|
||||||
|
|
||||||
|
- 移除 ECharts 默认会吸附存储点的轴指示线,改用图表内独立的垂直游标线;鼠标像素位置先换算为时间,再用 `requestAnimationFrame` 更新线位置,避免在 ECharts 事件处理期间调用 `setOption`。
|
||||||
|
- 游标线仅覆盖绘图区,和插值明细使用同一时间;鼠标离开曲线区域不再清空最后一次游标时间和明细数据。
|
||||||
|
- 回归验证连续移动得到 `23:41:48`、`23:44:25`、`23:47:00`、`23:49:38` 四个不同时间点;离开图表后仍保留 2 条曲线明细,控制台无错误。
|
||||||
|
|
||||||
|
## 2026-07-13 · 设备状态与采集成功状态同步
|
||||||
|
|
||||||
|
- 根因:采集读失败后 `Manager` 将设备状态置为 `disconnected`;缓存连接后续读取成功时此前只更新点位最新数据,没有恢复设备状态,因此设备管理页持续显示离线。
|
||||||
|
- 修复:采集成功后调用 `Manager.MarkConnected` 恢复在线状态;`MarkConnected` 与 `MarkDisconnected` 仅在状态发生变化时更新时间戳,避免每个采集周期反复刷新最近在线/离线时间。
|
||||||
|
- 验证:真实环境中 PLC1、PLC2、PLC3 均显示 `connected`,40 个采集点的 `latest_value.ts` 持续更新;新增设备状态恢复及时间戳稳定性单元测试。
|
||||||
|
|||||||
+38
-40
@@ -57,6 +57,7 @@ func NewRouter(h *Handler) *gin.Engine {
|
|||||||
v.POST("/write-points/:id/write", h.writePoint)
|
v.POST("/write-points/:id/write", h.writePoint)
|
||||||
v.GET("/write-logs", h.logs)
|
v.GET("/write-logs", h.logs)
|
||||||
v.GET("/history/tree", h.tree)
|
v.GET("/history/tree", h.tree)
|
||||||
|
v.POST("/history/archive/cleanup", h.cleanupHistoryArchive)
|
||||||
v.POST("/history/query", h.historyQuery)
|
v.POST("/history/query", h.historyQuery)
|
||||||
v.POST("/history/query-table", h.historyTable)
|
v.POST("/history/query-table", h.historyTable)
|
||||||
v.POST("/history/export", h.exportHistory)
|
v.POST("/history/export", h.exportHistory)
|
||||||
@@ -182,8 +183,10 @@ func (h *Handler) points(kind string) gin.HandlerFunc {
|
|||||||
if v := c.Query("data_type"); v != "" && p.DataType != v {
|
if v := c.Query("data_type"); v != "" && p.DataType != v {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if v := c.Query("enabled"); v != "" && strconv.FormatBool(p.Enabled) != v {
|
if kind == "collection" {
|
||||||
continue
|
if v := c.Query("enabled"); v != "" && strconv.FormatBool(p.Enabled) != v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if kind == "write" {
|
if kind == "write" {
|
||||||
if v := c.Query("write_enabled"); v != "" && strconv.FormatBool(p.WriteEnabled) != v {
|
if v := c.Query("write_enabled"); v != "" && strconv.FormatBool(p.WriteEnabled) != v {
|
||||||
@@ -332,9 +335,9 @@ func (h *Handler) exportConfig(kind string) gin.HandlerFunc {
|
|||||||
w.Write([]string{p.Name, p.GroupName, p.DeviceName, p.Address, p.DataType, stringValue(p.Unit), strconv.Itoa(p.CollectInterval), strings.ToUpper(strconv.FormatBool(p.StoreHistory)), strconv.Itoa(p.HistoryInterval), strings.ToUpper(strconv.FormatBool(p.Enabled))})
|
w.Write([]string{p.Name, p.GroupName, p.DeviceName, p.Address, p.DataType, stringValue(p.Unit), strconv.Itoa(p.CollectInterval), strings.ToUpper(strconv.FormatBool(p.StoreHistory)), strconv.Itoa(p.HistoryInterval), strings.ToUpper(strconv.FormatBool(p.Enabled))})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
w.Write([]string{"name", "group_name", "device_name", "address", "data_type", "unit", "enabled", "write_enabled", "readback_tolerance"})
|
w.Write([]string{"name", "group_name", "device_name", "address", "data_type", "unit", "write_enabled"})
|
||||||
for _, p := range items {
|
for _, p := range items {
|
||||||
w.Write([]string{p.Name, p.GroupName, p.DeviceName, p.Address, p.DataType, stringValue(p.Unit), strings.ToUpper(strconv.FormatBool(p.Enabled)), strings.ToUpper(strconv.FormatBool(p.WriteEnabled)), strconv.FormatFloat(p.ReadbackTolerance, 'g', -1, 64)})
|
w.Write([]string{p.Name, p.GroupName, p.DeviceName, p.Address, p.DataType, stringValue(p.Unit), strings.ToUpper(strconv.FormatBool(p.WriteEnabled))})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,12 +348,6 @@ func stringValue(v *string) string {
|
|||||||
}
|
}
|
||||||
return *v
|
return *v
|
||||||
}
|
}
|
||||||
func floatPtr(v *float64) string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strconv.FormatFloat(*v, 'g', -1, 64)
|
|
||||||
}
|
|
||||||
func (h *Handler) importConfig(kind string) gin.HandlerFunc {
|
func (h *Handler) importConfig(kind string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
file, e := c.FormFile("file")
|
file, e := c.FormFile("file")
|
||||||
@@ -419,9 +416,13 @@ func (h *Handler) importRow(ctx context.Context, kind string, head map[string]in
|
|||||||
}
|
}
|
||||||
existing, e := h.Platform.FindPointByName(ctx, kind, get("name"))
|
existing, e := h.Platform.FindPointByName(ctx, kind, get("name"))
|
||||||
created := platform.IsNotFound(e)
|
created := platform.IsNotFound(e)
|
||||||
enabled, be := parseCSVBool(get("enabled"), true)
|
enabled := true
|
||||||
if be != nil {
|
if kind == "collection" {
|
||||||
return false, be
|
var be error
|
||||||
|
enabled, be = parseCSVBool(get("enabled"), true)
|
||||||
|
if be != nil {
|
||||||
|
return false, be
|
||||||
|
}
|
||||||
}
|
}
|
||||||
unit := get("unit")
|
unit := get("unit")
|
||||||
p := postgres.PointRow{ID: uuid.Nil, Name: get("name"), GroupName: get("group_name"), DeviceID: device.ID, Enabled: enabled, Address: get("address"), DataType: get("data_type"), Unit: &unit}
|
p := postgres.PointRow{ID: uuid.Nil, Name: get("name"), GroupName: get("group_name"), DeviceID: device.ID, Enabled: enabled, Address: get("address"), DataType: get("data_type"), Unit: &unit}
|
||||||
@@ -434,7 +435,6 @@ func (h *Handler) importRow(ctx context.Context, kind string, head map[string]in
|
|||||||
p.HistoryInterval = parseInt(get("history_interval"), 1)
|
p.HistoryInterval = parseInt(get("history_interval"), 1)
|
||||||
} else {
|
} else {
|
||||||
p.WriteEnabled, _ = parseCSVBool(get("write_enabled"), false)
|
p.WriteEnabled, _ = parseCSVBool(get("write_enabled"), false)
|
||||||
p.ReadbackTolerance = parseFloat(get("readback_tolerance"), .0001)
|
|
||||||
}
|
}
|
||||||
return created, h.Platform.SavePoint(ctx, kind, &p)
|
return created, h.Platform.SavePoint(ctx, kind, &p)
|
||||||
}
|
}
|
||||||
@@ -448,16 +448,6 @@ func parseInt(v string, d int) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
func parseFloat(v string, d float64) float64 {
|
|
||||||
if v == "" {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
n, e := strconv.ParseFloat(v, 64)
|
|
||||||
if e != nil {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
func parseCSVBool(v string, d bool) (bool, error) {
|
func parseCSVBool(v string, d bool) (bool, error) {
|
||||||
if v == "" {
|
if v == "" {
|
||||||
return d, nil
|
return d, nil
|
||||||
@@ -467,19 +457,18 @@ func parseCSVBool(v string, d bool) (bool, error) {
|
|||||||
func (h *Handler) savePoint(kind string, update bool) gin.HandlerFunc {
|
func (h *Handler) savePoint(kind string, update bool) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var p struct {
|
var p struct {
|
||||||
ID uuid.UUID `json:"-"`
|
ID uuid.UUID `json:"-"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
GroupName string `json:"group_name"`
|
GroupName string `json:"group_name"`
|
||||||
DeviceID uuid.UUID `json:"device_id"`
|
DeviceID uuid.UUID `json:"device_id"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
WriteEnabled bool `json:"write_enabled"`
|
WriteEnabled bool `json:"write_enabled"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
DataType string `json:"data_type"`
|
DataType string `json:"data_type"`
|
||||||
Unit *string `json:"unit"`
|
Unit *string `json:"unit"`
|
||||||
CollectInterval int `json:"collect_interval"`
|
CollectInterval int `json:"collect_interval"`
|
||||||
StoreHistory *bool `json:"store_history"`
|
StoreHistory *bool `json:"store_history"`
|
||||||
HistoryInterval int `json:"history_interval"`
|
HistoryInterval int `json:"history_interval"`
|
||||||
ReadbackTolerance float64 `json:"readback_tolerance"`
|
|
||||||
}
|
}
|
||||||
if e := c.ShouldBindJSON(&p); e != nil {
|
if e := c.ShouldBindJSON(&p); e != nil {
|
||||||
response.Error(c, 400, 41001, "参数格式错误", nil)
|
response.Error(c, 400, 41001, "参数格式错误", nil)
|
||||||
@@ -508,10 +497,10 @@ func (h *Handler) savePoint(kind string, update bool) gin.HandlerFunc {
|
|||||||
if p.HistoryInterval == 0 {
|
if p.HistoryInterval == 0 {
|
||||||
p.HistoryInterval = 1
|
p.HistoryInterval = 1
|
||||||
}
|
}
|
||||||
if p.ReadbackTolerance == 0 {
|
if kind == "write" {
|
||||||
p.ReadbackTolerance = .0001
|
enabled = false
|
||||||
}
|
}
|
||||||
row := postgres.PointRow{ID: id, Name: p.Name, GroupName: p.GroupName, DeviceID: p.DeviceID, Enabled: enabled, WriteEnabled: p.WriteEnabled, Address: p.Address, DataType: p.DataType, Unit: p.Unit, CollectInterval: p.CollectInterval, StoreHistory: storeHistory, HistoryInterval: p.HistoryInterval, ReadbackTolerance: p.ReadbackTolerance}
|
row := postgres.PointRow{ID: id, Name: p.Name, GroupName: p.GroupName, DeviceID: p.DeviceID, Enabled: enabled, WriteEnabled: p.WriteEnabled, Address: p.Address, DataType: p.DataType, Unit: p.Unit, CollectInterval: p.CollectInterval, StoreHistory: storeHistory, HistoryInterval: p.HistoryInterval, PointKind: kind}
|
||||||
if e := h.Platform.SavePoint(c, kind, &row); e != nil {
|
if e := h.Platform.SavePoint(c, kind, &row); e != nil {
|
||||||
code := 41001
|
code := 41001
|
||||||
if kind == "write" {
|
if kind == "write" {
|
||||||
@@ -619,6 +608,15 @@ func (h *Handler) tree(c *gin.Context) {
|
|||||||
response.OK(c, map[string]any{"tree": tree})
|
response.OK(c, map[string]any{"tree": tree})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) cleanupHistoryArchive(c *gin.Context) {
|
||||||
|
count, e := h.History.CleanupDeletedArchives(c)
|
||||||
|
if e != nil {
|
||||||
|
serverError(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, map[string]int{"deleted_points": count})
|
||||||
|
}
|
||||||
|
|
||||||
type historyRequest struct {
|
type historyRequest struct {
|
||||||
PointIDs []uuid.UUID `json:"point_ids"`
|
PointIDs []uuid.UUID `json:"point_ids"`
|
||||||
StartTime time.Time `json:"start_time"`
|
StartTime time.Time `json:"start_time"`
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func (e *Engine) collect(ctx context.Context, p pg.PointRow) {
|
|||||||
value = &v
|
value = &v
|
||||||
quality = 0
|
quality = 0
|
||||||
reason = nil
|
reason = nil
|
||||||
|
e.manager.MarkConnected(p.DeviceID)
|
||||||
} else {
|
} else {
|
||||||
reasonText = "read_error"
|
reasonText = "read_error"
|
||||||
reason = &reasonText
|
reason = &reasonText
|
||||||
|
|||||||
@@ -100,8 +100,18 @@ func (m *Manager) Times(id uuid.UUID) (online, offline *time.Time) {
|
|||||||
}
|
}
|
||||||
func (m *Manager) MarkDisconnected(id uuid.UUID) {
|
func (m *Manager) MarkDisconnected(id uuid.UUID) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
if m.statuses[id] != "disconnected" {
|
||||||
|
m.lastOffline[id] = time.Now()
|
||||||
|
}
|
||||||
m.statuses[id] = "disconnected"
|
m.statuses[id] = "disconnected"
|
||||||
m.lastOffline[id] = time.Now()
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
func (m *Manager) MarkConnected(id uuid.UUID) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.statuses[id] != "connected" {
|
||||||
|
m.lastOnline[id] = time.Now()
|
||||||
|
}
|
||||||
|
m.statuses[id] = "connected"
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
func (m *Manager) setStatus(id uuid.UUID, s string) {
|
func (m *Manager) setStatus(id uuid.UUID, s string) {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagerStatusRecoversAfterSuccessfulRead(t *testing.T) {
|
||||||
|
m := NewManager(nil, nil)
|
||||||
|
id := uuid.New()
|
||||||
|
|
||||||
|
m.MarkConnected(id)
|
||||||
|
online, offline := m.Times(id)
|
||||||
|
if m.Status(id) != "connected" || online == nil || offline != nil {
|
||||||
|
t.Fatalf("expected connected status with online timestamp, got status=%q online=%v offline=%v", m.Status(id), online, offline)
|
||||||
|
}
|
||||||
|
firstOnline := *online
|
||||||
|
|
||||||
|
m.MarkDisconnected(id)
|
||||||
|
if m.Status(id) != "disconnected" {
|
||||||
|
t.Fatalf("expected disconnected status, got %q", m.Status(id))
|
||||||
|
}
|
||||||
|
_, offline = m.Times(id)
|
||||||
|
if offline == nil {
|
||||||
|
t.Fatal("expected offline timestamp after disconnect")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
m.MarkConnected(id)
|
||||||
|
online, offline = m.Times(id)
|
||||||
|
if m.Status(id) != "connected" || online == nil || offline == nil {
|
||||||
|
t.Fatalf("expected recovered connected status, got status=%q online=%v offline=%v", m.Status(id), online, offline)
|
||||||
|
}
|
||||||
|
if !online.After(firstOnline) {
|
||||||
|
t.Fatalf("expected reconnect to refresh online timestamp: first=%v current=%v", firstOnline, *online)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerRepeatedConnectedDoesNotRefreshOnlineTimestamp(t *testing.T) {
|
||||||
|
m := NewManager(nil, nil)
|
||||||
|
id := uuid.New()
|
||||||
|
m.MarkConnected(id)
|
||||||
|
first, _ := m.Times(id)
|
||||||
|
m.MarkConnected(id)
|
||||||
|
second, _ := m.Times(id)
|
||||||
|
if first == nil || second == nil || !first.Equal(*second) {
|
||||||
|
t.Fatalf("repeated connected mark changed online timestamp: first=%v second=%v", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,13 @@ type Result struct {
|
|||||||
TS time.Time
|
TS time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// REAL values are compared with a fixed protocol precision. The tolerance is
|
||||||
|
// intentionally not part of the write-point configuration anymore.
|
||||||
|
const realReadbackPrecision = 0.0001
|
||||||
|
|
||||||
func (e *Engine) Execute(ctx context.Context, p pg.PointRow, value float64) (Result, error) {
|
func (e *Engine) Execute(ctx context.Context, p pg.PointRow, value float64) (Result, error) {
|
||||||
if !p.Enabled || !p.WriteEnabled {
|
if !p.WriteEnabled {
|
||||||
return Result{}, errors.New("写入点未启用或写入开关关闭")
|
return Result{}, errors.New("写入点未允许写入")
|
||||||
}
|
}
|
||||||
c, err := e.Manager.Connection(ctx, p.DeviceID)
|
c, err := e.Manager.Connection(ctx, p.DeviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,7 +43,7 @@ func (e *Engine) Execute(ctx context.Context, p pg.PointRow, value float64) (Res
|
|||||||
}
|
}
|
||||||
ok := actual == value
|
ok := actual == value
|
||||||
if dt == protocol.Real {
|
if dt == protocol.Real {
|
||||||
ok = math.Abs(actual-value) <= p.ReadbackTolerance
|
ok = math.Abs(actual-value) <= realReadbackPrecision
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
return Result{value, actual, time.Now()}, nil
|
return Result{value, actual, time.Now()}, nil
|
||||||
|
|||||||
@@ -35,6 +35,5 @@ type Point struct {
|
|||||||
HistoryInterval int
|
HistoryInterval int
|
||||||
HistoryStartedAt *time.Time
|
HistoryStartedAt *time.Time
|
||||||
WriteEnabled bool
|
WriteEnabled bool
|
||||||
ReadbackTolerance float64
|
|
||||||
CreatedAt, UpdatedAt time.Time
|
CreatedAt, UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id uuid.UUID) error {
|
|||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return pgx.ErrNoRows
|
return pgx.ErrNoRows
|
||||||
}
|
}
|
||||||
for _, q := range []string{`UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`, `UPDATE write_points SET deleted=TRUE,enabled=FALSE,write_enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`} {
|
for _, q := range []string{`UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`, `UPDATE write_points SET deleted=TRUE,write_enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`} {
|
||||||
if _, e = tx.Exec(ctx, q, id); e != nil {
|
if _, e = tx.Exec(ctx, q, id); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -76,25 +76,37 @@ func (s *Store) DeleteDevice(ctx context.Context, id uuid.UUID) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PointRow struct {
|
type PointRow struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
GroupName string `json:"group_name"`
|
GroupName string `json:"group_name"`
|
||||||
DeviceID uuid.UUID `json:"device_id"`
|
DeviceID uuid.UUID `json:"device_id"`
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
ProtocolType string `json:"protocol_type"`
|
ProtocolType string `json:"protocol_type"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
DataType string `json:"data_type"`
|
DataType string `json:"data_type"`
|
||||||
Unit *string `json:"unit"`
|
Unit *string `json:"unit"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CollectInterval int `json:"collect_interval,omitempty"`
|
CollectInterval int `json:"collect_interval,omitempty"`
|
||||||
StoreHistory bool `json:"store_history,omitempty"`
|
StoreHistory bool `json:"store_history,omitempty"`
|
||||||
HistoryInterval int `json:"history_interval,omitempty"`
|
HistoryInterval int `json:"history_interval,omitempty"`
|
||||||
HistoryStartedAt *time.Time `json:"history_started_at,omitempty"`
|
HistoryStartedAt *time.Time `json:"history_started_at,omitempty"`
|
||||||
WriteEnabled bool `json:"write_enabled,omitempty"`
|
WriteEnabled bool `json:"write_enabled,omitempty"`
|
||||||
ReadbackTolerance float64 `json:"readback_tolerance,omitempty"`
|
ReadbackValue any `json:"readback_value"`
|
||||||
LatestValue any `json:"latest_value"`
|
LatestValue any `json:"latest_value"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
PointKind string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PointRow) MarshalJSON() ([]byte, error) {
|
||||||
|
type alias PointRow
|
||||||
|
if p.PointKind == "write" {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
alias
|
||||||
|
Enabled any `json:"enabled,omitempty"`
|
||||||
|
}{alias: alias(p)})
|
||||||
|
}
|
||||||
|
return json.Marshal(alias(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetPoint(ctx context.Context, kind string, id uuid.UUID) (PointRow, error) {
|
func (s *Store) GetPoint(ctx context.Context, kind string, id uuid.UUID) (PointRow, error) {
|
||||||
@@ -114,29 +126,26 @@ func (s *Store) SavePoint(ctx context.Context, kind string, p *PointRow) error {
|
|||||||
if p.ID == uuid.Nil {
|
if p.ID == uuid.Nil {
|
||||||
return s.DB.QueryRow(ctx, `INSERT INTO collection_points(name,group_name,device_id,enabled,address,data_type,unit,collect_interval,store_history,history_interval) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id,created_at,updated_at`, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.Address, p.DataType, p.Unit, p.CollectInterval, p.StoreHistory, p.HistoryInterval).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
|
return s.DB.QueryRow(ctx, `INSERT INTO collection_points(name,group_name,device_id,enabled,address,data_type,unit,collect_interval,store_history,history_interval) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id,created_at,updated_at`, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.Address, p.DataType, p.Unit, p.CollectInterval, p.StoreHistory, p.HistoryInterval).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
|
||||||
}
|
}
|
||||||
tag, e := s.DB.Exec(ctx, `UPDATE collection_points SET name=$2,group_name=$3,device_id=$4,enabled=$5,address=$6,data_type=$7,unit=$8,collect_interval=$9,store_history=$10,history_interval=$11,updated_at=NOW() WHERE id=$1 AND deleted=FALSE AND (history_started_at IS NULL OR (device_id=$4 AND data_type=$7))`, p.ID, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.Address, p.DataType, p.Unit, p.CollectInterval, p.StoreHistory, p.HistoryInterval)
|
tag, e := s.DB.Exec(ctx, `UPDATE collection_points SET name=$2,group_name=$3,device_id=$4,enabled=$5,address=$6,data_type=$7,unit=$8,collect_interval=$9,store_history=$10,history_interval=$11,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, p.ID, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.Address, p.DataType, p.Unit, p.CollectInterval, p.StoreHistory, p.HistoryInterval)
|
||||||
if e == nil && tag.RowsAffected() == 0 {
|
if e == nil && tag.RowsAffected() == 0 {
|
||||||
return pgx.ErrNoRows
|
return pgx.ErrNoRows
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
if p.ID == uuid.Nil {
|
if p.ID == uuid.Nil {
|
||||||
return s.DB.QueryRow(ctx, `INSERT INTO write_points(name,group_name,device_id,enabled,write_enabled,address,data_type,unit,readback_tolerance) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id,created_at,updated_at`, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.WriteEnabled, p.Address, p.DataType, p.Unit, p.ReadbackTolerance).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
|
return s.DB.QueryRow(ctx, `INSERT INTO write_points(name,group_name,device_id,write_enabled,address,data_type,unit) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id,created_at,updated_at`, p.Name, p.GroupName, p.DeviceID, p.WriteEnabled, p.Address, p.DataType, p.Unit).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
|
||||||
}
|
}
|
||||||
tag, e := s.DB.Exec(ctx, `UPDATE write_points SET name=$2,group_name=$3,device_id=$4,enabled=$5,write_enabled=$6,address=$7,data_type=$8,unit=$9,readback_tolerance=$10,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, p.ID, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.WriteEnabled, p.Address, p.DataType, p.Unit, p.ReadbackTolerance)
|
tag, e := s.DB.Exec(ctx, `UPDATE write_points SET name=$2,group_name=$3,device_id=$4,write_enabled=$5,address=$6,data_type=$7,unit=$8,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, p.ID, p.Name, p.GroupName, p.DeviceID, p.WriteEnabled, p.Address, p.DataType, p.Unit)
|
||||||
if e == nil && tag.RowsAffected() == 0 {
|
if e == nil && tag.RowsAffected() == 0 {
|
||||||
return pgx.ErrNoRows
|
return pgx.ErrNoRows
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
func (s *Store) DeletePoint(ctx context.Context, kind string, id uuid.UUID) error {
|
func (s *Store) DeletePoint(ctx context.Context, kind string, id uuid.UUID) error {
|
||||||
table := "collection_points"
|
q := `UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`
|
||||||
extra := ""
|
|
||||||
if kind == "write" {
|
if kind == "write" {
|
||||||
table = "write_points"
|
q = `UPDATE write_points SET deleted=TRUE,write_enabled=FALSE,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`
|
||||||
extra = ",write_enabled=FALSE"
|
|
||||||
}
|
}
|
||||||
q := fmt.Sprintf(`UPDATE %s SET deleted=TRUE,enabled=FALSE%s,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, table, extra)
|
|
||||||
tag, e := s.DB.Exec(ctx, q, id)
|
tag, e := s.DB.Exec(ctx, q, id)
|
||||||
if e == nil && tag.RowsAffected() == 0 {
|
if e == nil && tag.RowsAffected() == 0 {
|
||||||
return pgx.ErrNoRows
|
return pgx.ErrNoRows
|
||||||
@@ -166,7 +175,10 @@ func (s *Store) FindPointByName(ctx context.Context, kind, name string) (PointRo
|
|||||||
return PointRow{}, pgx.ErrNoRows
|
return PointRow{}, pgx.ErrNoRows
|
||||||
}
|
}
|
||||||
func (s *Store) Groups(ctx context.Context) ([]map[string]any, error) {
|
func (s *Store) Groups(ctx context.Context) ([]map[string]any, error) {
|
||||||
rows, e := s.DB.Query(ctx, `SELECT g.name,COUNT(p.id) FROM collection_groups g LEFT JOIN collection_points p ON p.group_name=g.name AND p.deleted=FALSE GROUP BY g.name ORDER BY g.name`)
|
rows, e := s.DB.Query(ctx, `SELECT g.name,
|
||||||
|
(SELECT COUNT(*) FROM collection_points cp WHERE cp.group_name=g.name AND cp.deleted=FALSE),
|
||||||
|
(SELECT COUNT(*) FROM write_points wp WHERE wp.group_name=g.name AND wp.deleted=FALSE)
|
||||||
|
FROM collection_groups g ORDER BY g.name`)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
@@ -174,11 +186,16 @@ func (s *Store) Groups(ctx context.Context) ([]map[string]any, error) {
|
|||||||
out := []map[string]any{}
|
out := []map[string]any{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var name string
|
||||||
var count int
|
var collectionCount, writeCount int
|
||||||
if e = rows.Scan(&name, &count); e != nil {
|
if e = rows.Scan(&name, &collectionCount, &writeCount); e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
out = append(out, map[string]any{"name": name, "count": count})
|
out = append(out, map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"count": collectionCount,
|
||||||
|
"collection_count": collectionCount,
|
||||||
|
"write_count": writeCount,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
@@ -198,6 +215,9 @@ func (s *Store) UpdateGroup(ctx context.Context, oldName, newName string) error
|
|||||||
if _, e = tx.Exec(ctx, `UPDATE collection_points SET group_name=$2,updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, oldName, newName); e != nil {
|
if _, e = tx.Exec(ctx, `UPDATE collection_points SET group_name=$2,updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, oldName, newName); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
if _, e = tx.Exec(ctx, `UPDATE write_points SET group_name=$2,updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, oldName, newName); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
if _, e = tx.Exec(ctx, `DELETE FROM collection_groups WHERE name=$1`, oldName); e != nil {
|
if _, e = tx.Exec(ctx, `DELETE FROM collection_groups WHERE name=$1`, oldName); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -212,7 +232,13 @@ func (s *Store) DeleteGroup(ctx context.Context, name string) error {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
if _, e = tx.Exec(ctx, `UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, name); e != nil {
|
if _, e = tx.Exec(ctx, `INSERT INTO collection_groups(name) VALUES('default') ON CONFLICT(name) DO NOTHING`); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if _, e = tx.Exec(ctx, `UPDATE collection_points SET group_name='default',updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, name); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if _, e = tx.Exec(ctx, `UPDATE write_points SET group_name='default',updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, name); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
tag, e := tx.Exec(ctx, `DELETE FROM collection_groups WHERE name=$1`, name)
|
tag, e := tx.Exec(ctx, `DELETE FROM collection_groups WHERE name=$1`, name)
|
||||||
@@ -236,16 +262,19 @@ func (s *Store) SetRetention(ctx context.Context, days int) error {
|
|||||||
|
|
||||||
func (s *Store) ListPoints(ctx context.Context, kind, keyword string, archived bool) ([]PointRow, error) {
|
func (s *Store) ListPoints(ctx context.Context, kind, keyword string, archived bool) ([]PointRow, error) {
|
||||||
table := "collection_points"
|
table := "collection_points"
|
||||||
cols := `p.collect_interval,p.store_history,p.history_interval,p.history_started_at,FALSE,0`
|
enabledColumn := "p.enabled"
|
||||||
|
cols := `p.collect_interval,p.store_history,p.history_interval,p.history_started_at,FALSE,NULL::text`
|
||||||
if kind == "write" {
|
if kind == "write" {
|
||||||
table = "write_points"
|
table = "write_points"
|
||||||
cols = `NULL::double precision,NULL::double precision,0,FALSE,0,NULL::timestamptz,p.write_enabled,p.readback_tolerance`
|
enabledColumn = "FALSE"
|
||||||
|
cols = `0::int,FALSE::bool,0,NULL::timestamptz,p.write_enabled,
|
||||||
|
(SELECT wl.readback_value FROM write_logs wl WHERE wl.point_id=p.id ORDER BY wl.created_at DESC LIMIT 1)`
|
||||||
}
|
}
|
||||||
deleted := "p.deleted=FALSE AND d.deleted=FALSE"
|
deleted := "p.deleted=FALSE AND d.deleted=FALSE"
|
||||||
if archived {
|
if archived {
|
||||||
deleted = "TRUE"
|
deleted = "TRUE"
|
||||||
}
|
}
|
||||||
q := fmt.Sprintf(`SELECT p.id,p.name,p.group_name,p.device_id,d.name,d.protocol_type,p.address,p.data_type,p.unit,p.enabled,%s,p.created_at,p.updated_at FROM %s p JOIN devices d ON d.id=p.device_id WHERE %s AND ($1='' OR p.name ILIKE '%%'||$1||'%%' OR p.group_name ILIKE '%%'||$1||'%%') ORDER BY p.group_name,p.name LIMIT 1000`, cols, table, deleted)
|
q := fmt.Sprintf(`SELECT p.id,p.name,p.group_name,p.device_id,d.name,d.protocol_type,p.address,p.data_type,p.unit,%s,%s,p.created_at,p.updated_at FROM %s p JOIN devices d ON d.id=p.device_id WHERE %s AND ($1='' OR p.name ILIKE '%%'||$1||'%%' OR p.group_name ILIKE '%%'||$1||'%%') ORDER BY p.group_name,p.name LIMIT 1000`, enabledColumn, cols, table, deleted)
|
||||||
rows, e := s.DB.Query(ctx, q, keyword)
|
rows, e := s.DB.Query(ctx, q, keyword)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
@@ -253,15 +282,61 @@ func (s *Store) ListPoints(ctx context.Context, kind, keyword string, archived b
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var out []PointRow
|
var out []PointRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p PointRow
|
p := PointRow{PointKind: kind}
|
||||||
if e = rows.Scan(&p.ID, &p.Name, &p.GroupName, &p.DeviceID, &p.DeviceName, &p.ProtocolType, &p.Address, &p.DataType, &p.Unit, &p.Enabled, &p.CollectInterval, &p.StoreHistory, &p.HistoryInterval, &p.HistoryStartedAt, &p.WriteEnabled, &p.ReadbackTolerance, &p.CreatedAt, &p.UpdatedAt); e != nil {
|
var rawReadback *string
|
||||||
|
if e = rows.Scan(&p.ID, &p.Name, &p.GroupName, &p.DeviceID, &p.DeviceName, &p.ProtocolType, &p.Address, &p.DataType, &p.Unit, &p.Enabled, &p.CollectInterval, &p.StoreHistory, &p.HistoryInterval, &p.HistoryStartedAt, &p.WriteEnabled, &rawReadback, &p.CreatedAt, &p.UpdatedAt); e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
|
p.ReadbackValue = parseOptional(p.DataType, rawReadback)
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListDeletedCollectionPointIDs returns collection points whose realtime
|
||||||
|
// metadata no longer exists. The history cleanup flow uses this list to avoid
|
||||||
|
// touching disabled points that may be re-enabled later.
|
||||||
|
func (s *Store) ListDeletedCollectionPointIDs(ctx context.Context) ([]uuid.UUID, error) {
|
||||||
|
rows, e := s.DB.Query(ctx, `SELECT p.id
|
||||||
|
FROM collection_points p
|
||||||
|
LEFT JOIN devices d ON d.id=p.device_id
|
||||||
|
WHERE p.deleted=TRUE OR d.deleted=TRUE
|
||||||
|
ORDER BY p.id`)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []uuid.UUID
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
if e = rows.Scan(&id); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDeviceActivity returns the current realtime availability of every
|
||||||
|
// device, including logically deleted rows used by the history tree.
|
||||||
|
func (s *Store) ListDeviceActivity(ctx context.Context) (map[uuid.UUID]bool, error) {
|
||||||
|
rows, e := s.DB.Query(ctx, `SELECT id,enabled,deleted FROM devices`)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[uuid.UUID]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
var enabled, deleted bool
|
||||||
|
if e = rows.Scan(&id, &enabled, &deleted); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
out[id] = enabled && !deleted
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
type WriteLog struct {
|
type WriteLog struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PointID uuid.UUID `json:"point_id"`
|
PointID uuid.UUID `json:"point_id"`
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ func (s *Store) HasData(ctx context.Context, id uuid.UUID) bool {
|
|||||||
var n int
|
var n int
|
||||||
return s.DB.QueryRowContext(ctx, q).Scan(&n) == nil && n > 0
|
return s.DB.QueryRowContext(ctx, q).Scan(&n) == nil && n > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DropTables removes the per-point child tables for the supplied archived
|
||||||
|
// points. The caller is responsible for selecting only points that have no
|
||||||
|
// realtime metadata; TableName validates every derived identifier.
|
||||||
|
func (s *Store) DropTables(ctx context.Context, ids []uuid.UUID) (int, error) {
|
||||||
|
removed := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
if !s.HasData(ctx, id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q := fmt.Sprintf("DROP TABLE IF EXISTS `%s`.`%s`", s.Database, TableName(id))
|
||||||
|
if _, e := s.DB.ExecContext(ctx, q); e != nil {
|
||||||
|
return removed, e
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
return removed, nil
|
||||||
|
}
|
||||||
func (s *Store) Query(ctx context.Context, id uuid.UUID, start, end time.Time) ([]Sample, error) {
|
func (s *Store) Query(ctx context.Context, id uuid.UUID, start, end time.Time) ([]Sample, error) {
|
||||||
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||||
q := fmt.Sprintf("SELECT ts,`value`,quality,quality_reason FROM `%s`.`%s` WHERE ts >= %s AND ts <= %s ORDER BY ts", s.Database, TableName(id), sqlString(start.In(shanghai).Format("2006-01-02 15:04:05.000")), sqlString(end.In(shanghai).Format("2006-01-02 15:04:05.000")))
|
q := fmt.Sprintf("SELECT ts,`value`,quality,quality_reason FROM `%s`.`%s` WHERE ts >= %s AND ts <= %s ORDER BY ts", s.Database, TableName(id), sqlString(start.In(shanghai).Format("2006-01-02 15:04:05.000")), sqlString(end.In(shanghai).Format("2006-01-02 15:04:05.000")))
|
||||||
@@ -91,6 +109,10 @@ func (s *Store) Query(ctx context.Context, id uuid.UUID, start, end time.Time) (
|
|||||||
if e = rows.Scan(&x.TS, &x.Value, &quality, &x.QualityReason); e != nil {
|
if e = rows.Scan(&x.TS, &x.Value, &quality, &x.QualityReason); e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
|
// The REST driver returns TDengine's local wall-clock timestamp with a
|
||||||
|
// UTC location. Rebuild it in Asia/Shanghai instead of converting the
|
||||||
|
// instant, otherwise an eight-hour offset would be applied twice.
|
||||||
|
x.TS = time.Date(x.TS.Year(), x.TS.Month(), x.TS.Day(), x.TS.Hour(), x.TS.Minute(), x.TS.Second(), x.TS.Nanosecond(), shanghai)
|
||||||
if quality == 0 {
|
if quality == 0 {
|
||||||
x.Quality = "good"
|
x.Quality = "good"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -36,9 +36,21 @@ func (h *History) Tree(ctx context.Context) ([]map[string]any, error) {
|
|||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
|
deletedIDs, e := h.PG.ListDeletedCollectionPointIDs(ctx)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
deviceActive, e := h.PG.ListDeviceActivity(ctx)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
deleted := make(map[uuid.UUID]bool, len(deletedIDs))
|
||||||
|
for _, id := range deletedIDs {
|
||||||
|
deleted[id] = true
|
||||||
|
}
|
||||||
groups := map[string][]map[string]any{}
|
groups := map[string][]map[string]any{}
|
||||||
for _, p := range points {
|
for _, p := range points {
|
||||||
active := p.Enabled && p.StoreHistory
|
active := p.Enabled && p.StoreHistory && deviceActive[p.DeviceID]
|
||||||
has := h.TD.HasData(ctx, p.ID)
|
has := h.TD.HasData(ctx, p.ID)
|
||||||
if !active && !has {
|
if !active && !has {
|
||||||
continue
|
continue
|
||||||
@@ -51,7 +63,7 @@ func (h *History) Tree(ctx context.Context) ([]map[string]any, error) {
|
|||||||
if h.Collector != nil && life == "active" {
|
if h.Collector != nil && life == "active" {
|
||||||
latest = h.Collector.Latest(p.ID)
|
latest = h.Collector.Latest(p.ID)
|
||||||
}
|
}
|
||||||
groups[p.GroupName] = append(groups[p.GroupName], map[string]any{"id": p.ID, "name": p.Name, "type": "collection", "data_type": p.DataType, "unit": p.Unit, "history_interval": p.HistoryInterval, "device_id": p.DeviceID, "device_name": p.DeviceName, "group_name": p.GroupName, "lifecycle_status": life, "has_history_data": has, "latest_value": latest})
|
groups[p.GroupName] = append(groups[p.GroupName], map[string]any{"id": p.ID, "name": p.Name, "type": "collection", "data_type": p.DataType, "unit": p.Unit, "history_interval": p.HistoryInterval, "device_id": p.DeviceID, "device_name": p.DeviceName, "group_name": p.GroupName, "lifecycle_status": life, "has_history_data": has, "can_cleanup": deleted[p.ID], "latest_value": latest})
|
||||||
}
|
}
|
||||||
names := make([]string, 0, len(groups))
|
names := make([]string, 0, len(groups))
|
||||||
for n := range groups {
|
for n := range groups {
|
||||||
@@ -66,6 +78,16 @@ func (h *History) Tree(ctx context.Context) ([]map[string]any, error) {
|
|||||||
tree = append(tree, map[string]any{"id": "internal-data", "name": "内部数据", "type": "reserved", "children": []map[string]any{{"id": "placeholder", "name": "暂无数据", "type": "placeholder", "disabled": true}}})
|
tree = append(tree, map[string]any{"id": "internal-data", "name": "内部数据", "type": "reserved", "children": []map[string]any{{"id": "placeholder", "name": "暂无数据", "type": "placeholder", "disabled": true}}})
|
||||||
return tree, nil
|
return tree, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanupDeletedArchives drops TDengine history tables only for collection
|
||||||
|
// points that have been logically deleted (or belong to a deleted device).
|
||||||
|
func (h *History) CleanupDeletedArchives(ctx context.Context) (int, error) {
|
||||||
|
ids, e := h.PG.ListDeletedCollectionPointIDs(ctx)
|
||||||
|
if e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
return h.TD.DropTables(ctx, ids)
|
||||||
|
}
|
||||||
func (h *History) Query(ctx context.Context, ids []uuid.UUID, start, end time.Time, max int) ([]Series, error) {
|
func (h *History) Query(ctx context.Context, ids []uuid.UUID, start, end time.Time, max int) ([]Series, error) {
|
||||||
meta, e := h.PG.ListPoints(ctx, "collection", "", true)
|
meta, e := h.PG.ListPoints(ctx, "collection", "", true)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
@@ -159,6 +181,9 @@ type TableResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *History) QueryTable(ctx context.Context, ids []uuid.UUID, start, end time.Time, minutes int) (TableResult, error) {
|
func (h *History) QueryTable(ctx context.Context, ids []uuid.UUID, start, end time.Time, minutes int) (TableResult, error) {
|
||||||
|
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||||
|
start = start.In(shanghai)
|
||||||
|
end = end.In(shanghai)
|
||||||
step := time.Duration(minutes) * time.Minute
|
step := time.Duration(minutes) * time.Minute
|
||||||
times := []time.Time{}
|
times := []time.Time{}
|
||||||
for t := start; !t.After(end); t = t.Add(step) {
|
for t := start; !t.After(end); t = t.Add(step) {
|
||||||
|
|||||||
@@ -190,8 +190,6 @@ func (s *Service) SavePoint(ctx context.Context, kind string, p *pg.PointRow) er
|
|||||||
if p.HistoryInterval < 1 || p.HistoryInterval > 1440 {
|
if p.HistoryInterval < 1 || p.HistoryInterval > 1440 {
|
||||||
return errors.New("history_interval必须为1~1440")
|
return errors.New("history_interval必须为1~1440")
|
||||||
}
|
}
|
||||||
} else if p.ReadbackTolerance < 0 || p.ReadbackTolerance > 1000000 {
|
|
||||||
return errors.New("readback_tolerance超出范围")
|
|
||||||
}
|
}
|
||||||
return s.Store.SavePoint(ctx, kind, p)
|
return s.Store.SavePoint(ctx, kind, p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ var PostgreSQL string
|
|||||||
//go:embed postgres/000002_collection_groups.up.sql
|
//go:embed postgres/000002_collection_groups.up.sql
|
||||||
var PostgreSQLGroups string
|
var PostgreSQLGroups string
|
||||||
|
|
||||||
|
//go:embed postgres/000003_write_points_simplify.up.sql
|
||||||
|
var PostgreSQLWritePoints string
|
||||||
|
|
||||||
//go:embed tdengine/000001_init.sql
|
//go:embed tdengine/000001_init.sql
|
||||||
var TDengine string
|
var TDengine string
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS collection_points (id UUID PRIMARY KEY DEFAULT gen_ra
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_collection_points_name ON collection_points(name) WHERE deleted=FALSE;
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_collection_points_name ON collection_points(name) WHERE deleted=FALSE;
|
||||||
CREATE INDEX IF NOT EXISTS idx_collection_points_device_id ON collection_points(device_id) WHERE deleted=FALSE;
|
CREATE INDEX IF NOT EXISTS idx_collection_points_device_id ON collection_points(device_id) WHERE deleted=FALSE;
|
||||||
CREATE INDEX IF NOT EXISTS idx_collection_points_group_name ON collection_points(group_name) WHERE deleted=FALSE;
|
CREATE INDEX IF NOT EXISTS idx_collection_points_group_name ON collection_points(group_name) WHERE deleted=FALSE;
|
||||||
CREATE TABLE IF NOT EXISTS write_points (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),name VARCHAR(128) NOT NULL,group_name VARCHAR(64) NOT NULL DEFAULT 'default',device_id UUID NOT NULL REFERENCES devices(id),enabled BOOLEAN NOT NULL DEFAULT TRUE,write_enabled BOOLEAN NOT NULL DEFAULT FALSE,deleted BOOLEAN NOT NULL DEFAULT FALSE,address VARCHAR(256) NOT NULL,data_type VARCHAR(16) NOT NULL CHECK(data_type IN('BOOL','INT','REAL')),unit VARCHAR(32),readback_tolerance DOUBLE PRECISION NOT NULL DEFAULT .0001 CHECK(readback_tolerance BETWEEN 0 AND 1000000),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),created_by VARCHAR(64),updated_by VARCHAR(64));
|
CREATE TABLE IF NOT EXISTS write_points (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),name VARCHAR(128) NOT NULL,group_name VARCHAR(64) NOT NULL DEFAULT 'default',device_id UUID NOT NULL REFERENCES devices(id),write_enabled BOOLEAN NOT NULL DEFAULT FALSE,deleted BOOLEAN NOT NULL DEFAULT FALSE,address VARCHAR(256) NOT NULL,data_type VARCHAR(16) NOT NULL CHECK(data_type IN('BOOL','INT','REAL')),unit VARCHAR(32),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),created_by VARCHAR(64),updated_by VARCHAR(64));
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_write_points_name ON write_points(name) WHERE deleted=FALSE;
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_write_points_name ON write_points(name) WHERE deleted=FALSE;
|
||||||
CREATE INDEX IF NOT EXISTS idx_write_points_device_id ON write_points(device_id) WHERE deleted=FALSE;
|
CREATE INDEX IF NOT EXISTS idx_write_points_device_id ON write_points(device_id) WHERE deleted=FALSE;
|
||||||
CREATE TABLE IF NOT EXISTS write_logs (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),point_id UUID NOT NULL REFERENCES write_points(id),point_name VARCHAR(128) NOT NULL,device_id UUID NOT NULL REFERENCES devices(id),device_name VARCHAR(128) NOT NULL,address VARCHAR(256) NOT NULL,data_type VARCHAR(16) NOT NULL,unit VARCHAR(32),source VARCHAR(16) NOT NULL DEFAULT 'manual' CHECK(source='manual'),target_value TEXT NOT NULL,readback_value TEXT,result VARCHAR(16) NOT NULL CHECK(result IN('success','failed','timeout')),error_message TEXT,operator VARCHAR(64),reason VARCHAR(500),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
|
CREATE TABLE IF NOT EXISTS write_logs (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),point_id UUID NOT NULL REFERENCES write_points(id),point_name VARCHAR(128) NOT NULL,device_id UUID NOT NULL REFERENCES devices(id),device_name VARCHAR(128) NOT NULL,address VARCHAR(256) NOT NULL,data_type VARCHAR(16) NOT NULL,unit VARCHAR(32),source VARCHAR(16) NOT NULL DEFAULT 'manual' CHECK(source='manual'),target_value TEXT NOT NULL,readback_value TEXT,result VARCHAR(16) NOT NULL CHECK(result IN('success','failed','timeout')),error_message TEXT,operator VARCHAR(64),reason VARCHAR(500),created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Write points use one explicit permission switch; keep the previous
|
||||||
|
-- effective permission before removing the redundant columns. The guard
|
||||||
|
-- keeps startup migrations safe when the service is restarted.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'write_points'
|
||||||
|
AND column_name = 'enabled'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'UPDATE write_points SET write_enabled = enabled AND write_enabled WHERE enabled IS NOT NULL';
|
||||||
|
EXECUTE 'ALTER TABLE write_points DROP COLUMN enabled';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'write_points'
|
||||||
|
AND column_name = 'readback_tolerance'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'ALTER TABLE write_points DROP COLUMN readback_tolerance';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -32,6 +32,7 @@ export const writePointApi = {
|
|||||||
};
|
};
|
||||||
export const historyApi = {
|
export const historyApi = {
|
||||||
tree: () => request.get("/history/tree"),
|
tree: () => request.get("/history/tree"),
|
||||||
|
cleanupArchives: () => request.post("/history/archive/cleanup"),
|
||||||
query: (data: unknown) => request.post("/history/query", data),
|
query: (data: unknown) => request.post("/history/query", data),
|
||||||
queryTable: (data: unknown) => request.post("/history/query-table", data),
|
queryTable: (data: unknown) => request.post("/history/query-table", data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,64 +2,404 @@
|
|||||||
import * as echarts from "echarts";
|
import * as echarts from "echarts";
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { buildSegmentedAxis, mapValue, unmapValue } from "./segmented-axis";
|
import { buildSegmentedAxis, mapValue, unmapValue } from "./segmented-axis";
|
||||||
const props = defineProps<{ series: any[]; segmented: boolean }>();
|
|
||||||
|
const props = defineProps<{
|
||||||
|
series: any[];
|
||||||
|
segmented: boolean;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}>();
|
||||||
const emit = defineEmits<{ cursor: [any[]] }>();
|
const emit = defineEmits<{ cursor: [any[]] }>();
|
||||||
const el = ref<HTMLDivElement>();
|
const el = ref<HTMLDivElement>();
|
||||||
let chart: echarts.ECharts | undefined, observer: ResizeObserver | undefined;
|
let chart: echarts.ECharts | undefined;
|
||||||
|
let observer: ResizeObserver | undefined;
|
||||||
|
let currentNames: string[] = [];
|
||||||
|
let visibleNames: Record<string, boolean> = {};
|
||||||
|
let lastCursorTime: number | null = null;
|
||||||
|
let cursorFrame: number | undefined;
|
||||||
|
const gridTop = 58;
|
||||||
|
const gridBottom = 52;
|
||||||
|
|
||||||
const segments = computed(() =>
|
const segments = computed(() =>
|
||||||
buildSegmentedAxis(
|
buildSegmentedAxis(
|
||||||
props.series.flatMap((s) =>
|
props.series.flatMap((s) =>
|
||||||
s.data.filter((d: any) => d.value !== null).map((d: any) => d.value),
|
(s.data ?? [])
|
||||||
|
.filter((d: any) => d.value !== null && d.value !== undefined)
|
||||||
|
.map((d: any) => d.value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
function render() {
|
const range = computed(() => Math.max(1, props.endTime - props.startTime || 1));
|
||||||
|
const colors = [
|
||||||
|
"#63f04f",
|
||||||
|
"#18d7e9",
|
||||||
|
"#f3bd42",
|
||||||
|
"#c68cff",
|
||||||
|
"#ff8f66",
|
||||||
|
"#73b7ff",
|
||||||
|
];
|
||||||
|
|
||||||
|
function timestamp(value: any) {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
const result = new Date(value).getTime();
|
||||||
|
return Number.isFinite(result) ? result : NaN;
|
||||||
|
}
|
||||||
|
function displayValue(value: any) {
|
||||||
|
return typeof value === "number" ? Number(value.toFixed(3)) : value;
|
||||||
|
}
|
||||||
|
function axisLabel(value: number) {
|
||||||
|
const date = new Date(value);
|
||||||
|
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(date);
|
||||||
|
const part = (type: string) =>
|
||||||
|
parts.find((x) => x.type === type)?.value ?? "";
|
||||||
|
if (range.value <= 2 * 3600000)
|
||||||
|
return `${part("hour")}:${part("minute")}:${part("second")}`;
|
||||||
|
return `${part("month")}-${part("day")} ${part("hour")}:${part("minute")}`;
|
||||||
|
}
|
||||||
|
function splitNumber() {
|
||||||
|
if (range.value <= 2 * 3600000) return 8;
|
||||||
|
if (range.value <= 24 * 3600000) return 10;
|
||||||
|
if (range.value <= 7 * 24 * 3600000) return 8;
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
function seriesName(s: any, index: number, used: Set<string>) {
|
||||||
|
const base = `${s.point_name}${s.unit ? ` (${s.unit})` : ""}`;
|
||||||
|
if (!used.has(base)) {
|
||||||
|
used.add(base);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const name = `${base} #${index + 1}`;
|
||||||
|
used.add(name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
function pointRows(s: any) {
|
||||||
|
return (s.data ?? [])
|
||||||
|
.map((d: any) => ({ ...d, time: timestamp(d.ts) }))
|
||||||
|
.filter((d: any) => Number.isFinite(d.time))
|
||||||
|
.sort((a: any, b: any) => a.time - b.time);
|
||||||
|
}
|
||||||
|
function cursorRow(s: any, target: number) {
|
||||||
|
const points = pointRows(s);
|
||||||
|
const cursorTS = new Date(target).toISOString();
|
||||||
|
const base = {
|
||||||
|
pointId: s.point_id,
|
||||||
|
pointName: s.point_name,
|
||||||
|
ts: cursorTS,
|
||||||
|
value: null as number | null,
|
||||||
|
quality: "none",
|
||||||
|
qualityReason: null as string | null,
|
||||||
|
unit: s.unit,
|
||||||
|
interpolated: false,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!points.length ||
|
||||||
|
target < points[0].time ||
|
||||||
|
target > points.at(-1).time
|
||||||
|
) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let nearest = points[0];
|
||||||
|
for (const point of points) {
|
||||||
|
if (Math.abs(point.time - target) < Math.abs(nearest.time - target)) {
|
||||||
|
nearest = point;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Math.abs(nearest.time - target) <= 1) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
value: nearest.value ?? null,
|
||||||
|
quality: nearest.quality ?? "none",
|
||||||
|
qualityReason: nearest.quality_reason ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const numeric = points.filter(
|
||||||
|
(point: any) => point.value !== null && point.value !== undefined,
|
||||||
|
);
|
||||||
|
if (!numeric.length) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
quality: nearest.quality ?? "none",
|
||||||
|
qualityReason: nearest.quality_reason ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let before: any;
|
||||||
|
let after: any;
|
||||||
|
for (const point of numeric) {
|
||||||
|
if (point.time <= target) before = point;
|
||||||
|
if (point.time >= target) {
|
||||||
|
after = point;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (before && after && before.time !== after.time) {
|
||||||
|
const ratio = (target - before.time) / (after.time - before.time);
|
||||||
|
const badBetween = points.some(
|
||||||
|
(point: any) =>
|
||||||
|
point.time > before.time &&
|
||||||
|
point.time < after.time &&
|
||||||
|
point.quality === "bad",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
value: before.value + (after.value - before.value) * ratio,
|
||||||
|
quality:
|
||||||
|
before.quality === "bad" || after.quality === "bad" || badBetween
|
||||||
|
? "bad"
|
||||||
|
: "good",
|
||||||
|
qualityReason: before.quality_reason ?? after.quality_reason ?? null,
|
||||||
|
interpolated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const edge = before ?? after;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
value: edge.value ?? null,
|
||||||
|
quality: edge.quality ?? "none",
|
||||||
|
qualityReason: edge.quality_reason ?? null,
|
||||||
|
interpolated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function cursorRows(target: number) {
|
||||||
|
return props.series
|
||||||
|
.map((s: any, index: number) => {
|
||||||
|
const name = currentNames[index];
|
||||||
|
return visibleNames[name] === false ? null : cursorRow(s, target);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
function emitCursorAt(target: number) {
|
||||||
|
if (!Number.isFinite(target)) return;
|
||||||
|
lastCursorTime = Math.min(Math.max(target, props.startTime), props.endTime);
|
||||||
|
scheduleVisualCursor(lastCursorTime);
|
||||||
|
emit("cursor", cursorRows(lastCursorTime));
|
||||||
|
}
|
||||||
|
function scheduleVisualCursor(target: number) {
|
||||||
|
if (cursorFrame !== undefined) cancelAnimationFrame(cursorFrame);
|
||||||
|
cursorFrame = requestAnimationFrame(() => {
|
||||||
|
cursorFrame = undefined;
|
||||||
|
updateVisualCursor(target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateVisualCursor(target: number) {
|
||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
const colors = ["#63f04f", "#18d7e9", "#f3bd42", "#c68cff"];
|
const pixel = chart.convertToPixel({ gridIndex: 0 }, [target, 0]) as number[];
|
||||||
|
if (!Number.isFinite(pixel?.[0])) return;
|
||||||
chart.setOption(
|
chart.setOption(
|
||||||
{
|
{
|
||||||
animationDuration: 350,
|
graphic: [
|
||||||
|
{
|
||||||
|
id: "history-cursor-line",
|
||||||
|
type: "line",
|
||||||
|
left: pixel[0],
|
||||||
|
top: gridTop,
|
||||||
|
shape: {
|
||||||
|
x1: 0,
|
||||||
|
y1: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: Math.max(1, chart.getHeight() - gridTop - gridBottom),
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
stroke: "#dbeee8",
|
||||||
|
lineWidth: 1,
|
||||||
|
lineDash: [5, 5],
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
animation: false,
|
||||||
|
silent: true,
|
||||||
|
z: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ lazyUpdate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function handleMouseMove(event: any) {
|
||||||
|
if (!chart) return;
|
||||||
|
const x = event.zrX ?? event.offsetX;
|
||||||
|
const y = event.zrY ?? event.offsetY;
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||||
|
const converted = chart.convertFromPixel({ gridIndex: 0 }, [
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
]) as number[];
|
||||||
|
const target = timestamp(converted?.[0]);
|
||||||
|
if (Number.isFinite(target)) emitCursorAt(target);
|
||||||
|
}
|
||||||
|
function handleLegendChange(event: any) {
|
||||||
|
visibleNames = { ...(event.selected ?? {}) };
|
||||||
|
if (lastCursorTime !== null) emitCursorAt(lastCursorTime);
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
if (!chart) return;
|
||||||
|
const option = (chart.getOption?.() as any) ?? {};
|
||||||
|
const previous = option.legend?.[0]?.selected ?? {};
|
||||||
|
const used = new Set<string>();
|
||||||
|
currentNames = props.series.map((s: any, i: number) =>
|
||||||
|
seriesName(s, i, used),
|
||||||
|
);
|
||||||
|
const selected: Record<string, boolean> = {};
|
||||||
|
currentNames.forEach((name) => {
|
||||||
|
selected[name] = previous[name] !== false;
|
||||||
|
});
|
||||||
|
visibleNames = selected;
|
||||||
|
const chartSeries: any[] = [];
|
||||||
|
props.series.forEach((s: any, i: number) => {
|
||||||
|
const solid: any[] = [];
|
||||||
|
const bad: any[] = [];
|
||||||
|
(s.data ?? []).forEach((d: any) => {
|
||||||
|
const numeric = d.value !== null && d.value !== undefined;
|
||||||
|
const mapped = numeric
|
||||||
|
? props.segmented
|
||||||
|
? mapValue(d.value, segments.value)
|
||||||
|
: d.value
|
||||||
|
: null;
|
||||||
|
const row = [
|
||||||
|
d.ts,
|
||||||
|
mapped,
|
||||||
|
d.ts,
|
||||||
|
d.value,
|
||||||
|
d.quality,
|
||||||
|
s.unit,
|
||||||
|
s.point_name,
|
||||||
|
s.point_id,
|
||||||
|
];
|
||||||
|
if (d.quality === "bad") {
|
||||||
|
solid.push([
|
||||||
|
d.ts,
|
||||||
|
null,
|
||||||
|
d.ts,
|
||||||
|
null,
|
||||||
|
d.quality,
|
||||||
|
s.unit,
|
||||||
|
s.point_name,
|
||||||
|
s.point_id,
|
||||||
|
]);
|
||||||
|
bad.push([
|
||||||
|
d.ts,
|
||||||
|
numeric ? mapped : null,
|
||||||
|
d.ts,
|
||||||
|
d.value,
|
||||||
|
d.quality,
|
||||||
|
s.unit,
|
||||||
|
s.point_name,
|
||||||
|
s.point_id,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
solid.push(row);
|
||||||
|
bad.push([
|
||||||
|
d.ts,
|
||||||
|
null,
|
||||||
|
d.ts,
|
||||||
|
null,
|
||||||
|
d.quality,
|
||||||
|
s.unit,
|
||||||
|
s.point_name,
|
||||||
|
s.point_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const color = colors[i % colors.length];
|
||||||
|
chartSeries.push(
|
||||||
|
{
|
||||||
|
id: `${s.point_id}-good`,
|
||||||
|
name: currentNames[i],
|
||||||
|
type: "line",
|
||||||
|
showSymbol: false,
|
||||||
|
connectNulls: false,
|
||||||
|
data: solid,
|
||||||
|
lineStyle: { width: 1.8, color },
|
||||||
|
itemStyle: { color },
|
||||||
|
emphasis: { focus: "series" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${s.point_id}-bad`,
|
||||||
|
name: currentNames[i],
|
||||||
|
type: "line",
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 5,
|
||||||
|
connectNulls: false,
|
||||||
|
data: bad,
|
||||||
|
lineStyle: { width: 1.8, type: "dashed", color },
|
||||||
|
itemStyle: { color },
|
||||||
|
emphasis: { focus: "series" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
chart.setOption(
|
||||||
|
{
|
||||||
|
animation: true,
|
||||||
|
animationDuration: 260,
|
||||||
|
animationDurationUpdate: 260,
|
||||||
|
animationEasingUpdate: "cubicOut",
|
||||||
color: colors,
|
color: colors,
|
||||||
grid: { left: 58, right: 26, top: 58, bottom: 46 },
|
grid: {
|
||||||
legend: { top: 12, textStyle: { color: "#a9bbc0" } },
|
left: 62,
|
||||||
|
right: 28,
|
||||||
|
top: currentNames.length ? 58 : 30,
|
||||||
|
bottom: 52,
|
||||||
|
},
|
||||||
|
graphic: [],
|
||||||
|
legend: {
|
||||||
|
show: currentNames.length > 0,
|
||||||
|
data: currentNames,
|
||||||
|
selected,
|
||||||
|
top: 12,
|
||||||
|
textStyle: { color: "#a9bbc0" },
|
||||||
|
itemWidth: 18,
|
||||||
|
itemHeight: 8,
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
|
transitionDuration: 0.12,
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
type: "line",
|
type: "none",
|
||||||
lineStyle: { color: "#dbeee8", type: "dashed" },
|
|
||||||
},
|
},
|
||||||
backgroundColor: "#07141af2",
|
backgroundColor: "#07141af2",
|
||||||
borderColor: "#35505a",
|
borderColor: "#35505a",
|
||||||
textStyle: { color: "#dce9e5" },
|
textStyle: { color: "#dce9e5" },
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
emit(
|
const rows = Array.isArray(params) ? params : [params];
|
||||||
"cursor",
|
const axisValue = rows[0]?.axisValue;
|
||||||
params.map((p: any) => ({
|
const target =
|
||||||
pointName: p.seriesName,
|
lastCursorTime ?? timestamp(axisValue ?? rows[0]?.data?.[2]);
|
||||||
ts: p.data?.[2],
|
if (!Number.isFinite(target)) return "";
|
||||||
value: p.data?.[3],
|
emitCursorAt(target);
|
||||||
quality: p.data?.[4],
|
return cursorRows(target)
|
||||||
unit: p.data?.[5],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
return params
|
|
||||||
.map(
|
.map(
|
||||||
(p: any) =>
|
(row: any) =>
|
||||||
`${p.marker}${p.seriesName}<br/>${p.data?.[3] ?? "—"} ${p.data?.[5] ?? ""} · ${p.data?.[4]}`,
|
`${row.pointName}<br/>${displayValue(row.value) ?? "—"} ${row.unit ?? ""} · ${row.quality}`,
|
||||||
)
|
)
|
||||||
.join("<br/>");
|
.join("<br/>\n");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "time",
|
type: "time",
|
||||||
|
min: props.startTime,
|
||||||
|
max: props.endTime,
|
||||||
|
splitNumber: splitNumber(),
|
||||||
|
minInterval: range.value / 24,
|
||||||
|
maxInterval: range.value / 3,
|
||||||
axisLine: { lineStyle: { color: "#35505a" } },
|
axisLine: { lineStyle: { color: "#35505a" } },
|
||||||
axisLabel: { color: "#718990" },
|
axisLabel: {
|
||||||
|
color: "#718990",
|
||||||
|
hideOverlap: true,
|
||||||
|
formatter: axisLabel,
|
||||||
|
},
|
||||||
splitLine: { show: true, lineStyle: { color: "#142b33" } },
|
splitLine: { show: true, lineStyle: { color: "#142b33" } },
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: "value",
|
type: "value",
|
||||||
min: props.segmented ? 0 : undefined,
|
min: props.segmented ? 0 : undefined,
|
||||||
max: props.segmented ? 1 : undefined,
|
max: props.segmented ? 1 : undefined,
|
||||||
|
scale: !props.segmented,
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: "#789098",
|
color: "#789098",
|
||||||
formatter: (v: number) =>
|
formatter: (v: number) =>
|
||||||
@@ -69,66 +409,43 @@ function render() {
|
|||||||
},
|
},
|
||||||
splitLine: { lineStyle: { color: "#173039", type: "dashed" } },
|
splitLine: { lineStyle: { color: "#173039", type: "dashed" } },
|
||||||
},
|
},
|
||||||
series: props.series.flatMap((s: any, i: number) => {
|
series: chartSeries,
|
||||||
const solid: any[] = [];
|
|
||||||
const bad: any[] = [];
|
|
||||||
s.data.forEach((d: any) => {
|
|
||||||
const row = [
|
|
||||||
d.ts,
|
|
||||||
d.value === null
|
|
||||||
? null
|
|
||||||
: props.segmented
|
|
||||||
? mapValue(d.value, segments.value)
|
|
||||||
: d.value,
|
|
||||||
d.ts,
|
|
||||||
d.value,
|
|
||||||
d.quality,
|
|
||||||
s.unit,
|
|
||||||
];
|
|
||||||
(d.quality === "bad" && d.value !== null ? bad : solid).push(row);
|
|
||||||
});
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: `${s.point_name}${s.unit ? ` (${s.unit})` : ""}`,
|
|
||||||
type: "line",
|
|
||||||
showSymbol: false,
|
|
||||||
connectNulls: false,
|
|
||||||
data: solid,
|
|
||||||
lineStyle: { width: 1.6, color: colors[i % colors.length] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: `${s.point_name} · bad`,
|
|
||||||
type: "line",
|
|
||||||
showSymbol: true,
|
|
||||||
symbolSize: 5,
|
|
||||||
connectNulls: false,
|
|
||||||
data: bad,
|
|
||||||
lineStyle: { width: 1.4, type: "dashed", color: "#ff665c" },
|
|
||||||
itemStyle: { color: "#ff665c" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
if (lastCursorTime !== null) scheduleVisualCursor(lastCursorTime);
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
chart = echarts.init(el.value!);
|
chart = echarts.init(el.value!);
|
||||||
observer = new ResizeObserver(() => chart?.resize());
|
observer = new ResizeObserver(() => {
|
||||||
|
chart?.resize();
|
||||||
|
if (chart && lastCursorTime !== null) scheduleVisualCursor(lastCursorTime);
|
||||||
|
});
|
||||||
observer.observe(el.value!);
|
observer.observe(el.value!);
|
||||||
|
chart.getZr().on("mousemove", handleMouseMove);
|
||||||
|
chart.on("legendselectchanged", handleLegendChange);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
watch(() => [props.series, props.segmented], render, { deep: true });
|
watch(
|
||||||
|
() => [props.series, props.segmented, props.startTime, props.endTime],
|
||||||
|
render,
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (cursorFrame !== undefined) cancelAnimationFrame(cursorFrame);
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
|
chart?.getZr().off("mousemove", handleMouseMove);
|
||||||
|
chart?.off("legendselectchanged", handleLegendChange);
|
||||||
chart?.dispose();
|
chart?.dispose();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template><div ref="el" class="history-chart" /></template>
|
<template><div ref="el" class="history-chart" /></template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.history-chart {
|
.history-chart {
|
||||||
height: 450px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 520px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(#0a1a21aa, #07151baa),
|
linear-gradient(#0a1a21aa, #07151baa),
|
||||||
repeating-linear-gradient(0deg, transparent 0 31px, #122a321f 32px);
|
repeating-linear-gradient(0deg, transparent 0 31px, #122a321f 32px);
|
||||||
|
|||||||
+78
-1
@@ -209,6 +209,18 @@ main {
|
|||||||
.btn:hover {
|
.btn:hover {
|
||||||
border-color: #3f5d67;
|
border-color: #3f5d67;
|
||||||
}
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
.panel {
|
.panel {
|
||||||
background: rgba(9, 23, 29, 0.9);
|
background: rgba(9, 23, 29, 0.9);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -332,7 +344,7 @@ main {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9db0b6;
|
color: #9db0b6;
|
||||||
}
|
}
|
||||||
.field input,
|
.field input:not([type="checkbox"]),
|
||||||
.field select {
|
.field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -348,6 +360,71 @@ main {
|
|||||||
.switch {
|
.switch {
|
||||||
accent-color: var(--green);
|
accent-color: var(--green);
|
||||||
}
|
}
|
||||||
|
.check-field {
|
||||||
|
min-height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #0b1920;
|
||||||
|
color: #c7d8d5;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.14s ease,
|
||||||
|
background 0.14s ease,
|
||||||
|
color 0.14s ease;
|
||||||
|
}
|
||||||
|
.check-field-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.check-field input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.check-box {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #45616a;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #07141a;
|
||||||
|
transition:
|
||||||
|
border-color 0.14s ease,
|
||||||
|
background 0.14s ease,
|
||||||
|
box-shadow 0.14s ease;
|
||||||
|
}
|
||||||
|
.check-box::after {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 4px;
|
||||||
|
border-left: 2px solid #041006;
|
||||||
|
border-bottom: 2px solid #041006;
|
||||||
|
transform: rotate(-45deg) scale(0);
|
||||||
|
transition: transform 0.14s ease;
|
||||||
|
}
|
||||||
|
.check-field:has(input:checked) {
|
||||||
|
border-color: rgba(99, 240, 79, 0.55);
|
||||||
|
background: rgba(99, 240, 79, 0.09);
|
||||||
|
color: #dff8dc;
|
||||||
|
}
|
||||||
|
.check-field:has(input:checked) .check-box {
|
||||||
|
border-color: var(--green);
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 12px rgba(99, 240, 79, 0.22);
|
||||||
|
}
|
||||||
|
.check-field:has(input:checked) .check-box::after {
|
||||||
|
transform: rotate(-45deg) scale(1);
|
||||||
|
}
|
||||||
|
.check-field:focus-within {
|
||||||
|
outline: 2px solid rgba(99, 240, 79, 0.35);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
.collection-layout {
|
.collection-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px 1fr;
|
grid-template-columns: 220px 1fr;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onMounted, reactive, ref } from "vue";
|
|||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from "@/api/platform";
|
} from "@/api/platform";
|
||||||
const items = ref<any[]>([]),
|
const items = ref<any[]>([]),
|
||||||
devices = ref<any[]>([]),
|
devices = ref<any[]>([]),
|
||||||
|
loading = ref(false),
|
||||||
keyword = ref(""),
|
keyword = ref(""),
|
||||||
show = ref(false),
|
show = ref(false),
|
||||||
editing = ref<string | null>(null);
|
editing = ref<string | null>(null);
|
||||||
@@ -52,33 +54,45 @@ async function loadGroups() {
|
|||||||
const r: any = await collectionApi.groups();
|
const r: any = await collectionApi.groups();
|
||||||
groups.value = r.data.groups;
|
groups.value = r.data.groups;
|
||||||
}
|
}
|
||||||
|
async function refresh() {
|
||||||
|
if (loading.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await Promise.all([loadGroups(), load()]);
|
||||||
|
} catch {
|
||||||
|
alert("刷新数据失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
async function addGroup() {
|
async function addGroup() {
|
||||||
const name = prompt("请输入新分组名称")?.trim();
|
const name = prompt("请输入新分组名称")?.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
await collectionApi.createGroup(name);
|
await collectionApi.createGroup(name);
|
||||||
selectedGroup.value = name;
|
selectedGroup.value = name;
|
||||||
await loadGroups();
|
await refresh();
|
||||||
await load();
|
|
||||||
}
|
}
|
||||||
async function editGroup(g: any) {
|
async function editGroup(g: any) {
|
||||||
const name = prompt("修改分组名称", g.name)?.trim();
|
const name = prompt("修改分组名称", g.name)?.trim();
|
||||||
if (!name || name === g.name) return;
|
if (!name || name === g.name) return;
|
||||||
await collectionApi.updateGroup(g.name, name);
|
await collectionApi.updateGroup(g.name, name);
|
||||||
if (selectedGroup.value === g.name) selectedGroup.value = name;
|
if (selectedGroup.value === g.name) selectedGroup.value = name;
|
||||||
await loadGroups();
|
await refresh();
|
||||||
await load();
|
|
||||||
}
|
}
|
||||||
async function deleteGroup(g: any) {
|
async function deleteGroup(g: any) {
|
||||||
if (g.name === "default") {
|
if (g.name === "default") {
|
||||||
alert("default 分组不可删除");
|
alert("default 分组不可删除");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm(`删除分组“${g.name}”后,里面的采集点也会跟着删除,是否继续?`))
|
if (
|
||||||
|
!confirm(
|
||||||
|
`删除分组“${g.name}”后,里面的采集点和写入点会自动转移到 default 分组,是否继续?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
await collectionApi.removeGroup(g.name);
|
await collectionApi.removeGroup(g.name);
|
||||||
if (selectedGroup.value === g.name) selectedGroup.value = "";
|
if (selectedGroup.value === g.name) selectedGroup.value = "";
|
||||||
await loadGroups();
|
await refresh();
|
||||||
await load();
|
|
||||||
}
|
}
|
||||||
function open(p?: any) {
|
function open(p?: any) {
|
||||||
editing.value = p?.id ?? null;
|
editing.value = p?.id ?? null;
|
||||||
@@ -102,16 +116,24 @@ function open(p?: any) {
|
|||||||
show.value = true;
|
show.value = true;
|
||||||
}
|
}
|
||||||
async function save() {
|
async function save() {
|
||||||
editing.value
|
try {
|
||||||
? await collectionApi.update(editing.value, form)
|
editing.value
|
||||||
: await collectionApi.create(form);
|
? await collectionApi.update(editing.value, form)
|
||||||
show.value = false;
|
: await collectionApi.create(form);
|
||||||
await load();
|
show.value = false;
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "保存采集点失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function remove(id: string) {
|
async function remove(id: string) {
|
||||||
if (confirm("确认删除该采集点?")) {
|
if (confirm("确认删除该采集点?")) {
|
||||||
await collectionApi.remove(id);
|
try {
|
||||||
await load();
|
await collectionApi.remove(id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "删除采集点失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function upload(e: Event) {
|
async function upload(e: Event) {
|
||||||
@@ -121,14 +143,18 @@ async function upload(e: Event) {
|
|||||||
alert(
|
alert(
|
||||||
`导入完成:新增${r.data.created},更新${r.data.updated},失败${r.data.failed}`,
|
`导入完成:新增${r.data.created},更新${r.data.updated},失败${r.data.failed}`,
|
||||||
);
|
);
|
||||||
await load();
|
await refresh();
|
||||||
}
|
}
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const r: any = await deviceApi.list({ page_size: 100 });
|
const r: any = await deviceApi.list({ page_size: 100 });
|
||||||
devices.value = r.data.items;
|
devices.value = r.data.items;
|
||||||
await loadGroups();
|
await refresh();
|
||||||
await load();
|
|
||||||
});
|
});
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
return value
|
||||||
|
? new Date(value).toLocaleString("zh-CN", { hour12: false })
|
||||||
|
: "—";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
@@ -194,6 +220,9 @@ onMounted(async () => {
|
|||||||
<button class="btn" @click="exportConfig('collection-points')">
|
<button class="btn" @click="exportConfig('collection-points')">
|
||||||
<Download />导出CSV
|
<Download />导出CSV
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn" :disabled="loading" @click="refresh">
|
||||||
|
<RefreshCw :class="loading && 'spin'" />刷新数据
|
||||||
|
</button>
|
||||||
<button class="btn btn-primary" @click="open()">
|
<button class="btn btn-primary" @click="open()">
|
||||||
<Plus />新增采集点
|
<Plus />新增采集点
|
||||||
</button>
|
</button>
|
||||||
@@ -219,6 +248,7 @@ onMounted(async () => {
|
|||||||
<th>历史</th>
|
<th>历史</th>
|
||||||
<th>最新值</th>
|
<th>最新值</th>
|
||||||
<th>质量</th>
|
<th>质量</th>
|
||||||
|
<th>更新时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -245,6 +275,7 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
{{ p.latest_value?.quality ?? "none" }}
|
{{ p.latest_value?.quality ?? "none" }}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ formatTime(p.latest_value?.ts) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn" @click="open(p)">编辑</button>
|
<button class="btn" @click="open(p)">编辑</button>
|
||||||
<button class="btn" @click="remove(p.id)">删除</button>
|
<button class="btn" @click="remove(p.id)">删除</button>
|
||||||
@@ -309,16 +340,16 @@ onMounted(async () => {
|
|||||||
min="1"
|
min="1"
|
||||||
max="1440"
|
max="1440"
|
||||||
/></label>
|
/></label>
|
||||||
<label class="field"
|
<label class="check-field">
|
||||||
><span
|
<input v-model="form.enabled" type="checkbox" />
|
||||||
><input v-model="form.enabled" type="checkbox" /> 启用</span
|
<span class="check-box" aria-hidden="true"></span>
|
||||||
></label
|
<span class="check-text">启用采集</span>
|
||||||
><label class="field"
|
</label>
|
||||||
><span
|
<label class="check-field">
|
||||||
><input v-model="form.store_history" type="checkbox" />
|
<input v-model="form.store_history" type="checkbox" />
|
||||||
存储历史</span
|
<span class="check-box" aria-hidden="true"></span>
|
||||||
></label
|
<span class="check-text">存储历史</span>
|
||||||
>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn" @click="show = false">取消</button
|
<button type="button" class="btn" @click="show = false">取消</button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { onMounted, reactive, ref } from "vue";
|
||||||
import { Download, Plus, Search, Upload } from "lucide-vue-next";
|
import { Download, Plus, RefreshCw, Search, Upload } from "lucide-vue-next";
|
||||||
import { deviceApi, exportConfig, importConfig } from "@/api/platform";
|
import { deviceApi, exportConfig, importConfig } from "@/api/platform";
|
||||||
type Device = {
|
type Device = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -76,16 +76,24 @@ async function save() {
|
|||||||
? { rack: form.rack, slot: form.slot }
|
? { rack: form.rack, slot: form.slot }
|
||||||
: { unit_id: 1, float32_order: "ABCD" },
|
: { unit_id: 1, float32_order: "ABCD" },
|
||||||
};
|
};
|
||||||
editing.value
|
try {
|
||||||
? await deviceApi.update(editing.value, data)
|
editing.value
|
||||||
: await deviceApi.create(data);
|
? await deviceApi.update(editing.value, data)
|
||||||
show.value = false;
|
: await deviceApi.create(data);
|
||||||
await load();
|
show.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "保存设备失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function remove(id: string) {
|
async function remove(id: string) {
|
||||||
if (confirm("确认删除?该设备下所有采集点和写入点将同步逻辑删除。")) {
|
if (confirm("确认删除?该设备下所有采集点和写入点将同步逻辑删除。")) {
|
||||||
await deviceApi.remove(id);
|
try {
|
||||||
await load();
|
await deviceApi.remove(id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "删除设备失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
@@ -136,6 +144,9 @@ function formatTime(value?: string) {
|
|||||||
<button class="btn" @click="exportConfig('devices')">
|
<button class="btn" @click="exportConfig('devices')">
|
||||||
<Download />导出CSV
|
<Download />导出CSV
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn" :disabled="loading" @click="load">
|
||||||
|
<RefreshCw :class="loading && 'spin'" />刷新数据
|
||||||
|
</button>
|
||||||
<button class="btn btn-primary" @click="open()">
|
<button class="btn btn-primary" @click="open()">
|
||||||
<Plus />新增设备
|
<Plus />新增设备
|
||||||
</button>
|
</button>
|
||||||
@@ -239,11 +250,11 @@ function formatTime(value?: string) {
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="3600" /></label
|
max="3600" /></label
|
||||||
><label class="field field-wide"
|
><label class="check-field check-field-wide">
|
||||||
><span
|
<input v-model="form.enabled" type="checkbox" />
|
||||||
><input v-model="form.enabled" type="checkbox" /> 启用设备</span
|
<span class="check-box" aria-hidden="true"></span>
|
||||||
></label
|
<span class="check-text">启用设备</span>
|
||||||
>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn" @click="show = false">取消</button
|
<button type="button" class="btn" @click="show = false">取消</button
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,81 +1,159 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { onMounted, reactive, ref } from "vue";
|
||||||
import { Download, Plus, Upload, ShieldAlert } from "lucide-vue-next";
|
|
||||||
import {
|
import {
|
||||||
writePointApi,
|
Download,
|
||||||
|
FolderPlus,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
ShieldAlert,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
import {
|
||||||
|
collectionApi,
|
||||||
deviceApi,
|
deviceApi,
|
||||||
exportConfig,
|
exportConfig,
|
||||||
importConfig,
|
importConfig,
|
||||||
|
writePointApi,
|
||||||
} from "@/api/platform";
|
} from "@/api/platform";
|
||||||
|
|
||||||
const items = ref<any[]>([]),
|
const items = ref<any[]>([]),
|
||||||
logs = ref<any[]>([]),
|
logs = ref<any[]>([]),
|
||||||
tab = ref<"points" | "logs">("points"),
|
tab = ref<"points" | "logs">("points"),
|
||||||
devices = ref<any[]>([]),
|
devices = ref<any[]>([]),
|
||||||
|
groups = ref<any[]>([]),
|
||||||
|
selectedGroup = ref(""),
|
||||||
|
keyword = ref(""),
|
||||||
|
loading = ref(false),
|
||||||
show = ref(false),
|
show = ref(false),
|
||||||
editing = ref<string | null>(null);
|
editing = ref<string | null>(null);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
group_name: "default",
|
group_name: "default",
|
||||||
device_id: "",
|
device_id: "",
|
||||||
enabled: true,
|
|
||||||
write_enabled: false,
|
write_enabled: false,
|
||||||
address: "",
|
address: "",
|
||||||
data_type: "REAL",
|
data_type: "REAL",
|
||||||
unit: "",
|
unit: "",
|
||||||
readback_tolerance: 0.0001,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [a, b]: any = await Promise.all([
|
const [a, b]: any = await Promise.all([
|
||||||
writePointApi.list({ page_size: 100 }),
|
writePointApi.list({
|
||||||
|
keyword: keyword.value,
|
||||||
|
page_size: 100,
|
||||||
|
group_name: selectedGroup.value,
|
||||||
|
}),
|
||||||
writePointApi.logs({ page_size: 100 }),
|
writePointApi.logs({ page_size: 100 }),
|
||||||
]);
|
]);
|
||||||
items.value = a.data.items;
|
items.value = a.data.items;
|
||||||
logs.value = b.data.items;
|
logs.value = b.data.items;
|
||||||
} catch {
|
} catch {
|
||||||
items.value = [];
|
items.value = [];
|
||||||
|
logs.value = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
const r: any = await collectionApi.groups();
|
||||||
|
groups.value = r.data.groups;
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
if (loading.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await Promise.all([loadGroups(), load()]);
|
||||||
|
} catch {
|
||||||
|
alert("刷新数据失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGroup() {
|
||||||
|
const name = prompt("请输入新分组名称")?.trim();
|
||||||
|
if (!name) return;
|
||||||
|
await collectionApi.createGroup(name);
|
||||||
|
selectedGroup.value = name;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editGroup(g: any) {
|
||||||
|
const name = prompt("修改分组名称", g.name)?.trim();
|
||||||
|
if (!name || name === g.name) return;
|
||||||
|
await collectionApi.updateGroup(g.name, name);
|
||||||
|
if (selectedGroup.value === g.name) selectedGroup.value = name;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(g: any) {
|
||||||
|
if (g.name === "default") {
|
||||||
|
alert("default 分组不可删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`删除分组“${g.name}”后,里面的采集点和写入点会自动转移到 default 分组,是否继续?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await collectionApi.removeGroup(g.name);
|
||||||
|
if (selectedGroup.value === g.name) selectedGroup.value = "";
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(p?: any) {
|
||||||
|
editing.value = p?.id ?? null;
|
||||||
|
Object.assign(form, {
|
||||||
|
name: p?.name ?? "",
|
||||||
|
group_name: p?.group_name ?? groups.value[0]?.name ?? "default",
|
||||||
|
device_id: p?.device_id ?? devices.value[0]?.id ?? "",
|
||||||
|
write_enabled: p?.write_enabled ?? false,
|
||||||
|
address: p?.address ?? "",
|
||||||
|
data_type: p?.data_type ?? "REAL",
|
||||||
|
unit: p?.unit ?? "",
|
||||||
|
});
|
||||||
|
show.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
editing.value
|
||||||
|
? await writePointApi.update(editing.value, form)
|
||||||
|
: await writePointApi.create(form);
|
||||||
|
show.value = false;
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "保存写入点失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function execute(p: any) {
|
async function execute(p: any) {
|
||||||
const raw = prompt(`向 ${p.name} (${p.address}) 写入值:`);
|
const raw = prompt(`向 ${p.name} (${p.address}) 写入值:`);
|
||||||
if (raw === null) return;
|
if (raw === null) return;
|
||||||
const value = p.data_type === "BOOL" ? raw === "true" : Number(raw);
|
const value = p.data_type === "BOOL" ? raw === "true" : Number(raw);
|
||||||
const reason = prompt("写入原因(可选):") ?? "";
|
const reason = prompt("写入原因(可选):") ?? "";
|
||||||
await writePointApi.write(p.id, { value, reason });
|
const r: any = await writePointApi.write(p.id, { value, reason });
|
||||||
alert("写入及回读验证成功");
|
const readback = r.data?.readback_value;
|
||||||
await load();
|
alert(`写入成功,回读值:${readback ?? "—"}`);
|
||||||
}
|
await refresh();
|
||||||
function open(p?: any) {
|
|
||||||
editing.value = p?.id ?? null;
|
|
||||||
Object.assign(
|
|
||||||
form,
|
|
||||||
p ?? {
|
|
||||||
name: "",
|
|
||||||
group_name: "default",
|
|
||||||
device_id: devices.value[0]?.id ?? "",
|
|
||||||
enabled: true,
|
|
||||||
write_enabled: false,
|
|
||||||
address: "",
|
|
||||||
data_type: "REAL",
|
|
||||||
unit: "",
|
|
||||||
readback_tolerance: 0.0001,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
show.value = true;
|
|
||||||
}
|
|
||||||
async function save() {
|
|
||||||
editing.value
|
|
||||||
? await writePointApi.update(editing.value, form)
|
|
||||||
: await writePointApi.create(form);
|
|
||||||
show.value = false;
|
|
||||||
await load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: string) {
|
async function remove(id: string) {
|
||||||
if (confirm("确认删除该写入点?")) {
|
if (confirm("确认删除该写入点?")) {
|
||||||
await writePointApi.remove(id);
|
try {
|
||||||
await load();
|
await writePointApi.remove(id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "删除写入点失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload(e: Event) {
|
async function upload(e: Event) {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -83,176 +161,240 @@ async function upload(e: Event) {
|
|||||||
alert(
|
alert(
|
||||||
`导入完成:新增${r.data.created},更新${r.data.updated},失败${r.data.failed}`,
|
`导入完成:新增${r.data.created},更新${r.data.updated},失败${r.data.failed}`,
|
||||||
);
|
);
|
||||||
await load();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const r: any = await deviceApi.list({ page_size: 100 });
|
const deviceResult: any = await deviceApi.list({ page_size: 100 });
|
||||||
devices.value = r.data.items;
|
devices.value = deviceResult.data.items;
|
||||||
await load();
|
await refresh();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<div class="page-head">
|
<div class="collection-layout">
|
||||||
<div>
|
<aside class="group-sidebar panel">
|
||||||
<h2>数据写入</h2>
|
<div class="group-header">
|
||||||
<p>人工写入、回读验证与完整操作审计</p>
|
<span>写入点分组</span>
|
||||||
</div>
|
<button class="icon-btn" title="添加分组" @click="addGroup">
|
||||||
<div class="toolbar">
|
<FolderPlus />
|
||||||
<label class="btn"
|
</button>
|
||||||
><Upload />导入CSV<input
|
</div>
|
||||||
hidden
|
<button
|
||||||
type="file"
|
:class="['group-item', !selectedGroup && 'active']"
|
||||||
accept=".csv"
|
@click="
|
||||||
@change="upload"
|
selectedGroup = '';
|
||||||
/></label>
|
load();
|
||||||
<button class="btn" @click="exportConfig('write-points')">
|
"
|
||||||
<Download />导出CSV
|
>
|
||||||
|
全部分组
|
||||||
|
<span>{{ groups.reduce((sum, g) => sum + g.write_count, 0) }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" @click="open()">
|
<div
|
||||||
<Plus />新增写入点
|
v-for="g in groups"
|
||||||
</button>
|
:key="g.name"
|
||||||
</div>
|
:class="['group-item-wrap', selectedGroup === g.name && 'active']"
|
||||||
</div>
|
>
|
||||||
<div class="toolbar" style="margin-bottom: 12px">
|
<button
|
||||||
<button
|
:class="['group-item', selectedGroup === g.name && 'active']"
|
||||||
:class="['btn', tab === 'points' && 'btn-primary']"
|
@click="
|
||||||
@click="tab = 'points'"
|
selectedGroup = g.name;
|
||||||
>
|
load();
|
||||||
写入点</button
|
"
|
||||||
><button
|
>
|
||||||
:class="['btn', tab === 'logs' && 'btn-primary']"
|
<span>{{ g.name }}</span>
|
||||||
@click="tab = 'logs'"
|
<span>{{ g.write_count }}</span>
|
||||||
>
|
</button>
|
||||||
操作日志</button
|
<span class="group-actions">
|
||||||
><span style="margin-left: auto; color: var(--warn); font-size: 12px"
|
<button class="icon-btn" title="修改分组" @click="editGroup(g)">
|
||||||
><ShieldAlert style="width: 15px; vertical-align: middle" />
|
<Pencil />
|
||||||
写入必须启用并通过回读验证</span
|
</button>
|
||||||
>
|
<button
|
||||||
</div>
|
class="icon-btn danger"
|
||||||
<div class="panel">
|
title="删除分组"
|
||||||
<table v-if="tab === 'points'" class="data-table">
|
@click="deleteGroup(g)"
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>名称</th>
|
|
||||||
<th>分组</th>
|
|
||||||
<th>设备</th>
|
|
||||||
<th>地址 / 类型</th>
|
|
||||||
<th>单位</th>
|
|
||||||
<th>回读容差</th>
|
|
||||||
<th>允许写入</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="p in items" :key="p.id">
|
|
||||||
<td>{{ p.name }}</td>
|
|
||||||
<td>{{ p.group_name }}</td>
|
|
||||||
<td>{{ p.device_name }}</td>
|
|
||||||
<td>
|
|
||||||
{{ p.address }} <span class="tag">{{ p.data_type }}</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ p.unit || "—" }}</td>
|
|
||||||
<td>{{ p.readback_tolerance }}</td>
|
|
||||||
<td :class="p.write_enabled ? 'quality-good' : ''">
|
|
||||||
{{ p.write_enabled ? "已启用" : "已锁定" }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
:disabled="!p.write_enabled"
|
|
||||||
@click="execute(p)"
|
|
||||||
>
|
|
||||||
执行写入
|
|
||||||
</button>
|
|
||||||
<button class="btn" @click="open(p)">编辑</button
|
|
||||||
><button class="btn" @click="remove(p.id)">删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<table v-else class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>时间</th>
|
|
||||||
<th>点位</th>
|
|
||||||
<th>目标值</th>
|
|
||||||
<th>回读值</th>
|
|
||||||
<th>结果</th>
|
|
||||||
<th>原因</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="l in logs" :key="l.id">
|
|
||||||
<td>{{ l.created_at }}</td>
|
|
||||||
<td>{{ l.point_name }}</td>
|
|
||||||
<td>{{ l.target_value }}</td>
|
|
||||||
<td>{{ l.readback_value ?? "—" }}</td>
|
|
||||||
<td
|
|
||||||
:class="l.result === 'success' ? 'quality-good' : 'quality-bad'"
|
|
||||||
>
|
>
|
||||||
{{ l.result }}
|
<Trash2 />
|
||||||
</td>
|
</button>
|
||||||
<td>{{ l.reason || "—" }}</td>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
</aside>
|
||||||
</table>
|
|
||||||
<div v-if="!(tab === 'points' ? items : logs).length" class="empty">
|
<div class="collection-main">
|
||||||
暂无数据
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>数据写入</h2>
|
||||||
|
<p>人工写入、回读数据与完整操作审计</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<label class="btn">
|
||||||
|
<Upload />导入CSV
|
||||||
|
<input hidden type="file" accept=".csv" @change="upload" />
|
||||||
|
</label>
|
||||||
|
<button class="btn" @click="exportConfig('write-points')">
|
||||||
|
<Download />导出CSV
|
||||||
|
</button>
|
||||||
|
<button class="btn" :disabled="loading" @click="refresh">
|
||||||
|
<RefreshCw :class="loading && 'spin'" />刷新数据
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" @click="open()">
|
||||||
|
<Plus />新增写入点
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar" style="margin-bottom: 12px">
|
||||||
|
<input v-model="keyword" class="input" placeholder="搜索名称或分组" />
|
||||||
|
<button class="btn" @click="load"><Search />查询</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar" style="margin-bottom: 12px">
|
||||||
|
<button
|
||||||
|
:class="['btn', tab === 'points' && 'btn-primary']"
|
||||||
|
@click="tab = 'points'"
|
||||||
|
>
|
||||||
|
写入点
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="['btn', tab === 'logs' && 'btn-primary']"
|
||||||
|
@click="tab = 'logs'"
|
||||||
|
>
|
||||||
|
操作日志
|
||||||
|
</button>
|
||||||
|
<span style="margin-left: auto; color: var(--warn); font-size: 12px">
|
||||||
|
<ShieldAlert style="width: 15px; vertical-align: middle" />
|
||||||
|
写入必须允许写入并完成回读验证
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<table v-if="tab === 'points'" class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>分组</th>
|
||||||
|
<th>所属设备</th>
|
||||||
|
<th>地址</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>单位</th>
|
||||||
|
<th>回读数据</th>
|
||||||
|
<th>允许写入</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="p in items" :key="p.id">
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td>{{ p.group_name }}</td>
|
||||||
|
<td>{{ p.device_name }}</td>
|
||||||
|
<td>{{ p.address }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="tag">{{ p.data_type }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ p.unit || "—" }}</td>
|
||||||
|
<td>{{ p.readback_value ?? "—" }}</td>
|
||||||
|
<td :class="p.write_enabled ? 'quality-good' : 'quality-bad'">
|
||||||
|
{{ p.write_enabled ? "允许" : "禁止" }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:disabled="!p.write_enabled"
|
||||||
|
@click="execute(p)"
|
||||||
|
>
|
||||||
|
执行写入
|
||||||
|
</button>
|
||||||
|
<button class="btn" @click="open(p)">编辑</button>
|
||||||
|
<button class="btn" @click="remove(p.id)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table v-else class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>点位</th>
|
||||||
|
<th>目标值</th>
|
||||||
|
<th>回读值</th>
|
||||||
|
<th>结果</th>
|
||||||
|
<th>原因</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="l in logs" :key="l.id">
|
||||||
|
<td>{{ l.created_at }}</td>
|
||||||
|
<td>{{ l.point_name }}</td>
|
||||||
|
<td>{{ l.target_value }}</td>
|
||||||
|
<td>{{ l.readback_value ?? "—" }}</td>
|
||||||
|
<td
|
||||||
|
:class="
|
||||||
|
l.result === 'success' ? 'quality-good' : 'quality-bad'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ l.result }}
|
||||||
|
</td>
|
||||||
|
<td>{{ l.reason || "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="!(tab === 'points' ? items : logs).length" class="empty">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="show" class="modal-mask">
|
<div v-if="show" class="modal-mask">
|
||||||
<form class="modal" @submit.prevent="save">
|
<form class="modal" @submit.prevent="save">
|
||||||
<h3>{{ editing ? "编辑写入点" : "新增写入点" }}</h3>
|
<h3>{{ editing ? "编辑写入点" : "新增写入点" }}</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label class="field"
|
<label class="field">
|
||||||
>名称<input v-model="form.name" class="input" required /></label
|
名称<input v-model="form.name" class="input" required />
|
||||||
><label class="field"
|
</label>
|
||||||
>分组<input v-model="form.group_name" class="input" required
|
<label class="field">
|
||||||
/></label>
|
分组<select v-model="form.group_name" class="select" required>
|
||||||
<label class="field"
|
<option v-for="g in groups" :key="g.name" :value="g.name">
|
||||||
>设备<select v-model="form.device_id" class="select" required>
|
{{ g.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
设备<select v-model="form.device_id" class="select" required>
|
||||||
<option v-for="d in devices" :key="d.id" :value="d.id">
|
<option v-for="d in devices" :key="d.id" :value="d.id">
|
||||||
{{ d.name }}
|
{{ d.name }}
|
||||||
</option>
|
</option>
|
||||||
</select></label
|
</select>
|
||||||
><label class="field"
|
</label>
|
||||||
>数据类型<select v-model="form.data_type" class="select">
|
<label class="field">
|
||||||
|
数据类型<select v-model="form.data_type" class="select">
|
||||||
<option>BOOL</option>
|
<option>BOOL</option>
|
||||||
<option>INT</option>
|
<option>INT</option>
|
||||||
<option>REAL</option>
|
<option>REAL</option>
|
||||||
</select></label
|
</select>
|
||||||
>
|
</label>
|
||||||
<label class="field"
|
<label class="field">
|
||||||
>地址<input
|
地址<input
|
||||||
v-model="form.address"
|
v-model="form.address"
|
||||||
class="input"
|
class="input"
|
||||||
required
|
required
|
||||||
placeholder="MD540" /></label
|
placeholder="MD540"
|
||||||
><label class="field"
|
/>
|
||||||
>单位<input v-model="form.unit" class="input" /></label
|
</label>
|
||||||
><label class="field"
|
<label class="field">
|
||||||
>回读容差<input
|
单位<input v-model="form.unit" class="input" />
|
||||||
v-model.number="form.readback_tolerance"
|
</label>
|
||||||
class="input"
|
<label class="check-field check-field-wide">
|
||||||
type="number"
|
<input v-model="form.write_enabled" type="checkbox" />
|
||||||
min="0"
|
<span class="check-box" aria-hidden="true"></span>
|
||||||
step="any"
|
<span class="check-text">允许写入</span>
|
||||||
/></label>
|
</label>
|
||||||
<label class="field"
|
|
||||||
><span
|
|
||||||
><input v-model="form.enabled" type="checkbox" /> 启用</span
|
|
||||||
></label
|
|
||||||
><label class="field"
|
|
||||||
><span
|
|
||||||
><input v-model="form.write_enabled" type="checkbox" />
|
|
||||||
允许写入</span
|
|
||||||
></label
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn" @click="show = false">取消</button
|
<button type="button" class="btn" @click="show = false">取消</button>
|
||||||
><button class="btn btn-primary">保存</button>
|
<button class="btn btn-primary">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user