From 547dfdd5f44147adbc9960806be3ca721e3e8ec0 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Tue, 3 Mar 2026 16:56:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(aiot):=20=E6=88=AA=E5=9B=BE=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20+=20ROI=20=E6=98=BE=E7=A4=BA=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20=E5=91=8A=E8=AD=A6=E5=9B=BE=E7=89=87=E4=BB=A3?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 截图策略改为截一次持久化到 DB,打开 ROI 页面不再触发 Edge 截图: - 新增 wvp_ai_camera_snapshot 表存储 cos_key - AiScreenshotServiceImpl 回调时持久化 cos_key,图片代理增加 DB 回退 - 前端 getSnapUrl 直接返回代理 URL,force=true 才触发 Edge RoiCanvas 修复: - ResizeObserver 替代 nextTick 初始化 canvas - 图片加载失败时仍初始化 canvas 以显示 ROI 告警图片代理: - AiAlertController 新增 /image 端点通过 presign URL 代理 COS 图片 - 新增告警列表前端页面 alertList Co-Authored-By: Claude Opus 4.6 --- sql/wvp_ai_camera_snapshot.sql | 8 + .../aiot/controller/AiAlertController.java | 15 ++ .../vmp/aiot/controller/AiRoiController.java | 2 +- .../vmp/aiot/dao/AiCameraSnapshotMapper.java | 15 ++ .../iot/vmp/aiot/service/IAiAlertService.java | 2 + .../aiot/service/impl/AiAlertServiceImpl.java | 26 +++ .../service/impl/AiScreenshotServiceImpl.java | 75 ++++++-- .../vmp/conf/security/WebSecurityConfig.java | 1 + src/main/resources/application-dev.yml | 2 + src/main/resources/application-docker.yml | 2 + web/src/api/aiAlert.js | 51 ++++++ web/src/api/aiRoi.js | 25 ++- web/src/router/index.js | 11 ++ web/src/views/alertList/index.vue | 166 ++++++++++++++++++ web/src/views/cameraConfig/roiConfig.vue | 4 +- .../views/roiConfig/components/RoiCanvas.vue | 19 +- 16 files changed, 399 insertions(+), 25 deletions(-) create mode 100644 sql/wvp_ai_camera_snapshot.sql create mode 100644 src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java create mode 100644 web/src/api/aiAlert.js create mode 100644 web/src/views/alertList/index.vue diff --git a/sql/wvp_ai_camera_snapshot.sql b/sql/wvp_ai_camera_snapshot.sql new file mode 100644 index 000000000..932782aeb --- /dev/null +++ b/sql/wvp_ai_camera_snapshot.sql @@ -0,0 +1,8 @@ +-- Screenshot persistence table for storing COS object keys +-- Run this SQL on the WVP database before deploying the new version + +CREATE TABLE IF NOT EXISTS wvp_ai_camera_snapshot ( + camera_code VARCHAR(64) PRIMARY KEY, + cos_key VARCHAR(512) NOT NULL COMMENT 'COS 对象键', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java index 7b6d8c0ad..94ef66ad9 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java @@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -60,6 +62,19 @@ public class AiAlertController { return alertService.statistics(startTime); } + @Operation(summary = "告警图片代理") + @GetMapping("/image") + public ResponseEntity getAlertImage(@RequestParam String imagePath) { + byte[] image = alertService.proxyAlertImage(imagePath); + if (image == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG) + .header("Cache-Control", "public, max-age=3600") + .body(image); + } + // ==================== Edge 告警上报 ==================== @Operation(summary = "Edge 告警上报") diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java index 7c5c60f86..aa9b29ac0 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java @@ -124,7 +124,7 @@ public class AiRoiController { } return ResponseEntity.ok() .contentType(MediaType.IMAGE_JPEG) - .header("Cache-Control", "max-age=60") + .header("Cache-Control", "public, max-age=300") .body(image); } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java new file mode 100644 index 000000000..44a789a50 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java @@ -0,0 +1,15 @@ +package com.genersoft.iot.vmp.aiot.dao; + +import org.apache.ibatis.annotations.*; + +@Mapper +public interface AiCameraSnapshotMapper { + + @Select("SELECT cos_key FROM wvp_ai_camera_snapshot WHERE camera_code=#{cameraCode}") + String getCosKey(@Param("cameraCode") String cameraCode); + + @Insert("INSERT INTO wvp_ai_camera_snapshot (camera_code, cos_key) " + + "VALUES (#{cameraCode}, #{cosKey}) " + + "ON DUPLICATE KEY UPDATE cos_key=#{cosKey}, updated_at=NOW()") + int upsert(@Param("cameraCode") String cameraCode, @Param("cosKey") String cosKey); +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java index d257b18dd..4a26416f3 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java @@ -21,4 +21,6 @@ public interface IAiAlertService { Map statistics(String startTime); void updateDuration(String alertId, double durationMinutes); + + byte[] proxyAlertImage(String imagePath); } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java index 9cb3972a2..14ef8415e 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java @@ -1,5 +1,7 @@ package com.genersoft.iot.vmp.aiot.service.impl; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; import com.genersoft.iot.vmp.aiot.bean.AiAlert; import com.genersoft.iot.vmp.aiot.dao.AiAlertMapper; import com.genersoft.iot.vmp.aiot.service.IAiAlertService; @@ -7,8 +9,13 @@ import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -20,6 +27,9 @@ public class AiAlertServiceImpl implements IAiAlertService { @Autowired private AiAlertMapper alertMapper; + @Value("${ai.alert.presign-url:http://127.0.0.1:8000/admin-api/aiot/storage/presign}") + private String presignUrl; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Override @@ -72,4 +82,20 @@ public class AiAlertServiceImpl implements IAiAlertService { public void updateDuration(String alertId, double durationMinutes) { alertMapper.updateDuration(alertId, durationMinutes); } + + @Override + public byte[] proxyAlertImage(String imagePath) { + try { + RestTemplate rest = new RestTemplate(); + String endpoint = presignUrl + "?objectKey=" + + URLEncoder.encode(imagePath, StandardCharsets.UTF_8); + String json = rest.getForObject(endpoint, String.class); + JSONObject resp = JSON.parseObject(json); + String cosUrl = resp.getJSONObject("data").getString("url"); + return rest.getForObject(URI.create(cosUrl), byte[].class); + } catch (Exception e) { + log.error("[AiAlert] 代理告警图片失败: path={}, error={}", imagePath, e.getMessage()); + return null; + } + } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java index 13036713f..4e1808b2f 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java @@ -2,6 +2,7 @@ package com.genersoft.iot.vmp.aiot.service.impl; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; +import com.genersoft.iot.vmp.aiot.dao.AiCameraSnapshotMapper; import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService; import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy; import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper; @@ -16,6 +17,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; @@ -41,15 +44,24 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { /** 等待 Edge 回调的 pending 请求表 */ private final ConcurrentHashMap>> pendingRequests = new ConcurrentHashMap<>(); + /** requestId → cosPath 映射,用于回调时持久化 cos_key */ + private final ConcurrentHashMap pendingCosKeys = new ConcurrentHashMap<>(); + @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private StreamProxyMapper streamProxyMapper; + @Autowired + private AiCameraSnapshotMapper snapshotMapper; + @Value("${ai.screenshot.callback-url:}") private String callbackUrl; + @Value("${ai.alert.presign-url:http://127.0.0.1:8000/admin-api/aiot/storage/presign}") + private String presignUrl; + @Override public Map requestScreenshot(String cameraCode, boolean force) { Map result = new HashMap<>(); @@ -100,6 +112,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { try { MapRecord record = MapRecord.create(SNAP_REQUEST_STREAM, fields); RecordId recordId = stringRedisTemplate.opsForStream().add(record); + pendingCosKeys.put(requestId, cosPath); log.info("[AI截图] 发送截图请求: requestId={}, cameraCode={}, streamId={}", requestId, cameraCode, recordId); } catch (Exception e) { log.error("[AI截图] 发送截图请求失败: {}", e.getMessage()); @@ -166,6 +179,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { return result; } finally { pendingRequests.remove(requestId); + pendingCosKeys.remove(requestId); } } @@ -182,6 +196,19 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { if ("ok".equals(status) && cameraCode != null) { String url = (String) data.get("url"); writeCache(cameraCode, url); + + // 持久化 cos_key 到数据库 + String cosKey = pendingCosKeys.remove(requestId); + if (cosKey != null) { + try { + snapshotMapper.upsert(cameraCode, cosKey); + log.info("[AI截图] cos_key 已持久化: cameraCode={}, cosKey={}", cameraCode, cosKey); + } catch (Exception e) { + log.warn("[AI截图] cos_key 持久化失败: cameraCode={}, error={}", cameraCode, e.getMessage()); + } + } + } else { + pendingCosKeys.remove(requestId); } CompletableFuture> future = pendingRequests.get(requestId); @@ -218,27 +245,49 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { @Override public byte[] proxyScreenshotImage(String cameraCode) { + // 1. 先查 Redis 缓存(5分钟内的 presigned URL) String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode); - if (cacheJson == null) { - log.warn("[AI截图] 代理图片: 缓存不存在 cameraCode={}", cameraCode); + if (cacheJson != null) { + try { + JSONObject cached = JSON.parseObject(cacheJson); + String cosUrl = cached.getString("url"); + if (cosUrl != null && !cosUrl.isEmpty()) { + RestTemplate rest = new RestTemplate(); + byte[] bytes = rest.getForObject(URI.create(cosUrl), byte[].class); + if (bytes != null) return bytes; + } + } catch (Exception e) { + log.warn("[AI截图] Redis 缓存 URL 下载失败,尝试 DB: {}", e.getMessage()); + } + } + + // 2. 查 DB 持久化的 cos_key(永不过期) + String cosKey = snapshotMapper.getCosKey(cameraCode); + if (cosKey == null) { + log.warn("[AI截图] 代理图片: 无缓存也无持久化记录 cameraCode={}", cameraCode); return null; } + // 3. 调 FastAPI 生成新的 presigned URL try { - JSONObject cached = JSON.parseObject(cacheJson); - String cosUrl = cached.getString("url"); - if (cosUrl == null || cosUrl.isEmpty()) { - log.warn("[AI截图] 代理图片: 缓存中无 URL cameraCode={}", cameraCode); - return null; - } + RestTemplate rest = new RestTemplate(); + String endpoint = presignUrl + "?objectKey=" + + URLEncoder.encode(cosKey, StandardCharsets.UTF_8); + String json = rest.getForObject(endpoint, String.class); + JSONObject resp = JSON.parseObject(json); + String cosUrl = resp.getJSONObject("data").getString("url"); - // 使用 URI.create 避免 RestTemplate 对已编码的预签名 URL 做二次编码 - RestTemplate restTemplate = new RestTemplate(); - byte[] imageBytes = restTemplate.getForObject(URI.create(cosUrl), byte[].class); - log.debug("[AI截图] 代理图片成功: cameraCode={}, size={}", cameraCode, imageBytes != null ? imageBytes.length : 0); + // 4. 下载并返回图片 + byte[] imageBytes = rest.getForObject(URI.create(cosUrl), byte[].class); + + // 5. 更新 Redis 缓存(加速后续请求) + if (imageBytes != null) { + writeCache(cameraCode, cosUrl); + } return imageBytes; } catch (Exception e) { - log.error("[AI截图] 代理下载图片失败: cameraCode={}, error={}", cameraCode, e.getMessage()); + log.error("[AI截图] 通过 DB cos_key 下载图片失败: cameraCode={}, cosKey={}, error={}", + cameraCode, cosKey, e.getMessage()); return null; } } diff --git a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java index 5d5208eb5..5de05670e 100644 --- a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java +++ b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java @@ -106,6 +106,7 @@ public class WebSecurityConfig { defaultExcludes.add("/api/ai/roi/snap/image"); defaultExcludes.add("/api/ai/camera/get"); defaultExcludes.add("/api/ai/alert/edge/**"); + defaultExcludes.add("/api/ai/alert/image"); defaultExcludes.add("/api/ai/device/edge/**"); if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 59d170601..57b4118aa 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -124,6 +124,8 @@ ai: screenshot: # Edge截图回调地址(WVP外部可访问地址,Edge通过此地址回调截图结果) callback-url: http://124.221.55.225:18080 + alert: + presign-url: http://127.0.0.1:8000/admin-api/aiot/storage/presign mqtt: # MQTT推送开关 enabled: false diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 4a24fa674..7d390fbcf 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -86,6 +86,8 @@ ai: enabled: ${AI_SERVICE_ENABLED:false} screenshot: callback-url: ${AI_SCREENSHOT_CALLBACK_URL:} + alert: + presign-url: ${AI_ALERT_PRESIGN_URL:http://vsp-service:8000/admin-api/aiot/storage/presign} mqtt: enabled: ${AI_MQTT_ENABLED:false} broker: ${AI_MQTT_BROKER:tcp://127.0.0.1:1883} diff --git a/web/src/api/aiAlert.js b/web/src/api/aiAlert.js new file mode 100644 index 000000000..909cfe0d2 --- /dev/null +++ b/web/src/api/aiAlert.js @@ -0,0 +1,51 @@ +import request from '@/utils/request' + +export function queryAlertList(params) { + const { page, count, cameraId, alertType, startTime, endTime } = params + return request({ + method: 'get', + url: '/api/ai/alert/list', + params: { page, count, cameraId, alertType, startTime, endTime } + }) +} + +export function queryAlertDetail(alertId) { + return request({ + method: 'get', + url: `/api/ai/alert/${alertId}` + }) +} + +export function deleteAlert(alertId) { + return request({ + method: 'delete', + url: '/api/ai/alert/delete', + params: { alertId } + }) +} + +export function deleteAlertBatch(alertIds) { + return request({ + method: 'delete', + url: '/api/ai/alert/delete', + params: { alertIds } + }) +} + +export function queryAlertStatistics(startTime) { + return request({ + method: 'get', + url: '/api/ai/alert/statistics', + params: { startTime } + }) +} + +/** + * 构建告警图片代理 URL + * @param {string} imagePath COS 对象键(image_path) + * @returns {string} 代理图片 URL + */ +export function getAlertImageUrl(imagePath) { + if (!imagePath) return '' + return '/api/ai/alert/image?imagePath=' + encodeURIComponent(imagePath) +} diff --git a/web/src/api/aiRoi.js b/web/src/api/aiRoi.js index 6898efdf2..5531f3772 100644 --- a/web/src/api/aiRoi.js +++ b/web/src/api/aiRoi.js @@ -63,10 +63,27 @@ export function updateAlgoParams(data) { }) } +/** + * 构建截图代理 URL。 + * 指向 /api/ai/roi/snap/image 代理端点,优先从 DB 读持久化 COS key。 + * force=true 时先触发 Edge 截新图并更新 DB。 + */ export function getSnapUrl(cameraCode, force = false) { - return request({ - method: 'get', - url: '/api/ai/roi/snap', - params: { cameraCode, force } + if (force) { + // force 时先触发一次截图请求(确保 Edge 截新图并更新 DB) + return request({ + method: 'get', + url: '/api/ai/roi/snap', + params: { cameraCode, force: true } + }).then(() => { + return { data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() } } + }).catch(() => { + // 截图请求可能超时,但 DB 会被更新,仍返回代理 URL + return { data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() } } + }) + } + // 非 force:直接返回代理 URL(从 DB 读已有截图,不触发 Edge) + return Promise.resolve({ + data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) } }) } diff --git a/web/src/router/index.js b/web/src/router/index.js index 6a8eee5f3..90696b7af 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -233,6 +233,17 @@ export const constantRoutes = [ } ] }, + { + path: '/alertList', + component: Layout, + redirect: '/alertList', + children: [{ + path: '', + name: 'AlertList', + component: () => import('@/views/alertList/index'), + meta: { title: '告警记录', icon: 'el-icon-warning' } + }] + }, { path: '/cameraConfig', component: Layout, diff --git a/web/src/views/alertList/index.vue b/web/src/views/alertList/index.vue new file mode 100644 index 000000000..20087b3ef --- /dev/null +++ b/web/src/views/alertList/index.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/web/src/views/cameraConfig/roiConfig.vue b/web/src/views/cameraConfig/roiConfig.vue index 48bc24e3d..d689682d7 100644 --- a/web/src/views/cameraConfig/roiConfig.vue +++ b/web/src/views/cameraConfig/roiConfig.vue @@ -153,9 +153,7 @@ export default { getSnapUrl(this.cameraId, force).then(res => { const data = res.data || res if (data.status === 'ok' && data.url) { - // 添加时间戳防止浏览器缓存旧截图 - const url = data.url - this.snapUrl = url + (url.includes('?') ? '&' : '?') + '_t=' + Date.now() + this.snapUrl = data.url if (data.stale) { this.$message.warning('截图为缓存数据,边缘设备可能离线') } diff --git a/web/src/views/roiConfig/components/RoiCanvas.vue b/web/src/views/roiConfig/components/RoiCanvas.vue index 2bece4a42..6b01b1e3f 100644 --- a/web/src/views/roiConfig/components/RoiCanvas.vue +++ b/web/src/views/roiConfig/components/RoiCanvas.vue @@ -57,16 +57,26 @@ export default { snapUrl() { this.loading = true this.errorMsg = '' + this.$nextTick(() => this.initCanvas()) } }, mounted() { - this.$nextTick(() => { - this.initCanvas() - window.addEventListener('resize', this.handleResize) - }) + if (this.$refs.wrapper) { + this._resizeObserver = new ResizeObserver(() => { + if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) { + this.initCanvas() + } + }) + this._resizeObserver.observe(this.$refs.wrapper) + } + window.addEventListener('resize', this.handleResize) }, beforeDestroy() { window.removeEventListener('resize', this.handleResize) + if (this._resizeObserver) { + this._resizeObserver.disconnect() + this._resizeObserver = null + } }, methods: { onImageLoad() { @@ -78,6 +88,7 @@ export default { onImageError() { this.loading = false this.errorMsg = '截图加载失败,请确认摄像头正在拉流' + this.$nextTick(() => this.initCanvas()) }, initCanvas() { const canvas = this.$refs.canvas