瀏覽代碼

feat(generateServer): 添加拍照接口支持

- 扩展 generateServer 添加 /take_photo 接口
- 实现与拍摄页相同的拍照流程处理逻辑
- 添加详细的拍照接口文档说明
- 重构摄影页面的拍照触发机制
- 增强 IPC 事件监听管理避免内存泄漏
panqiuyao 3 周之前
父節點
當前提交
99daeb608e

+ 0 - 0
generateServer.API.md → frontend/docs/generateServer.API.md


+ 214 - 0
frontend/docs/takePhoto.API.md

@@ -0,0 +1,214 @@
+# 拍照接口文档
+
+## 接口概述
+
+在拍照机客户端本机触发一次拍摄。调用效果与拍摄页操作一致:先填入货号,再按左脚或右脚启动对应拍摄程序。
+
+接口会等待这只脚的程序执行结束再返回。成功只表示程序已跑完,响应里不包含图片文件或 base64。照片仍按客户端原有流程落盘。
+
+**运行环境**: 拍照机客户端(Electron)已启动。接口跑在客户端内部,调用方访问本机 `localhost`。
+
+---
+
+## 接口信息
+
+### 基本信息
+
+- **接口地址**: `http://localhost:3001/take_photo`
+- **请求方法**: `POST`
+- **Content-Type**: `application/json`
+- **超时建议**: 客户端超时请大于单次拍摄时长。接口最长等待 10 分钟,超时返回失败。
+
+### 接口路径
+
+```
+POST /take_photo
+```
+
+同一端口上的 `POST /generate` 仍是图片生成接口,互不影响。同一时间只能有一个拍照请求在执行。
+
+---
+
+## 请求参数
+
+### 请求体结构
+
+```json
+{
+  "goods_art_no": "A596351",
+  "foot": "left"
+}
+```
+
+### 参数说明
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| goods_art_no | string | 是 | 货号。也可用字段名 `货号` 或 `goodsArtNo` |
+| foot | string | 是 | 左右脚。也可用字段名 `左右脚`、`side`、`type` |
+
+`foot` 取值:
+
+| 传值 | 实际动作 |
+|------|----------|
+| `left`、`l`、`0`、`左`、`左脚` | 执行左脚程序 |
+| `right`、`r`、`1`、`右`、`右脚` | 执行右脚程序 |
+
+货号前后空格会被去掉。未识别的左右脚会直接失败。
+
+---
+
+## 响应格式
+
+业务结果看 JSON 里的 `code`。`code` 不为 `0` 时,HTTP 状态码为 `500`,响应体仍是 JSON。
+
+### 成功响应
+
+**HTTP 状态码**: `200`
+
+```json
+{
+  "code": 0,
+  "msg": "货号:A596351,执行完成",
+  "data": {
+    "goods_art_no": "A596351",
+    "foot": "left",
+    "action": "执行左脚程序"
+  }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| code | number | `0` 表示这只脚的拍摄程序已执行完成 |
+| msg | string | 设备返回的完成说明 |
+| data.goods_art_no | string | 本次货号 |
+| data.foot | string | `left` 或 `right` |
+| data.action | string | `执行左脚程序` 或 `执行右脚程序` |
+
+### 失败响应
+
+**HTTP 状态码**: `500`
+
+```json
+{
+  "code": 1,
+  "msg": "货号不能为空"
+}
+```
+
+参数错误、设备忙、拍摄失败都是这个结构。已经开始拍摄后失败时,可能带上 `data`(货号、左右脚、动作名)。
+
+常见 `msg`:
+
+| msg | 说明 |
+|-----|------|
+| 货号不能为空 | 未传货号 |
+| 左右脚不能为空,请传 left/right 或 左/右 | 未传或无法识别左右脚 |
+| 拍摄程序正在运行,请稍候 | 已有拍摄在进行,不要并发调用 |
+| 拍照接口仅在客户端内可用 | 客户端未启动,或不是在客户端里访问 |
+| 设备服务未连接 | 8 秒内连不上本机拍摄服务 |
+| mcu设备未连接,请先连接设备 | 拍摄设备未连接 |
+| mcu设备未初始化 | 设备未完成初始化 |
+| 当前有未完成的任务,请稍后再试 | 设备上还有未结束的动作 |
+| 货号[xxx]已存在,请勿重复拍摄~ | 该货号已有拍摄记录,需先在客户端删除后再拍 |
+| 当前没有可用配置 | 对应左脚或右脚没有可用拍摄配置 |
+| 拍照超时 | 超过 10 分钟仍未结束 |
+
+`msg` 还可能是设备返回的具体步骤失败原因,以实际返回为准。
+
+### 其他错误
+
+| HTTP 状态码 | 说明 | 响应体 |
+|------------|------|--------|
+| 404 | 路径或方法不对 | `Not Found`(纯文本) |
+| 413 | 请求体过大 | `Payload Too Large`(纯文本) |
+
+---
+
+## 调用示例
+
+### cURL
+
+左脚:
+
+```bash
+curl -X POST http://localhost:3001/take_photo \
+  -H "Content-Type: application/json" \
+  -d "{\"goods_art_no\":\"A596351\",\"foot\":\"left\"}"
+```
+
+右脚(中文字段):
+
+```bash
+curl -X POST http://localhost:3001/take_photo \
+  -H "Content-Type: application/json" \
+  -d "{\"货号\":\"A596351\",\"左右脚\":\"右\"}"
+```
+
+请把调用超时调到 10 分钟以上。`curl` 可加 `--max-time 600`。
+
+### Python
+
+```python
+import requests
+
+url = "http://localhost:3001/take_photo"
+payload = {
+    "goods_art_no": "A596351",
+    "foot": "left",  # 右脚传 "right"
+}
+
+# 失败时 HTTP 状态码是 500,但仍返回 JSON,不要用 raise_for_status() 丢掉响应体
+response = requests.post(url, json=payload, timeout=600)
+result = response.json()
+
+if result.get("code") == 0:
+    print("拍摄完成:", result.get("msg"))
+    print(result.get("data"))
+else:
+    print("拍摄失败:", result.get("msg"))
+```
+
+### JavaScript
+
+```javascript
+const response = await fetch("http://localhost:3001/take_photo", {
+  method: "POST",
+  headers: { "Content-Type": "application/json" },
+  body: JSON.stringify({
+    goods_art_no: "A596351",
+    foot: "right",
+  }),
+});
+
+const result = await response.json();
+if (result.code === 0) {
+  console.log("拍摄完成", result.data);
+} else {
+  console.error("拍摄失败", result.msg);
+}
+```
+
+---
+
+## 调用前检查
+
+1. 拍照机客户端已打开,本机可以访问 `http://localhost:3001`。
+2. 拍摄设备已连接并完成初始化。
+3. 左脚、右脚拍摄配置已在客户端配好。
+4. 该货号当前没有未删除的拍摄记录。重复货号会被拒绝。
+5. 上一次拍摄已经结束。接口不支持并发,进行中再次调用会返回「拍摄程序正在运行,请稍候」。
+6. 调用方与客户端在同一台电脑上。这是本机接口,不对外网开放。
+
+拍摄页处于打开状态时,输入框会显示本次货号,界面进入与点击左右脚相同的拍摄状态。
+
+---
+
+## 注意事项
+
+1. 一次请求只拍一只脚。左脚、右脚需要分两次调用。
+2. 请求在程序结束前不会返回,请勿使用默认的短超时。
+3. 以响应 JSON 的 `code` 判断成败。`code === 0` 为成功,其余为失败。
+4. 接口不返回照片内容。拍完后的文件位置与客户端拍摄页一致。
+5. 中途在客户端点停止,接口会以失败返回,`msg` 为终止说明。

+ 21 - 7
frontend/src/utils/generateServer.ts

@@ -1,12 +1,15 @@
 /**
- * Lightweight HTTP bridge for image generation.
- * Listens on port 3001 (POST /generate) and returns base64 slices.
+ * Lightweight HTTP bridge.
+ * Listens on port 3001:
+ * - POST /generate 生成图片
+ * - POST /take_photo 按拍摄页左右脚流程拍照
  * Runs only when Node integration is available (Electron renderer).
  */
 
 // @ts-ignore - 路径别名在构建时会被正确解析
 import { generateImagesBase64 } from '@/views/components/marketingEdit/generateImagesRender'
 import pinia from "@/stores/index";
+import { requestTakePhoto } from '@/utils/takePhotoApi'
 
 // @ts-ignore - Node.js require 在Electron环境中可用
 type NodeRequire = typeof require
@@ -25,19 +28,23 @@ export async function startGenerateServer( ) {
     const http = nodeRequire('http')
 
   const server = http.createServer(async (req: any, res: any) => {
-    // 仅支持 POST /generate
-    if (req.method !== 'POST' || req.url !== '/generate') {
+    const pathname = String(req.url || '').split('?')[0]
+    const isGenerate = pathname === '/generate'
+    const isTakePhoto = pathname === '/take_photo'
+    if (req.method !== 'POST' || (!isGenerate && !isTakePhoto)) {
       res.statusCode = 404
       res.end('Not Found')
       return
     }
 
     let body = ''
+    let tooLarge = false
     req.on('data', (chunk: any) => {
       body += chunk
 
       // 简单的大小保护(500MB)
       if (body.length > 500 * 1024 * 1024) {
+        tooLarge = true
         res.statusCode = 413
         res.end('Payload Too Large')
         req.destroy()
@@ -45,23 +52,30 @@ export async function startGenerateServer( ) {
     })
 
     req.on('end', async () => {
-      res.setHeader('Content-Type', 'application/json')
+      if (tooLarge) return
+      res.setHeader('Content-Type', 'application/json; charset=utf-8')
       try {
         const payload = body ? JSON.parse(body) : {}
+        if (isTakePhoto) {
+          const result = await requestTakePhoto(payload)
+          if (result.code !== 0) res.statusCode = 500
+          res.end(JSON.stringify(result))
+          return
+        }
         const canvasList = payload.canvasList || []
         const goodsList = payload.goodsList || []
         const { images, plans } = await generateImagesBase64(canvasList, goodsList)
         res.end(JSON.stringify({ code: 0, images, plans }))
       } catch (e: any) {
         res.statusCode = 500
-        res.end(JSON.stringify({ code: 1, msg: e?.message || 'generate failed' }))
+        res.end(JSON.stringify({ code: 1, msg: e?.message || (isTakePhoto ? '拍照失败' : 'generate failed') }))
       }
     })
   })
 
   // 尝试启动服务器,如果端口被占用会自动失败
   server.listen(PORT, () => {
-    console.log(`[generateServer] listening on http://localhost:${PORT}/generate`)
+    console.log(`[generateServer] listening on http://localhost:${PORT}/generate and /take_photo`)
   }).on('error', (err: any) => {
     if (err.code === 'EADDRINUSE') {
       console.log(`[generateServer] port ${PORT} already in use, skipping server start`)

+ 254 - 0
frontend/src/utils/takePhotoApi.ts

@@ -0,0 +1,254 @@
+/**
+ * 拍照接口:与拍摄页一致,先写入货号,再走左脚 / 右脚按钮。
+ * POST /take_photo 由 generateServer 转发到这里。
+ */
+import client from '@/stores/modules/client'
+import socket from '@/stores/modules/socket'
+import icpList from '@/utils/ipc'
+
+export type FootSide = 'left' | 'right'
+
+export type TakePhotoParams = {
+  goods_art_no: string
+  foot: FootSide
+  action: string
+}
+
+type TriggerResult = { started: boolean; code: number; msg?: string }
+type Trigger = (params: TakePhotoParams) => Promise<TriggerResult>
+type FailHandler = (msg: string) => void
+
+let trigger: Trigger | null = null
+let onFail: FailHandler | null = null
+let shooting = false
+
+const TAKE_PHOTO_TIMEOUT = 10 * 60 * 1000
+const MCU_ERRORS = ['mcu设备未连接', 'mcu设备未初始化', '当前有未完成的任务']
+
+type IpcLike = {
+  on: (channel: string, listener: (...args: any[]) => void) => void
+  removeListener?: (channel: string, listener: (...args: any[]) => void) => void
+  off?: (channel: string, listener: (...args: any[]) => void) => void
+  removeAllListeners?: (channel?: string) => void
+  __takePhotoOriginalRemoveAll?: (channel?: string) => void
+}
+
+const keptListeners = new Map<string, Set<(...args: any[]) => void>>()
+
+export function registerTakePhotoTrigger(fn: Trigger, hooks?: { onFail?: FailHandler }) {
+  trigger = fn
+  onFail = hooks?.onFail || null
+  return () => {
+    if (trigger === fn) {
+      trigger = null
+      onFail = null
+    }
+  }
+}
+
+export function parseTakePhotoBody(payload: any): { ok: true; params: TakePhotoParams } | { ok: false; msg: string } {
+  const goodsArtNo = String(payload?.goods_art_no ?? payload?.goodsArtNo ?? payload?.['货号'] ?? '').trim()
+  const foot = normalizeFoot(payload?.foot ?? payload?.side ?? payload?.['左右脚'] ?? payload?.type)
+  if (!goodsArtNo) return { ok: false, msg: '货号不能为空' }
+  if (!foot) return { ok: false, msg: '左右脚不能为空,请传 left/right 或 左/右' }
+  return {
+    ok: true,
+    params: {
+      goods_art_no: goodsArtNo,
+      foot,
+      action: foot === 'right' ? '执行右脚程序' : '执行左脚程序',
+    },
+  }
+}
+
+function normalizeFoot(raw: unknown): FootSide | null {
+  const text = String(raw ?? '').trim()
+  if (!text) return null
+  const value = text.toLowerCase()
+  if (value === 'right' || value === 'r' || value === '1' || text.includes('右')) return 'right'
+  if (value === 'left' || value === 'l' || value === '0' || text.includes('左')) return 'left'
+  return null
+}
+
+export async function requestTakePhoto(payload: any) {
+  const parsed = parseTakePhotoBody(payload)
+  if (!parsed.ok) return { code: 1, msg: parsed.msg }
+  if (shooting) return { code: 1, msg: '拍摄程序正在运行,请稍候' }
+
+  const clientStore = client()
+  if (!clientStore.isClient || !clientStore.ipc) {
+    return { code: 1, msg: '拍照接口仅在客户端内可用' }
+  }
+
+  shooting = true
+  const pending = waitForShoot(clientStore.ipc)
+  try {
+    const started = trigger
+      ? await trigger(parsed.params)
+      : await sendRunMcu(parsed.params)
+    if (pending.settled()) {
+      const result = await pending.promise
+      return withData(parsed.params, result)
+    }
+    if (!started.started) {
+      pending.cancel()
+      return { code: started.code || 1, msg: started.msg || '未能开始拍摄' }
+    }
+    const result = await pending.promise
+    return withData(parsed.params, result)
+  } catch (error: any) {
+    pending.cancel()
+    return { code: 1, msg: error?.message || '拍照失败' }
+  } finally {
+    shooting = false
+  }
+}
+
+function withData(params: TakePhotoParams, result: { code: number; msg: string }) {
+  return {
+    code: result.code,
+    msg: result.msg,
+    data: {
+      goods_art_no: params.goods_art_no,
+      foot: params.foot,
+      action: params.action,
+    },
+  }
+}
+
+/** 拍摄页未打开时,仍下发与左右脚按钮相同的 run_mcu。 */
+async function sendRunMcu(params: TakePhotoParams): Promise<TriggerResult> {
+  const socketStore = socket()
+  await Promise.race([
+    socketStore.connectSocket(),
+    new Promise((_, reject) => {
+      setTimeout(() => reject(new Error('设备服务未连接')), 8000)
+    }),
+  ])
+  socketStore.sendMessage({
+    type: 'run_mcu',
+    data: {
+      action: params.action,
+      goods_art_no: params.goods_art_no,
+    },
+  })
+  return { started: true, code: 0 }
+}
+
+function waitForShoot(ipc: IpcLike) {
+  let settled = false
+  let stepError = ''
+  let stopped = false
+  let resolveFn: (value: { code: number; msg: string }) => void = () => {}
+  const promise = new Promise<{ code: number; msg: string }>((resolve) => {
+    resolveFn = resolve
+  })
+
+  const channels = {
+    runMcu: icpList.socket.message + '_run_mcu',
+    mcu: icpList.socket.message + '_mcu',
+    photoTake: icpList.socket.message + '_photo_take',
+    finish: icpList.socket.message + '_photo_take_finish',
+    stop: icpList.socket.message + '_run_mcu_stop',
+  }
+  const handlers: Record<string, (...args: any[]) => void> = {}
+  let timer: ReturnType<typeof setTimeout>
+  let release = () => {}
+
+  const done = (result: { code: number; msg: string }, reset = false) => {
+    if (settled) return
+    settled = true
+    clearTimeout(timer)
+    release()
+    if (reset) onFail?.(result.msg)
+    resolveFn(result)
+  }
+
+  handlers[channels.runMcu] = (_event, result) => {
+    if (result && result.code !== 0) {
+      done({ code: 1, msg: result.msg || '拍照失败' })
+    }
+  }
+  handlers[channels.mcu] = (_event, result) => {
+    const msg = String(result?.msg || '')
+    if (result?.code === 1 && MCU_ERRORS.some((item) => msg.includes(item))) {
+      done({ code: 1, msg }, true)
+    }
+  }
+  handlers[channels.photoTake] = (_event, result) => {
+    if (result?.code !== 0 && result?.msg) stepError = result.msg
+  }
+  handlers[channels.stop] = (_event, result) => {
+    stopped = true
+    stepError = result?.msg || '拍摄已终止'
+  }
+  handlers[channels.finish] = (_event, result) => {
+    if (stopped || stepError) {
+      done({ code: 1, msg: stepError || '拍摄失败' })
+      return
+    }
+    if (result?.code === 0) {
+      done({ code: 0, msg: result.msg || '拍摄完成' })
+      return
+    }
+    done({ code: 1, msg: result?.msg || '拍照失败' }, true)
+  }
+
+  timer = setTimeout(() => {
+    done({ code: 1, msg: '拍照超时' }, true)
+  }, TAKE_PHOTO_TIMEOUT)
+
+  release = () => {
+    Object.entries(handlers).forEach(([channel, handler]) => keepOff(ipc, channel, handler))
+  }
+
+  ensureKeep(ipc)
+  Object.entries(handlers).forEach(([channel, handler]) => keepOn(ipc, channel, handler))
+
+  return {
+    promise,
+    settled: () => settled,
+    cancel: () => {
+      if (settled) return
+      settled = true
+      clearTimeout(timer)
+      release()
+    },
+  }
+}
+
+function keepOn(ipc: IpcLike, channel: string, handler: (...args: any[]) => void) {
+  let set = keptListeners.get(channel)
+  if (!set) {
+    set = new Set()
+    keptListeners.set(channel, set)
+  }
+  set.add(handler)
+  ipc.on(channel, handler)
+}
+
+function keepOff(ipc: IpcLike, channel: string, handler: (...args: any[]) => void) {
+  const set = keptListeners.get(channel)
+  set?.delete(handler)
+  if (set && set.size === 0) keptListeners.delete(channel)
+  if (typeof ipc.removeListener === 'function') {
+    ipc.removeListener(channel, handler)
+  } else if (typeof ipc.off === 'function') {
+    ipc.off(channel, handler)
+  }
+}
+
+/** 拍摄页卸载时会 removeAllListeners,进行中的接口监听需要挂回去。 */
+function ensureKeep(ipc: IpcLike) {
+  if (!ipc || ipc.__takePhotoOriginalRemoveAll || typeof ipc.removeAllListeners !== 'function') return
+  const original = ipc.removeAllListeners.bind(ipc)
+  ipc.__takePhotoOriginalRemoveAll = original
+  ipc.removeAllListeners = (channel?: string) => {
+    const result = original(channel)
+    const channels = channel ? [channel] : Array.from(keptListeners.keys())
+    channels.forEach((item) => {
+      keptListeners.get(item)?.forEach((handler) => ipc.on(item, handler))
+    })
+    return result
+  }
+}

+ 50 - 5
frontend/src/views/Photography/mixin/usePhotography.ts

@@ -13,6 +13,7 @@ import useUserInfo from "@/stores/modules/user";
 import configInfo from '@/stores/modules/config';
 import tokenInfo from "@/stores/modules/token";
 import { getAllUserConfigs, getTopTabs } from '@/apis/setting';
+import { registerTakePhotoTrigger, type TakePhotoParams } from '@/utils/takePhotoApi';
 
 export default function usePhotography() {
     const loading = ref(false)
@@ -61,6 +62,9 @@ export default function usePhotography() {
     const configInfoStore = configInfo();
     const tokenInfoStore = tokenInfo();
     const checkInfoStore = checkInfo()
+    let unbindTakePhoto: (() => void) | null = null
+    let captureRunError: ((msg: string) => void) | null = null
+    let onRunMcuResult: ((event: any, result: any) => void) | null = null
 
     // 拍照点位配置
     const STORAGE_KEY = 'photo_point_name'
@@ -228,19 +232,27 @@ export default function usePhotography() {
       reNosObj.value.action = null;
       searchGoodsArtNo.value = ''
 
-      clientStore.ipc.on(icpList.socket.message + '_run_mcu', (event, result) => {
-
-        clientStore.ipc.removeAllListeners(icpList.socket.message + '_run_mcu');
+      const runMcuChannel = icpList.socket.message + '_run_mcu'
+      if (onRunMcuResult && clientStore.ipc?.removeListener) {
+        clientStore.ipc.removeListener(runMcuChannel, onRunMcuResult)
+      }
+      onRunMcuResult = (_event, result) => {
+        if (clientStore.ipc?.removeListener) {
+          clientStore.ipc.removeListener(runMcuChannel, onRunMcuResult)
+        }
+        onRunMcuResult = null
         console.log('_run_mcu');
         console.log(result);
         if (result.code !== 0 && result.msg) {
+          captureRunError?.(result.msg)
           ElMessage.error(result.msg)
           runLoading.value = false
           return;
         } else if (!isDemoMode.value) {
           ElMessage.success('开始拍摄,请稍后')
         }
-      })
+      }
+      clientStore.ipc.on(runMcuChannel, onRunMcuResult)
 
 
     }
@@ -754,15 +766,46 @@ const onRemoteControl = async (type) => {
   // 埋点:遥控器启动拍摄
   clickLog({ describe: { action: `点击遥控器${type === 'left' ? '左脚' : '右脚'}按钮`, goods_art_no: goods_art_no.value, point_name: pointName } }, route);
 
-  runGoods({
+  await runGoods({
     "action": action,
     "goods_art_no": goods_art_no.value
   })
 
 }
 
+    /**
+     * 与拍摄页按钮相同:把货号写进输入框,再点左脚或右脚。
+     */
+    async function triggerTakePhoto(params: TakePhotoParams) {
+      if (runLoading.value || takePictureLoading.value) {
+        return { started: false, code: 1, msg: '拍摄程序正在运行,请稍候' }
+      }
+      goods_art_no.value = params.goods_art_no
+      let runError = ''
+      captureRunError = (msg: string) => {
+        runError = msg
+      }
+      try {
+        await onRemoteControl(params.foot)
+      } finally {
+        captureRunError = null
+      }
+      if (runError) return { started: false, code: 1, msg: runError }
+      if (!runLoading.value) return { started: false, code: 1, msg: '未能开始拍摄' }
+      return { started: true, code: 0 }
+    }
+
     // 初始化事件监听
     const initEventListeners = () => {
+      unbindTakePhoto?.()
+      unbindTakePhoto = registerTakePhotoTrigger(triggerTakePhoto, {
+        onFail(msg) {
+          runLoading.value = false
+          runAction.value.action = ''
+          runAction.value.goods_art_no = ''
+          if (msg) ElMessage.error(msg)
+        },
+      })
       // 监听蓝牙扫描事件
       clientStore.ipc.on(icpList.socket.message + '_blue_tooth_scan', (event, result) => {
 
@@ -993,6 +1036,8 @@ const onRemoteControl = async (type) => {
 
     // 清理事件监听
     const cleanupEventListeners = () => {
+      unbindTakePhoto?.()
+      unbindTakePhoto = null
       clientStore.ipc.removeAllListeners(icpList.socket.message + '_blue_tooth_scan');
       clientStore.ipc.removeAllListeners(icpList.socket.message + '_image_process');
       clientStore.ipc.removeAllListeners(icpList.socket.message + '_run_mcu');

+ 1 - 1
public/dist/index.html

@@ -5,7 +5,7 @@
     <link rel="icon" type="image/svg+xml" href="./vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>智惠映AI自动拍照机</title>
-    <script type="module" crossorigin src="./assets/index-Cu4duvgh.js"></script>
+    <script type="module" crossorigin src="./assets/index-BzDVIlAS.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-CUtW5LTp.css">
   </head>
   <body>