瀏覽代碼

Merge commit '9dc94f978ecdcb59f26a1a22f7a23d760aa22918'

cc12458 3 月之前
父節點
當前提交
2598c82fc9
共有 89 個文件被更改,包括 7013 次插入1134 次删除
  1. 3 1
      .env/.env.development
  2. 3 1
      .env/.env.production
  3. 1 0
      .gitignore
  4. 55 0
      @types/bridge.d.ts
  5. 3 1
      @types/env.d.ts
  6. 26 0
      @types/global.d.ts
  7. 1 0
      @types/router.d.ts
  8. 2 1
      index.html
  9. 4 1
      package.json
  10. 26 0
      pnpm-lock.yaml
  11. 二進制
      public/camera/step-11.audio.wav
  12. 二進制
      public/camera/step-11.example.png
  13. 0 3
      public/camera/step-11.shade.svg
  14. 0 8
      public/camera/step-12.shade.svg
  15. 二進制
      public/camera/step-21.audio.wav
  16. 8 0
      public/camera/step-21.shade.svg
  17. 二進制
      public/camera/step-31.audio.wav
  18. 16 0
      public/pdf/download.js
  19. 65 0
      public/pdf/preview.html
  20. 1 0
      public/pdf/script/jquery-3.6.0.min.js
  21. 20 0
      public/pdf/script/pdf-4.0.379.min.mjs
  22. 3363 0
      public/pdf/script/pdf-4.0.379.worker.mjs
  23. 816 0
      public/pdf/script/pdf-viewer-1.1.2.min.js
  24. 19 0
      public/pdf/style/download.css
  25. 65 0
      public/pdf/style/loader.css
  26. 54 0
      public/pdf/style/viewer.css
  27. 5 15
      src/App.vue
  28. 176 0
      src/assets/camera.html
  29. 1 0
      src/assets/images/mini-program.svg
  30. 二進制
      src/assets/images/pulse-hand-left.png
  31. 二進制
      src/assets/images/pulse-hand-right.png
  32. 111 0
      src/components/AnalysisComponent.vue
  33. 0 33
      src/hooks/useTitle.ts
  34. 61 0
      src/loader/bridge.loader.ts
  35. 1 1
      src/loader/debug.loader.ts
  36. 1 0
      src/loader/index.ts
  37. 5 2
      src/main.ts
  38. 35 24
      src/modules/camera/ShadeFace.vue
  39. 30 14
      src/modules/camera/ShadeTongueDown.vue
  40. 28 26
      src/modules/camera/ShadeTongueUp.vue
  41. 72 0
      src/modules/camera/camera-result.page.vue
  42. 21 27
      src/modules/camera/camera.config.ts
  43. 151 0
      src/modules/camera/camera.page.vue
  44. 57 118
      src/modules/camera/camera.vue
  45. 0 214
      src/modules/camera/page.vue
  46. 219 0
      src/modules/pulse/pulse.page.vue
  47. 4 3
      src/modules/questionnaire/TierSelect.field.vue
  48. 80 35
      src/modules/questionnaire/page.vue
  49. 19 9
      src/modules/report/NavBar.vue
  50. 21 36
      src/modules/report/PhysiqueChart.vue
  51. 53 0
      src/modules/report/ReportPreview.vue
  52. 2 1
      src/modules/report/SchemeMedia.vue
  53. 8 9
      src/modules/report/SyndromeChart.vue
  54. 9 3
      src/modules/report/echart.ts
  55. 140 0
      src/modules/report/report-analyse.page.vue
  56. 251 131
      src/modules/report/report.page.vue
  57. 46 17
      src/modules/report/scheme.page.vue
  58. 118 200
      src/pages/register.page.vue
  59. 47 26
      src/pages/screen.page.vue
  60. 10 0
      src/platform/dialog.ui.ts
  61. 6 1
      src/platform/index.ts
  62. 15 0
      src/polyfill.ts
  63. 3 3
      src/request/alova.ts
  64. 41 15
      src/request/api/account.api.ts
  65. 20 4
      src/request/api/camera.api.ts
  66. 54 24
      src/request/api/flow.api.ts
  67. 2 1
      src/request/api/index.ts
  68. 18 0
      src/request/api/pulse.api.ts
  69. 8 8
      src/request/api/questionnaire.api.ts
  70. 60 5
      src/request/api/report.api.ts
  71. 136 0
      src/request/model/analysis.model.ts
  72. 45 0
      src/request/model/flow.model.ts
  73. 2 0
      src/request/model/index.ts
  74. 154 0
      src/request/model/register.model.ts
  75. 12 23
      src/request/model/report.model.ts
  76. 10 19
      src/router/index.ts
  77. 1 1
      src/stores/platform.store.ts
  78. 5 0
      src/themes/fix.scss
  79. 16 5
      src/themes/index.scss
  80. 13 0
      src/themes/vant.scss
  81. 36 0
      src/tools/bubble.ts
  82. 0 23
      src/tools/camera.tool.ts
  83. 1 0
      src/tools/index.ts
  84. 18 0
      src/tools/polyfills.ts
  85. 3 0
      src/tools/string.tool.ts
  86. 0 11
      src/views/page.view.vue
  87. 0 7
      src/widgets/footer.widget.vue
  88. 0 17
      src/widgets/header.widget.vue
  89. 1 7
      vite.config.ts

+ 3 - 1
.env/.env.development

@@ -1 +1,3 @@
-REQUEST_API_PROXY_URL=http://121.43.162.141:8080
+REQUEST_API_PROXY_URL=https://wx.hzliuzhi.com:4433
+
+SIX_REQUEST_BASE=/manager

+ 3 - 1
.env/.env.production

@@ -1 +1,3 @@
-REQUEST_API_PROXY_URL=https://wx.hzliuzhi.com/manager
+REQUEST_API_PROXY_URL=https://wx.hzliuzhi.com
+
+SIX_REQUEST_BASE=/manager

+ 1 - 0
.gitignore

@@ -31,3 +31,4 @@ coverage
 
 components.d.ts
 auto-imports.d.ts
+six.aio-**.zip

+ 55 - 0
@types/bridge.d.ts

@@ -0,0 +1,55 @@
+interface HandSummary {
+  chi: string;
+  cun: string;
+  guan: string;
+  summary: string[];
+}
+
+type HandKeys = 'chen' | 'chi' | 'fu' | 'hua' | 'kong' | 'ruan' | 'shi' | 'shu' | 'xi' | 'xian';
+
+interface ScanData {
+  code: string;
+  state: number;
+  type: number;
+}
+
+export interface BridgeEventMap {
+  scan: CustomEvent<{code: number, data?: ScanData, message?: string}>;
+}
+
+export class Bridge extends EventTarget {
+  public static getInstance(): Bridge;
+
+  public static pulse(userId: string): Promise<{
+    measureId: string;
+    summaryLabel: {
+      summary?: HandSummary['summary'];
+      left?: HandSummary;
+      right?: HandSummary;
+    };
+    summaryValue: Record<HandKeys, number[]>;
+    time: string;
+
+    appId?: string;
+    userId?: string;
+    url: string;
+    report: string;
+  }>;
+
+  public static print(): Promise<void>;
+  public static print(params: { url?: string }): Promise<void>;
+
+  /**
+   * 监听扫码事件
+   * @param type 事件类型 'scan'
+   * @param listener 事件回调,参数为 ScanEvent
+   * @param options
+   */
+  addEventListener<T extends keyof BridgeEventMap>(type: T, listener: (event: BridgeEventMap[T]) => void, options?: boolean | AddEventListenerOptions): () => void;
+  removeEventListener<T extends keyof BridgeEventMap>(type: T, listener: (event: BridgeEventMap[T]) => void, options?: boolean | AddEventListenerOptions): () => void;
+}
+
+export interface globalAIO {
+  scan(value: string): number;
+  print(value: string): void;
+}

+ 3 - 1
@types/env.d.ts

@@ -3,8 +3,10 @@
 interface ImportMetaEnv {
   readonly SIX_APP_NAME: string;
   readonly SIX_APP_TITLE: string;
+
+  readonly SIX_REQUEST_BASE?: string;
 }
 
 interface ImportMeta {
-  readonly env: ImportMetaEnv
+  readonly env: ImportMetaEnv;
 }

+ 26 - 0
@types/global.d.ts

@@ -0,0 +1,26 @@
+export {};
+
+declare global {
+  declare const Bridge: typeof import('./bridge').Bridge;
+
+  interface Window {
+    /* six-aio 设备注入 */
+    bridge: InstanceType<typeof import('./bridge').Bridge>;
+    /**
+     * webview 设备注入的 全局对象(历史遗留)
+     * @deprecated 使用 bridge
+     */
+    AIO: Partial<import('./bridge').globalAIO>;
+  }
+
+  /**
+   * Promise 扩展
+   */
+  interface PromiseConstructor {
+    withResolvers<T>(): {
+      promise: Promise<T>;
+      resolve: (value: T | PromiseLike<T>) => void;
+      reject: (reason?: any) => void;
+    };
+  }
+}

+ 1 - 0
@types/router.d.ts

@@ -7,5 +7,6 @@ export {};
 declare module 'vue-router' {
   interface RouteMeta {
     title?: string;
+    scan?: boolean;
   }
 }

+ 2 - 1
index.html

@@ -3,8 +3,9 @@
   <head>
     <meta charset="UTF-8">
     <link rel="icon" href="/favicon.ico">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
     <title>%SIX_APP_TITLE%</title>
+    <script>document.addEventListener('gesturestart', function(event) { event.preventDefault(); }, false);</script>
   </head>
   <body>
     <div id="app"></div>

+ 4 - 1
package.json

@@ -6,8 +6,9 @@
   "scripts": {
     "dev": "vite",
     "build": "run-p type-check \"build-aio {@}\" --",
-    "preview": "vite preview",
     "build-aio": "vite build --base=/aio/",
+    "preview": "vite preview --base=/aio/ --mode development",
+    "device": "run-s build-aio preview",
     "type-check": "vue-tsc --build --force",
     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
     "format": "prettier --write src/"
@@ -25,6 +26,8 @@
     "p5": "^1.11.0",
     "pinia": "^2.2.4",
     "pinia-plugin-persistedstate": "^4.1.1",
+    "qrcode.vue": "^3.6.0",
+    "svg-pathdata": "^7.1.0",
     "vant": "4",
     "vconsole": "^3.15.1",
     "vue": "^3.5.11",

+ 26 - 0
pnpm-lock.yaml

@@ -41,6 +41,12 @@ dependencies:
   pinia-plugin-persistedstate:
     specifier: ^4.1.1
     version: 4.1.1(pinia@2.2.4)
+  qrcode.vue:
+    specifier: ^3.6.0
+    version: 3.6.0(vue@3.5.11)
+  svg-pathdata:
+    specifier: ^7.1.0
+    version: 7.1.0
   vant:
     specifier: '4'
     version: 4.9.8(vue@3.5.11)
@@ -3270,6 +3276,14 @@ packages:
     engines: {node: '>=6'}
     dev: true
 
+  /qrcode.vue@3.6.0(vue@3.5.11):
+    resolution: {integrity: sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==}
+    peerDependencies:
+      vue: ^3.0.0
+    dependencies:
+      vue: 3.5.11(typescript@5.6.2)
+    dev: false
+
   /queue-microtask@1.2.3:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
 
@@ -3736,6 +3750,13 @@ packages:
     engines: {node: '>= 0.4'}
     dev: true
 
+  /svg-pathdata@7.1.0:
+    resolution: {integrity: sha512-wrvKHXZSYZyODOj5E1l1bMTIo8sR7YCH0E4SA8IgLgMsZq4RypslpYvNSsrdg4ThD6du2KWPyVeKinkqUelGhg==}
+    engines: {node: '>=20.11.1'}
+    dependencies:
+      yerror: 8.0.0
+    dev: false
+
   /svg-tags@1.0.0:
     resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==}
     dev: true
@@ -4349,6 +4370,11 @@ packages:
     hasBin: true
     dev: true
 
+  /yerror@8.0.0:
+    resolution: {integrity: sha512-FemWD5/UqNm8ffj8oZIbjWXIF2KE0mZssggYpdaQkWDDgXBQ/35PNIxEuz6/YLn9o0kOxDBNJe8x8k9ljD7k/g==}
+    engines: {node: '>=18.16.0'}
+    dev: false
+
   /yocto-queue@0.1.0:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
     engines: {node: '>=10'}

二進制
public/camera/step-11.audio.wav


二進制
public/camera/step-11.example.png


+ 0 - 3
public/camera/step-11.shade.svg

@@ -2,9 +2,6 @@
   <path id="outline" stroke="#38FF6E" stroke-width="2" fill="none"
         d="M21 193C21 193-46 60 78 9 84 7 91 4 98 4 105 3 115 3 122 4 136 7 140 7 160 3 182 0 324 31 249 192M139 127C139 127 49 81 27 152 6 223 51 357 130 364 205 370 244 331 249 181 249 181 259 75 139 127Z"
   />
-  <path id="tooth" stroke="#38FF6E" stroke-width="2" fill="none"
-        d="M25 162C25 162-16 37 136 35 287 33 255 149 250 169M88 55C88 55 73 81 58 55 58 55 62 76 36 76M189 41C189 41 195 60 182 61 170 62 160 62 147 61 137 60 139 35 139 35 139 35 140 40 138 54 137 67 93 61 90 59 87 56 89 41 89 41M219 53C219 53 218 71 196 62 196 62 189 59 189 57"
-  />
   <path id="tongue" stroke="#38FF6E" stroke-width="2" fill="none"
         d="M 139 298 L 138 127 C 138 126 138 126 139 126 C 139 126 140 126 140 127 L 140 127 L 139 298 Z"
   />

+ 0 - 8
public/camera/step-12.shade.svg

@@ -1,8 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 366">
-  <path id="outline" stroke="#38FF6E" stroke-width="2" fill="none"
-        d="M5 195C5 195 31 45 71 12 72 11 73 9 74 8 77 5 86-1 117 5 144 9 156 7 161 5 168 2 175 1 182 3 190 5 198 9 206 18 224 39 248 87 255 153 255 153 262 192 269 195 269 195 248 311 215 332 213 333 211 334 209 336 202 341 187 350 165 350 144 349 118 350 105 351 99 351 93 350 87 348 71 341 40 323 26 280 7 221 3 205 3 205 3 205-0 195 5 195Z"
-  />
-  <path id="interior" stroke="#38FF6E" stroke-width="2" fill="none"
-        d="M13 225C13 225 35 92 80 57 80 57 94 42 125 54 125 54 135 60 159 49 183 39 208 68 232 121 232 121 253 181 252 196 252 196 263 204 244 223 244 223 207 300 134 302 61 304 24 248 13 225ZM64 77C64 77 79 81 81 76 83 71 81 59 76 60M80 61 123 53 135 57 136 65C136 68 134 70 132 72 125 75 111 81 91 78 86 77 82 73 82 69L80 61ZM193 62 135 57 136 65C136 68 138 71 140 72 147 77 164 84 188 76L193 62ZM207 76C207 76 192 82 188 76M34 143C34 143 126 18 239 143M110 241C132 232 129 211 134 160 137 128 130 113 124 107 121 104 116 103 112 103 43 113 26 212 26 212M166 245C166 245 129 243 136 125 136 125 135 84 182 108 229 132 239 198 239 198M255 201C255 201 247 190 227 207 227 207 215 210 212 222 208 234 193 241 193 241 193 241 174 235 166 248 166 248 151 233 135 245 135 245 110 239 103 243 103 243 75 233 70 248L58 232C58 232 57 223 37 223 37 223 31 208 16 214M37 223C37 223 22 234 13 225M103 243C103 243 117 293 96 294 75 295 70 248 70 248 70 248 74 266 76 275 77 283 65 284 65 284L44 267C43 227 60 234 60 234M135 245C135 245 148 293 122 296 122 296 107 298 107 281M166 248C166 248 170 278 158 290 146 303 137 279 137 279M193 241C193 241 200 276 181 283 181 283 175 286 165 274M212 222C212 222 230 223 221 248 211 272 194 267 194 267M225 208 244 223"
-  />
-</svg>

二進制
public/camera/step-21.audio.wav


+ 8 - 0
public/camera/step-21.shade.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 366">
+  <path id="outline" stroke="#38FF6E" stroke-width="2" fill="none"
+        d="M5 195C5 195 31 45 71 12 72 11 73 9 74 8 77 5 86-1 117 5 144 9 156 7 161 5 168 2 175 1 182 3 190 5 198 9 206 18 224 39 248 87 255 153 255 153 262 192 269 195 269 195 248 311 215 332 213 333 211 334 209 336 202 341 187 350 165 350 144 349 118 350 105 351 99 351 93 350 87 348 71 341 40 323 26 280 7 221 3 205 3 205 3 205-0 195 5 195Z"
+  />
+  <path id="interior" stroke="#38FF6E" stroke-width="2" fill="none"
+        d="M18 225C18 225 22 83 85 57 85 57 99 42 130 54 130 54 140 60 164 49 188 39 213 68 237 121 237 121 258 181 257 196 257 196 261 208 249 223 248 222 212 300 145 302 66 304 29 248 18 225M73 245C137 232 138 232 139 95M205 245C171 245 134 243 139 95"
+  />
+</svg>

二進制
public/camera/step-31.audio.wav


+ 16 - 0
public/pdf/download.js

@@ -0,0 +1,16 @@
+function download(blobUrl, filename) {
+  if (filename == null) {
+    filename = blobUrl.split('/').pop();
+  }
+  const anchorDownload = document.createElement('a');
+  document.body.appendChild(anchorDownload);
+  anchorDownload.href = blobUrl;
+  anchorDownload.download = filename;
+  anchorDownload.style.display = 'none';
+  anchorDownload.click();
+
+  setTimeout(() => {
+    URL.revokeObjectURL(blobUrl);
+    document.body.removeChild(anchorDownload);
+  }, 10);
+}

+ 65 - 0
public/pdf/preview.html

@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+  <title>健康分析报告</title>
+  <style>
+    html, body {
+      margin: 0;
+      padding: 0;
+      width: 100vw;
+      height: 100vh;
+    }
+  </style>
+  <script>
+    const base = document.createElement('base');
+    base.setAttribute('href', location.href.split('preview.html')[0]);
+    document.head.appendChild(base);
+  </script>
+  <link rel="stylesheet" href="./style/loader.css">
+  <link rel="stylesheet" href="./style/viewer.css">
+  <link rel="stylesheet" href="./style/download.css">
+</head>
+<body>
+<div class="loader-container">
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+</div>
+<div class="pdf-container"></div>
+<div class="download-container">
+  <svg viewBox="0 0 1026 1024" width="200.390625" height="200">
+    <path
+      d="M787.2 711.04a32 32 0 0 1-32-32 32 32 0 0 1 32-32 156.8 156.8 0 0 0 136.32-80.64A200.96 200.96 0 0 0 960 448a198.4 198.4 0 0 0-198.4-198.4 35.2 35.2 0 0 1-30.72-15.36 256 256 0 0 0-460.16 49.92 32.64 32.64 0 0 1-31.36 21.76h-7.68a170.88 170.88 0 0 0 0 341.76h28.8a32 32 0 0 1 32 32 32 32 0 0 1-32 32h-25.6a234.88 234.88 0 0 1-13.44-469.12 320 320 0 0 1 556.8-55.04A263.04 263.04 0 0 1 1024 448a256 256 0 0 1-49.28 152.32 218.88 218.88 0 0 1-187.52 110.72z"></path>
+    <path
+      d="M512 948.48a32.64 32.64 0 0 1-32-32V576a32 32 0 0 1 32-32 32 32 0 0 1 32 32v340.48a32 32 0 0 1-32 32z"></path>
+    <path
+      d="M512 960a32 32 0 0 1-22.4-9.6l-140.8-141.44a32 32 0 1 1 44.8-46.08l142.08 139.52A32 32 0 0 1 512 960z"></path>
+    <path
+      d="M512 960a32.64 32.64 0 0 1-23.04-9.6 32.64 32.64 0 0 1 0-45.44l144.64-142.08a31.36 31.36 0 0 1 44.8 0 32 32 0 0 1 0 45.44l-142.72 139.52A31.36 31.36 0 0 1 512 960z"></path>
+  </svg>
+</div>
+<script src="./script/jquery-3.6.0.min.js"></script>
+<script src="./script/pdf-viewer-1.1.2.min.js"></script>
+<script src="./download.js"></script>
+<script>
+  const $download = $('.download-container');
+  let url = new URLSearchParams(window.location.search).get('url');
+  url = decodeURIComponent(url);
+  import('./script/pdf-4.0.379.min.mjs').then(module => {
+    module.GlobalWorkerOptions.workerSrc = './script/pdf-4.0.379.worker.mjs';
+    const pdfViewer = new PDFjsViewer($('.pdf-container'), { pageClass: 'pdf-page' });
+    pdfViewer.loadDocument(url).then(() => { pdfViewer.setZoom('fit'); });
+
+    $download.show();
+
+    $download.on('click', function() { download(url); });
+  });
+</script>
+</body>
+</html>

文件差異過大導致無法顯示
+ 1 - 0
public/pdf/script/jquery-3.6.0.min.js


文件差異過大導致無法顯示
+ 20 - 0
public/pdf/script/pdf-4.0.379.min.mjs


文件差異過大導致無法顯示
+ 3363 - 0
public/pdf/script/pdf-4.0.379.worker.mjs


+ 816 - 0
public/pdf/script/pdf-viewer-1.1.2.min.js

@@ -0,0 +1,816 @@
+/*
+   Copyright 2020 Carlos de Alfonso (https://github.com/dealfonso)
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+(function(exports, $) {
+    'use strict';
+
+    let defaults = {
+        // Threshold to consider that a page is visible
+        visibleThreshold: 0.5,
+        // Number of extra pages to load (appart from the visible)
+        extraPagesToLoad: 3,
+        // The class used for each page (the div that wraps the content of the page)
+        pageClass: "pdfpage",
+        // The class used for the content of each page (the div that contains the page)
+        contentClass: "content-wrapper",
+        // Function called when a document has been loaded and its structure has been created
+        onDocumentReady: () => {},
+        // Function called when a new page is created (it is binded to the object, and receives a jQuery object as parameter)
+        onNewPage: (page, i) => {},
+        // Function called when a page is rendered
+        onPageRender: (page, i) => {},
+        // Function called to obtain a page that shows an error when the document could not be loaded (returns a jQuery object)
+        errorPage: () => {
+            $(`<div class="placeholder"></div>`).addClass(this.settings.pageClass).append($(`<p class="m-auto"></p>`).text("could not load document"))
+        },
+        // Posible zoom values to iterate over using "in" and "out"
+        zoomValues: [ 0.25, 0.5, 0.75, 1, 1.25, 1.50, 2, 4, 8 ],
+        // Function called when the zoom level changes (it receives the zoom level)
+        onZoomChange: (zoomlevel) => {},
+        // Function called whenever the active page is changed (the active page is the one that is shown in the viewer)
+        onActivePageChanged: (page, i) => {},
+        // Percentage of the container that will be filled with the page
+        zoomFillArea: 0.95,
+        // Function called to get the content of an empty page
+        emptyContent: () => $('<div class="loader"></div>'),
+        // The scale to which the pages are rendered (1.5 is the default value for the PDFjs viewer); a higher value will render the pages with a higher resolution
+        //   but it will consume more memory and CPU. A lower value will render the pages with a lower resolution, but they will be uglier.
+        renderingScale: 1.5,
+    }
+
+    // Class used to help in zoom management; probably it can be moved to the main class, but it is used to group methods
+    class Zoomer {
+        /**
+         * Construct the helper class
+         * @param {PDFjsViewer} viewer - the viewer object
+         * @param {*} options - the options object
+         */
+        constructor(viewer, options = {}) {
+            let defaults = {
+                // The possible zoom values to iterate through using "in" and "out"
+                zoomValues: [ 0.25, 0.5, 0.75, 1, 1.25, 1.50, 2, 4, 8 ],
+                // The area to fill the container with the zoomed pages
+                fillArea: 0.9,
+            }
+
+            // The current zooom value
+            this.current = 1;
+            // The viewer instance whose pages may be zoomed
+            this.viewer = viewer;
+            // The settings
+            this.settings = $.extend(defaults, options);
+
+            // Need having the zoom values in order
+            this.settings.zoomValues = this.settings.zoomValues.sort();
+        }
+
+        /** Translates a zoom value into a float value; possible values:
+         * - a float value
+         * - a string with a keyword (e.g. "width", "height", "fit", "in", "out")
+         * @param {number} zoom - the zoom value to be translated
+         * @return {number} The zoom value
+        */
+        get(zoom = null) {
+            // If no zoom is specified, return the current one
+            if (zoom === null) {
+                return this.current;
+            }
+            // If it is a number, return it
+            if (parseFloat(zoom) == zoom) {
+                return zoom;
+            }
+            let $activepage = this.viewer.getActivePage();
+            let zoomValues = [];
+            // If it is a keyword, return the corresponding value
+            switch(zoom) {
+                case "in":
+                    zoom = this.current;
+                    zoomValues = this.settings.zoomValues.filter((x) => x > zoom);
+                    if (zoomValues.length > 0) {
+                        zoom = Math.min(...zoomValues);
+                    }
+                    break;
+                case "out":
+                    zoom = this.current;
+                    zoomValues = this.settings.zoomValues.filter((x) => x < zoom);
+                    if (zoomValues.length > 0) {
+                        zoom = Math.max(...zoomValues);
+                    }
+                    break;
+                case "fit":
+                    zoom = Math.min(this.get("width"), this.get("height"));
+                    break;
+                case "width":
+                    zoom = this.settings.fillArea * this.viewer.$container.width() / $activepage.data("width");
+                    break;
+                case "height":
+                    zoom = this.settings.fillArea * this.viewer.$container.height() / $activepage.data("height");
+                    break;
+                default:
+                    zoom = this.current;
+                    break;
+            }
+            return zoom;
+        }
+
+        /**
+         * Sets the zoom value to each page (changes both the page and the content div); relies on the data-values for the page
+         * @param {number} zoom - the zoom value to be set
+         */
+        zoomPages(zoom) {
+            zoom = this.get(zoom);
+            this.viewer.getPages().forEach(function(page) {
+                let $page = page.$div;
+                let c_width = $page.data("width");
+                let c_height = $page.data("height");
+
+                $page.width(c_width * zoom).height(c_height * zoom);
+                $page.data('zoom', zoom);
+                $page.find(`.${this.viewer.settings.contentClass}`).width(c_width * zoom).height(c_height * zoom);
+            }.bind(this));
+            this.current = zoom;
+        }
+    }
+
+    class PDFjsViewer {
+        /**
+         * Constructs the object, and initializes actions:
+         *   - add the scroll handler to the container
+         *   - set the first adjusting action when the page is loaded
+         *   - creates the zoom helper
+         * @param {jQuery} $container the jQuery value that will hold the pages
+         * @param {dictionary} options options for the viewer
+         */
+        constructor($container, options = {}) {
+
+            this.settings = $.extend(Object.assign({}, defaults), options);
+
+            // Create the zoomer helper
+            this._zoom = new Zoomer(this, {
+                zoomValues: this.settings.zoomValues,
+                fillArea: this.settings.zoomFillArea,
+            });
+
+            // Store the container
+            this.$container = $container;
+
+            // Add a reference to this object to the container
+            $container.get(0)._pdfjsViewer = this;
+
+            // Add the event listeners
+            this._setScrollListener();
+
+            // Initialize some variables
+            this.pages = [];
+            this.pdf = null;
+
+            // Whether the document is ready or not
+            this._documentReady = false;
+        }
+
+        /**
+         * Sets the current zoom level and applies it to all the pages
+         * @param {number} zoom the desired zoom level, which will be a value (1 equals to 100%), or the keywords 'in', 'out', 'width', 'height' or 'fit'
+         */
+        setZoom(zoom) {
+            let container = this.$container.get(0);
+
+            // Get the previous zoom and scroll position
+            let prevzoom = this._zoom.current;
+            let prevScroll = {
+                top: container.scrollTop,
+                left: container.scrollLeft
+            };
+
+            // Now zoom the pages
+            this._zoom.zoomPages(zoom);
+
+            // Update the scroll position (to match the previous one), according to the new relationship of zoom
+            container.scrollLeft = prevScroll.left * this._zoom.current / prevzoom;
+            container.scrollTop = prevScroll.top * this._zoom.current / prevzoom;
+
+            // Force to redraw the visible pages to upgrade the resolution
+            this._visiblePages(true);
+
+            // Call the callback (if provided)
+            if (this._documentReady) {
+                if (typeof this.settings.onZoomChange === "function")
+                    this.settings.onZoomChange.call(this, this._zoom.current);
+                this.$container.get(0).dispatchEvent(new CustomEvent("zoomchange", { detail: { zoom: this._zoom.current } }));
+            }
+
+            return this._zoom.current;
+        }
+
+        /**
+         * Obtain the current zoom level
+         * @returns {number} the current zoom level
+         */
+        getZoom() {
+            return this._zoom.current;
+        }
+
+        /**
+         * Function that removes the content of a page and replaces it with the empty content (i.e. a content generated by function emptyContent)
+         *   such content will not be visible except for the time that the
+         * @param {jQuery} $page the page to be emptied
+         */
+        _cleanPage($page) {
+            let $emptyContent = this.settings.emptyContent();
+            $page.find(`.${this.settings.contentClass}`).empty().append($emptyContent)
+        }
+
+        /**
+         * Function that replaces the content with the empty class in a page with a new content
+         * @param {*} $page the page to be modified
+         * @param {*} $content the new content that will be set in the page
+         */
+        _setPageContent($page, $content) {
+            $page.find(`.${this.settings.contentClass}`).empty().append($content)
+        }
+
+        /**
+         *  Recalculates which pages are now visible and forces redrawing them (moreover it cleans those not visible)
+        */
+        refreshAll() {
+            this._visiblePages(true);
+        }
+
+        /** Function that creates a scroll handler to update the active page and to load more pages as the scroll position changes */
+        _setScrollListener() {
+            // Create a scroll handler that prevents reentrance if called multiple times and the loading of pages is not finished
+            let scrollLock = false;
+            let scrollPos = { top:0 , left:0 };
+            this.__scrollHandler = function(e) {
+                // Avoid re-entrance for the same event while loading pages
+                if (scrollLock === true) {
+                    return;
+                }
+                scrollLock = true;
+
+                let container = this.$container.get(0);
+                if ((Math.abs(container.scrollTop - scrollPos.top) > (container.clientHeight * 0.2 * this._zoom.current)) ||
+                    (Math.abs(container.scrollLeft - scrollPos.left) > (container.clientWidth * 0.2 * this._zoom.current))) {
+                    scrollPos = {
+                        top: container.scrollTop,
+                        left: container.scrollLeft
+                    }
+                    this._visiblePages();
+                }
+
+                scrollLock = false;
+            }.bind(this);
+
+            // Set the scroll handler
+            this.$container.off('scroll');
+            this.$container.on('scroll', this.__scrollHandler);
+        }
+        /**
+         * Function that creates the pageinfo structure for one page, along with the skeleton to host the page (i.e. <div class="page"><div class="content-wrapper"></div></div>)
+         *   If the page is a pageinfo, the new pageinfo structure will not rely on the size (it will copy it, but it won't be marked as loaded). If it is a page, the size will
+         *   be calculated from the viewport and it will be marked as loaded.
+         *   This is done in this way, because when creating the pages in the first time, they will be created assuming that they are of the same size than the first one. If they
+         *   are not, the size will be adjusted later, when the pages are loaded.
+         *
+         * @param {*} page - the pageinfo (or the page) from which to create the pageinfo structure
+         * @param {*} i - the number of the page to be created
+         * @returns pageinfo - the pageinfo structure for the page
+         */
+        _createSkeleton(page, i) {
+            let pageinfo = {
+                $div: null,
+                width: 0,
+                height: 0,
+                loaded: false,
+            };
+
+            // If it is a page, the size will be obtained from the viewport; otherwise, it will be copied from the provided pageinfo
+            if (page.getViewport !== undefined) {
+                let viewport = page.getViewport({rotation:this._rotation,scale:1});
+                pageinfo.width = viewport.width;
+                pageinfo.height = viewport.height;
+                pageinfo.loaded = true;
+            } else {
+                pageinfo.width = page.width;
+                pageinfo.height = page.height;
+            }
+            console.assert(((pageinfo.width > 0) && (pageinfo.height > 0)), "Page width and height must be greater than 0");
+
+            // Now create the skeleton for the divs
+            pageinfo.$div = $(`<div id="page-${i}">`)
+                .attr('data-page', i)
+                .data('width', pageinfo.width)
+                .data('height', pageinfo.height)
+                .data('zoom', this._zoom.current)
+                .addClass(this.settings.pageClass)
+                .width(pageinfo.width * this._zoom.current)
+                .height(pageinfo.height * this._zoom.current);
+
+            let $content = $(`<div class="${this.settings.contentClass}">`)
+                .width(pageinfo.width)
+                .height(pageinfo.height);
+
+            pageinfo.$div.append($content);
+
+            // Clean the page (i.e. put the empty content, etc.)
+            this._cleanPage(pageinfo.$div);
+
+            return pageinfo;
+        }
+
+        /**
+         * This function places the page.$div in the container, according to its page number (i.e. it searches for the previous page and puts this page after)
+         *   * in principle, this method sould not be needed because all the pages are put in order; but this is created just in case it is needed in further versions
+         * @param {*} pageinfo - the pageinfo structure for the page (needs a valid $div)
+         * @param {*} i - the number of the page
+         */
+        _placeSkeleton(pageinfo, i) {
+            let prevpage = i - 1;
+            let $prevpage = null;
+            while ((prevpage>0) && (($prevpage = this.$container.find(`.${this.settings.pageClass}[data-page="${prevpage}"]`)).length === 0)) {
+                prevpage--;
+            }
+            if (prevpage === 0) {
+                this.$container.append(pageinfo.$div);
+            }
+            else {
+                $prevpage.after(pageinfo.$div);
+            }
+        }
+
+        /**
+         * Creates the initial skeletons for all the pages, and places them into the container
+         * @param {page/pageinfo} pageinfo - the initial pageinfo (or page) structure
+         */
+        _createSkeletons(pageinfo) {
+            for (let i = 1; i <= this.pageCount; i++) {
+                if (this.pages[i] === undefined) {
+
+                    // Create the pageinfo structure, store it and place it in the appropriate place (the next page will be created similar to the previous one)
+                    pageinfo = this._createSkeleton(pageinfo, i);
+                    this.pages[i] = pageinfo;
+                    this._placeSkeleton(pageinfo, i);
+
+                    // Call the callback function (if provided)
+                    if (this._documentReady) {
+                        if (typeof this.settings.onNewPage === "function") {
+                            this.settings.onNewPage.call(this, pageinfo.$div, i);
+                        }
+                        this.$container.get(0).dispatchEvent(new CustomEvent("newpage", { detail: { pageNumber: i, page: pageinfo.$div.get(0) } }));
+                    }
+                }
+            }
+        }
+
+        /**
+         * Function to set the active page, and calling the callback (if provided)
+         * @param {*} i - the number of the page to set active
+         */
+        _setActivePage(i) {
+            if (this._activePage !== i) {
+                this._activePage = i;
+                let activePage = this.getActivePage();
+                if (this._documentReady) {
+                    if (typeof this.settings.onActivePageChanged === "function")
+                        this.settings.onActivePageChanged.call(this, activePage, i);
+                    this.$container.get(0).dispatchEvent(new CustomEvent("activepagechanged", { detail: { activePageNumber: i, activePage: activePage==null?null:activePage.get(0) } }));
+                }
+            }
+        }
+
+        /**
+         * Obtains the area of a div that falls in the viewer
+         * @param {*} $page - div whose area is to be calculated
+         * @returns the visible area
+         */
+        _areaOfPageVisible($page) {
+            if ($page === undefined) {
+                return 0;
+            }
+            let c_offset = this.$container.offset();
+            let c_width = this.$container.width();
+            let c_height = this.$container.height();
+            let position = $page.offset();
+            position.top -= c_offset.top;
+            position.left -= c_offset.left;
+            position.bottom = position.top + $page.outerHeight();
+            position.right = position.left + $page.outerWidth();
+            let page_y0 = Math.min(Math.max(position.top, 0), c_height);
+            let page_y1 = Math.min(Math.max($page.outerHeight() + position.top, 0), c_height);
+            let page_x0 = Math.min(Math.max(position.left, 0), c_width);
+            let page_x1 = Math.min(Math.max($page.outerWidth() + position.left, 0), c_width);
+            let vis_x = page_x1 - page_x0;
+            let vis_y = page_y1 - page_y0;
+            return (vis_x * vis_y);
+        }
+
+        /**
+         * Function that returns true if the page is considered to be visible (the amount of visible area is greater than the threshold)
+         * @param {*} i - the number of page to check
+         * @returns true if the page is visible
+         */
+        isPageVisible(i) {
+            if ((this.pdf === null) || (i === undefined) || (i === null) || (i < 1) || (i > this.pdf.numPages)) {
+                return false;
+            }
+            let $page = i;
+            if (typeof i === "number") {
+                if (this.pages[i] === undefined)
+                    return false;
+                $page = this.pages[i].$div;
+            }
+            return this._areaOfPageVisible($page) > ($page.outerWidth() * $page.outerHeight() * this.settings.visibleThreshold);
+        }
+
+        /**
+         * Function that calculates which pages are visible in the viewer, draws them (if not already drawn), and clears those not visible
+         * @param {*} forceRedraw - if true, the visible pages will be redrawn regardless of whether they are already drawn (useful for zoom changes)
+         */
+        _visiblePages(forceRedraw = false) {
+            // Will grab the page with the greater visible area to set it as active
+            let max_area = 0;
+            let i_page = null;
+
+            // If there are no visible pages, return
+            if (this.pages.length === 0) {
+                this._visibles = [];
+                this._setActivePage(0);
+                return;
+            }
+
+            // Calculate the visible area for each page and consider it visible if the visible area is greater than 0
+            let $visibles = this.pages.filter(function(pageinfo) {
+                let areaVisible = this._areaOfPageVisible(pageinfo.$div);
+                if (areaVisible > max_area) {
+                    max_area = areaVisible;
+                    i_page = pageinfo.$div.data('page');
+                }
+                return areaVisible > 0;
+            }.bind(this)).map((x) => x.$div);
+
+            // Set the active page
+            this._setActivePage(i_page);
+
+            // Now get the visible pages
+            let visibles = $visibles.map((x) => $(x).data('page'));
+            if (visibles.length > 0) {
+                // Now will add some extra pages (before and after) the visible ones, to have them prepared in case of scroll
+                let minVisible = Math.min(...visibles);
+                let maxVisible = Math.max(...visibles);
+
+                for (let i = Math.max(1, minVisible - this.settings.extraPagesToLoad) ; i < minVisible ; i++) {
+                    if (!visibles.includes(i))
+                        visibles.push(i)
+                }
+                for (let i = maxVisible + 1; i <= Math.min(maxVisible + this.settings.extraPagesToLoad, this.pdf.numPages); i++) {
+                    if (!visibles.includes(i))
+                        visibles.push(i)
+                }
+            }
+
+            // Now will draw the visible pages, but if not forcing, will only draw those that were not visible before
+            let nowVisibles = visibles;
+            if (! forceRedraw) {
+                nowVisibles = visibles.filter(function (x) {
+                    return !this._visibles.includes(x)
+                }.bind(this));
+            }
+
+            // Get the pages that were visible before, that are not visible now, and clear them
+            this._visibles.filter(function (x) {
+                return !visibles.includes(x)
+            }).forEach(function (i) {
+                this._cleanPage(this.pages[i].$div);
+            }.bind(this))
+
+            // Store the new visible pages
+            this._visibles = visibles;
+
+            // And now we'll queue the pages to load
+            this.loadPages(...nowVisibles);
+        }
+
+        /**
+         * Function queue a set of pages to be loaded; if not loading, the function starts the loading worker
+         * @param  {...pageinfo} pages - the pages to load
+         */
+        loadPages(...pages) {
+            this._pagesLoading.push(...pages);
+            if (this._loading) {
+                return;
+            }
+            this._loadingTask();
+        }
+
+        /**
+         * Function that gets the pages pending to load and renders them sequentially (to avoid multiple rendering promises)
+         */
+         _loadingTask() {
+            this._loading = true;
+            if (this._pagesLoading.length > 0) {
+                let pagei = this._pagesLoading.shift();
+                this.pdf.getPage(pagei).then(function(page) {
+                    // Render the page and update the information about the page with the loaded values
+                    this._renderPage(page, pagei);
+                }.bind(this)).then(function(pageinfo) {
+                    // Once loaded, we are not loading anymore
+                    if (this._pagesLoading.length > 0) {
+                        this._loadingTask();
+                    }
+                }.bind(this));
+            }
+            // Free the loading state
+            this._loading = false;
+        }
+
+        /**
+         * Function that sets the scroll position of the container to the specified page
+         * @param {*} i - the number of the page to set the scroll position
+         */
+        scrollToPage(i) {
+            if ((this.pages.length === 0) || (this.pages[i] === undefined)) {
+                return;
+            }
+            let $page = this.pages[i].$div;
+            if ($page.length === 0) {
+                console.warn(`Page ${i} not found`);
+                return;
+            }
+            let position = $page.position();
+            let containerPosition = this.$container.position();
+            if (position !== undefined) {
+                this.$container.get(0).scrollTop = this.$container.get(0).scrollTop + position.top - containerPosition.top;
+                this.$container.get(0).scrollLeft = this.$container.get(0).scrollLeft + position.left - containerPosition.left;
+            }
+            this._setActivePage(i);
+        }
+
+        /**
+         * Function that renders the page in a canvas, and sets the canvas into the $div
+         * @param {*} page - the page to be rendered
+         * @param {*} i - the number of the page to be rendered
+         * @returns a promise to render the page (the result of the promise will be the pageinfo)
+         */
+        _renderPage(page, i) {
+            // Get the pageinfo structure
+            let pageinfo = this.pages[i];
+            let scale = this.settings.renderingScale;
+
+            // Calculate the pixel ratio of the device (we'll use a minimum of 1)
+            let pixel_ratio = window.devicePixelRatio || 1;
+            // Update the information that we know about the page to the actually loaded page
+            let viewport = page.getViewport({rotation: this._rotation, scale: this._zoom.current * scale});
+            pageinfo.width = (viewport.width / this._zoom.current) / scale;
+            pageinfo.height = (viewport.height / this._zoom.current) / scale;
+            pageinfo.$div.data("width", pageinfo.width);
+            pageinfo.$div.data("height", pageinfo.height);
+            pageinfo.$div.width(pageinfo.width * this._zoom.current);
+            pageinfo.$div.height(pageinfo.height * this._zoom.current);
+            pageinfo.loaded = true;
+
+            // Create the canvas and prepare the rendering context
+            let $canvas = $('<canvas></canvas>');
+            let canvas = $canvas.get(0);
+            let context = canvas.getContext('2d');
+            canvas.height = viewport.height * pixel_ratio;
+            canvas.width = viewport.width * pixel_ratio;
+            canvas.getContext("2d")//.scale(pixel_ratio, pixel_ratio);
+            var transform = pixel_ratio !== 1
+                ? [pixel_ratio, 0, 0, pixel_ratio, 0, 0]
+                : null;
+            var renderContext = {
+                canvasContext: context,
+                viewport: viewport,
+                transform: transform,
+            };
+
+            // Render the page and put the resulting rendered canvas into the page $div
+            return page.render(renderContext).promise.then(function() {
+                this._setPageContent(pageinfo.$div, $canvas);
+
+                // Call the callback (if provided)
+                if (this._documentReady) {
+                    if (typeof this.settings.onPageRender === "function") {
+                        this.settings.onPageRender.call(this, pageinfo.$div, i);
+                    }
+                    this.$container.get(0).dispatchEvent(new CustomEvent("pagerender", { detail: { pageNumber: i, page: pageinfo.$div.get(0) } }));
+                }
+                return pageinfo;
+            }.bind(this));
+        }
+
+        /** Gets the div object corresponding to the active page */
+        getActivePage() {
+            if ((this._activePage === null) || (this.pdf === null)) {
+                return null;
+            }
+            if ((this._activePage < 1) || (this._activePage > this.pdf.numPages)) {
+                return null;
+            }
+            return this.pages[this._activePage].$div;
+        }
+
+        /** Gets all the pages of the document (the pageinfo structures) */
+        getPages() {
+            return this.pages;
+        }
+
+        /** Gets the number of pages of the document */
+        getPageCount() {
+            if (this.pdf === null) {
+                return 0;
+            }
+            return this.pdf.numPages;
+        }
+
+        /** Scrolls to the next page (if any) */
+        next() {
+            if (this._activePage < this.pdf.numPages) {
+                this.scrollToPage(this._activePage + 1);
+            }
+        }
+
+        /** Scrolls to the previous page (if any) */
+        prev() {
+            if (this._activePage > 1) {
+                this.scrollToPage(this._activePage - 1);
+            }
+        }
+
+        first() {
+            if (this._activePage !== 1) {
+                this.scrollToPage(1);
+            }
+        }
+
+        last() {
+            if (this.pdf === null)
+                return;
+            if (this._activePage !== this.pdf.numPages) {
+                this.scrollToPage(this.pdf.numPages);
+            }
+        }
+        /**
+         * Rotates the pages of the document
+         * @param {*} deg - degrees to rotate the pages
+         * @param {*} accumulate - whether the rotation is accumulated or not
+         */
+        rotate(deg, accumulate = false) {
+            if (accumulate) {
+                deg = deg + this._rotation;
+            }
+            this._rotation = deg;
+
+            let container = this.$container.get(0);
+            let prevScroll = {
+                top: container.scrollTop,
+                left: container.scrollLeft,
+                height: container.scrollHeight,
+                width: container.scrollWidth
+            };
+
+            return this.forceViewerInitialization().then(function() {
+                let newScroll = {
+                    top: container.scrollTop,
+                    left: container.scrollLeft,
+                    height: container.scrollHeight,
+                    width: container.scrollWidth
+                };
+                container.scrollTop = prevScroll.top * (newScroll.height / prevScroll.height);
+                container.scrollLeft = prevScroll.left * (newScroll.width / prevScroll.width);
+            }.bind(this));
+        }
+        /**
+         * This functions forces the creation of the whole content of the viewer (i.e. new divs, structures, etc.). It is usefull for full refresh of the viewer (e.g. when changes
+         *   the rotation of the pages)
+         * @returns a promise that is resolved when the viewer is fully initialized
+         */
+        forceViewerInitialization() {
+            // Store the pdf file
+            // Now prepare a placeholder for the pages
+            this.pages = [];
+
+            // Remove all the pages
+            this.$container.find(`.${this.settings.pageClass}`).remove();
+
+            this._pagesLoading = [];
+            this._loading = false;
+            this._visibles = [];
+            this._activePage = null;
+            return this.pdf.getPage(1).then(function(page) {
+                this._createSkeletons(page);
+                this._visiblePages();
+                this._setActivePage(1);
+            }.bind(this));
+        }
+        /**
+         * Loads the document and creates the pages
+         * @param {string} document - the url of the document to load
+         */
+        async loadDocument(document) {
+            // The document is not ready while loading
+            this._documentReady = false;
+
+            // Now prepare a placeholder for the pages
+            this.pages = [];
+
+            // Remove all the pages
+            this.$container.find(`.${this.settings.pageClass}`).remove();
+
+            // Let's free the pdf file (if there was one before), and rely on the garbage collector to free the memory
+            this.pdf = null;
+
+            // Load the task and return the promise to load the document
+            let loadingTask = pdfjsLib.getDocument(document);
+            return loadingTask.promise.then(function(pdf) {
+                // Store the pdf file and get the
+                this.pdf = pdf;
+                this.pageCount = pdf.numPages;
+                this._rotation = 0;
+                return this.forceViewerInitialization();
+            }.bind(this)).then(function() {
+                if (typeof this.settings.onDocumentReady === "function") {
+                    this.settings.onDocumentReady.call(this);
+                }
+                this.$container.get(0).dispatchEvent(new CustomEvent("documentready", { detail: { document: this.pdf } }));
+
+                // This is a trick to force active page changed event triggering after the document is ready
+                this._setActivePage(0)
+                this._documentReady = true;
+                this._setActivePage(1)
+            }.bind(this));
+        }
+    }
+
+    function recoverAttributes(target, attributeDefaults) {
+        const camelcaseToSnakecase = str => str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
+        let $target = $(target);
+        let result = {};
+        if ($target.length > 0) {
+            $target = $($target[0]);
+            for (let originalAttributeName in attributeDefaults) {
+                let attributeName = camelcaseToSnakecase(originalAttributeName)
+                let attributeValue = $target.attr(attributeName);
+                if (attributeValue != null) {
+                    switch (typeof(attributeDefaults[originalAttributeName])) {
+                        case 'float':
+                            try {
+                                attributeValue = parseFloat(attributeValue);
+                            } catch (_) {
+                            }
+                            break;
+                        case 'number':
+                            try {
+                                attributeValue = parseInt(attributeValue);
+                            } catch (_) {
+                            }
+                            break;
+                        case 'function':
+                            let functionString = attributeValue;
+                            attributeValue = function() { eval(functionString); }.bind(target[0]); break;
+                        default:
+                            break;
+                    }
+                    result[originalAttributeName] = attributeValue;
+                }
+            };
+        }
+        return result;
+    }
+
+    function init(element) {
+        let options = recoverAttributes(element, Object.assign({
+            pdfDocument: "", initialZoom: ""
+        }, defaults));
+        if (options["pdfDocument"] != null) {
+            let pdfViewer = new PDFjsViewer($(element), options);
+            pdfViewer.loadDocument(options["pdfDocument"]).then(function() {
+                if (options["initialZoom"] != null) {
+                    pdfViewer.setZoom(options["initialZoom"]);
+                }
+            })
+            element.get(0).pdfViewer = pdfViewer;
+        }
+    }
+
+    $(function() {
+        $('.pdfjs-viewer').each(function() {
+            let $viewer = $(this);
+            init($viewer);
+        })
+    });
+
+    exports.PDFjsViewer = PDFjsViewer;
+})(window, jQuery)

+ 19 - 0
public/pdf/style/download.css

@@ -0,0 +1,19 @@
+.download-container {
+  position: fixed;
+  bottom: 8px;
+  right: 8px;
+  z-index: 11;
+  padding: 8px;
+  width: 36px;
+  height: 36px;
+  background-color: #12312c;
+  border-radius: 50%;
+  
+  display: none;
+}
+
+.download-container svg {
+  width: 100%;
+  height: 100%;
+  fill: #38ff6e;
+}

+ 65 - 0
public/pdf/style/loader.css

@@ -0,0 +1,65 @@
+.loader-container {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  display: flex;
+  flex-direction: row;
+  transform: translate(-50%, -50%);
+}
+
+.loader-container div {
+  height: 20px;
+  width: 5px;
+  background: #FE4A49;
+  margin: 3px;
+  border-radius: 25px;
+}
+
+.loader-container div:nth-child(1) {
+  animation: loader 1s ease-in-out infinite 0s;
+}
+
+.loader-container div:nth-child(2) {
+  animation: loader 1s ease-in-out infinite 0.1s;
+}
+
+.loader-container div:nth-child(3) {
+  animation: loader 1s ease-in-out infinite 0.2s;
+}
+
+.loader-container div:nth-child(4) {
+  animation: loader 1s ease-in-out infinite 0.3s;
+}
+
+.loader-container div:nth-child(5) {
+  animation: loader 1s ease-in-out infinite 0.4s;
+}
+
+.loader-container div:nth-child(6) {
+  animation: loader 1s ease-in-out infinite 0.5s;
+}
+
+.loader-container div:nth-child(7) {
+  animation: loader 1s ease-in-out infinite 0.6s;
+}
+
+@keyframes loader {
+  0% {
+    transform: scaleY(1);
+    background: #FED766;
+  }
+  25% {
+    background: #009FB7;
+  }
+  50% {
+    transform: scaleY(2);
+    background: #59CD90;
+  }
+  75% {
+    background: #FE4A49;
+  }
+  100% {
+    transform: scaleY(1);
+    background: #D91E36;
+  }
+}

+ 54 - 0
public/pdf/style/viewer.css

@@ -0,0 +1,54 @@
+.pdf-container {
+  margin: 1em auto;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.pdf-page {
+  position: relative;
+  margin: auto;
+  box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.1), 0 6px 20px 0 rgba(0, 0, 0, 0.09);
+  /* display: flex; */
+}
+
+.pdf-page canvas {
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  width: 100%;
+}
+
+.pdf-page.placeholder {
+  display: flex;
+  margin-bottom: 0em !important;
+  margin-top: 0em !important;
+  height: 100%;
+  width: 100%;
+}
+
+.pdf-page .content-wrapper {
+  margin: 0 !important;
+  padding: 0 !important;
+  display: flex !important;
+}
+
+.pdf-page .content-wrapper .loader {
+  border: 2px solid #f3f3f3;
+  border-top: 3px solid #3498db;
+  border-radius: 50%;
+  width: 24px;
+  height: 24px;
+  animation: spin 1s linear infinite;
+  margin: auto;
+}
+
+@keyframes spin {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
+

+ 5 - 15
src/App.vue

@@ -3,20 +3,10 @@ const title = import.meta.env.SIX_APP_TITLE;
 const theme = ref<'light' | 'dark'>('dark');
 </script>
 <template>
-  <van-config-provider class="page-container" :theme>
-    <router-view class="page-header" name="header"></router-view>
-    <router-view class="page-content"></router-view>
-    <router-view class="page-footer" name="footer"></router-view>
+  <van-config-provider :theme>
+    <router-view class="page-container" v-slot="{ Component }">
+      <component :is="Component" />
+    </router-view>
   </van-config-provider>
 </template>
-<style scoped lang="scss">
-.page {
-  &-header, &-footer {
-    flex: none;
-  }
-
-  &-content {
-    flex: auto;
-  }
-}
-</style>
+<style scoped lang="scss"></style>

+ 176 - 0
src/assets/camera.html

@@ -0,0 +1,176 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+  <title>拍摄</title>
+  <style>
+    html, body {
+      margin: 0;
+      padding: 0;
+      width: 100vw;
+      height: 100vh;
+      position: relative;
+      overflow: hidden;
+      pointer-events: none;
+    }
+
+    canvas {
+      position: absolute;
+    }
+  </style>
+</head>
+<body class="camera-container">
+<script>
+  /**
+   * 设置视口宽度
+   * @param width
+   */
+  function updateViewportWidth(width) {
+    let metaTag = document.querySelector('meta[name="viewport"]');
+    if ( !metaTag ) {
+      metaTag = document.createElement('meta');
+      metaTag.setAttribute('name', 'viewport');
+      document.head.appendChild(metaTag);
+    }
+    const content = metaTag.getAttribute('content');
+    metaTag.setAttribute('content', content.replace(/width=[^,]+/, `width=${ width }`));
+  }
+
+  /**
+   * 媒体流
+   * @type {MediaStream}
+   */
+  let stream;
+  /**
+   * 视频元素
+   * @type {HTMLVideoElement}
+   */
+  let video;
+  /**
+   * 画布元素
+   * @type {HTMLCanvasElement}
+   */
+  let canvas;
+
+  /**
+   * @type {number}
+   */
+  let zoom;
+
+  /**
+   * @type {number}
+   */
+  let dpr;
+  let x = 0, y = 0;
+
+  /**
+   * 加载摄像头
+   * @param constraints
+   * @param constraints.width
+   * @param constraints.height
+   * @param constraints.zoom
+   * @param constraints.dpr
+   * @return {Promise<void>}
+   */
+  async function loadCamera(constraints) {
+    zoom = constraints.zoom || zoom || 1;
+    dpr = constraints.dpr || window.devicePixelRatio || 1;
+    const width = constraints.width * zoom * dpr;
+    const height = constraints.height * zoom * dpr;
+    updateViewportWidth(width);
+    document.body.style.width = `${ width }px`;
+    document.body.style.height = `${ height }px`;
+
+    stream = await getMediaStream({ width, height });
+
+    video = document.createElement('video');
+    video.srcObject = stream;
+    video.addEventListener('canplay', () => video.paused && video.play());
+    document.body.appendChild(video);
+
+    canvas = document.createElement('canvas');
+    document.body.appendChild(canvas);
+  }
+
+  /**
+   * 获取图片
+   * @return {string} bas64
+   */
+  function handle(promise) {
+    const context = canvas.getContext('2d');
+    context.drawImage(
+      video,
+      x * dpr, y * dpr, canvas.width, canvas.height,
+      0, 0, canvas.width , canvas.height,
+    );
+    const base64 = canvas.toDataURL('image/png');
+    context.clearRect(0, 0, canvas.width, canvas.height);
+    return base64;
+  }
+
+  /**
+   *
+   * @param constraints
+   * @param {number} constraints.width
+   * @param {number} constraints.height
+   * @returns {Promise<MediaStream>}
+   */
+  async function getMediaStream(constraints) {
+    const stream = await navigator.mediaDevices.getUserMedia({ video: constraints ?? true, audio: false, });
+    if ( constraints?.width != null && constraints?.height != null ) {
+      // 修正宽高
+      const track = stream.getVideoTracks()[ 0 ];
+      const { width: CW = 1, height: CH = 1, aspectRatio: CAR = CW / CH, ..._constraints } = constraints;
+      const { width: SW = 1, height: SH = 1, aspectRatio: SAR = SW / SH } = track.getSettings();
+      if ( SAR > CAR || (
+        CW === SH && CH === SW
+      ) ) {
+        await track.applyConstraints({ ..._constraints, height: CW, width: CH });
+      }
+    }
+    return stream;
+  }
+
+  function updateCoordinate(offsetX = 0, offsetY = 0) {
+    offsetX = offsetX * zoom;
+    offsetY = offsetY * zoom;
+    const track = stream.getVideoTracks()[ 0 ];
+    const { width, height } = track.getSettings();
+
+    video.width = width;
+    video.height = height;
+    video.style.width = `${ width / dpr }px`;
+    video.style.height = `${ height / dpr }px`;
+
+    const root = document.documentElement.getBoundingClientRect();
+    const rect = video.getBoundingClientRect();
+
+    x = rect.width / 2 - root.width / 2 + offsetX;
+    y = rect.height / 2 - root.height / 2 + offsetY;
+
+    canvas.width = root.width * dpr;
+    canvas.height = root.height * dpr;
+    canvas.style.width = `${ root.width }px`;
+    canvas.style.height = `${ root.height }px`;
+    canvas.style.left = `${ x }px`;
+    canvas.style.top = `${ y }px`;
+
+    document.documentElement.scroll({ top: y, left: x, behavior: 'instant' });
+
+    if ( video.paused ) video.play();
+
+    log();
+  }
+
+  function log() {
+    const track = stream.getVideoTracks()[ 0 ];
+    const setting = track.getSettings();
+    console.group(`获取摄像头:`);
+    console.log(`width`, setting.width);
+    console.log(`height`, setting.height);
+    console.groupEnd();
+  }
+</script>
+</body>
+</html>

+ 1 - 0
src/assets/images/mini-program.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1732606154666" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4264" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 0a512 512 0 1 0 512 512A512 512 0 0 0 512 0z m256.717 460.186a151.962 151.962 0 0 1-87.347 65.74 83.251 83.251 0 0 1-24.474 4.096 29.082 29.082 0 0 1 0-58.163 15.667 15.667 0 0 0 6.451-1.229 91.443 91.443 0 0 0 55.91-40.96 75.264 75.264 0 0 0 11.06-39.628c0-45.978-42.496-83.866-94.31-83.866a105.267 105.267 0 0 0-51.2 13.414 81.92 81.92 0 0 0-43.725 70.452v244.224a138.445 138.445 0 0 1-72.704 120.422 159.642 159.642 0 0 1-79.77 20.48c-84.378 0-153.6-63.488-153.6-142.029a136.192 136.192 0 0 1 19.763-69.837 151.962 151.962 0 0 1 87.347-65.74 85.914 85.914 0 0 1 24.474-4.096 29.082 29.082 0 1 1 0 58.163 15.667 15.667 0 0 0-6.451 1.229 95.949 95.949 0 0 0-55.91 40.96 75.264 75.264 0 0 0-11.06 39.628c0 45.978 42.496 83.866 94.925 83.866a105.267 105.267 0 0 0 51.2-13.414 81.92 81.92 0 0 0 43.622-70.452V390.35a138.752 138.752 0 0 1 72.807-120.525 151.245 151.245 0 0 1 79.155-21.504c84.378 0 153.6 63.488 153.6 142.029a136.192 136.192 0 0 1-19.763 69.837z" fill="#00B240" p-id="4265"></path></svg>

二進制
src/assets/images/pulse-hand-left.png


二進制
src/assets/images/pulse-hand-right.png


+ 111 - 0
src/components/AnalysisComponent.vue

@@ -0,0 +1,111 @@
+<script setup lang="ts">
+import type { AnalysisModel } from '@/request/model';
+
+type Props = AnalysisModel & { title?: string, exceptionType?: 'list' | 'group' };
+
+const {
+  title = '分析', exceptionType = 'list',
+  table, result = null, cover = [],
+  exception, exceptionGroup,
+} = defineProps<Props>();
+</script>
+
+<template>
+  <van-skeleton class="analysis" :row="5" :loading="result == null" v-if="result !== ''">
+    <slot>
+      <div class="card m-6 text-lg">
+        <div class="card__title mb-3 text-primary text-2xl font-bold">{{ title }}</div>
+        <slot name="content" :result="result" :cover="cover">
+          <div class="card__content">
+            <div
+              class="grid grid-rows-1 gap-8"
+              :class="cover?.length > 1 ? 'grid-cols-2' : 'grid-cols-1'"
+              v-if="cover?.length"
+            >
+              <img class="m-auto w-2/4 object-contain" v-for="src in cover" :key="src" :src="src" alt="分析图像" />
+            </div>
+            <table class="mt-8 mb-2 w-full table-auto border border-collapse border-primary">
+              <thead>
+                <tr>
+                  <th
+                    class="py-4 px-2 text-primary border border-primary"
+                    v-for="(value, i) in table?.columns"
+                    :key="i"
+                    v-html="value"
+                  ></th>
+                </tr>
+              </thead>
+              <tbody>
+              <tr
+                v-for="item in table?.data"
+                :key="item.columns[0]"
+                :data-exception="item.exception"
+                :data-invalid="item.invalid"
+              >
+                <td
+                    class="py-4 px-2 border border-primary text-center text-grey"
+                    v-for="(value, i) in item.columns"
+                    :key="i"
+                    v-html="value"
+                ></td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </slot>
+      </div>
+      <slot name="exception">
+        <div class="grid grid-rows-1 grid-cols-2 gap-8 m-6" v-if="exceptionType === 'list'">
+          <div class="card text-lg" v-for="item in exception" :key="item.title">
+            <div class="card__title mb-3 text-primary text-2xl font-bold">{{ item.title }}</div>
+            <div class="card__content">
+              <div class="flex my-6 justify-center">
+                <img v-if="item.cover" class="flex-none w-2/4 object-scale-down" :src="item.cover" alt="分析异常图像" />
+                <div class="flex-none ml-8">
+                  <div
+                    class="my-2 px-4 py-2 rounded-lg border border-primary-400 text-primary"
+                    v-for="value in item.tags"
+                    :key="value"
+                  >
+                    {{ value }}
+                  </div>
+                </div>
+              </div>
+              <div class="my-2 text-grey" v-for="description in item.descriptions" :key="description.value">
+                <label>{{ description.label }}</label>
+                <span v-html="description.value"></span>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="grid grid-rows-1 grid-cols-1 gap-8 m-6" v-else-if="exceptionType === 'group'" >
+          <div class="card text-lg" v-for="group in exceptionGroup" :key="group.key">
+            <div class="card__content">
+              <div class="flex mb-6 justify-center">
+                <img v-if="group.key" class="flex-none w-2/4 max-h-[200px] object-scale-down" :src="group.key" alt="分析异常图像" />
+              </div>
+              <div class="my-3" v-for="item in group.exception" :key="item.title">
+                <div class="card__title mb-2 text-primary text-2xl font-bold">{{ item.title }}</div>
+                <div class="card__content">
+                  <div class="my-2 text-grey" v-for="description in item.descriptions" :key="description.value">
+                    <label>{{ description.label }}</label>
+                    <span v-html="description.value"></span>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </slot>
+    </slot>
+  </van-skeleton>
+</template>
+
+<style scoped lang="scss">
+tr[data-exception='true'] td:nth-of-type(2) {
+  color: #f87171;
+}
+tr[data-invalid='true'] td:nth-of-type(2) {
+  color: #9ca3af;
+}
+</style>

+ 0 - 33
src/hooks/useTitle.ts

@@ -1,33 +0,0 @@
-import { useRouteMeta }                        from '@/router/hooks/useRouteMeta';
-import { defaultDocument, tryOnBeforeUnmount } from '@vueuse/core';
-import type { Ref }                            from 'vue';
-
-
-let title: Ref<string>;
-
-
-export function useTitle() {
-  const defaultValue = import.meta.env.SIX_APP_TITLE;
-
-  title ??= useRouteMeta('title', defaultValue);
-
-  function format(t: string) {
-    const template = t === defaultValue ? '%s' : `%s | ${ import.meta.env.SIX_APP_TITLE }`;
-    return toValue(template).replace(/%s/g, t);
-  }
-
-
-  const handle = watch(
-    title,
-    (t, o) => {
-      if ( t !== o && defaultDocument ) {
-        defaultDocument.title = format(typeof t === 'string' ? t : '');
-      }
-    },
-    { immediate: true },
-  );
-
-  tryOnBeforeUnmount(handle);
-
-  return title;
-}

+ 61 - 0
src/loader/bridge.loader.ts

@@ -0,0 +1,61 @@
+import { processMethod, scanAccountMethod } from '@/request/api';
+import router                               from '@/router';
+
+import { platformIsAIO, platformIsAIO_1 } from '@/platform';
+
+export function waitFor(condition: () => boolean | Promise<boolean>, timeout: number = 300 * 1000) {
+  const start = Date.now();
+  const { promise, resolve, reject } = Promise.withResolvers<void>();
+  const check = async () => {
+    try {
+      if (await condition()) resolve();
+      else if (timeout && Date.now() - start >= timeout) reject({ message: 'waitForBridge timeout' });
+      else requestAnimationFrame(check);
+    } catch (e) {
+      reject(e);
+    }
+  };
+  return check().then(
+    () => promise,
+    () => promise
+  );
+}
+
+export default function bridgeLoader(): DEV.Loader {
+  window.AIO ??= {};
+  window.AIO.scan ??= (value) => {
+    if (!value) return -1;
+    const event = new CustomEvent('scan', { detail: { code: 0, data: { code: value, state: 0, type: -1 } } });
+    window.bridge.dispatchEvent(event);
+    return 0;
+  };
+  window.AIO.print ??= (value) => {
+    (window as any).sixWisdom.printPdfByUrl(value);
+  };
+
+  return async function () {
+    if (platformIsAIO_1()) window.bridge = new EventTarget() as InstanceType<typeof Bridge>;
+    if (platformIsAIO()) {
+      await waitFor(() => window.bridge != null);
+      window.bridge.addEventListener('scan', async ({ detail }) => {
+        if (detail.code !== 0 || detail.data?.code == null) return;
+        const route = unref(router.currentRoute);
+        if (route.meta?.scan) {
+          const { Toast } = await import(`@/platform/toast.ui`);
+          const toast = Toast.loading(100, { message: '加载中' });
+          const data = await scanAccountMethod(detail.data.code).catch(() => {});
+
+          if (data) {
+            const path = data?.path ?? (route?.path === '/screen' ? await processMethod() : route?.path);
+            const key = Date.now();
+            sessionStorage.setItem(`scan_${key}`, JSON.stringify(data));
+            await router.replace({ path, query: { scan: key } });
+            Toast.success('扫码成功');
+          }
+
+          toast.close();
+        } else import(`@/platform/notify.ui`).then(({ Notify }) => { Notify.warning(`请返回首页后,再进行扫码!`); });
+      });
+    }
+  };
+}

+ 1 - 1
src/loader/debug.loader.ts

@@ -7,7 +7,7 @@ export default function debugLoader(tag = 'debug', ignoreDevelop = true): DEV.Lo
     if (import.meta.env.DEV && ignoreDevelop) return;
 
     const query = getURLSearchParams();
-    const lib = (query.get(tag) as Lib) || 'eruda';
+    const lib = query.get(tag) as Lib;
     switch (lib) {
       case 'eruda':
         const { default: eruda } = await import('eruda');

+ 1 - 0
src/loader/index.ts

@@ -11,4 +11,5 @@ export default async function Loader(component: Component, ...loader: DEV.Loader
 }
 
 export { default as launchLoader } from './launch.loader';
+export { default as bridgeLoader } from './bridge.loader';
 export { default as debugLoader } from './debug.loader';

+ 5 - 2
src/main.ts

@@ -1,7 +1,9 @@
 import '@/themes/index.scss';
 
-import Loader, { debugLoader, launchLoader } from '@/loader';
-import { platformIsAIO }                     from '@/platform';
+import Loader, { bridgeLoader, debugLoader, launchLoader } from '@/loader';
+import { platformIsAIO }                                   from '@/platform';
+
+import './polyfill'
 
 import App from './App.vue';
 
@@ -9,6 +11,7 @@ import App from './App.vue';
 Loader(
   App,
   debugLoader('debug', !platformIsAIO()),
+  bridgeLoader(),
   launchLoader('#app'),
 ).then(
   (app) => {},

+ 35 - 24
src/modules/camera/ShadeFace.vue

@@ -1,32 +1,43 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { SVGPathData, SVGPathDataTransformer } from 'svg-pathdata';
 
+
+const Shade: SVGPathData[] = [
+  'M0 183A136 183 0 11272 183 136 183 0 110 183Z',
+].map(d => new SVGPathData(d));
+
+const { translateX = 0, translateY = 0, scale = 1, } = defineProps<{
+  translateX?: number; translateY?: number;
+  scale?: number;
+}>();
+
+const paths = ref<string[]>([]);
+
+watchEffect(() => {
+  paths.value = Shade.map(data => data
+    .transform(SVGPathDataTransformer.TRANSLATE(translateX, translateY))
+    .transform(SVGPathDataTransformer.SCALE(scale, scale))
+    .encode(),
+  );
+});
+</script>
 <template>
-  <svg width="225px" height="305px" viewBox="0 0 225 305" xmlns="http://www.w3.org/2000/svg">
+  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 366">
     <clipPath id="shade">
-      <ellipse
-        cx="135"
-        cy="183"
-        rx="135"
-        ry="183"
-        fill="none"
-        stroke="#FEFEFE"
-        stroke-width="2"
-        stroke-linecap="round"
-        stroke-linejoin="round"
-      />
+      <path v-for="(d,i) in paths" :id="'outline-'+i" stroke="#fefefe" stroke-width="2" fill="none" :d="d" />
     </clipPath>
-    <ellipse
-      cx="112.5"
-      cy="152.5"
-      rx="112.5"
-      ry="152.5"
-      fill="none"
-      stroke="#FEFEFE"
+    <path
+      id="face"
+      stroke="#fefefe"
       stroke-width="2"
-      stroke-linecap="round"
-      stroke-linejoin="round"
+      fill="none"
+      d="M0 183A136 183 0 11272 183 136 183 0 110 183Z"
     />
   </svg>
 </template>
-
-<style scoped lang="scss"></style>
+<style scoped lang="scss">
+svg { opacity: 0.85; }
+</style>
+<style lang="scss">
+.camera-container { clip-path: url("#shade");}
+</style>

+ 30 - 14
src/modules/camera/ShadeTongueDown.vue

@@ -1,35 +1,51 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { SVGPathData, SVGPathDataTransformer } from 'svg-pathdata';
 
+
+const Shade: SVGPathData[] = [
+  'M5 205C5 205 32 47 72 13 73 11 74 9 75 8 78 5 87-1 119 5 146 9 158 7 163 5 171 2 178 1 185 3 193 5 201 9 209 19 227 41 252 91 259 161 259 161 266 201 273 205 273 205 252 326 218 348 216 349 214 350 212 352 205 358 190 367 168 367 146 366 120 367 107 368 101 368 94 367 88 365 72 358 41 339 26 294 7 232 3 215 3 215 3 215 0 205 5 205Z',
+].map(d => new SVGPathData(d));
+
+const { translateX = 0, translateY = 0, scale = 1, } = defineProps<{
+  translateX?: number; translateY?: number;
+  scale?: number;
+}>();
+
+const paths = ref<string[]>([]);
+
+watchEffect(() => {
+  paths.value = Shade.map(data => data
+    .transform(SVGPathDataTransformer.TRANSLATE(translateX, translateY))
+    .transform(SVGPathDataTransformer.SCALE(scale, scale))
+    .encode(),
+  );
+});
+</script>
 <template>
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 366">
     <clipPath id="shade">
-      <path
-        id="outline"
-        stroke="#fefefe"
-        stroke-width="2"
-        fill="none"
-        d="M5 195C5 195 31 45 71 12 72 11 73 9 74 8 77 5 86-1 117 5 144 9 156 7 161 5 168 2 175 1 182 3 190 5 198 9 206 18 224 39 248 87 255 153 255 153 262 192 269 195 269 195 248 311 215 332 213 333 211 334 209 336 202 341 187 350 165 350 144 349 118 350 105 351 99 351 93 350 87 348 71 341 40 323 26 280 7 221 3 205 3 205 3 205-0 195 5 195Z"
-      />
+      <path v-for="(d,i) in paths" :id="'outline-'+i" stroke="#fefefe" stroke-width="2" fill="none" :d="d" />
     </clipPath>
     <path
       id="outline"
       stroke="#fefefe"
       stroke-width="2"
       fill="none"
-      d="M5 195C5 195 31 45 71 12 72 11 73 9 74 8 77 5 86-1 117 5 144 9 156 7 161 5 168 2 175 1 182 3 190 5 198 9 206 18 224 39 248 87 255 153 255 153 262 192 269 195 269 195 248 311 215 332 213 333 211 334 209 336 202 341 187 350 165 350 144 349 118 350 105 351 99 351 93 350 87 348 71 341 40 323 26 280 7 221 3 205 3 205 3 205-0 195 5 195Z"
+      d="M5 205C5 205 32 47 72 13 73 11 74 9 75 8 78 5 87-1 119 5 146 9 158 7 163 5 171 2 178 1 185 3 193 5 201 9 209 19 227 41 252 91 259 161 259 161 266 201 273 205 273 205 252 326 218 348 216 349 214 350 212 352 205 358 190 367 168 367 146 366 120 367 107 368 101 368 94 367 88 365 72 358 41 339 26 294 7 232 3 215 3 215 3 215 0 205 5 205Z"
     />
     <path
       id="interior"
       stroke="#fefefe"
       stroke-width="2"
       fill="none"
-      d="M13 225C13 225 35 92 80 57 80 57 94 42 125 54 125 54 135 60 159 49 183 39 208 68 232 121 232 121 253 181 252 196 252 196 263 204 244 223 244 223 207 300 134 302 61 304 24 248 13 225ZM64 77C64 77 79 81 81 76 83 71 81 59 76 60M80 61 123 53 135 57 136 65C136 68 134 70 132 72 125 75 111 81 91 78 86 77 82 73 82 69L80 61ZM193 62 135 57 136 65C136 68 138 71 140 72 147 77 164 84 188 76L193 62ZM207 76C207 76 192 82 188 76M34 143C34 143 126 18 239 143M110 241C132 232 129 211 134 160 137 128 130 113 124 107 121 104 116 103 112 103 43 113 26 212 26 212M166 245C166 245 129 243 136 125 136 125 135 84 182 108 229 132 239 198 239 198M255 201C255 201 247 190 227 207 227 207 215 210 212 222 208 234 193 241 193 241 193 241 174 235 166 248 166 248 151 233 135 245 135 245 110 239 103 243 103 243 75 233 70 248L58 232C58 232 57 223 37 223 37 223 31 208 16 214M37 223C37 223 22 234 13 225M103 243C103 243 117 293 96 294 75 295 70 248 70 248 70 248 74 266 76 275 77 283 65 284 65 284L44 267C43 227 60 234 60 234M135 245C135 245 148 293 122 296 122 296 107 298 107 281M166 248C166 248 170 278 158 290 146 303 137 279 137 279M193 241C193 241 200 276 181 283 181 283 175 286 165 274M212 222C212 222 230 223 221 248 211 272 194 267 194 267M225 208 244 223"
+      d="M18 225C18 225 22 83 85 57 85 57 99 42 130 54 130 54 140 60 164 49 188 39 213 68 237 121 237 121 258 181 257 196 257 196 261 208 249 223 248 222 212 300 145 302 66 304 29 248 18 225M73 245C137 232 138 232 139 95M205 245C171 245 134 243 139 95"
     />
   </svg>
 </template>
-
 <style scoped lang="scss">
-svg {
-  opacity: 0.85;
-}
+svg { opacity: 0.85; }
+</style>
+<style lang="scss">
+.camera-container { clip-path: url("#shade");}
 </style>
+

+ 28 - 26
src/modules/camera/ShadeTongueUp.vue

@@ -1,23 +1,31 @@
 <script setup lang="ts">
-</script>
+import { SVGPathData, SVGPathDataTransformer } from 'svg-pathdata';
+
+
+const Shade: SVGPathData[] = [
+  'M21 193C21 193-46 60 78 9 84 7 91 4 98 4 105 3 115 3 122 4 136 7 140 7 160 3 182 0 324 31 249 192Z',
+  'M139 127C139 127 49 81 27 152 6 223 51 357 130 364 205 370 244 331 249 181 249 181 259 75 139 127Z',
+].map(d => new SVGPathData(d));
+
+const { translateX = 0, translateY = 0, scale = 1, } = defineProps<{
+  translateX?: number; translateY?: number;
+  scale?: number;
+}>();
 
+const paths = ref<string[]>([]);
+
+watchEffect(() => {
+  paths.value = Shade.map(data => data
+    .transform(SVGPathDataTransformer.TRANSLATE(translateX, translateY))
+    .transform(SVGPathDataTransformer.SCALE(scale, scale))
+    .encode(),
+  );
+});
+</script>
 <template>
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 366">
     <clipPath id="shade">
-      <path
-        id="outline-up"
-        stroke="#fefefe"
-        stroke-width="2"
-        fill="none"
-        d="M21 193C21 193-46 60 78 9 84 7 91 4 98 4 105 3 115 3 122 4 136 7 140 7 160 3 182 0 324 31 249 192Z"
-      />
-      <path
-        id="outline-down"
-        stroke="#fefefe"
-        stroke-width="2"
-        fill="none"
-        d="M139 127C139 127 49 81 27 152 6 223 51 357 130 364 205 370 244 331 249 181 249 181 259 75 139 127Z"
-      />
+      <path v-for="(d,i) in paths" :id="'outline-'+i" stroke="#fefefe" stroke-width="2" fill="none" :d="d" />
     </clipPath>
     <path
       id="outline"
@@ -26,13 +34,6 @@
       fill="none"
       d="M21 193C21 193-46 60 78 9 84 7 91 4 98 4 105 3 115 3 122 4 136 7 140 7 160 3 182 0 324 31 249 192M139 127C139 127 49 81 27 152 6 223 51 357 130 364 205 370 244 331 249 181 249 181 259 75 139 127Z"
     />
-    <path
-      id="tooth"
-      stroke="#fefefe"
-      stroke-width="2"
-      fill="none"
-      d="M25 162C25 162-16 37 136 35 287 33 255 149 250 169M88 55C88 55 73 81 58 55 58 55 62 76 36 76M189 41C189 41 195 60 182 61 170 62 160 62 147 61 137 60 139 35 139 35 139 35 140 40 138 54 137 67 93 61 90 59 87 56 89 41 89 41M219 53C219 53 218 71 196 62 196 62 189 59 189 57"
-    />
     <path
       id="tongue"
       stroke="#fefefe"
@@ -42,9 +43,10 @@
     />
   </svg>
 </template>
-
 <style scoped lang="scss">
-svg {
-  opacity: 0.85;
-}
+svg { opacity: 0.85; }
+</style>
+<style lang="scss">
+.camera-container { clip-path: url("#shade");}
 </style>
+

+ 72 - 0
src/modules/camera/camera-result.page.vue

@@ -0,0 +1,72 @@
+<script setup lang="ts">
+import { processMethod2 } from '@/request/api';
+import { useRequest } from 'alova/client';
+import { useRouter } from 'vue-router';
+
+defineOptions({
+  name: 'CameraResult',
+});
+
+const router = useRouter();
+
+const { data, loading } = useRequest(processMethod2).onSuccess(({ data }) => {
+  init(data.optional ? 10 : 5);
+});
+const tips = computed(() => (data.value.route === '/screen' || data.value.optional ? '返回首页' : '获取健康调理方案'));
+const countdown = ref(5);
+
+let timer: ReturnType<typeof setInterval>;
+
+function done() {
+  if (!data.value.optional) next();
+  else {
+    clearInterval(timer);
+    router.replace({ path: '/screen' });
+  }
+}
+
+function next() {
+  clearInterval(timer);
+  router.replace({ path: data.value.route });
+}
+
+function init(value = 5) {
+  countdown.value = value;
+  timer = setInterval(() => {
+    const _countdown = countdown.value - 1;
+    if ( _countdown <= 0 ) { done(); } else { countdown.value = _countdown; }
+  }, 1000);
+}
+
+onBeforeUnmount(() => {
+  clearInterval(timer);
+});
+</script>
+<template>
+  <div>
+    <div class="page-content flex flex-col">
+      <header class="flex flex-col justify-center px-24" style="flex: 1 1 20%">
+        <div class="text-3xl text-center">拍摄完成</div>
+      </header>
+      <main class="flex flex-col justify-evenly px-24" style="flex: 1 1 50%">
+        <img class="size-40 mx-auto" src="@/assets/images/tips.png">
+        <div>
+          <div class="text-3xl text-center">恭喜您!</div>
+          <div class="text-3xl text-center my-8">完成舌面象的采集</div>
+        </div>
+      </main>
+      <footer class="flex flex-col items-center" style="flex: 1 1 30%">
+        <template v-if="!loading">
+          <van-button v-if="data.optional" class="decorate !text-xl !text-primary-400 !mb-6" :loading @click="next()">
+            {{ data.title || '获取健康调理方案' }}
+          </van-button>
+          <van-button class="decorate !text-xl !text-primary-400" :loading @click="done()">
+            {{ tips }}({{ countdown }})
+          </van-button>
+        </template>
+      </footer>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+</style>

+ 21 - 27
src/modules/camera/config.ts → src/modules/camera/camera.config.ts

@@ -6,23 +6,21 @@ const ShadeTongueUp = defineAsyncComponent(() => import('./ShadeTongueUp.vue'));
 const ShadeTongueDown = defineAsyncComponent(() => import('./ShadeTongueDown.vue'));
 const ShadeFace = defineAsyncComponent(() => import('./ShadeFace.vue'));
 
+
+export const DEFAULT_WIDTH = 270;
+export const DEFAULT_HEIGHT = 366;
+export const DEFAULT_ZOOM = 4;
+
 export interface ConfigProps {
   key: string;
   title: string;
-  required?: boolean;
+  label?: string;
   description?: string;
+  required?: boolean;
+  shade: Component;
   example?: string;
-  shade?: Component;
-  before?: {
-    label?: string;
-    description?: string;
-    example?: string;
-  };
-  after?: {
-    label?: string;
-    description?: string;
-    example?: string;
-  };
+  audio?: string;
+  video?: { scale?: number; offsetX?: number; offsetY?: number; };
 }
 
 export default [
@@ -30,37 +28,33 @@ export default [
     title: '舌面拍摄', key: 'upImg',
     shade: ShadeTongueUp,
     example: getClientURL('~/camera/step-11.example.png'),
+    audio: getClientURL('~/camera/step-11.audio.wav'),
     required: true,
-    description:
-      '请将舌头请确保舌面无食物残渣、没有染色,舌尖向下伸直、 舌体放松、舌面平展、口张大、请避免在有色光线下拍摄。',
+    description: '请确保舌面无食物残渣、没有染色,舌尖向下伸直、 舌体放松、舌面平展、口张大,请避免在有色光线下拍摄。',
     before: { label: '请将舌头放入框内,点击拍照' },
-    after: {
-      label: '请确认照片',
-      example: '',
-    },
+    after: { label: '请确认照片', example: '', audio: '' },
+    video: { scale: 1, offsetX: 0, offsetY: 50, },
   },
   {
     title: '舌下拍摄', key: 'downImg',
     shade: ShadeTongueDown,
     example: getClientURL('~/camera/step-21.example.png'),
+    audio: getClientURL('~/camera/step-21.audio.wav'),
     required: true,
     description: '舌尖向上抵住上颚、舌体放松、口张大、露出舌下,请避免在有色光线下拍摄。',
     before: { label: '请将舌下放入框内,点击拍照' },
-    after: {
-      label: '请确认照片',
-      example: '',
-    },
+    after: { label: '请确认照片', example: '', audio: '' },
+    video: { scale: 1, offsetX: 0, offsetY: 50, },
   },
   {
     title: '面部拍摄', key: 'faceImg',
     shade: ShadeFace,
     example: getClientURL('~/camera/step-31.example.png'),
+    audio: getClientURL('~/camera/step-31.audio.wav'),
     required: true,
     description: '请摘下眼镜、平视前方、不要浓妆、不要遮挡面部,请避免在有色光线下拍摄。',
     before: { label: '请将面部放入框内,点击拍照' },
-    after: {
-      label: '请确认照片',
-      example: '',
-    },
+    after: { label: '请确认照片', example: '', audio: '' },
+    video: { scale: 2, offsetX: 0, offsetY: 0, },
   },
-] as ConfigProps[];
+] as ( ConfigProps & { before: Partial<ConfigProps>; after: Partial<ConfigProps> } )[];

+ 151 - 0
src/modules/camera/camera.page.vue

@@ -0,0 +1,151 @@
+<script setup lang="ts">
+import { Dialog, Toast }                    from '@/platform';
+import { saveFileMethod, uploadFileMethod } from '@/request/api/camera.api';
+import { tryOnMounted, tryOnUnmounted }     from '@vueuse/core';
+import { useForm, useRequest }              from 'alova/client';
+import Segmented, { type ConfigProps }      from './camera.config';
+import Camera                               from './camera.vue';
+
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+
+
+let audio: HTMLAudioElement | void;
+
+const router = useRouter();
+
+const { form: dataset, loading: submitting, send: submit } = useForm(data => saveFileMethod(data), {
+  initialForm: { } as Record<string, any>,
+}).onSuccess(({ data }) => router.replace(data.route)).onError(() => {
+  handle();
+  step.value = 1;
+});
+
+
+const step = ref(0);
+const snapshot = ref<string | void>();
+const config = shallowRef<ConfigProps>();
+
+const showExample = ref(false);
+
+watch([ step, snapshot ], ([ step, snapshot ], old, onCleanup) => {
+  const { before, after, ..._config } = Segmented[ step - 1 ];
+  const old_audio = config.value?.audio;
+  config.value = Object.assign(_config, snapshot ? after : before);
+
+  if ( old_audio !== config.value.audio ) {
+    audio?.pause();
+    if ( config.value.audio && audio ) {
+      audio.src = config.value.audio;
+      audio.play()
+           .catch(() => Dialog.show({ message: '开始拍摄', theme: 'round-button' }))
+           .then(() => audio!.play());
+    }
+  }
+});
+
+const cameraRef = useTemplateRef<InstanceType<typeof Camera>>('camera');
+
+const { loading: uploading, send: update } = useRequest((file: File) => uploadFileMethod(file), {
+  immediate: false,
+});
+
+const handle = () => {
+  if ( submitting.value ) return;
+  snapshot.value = cameraRef.value?.handle();
+};
+const next = async () => {
+  if ( uploading.value || submitting.value ) return;
+  if ( snapshot.value ) {
+    uploading.value = true;
+    const key = config.value!.key;
+
+    const toast = Toast.loading(50, { message: '上传中...' });
+
+    const response = await fetch(snapshot.value);
+    const blob = await response.blob();
+    const file = new File(
+      [ blob ],
+      `${ dataset.value.patientId }_${ key?.replace?.(/img/ig, '') }`,
+      { type: blob.type },
+    );
+    try {
+      const { url } = await update(file);
+      dataset.value[ key ] = url;
+    } finally {
+      toast.close();
+    }
+  }
+
+  if ( step.value === Segmented.length ) {
+    submit();
+  } else {
+    handle();
+    step.value += 1;
+  }
+};
+
+tryOnMounted(() => {
+  audio = document.createElement('audio');
+  document.body.appendChild(audio);
+});
+tryOnUnmounted(() => {
+  audio?.pause();
+  audio = void 0;
+});
+</script>
+<template>
+  <div>
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
+          {{ config?.title }}
+        </div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页">
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col">
+      <header class="flex flex-col justify-center px-24">
+        <div class="text-3xl text-center">{{ config?.label }}</div>
+        <div class="mt-8 text-lg text-center tracking-wider leading-10">{{ config?.description }}</div>
+      </header>
+      <main class="flex justify-center items-center">
+        <Camera ref="camera" v-bind="config?.video" @loaded="step = 1;">
+          <template #shade="{scale}">
+            <component :is="config?.shade" :scale="scale"></component>
+          </template>
+        </Camera>
+        <div v-if="config?.example" class="size-40 absolute -top-8 right-2 cursor-pointer hover:text-primary">
+          <img class="size-full object-scale-down" :src="config?.example" alt="示例" />
+        </div>
+      </main>
+      <footer class="flex flex-col justify-center items-center">
+        <div v-if="snapshot" class="flex justify-evenly w-full cursor-pointer">
+          <div class="">
+            <img class="h-20" src="@/assets/images/button-cancel.png" alt="重拍" @click="handle()" /></div>
+          <div class="cursor-pointer">
+            <img class="h-20" src="@/assets/images/button-confirm.png" alt="确认" @click="next()" />
+          </div>
+        </div>
+        <div v-else-if="step" class="h-min text-center cursor-pointer hover:text-primary" @click="handle()">
+          <button class="size-28 border-8 rounded-full hover:border-primary"></button>
+          <div class="mt-8 text-3xl">点击拍照</div>
+        </div>
+      </footer>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+header, footer {
+  flex: 1 1 20%;
+}
+
+main {
+  position: relative;;
+  flex: 1 1 40%;
+}
+</style>

+ 57 - 118
src/modules/camera/camera.vue

@@ -1,142 +1,81 @@
 <script setup lang="ts">
-import { withResolvers }                                  from '@/tools';
-import { getMediaStream }                                 from '@/tools/camera.tool';
-import { tryOnMounted, tryOnUnmounted, useEventListener } from '@vueuse/core';
-
-
-const { constraints = {} } = defineProps<{ constraints?: MediaTrackConstraints }>();
-const width = defineModel('width', { default: 0 });
-const height = defineModel('height', { default: 0 });
-
-const styleValue = computed(() => {
-  return width.value && height.value
-         ? {
-      width: `${ width.value }px`,
-      height: `${ height.value }px`,
-    }
-         : void 0;
-});
-
-const container = useTemplateRef<HTMLElement>('camera-container');
-const videoRef = ref<HTMLVideoElement | null>(null);
-const canvasRef = ref<HTMLCanvasElement | null>(null);
-
-const getVideoTrack = () => {
-  return videoRef.value?.srcObject instanceof MediaStream
-         ? videoRef.value.srcObject.getVideoTracks()[ 0 ]
-         : new MediaStreamTrack();
-};
-
-useEventListener(videoRef, 'canplay', (event) => {
-  const video = event.target as HTMLVideoElement;
-  const canvas = canvasRef.value;
-
-  const track = getVideoTrack();
-
-  const settings = track.getSettings();
-  const { width: w = 1, height: h = 1, aspectRatio = w / h } = settings;
-  const reverse = aspectRatio > 1;
-  width.value = reverse ? h : w;
-  height.value = reverse ? w : h;
-  video.setAttribute('width', `${ width.value }`);
-  video.setAttribute('height', `${ height.value }`);
-  canvas!.width = width.value;
-  canvas!.height = height.value;
-
-  video.play().then(
-    () => {},
-    // (_) => (error.value = _)
-  );
-  console.group('[log] camera:', '流媒体可播放');
-  console.log(`width=${ width.value }, height=${ height.value }`);
-  console.log(`浏览器支持: 可缩放 ${ !!(
-    <any> navigator.mediaDevices.getSupportedConstraints()
-  ).zoom }`);
-  console.log(`摄像头支持: 可缩放 ${ !!(
-    <any> track.getCapabilities()
-  )[ 'zoom' ] }`);
-  console.groupEnd();
-});
-
-tryOnMounted(() => {
-  const style = getComputedStyle(container.value!);
-  width.value ??= Number.parseInt(style.width);
-  height.value ??= Number.parseInt(style.height);
-  init(container.value!, { width: width.value, height: height.value, ...constraints });
-});
-
-tryOnUnmounted(() => {
-  getVideoTrack().stop();
-
-  const video = videoRef.value;
-  if ( video ) {
-    video.pause();
-    video.srcObject = null;
+import Camera from '@/assets/camera.html?url';
+import { DEFAULT_HEIGHT, DEFAULT_WIDTH, DEFAULT_ZOOM } from '@/modules/camera/camera.config';
+
+const {
+  preview = true,
+  scale = 1,
+  offsetX = 0,
+  offsetY = 0,
+} = defineProps<{
+  scale?: number;
+  offsetX?: number;
+  offsetY?: number;
+  preview?: boolean;
+}>();
+const emits = defineEmits<{ loaded: [] }>();
+const style = computed(() => `width: ${scale * DEFAULT_WIDTH}px;height: ${scale * DEFAULT_HEIGHT}px;`);
+
+const snapshot = ref<string | void>();
+
+const cameraFrameRef = useTemplateRef<
+  HTMLIFrameElement & {
+    contentWindow: {
+      loadCamera(props: { width: number; height: number; zoom?: number }): Promise<void>;
+      handle(promise?: Promise<void>): string;
+    };
   }
+>('camera-frame');
 
-  videoRef.value = null;
-  canvasRef.value = null;
-});
-
-async function init(container: HTMLElement, constraints: MediaTrackConstraints) {
-  canvasRef.value = container.querySelector('canvas');
-  videoRef.value = container.querySelector('video');
-  videoRef.value!.srcObject = await getMediaStream(<any> constraints);
-}
-
-
-const handle = async (promise: Promise<boolean>) => {
-  const video = videoRef.value;
-  const canvas = canvasRef.value;
-  if ( !video || !canvas ) return;
-  if ( video.paused ) await video.play();
+const loadCamera = async () => {
+  await cameraFrameRef.value?.contentWindow.loadCamera?.({
+      width: DEFAULT_WIDTH,
+      height: DEFAULT_HEIGHT,
+      zoom: DEFAULT_ZOOM,
+  });
 
-  const context = canvas.getContext('2d')!;
-  context.drawImage(video, 0, 0, canvas.width, canvas.height);
-  video.pause();
-
-  promise.then(value => video.paused && value ? video.play() : void 0);
+  cameraFrameRef.value?.contentWindow.addEventListener('resize', update);
+  emits('loaded');
 };
 
-const get = async (type: 'base64' | 'blob', mime = 'image/png') => {
-  const video = videoRef.value;
-  const canvas = canvasRef.value;
-  if ( !video || !canvas ) return;
-  if ( !video.paused ) await handle(Promise.resolve(true));
+watch([() => offsetX, () => offsetY], () => { setTimeout(update, 100); });
 
-  switch ( type ) {
-    case 'blob':
-      const { promise, resolve } = withResolvers<Blob>();
-      canvas.toBlob(<any> resolve, mime, 0.98);
-      return promise;
-    case 'base64':
-      return canvas.toDataURL(mime);
-  }
-};
+function update() {
+  cameraFrameRef.value?.contentWindow.updateCoordinate?.(offsetX, offsetY);
+}
 
 defineExpose({
-  handle, get,
+  handle() {
+    if ( !preview || !snapshot.value ) {
+      snapshot.value = cameraFrameRef.value?.contentWindow.handle?.();
+    } else {
+      snapshot.value = void 0;
+    }
+    return snapshot.value;
+  },
 });
 </script>
 <template>
-  <div class="camera-container size-full" ref="camera-container" :style="styleValue">
-    <canvas style="display: none;"></canvas>
-    <video></video>
-    <slot></slot>
+  <div class="relative camera-container" :style="style">
+    <iframe ref="camera-frame" :src="Camera" @load="loadCamera()"></iframe>
+    <img v-if="snapshot" :src="snapshot" alt="图像" />
+    <slot name="shade" :style="style" :scale="scale"></slot>
   </div>
 </template>
 <style scoped lang="scss">
 .camera-container {
-  position: relative;
+  iframe {
+    clip-path: url('#shade');
+  }
+
+  img {
+    object-fit: scale-down;
+  }
 
   > * {
     position: absolute;
     width: 100%;
     height: 100%;
   }
-
-  video {
-    clip-path: url("#shade");
-  }
 }
 </style>

+ 0 - 214
src/modules/camera/page.vue

@@ -1,214 +0,0 @@
-<script setup lang="ts">
-import { useTitle }                         from '@/hooks/useTitle';
-import { saveFileMethod, uploadFileMethod } from '@/request/api/camera.api';
-import { useVisitor }                       from '@/stores';
-import { withResolvers }                    from '@/tools';
-import { tryOnUnmounted }                   from '@vueuse/core';
-import { useForm, useRequest }              from 'alova/client';
-import Camera                               from './camera.vue';
-import type { ConfigProps }                 from './config';
-import Config                               from './config';
-
-
-defineOptions({
-  name: 'CameraPage',
-});
-const title = useTitle();
-
-const step = ref(0);
-const config = shallowRef<Omit<ConfigProps, 'before' | 'after'> & { type: 'before' | 'after'; label: string; }>();
-
-const camera = useTemplateRef<InstanceType<typeof Camera>>('camera');
-const area = reactive({
-  // width: 295,
-  // height: 400,
-  width: 270,
-  height: 366,
-});
-
-onBeforeMount(() => next());
-
-function next(half = false) {
-  if ( half ) {
-    const { after, before, ..._config } = Config[ step.value - 1 ];
-    config.value = {
-      ..._config, label: _config.title,
-      ...after, type: 'after',
-    };
-  } else {
-    const { after, before, ..._config } = Config[ step.value ];
-    config.value = {
-      ..._config, label: _config.title,
-      ...before, type: 'before',
-    };
-    step.value += 1;
-  }
-  title.value = config.value.title;
-}
-
-function prev(half = false) {
-  if ( half ) {
-    const { after, before, ..._config } = Config[ step.value - 1 ];
-    config.value = {
-      ..._config, label: _config.title,
-      ...before, type: 'before',
-    };
-  } else {
-    const { after, before, ..._config } = Config[ step.value - 2 ];
-    config.value = {
-      ..._config, label: _config.title,
-      ...before, type: 'before',
-    };
-    step.value -= 1;
-  }
-}
-
-let done = () => {};
-
-const visitor = useVisitor();
-let play: (next: boolean) => void;
-let timer: ReturnType<typeof setInterval>;
-
-const router = useRouter();
-const complete = ref(false);
-const countdown = ref(0);
-
-const { form: dataset, loading: submitting, send: submit } = useForm(data => saveFileMethod(data), {
-  initialForm: { patientId: visitor.patientId } as Record<string, any>,
-}).onSuccess(({ data }) => {
-  done = () => { router.replace(data.path); };
-  visitor.resultId = data.resultId;
-  if ( data.done ) {
-    complete.value = true;
-    countdown.value = 5;
-    timer = setInterval(() => { if ( countdown.value-- <= 0 ) done(); }, 1000);
-  } else {
-    done();
-  }
-});
-
-const { loading: uploading, send: update, abort: stop } = useRequest((file: File) => uploadFileMethod(file), {
-  immediate: false,
-});
-
-
-const onStart = () => {
-  if ( showExample.value ) return showExample.value = false;
-  const resolvers = withResolvers<boolean>();
-  play = resolvers.resolve;
-  camera.value?.handle(resolvers.promise);
-  next(true);
-};
-
-
-const onConfirm = async () => {
-  if ( submitting.value ) return;
-
-  const key = config.value!.key;
-  const title = config.value!.title;
-  // 创建文件
-  const blob = await camera.value?.get('blob') as Blob;
-  const file = new File([ blob ], `${ dataset.value.patientId }-${ title.slice(0, 2) }`, { type: blob.type });
-  // 上传文件
-  try {
-    const { url } = await update(file);
-    dataset.value[ key ] = url;
-    if ( step.value == Config.length ) {
-      submit();
-    } else {
-      next();
-      play(true);
-    }
-  } catch ( error: any ) {
-    console.log(`[log] 拍照上传错误`, error?.message ?? error);
-    onCancel();
-    play(true);
-  }
-};
-const onCancel = () => {
-  if ( submitting.value ) return;
-
-  prev(true);
-  play?.(true);
-
-  stop();
-};
-
-tryOnUnmounted(() => {
-  clearInterval(timer);
-  stop();
-});
-
-const showExample = ref(false);
-const transparency = ref(1);
-</script>
-<template>
-  <div class="py-8 flex flex-col">
-    <template v-if="complete">
-      <header class="flex flex-col justify-center px-24" style="flex: 1 1 20%">
-        <div class="text-3xl text-center">拍摄完成</div>
-      </header>
-      <main class="flex flex-col justify-evenly px-24" style="flex: 1 1 50%">
-        <img class="size-40 mx-auto" src="@/assets/images/tips.png">
-        <div>
-          <div class="text-3xl text-center">恭喜您!</div>
-          <div class="text-3xl text-center my-8">完成舌面象的采集</div>
-        </div>
-      </main>
-      <footer class="flex justify-evenly items-start" style="flex: 1 1 30%">
-        <van-button class="decorate !text-xl !text-primary-400" @click="done()">
-          返回首页({{ countdown }})
-        </van-button>
-      </footer>
-    </template>
-    <template v-else>
-      <header class="flex flex-col justify-center px-24" style="flex: 1 1 20%">
-        <div class="text-3xl text-center" :class="{ required: config?.required }">{{ config?.label }}</div>
-        <div class="mt-8 text-lg text-center tracking-wider leading-10">{{ config?.description }}</div>
-      </header>
-      <main class="relative flex justify-center items-center" style="flex: 1 1 40%">
-        <Camera ref="camera" v-bind="area">
-          <component :is="config?.shade"></component>
-          <img
-            v-if="showExample"
-            class="example"
-            :style="{ opacity: transparency }"
-            :src="config?.example"
-            alt="示例"
-            @click="showExample = false"
-          />
-        </Camera>
-        <div
-          v-if="config?.example"
-          class="size-40 absolute top-4 right-4 cursor-pointer hover:text-primary"
-          @click="(showExample = !showExample) && (transparency = 1)"
-        >
-          <img class="size-full object-scale-down" :src="config?.example" alt="示例" />
-          <div class="mt-2 text-xl text-center">示例</div>
-        </div>
-      </main>
-      <footer class="flex justify-evenly items-center" style="flex: 1 1 20%">
-        <template v-if="config?.type === 'after'">
-          <div class="cursor-pointer">
-            <img class="h-20" src="@/assets/images/button-cancel.png" alt="重拍" @click="onCancel()" /></div>
-          <div class="cursor-pointer">
-            <img class="h-20" src="@/assets/images/button-confirm.png" alt="确认" @click="onConfirm()" />
-          </div>
-        </template>
-        <div v-else-if="config?.type === 'before'" class="h-min cursor-pointer hover:text-primary" @click="onStart()">
-          <button class="size-28 border-8 rounded-full hover:border-primary"></button>
-          <div class="mt-8 text-3xl">{{ showExample && transparency === 1 ? '开始拍照' : '点击拍照' }}</div>
-        </div>
-      </footer>
-    </template>
-  </div>
-</template>
-<style scoped lang="scss">
-.required {
-  &::before {
-    content: "*";
-    margin-right: 4px;
-    color: #f53030;
-  }
-}
-</style>

+ 219 - 0
src/modules/pulse/pulse.page.vue

@@ -0,0 +1,219 @@
+<script setup lang="ts">
+import { Notify } from '@/platform';
+import { tryOnMounted, tryOnUnmounted } from '@vueuse/core';
+
+import { useRequest } from 'alova/client';
+import { putPulseMethod } from '@/request/api/pulse.api';
+import { processMethod3 } from '@/request/api';
+import type { Flow } from '@/request/model';
+
+import { useVisitor } from '@/stores';
+
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+import HandLeft from '@/assets/images/pulse-hand-left.png?url';
+import HandRight from '@/assets/images/pulse-hand-right.png?url';
+
+const router = useRouter();
+const Visitor = useVisitor();
+
+const pending = ref(false);
+const finished = ref(false);
+const supported = ref(true);
+
+async function handle() {
+  if (pending.value) return;
+  pending.value = true;
+  clearInterval(timer);
+
+  const patientId = Visitor.patientId;
+  try {
+    await load();
+    const result = await Bridge.pulse(patientId!!);
+    await submit(patientId, result);
+  } catch (e: any) {
+    let message = e.message;
+    if (e instanceof ReferenceError) {
+      supported.value = false;
+      message = '当前环境不支持脉诊设备,请联系管理员';
+    }
+    if (!supported.value || process.value?.current?.optional) {
+      done.value = next.value?.optional
+        ? { ...next.value, countdown: 5 }
+        : {
+            title: '返回首页',
+            route: '/screen',
+            countdown: 5,
+          };
+      next.value = void 0;
+      start();
+    } else {
+      done.value = void 0;
+      message = '请再次测量脉诊';
+    }
+    Notify.warning(message);
+  }
+  pending.value = false;
+}
+
+const done = shallowRef<Partial<Flow> & { countdown: number }>();
+const next = shallowRef<Partial<Flow>>();
+
+const {
+  data: process,
+  loading,
+  send: load,
+} = useRequest(processMethod3, { immediate: false }).onSuccess(({ data }) => {
+  if (data.next.route === '/screen') {
+    done.value = { title: '返回首页', route: '/screen', countdown: 30 };
+    next.value = void 0;
+  } else if (data.next.route === '/pulse/result') {
+    done.value = data.next.optional ? { title: '返回首页', route: '/screen', countdown: 10 } : void 0;
+    next.value = { title: data.next.title || '查看报告', route: data.next.route };
+  } else {
+    done.value = { title: data.next.title || '获取健康调理方案', route: data.next.route, countdown: 10 };
+    next.value = void 0;
+  }
+});
+
+const {
+  data: report,
+  loading: submitting,
+  send: submit,
+} = useRequest((id, result) => putPulseMethod(id, result), { immediate: false }).onSuccess(() => {
+  finished.value = true;
+  start();
+});
+
+let timer: ReturnType<typeof setInterval>;
+const countdown = ref(5);
+
+function start(value?: number) {
+  if (!done.value) return;
+  countdown.value = value ?? done.value.countdown ?? 3;
+  timer = setInterval(() => {
+    const _countdown = countdown.value - 1;
+    if (_countdown <= 0) {
+      replace(done.value?.route);
+    } else {
+      countdown.value = _countdown;
+    }
+  }, 1000);
+}
+
+function replace(path: string = '/screen') {
+  return router.replace({ path });
+}
+
+tryOnMounted(() => {
+  setTimeout(() => handle(), 300);
+});
+tryOnUnmounted(() => {
+  clearInterval(timer);
+});
+</script>
+<template>
+  <div>
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
+          {{ finished ? '完成脉诊采集' : '脉诊采集' }}
+        </div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col">
+      <header></header>
+      <main class="flex flex-col justify-evenly px-8">
+        <template v-if="finished && report">
+          <img class="size-40 mx-auto" src="@/assets/images/tips.png" alt="" />
+          <div>
+            <div class="text-3xl text-center">恭喜您!</div>
+            <div class="text-3xl text-center my-8">完成脉诊采集</div>
+          </div>
+          <div v-if="report">
+            <div v-if="report.summaryLabel" class="flex justify-evenly">
+              <div v-if="report.summaryLabel?.left" class="flex flex-row-reverse justify-center">
+                <img style="width: 100px;height: 200px;" :src="HandLeft" alt="左手" />
+                <div class="flex flex-col justify-evenly translate-y-2 h-40 text-xl">
+                  <div>
+                    寸:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.cun }}</span>
+                  </div>
+                  <div>
+                    关:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.guan }}</span>
+                  </div>
+                  <div>
+                    尺:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.chi }}</span>
+                  </div>
+                </div>
+              </div>
+              <div v-if="report.summaryLabel?.right">
+                <div class="flex justify-center">
+                  <img style="width: 100px;height: 200px;"  :src="HandRight" alt="右手" />
+                  <div class="flex flex-col justify-evenly translate-y-2 h-40 text-xl">
+                    <div>
+                      寸:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.cun }}</span>
+                    </div>
+                    <div>
+                      关:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.guan }}</span>
+                    </div>
+                    <div>
+                      尺:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.chi }}</span>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <p v-if="report.results" class="text-2xl text-center">
+              总体脉象:<span class="text-primary-400" style="letter-spacing: 4px">{{ report.results }}</span>
+            </p>
+          </div>
+        </template>
+      </main>
+      <footer class="flex flex-col justify-center items-center">
+        <van-button
+          v-if="!pending && finished && next"
+          class="decorate !text-xl !text-primary-400"
+          @click="replace(next.route)"
+        >
+          {{ next.title }}
+        </van-button>
+        <van-button
+          v-if="!pending && done"
+          class="decorate !text-xl !text-primary-400 !mb-6"
+          @click="replace(done.route)"
+        >
+          {{ done.title }}({{ countdown }})
+        </van-button>
+        <div v-if="supported && !finished" class="van-button decorate" @click="handle()">
+          <div class="van-button__content">
+            <van-loading v-if="loading || pending || submitting" />
+            <span v-else class="van-button__text">连接脉诊</span>
+          </div>
+        </div>
+      </footer>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+header {
+  flex: 1 1 10%;
+}
+
+footer {
+  flex: 1 1 30%;
+}
+
+main {
+  position: relative;
+  flex: 1 1 50%;
+}
+
+.decorate {
+  margin: 24px 0;
+}
+</style>

+ 4 - 3
src/modules/questionnaire/TierSelect.field.vue

@@ -76,13 +76,14 @@ function handle(option: QuestionnaireProps['options'][number], index: number, op
 </script>
 <template>
   <div class="grid grid-rows-1 grid-cols-5 gap-4 my-4">
-    <div v-for="(option, index) in options" :class="{checked: option.checked}"
+    <div v-for="(option, index) in options" :key="option.id"
          class="option
             flex justify-center items-center flex-wrap min-h-16
             text-lg text-primary hover:text-primary-400
             rounded-xl border border-primary hover:border-primary-400
             cursor-pointer
          "
+         :class="{checked: option.checked}"
          @click="!disabled && handle(option, index, options)"
     >
       <div class="p-2 text-center">
@@ -90,9 +91,9 @@ function handle(option: QuestionnaireProps['options'][number], index: number, op
         <span class="value" v-if="option.value">{{ option.value }}</span>
       </div>
     </div>
-    <van-dialog class="sub-dialog" v-bind="subDialog">
+    <van-dialog v-if="subDialog.show" class="sub-dialog" v-bind="subDialog">
       <div class="grid grid-rows-1 grid-cols-4 gap-4 my-12 p-12">
-        <div v-for="(option, index) in subDialog.options"
+        <div v-for="(option, index) in subDialog.options" :key="option.id"
              class="sub-option
               flex justify-center items-center min-h-16
               text-lg text-primary hover:text-white

+ 80 - 35
src/modules/questionnaire/page.vue

@@ -1,11 +1,11 @@
 <script setup lang="ts">
-import { Notify }                  from '@/platform';
-import { questionnaireMethod }     from '@/request/api';
+import { Dialog, Notify, Toast } from '@/platform';
+import { questionnaireMethod } from '@/request/api';
 import type { QuestionnaireProps } from '@/request/model';
-import { useRequest }              from 'alova/client';
 
 import TierSelectField from './TierSelect.field.vue';
 
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
 
 defineOptions({
   name: 'QuestionnairePage',
@@ -13,57 +13,102 @@ defineOptions({
 
 const router = useRouter();
 
-const showTitle = ref(true);
-const { data, loading, send } = useRequest((data) => questionnaireMethod(data), {
-  initialData: { reportId: null, questionnaires: [] },
-}).onSuccess(({ data }) => {
-  if ( data.reportId ) router.replace(`/report/${ data.reportId }`);
-});
+const first = ref(true);
 
+const showTitle = ref(true);
+const loading = ref(false);
+const data = ref<QuestionnaireProps[]>([]);
+// const { data, loading, send } = useRequest((data) => questionnaireMethod(data), {
+//   initialData: { reportId: null, questionnaires: [] },
+// }).onSuccess(({ data }) => {
+//   if ( data.reportId ) router.replace(`/report/${ data.reportId }`);
+// });
 
-function handle(questionnaires: QuestionnaireProps[]) {
-  console.log(questionnaires);
+function handle() {
+  const questionnaires = data.value;
   const tips: string[] = [];
-  for ( const { label, required, name, options } of questionnaires ) {
-    if ( !required ) continue;
-    switch ( name ) {
+  for (const { label, required, name, options } of questionnaires) {
+    if (!required) continue;
+    switch (name) {
       case 'select':
-        if ( !options.some(op => op.checked) ) tips.push(label);
+        if (!options.some((op) => op.checked)) tips.push(label);
         break;
     }
   }
-  if ( tips.length ) {
-    Notify.warning(`问卷请补充完整\n\n${ tips.join('\n') }`);
+  if (tips.length) {
+    Notify.warning(`问卷请补充完整\n\n${tips.join('\n')}`);
   } else {
-    send(questionnaires).then(() => showTitle.value = false);
+    load();
   }
 }
+
+async function load() {
+  const _first = first.value;
+  loading.value = true;
+  try {
+    const { reportId, questionnaires } = await questionnaireMethod(<any>data.value);
+    if (reportId) return await router.replace(`/report/${reportId}`);
+    showTitle.value = _first;
+    data.value = [];
+    // TODO 延迟渲染
+    setTimeout(() => (data.value = questionnaires), 300);
+  } catch (e: any) {
+    loading.value = false;
+    await Dialog.show({
+      message: e.message,
+      theme: 'round-button',
+      showCancelButton: false,
+      confirmButtonText: '好的',
+      width: '350px',
+    });
+    await router.replace(`/camera`);
+  } finally {
+    loading.value = false;
+    first.value = false;
+  }
+}
+
+load();
 </script>
 <template>
   <div>
-    <template v-if="data.questionnaires.length">
-      <div v-if="showTitle" class="my-8 text-2xl text-primary text-center">
-        <div>为了更全面地评估您的健康状况</div>
-        <div>还需要您回答6-9个问题,耗时2-3分钟</div>
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
+          问卷
+        </div>
       </div>
-      <div class="m-6" v-for="item in data.questionnaires" :key="item.id">
-        <div class="text-2xl" :class="{required:item.required}">{{ item.label }}</div>
-        <TierSelectField
-          v-if="item.name === 'select'"
-          v-model:options="item.options" :multiple="item.multiple" :disabled="loading"
-        />
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
       </div>
-      <van-button class="decorate" block :loading
-                  @click="handle(data.questionnaires)"
-      >提交
-      </van-button>
-    </template>
-    <van-toast v-else :show="loading" type="loading" message="加载中" />
+    </div>
+    <div class="page-content">
+      <template v-if="data.length">
+        <div v-if="showTitle" class="my-8 text-2xl text-primary text-center">
+          <div>为了更全面地评估您的健康状况</div>
+          <div>还需要您回答6-9个问题,耗时2-3分钟</div>
+        </div>
+        <div class="m-6" v-for="item in data" :key="item.id">
+          <div class="text-2xl" :class="{ required: item.required }">{{ item.label }}</div>
+          <TierSelectField
+            v-if="item.name === 'select'"
+            v-model:options="item.options"
+            :multiple="item.multiple"
+            :disabled="loading"
+          />
+        </div>
+        <van-button class="decorate" block :loading @click="handle()">提交</van-button>
+      </template>
+      <van-toast v-else-if="first" :show="loading" type="loading" message="加载中" />
+    </div>
   </div>
 </template>
 <style scoped lang="scss">
 .required::after {
-  content: "*";
+  content: '*';
   color: #f53030;
   font-size: 2rem;
 }

+ 19 - 9
src/modules/report/NavBar.vue

@@ -1,10 +1,9 @@
 <script setup lang="ts">
-import NavHome         from '@/assets/images/nav-home.png?url';
-import NavHomeSelect   from '@/assets/images/nav-home.select.png?url';
-import NavPrint        from '@/assets/images/nav-print.png?url';
-import NavPrintSelect  from '@/assets/images/nav-print.select.png?url';
-import NavScheme       from '@/assets/images/nav-scheme.png?url';
-import NavSchemeSelect from '@/assets/images/nav-scheme.select.png?url';
+import NavMiniProgramSelect from '@/assets/images/mini-program.svg?url';
+import NavPrint             from '@/assets/images/nav-print.png?url';
+import NavPrintSelect       from '@/assets/images/nav-print.select.png?url';
+import NavScheme            from '@/assets/images/nav-scheme.png?url';
+import NavSchemeSelect      from '@/assets/images/nav-scheme.select.png?url';
 
 
 interface Tabbar {
@@ -17,6 +16,9 @@ interface Tabbar {
 const route = useRoute();
 const router = useRouter();
 
+const props = defineProps<{ uploading?: boolean; }>();
+const emits = defineEmits<{ print: []; mini: []; }>();
+
 const isScheme = ref(false);
 const tabbar = shallowRef<Tabbar[]>([]);
 
@@ -24,11 +26,12 @@ watchEffect(() => {
   const path = route.fullPath;
   isScheme.value = path.endsWith('scheme');
   tabbar.value = isScheme.value ? [
-    { key: 'screen', label: '返回首页', icon: NavHome, select: NavHomeSelect },
+    // { key: 'screen', label: '返回首页', icon: NavHome, select: NavHomeSelect },
     { key: 'report', label: '健康报告', icon: NavScheme, select: NavSchemeSelect },
   ] : [
-    { key: 'screen', label: '返回首页', icon: NavHome, select: NavHomeSelect },
+    // { key: 'screen', label: '返回首页', icon: NavHome, select: NavHomeSelect },
     { key: 'scheme', label: '调理方案', icon: NavScheme, select: NavSchemeSelect },
+    { key: 'mini', label: '小程序', icon: NavMiniProgramSelect, select: NavMiniProgramSelect },
     { key: 'print', label: '打印', icon: NavPrint, select: NavPrintSelect },
   ];
 });
@@ -44,6 +47,12 @@ function handle(key: string) {
     case 'report':
       router.replace(route.fullPath.replace('/scheme', ''));
       break;
+    case 'print':
+      emits('print');
+      break;
+    case 'mini':
+      emits('mini');
+      break;
   }
 }
 </script>
@@ -54,7 +63,8 @@ function handle(key: string) {
       v-for="nav in tabbar" :key="nav.key"
       @click="handle(nav.key)"
     >
-      <img :src="nav.icon" :alt="nav.label">
+      <van-loading v-if="props.uploading && nav.label === '打印'" color="#38ff6e" style="font-size: 24px;" />
+      <img v-else :src="nav.icon" :alt="nav.label">
       <div class="mt-2">{{ nav.label }}</div>
     </div>
   </div>

+ 21 - 36
src/modules/report/PhysiqueChart.vue

@@ -11,10 +11,7 @@ provide(...theme);
 
 const option = ref<EChartsOption>({
   backgroundColor: 'transparent',
-  dataset: {
-    dimensions: [ '体质', '得分', '类别' ],
-    source: [],
-  },
+  legend: { top: 10, right: 10, },
   grid: {
     containLabel: true,
     top: 50,
@@ -32,24 +29,9 @@ const option = ref<EChartsOption>({
     axisLine: { show: false },
     axisLabel: { show: false },
   },
-  visualMap: {
-    type: 'piecewise',
-    inRange: {},
-    categories: [],
-    dimension: 2,
-    orient: 'horizontal',
-    top: 10,
-    right: 10,
-  },
-  series: [
-    {
-      type: 'bar',
-      barMaxWidth: 30,
-    },
-  ],
+  series: [],
 });
 
-
 const defaultSetting = [
   { label: '平和体质(正常体质)', color: '#38ff6e' },
   { label: '所属体质', color: '#ff8917' },
@@ -59,30 +41,33 @@ const defaultSetting = [
 ];
 
 watchEffect(() => {
-  const ref = new Set<number>(dataset.map(item => item[ 2 ]));
-  const categories = [];
-  const colors = [];
-  for ( const key of ref ) {
-    const { label, color } = defaultSetting[ key ] ?? {};
-    if ( label ) categories.push(label);
-    if ( color ) colors.push(color);
+  const ref = new Set<number>(dataset.map((item) => item[2]));
+
+  const legend: EChartsOption['legend'] & {} = { data: [] };
+  const series: EChartsOption['series'][] = [];
+
+  for (const key of ref) {
+    const { label: name, color } = defaultSetting[key] ?? {};
+    series.push({
+      name, type: 'bar',
+      barMaxWidth: 30, barGap: '-100%',
+      data: dataset.filter((item) => item[2] === key), itemStyle: { color },
+    });
+
+    if (name !== '体质') legend.data?.push({ name, itemStyle: { color } });
   }
 
-  option.value.dataset = {
-    ...option.value.dataset,
-    source: dataset.map(item => [ item[ 0 ], item[ 1 ], defaultSetting[ item[ 2 ] ]?.label ]),
-  };
-  option.value.visualMap = {
-    ...option.value.visualMap,
-    categories,
-    inRange: { color: colors },
-  };
+  option.value.legend = { ...option.value.legend, ...legend };
+  option.value.series = series as any;
 });
 
 const chart = useTemplateRef<InstanceType<typeof VChart>>('chart');
 
+let loaded = false;
 function onFinished() {
+  if (loaded || snapshot.value && snapshot.value.startsWith(`data:image/`)) return;
   snapshot.value = chart.value?.getDataURL() ?? '';
+  loaded = true;
 }
 </script>
 <template>

+ 53 - 0
src/modules/report/ReportPreview.vue

@@ -0,0 +1,53 @@
+<script setup lang="ts">
+import QrcodeVue from 'qrcode.vue';
+import { getClientURL } from '@/tools';
+
+
+const props = withDefaults(defineProps<{ url: string; title?: string; mode?: 'img' | 'qr' }>(), {
+  title: '扫一扫',
+  mode: 'qr',
+});
+
+const show = defineModel('show', { default: false });
+const config = reactive({
+  size: 300,
+  margin: 2,
+  background: '#0f2925',
+  foreground: '#38ff6e',
+});
+
+const url = computed(() => {
+  return props.mode === 'qr'
+         ? `${ getClientURL('/pdf/preview.html') }?url=${ encodeURIComponent(props.url) }`
+         : props.url;
+});
+</script>
+
+<template>
+  <van-dialog
+    v-model:show="show"
+    :title="props.title"
+    cancel-button-text="好的"
+    show-cancel-button
+    :show-confirm-button="false"
+    close-on-click-overlay
+  >
+    <div class="content">
+      <qrcode-vue v-if="props.mode === 'qr'" :value="url" v-bind="config"></qrcode-vue>
+      <img v-else-if="props.mode === 'img'" :src="url" alt="二维码">
+      <div v-else>{{ url }}</div>
+    </div>
+  </van-dialog>
+</template>
+
+<style scoped lang="scss">
+.content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+
+  img {
+    padding: 10px 0;
+  }
+}
+</style>

+ 2 - 1
src/modules/report/SchemeMedia.vue

@@ -19,7 +19,7 @@ function update(index: number) {
     const swipe = document.querySelectorAll(`.${ Tag } .van-swipe-item`);
     const video = swipe[ index ]?.querySelector(`video`);
     onPlay(<any> { target: video });
-  }, 20);
+  }, 200);
 }
 
 function handle(item: SchemeMediaProps) {
@@ -34,6 +34,7 @@ function handle(item: SchemeMediaProps) {
     closeOnClickOverlay: closable,
     className: Tag,
   };
+  update(index);
 }
 
 function onPlay(event?: Event) {

+ 8 - 9
src/modules/report/SyndromeChart.vue

@@ -16,18 +16,13 @@ const option = ref<EChartsOption>({
     fontStyle: 24,
   },
   radar: {
-    indicator: [
-      { text: 'Brand', max: 100 },
-      { text: 'Content', max: 100 },
-      { text: 'Usability', max: 100 },
-      { text: 'Function', max: 100 },
-    ],
-    center: [ '50%', '50%' ],
-    radius: 90,
+    indicator: [],
+    axisLine: { lineStyle: { color: 'rgba(56,255,110, 0.2)', }, },
+    splitLine: { lineStyle: { color: 'transparent', }, },
   },
   series: {
     type: 'radar',
-    label: { show: true, position: 'insideTopLeft', fontSize: 14, color: '#fff' },
+    label: { show: false },
     data: [
       {
         value: [ 60, 73, 85, 40 ],
@@ -48,6 +43,7 @@ watchEffect(() => {
     value.push(score);
   }
   option.value.radar = {
+    ...option.value.radar,
     indicator,
   };
   option.value.series = {
@@ -58,8 +54,11 @@ watchEffect(() => {
 
 const chart = useTemplateRef<InstanceType<typeof VChart>>('chart');
 
+let loaded = false;
 function onFinished() {
+  if (loaded || snapshot.value && snapshot.value.startsWith(`data:image/`)) return;
   snapshot.value = chart.value?.getDataURL() ?? '';
+  loaded = true;
 }
 </script>
 <template>

+ 9 - 3
src/modules/report/echart.ts

@@ -1,7 +1,12 @@
 import type { BarSeriesOption, RadarSeriesOption }                                    from 'echarts/charts';
 import { BarChart, RadarChart }                                                       from 'echarts/charts';
-import type { DatasetComponentOption, GridComponentOption, VisualMapComponentOption } from 'echarts/components';
-import { DatasetComponent, GridComponent, VisualMapComponent }                        from 'echarts/components';
+import type {
+  DatasetComponentOption,
+  GridComponentOption,
+  LegendComponentOption,
+  VisualMapComponentOption,
+} from 'echarts/components';
+import { DatasetComponent, GridComponent, LegendComponent, VisualMapComponent } from 'echarts/components';
 import type { ComposeOption }                                                         from 'echarts/core';
 import { use }                                                                        from 'echarts/core';
 import { CanvasRenderer }                                                             from 'echarts/renderers';
@@ -12,7 +17,7 @@ import VChart, { THEME_KEY } from 'vue-echarts';
 use([
   CanvasRenderer,
   DatasetComponent, VisualMapComponent,
-  GridComponent,
+  GridComponent, LegendComponent,
   BarChart,
   RadarChart,
 ]);
@@ -21,6 +26,7 @@ export type EChartsOption = ComposeOption<
   | DatasetComponentOption
   | GridComponentOption
   | VisualMapComponentOption
+  | LegendComponentOption
   | BarSeriesOption
   | RadarSeriesOption
 >;

+ 140 - 0
src/modules/report/report-analyse.page.vue

@@ -0,0 +1,140 @@
+<script setup lang="ts">
+import NavHomeSelect                               from '@/assets/images/nav-home.select.png?url';
+import { Dialog }                                  from '@/platform';
+import { getAnalysisResultsMethod, processMethod } from '@/request/api';
+import { useRequest }                              from 'alova/client';
+import { useRouter }                               from 'vue-router';
+
+
+const router = useRouter();
+
+const { data, loading, error } = useRequest(getAnalysisResultsMethod, { initialData: { tongue: {}, face: {} } })
+  .onSuccess(({ data }) => {
+    if ( data?.miniProgramURL ) {
+      panelProps.anchors[ 0 ] = 100;
+      if ( data.payLock ) panelOpen(100);
+    }
+  })
+  .onError(async ({ error }) => {
+    await Dialog.show({
+      message: error.message,
+      theme: 'round-button',
+      showCancelButton: false,
+      confirmButtonText: '好的',
+      width: '350px',
+    });
+    await router.replace(`/camera`);
+});
+
+const panelHeight = ref(0);
+const panelProps = reactive({
+  anchors: [0, window.innerWidth],
+  contentDraggable: false,
+  lockScroll: true,
+});
+const panelOpen = (min?: number) => {
+  if ( min ) panelProps.anchors[ 0 ] = min;
+  panelHeight.value = panelProps.anchors[1];
+};
+
+const scrollable = computed(() => !data.value.payLock &&
+                                  panelHeight.value < panelProps.anchors[ 1 ] || panelHeight.value === 0,
+);
+
+
+const { data: path, loading: nextLoading } = useRequest(processMethod).onSuccess((data) => {
+  console.log(data);
+});
+
+const showNext = computed(() => path.value && path.value !== '/screen');
+
+function next() {
+  console.log(path);
+  router.replace({ path: path.value });
+}
+</script>
+<template>
+  <div class="report-wrapper">
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
+          舌面象分析报告
+        </div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col overflow-hidden">
+      <van-skeleton class="flex-auto" v-if="!error" title :row="3" :loading>
+        <div class="flex-auto" :class="{ 'overflow-y-auto': scrollable }">
+          <div class="my-6 text-primary text-2xl text-center" v-if="data.date">报告日期:{{ data.date }}</div>
+          <AnalysisComponent title="舌象分析" exception-type="list" v-bind="data.tongue"></AnalysisComponent>
+          <AnalysisComponent title="面象分析" exception-type="group" v-bind="data.face"></AnalysisComponent>
+          <div class="m-4" v-if="!nextLoading && showNext">
+            <div class="m-auto size-16 cursor-pointer">
+              <img class="size-full" src="@/assets/images/next-step.svg" alt="提交" @click="next()">
+            </div>
+          </div>
+          <div :style="{ height: panelHeight + 'px' }"><!--补偿面板打开高度--></div>
+        </div>
+      </van-skeleton>
+    </div>
+    <van-floating-panel v-model:height="panelHeight" v-bind="panelProps">
+      <template #header>
+        <div class="van-floating-panel__header !justify-between">
+          <div></div>
+          <div class="van-floating-panel__header-bar"></div>
+          <div>
+            <van-icon v-if="!data.payLock" name="cross" @click.stop="panelHeight = panelProps.anchors[0];" />
+          </div>
+        </div>
+      </template>
+      <Transition>
+        <div class="panel-content">
+          <img
+            class="size-full object-contain"
+            v-if="panelHeight === panelProps.anchors[1] || panelHeight > panelProps.anchors[0] * 1.5"
+            :src="data.miniProgramURL"
+            alt="小程序码"
+          />
+          <div class="flex justify-center items-center" v-else @click="panelOpen()">
+            <img class="h-10 w-10" src="@/assets/images/mini-program.svg" alt="小程序" />
+            <span class="text-lg ml-2">小程序</span>
+          </div>
+        </div>
+      </Transition>
+    </van-floating-panel>
+  </div>
+</template>
+<style scoped lang="scss">
+.report-wrapper {
+  .panel-content {
+    padding: 0 var(--van-floating-panel-header-height) var(--van-floating-panel-header-height);
+  }
+
+  .v-enter-active,
+  .v-leave-active {
+    transition: opacity 0.5s ease;
+  }
+
+  .v-enter-from,
+  .v-leave-to {
+    opacity: 0;
+  }
+}
+
+.overflow-y-auto {
+  overflow-y: auto;
+}
+</style>
+<style lang="scss">
+.report-wrapper .card {
+  padding: 24px;
+  border-radius: 24px;
+  box-shadow: inset 0 0 80px 0 #34a76b60;
+}
+</style>

+ 251 - 131
src/modules/report/report.page.vue

@@ -1,175 +1,259 @@
 <script setup lang="ts">
-import NavBar                                  from '@/modules/report/NavBar.vue';
+import NavMiniProgram    from '@/assets/images/mini-program.svg?url';
+import NavHomeSelect     from '@/assets/images/nav-home.select.png?url';
+import NavPrint          from '@/assets/images/nav-print.png?url';
+import NavScheme         from '@/assets/images/nav-scheme.png?url';
+
 import PhysiqueChart                           from '@/modules/report/PhysiqueChart.vue';
 import SyndromeChart                           from '@/modules/report/SyndromeChart.vue';
-import { getReportMethod, updateReportMethod } from '@/request/api/report.api';
-import { useRouteParams }                      from '@vueuse/router';
-import { useRequest, useWatcher }              from 'alova/client';
+import { Notify, Toast } from '@/platform';
+import { getReportMethod, updateReportMethod } from '@/request/api';
+
+import { useRouteParams }         from '@vueuse/router';
+import { useRequest, useWatcher } from 'alova/client';
+import { useRouter }              from 'vue-router';
 
 
 const id = useRouteParams<string>('id');
 const { data, loading } = useWatcher(() => getReportMethod(id.value), [ id ], {
   initialData: {
     descriptionsTable: { column: [], data: [] },
-    tongueTable: { column: [], data: [] },
-    tongueException: [],
-    tongueAnalysis: {},
-    faceAnalysis: {},
+    tongue: {},
+    face: {},
   },
   immediate: true,
-});
+}).onSuccess(({ data }) => {
+  if ( data?.miniProgramURL && data.payLock ) panelOpen(100);
+})
 
 const { loading: uploading, send: upload } = useRequest(() => updateReportMethod(id.value, data.value), {
   immediate: false,
   middleware(_, next) {
+    if (data.value.reportURL) return;
     const hasConstitutionGroupImg = data.value.constitutionGroupImg;
     const hasFactorItemRadarImg = data.value[ '中医证素' ]?.length ? data.value.factorItemRadarImg : true;
     if ( hasConstitutionGroupImg && hasFactorItemRadarImg ) { next(); }
   },
+}).onSuccess(({ data: url }) => { data.value.reportURL = url; });
+
+let ReportPreview: Component;
+const reportPreviewProps = reactive({
+  show: false,
+  title: '',
+  url: '',
+  mode: '' as 'img' | 'qr',
 });
+async function print() {
+  let url = data.value.reportURL;
+  if ( !url ) url = await upload();
+  if (!url) {
+    Notify.warning(`未获取到报告地址,请联系管理员或重试`);
+    return;
+  }
+
+  try {
+    try {
+      await Bridge.print({ url });
+    } catch (e) {
+      window.AIO?.print?.(url)
+    }
+    Toast.success(`开始打印`);
+  } catch ( e ) {
+    Notify.warning(`打印失败 (${ e.message })`, { duration: 1500 });
+    setTimeout(() => {
+      ReportPreview = defineAsyncComponent(() => import('./ReportPreview.vue'));
+      reportPreviewProps.mode = 'qr';
+      reportPreviewProps.title = '扫一扫 获取报告';
+      reportPreviewProps.url = url;
+      reportPreviewProps.show = true;
+    }, 1500);
+  }
+}
+
+async function miniProgram() {
+  let url = data.value.miniProgramURL;
+  if ( !url ) {
+    Notify.warning(`未获取到小程序地址,请联系管理员或重试`);
+    return;
+  }
+  panelOpen();
+}
+
+const router = useRouter();
+
+function toggle() {
+  const path = `${ router.currentRoute.value.fullPath }/scheme`.replace(/\/{2,}/g, '/');
+  router.replace({ path });
+}
+
+const panelHeight = ref(0);
+const panelProps = reactive({
+  anchors: [0, window.innerWidth],
+  contentDraggable: false,
+  lockScroll: true,
+});
+const panelOpen = (min?: number) => {
+  if ( min ) panelProps.anchors[ 0 ] = min;
+  panelHeight.value = panelProps.anchors[1];
+};
+
+const scrollable = computed(() => !data.value.payLock &&
+                                  panelHeight.value < panelProps.anchors[ 1 ] || panelHeight.value === 0,
+);
 </script>
 <template>
-  <div class="flex flex-col overflow-hidden">
-    <van-skeleton class="flex-auto" title :row="3" :loading>
-      <div class="flex-auto x-6 overflow-y-auto">
-        <div class="my-6 text-primary text-2xl text-center">报告日期:{{ data.date }}</div>
-        <div class="card my-6 text-lg">
-          <div class="card__title text-primary text-3xl font-bold"></div>
-          <div class="card__content flex">
-            <div class="flex-auto">
-              <div class="flex items-center my-2">
-                <span class="text-primary">结果显示您是:</span>
-                <van-button class="decorate !text-primary-400">{{ data[ '结果' ] }}</van-button>
+  <div class="report-wrapper">
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
+          健康分析报告
+        </div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col overflow-hidden">
+      <van-skeleton class="flex-auto" title :row="3" :loading>
+        <div class="flex-auto" :class="{ 'overflow-y-auto': scrollable }">
+          <div class="my-6 text-primary text-2xl text-center">报告日期:{{ data.date }}</div>
+          <div class="card m-6 text-lg">
+            <div class="card__title text-primary text-3xl font-bold"></div>
+            <div class="card__content flex">
+              <div class="flex-auto">
+                <div class="flex items-center my-2">
+                  <span class="text-primary">结果显示您是:</span>
+                  <van-button class="decorate !text-primary-400">{{ data[ '结果' ] }}</van-button>
+                </div>
+                <div class="flex items-center my-2" v-if="data[ '程度' ]">
+                  <span class="text-grey">程度:</span>
+                  <span class="px-4 py-2 rounded-lg border border-primary-400 text-primary">{{ data[ '程度' ] }}</span>
+                </div>
+                <div class="my-2 text-grey" v-if="data[ '表现' ]">表现:{{ data[ '表现' ] }}</div>
+                <div class="my-2 text-grey" v-if="data[ '体质' ]">体质:{{ data[ '体质' ] }}</div>
               </div>
-              <div class="flex items-center my-2" v-if="data[ '程度' ]">
-                <span class="text-grey">程度:</span>
-                <span class="px-4 py-2 rounded-lg border border-primary-400 text-primary">{{ data[ '程度' ] }}</span>
+              <div class="flex-none size-48 ml-4">
+                <img class="size-full object-cover" src="@/assets/images/report-cover.png" alt="封面">
               </div>
-              <div class="my-2 text-grey" v-if="data[ '表现' ]">表现:{{ data[ '表现' ] }}</div>
-              <div class="my-2 text-grey" v-if="data[ '体质' ]">体质:{{ data[ '体质' ] }}</div>
-            </div>
-            <div class="flex-none size-48 ml-4">
-              <img class="size-full object-cover" src="@/assets/images/report-cover.png" alt="封面">
             </div>
           </div>
-        </div>
-        <div class="card my-6 text-lg">
-          <div class="card__title mb-3 text-primary text-2xl font-bold">体质分析</div>
-          <div class="card__content">
-            <PhysiqueChart
-              :dataset="data['体质图表']"
-              v-model:snapshot="data.constitutionGroupImg" @update:snapshot="upload()"
-            />
-            <div class="my-2 text-primary" v-if="data[ '体质描述' ]">{{ data[ '体质描述' ] }}</div>
-            <table class="mt-8 mb-2 w-full table-auto border border-collapse  border-primary">
-              <thead>
-              <tr>
-                <th class="border border-primary min-w-[140px]"
-                    v-for="(value, i) in data.descriptionsTable.column" :key="i"
-                    v-html="value"
-                ></th>
-              </tr>
-              </thead>
-              <tbody>
-              <tr v-for="item in data.descriptionsTable?.data" :key="item[0]">
-                <td class="py-4 px-2 border border-primary min-w-[140px]"
-                    :class="{'text-grey': i, 'text-primary': !i}"
-                    v-for="(value, i) in item" :key="i"
-                    v-html="value"
-                ></td>
-              </tr>
-              </tbody>
-            </table>
+          <div class="card m-6 text-lg">
+            <div class="card__title mb-3 text-primary text-2xl font-bold">体质分析</div>
+            <div class="card__content">
+              <PhysiqueChart
+                :dataset="data['体质图表']"
+                v-model:snapshot="data.constitutionGroupImg"
+              />
+              <div class="my-2 text-primary" v-if="data[ '体质描述' ]">{{ data[ '体质描述' ] }}</div>
+              <table class="mt-8 mb-2 w-full table-auto border border-collapse  border-primary">
+                <thead>
+                <tr>
+                  <th class="border border-primary min-w-[140px]"
+                      v-for="(value, i) in data.descriptionsTable.column" :key="i"
+                      v-html="value"
+                  ></th>
+                </tr>
+                </thead>
+                <tbody>
+                <tr v-for="item in data.descriptionsTable?.data" :key="item[0]">
+                  <td class="py-4 px-2 border border-primary min-w-[140px]"
+                      :class="{'text-grey': i, 'text-primary': !i}"
+                      v-for="(value, i) in item" :key="i"
+                      v-html="value"
+                  ></td>
+                </tr>
+                </tbody>
+              </table>
+            </div>
           </div>
-        </div>
-        <div class="card my-6 text-lg">
-          <div class="card__title mb-3 text-primary text-2xl font-bold">舌象分析</div>
-          <div class="card__content">
-            <table class="mt-8 mb-2 w-full table-auto border border-collapse  border-primary">
-              <thead>
-              <tr>
-                <th class="py-4 px-2 text-primary border border-primary"
-                    v-for="(value, i) in data.tongueTable.column" :key="i"
-                    v-html="value"
-                >
-                </th>
-              </tr>
-              </thead>
-              <tbody>
-              <tr v-for="item in data.tongueTable?.data" :key="item[0]">
-                <td class="py-4 px-2 border border-primary text-center text-grey"
-                    v-for="(value, i) in item" :key="i"
-                    v-html="value"
-                ></td>
-              </tr>
-              </tbody>
-            </table>
+          <AnalysisComponent title="舌象分析" v-bind="data.tongue" :cover="[]"></AnalysisComponent>
+          <AnalysisComponent title="面象分析" v-bind="data.face">
+            <template #content="{result, cover}">
+              <div class="card__content flex">
+                <div class="flex-auto text-grey mt-6">{{ result }}</div>
+                <div class="flex-none w-2/4 max-h-96 ml-4">
+                  <img class="size-full object-scale-down"  v-for="src in cover" :key="src" :src="src" alt="面象">
+                </div>
+              </div>
+            </template>
+            <template #exception><div><!--空占位符--></div></template>
+          </AnalysisComponent>
+          <div class="card m-6 text-lg" v-if="data['中医证素']?.length">
+            <div class="card__title mb-3 text-primary text-2xl font-bold">中医证素</div>
+            <div class="card__content">
+              <SyndromeChart
+                :dataset="data['中医证素']"
+                v-model:snapshot="data.factorItemRadarImg"
+              />
+              <table class="mt-8 mb-2 w-full table-auto border border-collapse  border-primary">
+                <tbody>
+                <tr v-for="item in data['中医证素']" :key="item.label">
+                  <td class="py-4 px-2 border border-primary text-primary text-center" v-html="item.label"></td>
+                  <td class="py-4 px-2 border border-primary text-grey" v-html="item.value"></td>
+                </tr>
+                </tbody>
+              </table>
+            </div>
           </div>
-        </div>
-        <div class="grid grid-rows-1 grid-cols-2 gap-8 my-6">
-          <div class="card text-lg" v-for="item in data.tongueException">
-            <div class="card__title mb-3 text-primary text-2xl font-bold">{{ item.title }}</div>
+          <div class="card m-6 text-lg" v-if="data['中医证型']?.length">
+            <div class="card__title mb-3 text-primary text-2xl font-bold">中医证型</div>
             <div class="card__content">
-              <div class="flex my-6 justify-center">
-                <img class="flex-none w-2/4 object-scale-down" :src="item.cover" alt="舌象">
-                <div class="flex-none ml-8">
-                  <div class="my-2 px-4 py-2 rounded-lg border border-primary-400 text-primary"
-                       v-for="value in item.tags" :key="value"
-                  >{{ value }}
-                  </div>
-                </div>
+              <div class="my-6 text-grey" v-for="item in data['中医证型']" :key="item.label">
+                <div class="my-2 text-primary" v-html="item.label"></div>
+                <div style="text-indent: 2em;" v-html="item.value"></div>
               </div>
-              <div class="my-2 text-grey" v-for="value in item.descriptions" :key="value">{{ value }}</div>
             </div>
           </div>
         </div>
-        <div class="card my-6 text-lg" v-if="data.faceAnalysis?.['结果']">
-          <div class="card__title mb-3 text-primary text-2xl font-bold">面象分析</div>
-          <div class="card__content flex">
-            <div class="flex-auto text-grey mt-6">{{ data.faceAnalysis?.[ '结果' ] }}</div>
-            <div class="flex-none w-2/4 max-h-96 ml-4">
-              <img class="size-full object-scale-down" :src="data.faceAnalysis?.['面象']" alt="面象">
+      </van-skeleton>
+      <div class="flex-none flex justify-between py-2 nav-wrapper" style="background-color: #12312c;">
+        <div class="m-auto min-w-16 text-center hover:text-primary" v-if="data.scheme" @click="toggle()">
+          <img :src="NavScheme" alt="调理方案">
+          <div class="mt-2">调理方案</div>
+        </div>
+        <div class="m-auto min-w-16 text-center hover:text-primary" v-if="data.miniProgramURL" @click="miniProgram()">
+          <img :src="NavMiniProgram" alt="小程序">
+          <div class="mt-2">小程序</div>
+        </div>
+        <div class="m-auto min-w-16 text-center hover:text-primary" @click="print()">
+          <van-loading v-if="uploading" color="#38ff6e" style="font-size: 24px;" />
+          <img v-else :src="NavPrint" alt="打印">
+          <div class="mt-2">打印</div>
+        </div>
+      </div>
+      <Component :is="ReportPreview" v-bind="reportPreviewProps" v-model:show="reportPreviewProps.show"></Component>
+      <van-floating-panel v-model:height="panelHeight" v-bind="panelProps">
+        <template #header>
+          <div class="van-floating-panel__header !justify-between">
+            <div></div>
+            <div class="van-floating-panel__header-bar"></div>
+            <div>
+              <van-icon v-if="!data.payLock" name="cross" @click.stop="panelHeight = panelProps.anchors[0];" />
             </div>
           </div>
-        </div>
-        <div class="card my-6 text-lg" v-if="data['中医证素']?.length">
-          <div class="card__title mb-3 text-primary text-2xl font-bold">中医证素</div>
-          <div class="card__content">
-            <SyndromeChart
-              :dataset="data['中医证素']"
-              v-model:snapshot="data.factorItemRadarImg" @update:snapshot="upload()"
+        </template>
+        <Transition>
+          <div class="panel-content">
+            <img
+              class="size-full object-contain"
+              v-if="panelHeight === panelProps.anchors[1] || panelHeight > panelProps.anchors[0] * 1.5"
+              :src="data.miniProgramURL"
+              alt="小程序码"
             />
-            <table class="mt-8 mb-2 w-full table-auto border border-collapse  border-primary">
-              <tbody>
-              <tr v-for="item in data['中医证素']" :key="item.label">
-                <td class="py-4 px-2 border border-primary text-primary text-center" v-html="item.label"></td>
-                <td class="py-4 px-2 border border-primary text-grey" v-html="item.value"></td>
-              </tr>
-              </tbody>
-            </table>
-          </div>
-        </div>
-        <div class="card my-6 text-lg" v-if="data['中医证型']?.length">
-          <div class="card__title mb-3 text-primary text-2xl font-bold">中医证型</div>
-          <div class="card__content">
-            <div class="my-6 text-grey" v-for="item in data['中医证型']" :key="item.label">
-              <div class="my-2 text-primary" v-html="item.label"></div>
-              <div style="text-indent: 2em;" v-html="item.value"></div>
+            <div class="flex justify-center items-center" v-else @click="panelOpen()">
+              <img class="h-10 w-10" src="@/assets/images/mini-program.svg" alt="小程序" />
+              <span class="text-lg ml-2">小程序</span>
             </div>
           </div>
-        </div>
-      </div>
-    </van-skeleton>
-    <NavBar class="flex-none"></NavBar>
+        </Transition>
+      </van-floating-panel>
+    </div>
   </div>
 </template>
 <style scoped lang="scss">
-.card {
-  padding: 24px;
-  border-radius: 24px;
-  box-shadow: inset 0 0 80px 0 #34a76b60;
-}
-
 .van-button.decorate {
   font-size: 20px;
   height: 62px;
@@ -181,4 +265,40 @@ const { loading: uploading, send: upload } = useRequest(() => updateReportMethod
 .text-grey {
   color: #e3e3e3;
 }
+
+.report-wrapper {
+  .panel-content {
+    padding: 0 var(--van-floating-panel-header-height) var(--van-floating-panel-header-height);
+  }
+
+  .v-enter-active,
+  .v-leave-active {
+    transition: opacity 0.5s ease;
+  }
+
+  .v-enter-from,
+  .v-leave-to {
+    opacity: 0;
+  }
+}
+
+.overflow-y-auto {
+  overflow-y: auto;
+}
+</style>
+<style lang="scss">
+.report-wrapper .card {
+  padding: 24px;
+  border-radius: 24px;
+  box-shadow: inset 0 0 80px 0 #34a76b60;
+}
+
+.nav-wrapper {
+  img {
+    margin: auto;
+    width: 36px;
+    height: 36px;
+    object-fit: scale-down;
+  }
+}
 </style>

+ 46 - 17
src/modules/report/scheme.page.vue

@@ -1,9 +1,11 @@
 <script setup lang="ts">
-import NavBar                    from '@/modules/report/NavBar.vue';
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+import NavScheme     from '@/assets/images/nav-scheme.png?url';
 import SchemeMedia               from '@/modules/report/SchemeMedia.vue';
 import { getReportSchemeMethod } from '@/request/api/report.api';
 import { useRouteParams }        from '@vueuse/router';
 import { useWatcher }            from 'alova/client';
+import { useRouter } from 'vue-router';
 
 
 const id = useRouteParams<string>('id');
@@ -13,29 +15,56 @@ const { data, loading } = useWatcher(() => getReportSchemeMethod(id.value), [ id
   },
   immediate: true,
 });
+
+const router = useRouter();
+
+function toggle() {
+  const path = router.currentRoute.value.fullPath.replace('/scheme', '');
+  router.replace({ path });
+}
 </script>
 <template>
-  <div class="flex flex-col overflow-hidden">
-    <!--{{ data }}-->
-    <van-skeleton class="flex-auto" title :row="3" :loading>
-      <div class="flex-auto px-6 overflow-y-auto">
-        <div class="card my-6 text-lg" v-for="item in data.children" :key="item.id">
-          <div class="card__title mb-3 text-primary text-2xl font-bold">{{ item.title }}</div>
-          <div class="card__content">
-            <div class="my-4" v-for="card in item.children" :key="card.id">
-              <div class="text-xl text-center text-primary">{{ card.title }}</div>
-              <SchemeMedia :media="card.media"></SchemeMedia>
-              <div v-if="card.description">{{ card.description }}</div>
-              <div v-for="(item,index) in card.descriptions ">
-                <span class="text-primary">【{{ item.title }}】</span>
-                <span v-html="item.description"></span>
+  <div>
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
+          调理方案
+        </div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col overflow-hidden">
+      <!--{{ data }}-->
+      <van-skeleton class="flex-auto" title :row="3" :loading>
+        <div class="flex-auto px-6 overflow-y-auto">
+          <div class="card my-6 text-lg" v-for="item in data.children" :key="item.id">
+            <div class="card__title mb-3 text-primary text-2xl font-bold">{{ item.title }}</div>
+            <div class="card__content">
+              <div class="my-4" v-for="card in item.children" :key="card.id">
+                <div class="text-xl text-center text-primary">{{ card.title }}</div>
+                <SchemeMedia :media="card.media"></SchemeMedia>
+                <div v-if="card.description">{{ card.description }}</div>
+                <div v-for="(item,index) in card.descriptions ">
+                  <span class="text-primary">【{{ item.title }}】</span>
+                  <span v-html="item.description"></span>
+                </div>
               </div>
             </div>
           </div>
         </div>
+      </van-skeleton>
+      <div class="flex-none flex justify-between py-2 nav-wrapper" style="background-color: #12312c;">
+        <div class="m-auto min-w-16 text-center hover:text-primary" @click="toggle()">
+          <img :src="NavScheme" alt="健康报告">
+          <div class="mt-2">健康报告</div>
+        </div>
       </div>
-    </van-skeleton>
-    <NavBar class="flex-none"></NavBar>
+    </div>
   </div>
 </template>
 <style scoped lang="scss">

+ 118 - 200
src/pages/register.page.vue

@@ -1,151 +1,35 @@
 <script setup lang="ts">
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
 import { Notify, Toast } from '@/platform';
 
 import {
-  type FieldKey,
   getCaptchaMethod,
   processMethod,
   registerAccountMethod,
   registerFieldsMethod,
   searchAccountMethod,
-} from '@/request/api';
+}                                     from '@/request/api';
+import type { Fields, RegisterModel } from '@/request/model';
+import { useRouteQuery }              from '@vueuse/router';
 
-import { useVisitor } from '@/stores';
+import { useCaptcha, useRequest, useSerialRequest } from 'alova/client';
 
-import { useCaptcha, useForm, useRequest } from 'alova/client';
+import type { FormInstance }           from 'vant';
+import { RadioGroup as vanRadioGroup } from 'vant';
 
-import type { FieldRule, FormInstance, NumberKeyboardProps, PasswordInputProps } from 'vant';
-import { RadioGroup as vanRadioGroup }                                           from 'vant';
 
+const { data: fields, loading } = useRequest(registerFieldsMethod);
 
-interface Field {
-  control: {
-    label: string; placeholder?: string;
-    type?: string; min?: number; max?: number; minlength?: number; maxlength?: number;
-    clearable?: boolean; border?: boolean;
-  };
-  component?: |
-    { name: 'radio', options: { label: string; value: string; }[] } |
-    { name: 'code', props?: Partial<PasswordInputProps> };
-  keyboard?: { show: boolean; } & Partial<NumberKeyboardProps>;
-  suffix?: string;
-  rules?: FieldRule | FieldRule[];
-}
-
-const Fields: Record<FieldKey, Field> = {
-  height: {
-    control: {
-      label: '身高', placeholder: '请输入身高',
-      type: 'number', min: 1, max: 300, clearable: true,
-    },
-    suffix: 'cm',
-  },
-  weight: {
-    control: {
-      label: '体重', placeholder: '请输入体重',
-      type: 'number', min: 1, max: 300, clearable: true,
-    },
-    suffix: 'kg',
-  },
-  sex: {
-    control: { label: '性别', border: false },
-    component: {
-      name: 'radio' as const,
-      options: [
-        { label: '男', value: '0' },
-        { label: '女', value: '1' },
-        { label: '未知', value: '2' },
-      ],
-    },
-  },
-  isEasyAllergy: {
-    control: { label: '容易过敏', border: false },
-    component: {
-      name: 'radio' as const,
-      options: [
-        { label: '是', value: 'Y' },
-        { label: '否', value: 'N' },
-      ],
-    },
-  },
-  name: {
-    control: {
-      label: '姓名', placeholder: '请输入姓名',
-      type: 'text', maxlength: 10, clearable: true,
-    },
-  },
-  cardno: {
-    control: {
-      label: '身份证号', placeholder: '请输入身份证号',
-      type: 'text', maxlength: 18, minlength: 18, clearable: true,
-    },
-    keyboard: { show: false, title: '身份证号', extraKey: 'X', closeButtonText: '完成' },
-    rules: [
-      { required: true, message: '请输入身份证号' },
-      {
-        validator: (value: string) => value && value.length === 18,
-        message: '请输入正确的身份证',
-        trigger: 'onBlur',
-      },
-    ],
-  },
-  phone: {
-    control: {
-      label: '手机号码', placeholder: '请输入手机号码',
-      type: 'tel', maxlength: 11, minlength: 11, clearable: true,
-    },
-    keyboard: { show: false, title: '手机号码', closeButtonText: '完成' },
-    rules: [
-      { required: true, message: '请输入手机号码' },
-      {
-        validator: (value: string) => value && value.length === 11,
-        message: '请输入正确的手机号码',
-        trigger: 'onBlur',
-      },
-    ],
-  },
-  code: {
-    control: {
-      label: '验证码', placeholder: '请输入验证码',
-      type: 'digit', maxlength: 6, minlength: 6, clearable: true,
-      border: false,
-    },
-    component: {
-      name: 'code' as const,
-      props: { mask: false },
-    },
-    keyboard: { show: false, title: '验证码', closeButtonText: '完成' },
-    rules: [
-      { required: true, message: '请输入验证码' },
-      {
-        validator: (value: string) => value && value.length === 6,
-        message: '请输入验证码',
-        trigger: [ 'onChange', 'onBlur' ],
-      },
-    ],
-  },
-};
-
-
-const fields = ref<( Field & { name: FieldKey } )[]>([]);
-const { loading } = useRequest(registerFieldsMethod).onSuccess(({ data }) => {
-  fields.value = data.map(name => {return { ...Fields[ name ], name };});
-});
-
-const Visitor = useVisitor();
 const formRef = useTemplateRef<FormInstance>('register-form');
-const { form: modelRef, loading: submitting, send: submit } = useForm(data => registerAccountMethod(data), {
-  initialForm: { code: '', sex: '2' } as Record<string, any>,
-}).onSuccess(async ({ data }) => {
-  Visitor.patientId = data;
-  Toast.success(`操作成功`);
-  try {
-    submitting.value = true;
-    await handle();
-  } finally {
-    submitting.value = false;
-  }
-});
+const modelRef = ref<Partial<RegisterModel>>({ code: '' });
+
+const router = useRouter();
+const { loading: submitting, send: submit } = useSerialRequest([
+  data => registerAccountMethod(data),
+  () => processMethod(),
+], { immediate: false })
+  .onSuccess(({ data }) => {router.replace(data);})
+  .onError(({ error }) => Notify.warning(error.message));
 
 const { loading: searching, send: search } = useRequest((data) => searchAccountMethod(data), {
   immediate: false,
@@ -153,10 +37,14 @@ const { loading: searching, send: search } = useRequest((data) => searchAccountM
   modelRef.value = { ...modelRef.value, ...data };
 });
 
+let captchaLoaded = false;
 const { loading: captchaLoading, countdown, send: getCaptcha } = useCaptcha(
   () => getCaptchaMethod(modelRef.value.phone!),
   { initialCountdown: 60 },
-);
+).onSuccess(({ data }) => {
+  captchaLoaded = true;
+  Toast.success(data ?? '获取成功')
+});
 const getCaptchaHandle = async () => {
   try {
     await formRef.value?.validate('phone');
@@ -177,8 +65,8 @@ const searchHandle = async (key: 'cardno' | 'code') => {
   }
 };
 
-function onKeyboardBlur(field: Field & { name: FieldKey }) {
-  if ( field?.name === 'phone' ) { getCaptchaHandle(); }
+function onKeyboardBlur(field: Fields[number]) {
+  if ( field?.name === 'phone' && !captchaLoaded ) { getCaptchaHandle(); }
   if ( field?.name === 'cardno' ) { searchHandle('cardno'); }
   if ( field?.name === 'code' ) { searchHandle('code'); }
 }
@@ -187,73 +75,99 @@ function onSubmitHandle() {
   submit(toValue(modelRef));
 }
 
-const router = useRouter();
-const { send: handle } = useRequest(
-  () => processMethod('/register'),
-  { immediate: false },
-).onSuccess(
-  ({ data }) => {
-    if ( data ) {
-      router.replace(data);
-    } else {
-      Notify.warning(`[路由] 配置异常无法解析正确路径,请联系管理员`);
-    }
-  });
+function fix(key: string) {
+  for ( const field of fields.value ) {
+    if (field.keyboard?.show && field.name !== key ) field.keyboard.show = false;
+  }
+}
+
+const scan = useRouteQuery<string>('scan');
+watch(scan, key => {
+  if ( key ) {
+    try {
+      const { model } = JSON.parse(sessionStorage.getItem(`scan_${ key }`) ?? '');
+      modelRef.value = { ...modelRef.value, ...model };
+    } catch ( e: any ) {}
+  }
+}, { immediate: true });
+
+onBeforeUnmount(() => {
+  for ( let i = 0; i < sessionStorage.length; i++ ) {
+    const key = sessionStorage.key(i);
+    if ( key?.startsWith('scan_') ) sessionStorage.removeItem(key);
+  }
+});
 </script>
 <template>
-  <div class="p-6">
-    <van-form class="register-form" ref="register-form" colon required="auto"
-              scroll-to-error scroll-to-error-position="center"
-              @submit="onSubmitHandle()"
-    >
-      <van-cell-group :border="false">
-        <template v-for="field in fields" :key="field.name">
-          <van-field v-model="modelRef[field.name]" :name="field.name" :id="field.name"
-                     :rules="field.rules" v-bind="field.control"
-                     :class="{'no-border': field.control?.border === false}"
-                     :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true)"
-                     @blur="field.keyboard && (field.keyboard.show = false)"
+  <div>
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
+          建档
+        </div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页">
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content p-6">
+      <van-form class="register-form" ref="register-form" colon required="auto"
+                scroll-to-error scroll-to-error-position="center"
+                @submit="onSubmitHandle()"
+      >
+        <van-cell-group :border="false">
+          <template v-for="field in fields" :key="field.name">
+            <van-field v-model="modelRef[field.name]" :name="field.name" :id="field.name"
+                       :rules="field.rules" v-bind="field.control"
+                       :class="{'no-border': field.control?.border === false}"
+                       :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true)"
+                       @blur="field.keyboard && (field.keyboard.show = false)"
+                       :readonly="field.control.readonly" @click="field.keyboard && (field.keyboard.show = true)"
+            >
+              <template #input v-if="field.component?.name === 'radio'">
+                <van-radio-group v-model="modelRef[field.name]" direction="horizontal" shape="dot">
+                  <van-radio v-for="option in field.component?.options" :key="option.value" :name="option.value">
+                    {{ option.label }}
+                  </van-radio>
+                </van-radio-group>
+              </template>
+              <template #input v-else-if="field.component?.name === 'code'">
+                <van-password-input
+                  style="width: 100%;"
+                  v-model:value="modelRef[field.name]" v-bind="(field.component as any)!.props"
+                  :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true);fix('code')"
+                />
+              </template>
+              <template #button>
+                <div class="text-primary cursor-pointer">
+                  <template v-if="field.component?.name === 'code'">
+                    <div class="text-primary cursor-pointer" @click="getCaptchaHandle()">
+                      {{ captchaLoading ? '发送中...' : countdown > 0 ? `${ countdown }后可重发` : '获取验证码' }}
+                    </div>
+                  </template>
+                  <template v-else>{{ field.suffix }}</template>
+                </div>
+              </template>
+            </van-field>
+            <van-number-keyboard
+              v-if="field.keyboard"
+              v-model="modelRef[field.name]"
+              v-bind="field.keyboard" :maxlength="field.control.maxlength"
+              @blur="field.keyboard.show = false; onKeyboardBlur(field)"
+            />
+          </template>
+        </van-cell-group>
+      </van-form>
+      <div class="m-4">
+        <div class="m-auto size-16 cursor-pointer">
+          <van-loading v-if="submitting || loading" type="spinner" size="64" color="#38ff6e" />
+          <img v-else class="size-full"
+               src="@/assets/images/next-step.svg" alt="提交" @click="formRef?.submit()"
           >
-            <template #input v-if="field.component?.name === 'radio'">
-              <van-radio-group v-model="modelRef[field.name]" direction="horizontal" shape="dot">
-                <van-radio v-for="option in field.component?.options" :key="option.value" :name="option.value">
-                  {{ option.label }}
-                </van-radio>
-              </van-radio-group>
-            </template>
-            <template #input v-else-if="field.component?.name === 'code'">
-              <van-password-input
-                style="width: 100%;"
-                v-model:value="modelRef[field.name]" v-bind="field.component.props"
-                :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true)"
-              />
-            </template>
-            <template #button>
-              <div class="text-primary cursor-pointer">
-                <template v-if="field.component?.name === 'code'">
-                  <div class="text-primary cursor-pointer" @click="getCaptchaHandle()">
-                    {{ captchaLoading ? '发送中...' : countdown > 0 ? `${ countdown }后可重发` : '获取验证码' }}
-                  </div>
-                </template>
-                <template v-else>{{ field.suffix }}</template>
-              </div>
-            </template>
-          </van-field>
-          <van-number-keyboard
-            v-if="field.keyboard"
-            v-model="modelRef[field.name]"
-            v-bind="field.keyboard" :maxlength="field.control.maxlength"
-            @blur="field.keyboard.show = false; onKeyboardBlur(field)"
-          />
-        </template>
-      </van-cell-group>
-    </van-form>
-    <div class="m-4">
-      <div class="m-auto size-16 cursor-pointer">
-        <van-loading v-if="submitting" type="spinner" size="64" color="#38ff6e" />
-        <img v-else class="size-full"
-             src="@/assets/images/next-step.svg" alt="提交" @click="formRef?.submit()"
-        >
+        </div>
       </div>
     </div>
   </div>
@@ -320,6 +234,10 @@ const { send: handle } = useRequest(
     $size: 40px;
     height: $size + 2px;
 
+    &::after {
+      display: none;
+    }
+
     li {
       height: $size;
       width: $size;

+ 47 - 26
src/pages/screen.page.vue

@@ -1,25 +1,39 @@
 <script setup lang="ts">
-import { Notify }                         from '@/platform';
-import { copyrightMethod, processMethod } from '@/request/api';
-import { useVisitor }                     from '@/stores';
-import { useElementSize }                 from '@vueuse/core';
-import { useRequest }                     from 'alova/client';
-import p5                                 from 'p5';
+import { Dialog, Notify } from '@/platform';
+
+import { copyrightMethod, processMethod, registerVisitorMethod } from '@/request/api';
+
+import { useVisitor }     from '@/stores';
+import getBubbles         from '@/tools/bubble';
+import { useElementSize } from '@vueuse/core';
+import { useRequest }     from 'alova/client';
+import p5                 from 'p5';
 
 
 const router = useRouter();
 const Visitor = useVisitor();
 
 const title = import.meta.env.SIX_APP_TITLE;
-const { data: copyright } = useRequest(copyrightMethod);
-const { send: handle, loading } = useRequest(processMethod, { immediate: false }).onSuccess(({ data }) => {
-  if ( data ) {
-    Visitor.$reset();
-    router.push(data);
-  } else {
-    Notify.warning(`[路由] 配置异常无法解析正确路径,请联系管理员`);
-  }
+const { data: visitor, loading: registering, send: register } = useRequest(registerVisitorMethod, { immediate: false, });
+const { data: copyright, send: load } = useRequest(copyrightMethod).onError(async ({ error }) => {
+  await Dialog.show({
+    message: error.message,
+    theme: 'round-button',
+    showCancelButton: false,
+    confirmButtonText: '刷新',
+    width: '350px',
+  });
+  await load();
 });
+const { send: handle, loading } = useRequest(processMethod, { immediate: false })
+  .onSuccess(({ data }) => {
+    Visitor.$reset();
+    router.push({ path: data, replace: true }).then(
+      () => { if (visitor.value) Visitor.patientId = visitor.value; },
+      () => {}
+    );
+  })
+  .onError(({ error }) => Notify.warning(error.message));
 
 const container = useTemplateRef<HTMLDivElement>('container');
 const { width, height } = useElementSize(container);
@@ -32,6 +46,7 @@ interface Bubble {
   dx?: number;
   dy?: number;
   diameter?: number;
+  size?: number;
 }
 
 watchEffect(() => {
@@ -81,15 +96,7 @@ function init({ width, height, container }: { width: number; height: number; con
       sketch.pop();
     };
 
-    const bubbles: Bubble[] = [
-      { text: '有气\n无力', color: '#367dd599' },
-      { text: '容易\n犯困', color: '#b1450399' },
-      { text: '睡眠\n障碍', color: '#34b10399' },
-      { text: '消化\n不良', color: '#b1860399' },
-      { text: '肩颈\n腰痛', color: '#03b19b99' },
-      { text: '掉\n头发', color: '#b1a30399' },
-      { text: '记忆力\n下降', color: '#34b10399' },
-    ];
+    const bubbles: Bubble[] = getBubbles();
     const drawBubble = (x = 40, y = x, diameter = 90) => {
       for ( const bubble of <Required<Bubble>[]> bubbles ) {
         bubble.diameter ??= diameter;
@@ -106,7 +113,7 @@ function init({ width, height, container }: { width: number; height: number; con
         if ( bubble.x + radius >= width - y ) bubble.x = width - y;
         if ( bubble.y + radius >= height - y ) bubble.y = height - y;
         // 绘制
-        const size = 24;
+        const size = bubble.size;
         const color = sketch.color(bubble.color);
         sketch.push();
         sketch.fill('#fff');
@@ -127,7 +134,10 @@ function init({ width, height, container }: { width: number; height: number; con
         }
       }
       const collide = (bubble: Required<Bubble>, other: Required<Bubble>) => {
-        const radius = Math.floor(bubble.diameter / 2);
+        const ra = Math.floor(bubble.diameter / 2);
+        const rb = Math.floor(other.diameter / 2);
+
+        const radius = Math.max(ra, rb);
 
         let angle = sketch.atan2(other.y - bubble.y, other.x - bubble.x);
         let target = sketch.createVector(bubble.x, bubble.y);
@@ -166,6 +176,11 @@ function init({ width, height, container }: { width: number; height: number; con
     };
   }, container);
 }
+
+onBeforeRouteLeave((to, from) => {
+  if (to.path === '/register' || Visitor.patientId) return true;
+  return register().then((data) => !!data, () => false);
+});
 </script>
 <template>
   <div class="wrapper">
@@ -176,7 +191,13 @@ function init({ width, height, container }: { width: number; height: number; con
       </div>
       <div class="flex-auto flex flex-col">
         <div class="flex-auto flex justify-center items-center">
-          <van-button class="decorate" :loading @click="handle()">开始检测</van-button>
+          <!--<van-button class="decorate" :loading="loading || registering" @click="handle()">开始检测</van-button>-->
+          <div class="van-button decorate" @click="!(loading || registering) && handle()">
+            <div class="van-button__content">
+              <van-loading v-if="loading || registering" />
+              <span v-else class="van-button__text">开始检测</span>
+            </div>
+          </div>
         </div>
         <div class="flex-none text-xl p-8 text-center" v-html="copyright"></div>
       </div>

+ 10 - 0
src/platform/dialog.ui.ts

@@ -0,0 +1,10 @@
+import type { DialogOptions as VantDialogOptions } from 'vant';
+import { showDialog }                              from 'vant';
+import 'vant/es/dialog/style';
+
+
+export class Dialog {
+  static show(options: VantDialogOptions) {
+    return showDialog(options);
+  }
+}

+ 6 - 1
src/platform/index.ts

@@ -4,7 +4,11 @@ import { getURLSearchParams } from '@/tools';
 const userAgent = navigator.userAgent;
 
 export function platformIsAIO() {
-  return /aio\/\w+/i.test(userAgent);
+  return /Six\/applet \(AIO;.+\)/i.test(userAgent) || /aio\/\w+/i.test(userAgent);
+}
+
+export function platformIsAIO_1() {
+  return /aio\/1\.0/i.test(userAgent);
 }
 
 export function getSerialNumberSync() {
@@ -17,5 +21,6 @@ export function getSerialNumberSync() {
   )();
 }
 
+export * from './dialog.ui';
 export * from './notify.ui';
 export * from './toast.ui';

+ 15 - 0
src/polyfill.ts

@@ -0,0 +1,15 @@
+if (typeof Promise.withResolvers !== 'function') {
+  Promise.withResolvers = function <T>() {
+    let resolve!: (value: T | PromiseLike<T>) => void;
+    let reject!: (reason?: any) => void;
+
+    const promise = new Promise<T>((res, rej) => {
+      resolve = res;
+      reject = rej;
+    });
+
+    return { promise, resolve, reject };
+  };
+}
+
+export {};

+ 3 - 3
src/request/alova.ts

@@ -7,7 +7,7 @@ import VueHook         from 'alova/vue';
 
 
 export default createAlova({
-  baseURL: import.meta.env.BASE_URL,
+  baseURL: import.meta.env.SIX_REQUEST_BASE ?? '/',
   statesHook: VueHook,
   requestAdapter: import.meta.env.DEV ? adapterMock() : adapterFetch(),
   async beforeRequest(method) {
@@ -25,7 +25,7 @@ export default createAlova({
         if ( result.success === true || result.code === 200 ) result.code = 0;
         const { success = false, code = success ? 0 : -1, data, msg: message = '未知错误', ...props } = result;
         if ( code === 0 ) { return data; } else {}
-        throw { ...props, message: `${ message }(${ code })` };
+        throw { ...props, message: `${ message }` };
       } catch ( e: any ) {
         if ( !method.meta?.ignoreException ) {
           const { Notify } = await import('@/platform/notify.ui');
@@ -37,7 +37,7 @@ export default createAlova({
     async onError(error, method) {
       if ( !method.meta?.ignoreException && error.name !== 'AbortError' ) {
         const { Notify } = await import('@/platform/notify.ui');
-        Notify.error(`${ error.message }(${ error?.code })`);
+        Notify.error(`${ error.message }`);
       }
     },
     onComplete() {},

+ 41 - 15
src/request/api/account.api.ts

@@ -1,40 +1,66 @@
-import { cacheFor }           from '@/request/api/index';
-import type { RegisterModel } from '@/request/model';
-import HTTP                   from '../alova';
+import { cacheFor }                                                     from '@/request/api/index';
+import { type Fields, fromRegisterFields, getPath, type RegisterModel } from '@/request/model';
+import { useVisitor } from '@/stores';
 
-
-export type FieldKey = 'cardno' | 'phone' | 'code' | 'name' | 'sex' | 'height' | 'weight' | 'isEasyAllergy'
+import HTTP from '../alova';
 
 
 export function getCaptchaMethod(mobile: string) {
-  return HTTP.Get(`/fdhb-tablet/sms/sendVerCode`, {
+  return HTTP.Get<string>(`/fdhb-tablet/sms/sendVerCode`, {
     params: { phone: mobile },
   });
 }
 
 export function registerFieldsMethod() {
-  return HTTP.Post<FieldKey[], { tabletFileFields: FieldKey[] }>(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
-    cacheFor,
+  return HTTP.Post<Fields, { tabletFileFields: string[] }>(`/fdhb-tablet/warrantManage/getPageSets`, void 0, {
+    cacheFor, name: `variate:register_fields`,
+    params: { k: 'register_fields' },
     transform(data, headers) {
-      // 修正 phone,code
-      const keys = data?.tabletFileFields?.join(',').replace(`phone`, 'phone,code') ?? '';
-      const replace = (key: string) => key.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
-      return keys.split(',').filter(Boolean).map(replace) as FieldKey[] ?? [];
+      const options = data?.tabletFileFields ?? [];
+      return fromRegisterFields(options);
     },
   });
 }
 
+export function registerVisitorMethod() {
+  return HTTP.Get<string, string>(`/fdhb-tablet/patientInfoManage/createTourist`, {
+    name: 'register',
+    cacheFor: null,
+  })
+}
+
 export function registerAccountMethod(params: Partial<RegisterModel>) {
-  if ( import.meta.env.DEV && params.code ) params.code = '176364';
-  return HTTP.Post<string, string>(`/fdhb-tablet/patientInfoManage/savePatientInfo`, params, {});
+  return HTTP.Post<string, string>(`/fdhb-tablet/patientInfoManage/savePatientInfo`, params, {
+    name: 'register',
+    transform(data) {
+      const Visitor = useVisitor();
+      Visitor.patientId = data;
+      return data;
+    },
+  });
 }
 
 export function searchAccountMethod(params: Partial<RegisterModel>) {
-  if ( import.meta.env.DEV && params.code ) params.code = '176364';
   return HTTP.Get(`/fdhb-tablet/patientInfoManage/getPatientInfoDetail`, {
+    hitSource: 'register',
     params,
     transform(data: Record<string, any>, headers) {
       return Object.fromEntries(Object.entries(data).filter(([ item, value ]) => !!value)) as Partial<RegisterModel>;
     },
   });
 }
+
+export function scanAccountMethod(key: string) {
+  return HTTP.Get(`/fdhb-tablet/patientInfoManage/getPatInfoByScanCode`, {
+    hitSource: 'register',
+    params: { scanCode: key },
+    transform(data: Record<string, any>, headers) {
+      const { id: patientId, processModule, ..._data } = data;
+      if ( patientId ) useVisitor().patientId = patientId;
+      return {
+        key, patientId, path: getPath(processModule),
+        model: Object.fromEntries(Object.entries(_data).filter(([ item, value ]) => !!value)) as Partial<RegisterModel>,
+      };
+    },
+  });
+}

+ 20 - 4
src/request/api/camera.api.ts

@@ -1,5 +1,7 @@
 import HTTP              from '@/request/alova';
-import { processMethod } from '@/request/api/flow.api';
+import { processMethod2 } from '@/request/api/flow.api';
+import { useVisitor }    from '@/stores';
+import { Dialog } from '@/platform';
 
 
 export function uploadFileMethod(file: File) {
@@ -9,14 +11,28 @@ export function uploadFileMethod(file: File) {
 }
 
 export function saveFileMethod(params: Record<string, string>) {
+  const Visitor = useVisitor();
+  params.patientId = Visitor.patientId!;
   return HTTP.Get(`/fdhb-tablet/patientInfoManage/saveTonguefaceImg`, {
     params,
     async transform(data: string, headers) {
-      const path = await processMethod(`/camera`);
+      Visitor.resultId = data;
+      const flow = await processMethod2();
+      let path = flow.optional
+        ? await Dialog.show({
+            title: flow.title || '获取健康调理方案',
+            confirmButtonText: '好的',
+            showCancelButton: true,
+            cancelButtonText: '返回首页',
+            width: 350,
+          }).then(
+            () => flow.route,
+            () => '/screen'
+          )
+        : flow.route;
       return {
         resultId: data,
-        done: !path,
-        path: path || `/screen`,
+        route: { path },
       };
     },
   });

+ 54 - 24
src/request/api/flow.api.ts

@@ -1,37 +1,67 @@
-import { cacheFor } from '@/request/api/index';
-import HTTP         from '../alova';
+import { cacheFor }     from '@/request/api/index';
+import { type Flow, fromFlowData } from '@/request/model';
+import Router           from '@/router';
+
+import HTTP from '../alova';
 
 
 export function copyrightMethod() {
-  return HTTP.Post(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
-    cacheFor,
+  return HTTP.Post(`/fdhb-tablet/warrantManage/getPageSets`, void 0, {
+    cacheFor, name: `variate:copyright`,
+    params: { k: 'copyright' },
+    meta: { ignoreException: true },
     transform(data: any, headers) {
+      if ( !data ) { throw { message: `无效的证书` }; }
       return [ data?.partner, data?.technicalSupporter ].filter(Boolean).join('<br>');
     },
   });
 }
 
-export function processMethod(value = '/screen') {
-  const routes: Record<string, string> = {
-    'patient_file': '/register',
-    'tongueface_upload': '/camera',
-    'tongueface_analysis': '/questionnaire',
-    'health_analysis': '/report',
-  };
-  return HTTP.Post(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
-    cacheFor,
-    params: { t: 'process', k: value },
-    transform(data: any, headers) {
-      const options = data?.tabletProcessModules ?? [];
-      const ref = new Map<string, string>();
-      for ( let i = 0; i < options.length; i++ ) {
-        const route = routes[ options[ i ] ];
-        if ( !route ) continue;
-        if ( !i ) ref.set('/screen', route);
-        ref.set(route, routes[ options[ i + 1 ] ] ?? '');
-      }
-      return ref.get(value);
+export function processMethod() {
+  const path = unref(Router.currentRoute).path;
+  return HTTP.Post<string, { tabletProcessModules?: string[]; }>(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
+    cacheFor, name: `variate:process`,
+    params: { k: 'process', p: path },
+    transform(data) {
+      const options = data.tabletProcessModules ?? [];
+      const ref = fromFlowData(options);
+      const flow = ref.get(path);
+      if ( !flow ) throw { message: `[路由] 配置异常无法解析正确路径,请联系管理员 (${ data?.tabletProcessModules?.join(' -> ') }})` };
+      return flow.route;
     },
   });
 }
 
+export function processMethod2() {
+  const path = unref(Router.currentRoute).path;
+  return HTTP.Post<Flow, { tabletProcessModules?: string[]; }>(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
+    cacheFor, name: `variate:process`,
+    params: { k: 'process', p: path },
+    transform(data) {
+      const options = data.tabletProcessModules ?? [];
+      const ref = fromFlowData(options);
+      const flow = ref.get(path);
+      if ( !flow ) throw { message: `[路由] 配置异常无法解析正确路径,请联系管理员 (${ data?.tabletProcessModules?.join(' -> ') }})` };
+      return flow;
+    },
+  });
+}
+
+
+export function processMethod3() {
+  const path = unref(Router.currentRoute).path;
+  return HTTP.Post<{ current: Flow; prev: Flow; next: Flow }, { tabletProcessModules?: string[]; }>(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
+    cacheFor, name: `variate:process`,
+    params: { k: 'process', p: path },
+    transform(data) {
+      const options = data.tabletProcessModules ?? [];
+      const ref = fromFlowData(options);
+      const [key, current] = [...ref.entries()].find(([,flow]) => flow.route === path) || [];
+      const prev = [...ref.values()].find(flow => flow.route === key);
+      const next = ref.get(path);
+      if ( !next ) throw { message: `[路由] 配置异常无法解析正确路径,请联系管理员 (${ data?.tabletProcessModules?.join(' -> ') }})` };
+      return { current, prev, next } as any;
+    }
+  })
+}
+

+ 2 - 1
src/request/api/index.ts

@@ -1,6 +1,7 @@
 export * from './account.api';
 export * from './flow.api';
 export * from './questionnaire.api';
+export * from './report.api';
 
 
-export const cacheFor = 60 * 60 * 1000;
+export const cacheFor = 24 * 60 * 60 * 1000;

+ 18 - 0
src/request/api/pulse.api.ts

@@ -0,0 +1,18 @@
+import HTTP from '@/request/alova';
+
+export type PulseResult = Awaited<ReturnType<typeof Bridge.pulse>> & { patientId: string; results: string };
+const getSummary = (value?: string[]) => (Array.isArray(value) && value.length ? value : void 0);
+
+export function putPulseMethod(patientId: string, result: PulseResult) {
+  const summary =
+    getSummary(result.summaryLabel?.summary) ??
+    getSummary(result.summaryLabel?.left?.summary) ??
+    getSummary(result.summaryLabel?.right?.summary) ??
+    [];
+  const data = Object.assign(result, { patientId, results: summary.join(',') });
+  return HTTP.Post<PulseResult, any>(`/fdhb-tablet/analysisManage/savePulseAnalysisReport`, data, {
+    transform() {
+      return data;
+    },
+  });
+}

+ 8 - 8
src/request/api/questionnaire.api.ts

@@ -1,24 +1,24 @@
-import { useVisitor }                                 from '@/stores';
+import { useVisitor } from '@/stores';
 import HTTP                                           from '../alova';
 import type { QuestionnaireStorage }                  from '../model';
 import { fromQuestionnaireData, toQuestionnaireData } from '../model';
 
 
-const visitor = useVisitor();
 let storage: Pick<QuestionnaireStorage, 'dialogId'> & { questions: QuestionnaireStorage['questions'][] } = { questions: [] };
 
 export function questionnaireMethod(data = []) {
+  const Visitor = useVisitor();
   if ( !data?.length ) { storage = { questions: [] }; }
-  const step = storage.questions.length;
   return HTTP.Post(
-    `/fdhb-tablet/dialogueManage/dialog/${ visitor.patientId }/${ visitor.resultId }`,
-    { step, ...toQuestionnaireData(data, { dialogId: storage.dialogId, questions: storage.questions[ step - 1 ] }) },
+    `/fdhb-tablet/dialogueManage/dialog/${ Visitor.patientId }/${ Visitor.resultId }`,
+    { ...toQuestionnaireData (data, storage), asyncTongueResult: true },
     {
+      meta: { ignoreException: true },
       transform(data: Record<string, any>, headers) {
-        const { storage: { dialogId, questions }, model } = fromQuestionnaireData(data);
-        storage = { dialogId, questions: questions.length ? [ ...storage.questions, questions ] : [] };
+        const { storage: _storage, model } = fromQuestionnaireData(data);
+        storage = _storage as any;
         return model;
       },
-    },
+    }
   );
 }

+ 60 - 5
src/request/api/report.api.ts

@@ -1,26 +1,65 @@
-import HTTP                                  from '../alova';
-import { fromReportData, fromSchemeRequest } from '../model';
+import { cacheFor }                                             from '@/request/api/index';
+import { useVisitor }                                           from '@/stores';
+import HTTP                                                     from '../alova';
+import { fromAnalysisModel, fromReportData, fromSchemeRequest } from '../model';
 
 
+export function getAnalysisResultsMethod() {
+  const Visitor = useVisitor();
+  return HTTP.Post(
+    `/fdhb-tablet/dialogueManage/dialog/${ Visitor.patientId }/${ Visitor.resultId }`,
+    { questions: [], asyncTongueResult: false, patientId: Visitor.patientId },
+    {
+      meta: { ignoreException: true },
+      async transform(data: Record<string, any>, headers) {
+        const id = data?.tonguefaceAnalysisReportId;
+        const date = data?.tonguefaceAnalysisReportDate;
+        const miniProgramURL = data?.tonguefaceAnalysisReportAppletImg;
+        data = data.nextQuestions?.find((item: any) => item.classify === 'tongue_result');
+        if ( data ) {
+          const { show, force } = await miniProgramMethod();
+          return {
+            id, date, miniProgramURL: show ? miniProgramURL : void 0,
+            tongue: fromAnalysisModel('tongue', data),
+            face: fromAnalysisModel('face', data),
+            payLock: show && force,
+          };
+        }
+        throw { message: `[分析结果] 照片不符合检测要求,图片不是舌头(请拍摄带有舌头的、清晰的彩色照!)` };
+      },
+    },
+  );
+}
+
 export function getReportMethod(id: string) {
-  const params = { healthAnalysisReportId: id };
+  const Visitor = useVisitor();
+  const params = { healthAnalysisReportId: id, patientId: Visitor.patientId };
   return HTTP.Get(`/fdhb-tablet/analysisManage/getHealRepDetailById`, {
     params,
-    transform(data, headers) { return fromReportData(<any> data); },
+    async transform(data, headers) {
+      const report = fromReportData(<any> data);
+      const { show, force } = await miniProgramMethod();
+      if ( !show ) { report.miniProgramURL = void 0; }
+      report.payLock = show && force;
+      return report;
+    },
   });
 }
 
 export function updateReportMethod(id: string, data: Record<string, any>) {
+  const Visitor = useVisitor();
   const params = {
     healthAnalysisReportId: id,
     constitutionGroupImg: data?.constitutionGroupImg,
     factorItemRadarImg: data?.factorItemRadarImg,
+    patientId: Visitor.patientId
   };
   return HTTP.Post(`/fdhb-tablet/analysisManage/upConFacImgById`, params, {});
 }
 
 export function getReportSchemeMethod(id: string) {
-  const params = { healthAnalysisReportId: id };
+  const Visitor = useVisitor();
+  const params = { healthAnalysisReportId: id, patientId: Visitor.patientId };
   return HTTP.Get(`/fdhb-tablet/analysisManage/getCondProgDetailById`, {
     params,
     transform(data: any, headers) {
@@ -28,3 +67,19 @@ export function getReportSchemeMethod(id: string) {
     },
   });
 }
+
+
+export function miniProgramMethod() {
+  return HTTP.Post<{ show: boolean; force: boolean; }, { tabletRequiredPageOperationElements: string[] }>(
+    `/fdhb-tablet/warrantManage/getPageSets`, void 0, {
+      cacheFor, name: `variate:mini_program`,
+      params: { k: 'mini_program' },
+      transform(data, headers) {
+        return {
+          show: data?.tabletRequiredPageOperationElements?.includes('health_analysis_report_page_appletbutton'),
+          force: data?.tabletRequiredPageOperationElements?.includes('health_analysis_report_page_appletscan'),
+        };
+      },
+    });
+}
+

+ 136 - 0
src/request/model/analysis.model.ts

@@ -0,0 +1,136 @@
+import { groupBy } from '@/tools';
+
+
+export function fromAnalysisModel(mode: 'tongue' | 'face', data: Record<string, any>): AnalysisModel {
+  let model: AnalysisModel;
+  switch ( mode ) {
+    case 'tongue':
+      model = fromTongueAnalysisModel(data);
+      break;
+    case 'face':
+      model = fromFaceAnalysisModel(data);
+      break;
+  }
+
+  const group = groupBy<AnalysisException>(model.exception, (item) => item.cover ?? '');
+  model.exceptionGroup = Object.entries(group).map(([ key, exception ]) => (
+    { key, exception }
+  ));
+  return model;
+}
+
+export interface AnalysisModel {
+  table: {
+    columns: string[];
+    data: { exception: boolean; invalid?: boolean; columns: string[] }[];
+  };
+  exception: AnalysisException[];
+  exceptionGroup: {
+    key: string;
+    exception: AnalysisException[]
+  }[];
+  cover: string[];
+  result: string;
+}
+
+export interface AnalysisException {
+  title: string;
+  cover?: string;
+  description?: string;
+  descriptions: { label: string; value: string }[];
+  tags: string[];
+}
+
+function fromTongueAnalysisModel(data: Record<string, any>): AnalysisModel {
+  const exception: AnalysisException[] = [];
+  const fromTongueException = fromAnalysisException(exception);
+  const c1 = data?.upImg ?? data?.tongueImgUrl;
+  const c2 = data?.downImg ?? data?.tongueBackImgUrl;
+  return {
+    table: {
+      columns: ['舌象维度', '检测结果', '标准值'],
+      data: [
+        fromTongueException(data?.tongueColor, '舌色'),
+        fromTongueException(data?.tongueCoatingColor, '苔色'),
+        fromTongueException(data?.tongueShape, '舌形'),
+        fromTongueException(data?.tongueCoating, '苔质'),
+        fromTongueException(data?.bodyFluid, '津液'),
+        fromTongueException(data?.sublingualVein, '舌下'),
+      ],
+    },
+    exception,
+    exceptionGroup: [],
+    result: data?.tongueAnalysisResult ?? data?.tongue ?? '',
+    cover: Object.assign([c1, c2].filter(Boolean), {
+      ['舌上']: c1,
+      ['舌下']: c2,
+    }),
+  };
+}
+
+function fromFaceAnalysisModel(data: Record<string, any>): AnalysisModel {
+  const exception: AnalysisException[] = [];
+  const fromFaceException = fromAnalysisException(exception, (label, value) => `${label}${value}`);
+  const c1 = data?.faceImg ?? data?.faceImgUrl;
+  const c2 = data?.faceLeft ?? data?.faceLeftImgUrl;
+  const c3 = data?.faceRight ?? data?.faceRightImgUrl;
+  return {
+    table: {
+      columns: ['面象维度', '检测结果', '标准值'],
+      data: [
+        fromFaceException(data?.faceColor, '面色'),
+        fromFaceException(data?.mainColor, '主色'),
+        fromFaceException(data?.shine, '光泽'),
+        fromFaceException(data?.leftBlackEye, '左黑眼圈'),
+        fromFaceException(data?.rightBlackEye, '右黑眼圈'),
+        fromFaceException(data?.lipColor, '唇色'),
+        fromFaceException(data?.eyeContact, '眼神'),
+        fromFaceException(data?.leftEyeColor, '左目色'),
+        fromFaceException(data?.rightEyeColor, '右目色'),
+        fromFaceException(data?.hecticCheek, '两颧红'),
+        fromFaceException(data?.noseFold, '鼻褶'),
+        fromFaceException(data?.cyanGlabella, '眉间/鼻柱青色'),
+        fromFaceException(data?.faceSkinDefects, '面部皮损'),
+      ],
+    },
+    exception,
+    exceptionGroup: [],
+    result: data?.faceAnalysisResult ?? data?.face ?? '',
+    cover: Object.assign([c1, c2, c3].filter(Boolean), {
+      ['正面']: c1,
+      ['左面']: c2,
+      ['右面']: c3,
+    }),
+  };
+}
+
+function fromAnalysisException(exception: AnalysisException[], $title = (label: string, value: string) => value) {
+  return function (data: { actualList?: Record<string, any>[]; standardValue?: string }, label: string) {
+    let is = false;
+    let invalid = false;
+    const values =
+      data?.actualList?.map((item) => {
+        let title: string = item?.actualValue ?? '';
+        const suffix = item?.contrast ?? 's';
+        if (title.endsWith('不符合检测要求')) { invalid = true; }
+        else if (suffix !== 's') {
+          if (suffix !== 'r') title += ` (${suffix || ''}) `;
+          is = true;
+          exception.push({
+            title: $title(label, title),
+            cover: item.splitImage,
+            descriptions: [
+              item.features ? { label: '【特征】', value: item.features } : null,
+              item.clinicalSignificance ? { label: '【临床意义】', value: item.clinicalSignificance } : null,
+            ].filter((v) => !!v),
+            tags: item.attrs ?? [],
+          });
+        }
+        return title;
+      }) ?? [];
+    return {
+      exception: is, invalid,
+      columns: [label, values.join('<br>'), data?.standardValue ?? '']
+    }
+  };
+}

+ 45 - 0
src/request/model/flow.model.ts

@@ -0,0 +1,45 @@
+const Routes = {
+  'patient_file': /* 建档页 */ '/register',
+  'tongueface_upload': /*拍照页*/ '/camera',
+  'tongueface_upload_result': /* [虚拟] 拍照结果页 */ '/camera/result',
+  'tongueface_analysis': /* 问卷页 */ '/questionnaire',
+  'tongueface_analysis_result': /* 舌面象分析报告页 */ '/report/analysis',
+  'health_analysis': /* 健康报告页 */ '/report',
+  'pulse_upload': /* 脉诊页 */ '/pulse',
+  'pulse_upload_result': /* 脉诊结果页 */ '/pulse/result',
+
+  'screen': '/screen',
+} as const;
+
+export interface Flow {
+  route: string;
+  title?: string;
+  optional?: boolean;
+}
+
+export function fromFlowData(options: string[]): Map<string, Flow> {
+  const ref = new Map<string, Flow>();
+  const length = options.length;
+
+  // 修正虚拟路由
+  const k1 = 'tongueface_upload';
+  const k2 = 'tongueface_upload_result';
+  if ( !options.includes(k2) && options[ length - 1 ] === k1 ) options.push(k2);
+
+  options.unshift('/screen');
+  options.push('screen');
+
+  for ( let i = 1; i < options.length; i++ ) {
+    const path = options[i];
+    const optional = path.includes('?');
+    const [name, title] = path.split('?');
+    const route = (options[i] = getPath(name));
+    ref.set(options[i - 1], { route, optional, title, });
+  }
+  return ref;
+}
+
+
+export function getPath(value?: string) {
+  return Routes[ value as keyof typeof Routes ];
+}

+ 2 - 0
src/request/model/index.ts

@@ -1,4 +1,6 @@
+export * from './flow.model';
 export * from './register.model';
+export * from './analysis.model';
 export * from './questionnaire.model';
 export * from './report.model';
 export * from './scheme.model';

+ 154 - 0
src/request/model/register.model.ts

@@ -1,10 +1,164 @@
+import { toCamelCase } from '@/tools';
+
+import type { FieldRule, NumberKeyboardProps, PasswordInputProps } from 'vant';
+
+
 export interface RegisterModel {
   cardno: string;
   phone: string;
   code: string;
   name: string;
   sex: string;
+  age: number;
   height: number;
   weight: number;
   isEasyAllergy: boolean;
 }
+
+export interface Field {
+  control: {
+    label: string; placeholder?: string;
+    type?: string; min?: number; max?: number; minlength?: number; maxlength?: number;
+    clearable?: boolean; border?: boolean; readonly?: boolean;
+  };
+  component?: |
+    { name: 'radio', options: { label: string; value: string; }[] } |
+    { name: 'code', props?: Partial<PasswordInputProps> };
+  keyboard?: { show: boolean; } & Partial<NumberKeyboardProps>;
+  suffix?: string;
+  rules: FieldRule[];
+}
+
+export type FieldKey = keyof RegisterModel;
+export type Fields = ( Field & { name: FieldKey } )[];
+
+const Fields: Record<FieldKey, Field> = {
+  height: {
+    control: {
+      label: '身高', placeholder: '请输入身高',
+      type: 'number', min: 1, max: 300, clearable: true, readonly: true,
+      maxlength: 5,
+    },
+    keyboard: { show: false, title: '身高', extraKey: '.', closeButtonText: '完成' },
+    suffix: 'cm',
+    rules: [],
+  },
+  weight: {
+    control: {
+      label: '体重', placeholder: '请输入体重',
+      type: 'number', min: 1, max: 300, clearable: true, readonly: true,
+      maxlength: 5,
+    },
+    keyboard: { show: false, title: '体重', extraKey: '.', closeButtonText: '完成' },
+    suffix: 'kg',
+    rules: [],
+  },
+  age: {
+    control: {
+      label: '年龄', placeholder: '请输入年龄',
+      type: 'digit', min: 0, max: 300, clearable: true, readonly: true,
+      maxlength: 3,
+    },
+    keyboard: { show: false, title: '年龄', closeButtonText: '完成' },
+    suffix: '岁',
+    rules: [],
+  },
+  sex: {
+    control: { label: '性别', border: false },
+    component: {
+      name: 'radio' as const,
+      options: [
+        { label: '男', value: '0' },
+        { label: '女', value: '1' },
+      ],
+    },
+    rules: [],
+  },
+  isEasyAllergy: {
+    control: { label: '容易过敏', border: false },
+    component: {
+      name: 'radio' as const,
+      options: [
+        { label: '是', value: 'Y' },
+        { label: '否', value: 'N' },
+      ],
+    },
+    rules: [],
+  },
+  name: {
+    control: {
+      label: '姓名', placeholder: '请输入姓名',
+      type: 'text', maxlength: 10, clearable: true,
+    },
+    rules: [],
+  },
+  cardno: {
+    control: {
+      label: '身份证号', placeholder: '请输入身份证号',
+      type: 'text', maxlength: 18, minlength: 18, clearable: true, readonly: true,
+    },
+    keyboard: { show: false, title: '身份证号', extraKey: 'X', closeButtonText: '完成' },
+    rules: [
+      {
+        validator: (value: string) => value && value.length === 18,
+        message: '请输入正确的身份证',
+        trigger: 'onBlur',
+      },
+    ],
+  },
+  phone: {
+    control: {
+      label: '手机号码', placeholder: '请输入手机号码',
+      type: 'tel', maxlength: 11, minlength: 11, clearable: true, readonly: true,
+    },
+    keyboard: { show: false, title: '手机号码', closeButtonText: '完成' },
+    rules: [
+      {
+        validator: (value: string) => value && value.length === 11,
+        message: '请输入正确的手机号码',
+        trigger: 'onBlur',
+      },
+    ],
+  },
+  code: {
+    control: {
+      label: '验证码', placeholder: '请输入验证码',
+      type: 'digit', maxlength: 6, minlength: 6, clearable: true,
+      border: false,
+    },
+    component: {
+      name: 'code' as const,
+      props: { mask: false },
+    },
+    keyboard: { show: false, title: '验证码', closeButtonText: '完成' },
+    rules: [
+      {
+        validator: (value: string) => value && value.length === 6,
+        message: '请输入验证码',
+        trigger: [ 'onChange', 'onBlur' ],
+      },
+    ],
+  },
+};
+
+export function fromRegisterFields(options: string[]): Fields {
+  // 修正 phone,code
+  const k1 = 'phone';
+  const k2 = 'code';
+  const index = options.findIndex(key => key.startsWith(k1));
+  if ( index !== -1 && !options.find(key => key.startsWith(k2)) ) {
+    const value = options[ index ].replace(k1, k2);
+    options.splice(index + 1, 0, value);
+  }
+
+  return options.map(option => {
+    const values = option.split(':');
+    const name = toCamelCase(values[ 0 ]);
+    const field = Fields[ name as unknown as FieldKey ] ?? {
+      control: { label: name, type: 'text' },
+      rules: [],
+    };
+    if ( values[ 1 ] === 'required' ) field.rules.push({ required: true, message: field.control.placeholder ?? '请补充完整' });
+    return { ...field, name } as Fields[number];
+  });
+}

+ 12 - 23
src/request/model/report.model.ts

@@ -1,6 +1,7 @@
+import { fromAnalysisModel } from '@/request/model/analysis.model';
+
+
 export function fromReportData(data: Record<string, any>) {
-  const tongueException: ReportTongueException[] = [];
-  const fromTongueException = fromReportTongueExceptionData.bind(null, tongueException);
   return {
     id: data?.healthAnalysisReportId,
     date: data?.reportTime,
@@ -25,27 +26,9 @@ export function fromReportData(data: Record<string, any>) {
         [ '环境适应能力', data?.constitutionGroupAdaptability ],
       ],
     },
-    tongueTable: {
-      column: [ '舌象维度', '检测结果', '标准值' ],
-      data: [
-        [ '舌色', fromTongueException(data?.tongueColor), data?.tongueColor?.standardValue ],
-        [ '苔色', fromTongueException(data?.tongueCoatingColor), data?.tongueCoatingColor?.standardValue ],
-        [ '舌形', fromTongueException(data?.tongueShape), data?.tongueShape?.standardValue ],
-        [ '苔质', fromTongueException(data?.tongueCoating), data?.tongueCoating?.standardValue ],
-        [ '津液', fromTongueException(data?.bodyFluid), data?.bodyFluid?.standardValue ],
-        [ '舌下', fromTongueException(data?.sublingualVein), data?.sublingualVein?.standardValue ],
-      ],
-    },
-    tongueException,
-    tongueAnalysis: {
-      [ '结果' ]: data?.tongueAnalysisResult,
-      [ '舌上' ]: data?.upImg,
-      [ '舌下' ]: data?.downImg,
-    },
-    faceAnalysis: {
-      [ '结果' ]: data?.faceAnalysisResult,
-      [ '面象' ]: data?.faceImg,
-    },
+
+    tongue: fromAnalysisModel('tongue', data),
+    face: fromAnalysisModel('face', data),
 
     [ '中医证素' ]: data?.factorItems?.map?.((item: Record<string, any>) => {
       return { label: item?.factorItemName, value: item?.factorItemDescription, score: +item?.score };
@@ -56,6 +39,9 @@ export function fromReportData(data: Record<string, any>) {
 
     constitutionGroupImg: data?.constitutionGroupImg,
     factorItemRadarImg: data?.factorItemRadarImg,
+    reportURL: data?.reportPdfUrl,
+    miniProgramURL: data?.appletImg,
+    payLock: data?.payLock,
   };
 }
 
@@ -88,6 +74,9 @@ function fromReportTongueExceptionData(exception: ReportTongueException[], data?
 }
 
 function fromReportPhysiqueChartData(data: Record<string, any>[]) {
+  if (data?.some(item => (item.isBasicTo === 'Y' || item.isTendTo === 'Y') && item.constitutionGroupName !== '平和质')) {
+    data = data.filter(item => item.constitutionGroupName !== '平和质');
+  }
   return data?.map((item: Record<string, any>) => [
     item.constitutionGroupName, +item.score,
     item.constitutionGroupName === '平和质'

+ 10 - 19
src/router/index.ts

@@ -4,25 +4,16 @@ import { createRouter, createWebHistory } from 'vue-router';
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   routes: [
-    {
-      path: '/screen', component: () => import('@/pages/screen.page.vue'),
-    },
-    {
-      path: '/',
-      children: [
-        { path: 'register', component: () => import('@/pages/register.page.vue'), meta: { title: '建档' } },
-        { path: 'camera', component: () => import('@/modules/camera/page.vue'), meta: { title: '拍摄2' } },
-        { path: 'questionnaire', component: () => import('@/modules/questionnaire/page.vue'), meta: { title: '问卷' } },
-        { path: 'report/:id/scheme', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案' } },
-        { path: 'report/:id', component: () => import('@/modules/report/report.page.vue'), meta: { title: '健康分析报告' } },
-        { path: '', redirect: '/screen' },
-      ],
-      components: {
-        header: () => import('@/widgets/header.widget.vue'),
-        // footer: () => import('@/widgets/footer.widget.vue'),
-        default: () => import('@/views/page.view.vue'),
-      },
-    },
+    { path: '/screen', component: () => import('@/pages/screen.page.vue'), meta: { scan: true } },
+    { path: '/register', component: () => import('@/pages/register.page.vue'), meta: { title: '建档', scan: true } },
+    { path: '/pulse', component: () => import('@/modules/pulse/pulse.page.vue'), meta: { title: '脉诊' } },
+    { path: '/camera', component: () => import('@/modules/camera/camera.page.vue'), meta: { title: '拍摄' } },
+    { path: '/camera/result', component: () => import('@/modules/camera/camera-result.page.vue'), meta: { title: '拍摄完成' } },
+    { path: '/questionnaire', component: () => import('@/modules/questionnaire/page.vue'), meta: { title: '问卷' } },
+    { path: '/report/analysis', component: () => import('@/modules/report/report-analyse.page.vue'), meta: { title: '舌面象分析报告' } },
+    { path: '/report/:id/scheme', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案' } },
+    { path: '/report/:id', component: () => import('@/modules/report/report.page.vue'), meta: { title: '健康分析报告' } },
+    { path: '/', redirect: '/screen' },
   ],
 });
 

+ 1 - 1
src/stores/platform.store.ts

@@ -4,7 +4,7 @@ import { getSerialNumberSync } from '@/platform';
 export const usePlatformStore = defineStore(
   'platform',
   () => {
-    const serialNumber = ref(getSerialNumberSync() || '45dde49f100eb0cb');
+    const serialNumber = ref(getSerialNumberSync());
     return { serialNumber };
   },
 );

+ 5 - 0
src/themes/fix.scss

@@ -0,0 +1,5 @@
+.page-header {
+  > div:not(.grow-\[3\]) {
+    flex-grow: 0;
+  }
+}

+ 16 - 5
src/themes/index.scss

@@ -3,14 +3,25 @@
 @tailwind utilities;
 
 @import "./vant";
+@import "./fix";
 
-.page-container {
-  display: flex;
-  flex-direction: column;
-  height: 100vh;
+.page {
+  &-container {
+    display: flex;
+    flex-direction: column;
+    height: 100vh;
+  }
+
+  &-header, &-footer {
+    flex: none;
+  }
+
+  &-content {
+    flex: auto;
+  }
 }
 
 :root:root {
-  --primary-color: #34A76B;
+  --primary-color: #34a76b;
   --primary-color-hover: #38ff6e;
 }

+ 13 - 0
src/themes/vant.scss

@@ -18,6 +18,8 @@
 
   --van-notify-font-size: 1.6rem;
   --van-notify-line-height: 2rem;
+
+  --van-number-keyboard-key-height: 60px;
 }
 
 .van-button.decorate {
@@ -46,3 +48,14 @@
     margin-top: 1rem !important;
   }
 }
+
+.van-dialog {
+  --van-dialog-round-button-height: 40px;
+  --van-dialog-message-font-size: 16px;
+  --van-dialog-message-line-height: 24px;
+  --van-font-size-md: 16px;
+}
+
+.van-floating-panel__header {
+  padding: 0 calc((var(--van-floating-panel-header-height) - 16px) / 2);
+}

+ 36 - 0
src/tools/bubble.ts

@@ -0,0 +1,36 @@
+/**
+ * 小球直径
+ * font = mx+c [14, 24]
+ */
+const SIZE = [50, 90] as const;
+const SIZE_S = 8;
+const SIZE_M = 0.25;
+const SIZE_C = 1.5;
+
+const seed_text = ['有气\n无力', '容易\n犯困', '睡眠\n障碍', '消化\n不良', '肩颈\n腰痛', '掉\n头发', '记忆力\n下降'];
+const seed_color = ['#367dd599', '#b1450399', '#34b10399', '#b1860399', '#03b19b99', '#b1a30399', '#34b10399'];
+
+function getRandomValueWithStep(step: number, min: number, max: number) {
+  step = Math.max(1, step);
+  if (min > max) {
+    [min, max] = [max, min];
+  }
+
+  const range = max - min;
+  const steps = Math.floor(range / step) + 1;
+  const random = Math.floor(Math.random() * steps);
+  return min + random * step;
+}
+
+export default function () {
+  const max = seed_text.length - 1;
+  return seed_text.map((text, i) => {
+    const diameter = getRandomValueWithStep(SIZE_S, ...SIZE);
+    return {
+      text,
+      color: seed_color[i],
+      diameter,
+      size: Math.floor(diameter * SIZE_M + SIZE_C),
+    };
+  });
+}

+ 0 - 23
src/tools/camera.tool.ts

@@ -1,23 +0,0 @@
-interface Size {
-  width?: number;
-  height?: number;
-  aspectRatio?: number;
-}
-
-
-export async function getMediaStream(constraints?: MediaTrackConstraints & Size) {
-  const stream = await navigator.mediaDevices.getUserMedia({ video: constraints ?? true });
-  if ( constraints?.width != null && constraints?.height != null ) {
-    // 修正宽高
-    const track = stream.getVideoTracks()[ 0 ];
-    const { width: CW = 1, height: CH = 1, aspectRatio: CAR = CW / CH, ..._constraints } = constraints;
-    const { width: SW = 1, height: SH = 1, aspectRatio: SAR = SW / SH } = track.getSettings();
-    if ( SAR > CAR || (
-      CW === SH && CH === SW
-    ) ) {
-      await track.applyConstraints({ ..._constraints, height: CW, width: CH });
-    }
-  }
-  return stream;
-}
-

+ 1 - 0
src/tools/index.ts

@@ -1,2 +1,3 @@
 export * from './url.tool';
+export * from './string.tool';
 export * from './polyfills';

+ 18 - 0
src/tools/polyfills.ts

@@ -20,3 +20,21 @@ export function withResolvers<T>(): {
     return fn();
   }
 }
+
+export function groupBy<T>(items: Iterable<T>, callbackFn: (element: T, index: number) => any): Record<string, T[]> {
+  if ( typeof Object.groupBy === 'function' ) {
+    groupBy = Object.groupBy.bind(Object);
+    return Object.groupBy(items, callbackFn);
+  } else {
+    const fn = function(items, callbackFn) {
+      const obj = Object.create(null);
+      let k = 0;
+      for ( const value of items ) {
+        const key = callbackFn(value, k++);
+        if ( key in obj ) { obj[ key ].push(value); } else { obj[ key ] = [ value ]; }
+      }
+    };
+    groupBy = fn.bind(Object);
+    return fn(items, callbackFn);
+  }
+}

+ 3 - 0
src/tools/string.tool.ts

@@ -0,0 +1,3 @@
+export function toCamelCase(value: string): string {
+  return value.replace(/_([a-z])/g, (match, $1) => $1.toUpperCase());
+}

+ 0 - 11
src/views/page.view.vue

@@ -1,11 +0,0 @@
-<script setup lang="ts">
-</script>
-<template>
-  <router-view v-slot="{ Component }">
-    <keep-alive>
-      <component :is="Component" />
-    </keep-alive>
-  </router-view>
-</template>
-<style scoped lang="scss">
-</style>

+ 0 - 7
src/widgets/footer.widget.vue

@@ -1,7 +0,0 @@
-<script setup lang="ts">
-</script>
-<template>
-  <div></div>
-</template>
-<style scoped lang="scss">
-</style>

+ 0 - 17
src/widgets/header.widget.vue

@@ -1,17 +0,0 @@
-<script setup lang="ts">
-import { useTitle } from '@/hooks/useTitle';
-
-const title = useTitle();
-</script>
-<template>
-  <div class="flex py-4 px-4">
-    <div class="grow shrink-0 h-full min-w-16"></div>
-    <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
-      <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
-        {{ title }}
-      </div>
-    </div>
-    <div class="grow shrink-0 h-full min-w-16"></div>
-  </div>
-</template>
-<style scoped lang="scss"></style>

+ 1 - 7
vite.config.ts

@@ -48,13 +48,7 @@ export default defineConfig((configEnv) => {
       open: true,
       port: 55555,
       proxy: {
-        '/fdhb-tablet': {
-          target: env.REQUEST_API_PROXY_URL,
-          secure: false,
-          changeOrigin: false,
-          logLevel: 'debug',
-        },
-        '/file': {
+        '/manager': {
           target: env.REQUEST_API_PROXY_URL,
           secure: false,
           changeOrigin: false,

部分文件因文件數量過多而無法顯示