Przeglądaj źródła

主体流程完成

cc12458 7 miesięcy temu
rodzic
commit
68617d2219
65 zmienionych plików z 2753 dodań i 4 usunięć
  1. BIN
      public/camera/step-11.example.png
  2. 11 0
      public/camera/step-11.shade.svg
  3. 8 0
      public/camera/step-12.shade.svg
  4. BIN
      public/camera/step-21.example.png
  5. BIN
      public/camera/step-31.example.png
  6. BIN
      src/assets/images/btn.png
  7. BIN
      src/assets/images/button-cancel.png
  8. BIN
      src/assets/images/button-confirm.png
  9. BIN
      src/assets/images/nav-home.png
  10. BIN
      src/assets/images/nav-home.select.png
  11. BIN
      src/assets/images/nav-print.png
  12. BIN
      src/assets/images/nav-print.select.png
  13. BIN
      src/assets/images/nav-scheme.png
  14. BIN
      src/assets/images/nav-scheme.select.png
  15. 13 0
      src/assets/images/next-step.svg
  16. BIN
      src/assets/images/report-cover.png
  17. BIN
      src/assets/images/screen.png
  18. BIN
      src/assets/images/tips.png
  19. BIN
      src/assets/images/title.png
  20. 33 0
      src/hooks/useTitle.ts
  21. 32 0
      src/modules/camera/ShadeFace.vue
  22. 35 0
      src/modules/camera/ShadeTongueDown.vue
  23. 50 0
      src/modules/camera/ShadeTongueUp.vue
  24. 142 0
      src/modules/camera/camera.vue
  25. 66 0
      src/modules/camera/config.ts
  26. 214 0
      src/modules/camera/page.vue
  27. 138 0
      src/modules/questionnaire/TierSelect.field.vue
  28. 70 0
      src/modules/questionnaire/page.vue
  29. 69 0
      src/modules/report/NavBar.vue
  30. 108 0
      src/modules/report/PhysiqueChart.vue
  31. 91 0
      src/modules/report/SchemeMedia.vue
  32. 85 0
      src/modules/report/SyndromeChart.vue
  33. 30 0
      src/modules/report/echart.ts
  34. 184 0
      src/modules/report/report.page.vue
  35. 59 0
      src/modules/report/scheme.page.vue
  36. 336 0
      src/pages/register.page.vue
  37. 190 0
      src/pages/screen.page.vue
  38. 3 0
      src/platform/index.ts
  39. 38 0
      src/platform/notify.ui.ts
  40. 48 0
      src/platform/toast.ui.ts
  41. 45 0
      src/request/alova.ts
  42. 40 0
      src/request/api/account.api.ts
  43. 23 0
      src/request/api/camera.api.ts
  44. 37 0
      src/request/api/flow.api.ts
  45. 6 0
      src/request/api/index.ts
  46. 24 0
      src/request/api/questionnaire.api.ts
  47. 30 0
      src/request/api/report.api.ts
  48. 14 0
      src/request/mock/index.ts
  49. 4 0
      src/request/model/index.ts
  50. 114 0
      src/request/model/questionnaire.model.ts
  51. 10 0
      src/request/model/register.model.ts
  52. 103 0
      src/request/model/report.model.ts
  53. 49 0
      src/request/model/scheme.model.ts
  54. 53 0
      src/router/hooks/useRouteMeta.ts
  55. 22 1
      src/router/index.ts
  56. 12 0
      src/stores/account.store.ts
  57. 7 3
      src/stores/index.ts
  58. 10 0
      src/stores/platform.store.ts
  59. 16 0
      src/stores/visitor.store.ts
  60. 23 0
      src/tools/camera.tool.ts
  61. 1 0
      src/tools/index.ts
  62. 22 0
      src/tools/polyfills.ts
  63. 11 0
      src/views/page.view.vue
  64. 7 0
      src/widgets/footer.widget.vue
  65. 17 0
      src/widgets/header.widget.vue

BIN
public/camera/step-11.example.png


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

@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 366">
+  <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"
+  />
+</svg>

+ 8 - 0
public/camera/step-12.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="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>

BIN
public/camera/step-21.example.png


BIN
public/camera/step-31.example.png


BIN
src/assets/images/btn.png


BIN
src/assets/images/button-cancel.png


BIN
src/assets/images/button-confirm.png


BIN
src/assets/images/nav-home.png


BIN
src/assets/images/nav-home.select.png


BIN
src/assets/images/nav-print.png


BIN
src/assets/images/nav-print.select.png


BIN
src/assets/images/nav-scheme.png


BIN
src/assets/images/nav-scheme.select.png


+ 13 - 0
src/assets/images/next-step.svg

@@ -0,0 +1,13 @@
+<svg width="73" height="73" viewBox="0 0 73 73" xmlns="http://www.w3.org/2000/svg">
+    <defs>
+        <filter color-interpolation-filters="auto" id="a">
+            <feColorMatrix in="SourceGraphic"
+                           values="0 0 0 0 0.219608 0 0 0 0 1.000000 0 0 0 0 0.431373 0 0 0 1.000000 0"/>
+        </filter>
+    </defs>
+    <g fill="#000" fill-rule="nonzero" transform="translate(-232 -500)" filter="url(#a)">
+        <path d="M268.5 573c-20.158 0-36.5-16.342-36.5-36.5s16.342-36.5 36.5-36.5 36.5 16.342 36.5 36.5-16.342 36.5-36.5 36.5zm0-4.867c17.471 0 31.633-14.162 31.633-31.633 0-17.471-14.162-31.633-31.633-31.633-17.471 0-31.633 14.162-31.633 31.633 0 17.471 14.162 31.633 31.633 31.633z"/>
+        <path d="M282.533 534c1.363 0 2.467 1.12 2.467 2.5s-1.104 2.5-2.467 2.5h-32.066c-1.363 0-2.467-1.12-2.467-2.5s1.104-2.5 2.467-2.5h32.066z"/>
+        <path d="M283.27 536l-11.6-12.92a2.398 2.398 0 0 1-.55-2.402c.275-.84.99-1.466 1.868-1.634a2.47 2.47 0 0 1 2.35.827l13.046 14.524a2.397 2.397 0 0 1 0 3.21l-13.048 14.524a2.47 2.47 0 0 1-2.35.827 2.442 2.442 0 0 1-1.868-1.634 2.398 2.398 0 0 1 .55-2.403L283.27 536z"/>
+    </g>
+</svg>

BIN
src/assets/images/report-cover.png


BIN
src/assets/images/screen.png


BIN
src/assets/images/tips.png


BIN
src/assets/images/title.png


+ 33 - 0
src/hooks/useTitle.ts

@@ -0,0 +1,33 @@
+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;
+}

+ 32 - 0
src/modules/camera/ShadeFace.vue

@@ -0,0 +1,32 @@
+<script setup lang="ts"></script>
+
+<template>
+  <svg width="225px" height="305px" viewBox="0 0 225 305" xmlns="http://www.w3.org/2000/svg">
+    <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"
+      />
+    </clipPath>
+    <ellipse
+      cx="112.5"
+      cy="152.5"
+      rx="112.5"
+      ry="152.5"
+      fill="none"
+      stroke="#FEFEFE"
+      stroke-width="2"
+      stroke-linecap="round"
+      stroke-linejoin="round"
+    />
+  </svg>
+</template>
+
+<style scoped lang="scss"></style>

+ 35 - 0
src/modules/camera/ShadeTongueDown.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts"></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"
+      />
+    </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"
+    />
+    <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"
+    />
+  </svg>
+</template>
+
+<style scoped lang="scss">
+svg {
+  opacity: 0.85;
+}
+</style>

+ 50 - 0
src/modules/camera/ShadeTongueUp.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts">
+</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"
+      />
+    </clipPath>
+    <path
+      id="outline"
+      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 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"
+      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"
+    />
+  </svg>
+</template>
+
+<style scoped lang="scss">
+svg {
+  opacity: 0.85;
+}
+</style>

+ 142 - 0
src/modules/camera/camera.vue

@@ -0,0 +1,142 @@
+<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;
+  }
+
+  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 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);
+};
+
+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));
+
+  switch ( type ) {
+    case 'blob':
+      const { promise, resolve } = withResolvers<Blob>();
+      canvas.toBlob(<any> resolve, mime, 0.98);
+      return promise;
+    case 'base64':
+      return canvas.toDataURL(mime);
+  }
+};
+
+defineExpose({
+  handle, get,
+});
+</script>
+<template>
+  <div class="camera-container size-full" ref="camera-container" :style="styleValue">
+    <canvas style="display: none;"></canvas>
+    <video></video>
+    <slot></slot>
+  </div>
+</template>
+<style scoped lang="scss">
+.camera-container {
+  position: relative;
+
+  > * {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+  }
+
+  video {
+    clip-path: url("#shade");
+  }
+}
+</style>

+ 66 - 0
src/modules/camera/config.ts

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

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

@@ -0,0 +1,214 @@
+<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>

+ 138 - 0
src/modules/questionnaire/TierSelect.field.vue

@@ -0,0 +1,138 @@
+<script setup lang="ts">
+import type { QuestionnaireProps }            from '@/request/model';
+import type { DialogProps as VanDialogProps } from 'vant';
+
+
+type DialogProps = Partial<VanDialogProps> & {
+  _ignore_close: boolean;
+  options?: Option[];
+  onConfirm: (option?: Option) => void;
+  onCancel?: () => void;
+  onClosed?: () => void;
+  'onUpdate:show'(value: boolean): void;
+}
+
+interface Option {
+  id: string;
+  label: string;
+  checked?: boolean;
+  value?: string;
+  // 子选项属性
+  multiple?: boolean;
+  options?: Option[];
+}
+
+
+const { multiple, disabled } = defineProps<{ multiple?: boolean; disabled?: boolean }>();
+const options = defineModel<QuestionnaireProps['options']>('options', { default: [] });
+
+const subDialog = reactive<DialogProps>({
+  _ignore_close: false,
+  title: '',
+  showCancelButton: true,
+  showConfirmButton: false,
+  closeOnClickOverlay: true,
+  show: false,
+  'onUpdate:show'(value: boolean) {
+    if ( !subDialog._ignore_close ) subDialog.show = false;
+    subDialog._ignore_close = false;
+  },
+  onConfirm: () => { subDialog._ignore_close = false; },
+  onCancel: () => { subDialog.show = false; },
+});
+
+function handle(option: QuestionnaireProps['options'][number], index: number, options: QuestionnaireProps['options'], sub = false) {
+  const checked = !option.checked;
+  if ( checked ) {
+    subDialog.show = !!option.options?.length;
+    if ( subDialog.show ) {
+      subDialog.title = option.label;
+      subDialog.options = option.options;
+      subDialog.showConfirmButton = !!option.multiple;
+      subDialog.onConfirm = (sub?: Option) => {
+        subDialog._ignore_close = true;
+        if ( sub ) {
+          const checked = !sub.checked;
+          if ( checked && !option.multiple ) for ( const op of subDialog.options! ) { op.checked = false; }
+          sub.checked = checked;
+          if ( option.multiple ) return;
+        }
+        const checked = subDialog.options!.some(op => op.checked);
+        if ( checked && !multiple ) for ( const op of options ) { op.checked = false; }
+        option.checked = checked;
+        option.value = subDialog.options!.filter(op => op.checked).map(item => item.label).join(' ');
+        subDialog.show = false;
+      };
+    } else {
+      if ( !multiple ) { for ( const op of options ) op.checked = false; }
+      option.checked = checked;
+    }
+  } else {
+    for ( const op of option.options ?? [] ) op.checked = checked;
+    option.checked = checked;
+    option.value = '';
+  }
+}
+</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}"
+         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
+         "
+         @click="!disabled && handle(option, index, options)"
+    >
+      <div class="p-2 text-center">
+        <span>{{ option.label }}</span>
+        <span class="value" v-if="option.value">{{ option.value }}</span>
+      </div>
+    </div>
+    <van-dialog 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"
+             class="sub-option
+              flex justify-center items-center min-h-16
+              text-lg text-primary hover:text-white
+              rounded-xl border border-primary hover:border-primary-400
+             "
+             :class="{checked: option.checked}"
+             @click="!disabled && subDialog.onConfirm(option)"
+        >
+          <div class="p-2 text-center">{{ option.label }}</div>
+        </div>
+      </div>
+    </van-dialog>
+  </div>
+</template>
+<style scoped lang="scss">
+.option {
+  &.checked {
+    color: var(--primary-color-hover);
+    background: linear-gradient(to bottom, #1ca459, transparent 40%, transparent 60%, #1ca459);
+  }
+
+  .value {
+    &::before {
+      content: ":";
+      margin: 0 4px 0 2px;
+    }
+  }
+}
+
+.sub-option.checked {
+  color: #fff;
+  background-color: var(--primary-color);;
+}
+</style>
+<style lang="scss">
+.sub-dialog {
+  --van-dialog-width: 60vw;
+  --van-dialog-font-size: 24px;
+  --van-dialog-button-height: 60px;
+  --van-button-default-font-size: 24px;
+  //--van-dialog-background: #fff;
+}
+</style>

+ 70 - 0
src/modules/questionnaire/page.vue

@@ -0,0 +1,70 @@
+<script setup lang="ts">
+import { Notify }                  from '@/platform';
+import { questionnaireMethod }     from '@/request/api';
+import type { QuestionnaireProps } from '@/request/model';
+import { useRequest }              from 'alova/client';
+
+import TierSelectField from './TierSelect.field.vue';
+
+
+defineOptions({
+  name: 'QuestionnairePage',
+});
+
+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 }`);
+});
+
+
+function handle(questionnaires: QuestionnaireProps[]) {
+  console.log(questionnaires);
+  const tips: string[] = [];
+  for ( const { label, required, name, options } of questionnaires ) {
+    if ( !required ) continue;
+    switch ( name ) {
+      case 'select':
+        if ( !options.some(op => op.checked) ) tips.push(label);
+        break;
+    }
+  }
+  if ( tips.length ) {
+    Notify.warning(`问卷请补充完整\n\n${ tips.join('\n') }`);
+  } else {
+    send(questionnaires).then(() => showTitle.value = false);
+  }
+}
+</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>
+      <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>
+      <van-button class="decorate" block :loading
+                  @click="handle(data.questionnaires)"
+      >提交
+      </van-button>
+    </template>
+    <van-toast v-else :show="loading" type="loading" message="加载中" />
+  </div>
+</template>
+<style scoped lang="scss">
+.required::after {
+  content: "*";
+  color: #f53030;
+  font-size: 2rem;
+}
+</style>

+ 69 - 0
src/modules/report/NavBar.vue

@@ -0,0 +1,69 @@
+<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';
+
+
+interface Tabbar {
+  key: 'screen' | 'report' | 'scheme' | 'print';
+  label: string;
+  icon: string;
+  select: string;
+}
+
+const route = useRoute();
+const router = useRouter();
+
+const isScheme = ref(false);
+const tabbar = shallowRef<Tabbar[]>([]);
+
+watchEffect(() => {
+  const path = route.fullPath;
+  isScheme.value = path.endsWith('scheme');
+  tabbar.value = isScheme.value ? [
+    { key: 'screen', label: '返回首页', icon: NavHome, select: NavHomeSelect },
+    { key: 'report', label: '健康报告', icon: NavScheme, select: NavSchemeSelect },
+  ] : [
+    { key: 'screen', label: '返回首页', icon: NavHome, select: NavHomeSelect },
+    { key: 'scheme', label: '调理方案', icon: NavScheme, select: NavSchemeSelect },
+    { key: 'print', label: '打印', icon: NavPrint, select: NavPrintSelect },
+  ];
+});
+
+function handle(key: string) {
+  switch ( key ) {
+    case 'screen':
+      router.replace('/screen');
+      break;
+    case 'scheme':
+      router.replace(`${ route.fullPath }/scheme`.replace(/\/{2,}/g, '/'));
+      break;
+    case 'report':
+      router.replace(route.fullPath.replace('/scheme', ''));
+      break;
+  }
+}
+</script>
+<template>
+  <div class="flex-none flex justify-between py-2" style="background-color: #12312c;">
+    <div
+      class="m-auto min-w-16 text-center hover:text-primary"
+      v-for="nav in tabbar" :key="nav.key"
+      @click="handle(nav.key)"
+    >
+      <img :src="nav.icon" :alt="nav.label">
+      <div class="mt-2">{{ nav.label }}</div>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+img {
+  margin: auto;
+  width: 36px;
+  height: 36px;
+  object-fit: scale-down;
+}
+</style>

+ 108 - 0
src/modules/report/PhysiqueChart.vue

@@ -0,0 +1,108 @@
+<script setup lang="ts">
+import { provide, ref }                      from 'vue';
+import VChart, { type EChartsOption, theme } from './echart';
+
+
+const { dataset = [] } = defineProps<{ dataset?: any[][] }>();
+
+const snapshot = defineModel('snapshot', { default: '' });
+
+provide(...theme);
+
+const option = ref<EChartsOption>({
+  backgroundColor: 'transparent',
+  dataset: {
+    dimensions: [ '体质', '得分', '类别' ],
+    source: [],
+  },
+  grid: {
+    containLabel: true,
+    top: 50,
+    bottom: 10,
+    left: 10,
+    right: 10,
+  },
+  xAxis: {
+    type: 'category',
+    splitLine: { show: false },
+    axisTick: { show: false },
+  },
+  yAxis: {
+    type: 'value', min: 0, max: 100, splitNumber: 5,
+    axisLine: { show: false },
+    axisLabel: { show: false },
+  },
+  visualMap: {
+    type: 'piecewise',
+    inRange: {},
+    categories: [],
+    dimension: 2,
+    orient: 'horizontal',
+    top: 10,
+    right: 10,
+  },
+  series: [
+    {
+      type: 'bar',
+      barMaxWidth: 30,
+    },
+  ],
+});
+
+
+const defaultSetting = [
+  { label: '平和体质(正常体质)', color: '#38ff6e' },
+  { label: '所属体质', color: '#ff8917' },
+  { label: '倾向体质', color: '#ffbc5b' },
+  { label: '推断体质', color: '#34a76b' },
+  { label: '体质', color: '#b2b4ab' },
+];
+
+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);
+  }
+
+  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 },
+  };
+});
+
+const chart = useTemplateRef<InstanceType<typeof VChart>>('chart');
+
+function onFinished() {
+  snapshot.value = chart.value?.getDataURL() ?? '';
+}
+</script>
+<template>
+  <div class="mx-auto">
+    <div class="chart-container">
+      <v-chart ref="chart" class="chart" :option="option" @finished="onFinished" />
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+.chart-container {
+  position: relative;
+  padding-bottom: 50%;
+
+  > .chart {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 91 - 0
src/modules/report/SchemeMedia.vue

@@ -0,0 +1,91 @@
+<script setup lang="ts">
+import type { SchemeMediaProps }  from '@/request/model/scheme.model';
+import type { ImagePreviewProps } from 'vant';
+
+
+const { media = [] } = defineProps<{ media: SchemeMediaProps[] }>();
+
+const images = computed(() => media?.filter(Boolean));
+
+const preview = ref(false);
+const previewProps = shallowRef<Partial<ImagePreviewProps>>();
+const current = shallowRef<SchemeMediaProps>();
+
+let Tag = `scheme-media-preview`;
+
+function update(index: number) {
+  current.value = images.value[ index ];
+  setTimeout(() => {
+    const swipe = document.querySelectorAll(`.${ Tag } .van-swipe-item`);
+    const video = swipe[ index ]?.querySelector(`video`);
+    onPlay(<any> { target: video });
+  }, 20);
+}
+
+function handle(item: SchemeMediaProps) {
+  const closable = !images.value.some(image => image.type === 'video');
+  const index = images.value.findIndex(image => image.url === item.url);
+  preview.value = true;
+  previewProps.value = {
+    images: images.value.map(item => item.url), startPosition: index,
+    showIndex: images.value.length > 1,
+    loop: false, closeable: true,
+    closeOnClickImage: closable,
+    closeOnClickOverlay: closable,
+    className: Tag,
+  };
+}
+
+function onPlay(event?: Event) {
+  const video = event?.target as HTMLVideoElement;
+  const pause = (el?: HTMLMediaElement) => {
+    el && !el.paused && el !== video && el.pause();
+  };
+
+  document.querySelectorAll('video').forEach(pause);
+  document.querySelectorAll('audio').forEach(pause);
+
+
+  if ( video?.paused ) video.play();
+}
+</script>
+<template>
+  <div class="flex my-2 -mx-2 overflow-x-auto" v-if="media.length">
+    <div class="flex-none mx-2" v-for="item in media" :key="item.title">
+      <div v-if="item.url" class="relative h-32 rounded-lg	overflow-hidden" @click="handle(item)">
+        <img class="size-full object-scale-down" v-if="item.poster" :src="item.poster" :alt="item.title" />
+        <van-icon class="play" v-if="item.type === 'video'" name="play-circle-o" />
+      </div>
+      <template v-if="item.title">
+        <div v-if="item.type === 'text'" class="text-lg text-primary">{{ item.title }}</div>
+        <div v-else class="mt-1 text-xs">{{ item.title }}</div>
+      </template>
+    </div>
+    <van-image-preview v-model:show="preview" v-bind="previewProps" @closed="current=void 0" @change="update">
+      <template v-slot:index="{index}">
+        <span>{{ images[ index ]?.title }}</span>
+        <span v-if="previewProps?.showIndex">({{ index + 1 }} / {{ images?.length }})</span>
+      </template>
+      <template #image="{  src, style, onLoad }">
+        <img v-if="current?.type === 'image'" :style="[{ width: '100%' }, style]" :src="src"
+             alt="" @load="onLoad"
+        />
+        <video v-else-if="current?.type === 'video'" :style="[{ width: '100%' }, style]"
+               :poster="current?.poster"
+               controls controlslist="nodownload" disablepictureinpicture
+               playsinline webkit-playsinline @play="onPlay"
+        >
+          <source :src="src" />
+        </video>
+      </template>
+    </van-image-preview>
+  </div>
+</template>
+<style scoped lang="scss">
+.play {
+  font-size: 36px;
+  position: absolute !important;
+  right: 5px;
+  bottom: 5px;
+}
+</style>

+ 85 - 0
src/modules/report/SyndromeChart.vue

@@ -0,0 +1,85 @@
+<script setup lang="ts">
+import { provide, ref }                      from 'vue';
+import VChart, { type EChartsOption, theme } from './echart';
+
+
+const { dataset = [] } = defineProps<{ dataset?: { label: string; score: number; }[] }>();
+
+const snapshot = defineModel('snapshot', { default: '' });
+
+provide(...theme);
+
+const option = ref<EChartsOption>({
+  backgroundColor: 'transparent',
+  color: [ '#38ff6e' ],
+  textStyle: {
+    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,
+  },
+  series: {
+    type: 'radar',
+    label: { show: true, position: 'insideTopLeft', fontSize: 14, color: '#fff' },
+    data: [
+      {
+        value: [ 60, 73, 85, 40 ],
+        name: '中医证素',
+      },
+    ],
+  },
+});
+
+watchEffect(() => {
+  const indicator: { name: string; max: number }[] = [];
+  const value: number[] = [];
+
+  for ( const item of dataset ) {
+    const { label: name, score } = item;
+    const max = 110;
+    indicator.push({ name, max });
+    value.push(score);
+  }
+  option.value.radar = {
+    indicator,
+  };
+  option.value.series = {
+    ...option.value.series,
+    data: [ { name: '中医证素', value } ],
+  };
+});
+
+const chart = useTemplateRef<InstanceType<typeof VChart>>('chart');
+
+function onFinished() {
+  snapshot.value = chart.value?.getDataURL() ?? '';
+}
+</script>
+<template>
+  <div class="mx-auto" style="width: 50%;">
+    <div class="chart-container">
+      <v-chart ref="chart" class="chart" :option="option" @finished="onFinished" />
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+.chart-container {
+  position: relative;
+  padding-bottom: 100%;
+
+  > .chart {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 30 - 0
src/modules/report/echart.ts

@@ -0,0 +1,30 @@
+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 { ComposeOption }                                                         from 'echarts/core';
+import { use }                                                                        from 'echarts/core';
+import { CanvasRenderer }                                                             from 'echarts/renderers';
+
+import VChart, { THEME_KEY } from 'vue-echarts';
+
+
+use([
+  CanvasRenderer,
+  DatasetComponent, VisualMapComponent,
+  GridComponent,
+  BarChart,
+  RadarChart,
+]);
+
+export type EChartsOption = ComposeOption<
+  | DatasetComponentOption
+  | GridComponentOption
+  | VisualMapComponentOption
+  | BarSeriesOption
+  | RadarSeriesOption
+>;
+
+
+export default VChart;
+export const theme = [ THEME_KEY, 'dark' ] as const;

+ 184 - 0
src/modules/report/report.page.vue

@@ -0,0 +1,184 @@
+<script setup lang="ts">
+import NavBar                                  from '@/modules/report/NavBar.vue';
+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';
+
+
+const id = useRouteParams<string>('id');
+const { data, loading } = useWatcher(() => getReportMethod(id.value), [ id ], {
+  initialData: {
+    descriptionsTable: { column: [], data: [] },
+    tongueTable: { column: [], data: [] },
+    tongueException: [],
+    tongueAnalysis: {},
+    faceAnalysis: {},
+  },
+  immediate: true,
+});
+
+const { loading: uploading, send: upload } = useRequest(() => updateReportMethod(id.value, data.value), {
+  immediate: false,
+  middleware(_, next) {
+    const hasConstitutionGroupImg = data.value.constitutionGroupImg;
+    const hasFactorItemRadarImg = data.value[ '中医证素' ]?.length ? data.value.factorItemRadarImg : true;
+    if ( hasConstitutionGroupImg && hasFactorItemRadarImg ) { next(); }
+  },
+});
+</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>
+              <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-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>
+        </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>
+          </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__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>
+              <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="面象">
+            </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()"
+            />
+            <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>
+          </div>
+        </div>
+      </div>
+    </van-skeleton>
+    <NavBar class="flex-none"></NavBar>
+  </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;
+  width: 240px;
+  background-size: 80%;
+  letter-spacing: 2px;
+}
+
+.text-grey {
+  color: #e3e3e3;
+}
+</style>

+ 59 - 0
src/modules/report/scheme.page.vue

@@ -0,0 +1,59 @@
+<script setup lang="ts">
+import NavBar                    from '@/modules/report/NavBar.vue';
+import SchemeMedia               from '@/modules/report/SchemeMedia.vue';
+import { getReportSchemeMethod } from '@/request/api/report.api';
+import { useRouteParams }        from '@vueuse/router';
+import { useWatcher }            from 'alova/client';
+
+
+const id = useRouteParams<string>('id');
+const { data, loading } = useWatcher(() => getReportSchemeMethod(id.value), [ id ], {
+  initialData: {
+    children: [],
+  },
+  immediate: true,
+});
+</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>
+          </div>
+        </div>
+      </div>
+    </van-skeleton>
+    <NavBar class="flex-none"></NavBar>
+  </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;
+  width: 240px;
+  background-size: 80%;
+  letter-spacing: 2px;
+}
+
+.text-grey {
+  color: #e3e3e3;
+}
+</style>

+ 336 - 0
src/pages/register.page.vue

@@ -0,0 +1,336 @@
+<script setup lang="ts">
+import { Notify, Toast } from '@/platform';
+
+import {
+  type FieldKey,
+  getCaptchaMethod,
+  processMethod,
+  registerAccountMethod,
+  registerFieldsMethod,
+  searchAccountMethod,
+} from '@/request/api';
+
+import { useVisitor } from '@/stores';
+
+import { useCaptcha, useForm, useRequest } from 'alova/client';
+
+import type { FieldRule, FormInstance, NumberKeyboardProps, PasswordInputProps } from 'vant';
+import { RadioGroup as vanRadioGroup }                                           from 'vant';
+
+
+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 { loading: searching, send: search } = useRequest((data) => searchAccountMethod(data), {
+  immediate: false,
+}).onSuccess(({ data }) => {
+  modelRef.value = { ...modelRef.value, ...data };
+});
+
+const { loading: captchaLoading, countdown, send: getCaptcha } = useCaptcha(
+  () => getCaptchaMethod(modelRef.value.phone!),
+  { initialCountdown: 60 },
+);
+const getCaptchaHandle = async () => {
+  try {
+    await formRef.value?.validate('phone');
+    await getCaptcha();
+    const field = fields.value.find(field => field.name === 'code');
+    if ( field?.keyboard ) { field.keyboard.show = true; }
+  } catch ( e: any ) {
+    Toast.warning(e?.message);
+  }
+};
+
+const searchHandle = async (key: 'cardno' | 'code') => {
+  try {
+    await formRef.value?.validate(key);
+    await search(modelRef.value).catch();
+  } catch ( e: any ) {
+    Toast.warning(e?.message);
+  }
+};
+
+function onKeyboardBlur(field: Field & { name: FieldKey }) {
+  if ( field?.name === 'phone' ) { getCaptchaHandle(); }
+  if ( field?.name === 'cardno' ) { searchHandle('cardno'); }
+  if ( field?.name === 'code' ) { searchHandle('code'); }
+}
+
+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(`[路由] 配置异常无法解析正确路径,请联系管理员`);
+    }
+  });
+</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)"
+          >
+            <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>
+</template>
+<style scoped lang="scss">
+.register-form {
+  .van-field {
+    margin: 0;
+    padding: 0;
+  }
+
+  .van-field.no-border {
+    :deep(.van-field__control) {
+      padding: 0;
+      border: none;
+      border-radius: 8px;
+      text-align: center;
+    }
+  }
+
+  :deep(.van-field__label) {
+    margin-bottom: 24px;
+    padding: 8px 0;
+    min-width: 100px;
+    font-size: 18px;
+  }
+
+  :deep(.van-field__control) {
+    margin-bottom: 24px;
+    padding: 8px;
+    border: 1px solid #38ff6e;
+    border-radius: 8px;
+    text-align: center;
+  }
+
+  :deep(.van-field__clear) {
+    align-self: flex-start;
+    display: flex;
+    align-items: center;
+    height: 42px;
+  }
+
+  :deep(.van-field__button) {
+    margin-bottom: 24px;
+    padding: 8px var(--van-padding-xs);
+    min-width: 100px;
+    font-size: 18px;
+    text-align: left;
+  }
+
+  :deep(.van-field__error-message) {
+    position: absolute;
+    top: 40px + 2px;
+  }
+
+  :deep(.van-password-input) {
+    margin: 0;
+  }
+
+  :deep(.van-password-input__security) {
+    justify-content: space-between;
+    align-items: center;
+    text-align: center;
+    $size: 40px;
+    height: $size + 2px;
+
+    li {
+      height: $size;
+      width: $size;
+      flex: none;
+      border: 1px solid #38ff6e;
+      border-radius: 8px;
+    }
+  }
+}
+
+.van-radio-group {
+  height: 40px + 2px;
+}
+</style>

+ 190 - 0
src/pages/screen.page.vue

@@ -0,0 +1,190 @@
+<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';
+
+
+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 container = useTemplateRef<HTMLDivElement>('container');
+const { width, height } = useElementSize(container);
+
+interface Bubble {
+  text: string;
+  color: string;
+  x?: number;
+  y?: number;
+  dx?: number;
+  dy?: number;
+  diameter?: number;
+}
+
+watchEffect(() => {
+  if ( width.value && height.value ) {
+    init(
+      { width: width.value, height: height.value * 0.90, container: container.value! });
+  }
+});
+
+function init({ width, height, container }: { width: number; height: number; container: HTMLElement }) {
+  const bubbles = [];
+  new p5((sketch) => {
+    let scanLineOffset;
+    let scanLineOffStep = 5;
+    const drawScan = (w = 24, x = 40, h = w, y = x, { color = '#34a76b', size = 5 } = {}) => {
+      sketch.push();
+      sketch.stroke(color);
+      sketch.strokeWeight(size);
+
+      // 左上角
+      sketch.line(x, y, x + w, y);
+      sketch.line(x, y, x, y + h);
+
+      // 左下角
+      sketch.line(x, height - y, x + w, height - y);
+      sketch.line(x, height - y, x, height - y - h);
+
+      // 右上角
+      sketch.line(width - x, y, width - x - w, y);
+      sketch.line(width - x, y, width - x, y + h);
+
+      // 右下角
+      sketch.line(width - x, height - y, width - x - w, height - y);
+      sketch.line(width - x, height - y, width - x, height - y - h);
+
+      // 线
+      const yT = y * 2;
+      const yB = height - yT;
+
+      scanLineOffset ??= yT;
+
+      if ( scanLineOffset < yT || scanLineOffset > yB ) scanLineOffStep = -scanLineOffStep;
+      scanLineOffset += scanLineOffStep;
+
+      sketch.line(x, scanLineOffset, width - x, scanLineOffset);
+
+      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 drawBubble = (x = 40, y = x, diameter = 90) => {
+      for ( const bubble of <Required<Bubble>[]> bubbles ) {
+        bubble.diameter ??= diameter;
+        const radius = Math.floor(bubble.diameter / 2);
+
+        bubble.x ??= sketch.random(x + radius, width - x - radius);
+        bubble.y ??= sketch.random(y + radius, height - y - radius);
+        bubble.dx ??= sketch.random(-2, 2);
+        bubble.dy ??= sketch.random(-2, 2);
+
+        // 移动
+        bubble.x += bubble.dx;
+        bubble.y += bubble.dy;
+        if ( bubble.x + radius >= width - y ) bubble.x = width - y;
+        if ( bubble.y + radius >= height - y ) bubble.y = height - y;
+        // 绘制
+        const size = 24;
+        const color = sketch.color(bubble.color);
+        sketch.push();
+        sketch.fill('#fff');
+        sketch.textSize(size);
+        sketch.textAlign(sketch.CENTER);
+        sketch.text(bubble.text, bubble.x, bubble.y - size / 4);
+        sketch.fill(color);
+        sketch.circle(bubble.x, bubble.y, bubble.diameter);
+        sketch.pop();
+        // 检测边界
+        if ( bubble.x - radius <= x || bubble.x + radius >= width - x ) {
+          bubble.dx *= -1;
+          if ( bubble.x - radius <= x ) { bubble.x = x + radius; } else { bubble.x = width - x - radius; }
+        }
+        if ( bubble.y - radius <= y || bubble.y + radius >= height - y ) {
+          bubble.dy *= -1;
+          if ( bubble.y - radius <= y ) { bubble.y = y + radius; } else { bubble.y = height - y - radius; }
+        }
+      }
+      const collide = (bubble: Required<Bubble>, other: Required<Bubble>) => {
+        const radius = Math.floor(bubble.diameter / 2);
+
+        let angle = sketch.atan2(other.y - bubble.y, other.x - bubble.x);
+        let target = sketch.createVector(bubble.x, bubble.y);
+        let a = p5.Vector.fromAngle(angle + sketch.PI, radius);
+        let b = p5.Vector.fromAngle(angle, radius);
+
+        bubble.x = target.x + a.x;
+        bubble.y = target.y + a.y;
+        other.x = target.x + b.x;
+        other.y = target.y + b.y;
+        bubble.dx *= -1;
+        other.dx *= -1;
+      };
+      for ( let i = 0; i < bubbles.length; i++ ) {
+        for ( let j = i + 1; j < bubbles.length; j++ ) {
+          const bubble = bubbles[ i ] as Required<Bubble>;
+          const other = bubbles[ j ] as Required<Bubble>;
+
+          const d = sketch.dist(bubble.x, bubble.y, other.x, other.y);
+          if ( d < bubble.diameter ) {
+            collide(bubble, other);
+            collide(other, bubble);
+          }
+        }
+      }
+    };
+
+    sketch.setup = () => {
+      sketch.createCanvas(width, height);
+      sketch.noStroke();
+    };
+    sketch.draw = () => {
+      sketch.clear();
+      drawScan();
+      drawBubble();
+    };
+  }, container);
+}
+</script>
+<template>
+  <div class="wrapper">
+    <div class="fixed size-full" ref="container"></div>
+    <div class="fixed flex flex-col size-full">
+      <div class="flex-none" style="height: 85vh;">
+        <img class="mx-auto h-full object-scale-down" style="width: 32vw;" src="@/assets/images/title.png" :alt="title">
+      </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>
+        </div>
+        <div class="flex-none text-xl p-8 text-center" v-html="copyright"></div>
+      </div>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+.wrapper {
+  background: url("@/assets/images/screen.png") no-repeat center / 100%;
+}
+</style>

+ 3 - 0
src/platform/index.ts

@@ -16,3 +16,6 @@ export function getSerialNumberSync() {
     }
   )();
 }
+
+export * from './notify.ui';
+export * from './toast.ui';

+ 38 - 0
src/platform/notify.ui.ts

@@ -0,0 +1,38 @@
+import type { NotifyOptions as VantNotifyOptions, NotifyType as VantNotifyType } from 'vant';
+import { closeNotify, showNotify } from 'vant';
+import 'vant/es/notify/style';
+
+type NotifyType = 'info' | 'success' | 'error' | 'warning'
+type NotifyOptions = Omit<VantNotifyOptions, 'type' | 'message'>;
+
+export class Notify {
+  static Type: Record<NotifyType, string> = {
+    info: 'primary',
+    success: 'success',
+    error: 'danger',
+    warning: 'warning',
+  };
+
+  static show(type: NotifyType, message: string, options?: NotifyOptions) {
+    const notifyRef = showNotify({ type: this.Type[type] as VantNotifyType, message, ...options });
+    return { notifyRef, close: Notify.close };
+  }
+
+  static close() { closeNotify(); }
+
+  static info(message: string, options?: NotifyOptions) {
+    return this.show('info', message, options);
+  }
+
+  static success(message: string, options?: NotifyOptions) {
+    return this.show('success', message, options);
+  }
+
+  static warning(message: string, options?: NotifyOptions) {
+    return this.show('warning', message, options);
+  }
+
+  static error(message: string, options?: NotifyOptions) {
+    return this.show('error', message, options);
+  }
+}

+ 48 - 0
src/platform/toast.ui.ts

@@ -0,0 +1,48 @@
+import type { ToastOptions as VantToastOptions, ToastWrapperInstance as VantToastWrapperInstance } from 'vant';
+import { closeToast, showToast } from 'vant';
+import 'vant/es/toast/style';
+
+
+type ToastOptions = Omit<VantToastOptions, 'message'>;
+
+export class Toast {
+  static show(message: string, options?: ToastOptions) {
+    const toastRef = showToast({ message, ...options });
+    return { toastRef, close: () => toastRef.close() };
+  }
+
+  static close() { closeToast(true); }
+
+  static success(message: string, options?: ToastOptions) {
+    return this.show(message, { ...options, closeOnClick: true, closeOnClickOverlay: true, type: 'success' });
+  }
+
+  static warning(message: string, options?: ToastOptions) {
+    return this.show(message, { ...options, icon: 'warning-o' });
+  }
+
+  static error(message: string, options?: ToastOptions) {
+    return this.show(message, { ...options, type: 'fail' });
+  }
+
+  static loading(delay = 0, options?: ToastOptions & { message?: string }) {
+    const fn = () => this.show(options?.message ?? '加载中...', {
+      ...options,
+      type: 'loading',
+      closeOnClick: false,
+      closeOnClickOverlay: false,
+      forbidClick: true,
+      duration: 0,
+    });
+    if (delay === 0) { return fn(); }
+    const timer = setTimeout(() => {
+      ({ toastRef: ref.toastRef, close: ref.close } = fn());
+    }, delay);
+    const ref = {
+      toastRef: null as VantToastWrapperInstance | null,
+      cancel: () => clearTimeout(timer),
+      close: () => clearTimeout(timer),
+    };
+    return ref;
+  }
+}

+ 45 - 0
src/request/alova.ts

@@ -0,0 +1,45 @@
+import adapterMock                                  from '@/request/mock';
+import pinia, { useAccountStore, usePlatformStore } from '@/stores';
+
+import { createAlova } from 'alova';
+import adapterFetch    from 'alova/fetch';
+import VueHook         from 'alova/vue';
+
+
+export default createAlova({
+  baseURL: import.meta.env.BASE_URL,
+  statesHook: VueHook,
+  requestAdapter: import.meta.env.DEV ? adapterMock() : adapterFetch(),
+  async beforeRequest(method) {
+    const store = usePlatformStore(pinia);
+    method.config.headers.warrant ??= store.serialNumber;
+    if ( !method.config.meta?.ignoreToken ) method.config.headers.Authorization ??= useAccountStore(pinia).token;
+  },
+  responded: {
+    async onSuccess(response, method) {
+      try {
+        if ( response.status >= 400 ) throw new Error(`${ response.statusText }(${ response.status })`);
+
+        const result = await response.json();
+        /* 接口修正 code */
+        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 })` };
+      } catch ( e: any ) {
+        if ( !method.meta?.ignoreException ) {
+          const { Notify } = await import('@/platform/notify.ui');
+          Notify.error(e?.message);
+        }
+        throw e;
+      }
+    },
+    async onError(error, method) {
+      if ( !method.meta?.ignoreException && error.name !== 'AbortError' ) {
+        const { Notify } = await import('@/platform/notify.ui');
+        Notify.error(`${ error.message }(${ error?.code })`);
+      }
+    },
+    onComplete() {},
+  },
+});

+ 40 - 0
src/request/api/account.api.ts

@@ -0,0 +1,40 @@
+import { cacheFor }           from '@/request/api/index';
+import type { RegisterModel } from '@/request/model';
+import HTTP                   from '../alova';
+
+
+export type FieldKey = 'cardno' | 'phone' | 'code' | 'name' | 'sex' | 'height' | 'weight' | 'isEasyAllergy'
+
+
+export function getCaptchaMethod(mobile: string) {
+  return HTTP.Get(`/fdhb-tablet/sms/sendVerCode`, {
+    params: { phone: mobile },
+  });
+}
+
+export function registerFieldsMethod() {
+  return HTTP.Post<FieldKey[], { tabletFileFields: FieldKey[] }>(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
+    cacheFor,
+    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[] ?? [];
+    },
+  });
+}
+
+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, {});
+}
+
+export function searchAccountMethod(params: Partial<RegisterModel>) {
+  if ( import.meta.env.DEV && params.code ) params.code = '176364';
+  return HTTP.Get(`/fdhb-tablet/patientInfoManage/getPatientInfoDetail`, {
+    params,
+    transform(data: Record<string, any>, headers) {
+      return Object.fromEntries(Object.entries(data).filter(([ item, value ]) => !!value)) as Partial<RegisterModel>;
+    },
+  });
+}

+ 23 - 0
src/request/api/camera.api.ts

@@ -0,0 +1,23 @@
+import HTTP              from '@/request/alova';
+import { processMethod } from '@/request/api/flow.api';
+
+
+export function uploadFileMethod(file: File) {
+  const formData = new FormData();
+  formData.append('file', file);
+  return HTTP.Post<{ name: string; url: string }>(`/file/upload`, formData, {});
+}
+
+export function saveFileMethod(params: Record<string, string>) {
+  return HTTP.Get(`/fdhb-tablet/patientInfoManage/saveTonguefaceImg`, {
+    params,
+    async transform(data: string, headers) {
+      const path = await processMethod(`/camera`);
+      return {
+        resultId: data,
+        done: !path,
+        path: path || `/screen`,
+      };
+    },
+  });
+}

+ 37 - 0
src/request/api/flow.api.ts

@@ -0,0 +1,37 @@
+import { cacheFor } from '@/request/api/index';
+import HTTP         from '../alova';
+
+
+export function copyrightMethod() {
+  return HTTP.Post(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
+    cacheFor,
+    transform(data: any, headers) {
+      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);
+    },
+  });
+}
+

+ 6 - 0
src/request/api/index.ts

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

+ 24 - 0
src/request/api/questionnaire.api.ts

@@ -0,0 +1,24 @@
+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 = []) {
+  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 ] }) },
+    {
+      transform(data: Record<string, any>, headers) {
+        const { storage: { dialogId, questions }, model } = fromQuestionnaireData(data);
+        storage = { dialogId, questions: questions.length ? [ ...storage.questions, questions ] : [] };
+        return model;
+      },
+    },
+  );
+}

+ 30 - 0
src/request/api/report.api.ts

@@ -0,0 +1,30 @@
+import HTTP                                  from '../alova';
+import { fromReportData, fromSchemeRequest } from '../model';
+
+
+export function getReportMethod(id: string) {
+  const params = { healthAnalysisReportId: id };
+  return HTTP.Get(`/fdhb-tablet/analysisManage/getHealRepDetailById`, {
+    params,
+    transform(data, headers) { return fromReportData(<any> data); },
+  });
+}
+
+export function updateReportMethod(id: string, data: Record<string, any>) {
+  const params = {
+    healthAnalysisReportId: id,
+    constitutionGroupImg: data?.constitutionGroupImg,
+    factorItemRadarImg: data?.factorItemRadarImg,
+  };
+  return HTTP.Post(`/fdhb-tablet/analysisManage/upConFacImgById`, params, {});
+}
+
+export function getReportSchemeMethod(id: string) {
+  const params = { healthAnalysisReportId: id };
+  return HTTP.Get(`/fdhb-tablet/analysisManage/getCondProgDetailById`, {
+    params,
+    transform(data: any, headers) {
+      return fromSchemeRequest(data);
+    },
+  });
+}

+ 14 - 0
src/request/mock/index.ts

@@ -0,0 +1,14 @@
+import { createAlovaMockAdapter, type MockWrapper } from '@alova/mock';
+import adapterFetch                                 from 'alova/fetch';
+
+
+export default function adapterMock(enable = true) {
+  const wrapper: MockWrapper[] = [];
+
+  return createAlovaMockAdapter(wrapper, {
+    enable,
+    httpAdapter: adapterFetch(),
+    delay: 1000,
+    mockRequestLogger: true,
+  });
+}

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

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

+ 114 - 0
src/request/model/questionnaire.model.ts

@@ -0,0 +1,114 @@
+export function fromQuestionnaireData(data: Record<string, any>): {
+  storage: QuestionnaireStorage;
+  model: QuestionnaireModel;
+} {
+  const { healthAnalysisReportId: reportId, dialogId } = data;
+  const nextQuestions = data.nextQuestions ?? [];
+  return {
+    storage: reportId ? { questions: [] } : { dialogId, questions: nextQuestions },
+    model: { reportId, questionnaires: fromQuestionnaireProps(nextQuestions) },
+  };
+}
+
+export function toQuestionnaireData(data: QuestionnaireProps[], { dialogId, questions }: QuestionnaireStorage) {
+  for ( const item of data ) {
+    switch ( item.name ) {
+      case 'select':
+        const update = (options: SelectQuestionnaireProps['options'], _options?: Record<string, any>[]) => {
+          if ( !options?.length || !_options?.length ) return;
+
+          for ( const option of options ) {
+            const _option = _options?.find(_ => _.id === option.id);
+            if ( !_option ) continue;
+
+            _option.checked = option.checked;
+
+            update(option.options, _option?.options);
+          }
+        };
+
+
+        for ( const item of data ) {
+          const _item = questions.find(_ => _.id === item.id);
+          update(item.options, _item?.options);
+        }
+        break;
+    }
+  }
+  return { dialogId, questions };
+}
+
+function fromQuestionnaireProps(data: Record<string, any>[]): QuestionnaireProps[] {
+  const questionnaires: QuestionnaireProps[] = [];
+  for ( const item of data ) {
+    const { css, classify } = item;
+    if ( css === 'hide' || classify?.endsWith('_result') ) continue;
+
+    switch ( css ) {
+      case 'select':
+      case 'checkbox':
+        const { id, title: label, required = false } = item;
+        const multiple = css === 'checkbox';
+
+        const _options = (data: Record<string, any>[]) => {
+          if ( !Array.isArray(data) ) return [];
+          const options: SelectQuestionnaireProps['options'] = [];
+          for ( const option of data ) {
+            const { css } = option;
+            if ( css === 'hide' ) continue;
+
+            const { id, name: label } = option;
+            const multiple = css === 'checkbox';
+            const children = _options(option.options);
+            const checked = option.checked || children.some(option => option.checked);
+            options.push({
+              id, label, checked,
+              multiple, options: children,
+
+              value: children.filter(option => option.checked).map(option => option.label).join(' '),
+            });
+          }
+          return options;
+        };
+
+        questionnaires.push(<SelectQuestionnaireProps> {
+          name: 'select', id, label, required,
+          multiple, options: _options(item.options),
+        });
+        break;
+    }
+  }
+  return questionnaires;
+}
+
+
+export interface QuestionnaireStorage {
+  dialogId?: string;
+  questions: Record<string, any>[];
+}
+
+export interface QuestionnaireModel {
+  reportId?: string;
+  questionnaires: QuestionnaireProps[];
+}
+
+export type QuestionnaireProps =
+  | SelectQuestionnaireProps
+
+export interface SelectQuestionnaireProps {
+  name: 'select';
+  id: string;
+  label: string;
+  required?: boolean;
+  options: {
+    id: string;
+    label: string;
+    checked?: boolean;
+
+    multiple?: boolean;
+    options: SelectQuestionnaireProps['options'];
+
+    value?: string;
+  }[];
+  multiple?: boolean;
+}

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

@@ -0,0 +1,10 @@
+export interface RegisterModel {
+  cardno: string;
+  phone: string;
+  code: string;
+  name: string;
+  sex: string;
+  height: number;
+  weight: number;
+  isEasyAllergy: boolean;
+}

+ 103 - 0
src/request/model/report.model.ts

@@ -0,0 +1,103 @@
+export function fromReportData(data: Record<string, any>) {
+  const tongueException: ReportTongueException[] = [];
+  const fromTongueException = fromReportTongueExceptionData.bind(null, tongueException);
+  return {
+    id: data?.healthAnalysisReportId,
+    date: data?.reportTime,
+    scheme: data?.isHaveConditioningProgram === 'Y' && data?.isConfirmConditioningProgram === 'Y' ? data?.healthAnalysisReportId : null,
+
+    [ '结果' ]: data?.willillStateName,
+    [ '程度' ]: data?.willillDegreeName,
+    [ '类型' ]: data?.willillSocialName,
+    [ '表现' ]: data?.willillFunctionName,
+    [ '体质' ]: data?.constitutionGroupName,
+    [ '体质描述' ]: data?.constitutionGroupDefinition,
+    [ '体质图表' ]: fromReportPhysiqueChartData(data?.allConstitutionGroups),
+
+    descriptionsTable: {
+      column: [],
+      data: [
+        [ '总体特征', data?.constitutionGroupGeneralCharacteristics ],
+        [ '形体特征', data?.constitutionGroupPhysicalCharacteristics ],
+        [ '精神特征', data?.constitutionGroupPsychicCharacteristics ],
+        [ '常见表现', data?.constitutionGroupCommonManifestations ],
+        [ '发病倾向', data?.constitutionGroupDiseaseTendency ],
+        [ '环境适应能力', 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,
+    },
+
+    [ '中医证素' ]: data?.factorItems?.map?.((item: Record<string, any>) => {
+      return { label: item?.factorItemName, value: item?.factorItemDescription, score: +item?.score };
+    }),
+    [ '中医证型' ]: data?.diagnoseSyndromes?.map?.((item: Record<string, any>) => {
+      return { label: item?.diagnoseSyndromeName, value: item?.diagnoseSyndromeAnalysis };
+    }),
+
+    constitutionGroupImg: data?.constitutionGroupImg,
+    factorItemRadarImg: data?.factorItemRadarImg,
+  };
+}
+
+
+export interface ReportTongueException {
+  title: string;
+  cover: string;
+  descriptions: string[];
+  tags: string[];
+}
+
+function fromReportTongueExceptionData(exception: ReportTongueException[], data?: { actualList: Record<string, any>[] }) {
+  const values = data?.actualList?.map(item => {
+    let title: string = item?.actualValue ?? '';
+    const suffix = item?.contrast ?? 's';
+    if ( suffix !== 's' ) {
+      title += ` (${ suffix || '' }) `;
+      exception.push({
+        title, cover: item.splitImage,
+        descriptions: [
+          item.features ? `【特征】${ item.features }` : '',
+          item.clinicalSignificance ? `【临床意义】${ item.clinicalSignificance }` : '',
+        ].filter(Boolean),
+        tags: item.attrs ?? [],
+      });
+    }
+    return title;
+  }) ?? [];
+  return values.join('<br>');
+}
+
+function fromReportPhysiqueChartData(data: Record<string, any>[]) {
+  return data?.map((item: Record<string, any>) => [
+    item.constitutionGroupName, +item.score,
+    item.constitutionGroupName === '平和质'
+    ? /* 平和体质 */ 0
+    : item.isBasicTo === 'Y'
+      ? /* 所属体质 */ 1
+      : item.isTendTo === 'Y'
+        ? /* 倾向体质 */ 2
+        : item.isInfer === 'Y'
+          ? /* 推断体质 */ 3
+          : 4,
+  ]);
+}

+ 49 - 0
src/request/model/scheme.model.ts

@@ -0,0 +1,49 @@
+type Data<T extends any = any> = Record<string, T>
+
+export function fromSchemeRequest(data: Data) {
+  return {
+    children: data?.types?.map((item: Data) => {
+      return {
+        title: item?.type || '',
+        children: item?.groups?.map((item: Data) => {
+          return {
+            ...fragment(item),
+            descriptions: item?.attrs?.map(fragment) ?? [],
+            media: fromSchemeMedia(item.items ?? []),
+          };
+        }) ?? [],
+      };
+    }) ?? [],
+  };
+}
+
+
+function fragment(data: Data<string>) {
+  return {
+    title: data?.name || '',
+    description: data?.description,
+  };
+}
+
+export interface SchemeMediaProps {
+  title: string;
+  description: string;
+  type: 'image' | 'video' | 'text';
+  url: string;
+  poster: string;
+}
+
+function fromSchemeMedia(data: Data[]) {
+  const media: SchemeMediaProps[] = [];
+  for ( const item of data ) {
+    const type = item.mediaUrl ? 'video' : item.imgUrl ? 'image' : item.name ? 'text' : null;
+    if ( !type ) continue;
+
+    const title = item.type === 'medicine' ? [ item.name, `${ item.doase || '' }${ item.unit || '' }` ].filter(Boolean).join(' ') : item.name;
+    media.push({
+      type, title, description: item.description || '',
+      url: item.mediaUrl ?? item.imgUrl, poster: item.imgUrl,
+    });
+  }
+  return media;
+}

+ 53 - 0
src/router/hooks/useRouteMeta.ts

@@ -0,0 +1,53 @@
+import { tryOnScopeDispose }          from '@vueuse/core';
+import type { MaybeRefOrGetter, Ref } from 'vue';
+import type { RouteMeta }             from 'vue-router';
+
+
+export interface ReactiveRouteOptionsWithTransform<V, R> {
+  route?: ReturnType<typeof useRoute>;
+  transform?: (val: V) => R;
+}
+
+export function useRouteMeta<T extends RouteMeta = RouteMeta, K = T>(
+  name: string,
+  defaultValue?: MaybeRefOrGetter,
+  options: ReactiveRouteOptionsWithTransform<T, K> = {},
+): Ref<K> {
+
+  const {
+    route = useRoute(),
+    transform = value => value as any as K,
+  } = options;
+
+  let meta = route.meta[ name ] as any;
+
+  tryOnScopeDispose(() => { meta = undefined; });
+
+
+  let _trigger: () => void;
+  const proxy = customRef<any>((track, trigger) => {
+    _trigger = trigger;
+    return {
+      get() {
+        track();
+        return transform(meta !== undefined && meta !== '' ? meta : toValue(defaultValue));
+      },
+      set(v) {
+        meta = v;
+        trigger();
+      },
+    };
+  });
+
+  watch(
+    () => route.meta[ name ],
+    (v) => {
+      if ( meta === v ) return;
+      meta = v;
+      _trigger();
+    },
+    { flush: 'sync' },
+  );
+
+  return proxy as Ref<K>;
+}

+ 22 - 1
src/router/index.ts

@@ -1,8 +1,29 @@
 import { createRouter, createWebHistory } from 'vue-router';
 
+
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
-  routes: []
+  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'),
+      },
+    },
+  ],
 });
 
 export default router;

+ 12 - 0
src/stores/account.store.ts

@@ -0,0 +1,12 @@
+import { defineStore } from 'pinia';
+
+
+export const useAccountStore = defineStore('account', () => {
+  const token = ref<string>();
+
+  const $reset = () => {
+    token.value = '';
+  };
+
+  return { token, $reset };
+});

+ 7 - 3
src/stores/index.ts

@@ -1,13 +1,17 @@
-import { createPinia } from 'pinia';
+import { createPinia }          from 'pinia';
 import { createPersistedState } from 'pinia-plugin-persistedstate';
 
+
 const persistedState = createPersistedState({
-  key: (storeKey) => `${import.meta.env.SIX_APP_NAME ?? '@six/unknown'}:${storeKey}`,
+  key: (storeKey) => `${ import.meta.env.SIX_APP_NAME ?? '@six/unknown' }:${ storeKey }`,
   auto: true,
-  debug: import.meta.env.DEV
+  debug: import.meta.env.DEV,
 });
 
 const pinia = createPinia();
 pinia.use(persistedState);
 
 export default pinia;
+export { usePlatformStore }     from './platform.store';
+export { useAccountStore }      from './account.store';
+export { useVisitor }     from './visitor.store';

+ 10 - 0
src/stores/platform.store.ts

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

+ 16 - 0
src/stores/visitor.store.ts

@@ -0,0 +1,16 @@
+import { defineStore } from 'pinia';
+
+
+export const useVisitor = defineStore('visitor', () => {
+  const patientId = ref<string>();
+  const resultId = ref<string>();
+  const reportId = ref<string>();
+
+  const $reset = () => {
+    patientId.value = '';
+    resultId.value = '';
+    reportId.value = '';
+  };
+
+  return { patientId, resultId, reportId, $reset };
+});

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

@@ -0,0 +1,23 @@
+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 +1,2 @@
 export * from './url.tool';
+export * from './polyfills';

+ 22 - 0
src/tools/polyfills.ts

@@ -0,0 +1,22 @@
+// @ts-nocheck
+export function withResolvers<T>(): {
+  promise: Promise<T>;
+  resolve: (value: T | PromiseLike<T>) => void;
+  reject: (reason?: any) => void;
+} {
+  if ( typeof Promise.withResolvers === 'function' ) {
+    withResolvers = Promise.withResolvers.bind(Promise);
+    return Promise.withResolvers<T>();
+  } else {
+    const fn = function() {
+      const out = {};
+      out.promise = new Promise<T>((resolve_, reject_) => {
+        out.resolve = resolve_;
+        out.reject = reject_;
+      });
+      return out;
+    };
+    withResolvers = fn.bind(Promise);
+    return fn();
+  }
+}

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

@@ -0,0 +1,11 @@
+<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>

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

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

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

@@ -0,0 +1,17 @@
+<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>