28 Commits 2598c82fc9 ... e64183ab88

Author SHA1 Message Date
  cc12458 e64183ab88 Merge branch 'release/2.1.1' 3 weeks ago
  cc12458 444c0731d3 面象分析指标修改: 1 month ago
  cc12458 2c1ad9c2ee Merge branch 'feature/task-152' into develop 1 month ago
  cc12458 e6067851ef task-152 调理方案添加商品链接操作 1 month ago
  cc12458 535c5e5116 Merge branch 'feature/task-153' into develop 1 month ago
  cc12458 ce19f268f8 task-153 调理方案添加小程序码,可单独进入 1 month ago
  cc12458 f9e228d6e8 健康报告提取小程序码组件 1 month ago
  cc12458 e45f3ed6d0 媒体元素若没有封面采用视频第一帧,提供后退方案 1 month ago
  cc12458 e703f032f2 Merge branch 'release/2.1.0' into develop 1 month ago
  cc12458 2eb3cb1407 Merge branch 'release/2.1.0' 1 month ago
  cc12458 b3b464767b 优化黄酒建议页面数据 2 months ago
  cc12458 2e4f5c3d09 Merge branch 'feature/next' into develop 2 months ago
  cc12458 847cede1d8 Merge branch 'feature/drink' into feature/next 2 months ago
  cc12458 5b93244eea 添加黄酒建议 2 months ago
  cc12458 64611f2dde 1. 添加字典接口 2 months ago
  cc12458 622ea1aece 统一操作流程 & 页面跳转逻辑 3 months ago
  cc12458 bea6e07484 Merge branch 'feature/pulse' into develop 3 months ago
  cc12458 ad43f27652 Merge branch 'feature/pulse-report' into develop 3 months ago
  cc12458 f232604909 对接脉诊报告地址 3 months ago
  cc12458 04c3932d69 Merge tag 'pulse' into develop 3 months ago
  cc12458 74d3ed2d6c Merge branch 'hotfix/pulse' 3 months ago
  cc12458 a9bb4b04ae 健康分析报告页 因设备性能问题禁止同时展示左右手脉象3D图 3 months ago
  cc12458 98dd62c661 修复脉象绘制 3D 跨域 (https://wx.hzliuzhi.com/mz/hybrid/) 3 months ago
  cc12458 10cc2ea558 若脉诊下一步是结果直接跳转 3 months ago
  cc12458 41a6028fe5 Merge branch 'feature/pulse-report' into develop 3 months ago
  cc12458 453d5b73b6 添加脉象分析报告详情 3 months ago
  cc12458 1afa3b782b 健康分析报告 添加脉象分析 3 months ago
  cc12458 a52307a1e6 Merge branch 'hotfix/legacy' into develop 3 months ago
44 changed files with 2405 additions and 2075 deletions
  1. 1 1
      .prettierrc.json
  2. 4 20
      @types/bridge.d.ts
  3. 20 0
      @types/pulse.d.ts
  4. 1 0
      @types/vite-env.d.ts
  5. 3 2
      package.json
  6. 592 1575
      pnpm-lock.yaml
  7. BIN
      src/assets/images/alcohol-0.png
  8. BIN
      src/assets/images/alcohol-1.png
  9. 304 0
      src/components/AnalysisPulseComponent.vue
  10. 80 0
      src/components/MiniProgram.vue
  11. 212 0
      src/components/PickerDialog.vue
  12. 60 0
      src/computable/useRouteNext.ts
  13. 3 3
      src/loader/bridge.loader.ts
  14. 151 0
      src/modules/alcohol/alcohol.page.vue
  15. 33 36
      src/modules/camera/camera-result.page.vue
  16. 19 1
      src/modules/camera/camera.page.vue
  17. 121 0
      src/modules/pulse/pulse-result.page.vue
  18. 62 136
      src/modules/pulse/pulse.page.vue
  19. 4 2
      src/modules/questionnaire/page.vue
  20. 2 0
      src/modules/report/SchemeMedia.vue
  21. 11 15
      src/modules/report/report-analyse.page.vue
  22. 17 59
      src/modules/report/report.page.vue
  23. 133 9
      src/modules/report/scheme.page.vue
  24. 136 65
      src/pages/register.page.vue
  25. 11 13
      src/pages/screen.page.vue
  26. 13 0
      src/platform/index.ts
  27. 17 4
      src/request/api/account.api.ts
  28. 1 19
      src/request/api/camera.api.ts
  29. 11 43
      src/request/api/flow.api.ts
  30. 52 5
      src/request/api/pulse.api.ts
  31. 3 1
      src/request/api/questionnaire.api.ts
  32. 53 18
      src/request/api/report.api.ts
  33. 67 11
      src/request/model/analysis.model.ts
  34. 64 21
      src/request/model/flow.model.ts
  35. 50 12
      src/request/model/register.model.ts
  36. 1 0
      src/request/model/report.model.ts
  37. 39 1
      src/request/model/scheme.model.ts
  38. 1 1
      src/router/hooks/useRouteMeta.ts
  39. 4 1
      src/router/index.ts
  40. 11 1
      src/stores/visitor.store.ts
  41. 1 0
      src/tools/index.ts
  42. 5 0
      src/tools/url.tool.ts
  43. 28 0
      src/tools/uuid.tool.ts
  44. 4 0
      vite.config.ts

+ 1 - 1
.prettierrc.json

@@ -3,6 +3,6 @@
   "semi": true,
   "tabWidth": 2,
   "singleQuote": true,
-  "printWidth": 120,
+  "printWidth": 180,
   "trailingComma": "es5"
 }

+ 4 - 20
@types/bridge.d.ts

@@ -1,11 +1,4 @@
-interface HandSummary {
-  chi: string;
-  cun: string;
-  guan: string;
-  summary: string[];
-}
-
-type HandKeys = 'chen' | 'chi' | 'fu' | 'hua' | 'kong' | 'ruan' | 'shi' | 'shu' | 'xi' | 'xian';
+import type { SimplePulseModel } from './pulse';
 
 interface ScanData {
   code: string;
@@ -20,20 +13,9 @@ export interface BridgeEventMap {
 export class Bridge extends EventTarget {
   public static getInstance(): Bridge;
 
-  public static pulse(userId: string): Promise<{
-    measureId: string;
-    summaryLabel: {
-      summary?: HandSummary['summary'];
-      left?: HandSummary;
-      right?: HandSummary;
-    };
-    summaryValue: Record<HandKeys, number[]>;
-    time: string;
-
+  public static pulse(userId: string): Promise<SimplePulseModel & { report: string; time: string; } & {
     appId?: string;
     userId?: string;
-    url: string;
-    report: string;
   }>;
 
   public static print(): Promise<void>;
@@ -47,6 +29,8 @@ export class Bridge extends EventTarget {
    */
   addEventListener<T extends keyof BridgeEventMap>(type: T, listener: (event: BridgeEventMap[T]) => void, options?: boolean | AddEventListenerOptions): () => void;
   removeEventListener<T extends keyof BridgeEventMap>(type: T, listener: (event: BridgeEventMap[T]) => void, options?: boolean | AddEventListenerOptions): () => void;
+
+  postMessage(...args: any[]): Promise<void>;
 }
 
 export interface globalAIO {

+ 20 - 0
@types/pulse.d.ts

@@ -0,0 +1,20 @@
+interface HandSummary {
+  chi: string;
+  cun: string;
+  guan: string;
+  summary: string[];
+}
+
+type HandKeys = 'chen' | 'chi' | 'fu' | 'hua' | 'kong' | 'ruan' | 'shi' | 'shu' | 'xi' | 'xian';
+
+export interface SimplePulseModel {
+  measureId: string;
+  summaryLabel: {
+    summary?: HandSummary['summary'];
+    left?: HandSummary;
+    right?: HandSummary;
+  };
+  summaryValue: Record<HandKeys, number[]>;
+  hand?: number;
+  url?: string;
+}

+ 1 - 0
@types/vite-env.d.ts

@@ -0,0 +1 @@
+declare const __FORBID_AUTO_PROCESS_PULSE_AGENCY__: boolean

+ 3 - 2
package.json

@@ -7,6 +7,7 @@
     "dev": "vite",
     "build": "run-p type-check \"build-aio {@}\" --",
     "build-aio": "vite build --base=/aio/",
+    "build-aio-CQ": "vite build --base=/aio/ -- --legacy-pulse-agency",
     "preview": "vite preview --base=/aio/ --mode development",
     "device": "run-s build-aio preview",
     "type-check": "vue-tsc --build --force",
@@ -15,8 +16,8 @@
   },
   "dependencies": {
     "@alova/mock": "^2.0.7",
-    "@vueuse/core": "^11.1.0",
-    "@vueuse/router": "^11.1.0",
+    "@vueuse/core": "^13.6.0",
+    "@vueuse/router": "^13.6.0",
     "alova": "^3.0.20",
     "echarts": "^5.5.1",
     "eruda": "^3.4.0",

File diff suppressed because it is too large
+ 592 - 1575
pnpm-lock.yaml


BIN
src/assets/images/alcohol-0.png


BIN
src/assets/images/alcohol-1.png


+ 304 - 0
src/components/AnalysisPulseComponent.vue

@@ -0,0 +1,304 @@
+<script setup lang="ts">
+import type { SimplePulseModel } from '../../@types/pulse';
+
+import { createReusableTemplate, tryOnMounted } from '@vueuse/core';
+import { useRequest } from 'alova/client';
+import { getPulseAgentMethod } from '@/request/api/pulse.api';
+
+import { getURLSearchParamsByUrl } from '@/tools';
+
+import HandLeft from '@/assets/images/pulse-hand-left.png?url';
+import HandRight from '@/assets/images/pulse-hand-right.png?url';
+import { useVisitor } from '@/stores';
+import { Toast } from '@/platform';
+
+type Props = Partial<SimplePulseModel> & { title?: string; results?: string; disabled?: boolean; simple?: boolean };
+
+const { title = '分析', disabled = false, simple = false, results, ..._report } = defineProps<Props>();
+const slots = defineSlots();
+
+const Visitor = useVisitor();
+const report = computed(() => (unref(results) ? Object.assign(_report, { results }) : Visitor.pulseReport));
+
+const parseUrl = (value?: string) => {
+  const [url, query = ''] = (value ?? report.value?.url)?.split('report.html') ?? [];
+  const params = getURLSearchParamsByUrl(query);
+  if (url && params.has('appId') && params.has('access_session')) {
+    const replace /* 绘制 3D 跨域 */ = () => [
+      `https://wx.hzliuzhi.com/mz/hybrid/`,
+    ].includes(url) ? 'https://hybrid.reborn-tech.com/' : url;
+    return {
+      appId: params.get('appId')!!,
+      token: params.get('access_session')!!,
+      url: `${replace()}report.html#/`,
+    };
+  }
+};
+
+const stringifyUrl = (panel: 'left' | 'right', simple = false, overall = true) => {
+  const fragment = overall ? [`overall-${panel}`] : [];
+  if (!simple) fragment.push(`${panel}-hand`);
+
+  if (!fragment.length) return '';
+
+  const value = fragment.map((value) => `${value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())}=true`).join('&');
+  return `${agent.value!.url}single?appId=${agent.value!.appId}&access_session=${agent.value!.token}&mid=${report.value?.measureId}&${value}`;
+};
+
+const { data: agent, loading, send: getPulseAgent } = useRequest(() => getPulseAgentMethod(), {
+  initialData: parseUrl(report.value?.url),
+  immediate: false,
+});
+
+const card = useTemplateRef('card');
+const panelConfig = reactive({
+  height: 0,
+  anchors: [0],
+});
+const offset = computed(() => panelConfig.anchors[panelConfig.anchors.length - 1] - panelConfig.height);
+const getHeightAndScrollTop = () => {
+  const el = card.value;
+  if (!el) return;
+  el.scrollIntoView({ behavior: 'instant', block: 'start' });
+  const rect = el.getBoundingClientRect();
+  const maxHeight = window.innerHeight - rect.top;
+  const height = maxHeight - rect.height;
+  panelConfig.anchors = [0, height, maxHeight];
+  panelConfig.height = height;
+};
+
+const preview = reactive({
+  clickMainPanelPreviewable: true,
+  leftUrl: void 0 as string | void,
+  rightUrl: void 0 as string | void,
+  leftPanelUrl: void 0 as string | void,
+  rightPanelUrl: void 0 as string | void,
+  showLeftPanel: false,
+  showRightPanel: false,
+});
+
+const load = async (panel: 'left' | 'right' | boolean = true, simple = false) => {
+  if (loading.value) return;
+
+  if (!agent.value) {
+    try {
+      if (__FORBID_AUTO_PROCESS_PULSE_AGENCY__) throw { _: `禁止从设备中获取代理地址` };
+      const { promise, ...resolvers } = Promise.withResolvers<string>();
+      await window.bridge.postMessage('pulse:url:get', report.value?.measureId, resolvers);
+      agent.value = parseUrl(await promise);
+    } catch (e: any) {
+      if (e._) console.info(`log: ${e._}`);
+      agent.value = await getPulseAgent().catch(() => void 0);
+    }
+  }
+
+  if (!agent.value) return;
+
+  if (panel === false) {
+    preview.leftUrl ??= stringifyUrl('left', simple);
+    preview.rightUrl ??= stringifyUrl('right', simple);
+  } else {
+    if (!slots.exception) {
+      preview.leftPanelUrl = void 0;
+      preview.rightPanelUrl = void 0;
+    }
+    await nextTick();
+    if (panel === true || panel === 'left') {
+      preview.leftPanelUrl ??= stringifyUrl('left', simple);
+      preview.showLeftPanel = true;
+    } else {
+      preview.showLeftPanel = false;
+    }
+
+    if (panel === true || panel === 'right') {
+      preview.rightPanelUrl ??= stringifyUrl('right', simple);
+      preview.showRightPanel = true;
+    } else {
+      preview.showRightPanel = false;
+    }
+  }
+};
+
+const panelWrapperRef = useTemplateRef('panel-wrapper-ref');
+const open = async (panel?: 'left' | 'right', scroll = true) => {
+  if (disabled) return;
+
+  toast = Toast.loading(500);
+  await load(panel);
+  if (scroll) {
+    const last = panelConfig.height;
+    getHeightAndScrollTop();
+    if (last === panelConfig.height) {
+      const el = panelWrapperRef.value?.$el?.querySelector(`.van-floating-panel__content`);
+      el.scroll({ top: 0, behavior: 'smooth' });
+      console.log(panelWrapperRef.value.$el);
+    }
+  } else {
+    const height = window.innerHeight * 0.8;
+    panelConfig.anchors = [0, height];
+    panelConfig.height = height;
+  }
+};
+
+let toast;
+function iframeLoaded() {
+  toast?.close();
+}
+
+tryOnMounted(() => {
+  if (!slots.exception) load(false, simple);
+  preview.clickMainPanelPreviewable = !!slots.exception;
+});
+
+const { define: PreviewSlot, reuse: ReusePreview } = createReusableTemplate<{ src: string | void }>();
+
+watchPostEffect(() => {
+  if (panelConfig.height === 0) {
+    preview.showLeftPanel = false;
+    preview.showRightPanel = false;
+  }
+});
+</script>
+
+<template>
+  <van-skeleton class="analysis" :row="5" :loading="report?.results == null" v-if="report?.results !== ''">
+    <PreviewSlot v-slot="{ src }">
+      <iframe v-if="src" :src="src" :class="{ simple: !src.includes('Hand=true') }" @load="iframeLoaded()"></iframe>
+    </PreviewSlot>
+    <slot>
+      <div ref="card" class="card m-6 text-lg" @click="panelConfig.height = 0">
+        <div v-if="title" class="card__title mb-3 text-primary text-2xl font-bold">{{ title }}</div>
+        <slot name="content" :report="report">
+          <div class="card__content">
+            <div v-if="report?.summaryLabel" class="flex justify-evenly">
+              <div v-if="report?.summaryLabel?.left" class="part flex flex-row-reverse justify-center" @click.stop="preview.clickMainPanelPreviewable && open('left')">
+                <img style="width: 100px; height: 200px" :src="HandLeft" alt="左手" />
+                <div class="flex flex-col justify-evenly translate-y-2 h-40 text-xl" :class="{ highlight: preview.showLeftPanel }">
+                  <div>寸:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.cun }}</span></div>
+                  <div>关:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.guan }}</span></div>
+                  <div>尺:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.chi }}</span>
+                  </div>
+                </div>
+                <div v-if="!disabled && preview.clickMainPanelPreviewable" class="absolute bottom-2 text-primary text-sm">查看脉象详情</div>
+              </div>
+              <div v-if="report?.summaryLabel?.right" class="part flex justify-center" @click.stop="preview.clickMainPanelPreviewable && open('right')">
+                <img style="width: 100px; height: 200px" :src="HandRight" alt="右手" />
+                <div class="flex flex-col justify-evenly translate-y-2 h-40 text-xl" :class="{ highlight: preview.showRightPanel }">
+                  <div>寸:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.cun }}</span></div>
+                  <div>关:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.guan }}</span></div>
+                  <div>尺:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.chi }}</span></div>
+                </div>
+                <div v-if="!disabled && preview.clickMainPanelPreviewable" class="absolute bottom-2 text-primary text-sm">查看脉象详情</div>
+              </div>
+            </div>
+            <p v-if="report?.results" class="text-2xl text-center" @click.stop="preview.clickMainPanelPreviewable && open()">
+              总体脉象:<span class="text-primary-400" style="letter-spacing: 4px">{{ report.results }}</span>
+            </p>
+          </div>
+        </slot>
+      </div>
+      <slot name="exception">
+        <div v-if="agent" class="grid grid-rows-1 grid-cols-1 gap-8 m-6">
+          <div class="card text-lg" v-if="report?.summaryLabel?.left" @click="panelConfig.height = 0">
+            <div class="card__title mb-3 text-primary text-2xl font-bold flex justify-between" @click.stop="simple && open('left', false)">
+              <div>左手脉象: {{ report.summaryLabel.left.summary?.join('、') }}</div>
+              <van-icon v-if="simple" class="text-xl" name="arrow" />
+            </div>
+            <div class="card__content">
+              <ReusePreview v-if="panelConfig.height < 100" :src="preview.leftUrl"></ReusePreview>
+            </div>
+          </div>
+          <div class="card text-lg" v-if="report?.summaryLabel?.right" @click="panelConfig.height = 0">
+            <div class="card__title mb-3 text-primary text-2xl font-bold flex justify-between" @click.stop="simple && open('right', false)">
+              <div>右手脉象: {{ report.summaryLabel.right.summary?.join('、') }}</div>
+              <van-icon v-if="simple" class="text-xl" name="arrow" />
+            </div>
+            <div class="card__content">
+              <ReusePreview v-if="panelConfig.height < 100" :src="preview.rightUrl"></ReusePreview>
+            </div>
+          </div>
+        </div>
+      </slot>
+    </slot>
+    <van-floating-panel ref="panel-wrapper-ref" class="pulse-info-panel" :content-draggable="false" :lock-scroll="true" :anchors="panelConfig.anchors" v-model:height="panelConfig.height">
+      <template #header>
+        <div class="van-floating-panel__header !justify-between">
+          <div></div>
+          <div class="van-floating-panel__header-bar"></div>
+          <van-icon class="pr-2" name="cross" @click="panelConfig.height = 0" />
+        </div>
+      </template>
+      <div class="area" :class="{ last: !preview.showRightPanel }" v-if="preview.showLeftPanel && report?.summaryLabel?.left">
+        <div class="title">左手脉象: {{ report.summaryLabel.left.summary?.join('、') }}</div>
+        <ReusePreview :src="preview.leftPanelUrl"></ReusePreview>
+      </div>
+      <div class="area" v-if="preview.showRightPanel && report?.summaryLabel?.right">
+        <div class="title">右手脉象: {{ report.summaryLabel.right.summary?.join('、') }}</div>
+        <ReusePreview :src="preview.rightPanelUrl"></ReusePreview>
+      </div>
+    </van-floating-panel>
+  </van-skeleton>
+</template>
+
+<style scoped lang="scss">
+.part {
+  position: relative;
+  width: 50%;
+}
+.highlight {
+  color: rgb(52 167 107 / var(--tw-text-opacity, 1));
+  & + div {
+    opacity: 0.5;
+  }
+}
+
+$h: 1660px;
+
+iframe {
+  width: 100%;
+  height: $h;
+  border: none;
+
+  &.simple {
+    height: 432px;
+  }
+}
+
+.pulse-info-panel {
+  --van-floating-panel-background: #fff;
+
+  .van-icon {
+    color: var(--van-floating-panel-bar-color);
+  }
+
+  .area {
+    margin: auto;
+    padding-left: 12px;
+    min-width: 320px;
+    max-width: 1200px;
+
+    .title {
+      padding-left: 9px;
+      font-family:
+        Helvetica Neue,
+        Helvetica,
+        Roboto,
+        Tahoma,
+        Arial,
+        PingFang SC,
+        sans-serif;
+      font-size: 18px;
+      font-weight: 700;
+      color: #1f2d3d;
+      line-height: 36px;
+    }
+
+    &:last-of-type,
+    &.last {
+      iframe {
+        height: calc(#{$h} + v-bind(offset) * 1px);
+      }
+    }
+  }
+}
+</style>

+ 80 - 0
src/components/MiniProgram.vue

@@ -0,0 +1,80 @@
+<script setup lang="ts">
+import NavMiniProgram from '@/assets/images/mini-program.svg?url';
+import { Notify } from '@/platform';
+
+const props = defineProps<{ url?: string; closeable?: boolean; forcedShow?: boolean }>();
+
+const panelHeight = ref(0);
+const panelProps = reactive({
+  anchors: [0, window.innerWidth],
+  contentDraggable: false,
+  lockScroll: true,
+});
+const showOverlay = computed(() => panelHeight.value >= panelProps.anchors[0] && panelHeight.value > 0)
+
+watchEffect(() => {
+  panelProps.anchors[0] = props.closeable ? 0 : 100;
+});
+
+const handle = () => {
+  if (!props.url) {
+    Notify.warning(`未获取到小程序地址,请联系管理员或重试`);
+    return;
+  }
+  panelOpen();
+};
+
+const panelOpen = (min?: number) => {
+  if (min) panelProps.anchors[0] = min;
+  panelHeight.value = panelProps.anchors[1];
+};
+
+defineExpose({
+  open: handle,
+});
+</script>
+
+<template>
+  <div class="nav m-auto min-w-16 text-center hover:text-primary" v-if="url || props.forcedShow" @click="handle()">
+    <img class="nav-img" :src="NavMiniProgram" alt="小程序" />
+    <div class="mt-2">小程序</div>
+  </div>
+  <van-overlay :show="showOverlay" class="panel-mask"></van-overlay>
+  <van-floating-panel v-model:height="panelHeight" v-bind="panelProps">
+    <template #header>
+      <div class="van-floating-panel__header !justify-between">
+        <div></div>
+        <div class="van-floating-panel__header-bar"></div>
+        <div>
+          <van-icon v-if="props.closeable" name="cross" @click.stop="panelHeight = panelProps.anchors[0]" />
+        </div>
+      </div>
+    </template>
+    <Transition>
+      <div class="panel-content">
+        <img class="size-full object-contain" v-if="panelHeight === panelProps.anchors[1] || panelHeight > panelProps.anchors[0] * 1.5" :src="url" alt="小程序码" />
+        <div class="flex justify-center items-center" v-else @click="panelOpen()">
+          <img class="h-10 w-10" src="@/assets/images/mini-program.svg" alt="小程序" />
+          <span class="text-lg ml-2">小程序</span>
+        </div>
+      </div>
+    </Transition>
+  </van-floating-panel>
+</template>
+
+<style scoped lang="scss">
+.nav-img {
+  margin: auto;
+  width: 36px;
+  height: 36px;
+  object-fit: scale-down;
+}
+
+.panel-mask {
+  --van-overlay-background: transparent;
+}
+
+.panel-content {
+  padding: 0 var(--van-floating-panel-header-height) var(--van-floating-panel-header-height);
+}
+</style>

+ 212 - 0
src/components/PickerDialog.vue

@@ -0,0 +1,212 @@
+<script setup lang="ts">
+import type { Option } from '@/request/model';
+import { tryOnBeforeMount, tryOnUnmounted } from '@vueuse/core';
+import type { DialogProps } from 'vant';
+
+export interface Props {
+  options: Option[];
+  title?: string;
+  multiple?: boolean;
+  /* 单选点击选项直接确定 */
+  immediate?: boolean;
+  selected?: (string | EditableOption)[];
+}
+
+type EditableOption = Option & { inputValue?: string; showLabel?: string };
+
+let cache: WeakMap<Option[], EditableOption[]>;
+const onCleanup = () => {
+  cache = new WeakMap();
+};
+
+const props = defineProps<Props>();
+const emits = defineEmits<{ closed: []; selected: [{ label: string; value: string }[]] }>();
+const show = defineModel('show', { default: false });
+
+const options = ref<EditableOption[]>([]);
+const dialog = reactive<Partial<DialogProps>>({
+  showCancelButton: true,
+});
+
+watchEffect(() => {
+  dialog.title = props.title ?? void 0;
+  dialog.showConfirmButton = props.multiple || !props.immediate;
+});
+const selectedInput = useTemplateRef<{ focus(): void; $el: HTMLInputElement; }>('input');
+const selectedOptions = ref<EditableOption>();
+const dialogConfirmHandle = (emit?: boolean) => {
+  if (selectedOptions.value && !emit) {
+    const option = selectedOptions.value;
+    if (!option.inputValue) option.inputValue = option.showLabel;
+    if (!option.inputValue || option.inputValue === option.label) return dialogCancelHandle();
+
+    selectedOptions.value.showLabel = option.inputValue;
+    selectedOptions.value.checked = true;
+    if (props.immediate) handle(selectedOptions.value, true);
+    else selectedOptions.value = void 0;
+  } else {
+    emits('selected', options.value.filter((option) => option.checked).map((option) => ({
+      label: option.showLabel ?? option.label,
+      value: option.editable ? `${option.value}:${option.showLabel ?? option.label}` : option.value,
+    })));
+    show.value = false;
+  }
+};
+const dialogCancelHandle = () => {
+  if (selectedOptions.value) {
+    dialog.showConfirmButton = props.multiple || !props.immediate;
+    delete dialog.confirmButtonText;
+    delete dialog.cancelButtonText;
+    const option = selectedOptions.value;
+    selectedOptions.value.inputValue = option.showLabel !== option.label ? option.showLabel : ``;
+    selectedOptions.value.checked = false;
+    selectedOptions.value = void 0;
+  } else {
+    show.value = false;
+  }
+};
+const dialogOpenHandle = (select?: EditableOption[]) => {
+  select ??= (props.selected?.map((option) => {
+    if (typeof option === 'string') {
+      const [value, inputValue] = option.split(':');
+      if (!value) return false;
+      option = { value, inputValue } as EditableOption;
+    }
+    return option;
+  })?.filter(Boolean) as EditableOption[]) ?? [];
+
+  const value = props.options;
+  if (!cache.has(value)) cache.set(value, value.map((item) => Object.assign({ inputValue: '', showLabel: item.label }, item)));
+  const values = cache.get(value)!;
+
+  if (select.length) for (const option of values) option.checked = false;
+  for (const item of select) {
+    if (!item) continue;
+    const option = values.find((v) => v.value === item.value);
+    if (!option) continue;
+    if (option.editable) {
+      option.inputValue = item.inputValue;
+      option.showLabel = item.inputValue ?? option.label;
+    }
+    option.checked = true;
+  }
+
+  options.value = values;
+  show.value = true;
+};
+const dialogClosedHandle = () => {
+  emits('closed');
+  selectedOptions.value = void 0;
+};
+const handle = (option: EditableOption, checked?: boolean) => {
+  if (option.disabled) return;
+
+  if (option.editable && checked == null) {
+    selectedOptions.value = option;
+    dialog.showConfirmButton = true;
+    dialog.confirmButtonText = '确认';
+    dialog.cancelButtonText = '返回';
+    return nextTick(() => {
+      /*try { selectedInput.value?.$el?.querySelector('input')?.select(); } catch (e) {}*/
+      try { selectedInput.value?.focus?.(); } catch (e) {}
+    })
+  }
+
+  checked ??= !option.checked;
+  if (checked) {
+    if (!props.multiple || option.single) for (const option of options.value) option.checked = false;
+    else {
+      const single = options.value.find((option) => option.single);
+      if (single) single.checked = false;
+    }
+  }
+
+  option.checked = checked;
+  if (props.immediate) setTimeout(() => dialogConfirmHandle(true), 100);
+};
+
+tryOnBeforeMount(() => onCleanup());
+tryOnUnmounted(() => onCleanup());
+defineExpose({ onCleanup });
+</script>
+
+<template>
+  <div>
+    <slot :handle="dialogOpenHandle"></slot>
+    <van-dialog
+      class="sub-dialog scrollable"
+      :show="show"
+      v-bind="dialog"
+      @confirm="dialogConfirmHandle"
+      @cancel="dialogCancelHandle"
+      @open="dialogOpenHandle"
+      @closed="dialogClosedHandle"
+    >
+      <template v-if="selectedOptions">
+        <van-cell-group inset class="form-wrapper">
+          <van-field ref="input" v-model="selectedOptions.inputValue" :placeholder="selectedOptions.showLabel" maxlength="10" />
+        </van-cell-group>
+      </template>
+      <template v-else>
+        <slot name="options" :options="options" :handle="handle">
+          <div class="grid grid-rows-1 grid-cols-2 gap-4 py-4 px-12">
+            <div
+              v-for="(option, index) in options"
+              :key="option.value"
+              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, disabled: option.disabled }"
+              @click="handle(option)"
+            >
+              <div class="p-2 text-center">{{ option.showLabel }}</div>
+            </div>
+          </div>
+        </slot>
+      </template>
+    </van-dialog>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.form-wrapper {
+  .van-field {
+    margin: 0;
+    padding: 0;
+  }
+
+  :deep(.van-field__control) {
+    margin-bottom: 24px;
+    padding: 8px;
+    border: 1px solid #38ff6e;
+    border-radius: 8px;
+    text-align: center;
+  }
+}
+
+.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;
+
+  &.scrollable {
+    max-height: 80vh;
+    display: flex;
+    flex-direction: column;
+
+    .van-dialog__header {
+      padding-bottom: var(--van-dialog-header-padding-top);
+    }
+
+    .van-dialog__content {
+      flex: auto;
+      overflow-y: auto;
+    }
+  }
+}
+</style>

+ 60 - 0
src/computable/useRouteNext.ts

@@ -0,0 +1,60 @@
+import { tryOnBeforeMount } from '@vueuse/core';
+
+import { useRequest } from 'alova/client';
+import { processMethod } from '@/request/api';
+import type { Flow, FlowRoute } from '@/request/model';
+
+import { useVisitor } from '@/stores';
+
+export interface RouteNextOption {
+  immediate: boolean;
+
+  onSuccess(flow: FlowRoute): void | Promise<void>;
+
+  onError(error: Error | { message: string }): void;
+
+  onComplete(): void;
+}
+
+export function useRouteNext(option?: Partial<RouteNextOption>) {
+  const router = useRouter();
+  const path = computed(() => router.currentRoute.value.path);
+
+  const flow = shallowRef<FlowRoute>();
+
+  const { send, loading } = useRequest(processMethod, { immediate: false }).onSuccess(({ data, args: [key] }) => {
+    flow.value = data.parse(key);
+  });
+
+  const handle = async () => {
+    try {
+      await send(path.value);
+      loading.value = true;
+      await option?.onSuccess?.(flow.value!);
+    } catch (error) {
+      flow.value = void 0;
+      option?.onError?.(<{ message: string }>error);
+    }
+    option?.onComplete?.();
+    loading.value = false;
+    return flow.value;
+  };
+
+  tryOnBeforeMount(() => {
+    if (option?.immediate) {
+      const block = () => void 0;
+      send(path.value).then(block, block);
+    }
+  });
+
+  return { handle, flow, loading };
+}
+
+export function getRoutePath(flow?: Flow) {
+  let route = flow?.route;
+  if (!route) throw { message: `[路由] 页面未找到` };
+  if (route === '/report') route += `/${useVisitor().reportId}`;
+  if (route === '/scheme') route += `/${useVisitor().reportId}`;
+
+  return route;
+}

+ 3 - 3
src/loader/bridge.loader.ts

@@ -1,5 +1,5 @@
-import { processMethod, scanAccountMethod } from '@/request/api';
-import router                               from '@/router';
+import { getNextProcess, scanAccountMethod } from '@/request/api';
+import router                                from '@/router';
 
 import { platformIsAIO, platformIsAIO_1 } from '@/platform';
 
@@ -46,7 +46,7 @@ export default function bridgeLoader(): DEV.Loader {
           const data = await scanAccountMethod(detail.data.code).catch(() => {});
 
           if (data) {
-            const path = data?.path ?? (route?.path === '/screen' ? await processMethod() : route?.path);
+            const path = data?.path ?? (route?.path === '/screen' ? await getNextProcess().catch(() => route?.path) : route?.path);
             const key = Date.now();
             sessionStorage.setItem(`scan_${key}`, JSON.stringify(data));
             await router.replace({ path, query: { scan: key } });

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

@@ -0,0 +1,151 @@
+<script setup lang="ts">
+import { Notify } from '@/platform';
+import { tryOnBeforeMount, tryOnUnmounted, useCountdown } from '@vueuse/core';
+
+import { getAlcoholReportMethod } from '@/request/api';
+import type { Flow, FlowRoute } from '@/request/model';
+import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
+import { useRouteMeta } from '@/router/hooks/useRouteMeta';
+
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+import alcohol_0 from '@/assets/images/alcohol-0.png?url';
+import alcohol_1 from '@/assets/images/alcohol-1.png?url';
+
+const router = useRouter();
+const actionText = computed(() => `获取健康调理方案`);
+/* 倒计时完成动作 */
+const done = shallowRef<Flow>();
+/* 下一动作可选 */
+const next = shallowRef<Flow>();
+
+const title = useRouteMeta('title');
+const { handle, flow, loading } = useRouteNext({
+  onSuccess(flow) { return load(flow); },
+  onError(error) { Notify.warning(error.message); },
+});
+
+const { remaining, start, stop } = useCountdown(5, {
+  onComplete() { replace(done.value!); },
+  immediate: false,
+});
+const countdown = computed(() => remaining.value.toString().padStart(2, '0'));
+
+tryOnBeforeMount(() => handle());
+tryOnUnmounted(() => stop());
+
+const report = ref<Awaited<ReturnType<typeof getAlcoholReportMethod>>>();
+const tips = '建议您每日饮酒';
+const description = computed(() => report.value?.alcohol?.description
+  ?.replace?.(new RegExp(`^${tips}`), '')
+  ?.replace?.(/(\S)\s*(或)/g, '$1\n$2')
+  ?.replace?.(/(\{[^}]*})/g, "\n$1\n")
+  ?.replace?.(/(\[[^\]]*])/g, "\n$1\n")
+  ?.replace?.(/(\([^)]*\))/g, "\n$1\n")
+  ?.replace?.(/(([^)]*))/g, "\n$1\n")
+  ?.replace?.(/([((\[][^))\]]*\))/g, "\n$1\n")
+  ?? ''
+);
+
+async function load(flow: FlowRoute) {
+  stop();
+  report.value = await getAlcoholReportMethod();
+
+  done.value = flow.next.optional ? { title: '返回首页', route: '/screen' } : flow.next;
+  next.value = flow.next.optional ? flow.next : void 0;
+  start(report.value?.alcohol?.description ? 60 : 5);
+}
+
+const replace = (flow: Flow) => router.push({ path: getRoutePath(flow), replace: true });
+</script>
+<template>
+  <div>
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
+          {{ flow?.value.title ?? title }}
+        </div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col">
+      <van-toast v-if="loading" show type="loading" message="加载中" />
+      <template v-else>
+        <header>
+          <div class="my-6 text-primary text-2xl text-center" v-if="report?.date">报告日期:{{ report.date }}</div>
+        </header>
+        <main class="flex flex-col justify-evenly">
+          <div class="report-wrapper" v-if="report">
+            <div class="card m-6 text-lg" v-if="report.alcohol?.condition">
+              <div v-if="title" class="card__title mb-3 text-primary text-2xl font-bold">您的情况为</div>
+              <div class="card__content">
+                <div class="text-center text-4xl font-bold pre" style="letter-spacing: 4px">
+                  {{ report.alcohol?.condition }}
+                </div>
+              </div>
+            </div>
+            <div class="card m-6 text-lg">
+              <div v-if="title" class="card__title mb-3 text-primary text-2xl">{{ tips }}</div>
+              <div class="card__content">
+                <div class="flex items-center justify-center min-h-32" v-if="report.alcohol?.description">
+                  <div class="text-center text-5xl font-semibold whitespace-pre" style="letter-spacing: 4px; line-height: 1.25em">
+                    {{ description }}
+                  </div>
+                  <img v-if="report.alcohol?.volume?.length" class="image-container object-scale-down" :src="alcohol_1" alt="可饮酒" />
+                </div>
+                <van-empty v-else :image="alcohol_0" image-size="160" description="暂无建议" />
+              </div>
+            </div>
+          </div>
+        </main>
+        <footer class="flex flex-col justify-center items-center gap-4">
+          <van-button v-if="next" class="decorate !text-xl" @click="replace(next)">
+            {{ next.title ?? actionText }}
+          </van-button>
+          <van-button v-if="done" class="decorate !text-xl !text-primary-400" @click="replace(done)">
+            {{ done.title ?? actionText }}
+            <template v-if="remaining">({{ countdown }}s)</template>
+          </van-button>
+        </footer>
+      </template>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+header {
+  flex: 1 1 10%;
+}
+
+footer {
+  flex: 1 1 30%;
+}
+
+main {
+  position: relative;
+  flex: 1 1 50%;
+}
+
+.image-container {
+  width: 30vw;
+  height: 30vw;
+  max-width: 216px;
+  max-height: 216px;
+}
+
+.card__content {
+  --van-empty-description-color: #fff;
+  --van-empty-description-margin-top: 36px;
+  --van-empty-description-font-size: 18px;
+}
+</style>
+<style scoped lang="scss">
+.report-wrapper .card {
+  padding: 24px;
+  border-radius: 24px;
+  box-shadow: inset 0 0 80px 0 #34a76b60;
+}
+</style>

+ 33 - 36
src/modules/camera/camera-result.page.vue

@@ -1,46 +1,42 @@
 <script setup lang="ts">
-import { processMethod2 } from '@/request/api';
-import { useRequest } from 'alova/client';
+import { tryOnBeforeMount, tryOnUnmounted, useCountdown } from '@vueuse/core';
 import { useRouter } from 'vue-router';
 
+import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
+import type { Flow } from '@/request/model';
+
+import { Notify } from '@/platform';
+
 defineOptions({
   name: 'CameraResult',
 });
 
-const router = useRouter();
+const actionText = computed(() => `获取健康调理方案`);
+/* 倒计时完成动作 */
+const done = shallowRef<Flow>();
+/* 下一动作可选 */
+const next = shallowRef<Flow>();
 
-const { data, loading } = useRequest(processMethod2).onSuccess(({ data }) => {
-  init(data.optional ? 10 : 5);
+const { handle, loading } = useRouteNext({
+  onSuccess(flow) {
+    done.value = flow.next.optional ? { title: '返回首页', route: '/screen' } : flow.next;
+    next.value = flow.next.optional ? flow.next : void 0;
+    start(flow.next.optional ? 10 : 5);
+  },
+  onError(error) { Notify.warning(error.message); },
 });
-const tips = computed(() => (data.value.route === '/screen' || data.value.optional ? '返回首页' : '获取健康调理方案'));
-const countdown = ref(5);
-
-let timer: ReturnType<typeof setInterval>;
 
-function done() {
-  if (!data.value.optional) next();
-  else {
-    clearInterval(timer);
-    router.replace({ path: '/screen' });
-  }
-}
-
-function next() {
-  clearInterval(timer);
-  router.replace({ path: data.value.route });
-}
+const { remaining, start, stop } = useCountdown(5, {
+  onComplete() { replace(done.value!); },
+  immediate: false,
+});
+const countdown = computed(() => remaining.value.toString().padStart(2, '0'));
 
-function init(value = 5) {
-  countdown.value = value;
-  timer = setInterval(() => {
-    const _countdown = countdown.value - 1;
-    if ( _countdown <= 0 ) { done(); } else { countdown.value = _countdown; }
-  }, 1000);
-}
+tryOnBeforeMount(() => handle());
+tryOnUnmounted(() => stop());
 
-onBeforeUnmount(() => {
-  clearInterval(timer);
-});
+const router = useRouter();
+const replace = (flow: Flow) => router.push({ path: getRoutePath(flow), replace: true });
 </script>
 <template>
   <div>
@@ -55,13 +51,14 @@ onBeforeUnmount(() => {
           <div class="text-3xl text-center my-8">完成舌面象的采集</div>
         </div>
       </main>
-      <footer class="flex flex-col items-center" style="flex: 1 1 30%">
+      <footer class="flex flex-col items-center gap-4" style="flex: 1 1 30%">
         <template v-if="!loading">
-          <van-button v-if="data.optional" class="decorate !text-xl !text-primary-400 !mb-6" :loading @click="next()">
-            {{ data.title || '获取健康调理方案' }}
+          <van-button v-if="next" class="decorate !text-xl" @click="replace(next)">
+            {{ next.title ?? actionText }}
           </van-button>
-          <van-button class="decorate !text-xl !text-primary-400" :loading @click="done()">
-            {{ tips }}({{ countdown }})
+          <van-button v-if="done" class="decorate !text-xl !text-primary-400" @click="replace(done)">
+            {{ done.title ?? actionText }}
+            <template v-if="remaining">({{ countdown }}s)</template>
           </van-button>
         </template>
       </footer>

+ 19 - 1
src/modules/camera/camera.page.vue

@@ -6,6 +6,7 @@ import { useForm, useRequest }              from 'alova/client';
 import Segmented, { type ConfigProps }      from './camera.config';
 import Camera                               from './camera.vue';
 
+import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
 import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
 
 
@@ -13,13 +14,30 @@ let audio: HTMLAudioElement | void;
 
 const router = useRouter();
 
+const { flow } = useRouteNext({ immediate: true });
 const { form: dataset, loading: submitting, send: submit } = useForm(data => saveFileMethod(data), {
   initialForm: { } as Record<string, any>,
-}).onSuccess(({ data }) => router.replace(data.route)).onError(() => {
+}).onSuccess(({ data }) => replace()).onError(() => {
   handle();
   step.value = 1;
 });
 
+const replace = async () => {
+  let next = flow.value?.next;
+
+  if (next?.optional) {
+    const action = await Dialog.show({
+      title: next.title || '获取健康调理方案',
+      confirmButtonText: '好的',
+      showCancelButton: true,
+      cancelButtonText: '返回首页',
+      width: 350,
+    }).catch((err) => err)
+    if (action === 'cancel') next = { title: '返回首页', route: '/screen' };
+  }
+  return router.push({ path: getRoutePath(next), replace: true });
+}
+
 
 const step = ref(0);
 const snapshot = ref<string | void>();

+ 121 - 0
src/modules/pulse/pulse-result.page.vue

@@ -0,0 +1,121 @@
+<script setup lang="ts">
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+import NavNextSelect from '@/assets/images/next-step.svg?url';
+import { Dialog, Notify } from '@/platform';
+
+import { useRouteQuery } from '@vueuse/router';
+import { useRequest } from 'alova/client';
+import { useRouter } from 'vue-router';
+import { getPulseMethod } from '@/request/api/pulse.api';
+import type { Flow, FlowRoute } from '@/request/model';
+import { useRouteNext } from '@/computable/useRouteNext';
+
+const id = useRouteQuery<string>('id');
+
+const { handle, loading } = useRouteNext({
+  onSuccess(flow) { return load(flow); },
+  onError(error) { Notify.warning(error.message); },
+});
+
+const { data, error, send } = useRequest(getPulseMethod, { immediate: false, initialData: { date: '', pulse: {}, } });
+
+const back = shallowRef<Flow>();
+const next = shallowRef<Flow>();
+
+async function load(flow: FlowRoute) {
+  back.value = flow.next.optional ? { title: '返回首页', route: '/screen' } : void 0;
+  next.value = flow.next.route !== '/screen' ? flow.next : void 0;
+
+  if (id.value) await send(id.value);
+  else { /* 从 Visitor 中获取 */ }
+}
+
+watch(id, (value, oldValue) => { if (value !== oldValue || value == null) handle(); }, { immediate: true });
+
+const router = useRouter();
+const replace = (flow: Flow) => router.replace(flow.route);
+
+const scrollable = computed(() => true);
+</script>
+<template>
+  <div class="report-wrapper">
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">脉象分析报告</div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col overflow-hidden">
+      <van-skeleton class="flex-auto" v-if="!error" title :row="3" :loading>
+        <div class="flex-auto" :class="{ 'overflow-y-auto': scrollable }">
+          <div class="my-6 text-primary text-2xl text-center" v-if="data.date">报告日期:{{ data.date }}</div>
+          <AnalysisPulseComponent title="脉象分析" v-bind="data.pulse"></AnalysisPulseComponent>
+        </div>
+      </van-skeleton>
+      <div class="flex-none flex justify-between py-2 nav-wrapper" style="background-color: #12312c" v-if="next || back">
+        <div v-if="back" class="m-auto min-w-16 text-center hover:text-primary" @click="replace(back)">
+          <img :src="NavHomeSelect" :alt="back.title" />
+          <div class="mt-2">{{ back.title }}</div>
+        </div>
+        <div v-if="next" class="m-auto min-w-16 text-center hover:text-primary" @click="replace(next)">
+          <img :src="NavNextSelect" :alt="next.title" />
+          <div class="mt-2">{{ next.title ?? `获取健康调理方案` }}</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+.van-button.decorate {
+  font-size: 20px;
+  height: 62px;
+  width: 240px;
+  background-size: 80%;
+  letter-spacing: 2px;
+}
+
+.text-grey {
+  color: #e3e3e3;
+}
+
+.report-wrapper {
+  .panel-content {
+    padding: 0 var(--van-floating-panel-header-height) var(--van-floating-panel-header-height);
+  }
+
+  .v-enter-active,
+  .v-leave-active {
+    transition: opacity 0.5s ease;
+  }
+
+  .v-enter-from,
+  .v-leave-to {
+    opacity: 0;
+  }
+}
+
+.overflow-y-auto {
+  overflow-y: auto;
+}
+</style>
+<style lang="scss">
+.report-wrapper .card {
+  padding: 24px;
+  border-radius: 24px;
+  box-shadow: inset 0 0 80px 0 #34a76b60;
+}
+
+.nav-wrapper {
+  img {
+    margin: auto;
+    width: 36px;
+    height: 36px;
+    object-fit: scale-down;
+  }
+}
+</style>

+ 62 - 136
src/modules/pulse/pulse.page.vue

@@ -1,115 +1,78 @@
 <script setup lang="ts">
 import { Notify } from '@/platform';
-import { tryOnMounted, tryOnUnmounted } from '@vueuse/core';
+import { tryOnBeforeMount, tryOnUnmounted, useCountdown } from '@vueuse/core';
 
 import { useRequest } from 'alova/client';
 import { putPulseMethod } from '@/request/api/pulse.api';
-import { processMethod3 } from '@/request/api';
-import type { Flow } from '@/request/model';
+import type { Flow, FlowRoute } from '@/request/model';
+import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
 
 import { useVisitor } from '@/stores';
 
 import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
-import HandLeft from '@/assets/images/pulse-hand-left.png?url';
-import HandRight from '@/assets/images/pulse-hand-right.png?url';
 
 const router = useRouter();
 const Visitor = useVisitor();
 
-const pending = ref(false);
 const finished = ref(false);
 const supported = ref(true);
+const actionText = computed(() => supported.value ? `获取健康调理方案` : `下一步`);
+/* 倒计时完成动作 */
+const done = shallowRef<Flow>();
+/* 下一动作可选 */
+const next = shallowRef<Flow>();
+
+const { handle, loading } = useRouteNext({
+  onSuccess(flow) { return pulse(flow); },
+  onError(error) { Notify.warning(error.message); },
+});
+
+const { remaining, start, stop } = useCountdown(5, {
+  onComplete() { replace(done.value!); },
+  immediate: false,
+});
+const countdown = computed(() => remaining.value.toString().padStart(2, '0'));
 
-async function handle() {
-  if (pending.value) return;
-  pending.value = true;
-  clearInterval(timer);
+tryOnBeforeMount(() => handle());
+tryOnUnmounted(() => stop());
 
-  const patientId = Visitor.patientId;
+async function pulse(flow: FlowRoute) {
+  stop();
+  const showReport = flow.next.route === '/pulse/result';
   try {
-    await load();
+    const patientId = Visitor.patientId;
     const result = await Bridge.pulse(patientId!!);
     await submit(patientId, result);
-  } catch (e: any) {
-    let message = e.message;
+    if (showReport) await replace(flow.next);
+    else {
+      finished.value = true;
+      done.value = flow.next.optional ? { title: '返回首页', route: '/screen' } : flow.next;
+      next.value = flow.next.optional ? flow.next : void 0;
+      start(10);
+    }
+  } catch (e) {
+    let message;
+    let countdown;
+
     if (e instanceof ReferenceError) {
       supported.value = false;
-      message = '当前环境不支持脉诊设备,请联系管理员';
-    }
-    if (!supported.value || process.value?.current?.optional) {
-      done.value = next.value?.optional
-        ? { ...next.value, countdown: 5 }
-        : {
-            title: '返回首页',
-            route: '/screen',
-            countdown: 5,
-          };
-      next.value = void 0;
-      start();
+      message = `当前环境不支持脉诊设备,请联系管理员!`;
+      countdown = 5;
     } else {
-      done.value = void 0;
-      message = '请再次测量脉诊';
+      message = `请再次测量脉诊!`;
+      countdown = 10;
     }
+    done.value = flow.value.optional && !showReport ? flow.next : { title: '返回首页', route: '/screen' };
+    start(countdown);
     Notify.warning(message);
   }
-  pending.value = false;
 }
 
-const done = shallowRef<Partial<Flow> & { countdown: number }>();
-const next = shallowRef<Partial<Flow>>();
-
-const {
-  data: process,
-  loading,
-  send: load,
-} = useRequest(processMethod3, { immediate: false }).onSuccess(({ data }) => {
-  if (data.next.route === '/screen') {
-    done.value = { title: '返回首页', route: '/screen', countdown: 30 };
-    next.value = void 0;
-  } else if (data.next.route === '/pulse/result') {
-    done.value = data.next.optional ? { title: '返回首页', route: '/screen', countdown: 10 } : void 0;
-    next.value = { title: data.next.title || '查看报告', route: data.next.route };
-  } else {
-    done.value = { title: data.next.title || '获取健康调理方案', route: data.next.route, countdown: 10 };
-    next.value = void 0;
-  }
+const { data: report, loading: submitting, send: submit, } = useRequest((id, result) => putPulseMethod(id, result), { immediate: false }).onSuccess(({ data }) => {
+  Visitor.updatePulseReport(data);
 });
 
-const {
-  data: report,
-  loading: submitting,
-  send: submit,
-} = useRequest((id, result) => putPulseMethod(id, result), { immediate: false }).onSuccess(() => {
-  finished.value = true;
-  start();
-});
-
-let timer: ReturnType<typeof setInterval>;
-const countdown = ref(5);
-
-function start(value?: number) {
-  if (!done.value) return;
-  countdown.value = value ?? done.value.countdown ?? 3;
-  timer = setInterval(() => {
-    const _countdown = countdown.value - 1;
-    if (_countdown <= 0) {
-      replace(done.value?.route);
-    } else {
-      countdown.value = _countdown;
-    }
-  }, 1000);
-}
-
-function replace(path: string = '/screen') {
-  return router.replace({ path });
-}
-
-tryOnMounted(() => {
-  setTimeout(() => handle(), 300);
-});
-tryOnUnmounted(() => {
-  clearInterval(timer);
-});
+const replace = (flow: Flow) => router.push({ path: getRoutePath(flow), replace: true });
 </script>
 <template>
   <div>
@@ -128,70 +91,33 @@ tryOnUnmounted(() => {
     </div>
     <div class="page-content flex flex-col">
       <header></header>
-      <main class="flex flex-col justify-evenly px-8">
+      <main class="flex flex-col justify-evenly">
         <template v-if="finished && report">
           <img class="size-40 mx-auto" src="@/assets/images/tips.png" alt="" />
           <div>
             <div class="text-3xl text-center">恭喜您!</div>
             <div class="text-3xl text-center my-8">完成脉诊采集</div>
           </div>
-          <div v-if="report">
-            <div v-if="report.summaryLabel" class="flex justify-evenly">
-              <div v-if="report.summaryLabel?.left" class="flex flex-row-reverse justify-center">
-                <img style="width: 100px;height: 200px;" :src="HandLeft" alt="左手" />
-                <div class="flex flex-col justify-evenly translate-y-2 h-40 text-xl">
-                  <div>
-                    寸:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.cun }}</span>
-                  </div>
-                  <div>
-                    关:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.guan }}</span>
-                  </div>
-                  <div>
-                    尺:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.chi }}</span>
-                  </div>
-                </div>
-              </div>
-              <div v-if="report.summaryLabel?.right">
-                <div class="flex justify-center">
-                  <img style="width: 100px;height: 200px;"  :src="HandRight" alt="右手" />
-                  <div class="flex flex-col justify-evenly translate-y-2 h-40 text-xl">
-                    <div>
-                      寸:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.cun }}</span>
-                    </div>
-                    <div>
-                      关:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.guan }}</span>
-                    </div>
-                    <div>
-                      尺:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.chi }}</span>
-                    </div>
-                  </div>
-                </div>
-              </div>
-            </div>
-            <p v-if="report.results" class="text-2xl text-center">
-              总体脉象:<span class="text-primary-400" style="letter-spacing: 4px">{{ report.results }}</span>
-            </p>
-          </div>
+          <AnalysisPulseComponent title="" v-bind="report" disabled>
+            <template #exception>
+              <div><!--空占位符--></div>
+            </template>
+          </AnalysisPulseComponent>
         </template>
       </main>
       <footer class="flex flex-col justify-center items-center">
-        <van-button
-          v-if="!pending && finished && next"
-          class="decorate !text-xl !text-primary-400"
-          @click="replace(next.route)"
-        >
-          {{ next.title }}
-        </van-button>
-        <van-button
-          v-if="!pending && done"
-          class="decorate !text-xl !text-primary-400 !mb-6"
-          @click="replace(done.route)"
-        >
-          {{ done.title }}({{ countdown }})
-        </van-button>
-        <div v-if="supported && !finished" class="van-button decorate" @click="handle()">
+        <template v-if="!loading">
+          <van-button v-if="next" class="decorate !text-xl" @click="replace(next)">
+            {{ next.title ?? actionText }}
+          </van-button>
+          <van-button v-if="done" class="decorate !text-xl !text-primary-400" @click="replace(done)">
+            {{ done.title ?? actionText }}
+            <template v-if="remaining">({{ countdown }}s)</template>
+          </van-button>
+        </template>
+        <div v-if="supported && !finished" class="van-button decorate" @click="!loading && handle()">
           <div class="van-button__content">
-            <van-loading v-if="loading || pending || submitting" />
+            <van-loading v-if="loading || submitting" />
             <span v-else class="van-button__text">连接脉诊</span>
           </div>
         </div>

+ 4 - 2
src/modules/questionnaire/page.vue

@@ -2,6 +2,7 @@
 import { Dialog, Notify, Toast } from '@/platform';
 import { questionnaireMethod } from '@/request/api';
 import type { QuestionnaireProps } from '@/request/model';
+import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
 
 import TierSelectField from './TierSelect.field.vue';
 
@@ -42,12 +43,13 @@ function handle() {
   }
 }
 
+const { loading: nextLoading, flow } = useRouteNext({ immediate: true });
 async function load() {
   const _first = first.value;
   loading.value = true;
   try {
     const { reportId, questionnaires } = await questionnaireMethod(<any>data.value);
-    if (reportId) return await router.replace(`/report/${reportId}`);
+    if (reportId) return await router.push({ path: getRoutePath(flow.value?.next!), replace: true });
     showTitle.value = _first;
     data.value = [];
     // TODO 延迟渲染
@@ -100,7 +102,7 @@ load();
             :disabled="loading"
           />
         </div>
-        <van-button class="decorate" block :loading @click="handle()">提交</van-button>
+        <van-button class="decorate" block :loading :disabled="nextLoading" @click="handle()">提交</van-button>
       </template>
       <van-toast v-else-if="first" :show="loading" type="loading" message="加载中" />
     </div>

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

@@ -55,6 +55,8 @@ function onPlay(event?: Event) {
     <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" />
+        <video class="size-full object-scale-down" v-else-if="item.type === 'video'" :src="item.url" preload="metadata"></video>
+        <div class="size-full object-scale-down w-64 bg-gray-600" v-else></div>
         <van-icon class="play" v-if="item.type === 'video'" name="play-circle-o" />
       </div>
       <template v-if="item.title">

+ 11 - 15
src/modules/report/report-analyse.page.vue

@@ -1,9 +1,11 @@
 <script setup lang="ts">
-import NavHomeSelect                               from '@/assets/images/nav-home.select.png?url';
-import { Dialog }                                  from '@/platform';
-import { getAnalysisResultsMethod, processMethod } from '@/request/api';
-import { useRequest }                              from 'alova/client';
-import { useRouter }                               from 'vue-router';
+import { useRouter } from 'vue-router';
+import { useRequest } from 'alova/client';
+import { getAnalysisResultsMethod } from '@/request/api';
+import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
+
+import { Dialog } from '@/platform';
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
 
 
 const router = useRouter();
@@ -37,20 +39,14 @@ const panelOpen = (min?: number) => {
   panelHeight.value = panelProps.anchors[1];
 };
 
-const scrollable = computed(() => !data.value.payLock &&
-                                  panelHeight.value < panelProps.anchors[ 1 ] || panelHeight.value === 0,
-);
-
+const scrollable = computed(() => !data.value.payLock && panelHeight.value < panelProps.anchors[ 1 ] || panelHeight.value === 0);
 
-const { data: path, loading: nextLoading } = useRequest(processMethod).onSuccess((data) => {
-  console.log(data);
-});
+const { flow, loading: nextLoading } = useRouteNext({ immediate: true });
 
-const showNext = computed(() => path.value && path.value !== '/screen');
+const showNext = computed(() => flow.value?.next.route && flow.value?.next.route !== '/screen');
 
 function next() {
-  console.log(path);
-  router.replace({ path: path.value });
+  router.push({ path: getRoutePath(flow.value?.next), replace: true })
 }
 </script>
 <template>

+ 17 - 59
src/modules/report/report.page.vue

@@ -6,13 +6,17 @@ import NavScheme         from '@/assets/images/nav-scheme.png?url';
 
 import PhysiqueChart                           from '@/modules/report/PhysiqueChart.vue';
 import SyndromeChart                           from '@/modules/report/SyndromeChart.vue';
-import { Notify, Toast } from '@/platform';
+import { Notify, platformIsAIO, Toast } from '@/platform';
 import { getReportMethod, updateReportMethod } from '@/request/api';
 
 import { useRouteParams }         from '@vueuse/router';
 import { useRequest, useWatcher } from 'alova/client';
 import { useRouter }              from 'vue-router';
 
+import MiniProgram from '@/components/MiniProgram.vue';
+const miniProgramRef = useTemplateRef<InstanceType<typeof MiniProgram>>('mini-program');
+
+const hidePulseExceptionTemplate = computed(() => platformIsAIO())
 
 const id = useRouteParams<string>('id');
 const { data, loading } = useWatcher(() => getReportMethod(id.value), [ id ], {
@@ -20,10 +24,13 @@ const { data, loading } = useWatcher(() => getReportMethod(id.value), [ id ], {
     descriptionsTable: { column: [], data: [] },
     tongue: {},
     face: {},
+    pulse: {},
   },
   immediate: true,
 }).onSuccess(({ data }) => {
-  if ( data?.miniProgramURL && data.payLock ) panelOpen(100);
+  if ( data?.miniProgramURL && data.payLock ) {
+    nextTick(() => miniProgramRef.value?.open());
+  }
 })
 
 const { loading: uploading, send: upload } = useRequest(() => updateReportMethod(id.value, data.value), {
@@ -70,36 +77,12 @@ async function print() {
   }
 }
 
-async function miniProgram() {
-  let url = data.value.miniProgramURL;
-  if ( !url ) {
-    Notify.warning(`未获取到小程序地址,请联系管理员或重试`);
-    return;
-  }
-  panelOpen();
-}
-
 const router = useRouter();
 
 function toggle() {
   const path = `${ router.currentRoute.value.fullPath }/scheme`.replace(/\/{2,}/g, '/');
   router.replace({ path });
 }
-
-const panelHeight = ref(0);
-const panelProps = reactive({
-  anchors: [0, window.innerWidth],
-  contentDraggable: false,
-  lockScroll: true,
-});
-const panelOpen = (min?: number) => {
-  if ( min ) panelProps.anchors[ 0 ] = min;
-  panelHeight.value = panelProps.anchors[1];
-};
-
-const scrollable = computed(() => !data.value.payLock &&
-                                  panelHeight.value < panelProps.anchors[ 1 ] || panelHeight.value === 0,
-);
 </script>
 <template>
   <div class="report-wrapper">
@@ -118,7 +101,7 @@ const scrollable = computed(() => !data.value.payLock &&
     </div>
     <div class="page-content flex flex-col overflow-hidden">
       <van-skeleton class="flex-auto" title :row="3" :loading>
-        <div class="flex-auto" :class="{ 'overflow-y-auto': scrollable }">
+        <div class="flex-auto overflow-y-auto">
           <div class="my-6 text-primary text-2xl text-center">报告日期:{{ data.date }}</div>
           <div class="card m-6 text-lg">
             <div class="card__title text-primary text-3xl font-bold"></div>
@@ -181,6 +164,9 @@ const scrollable = computed(() => !data.value.payLock &&
             </template>
             <template #exception><div><!--空占位符--></div></template>
           </AnalysisComponent>
+          <AnalysisPulseComponent title="脉象分析" v-bind="data.pulse" simple>
+            <template #exception v-if="hidePulseExceptionTemplate"><div><!--空占位符--></div></template>
+          </AnalysisPulseComponent>
           <div class="card m-6 text-lg" v-if="data['中医证素']?.length">
             <div class="card__title mb-3 text-primary text-2xl font-bold">中医证素</div>
             <div class="card__content">
@@ -211,45 +197,17 @@ const scrollable = computed(() => !data.value.payLock &&
       </van-skeleton>
       <div class="flex-none flex justify-between py-2 nav-wrapper" style="background-color: #12312c;">
         <div class="m-auto min-w-16 text-center hover:text-primary" v-if="data.scheme" @click="toggle()">
-          <img :src="NavScheme" alt="调理方案">
+          <img class="nav-img" :src="NavScheme" alt="调理方案">
           <div class="mt-2">调理方案</div>
         </div>
-        <div class="m-auto min-w-16 text-center hover:text-primary" v-if="data.miniProgramURL" @click="miniProgram()">
-          <img :src="NavMiniProgram" alt="小程序">
-          <div class="mt-2">小程序</div>
-        </div>
+        <mini-program ref="mini-program" :url="data.miniProgramURL" :closeable="!data.payLock"></mini-program>
         <div class="m-auto min-w-16 text-center hover:text-primary" @click="print()">
           <van-loading v-if="uploading" color="#38ff6e" style="font-size: 24px;" />
-          <img v-else :src="NavPrint" alt="打印">
+          <img class="nav-img" v-else :src="NavPrint" alt="打印">
           <div class="mt-2">打印</div>
         </div>
       </div>
       <Component :is="ReportPreview" v-bind="reportPreviewProps" v-model:show="reportPreviewProps.show"></Component>
-      <van-floating-panel v-model:height="panelHeight" v-bind="panelProps">
-        <template #header>
-          <div class="van-floating-panel__header !justify-between">
-            <div></div>
-            <div class="van-floating-panel__header-bar"></div>
-            <div>
-              <van-icon v-if="!data.payLock" name="cross" @click.stop="panelHeight = panelProps.anchors[0];" />
-            </div>
-          </div>
-        </template>
-        <Transition>
-          <div class="panel-content">
-            <img
-              class="size-full object-contain"
-              v-if="panelHeight === panelProps.anchors[1] || panelHeight > panelProps.anchors[0] * 1.5"
-              :src="data.miniProgramURL"
-              alt="小程序码"
-            />
-            <div class="flex justify-center items-center" v-else @click="panelOpen()">
-              <img class="h-10 w-10" src="@/assets/images/mini-program.svg" alt="小程序" />
-              <span class="text-lg ml-2">小程序</span>
-            </div>
-          </div>
-        </Transition>
-      </van-floating-panel>
     </div>
   </div>
 </template>
@@ -294,7 +252,7 @@ const scrollable = computed(() => !data.value.payLock &&
 }
 
 .nav-wrapper {
-  img {
+  .nav-img {
     margin: auto;
     width: 36px;
     height: 36px;

+ 133 - 9
src/modules/report/scheme.page.vue

@@ -7,21 +7,86 @@ import { useRouteParams }        from '@vueuse/router';
 import { useWatcher }            from 'alova/client';
 import { useRouter } from 'vue-router';
 
+import MiniProgram from '@/components/MiniProgram.vue';
 
+import type { SchemeGoodsProps } from '@/request/model';
+import { createReusableTemplate } from '@vueuse/core';
+import { Toast } from '@/platform';
+
+const miniProgramRef = useTemplateRef<InstanceType<typeof MiniProgram>>('mini-program');
+
+const route = useRoute();
 const id = useRouteParams<string>('id');
-const { data, loading } = useWatcher(() => getReportSchemeMethod(id.value), [ id ], {
+const { data, loading } = useWatcher(() => getReportSchemeMethod(id.value, !route.meta.toggle), [id], {
   initialData: {
     children: [],
   },
   immediate: true,
+}).onSuccess(({ data }) => {
+  if ( data?.miniProgramURL && data.payLock ) {
+    nextTick(() => miniProgramRef.value?.open());
+  }
 });
 
 const router = useRouter();
 
+const toggleable = computed(() => route.meta.toggle ?? true);
+
 function toggle() {
   const path = router.currentRoute.value.fullPath.replace('/scheme', '');
   router.replace({ path });
 }
+
+const { define: PreviewLinkSlot, reuse: ReusePreviewLink } = createReusableTemplate<{ src: string | void; complete?: Function; error?: Function }>();
+const panelConfig = reactive({
+  fullHeight: 0,
+  height: 0,
+  anchors: [0],
+  goods: void 0 as unknown as SchemeGoodsProps,
+  onError() {},
+  onComplete(event?: Event) {},
+});
+const container = useTemplateRef('container');
+const getHeightAndScrollTop = (value?: HTMLElement | string) => {
+  const el = typeof value === 'string' ? container.value?.querySelector<HTMLDivElement>(`#${value}`) : value;
+  if (!el) return true;
+  el.scrollIntoView({ behavior: 'instant', block: 'start' });
+  const rect = el.getBoundingClientRect();
+  const maxHeight = window.innerHeight - rect.top;
+  const height = maxHeight - rect.height;
+  panelConfig.anchors = [0, height, maxHeight];
+  panelConfig.height = height;
+  panelConfig.fullHeight = maxHeight;
+};
+async function openGoodsPanel(goods: SchemeGoodsProps, event?: Event | string) {
+  if (panelConfig.goods === goods) {
+    panelConfig.height = panelConfig.anchors[panelConfig.anchors.length - 1];
+    return;
+  }
+
+  if (goods.type === 'link') {
+    const toast = Toast.loading(500);
+    panelConfig.goods = goods;
+    panelConfig.onComplete = () => {
+      toast.close();
+      panelConfig.height = panelConfig.anchors[panelConfig.anchors.length - 1];
+    };
+    panelConfig.onError = () => {
+      Toast.error(`链接加载错误`)
+      panelConfig.height = 0;
+    };
+  } else {
+    Toast.warning(`暂不支持该操作 (${goods.type})`);
+    return;
+  }
+
+  if (getHeightAndScrollTop(typeof event === 'string' ? event : (event?.target as HTMLElement))) {
+    const height = window.innerHeight * 0.8;
+    panelConfig.anchors = [0, height];
+    panelConfig.height = height;
+    panelConfig.fullHeight = window.innerHeight;
+  }
+}
 </script>
 <template>
   <div>
@@ -41,15 +106,22 @@ function toggle() {
     <div class="page-content flex flex-col overflow-hidden">
       <!--{{ data }}-->
       <van-skeleton class="flex-auto" title :row="3" :loading>
-        <div class="flex-auto px-6 overflow-y-auto">
-          <div class="card my-6 text-lg" v-for="item in data.children" :key="item.id">
+        <div class="flex-auto px-6 overflow-y-auto" ref="container">
+          <div class="card my-6 text-lg" :id="item.id" 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>
+              <div class="my-4" :id="'T_' + card.id" v-for="card in item.children" :key="card.id">
+                <div class="relative" :class="{ 'has-link': card.goods }">
+                  <div class="text-xl text-center text-primary" v-if="card.title">{{ card.title }}</div>
+                  <van-button
+                    class="!absolute top-0 right-0" v-if="card.goods"
+                    type="primary" icon="cart-o" size="small" plain
+                    @click="openGoodsPanel(card.goods, 'T_' + card.id)"
+                  >{{ card.goods.label }}</van-button>
+                </div>
                 <SchemeMedia :media="card.media"></SchemeMedia>
                 <div v-if="card.description">{{ card.description }}</div>
-                <div v-for="(item,index) in card.descriptions ">
+                <div v-for="(item, index) in card.descriptions">
                   <span class="text-primary">【{{ item.title }}】</span>
                   <span v-html="item.description"></span>
                 </div>
@@ -58,13 +130,35 @@ function toggle() {
           </div>
         </div>
       </van-skeleton>
-      <div class="flex-none flex justify-between py-2 nav-wrapper" style="background-color: #12312c;">
-        <div class="m-auto min-w-16 text-center hover:text-primary" @click="toggle()">
-          <img :src="NavScheme" alt="健康报告">
+      <div class="flex-none flex justify-between py-2 nav-wrapper" style="background-color: #12312c">
+        <div v-if="toggleable" class="m-auto min-w-16 text-center hover:text-primary" @click="toggle()">
+          <img class="nav-img" :src="NavScheme" alt="健康报告" />
           <div class="mt-2">健康报告</div>
         </div>
+        <mini-program ref="mini-program" :url="data.miniProgramURL" :closeable="!data.payLock"></mini-program>
       </div>
     </div>
+
+    <PreviewLinkSlot v-slot="{ src, complete, error }">
+      <iframe v-if="src" :src="src" @load="complete!" @error="error!"></iframe>
+    </PreviewLinkSlot>
+    <van-floating-panel
+      ref="panel-wrapper-ref"
+      :class="{ full: panelConfig.height === panelConfig.fullHeight }"
+      :content-draggable="false"
+      :lock-scroll="true"
+      :anchors="panelConfig.anchors"
+      v-model:height="panelConfig.height"
+    >
+      <template #header>
+        <div class="van-floating-panel__header !justify-between">
+          <div></div>
+          <div class="van-floating-panel__header-bar"></div>
+          <van-icon class="pr-2" name="cross" @click="panelConfig.height = 0" />
+        </div>
+      </template>
+      <ReusePreviewLink v-if="panelConfig.goods?.type === 'link'" :src="panelConfig.goods?.value" :complete="panelConfig.onComplete"></ReusePreviewLink>
+    </van-floating-panel>
   </div>
 </template>
 <style scoped lang="scss">
@@ -85,4 +179,34 @@ function toggle() {
 .text-grey {
   color: #e3e3e3;
 }
+
+.nav-wrapper {
+  .nav-img {
+    margin: auto;
+    width: 36px;
+    height: 36px;
+    object-fit: scale-down;
+  }
+}
+
+.full {
+  --van-floating-panel-border-radius: 0;
+}
+
+.has-link {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  min-height: 40px;
+
+  > div + .van-button {
+    top: 4px !important;
+  }
+}
+
+iframe {
+  width: 100%;
+  height: 100%;
+  border: none;
+}
 </style>

+ 136 - 65
src/pages/register.page.vue

@@ -2,39 +2,64 @@
 import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
 import { Notify, Toast } from '@/platform';
 
-import {
-  getCaptchaMethod,
-  processMethod,
-  registerAccountMethod,
-  registerFieldsMethod,
-  searchAccountMethod,
-}                                     from '@/request/api';
-import type { Fields, RegisterModel } from '@/request/model';
-import { useRouteQuery }              from '@vueuse/router';
+import { getCaptchaMethod, registerAccountMethod, registerFieldsMethod, dictionariesMethod, searchAccountMethod } from '@/request/api';
+import type { Fields, Option, RegisterModel } from '@/request/model';
+import { useRouteQuery } from '@vueuse/router';
 
+import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
 import { useCaptcha, useRequest, useSerialRequest } from 'alova/client';
 
-import type { FormInstance }           from 'vant';
+import type { FormInstance } from 'vant';
 import { RadioGroup as vanRadioGroup } from 'vant';
-
-
-const { data: fields, loading } = useRequest(registerFieldsMethod);
+import PickerDialog from '@/components/PickerDialog.vue';
 
 const formRef = useTemplateRef<FormInstance>('register-form');
 const modelRef = ref<Partial<RegisterModel>>({ code: '' });
+const modelValueRef = ref<Partial<RegisterModel>>({});
+const model = computed(() => ({ ...modelRef.value, ...modelValueRef.value }));
+
+const { data: fields, loading } = useSerialRequest([dictionariesMethod, (dictionaries) => registerFieldsMethod(dictionaries)]).onSuccess(({ data }) => {
+  const sex = data.find((field) => field.name === 'sex');
+  if (sex) {
+    const unknown = (<any>sex).component?.options?.find((option: any) => option.value === '2');
+    modelRef.value.sex = unknown?.value;
+  }
+});
 
 const router = useRouter();
-const { loading: submitting, send: submit } = useSerialRequest([
-  data => registerAccountMethod(data),
-  () => processMethod(),
-], { immediate: false })
-  .onSuccess(({ data }) => {router.replace(data);})
-  .onError(({ error }) => Notify.warning(error.message));
+
+const { loading: submitting, send: submit } = useRequest(registerAccountMethod, { immediate: false }).onSuccess(({ data }) => {
+  submitting.value = true;
+  handle();
+});
+const { handle } = useRouteNext({
+  onSuccess(flow) { return router.push({ path: getRoutePath(flow.next), replace: true }).then() },
+  onError(error) { Notify.warning(error.message); },
+  onComplete() { submitting.value = false; },
+});
 
 const { loading: searching, send: search } = useRequest((data) => searchAccountMethod(data), {
   immediate: false,
 }).onSuccess(({ data }) => {
-  modelRef.value = { ...modelRef.value, ...data };
+  const modelLabel = {} as Record<string, any>;
+  const modelValue = {} as Record<string, any>;
+
+  for (const [key, value] of Object.entries(data)) {
+    const field = fields.value?.find((field) => field.name === key);
+    if (typeof value === 'string' && field?.component?.name === 'picker') {
+      const result = value.split(',').map((value) => {
+        const [v, l] = value.split(':');
+        return { value, label: l ?? (field.component as { options: Option[] })?.options?.find((option) => option.value === v)?.label ?? v };
+      });
+      modelValue[key] = result.map((t) => t.value).join(',');
+      modelLabel[key] = result.map((t) => t.label).join(',');
+    } else {
+      modelLabel[key] = value;
+    }
+  }
+
+  modelRef.value = { ...modelRef.value, ...modelLabel };
+  modelValueRef.value = { ...modelValueRef.value, ...modelValue };
 });
 
 let captchaLoaded = false;
@@ -43,11 +68,12 @@ const { loading: captchaLoading, countdown, send: getCaptcha } = useCaptcha(
   { initialCountdown: 60 },
 ).onSuccess(({ data }) => {
   captchaLoaded = true;
-  Toast.success(data ?? '获取成功')
+  Toast.success(data ?? '获取成功');
 });
 const getCaptchaHandle = async () => {
   try {
     await formRef.value?.validate('phone');
+    if ( !modelRef.value.phone ) throw { message: `请输入手机号码` };
     await getCaptcha();
     const field = fields.value.find(field => field.name === 'code');
     if ( field?.keyboard ) { field.keyboard.show = true; }
@@ -67,12 +93,12 @@ const searchHandle = async (key: 'cardno' | 'code') => {
 
 function onKeyboardBlur(field: Fields[number]) {
   if ( field?.name === 'phone' && !captchaLoaded ) { getCaptchaHandle(); }
-  if ( field?.name === 'cardno' ) { searchHandle('cardno'); }
-  if ( field?.name === 'code' ) { searchHandle('code'); }
+  if ( field?.name === 'cardno' && modelRef.value.cardno ) { searchHandle('cardno'); }
+  if ( field?.name === 'code' && modelRef.value.phone ) { searchHandle('code'); }
 }
 
 function onSubmitHandle() {
-  submit(toValue(modelRef));
+  submit(model.value);
 }
 
 function fix(key: string) {
@@ -97,6 +123,51 @@ onBeforeUnmount(() => {
     if ( key?.startsWith('scan_') ) sessionStorage.removeItem(key);
   }
 });
+
+const keyboardProps = reactive({
+  key: '',
+  props: {},
+  show: false,
+});
+
+const pickerProps = reactive({
+  key: '',
+  props: { options: [], selected: [] },
+  show: false,
+  handle(options: Option[]) {
+    const key = (this ?? pickerProps).key;
+    (modelRef.value as Record<string, any>)[key] = options.map(option => option.label).join(',');
+    (modelValueRef.value as Record<string, any>)[key] = options.map(option => option.value).join(',');
+  }
+});
+function onFieldFocus(field: any) {
+  if (field.keyboard) {
+    keyboardProps.key = field.name;
+    keyboardProps.show = true;
+    keyboardProps.props = {
+      ...field.keyboard,
+      maxlength: field.control?.maxlength ?? Number.POSITIVE_INFINITY,
+      onBlur() {
+        keyboardProps.show = false;
+        onKeyboardBlur(field);
+      },
+    }
+  } else if (field.component?.name === 'picker') {
+    pickerProps.key = field.name;
+    pickerProps.show = true;
+    pickerProps.props = {
+      ...field.component.props,
+      title: field.control.label,
+      options: field.component.options,
+      selected: (modelValueRef.value as Record<string, string>)[field.name]?.split(','),
+    }
+    return;
+  }
+}
+function onFieldBlur(field: any) {
+  keyboardProps.show = false;
+  pickerProps.show = false;
+}
 </script>
 <template>
   <div>
@@ -120,55 +191,50 @@ onBeforeUnmount(() => {
       >
         <van-cell-group :border="false">
           <template v-for="field in fields" :key="field.name">
-            <van-field v-model="modelRef[field.name]" :name="field.name" :id="field.name"
-                       :rules="field.rules" v-bind="field.control"
-                       :class="{'no-border': field.control?.border === false}"
-                       :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true)"
-                       @blur="field.keyboard && (field.keyboard.show = false)"
-                       :readonly="field.control.readonly" @click="field.keyboard && (field.keyboard.show = true)"
-            >
-              <template #input v-if="field.component?.name === 'radio'">
-                <van-radio-group v-model="modelRef[field.name]" direction="horizontal" shape="dot">
-                  <van-radio v-for="option in field.component?.options" :key="option.value" :name="option.value">
-                    {{ option.label }}
-                  </van-radio>
-                </van-radio-group>
-              </template>
-              <template #input v-else-if="field.component?.name === 'code'">
-                <van-password-input
-                  style="width: 100%;"
-                  v-model:value="modelRef[field.name]" v-bind="(field.component as any)!.props"
-                  :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true);fix('code')"
-                />
-              </template>
-              <template #button>
-                <div class="text-primary cursor-pointer">
-                  <template v-if="field.component?.name === 'code'">
-                    <div class="text-primary cursor-pointer" @click="getCaptchaHandle()">
-                      {{ captchaLoading ? '发送中...' : countdown > 0 ? `${ countdown }后可重发` : '获取验证码' }}
-                    </div>
-                  </template>
-                  <template v-else>{{ field.suffix }}</template>
-                </div>
-              </template>
-            </van-field>
-            <van-number-keyboard
-              v-if="field.keyboard"
-              v-model="modelRef[field.name]"
-              v-bind="field.keyboard" :maxlength="field.control.maxlength"
-              @blur="field.keyboard.show = false; onKeyboardBlur(field)"
-            />
+            <template v-if="!field.control?.hide || (typeof field.control?.hide === 'function' && !field.control.hide(model))">
+              <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}"
+                         @focus="onFieldFocus(field)" @blur="onFieldBlur(field)"
+                         :readonly="field.control?.readonly" @click="onFieldFocus(field)"
+              >
+                <template #input v-if="field.component?.name === 'radio'">
+                  <van-radio-group v-model="modelRef[field.name]" direction="horizontal" shape="dot">
+                    <van-radio v-for="option in field.component?.options" :key="option.value" :name="option.value">
+                      {{ option.label }}
+                    </van-radio>
+                  </van-radio-group>
+                </template>
+                <template #input v-else-if="field.component?.name === 'code'">
+                  <van-password-input
+                    style="width: 100%;"
+                    v-model:value="modelRef[field.name]" v-bind="(field.component as any)!.props"
+                    :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true);fix('code')"
+                  />
+                </template>
+                <template #button>
+                  <div class="text-primary cursor-pointer">
+                    <template v-if="field.component?.name === 'code'">
+                      <div class="text-primary cursor-pointer" @click="getCaptchaHandle()">
+                        {{ captchaLoading ? '发送中...' : countdown > 0 ? `${ countdown }后可重发` : '获取验证码' }}
+                      </div>
+                    </template>
+                    <template v-else>{{ field.suffix }}</template>
+                  </div>
+                </template>
+              </van-field>
+            </template>
           </template>
         </van-cell-group>
       </van-form>
       <div class="m-4">
         <div class="m-auto size-16 cursor-pointer">
           <van-loading v-if="submitting || loading" type="spinner" size="64" color="#38ff6e" />
-          <img v-else class="size-full"
-               src="@/assets/images/next-step.svg" alt="提交" @click="formRef?.submit()"
-          >
+          <img v-else class="size-full" src="@/assets/images/next-step.svg" alt="提交" @click="formRef?.submit()">
         </div>
       </div>
+      <van-number-keyboard v-bind="keyboardProps.props" :show="keyboardProps.show" v-model="modelRef[keyboardProps.key]"></van-number-keyboard>
+      <PickerDialog v-bind="pickerProps.props" immediate v-model:show="pickerProps.show" @selected="pickerProps.handle($event)"></PickerDialog>
     </div>
   </div>
 </template>
@@ -251,4 +317,9 @@ onBeforeUnmount(() => {
 .van-radio-group {
   height: 40px + 2px;
 }
+
+.sub-option.checked {
+  color: #fff;
+  background-color: var(--primary-color);
+}
 </style>

+ 11 - 13
src/pages/screen.page.vue

@@ -1,7 +1,8 @@
 <script setup lang="ts">
 import { Dialog, Notify } from '@/platform';
 
-import { copyrightMethod, processMethod, registerVisitorMethod } from '@/request/api';
+import { copyrightMethod, registerVisitorMethod } from '@/request/api';
+import { useRouteNext, getRoutePath } from '@/computable/useRouteNext';
 
 import { useVisitor }     from '@/stores';
 import getBubbles         from '@/tools/bubble';
@@ -25,15 +26,15 @@ const { data: copyright, send: load } = useRequest(copyrightMethod).onError(asyn
   });
   await load();
 });
-const { send: handle, loading } = useRequest(processMethod, { immediate: false })
-  .onSuccess(({ data }) => {
+
+const { handle, loading } = useRouteNext({
+  async onSuccess(flow) {
     Visitor.$reset();
-    router.push({ path: data, replace: true }).then(
-      () => { if (visitor.value) Visitor.patientId = visitor.value; },
-      () => {}
-    );
-  })
-  .onError(({ error }) => Notify.warning(error.message));
+    await router.push({ path: getRoutePath(flow.next), replace: true })
+    if (visitor.value) Visitor.patientId = visitor.value;
+  },
+  onError(error) { Notify.warning(error.message); },
+});
 
 const container = useTemplateRef<HTMLDivElement>('container');
 const { width, height } = useElementSize(container);
@@ -50,10 +51,7 @@ interface Bubble {
 }
 
 watchEffect(() => {
-  if ( width.value && height.value ) {
-    init(
-      { width: width.value, height: height.value * 0.90, container: container.value! });
-  }
+  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 }) {

+ 13 - 0
src/platform/index.ts

@@ -21,6 +21,19 @@ export function getSerialNumberSync() {
   )();
 }
 
+let getNetworkWall = async (): Promise<"exterior" | "interior"> => {
+  const controller = new AbortController();
+  setTimeout(() => controller.abort(), 1000);
+
+  const wall = await fetch(`https://wx.hzliuzhi.com/aio/`, { method: 'HEAD', mode: 'no-cors', signal: controller.signal }).then(
+    () => 'exterior' as const,
+    () => 'interior' as const,
+  );
+  return (getNetworkWall = () => Promise.resolve(wall), wall);
+};
+
+export { getNetworkWall };
+
 export * from './dialog.ui';
 export * from './notify.ui';
 export * from './toast.ui';

+ 17 - 4
src/request/api/account.api.ts

@@ -1,5 +1,6 @@
-import { cacheFor }                                                     from '@/request/api/index';
-import { type Fields, fromRegisterFields, getPath, type RegisterModel } from '@/request/model';
+import { cacheFor } from '@/request/api/index';
+import type { Dictionaries, Fields, RegisterModel } from '@/request/model';
+import { fromRegisterFields, getPath} from '@/request/model';
 import { useVisitor } from '@/stores';
 
 import HTTP from '../alova';
@@ -11,13 +12,13 @@ export function getCaptchaMethod(mobile: string) {
   });
 }
 
-export function registerFieldsMethod() {
+export function registerFieldsMethod(dictionaries?: Dictionaries) {
   return HTTP.Post<Fields, { tabletFileFields: string[] }>(`/fdhb-tablet/warrantManage/getPageSets`, void 0, {
     cacheFor, name: `variate:register_fields`,
     params: { k: 'register_fields' },
     transform(data, headers) {
       const options = data?.tabletFileFields ?? [];
-      return fromRegisterFields(options);
+      return fromRegisterFields(options, dictionaries);
     },
   });
 }
@@ -64,3 +65,15 @@ export function scanAccountMethod(key: string) {
     },
   });
 }
+
+export function dictionariesMethod() {
+  return HTTP.Get<Dictionaries | void, any>(`/fdhb-tablet/dict/getDicts`, {
+    cacheFor, name: `variate:dict`,
+    transform(data) {
+      if (!Array.isArray(data)) return void 0;
+      const dictionaries: Dictionaries = new Map();
+      for (const { dictType, items } of data) dictionaries.set(dictType, items.map((item: any) => ({label: item.dictLabel, value: item.dictValue })));
+      return dictionaries.size ? dictionaries : void 0;
+    },
+  });
+}

+ 1 - 19
src/request/api/camera.api.ts

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

+ 11 - 43
src/request/api/flow.api.ts

@@ -1,5 +1,6 @@
 import { cacheFor }     from '@/request/api/index';
-import { type Flow, fromFlowData } from '@/request/model';
+import { FlowMap }      from '@/request/model';
+import type { FlowKey } from '@/request/model';
 import Router           from '@/router';
 
 import HTTP from '../alova';
@@ -18,50 +19,17 @@ export function copyrightMethod() {
 }
 
 export function processMethod() {
-  const path = unref(Router.currentRoute).path;
-  return HTTP.Post<string, { tabletProcessModules?: string[]; }>(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
-    cacheFor, name: `variate:process`,
-    params: { k: 'process', p: path },
-    transform(data) {
-      const options = data.tabletProcessModules ?? [];
-      const ref = fromFlowData(options);
-      const flow = ref.get(path);
-      if ( !flow ) throw { message: `[路由] 配置异常无法解析正确路径,请联系管理员 (${ data?.tabletProcessModules?.join(' -> ') }})` };
-      return flow.route;
-    },
-  });
-}
-
-export function processMethod2() {
-  const path = unref(Router.currentRoute).path;
-  return HTTP.Post<Flow, { tabletProcessModules?: string[]; }>(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
+  return HTTP.Post(`/fdhb-tablet/warrantManage/getPageSets`, void 0, {
     cacheFor, name: `variate:process`,
-    params: { k: 'process', p: path },
-    transform(data) {
-      const options = data.tabletProcessModules ?? [];
-      const ref = fromFlowData(options);
-      const flow = ref.get(path);
-      if ( !flow ) throw { message: `[路由] 配置异常无法解析正确路径,请联系管理员 (${ data?.tabletProcessModules?.join(' -> ') }})` };
-      return flow;
-    },
+    params: { k: 'process' },
+    transform(data: any, headers) { return new FlowMap(data?.tabletProcessModules ?? []); },
   });
 }
 
-
-export function processMethod3() {
-  const path = unref(Router.currentRoute).path;
-  return HTTP.Post<{ current: Flow; prev: Flow; next: Flow }, { tabletProcessModules?: string[]; }>(`/fdhb-tablet/warrantManage/getPageSets`, {}, {
-    cacheFor, name: `variate:process`,
-    params: { k: 'process', p: path },
-    transform(data) {
-      const options = data.tabletProcessModules ?? [];
-      const ref = fromFlowData(options);
-      const [key, current] = [...ref.entries()].find(([,flow]) => flow.route === path) || [];
-      const prev = [...ref.values()].find(flow => flow.route === key);
-      const next = ref.get(path);
-      if ( !next ) throw { message: `[路由] 配置异常无法解析正确路径,请联系管理员 (${ data?.tabletProcessModules?.join(' -> ') }})` };
-      return { current, prev, next } as any;
-    }
-  })
+export async function getNextProcess(path?: FlowKey) {
+  const process = await processMethod();
+  path ??= unref(Router.currentRoute).path as FlowKey;
+  const flow = process.get(path);
+  if (!flow) throw { message: `[路由] 配置异常无法解析正确路径,请联系管理员!` };
+  return flow.route;
 }
-

+ 52 - 5
src/request/api/pulse.api.ts

@@ -1,18 +1,65 @@
 import HTTP from '@/request/alova';
+import { fromAnalysisModel, type PulseAnalysisModel } from '@/request/model';
+import { getNetworkWall } from '@/platform';
 
-export type PulseResult = Awaited<ReturnType<typeof Bridge.pulse>> & { patientId: string; results: string };
 const getSummary = (value?: string[]) => (Array.isArray(value) && value.length ? value : void 0);
 
-export function putPulseMethod(patientId: string, result: PulseResult) {
+export function putPulseMethod(patientId: string, result: PulseAnalysisModel) {
   const summary =
     getSummary(result.summaryLabel?.summary) ??
     getSummary(result.summaryLabel?.left?.summary) ??
     getSummary(result.summaryLabel?.right?.summary) ??
     [];
   const data = Object.assign(result, { patientId, results: summary.join(',') });
-  return HTTP.Post<PulseResult, any>(`/fdhb-tablet/analysisManage/savePulseAnalysisReport`, data, {
-    transform() {
-      return data;
+  return HTTP.Post<PulseAnalysisModel, any>(`/fdhb-tablet/analysisManage/savePulseAnalysisReport`, data, {
+    async transform() {
+      const network = await getNetworkWall();
+      return {
+        measureId: data.measureId,
+        hand: [
+          data.summaryLabel?.left?.summary?.length,
+          data.summaryLabel?.right?.summary?.length,
+        ].filter(Boolean).length,
+        summaryValue: data.summaryValue,
+        summaryLabel: data.summaryLabel,
+        results: data.results,
+        url: network === 'interior' ? (console.info('log: 判定内网环境 舍弃脉诊报告 url'),void 0) : data.url,
+      };
     },
   });
 }
+
+/**
+ * 暂未实现
+ * @deprecated
+ */
+export function getPulseMethod(id: string) {
+  return HTTP.Get(`/fdhb-tablet/analysisManage/getHealRepDetailById`, {
+    transform(data: Record<string, any>) {
+      return {
+        date: data?.pulseAnalysisReportDate,
+        pulse: fromAnalysisModel('pulse', data),
+      }
+    }
+  });
+}
+
+export function getPulseAgentMethod() {
+  return HTTP.Post(
+    `/fdhb-tablet/analysisManage/getPulseSupport`,
+    {},
+    {
+      transform(data: any) {
+        const url = data?.origin ?? data?.url;
+        if (!url) return void 0;
+
+        const origin = url.endsWith('/report.html') ? url : `${url}/taiyi/report.html`;
+        return {
+          appId: data.appId ?? 'hJn5D3rr',
+          token: data.token,
+          url: `${origin}#/`,
+        };
+      },
+    }
+  );
+}

+ 3 - 1
src/request/api/questionnaire.api.ts

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

+ 53 - 18
src/request/api/report.api.ts

@@ -16,16 +16,22 @@ export function getAnalysisResultsMethod() {
         const date = data?.tonguefaceAnalysisReportDate;
         const miniProgramURL = data?.tonguefaceAnalysisReportAppletImg;
         data = data.nextQuestions?.find((item: any) => item.classify === 'tongue_result');
+        let message = '';
         if ( data ) {
-          const { show, force } = await miniProgramMethod();
-          return {
-            id, date, miniProgramURL: show ? miniProgramURL : void 0,
-            tongue: fromAnalysisModel('tongue', data),
-            face: fromAnalysisModel('face', data),
-            payLock: show && force,
-          };
+          const tongue = fromAnalysisModel('tongue', data);
+          const face = fromAnalysisModel('face', data);
+          if (!tongue.result && !face.result) {
+            message = data.content;
+          } else {
+            const { show, force } = await miniProgramMethod('report');
+            return {
+              id, date, miniProgramURL: show ? miniProgramURL : void 0,
+              tongue, face,
+              payLock: show && force,
+            };
+          }
         }
-        throw { message: `[分析结果] 照片不符合检测要求,图片不是舌头(请拍摄带有舌头的、清晰的彩色照!)` };
+        throw { message: message || `[分析结果] 照片不符合检测要求,图片不是舌头(请拍摄带有舌头的、清晰的彩色照!)` };
       },
     },
   );
@@ -33,12 +39,15 @@ export function getAnalysisResultsMethod() {
 
 export function getReportMethod(id: string) {
   const Visitor = useVisitor();
-  const params = { healthAnalysisReportId: id, patientId: Visitor.patientId };
+  const params = { healthAnalysisReportId: id, patientId: Visitor.patientId?.toString() };
   return HTTP.Get(`/fdhb-tablet/analysisManage/getHealRepDetailById`, {
     params,
     async transform(data, headers) {
+      const patientId = (<any>data)?.patientId?.toString()
+      if (params.patientId !== patientId ) Visitor.$reset()
       const report = fromReportData(<any> data);
-      const { show, force } = await miniProgramMethod();
+      Visitor.updatePulseReport(report.pulse, patientId);
+      const { show, force } = await miniProgramMethod('report');
       if ( !show ) { report.miniProgramURL = void 0; }
       report.payLock = show && force;
       return report;
@@ -46,6 +55,20 @@ export function getReportMethod(id: string) {
   });
 }
 
+export function getAlcoholReportMethod() {
+  const Visitor = useVisitor();
+  const params = { healthAnalysisReportId: Visitor.reportId, patientId: Visitor.patientId };
+  return HTTP.Get(`/fdhb-tablet/analysisManage/getHealRepDetailById`, {
+    params,
+    transform(data: Record<string, any>) {
+      return {
+        date: data?.alcoholAnalysisReportDate /*?? data?.reportTime*/,
+        alcohol: fromAnalysisModel('alcohol', data),
+      }
+    }
+  });
+}
+
 export function updateReportMethod(id: string, data: Record<string, any>) {
   const Visitor = useVisitor();
   const params = {
@@ -57,27 +80,39 @@ export function updateReportMethod(id: string, data: Record<string, any>) {
   return HTTP.Post(`/fdhb-tablet/analysisManage/upConFacImgById`, params, {});
 }
 
-export function getReportSchemeMethod(id: string) {
+export function getReportSchemeMethod(id: string, standalone?: boolean) {
   const Visitor = useVisitor();
-  const params = { healthAnalysisReportId: id, patientId: Visitor.patientId };
+  const params = { healthAnalysisReportId: id, patientId: Visitor.patientId, standalone };
   return HTTP.Get(`/fdhb-tablet/analysisManage/getCondProgDetailById`, {
     params,
-    transform(data: any, headers) {
-      return fromSchemeRequest(data);
+    async transform(data: any, headers) {
+      const scheme = fromSchemeRequest(data);
+      const { show, force } = await miniProgramMethod('scheme');
+      if ( !show ) { scheme.miniProgramURL = void 0; }
+      scheme.payLock = show && force;
+      return scheme;
     },
   });
 }
 
 
-export function miniProgramMethod() {
+export function miniProgramMethod(type: 'report' | 'scheme' = 'report') {
   return HTTP.Post<{ show: boolean; force: boolean; }, { tabletRequiredPageOperationElements: string[] }>(
     `/fdhb-tablet/warrantManage/getPageSets`, void 0, {
       cacheFor, name: `variate:mini_program`,
-      params: { k: 'mini_program' },
+      params: { k: `mini_program_${type}` },
       transform(data, headers) {
+        const cfg = Array.isArray(data.tabletRequiredPageOperationElements) ? data.tabletRequiredPageOperationElements : [];
+        /**
+         *  - health_analysis_report_page_appletbutton
+         *  - health_analysis_report_page_appletscan
+         *
+         *  - health_analysis_scheme_page_appletbutton
+         *  - health_analysis_scheme_page_appletscan
+         */
         return {
-          show: data?.tabletRequiredPageOperationElements?.includes('health_analysis_report_page_appletbutton'),
-          force: data?.tabletRequiredPageOperationElements?.includes('health_analysis_report_page_appletscan'),
+          show: cfg.includes(`health_analysis_${type}_page_appletbutton`),
+          force: cfg.includes(`health_analysis_${type}_page_appletscan`),
         };
       },
     });

+ 67 - 11
src/request/model/analysis.model.ts

@@ -1,24 +1,43 @@
 import { groupBy } from '@/tools';
 
-
-export function fromAnalysisModel(mode: 'tongue' | 'face', data: Record<string, any>): AnalysisModel {
+export function fromAnalysisModel(mode: 'tongue' | 'face', data: Record<string, any>): AnalysisModel;
+export function fromAnalysisModel(mode: 'pulse', data: Record<string, any>): PulseAnalysisModel;
+export function fromAnalysisModel(mode: 'alcohol', data: Record<string, any>): AlcoholAnalysisModel;
+export function fromAnalysisModel(
+  mode: 'tongue' | 'face' | 'pulse' | 'alcohol',
+  data: Record<string, any>
+): AnalysisModel | AlcoholAnalysisModel | PulseAnalysisModel {
   let model: AnalysisModel;
-  switch ( mode ) {
+  switch (mode) {
     case 'tongue':
       model = fromTongueAnalysisModel(data);
       break;
     case 'face':
       model = fromFaceAnalysisModel(data);
       break;
+    case 'pulse':
+      return fromPulseAnalysisModel(data);
+    case 'alcohol':
+      return fromAlcoholAnalysisModel(data?.extendFlowData);
   }
 
-  const group = groupBy<AnalysisException>(model.exception, (item) => item.cover ?? '');
-  model.exceptionGroup = Object.entries(group).map(([ key, exception ]) => (
-    { key, exception }
-  ));
+  const group = groupBy<AnalysisException>(model.exception, (item) => item.cover ?? '') ?? {};
+  model.exceptionGroup = Object.entries(group).map(([key, exception]) => ({ key, exception }));
   return model;
 }
 
+export interface PulseAnalysisModel extends Pick<Awaited<ReturnType<typeof Bridge.pulse>>, 'measureId' | 'summaryLabel' | 'summaryValue'> {
+  hand?: number;
+  results?: string;
+  url?: string;
+}
+
+export interface AlcoholAnalysisModel {
+  condition: string;
+  description: string;
+  volume: [min: number, max?: number];
+}
+
 export interface AnalysisModel {
   table: {
     columns: string[];
@@ -41,6 +60,41 @@ export interface AnalysisException {
   tags: string[];
 }
 
+function fromPulseAnalysisModel(data: Record<string, any>): PulseAnalysisModel {
+  const pulse = (function (value) {
+    if (typeof value === 'string')
+      try {
+        value = JSON.parse(value);
+      } catch (e) {}
+    return typeof value === 'object' ? value : {};
+  })(data.pulseReport);
+
+  const getSummary = (value?: string[]) => (Array.isArray(value) && value.length ? value : void 0);
+  const summary =
+    getSummary(pulse?.summary_desc?.summary) ??
+    getSummary(pulse?.summary_desc?.left?.summary) ??
+    getSummary(pulse?.summary_desc?.right?.summary) ??
+    [];
+
+  return {
+    measureId: data?.measureId,
+    hand: pulse?.hand ?? 0,
+    summaryValue: pulse?.summary,
+    summaryLabel: pulse?.summary_desc,
+    results: summary.join(','),
+    url: pulse?.url ?? void 0,
+  };
+}
+
+function fromAlcoholAnalysisModel(data: Record<string, any>): AlcoholAnalysisModel {
+  const volume: [number, number] = data?.alcoholCapacity?.match(/(\d+)/g)?.slice(0, 2)?.filter(Boolean)?.map((v: string) => +v) ?? [];
+  return {
+    condition: data?.alcoholCondition,
+    description: data?.alcoholCapacity,
+    volume: volume.sort((a, b) => a - b),
+  };
+}
+
 function fromTongueAnalysisModel(data: Record<string, any>): AnalysisModel {
   const exception: AnalysisException[] = [];
   const fromTongueException = fromAnalysisException(exception);
@@ -81,12 +135,14 @@ function fromFaceAnalysisModel(data: Record<string, any>): AnalysisModel {
         fromFaceException(data?.faceColor, '面色'),
         fromFaceException(data?.mainColor, '主色'),
         fromFaceException(data?.shine, '光泽'),
-        fromFaceException(data?.leftBlackEye, '左黑眼圈'),
-        fromFaceException(data?.rightBlackEye, '右黑眼圈'),
+        fromFaceException(data?.blackEye, '黑眼圈'),
+        // fromFaceException(data?.leftBlackEye, '左黑眼圈'),
+        // fromFaceException(data?.rightBlackEye, '右黑眼圈'),
         fromFaceException(data?.lipColor, '唇色'),
         fromFaceException(data?.eyeContact, '眼神'),
-        fromFaceException(data?.leftEyeColor, '左目色'),
-        fromFaceException(data?.rightEyeColor, '右目色'),
+        fromFaceException(data?.eyeColor, '目色'),
+        // fromFaceException(data?.leftEyeColor, '左目色'),
+        // fromFaceException(data?.rightEyeColor, '右目色'),
         fromFaceException(data?.hecticCheek, '两颧红'),
         fromFaceException(data?.noseFold, '鼻褶'),
         fromFaceException(data?.cyanGlabella, '眉间/鼻柱青色'),

+ 64 - 21
src/request/model/flow.model.ts

@@ -1,3 +1,5 @@
+const ROUTE_START = '/screen';
+
 const Routes = {
   'patient_file': /* 建档页 */ '/register',
   'tongueface_upload': /*拍照页*/ '/camera',
@@ -5,41 +7,82 @@ const Routes = {
   'tongueface_analysis': /* 问卷页 */ '/questionnaire',
   'tongueface_analysis_result': /* 舌面象分析报告页 */ '/report/analysis',
   'health_analysis': /* 健康报告页 */ '/report',
+  'health_analysis_scheme': /* 调理方案页 */ '/scheme',
   'pulse_upload': /* 脉诊页 */ '/pulse',
   'pulse_upload_result': /* 脉诊结果页 */ '/pulse/result',
+  'alcohol_upload_result': /* 酒精结果页 */ '/alcohol/result',
 
-  'screen': '/screen',
+  'screen': ROUTE_START,
 } as const;
+const analysisFlowMap = new Map<FlowKey, string>([
+  ['/alcohol/result', 'alcohol'],
+]);
+const ANALYSIS_EXTEND_FLOW = 'ANALYSIS_EXTEND_FLOW';
+
+export type FlowKey = (typeof Routes)[keyof typeof Routes];
 
 export interface Flow {
-  route: string;
+  route: FlowKey;
   title?: string;
   optional?: boolean;
 }
 
-export function fromFlowData(options: string[]): Map<string, Flow> {
-  const ref = new Map<string, Flow>();
-  const length = options.length;
+export type FlowRoute = { next: Flow; prev: Flow | null; value: Flow };
+
+export class FlowMap extends Map<FlowKey, Flow> {
+  private readonly string: string = '';
+
+  constructor(options: string[]) {
+    super();
+    this.string = options.join('->');
 
-  // 修正虚拟路由
-  const k1 = 'tongueface_upload';
-  const k2 = 'tongueface_upload_result';
-  if ( !options.includes(k2) && options[ length - 1 ] === k1 ) options.push(k2);
+    const length = options.length;
+    if (length === 0) return;
 
-  options.unshift('/screen');
-  options.push('screen');
+    // 修正虚拟路由
+    const k1 = 'tongueface_upload';
+    const k2 = 'tongueface_upload_result';
+    if ( !options.includes(k2) && options[ length - 1 ] === k1 ) options.push(k2);
 
-  for ( let i = 1; i < options.length; i++ ) {
-    const path = options[i];
-    const optional = path.includes('?');
-    const [name, title] = path.split('?');
-    const route = (options[i] = getPath(name));
-    ref.set(options[i - 1], { route, optional, title, });
+    options.unshift(ROUTE_START);
+    options.push('screen?返回首页');
+
+    const analysisFlowSet = new Set<string>();
+    for ( let i = 1; i < options.length; i++ ) {
+      const path = options[i];
+      const optional = path.endsWith('?');
+      const [name, title] = path.split('?');
+      const route = (options[i] = getPath(name));
+      this.set(<FlowKey>options[i - 1], { route, optional, title: title || void 0});
+      if (analysisFlowMap.has(route)) analysisFlowSet.add(analysisFlowMap.get(route)!);
+    }
+    sessionStorage.setItem(ANALYSIS_EXTEND_FLOW, Array.from(analysisFlowSet).filter(Boolean).join(','));
   }
-  return ref;
-}
 
+  parse(key: FlowKey): FlowRoute {
+    const next = this.get(key);
+    if (!next) throw { message: `[路由] 配置异常无法解析正确路径,请联系管理员! (${this})` };
+
+    if (key === ROUTE_START) return { next, prev: null, value: { route: '/screen', optional: false, title: '' } };
+
+    const keys = [...this.keys()];
+    const values = [...this.values()];
 
-export function getPath(value?: string) {
-  return Routes[ value as keyof typeof Routes ];
+    const v = values.findIndex((flow) => flow.route === key);
+    const k = values.findIndex((flow) => flow.route === keys[v]);
+
+    return { next, prev: values[k], value: values[v] };
+  }
+
+  toString(): string {
+    return this.string || `未配置路由`;
+  }
 }
+
+export function getPath(value?: string): FlowKey {
+  return Routes[value as keyof typeof Routes];
+}
+
+export function getAnalysisExtendFlowValue(key = ANALYSIS_EXTEND_FLOW) {
+  return sessionStorage.getItem(key) || ''
+}

+ 50 - 12
src/request/model/register.model.ts

@@ -2,6 +2,9 @@ import { toCamelCase } from '@/tools';
 
 import type { FieldRule, NumberKeyboardProps, PasswordInputProps } from 'vant';
 
+export type Option = { label: string; value: string; checked?: boolean;  disabled?: boolean; editable?: boolean; single?: boolean; }
+export type Dictionaries = Map<string, Option[]>;
+
 
 export interface RegisterModel {
   cardno: string;
@@ -13,6 +16,8 @@ export interface RegisterModel {
   height: number;
   weight: number;
   isEasyAllergy: boolean;
+  womenSpecialPeriod: string;
+  job: string;
 }
 
 export interface Field {
@@ -20,10 +25,13 @@ export interface Field {
     label: string; placeholder?: string;
     type?: string; min?: number; max?: number; minlength?: number; maxlength?: number;
     clearable?: boolean; border?: boolean; readonly?: boolean;
+    hide?: boolean | ((model: Record<string, any>) => boolean);
   };
-  component?: |
-    { name: 'radio', options: { label: string; value: string; }[] } |
-    { name: 'code', props?: Partial<PasswordInputProps> };
+  component?:
+    | { name: 'picker', options: Option[] , props?: Partial<{ multiple: boolean; }> }
+    | { name: 'radio', options: Option[] }
+    | { name: 'code', props?: Partial<PasswordInputProps> }
+  ;
   keyboard?: { show: boolean; } & Partial<NumberKeyboardProps>;
   suffix?: string;
   rules: FieldRule[];
@@ -67,10 +75,28 @@ const Fields: Record<FieldKey, Field> = {
     control: { label: '性别', border: false },
     component: {
       name: 'radio' as const,
-      options: [
-        { label: '男', value: '0' },
-        { label: '女', value: '1' },
-      ],
+      options: 'sys_user_sex' as unknown as any,
+    },
+    rules: [],
+  },
+  womenSpecialPeriod: {
+    control: {
+      label: '女性特殊期', readonly: true,
+      hide(model) { return model?.sex !== '1'; },
+    },
+    component: {
+      name: 'picker' as const,
+      options: 'women_special_period' as unknown as any,
+      props: { multiple: false },
+    },
+    rules: [],
+  },
+  job: {
+    control: { label: '职业', readonly: true },
+    component: {
+      name: 'picker' as const,
+      options: 'job' as unknown as any,
+      props: { multiple: false },
     },
     rules: [],
   },
@@ -78,10 +104,7 @@ const Fields: Record<FieldKey, Field> = {
     control: { label: '容易过敏', border: false },
     component: {
       name: 'radio' as const,
-      options: [
-        { label: '是', value: 'Y' },
-        { label: '否', value: 'N' },
-      ],
+      options: 'sys_yes_no' as unknown as any,
     },
     rules: [],
   },
@@ -141,7 +164,21 @@ const Fields: Record<FieldKey, Field> = {
   },
 };
 
-export function fromRegisterFields(options: string[]): Fields {
+export function fromRegisterFields(options: string[], dictionaries?: Dictionaries): Fields {
+  dictionaries ??= new Map([
+    ['sys_yes_no', [{ label: '是', value: 'Y' }, { label: '否', value: 'N' }]],
+    ['sys_user_sex', [{ label: '男', value: '0' }, { label: '女', value: '1' }]]
+  ]);
+
+  const getOptions = (options?: Option[] | string): Option[] => {
+    if (options == null) options = [];
+    if (typeof options === 'string') options = dictionaries?.get(options) ?? [];
+    return options.map((option) => Object.assign({
+      editable: option.label === `其他`,
+      single: option.label === `无`,
+    } ,option));
+  };
+
   // 修正 phone,code
   const k1 = 'phone';
   const k2 = 'code';
@@ -158,6 +195,7 @@ export function fromRegisterFields(options: string[]): Fields {
       control: { label: name, type: 'text' },
       rules: [],
     };
+    if ((field.component as any)?.options) (field.component as any) = { ...field.component, options: getOptions((field.component as any).options) }
     if ( values[ 1 ] === 'required' ) field.rules.push({ required: true, message: field.control.placeholder ?? '请补充完整' });
     return { ...field, name } as Fields[number];
   });

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

@@ -29,6 +29,7 @@ export function fromReportData(data: Record<string, any>) {
 
     tongue: fromAnalysisModel('tongue', data),
     face: fromAnalysisModel('face', data),
+    pulse: fromAnalysisModel('pulse', data),
 
     [ '中医证素' ]: data?.factorItems?.map?.((item: Record<string, any>) => {
       return { label: item?.factorItemName, value: item?.factorItemDescription, score: +item?.score };

+ 39 - 1
src/request/model/scheme.model.ts

@@ -1,30 +1,54 @@
+import { randomUUID } from '@/tools';
+
 type Data<T extends any = any> = Record<string, T>
 
 export function fromSchemeRequest(data: Data) {
   return {
     children: data?.types?.map((item: Data) => {
       return {
+        id: data?.id ?? randomUUID(),
         title: item?.type || '',
         children: item?.groups?.map((item: Data) => {
           return {
             ...fragment(item),
             descriptions: item?.attrs?.map(fragment) ?? [],
             media: fromSchemeMedia(item.items ?? []),
+            goods: item?.buyUrl ? fromSchemeGoods(item) : void 0,
           };
         }) ?? [],
       };
     }) ?? [],
-  };
+    miniProgramURL: data?.appletImg,
+    payLock: data?.payLock,
+  } as { children: SchemeProps[], miniProgramURL?: string; payLock?: boolean };
 }
 
 
 function fragment(data: Data<string>) {
   return {
+    id: data?.id ?? randomUUID(),
     title: data?.name || '',
     description: data?.description,
   };
 }
 
+export interface SchemeProps {
+  id: string;
+  title: string;
+  children: {
+    id: string;
+    title: string;
+    description?: string;
+    descriptions: {
+      id: string;
+      title: string;
+      description?: string;
+    }[];
+    goods?: SchemeGoodsProps;
+    media: SchemeMediaProps[];
+  }[];
+}
+
 export interface SchemeMediaProps {
   title: string;
   description: string;
@@ -47,3 +71,17 @@ function fromSchemeMedia(data: Data[]) {
   }
   return media;
 }
+
+export interface SchemeGoodsProps {
+  type: 'link' | string;
+  value: string;
+  label?: string;
+}
+
+function fromSchemeGoods(data: Data): SchemeGoodsProps {
+  return {
+    type: { URL: 'link' }[<string>data.buyType] ?? data.buyType,
+    label: data.buyName ?? '去购买',
+    value: data.buyUrl,
+  }
+}

+ 1 - 1
src/router/hooks/useRouteMeta.ts

@@ -9,7 +9,7 @@ export interface ReactiveRouteOptionsWithTransform<V, R> {
 }
 
 export function useRouteMeta<T extends RouteMeta = RouteMeta, K = T>(
-  name: string,
+  name: keyof T,
   defaultValue?: MaybeRefOrGetter,
   options: ReactiveRouteOptionsWithTransform<T, K> = {},
 ): Ref<K> {

+ 4 - 1
src/router/index.ts

@@ -7,12 +7,15 @@ const router = createRouter({
     { path: '/screen', component: () => import('@/pages/screen.page.vue'), meta: { scan: true } },
     { path: '/register', component: () => import('@/pages/register.page.vue'), meta: { title: '建档', scan: true } },
     { path: '/pulse', component: () => import('@/modules/pulse/pulse.page.vue'), meta: { title: '脉诊' } },
+    { path: '/pulse/result', component: () => import('@/modules/pulse/pulse-result.page.vue'), meta: { title: '脉象分析报告' } },
     { path: '/camera', component: () => import('@/modules/camera/camera.page.vue'), meta: { title: '拍摄' } },
     { path: '/camera/result', component: () => import('@/modules/camera/camera-result.page.vue'), meta: { title: '拍摄完成' } },
     { path: '/questionnaire', component: () => import('@/modules/questionnaire/page.vue'), meta: { title: '问卷' } },
+    { path: '/alcohol/result', component: () => import('@/modules/alcohol/alcohol.page.vue'), meta: { title: '黄酒建议' } },
     { path: '/report/analysis', component: () => import('@/modules/report/report-analyse.page.vue'), meta: { title: '舌面象分析报告' } },
-    { path: '/report/:id/scheme', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案' } },
+    { path: '/report/:id/scheme', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案', toggle: true } },
     { path: '/report/:id', component: () => import('@/modules/report/report.page.vue'), meta: { title: '健康分析报告' } },
+    { path: '/scheme/:id', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案', toggle: false, } },
     { path: '/', redirect: '/screen' },
   ],
 });

+ 11 - 1
src/stores/visitor.store.ts

@@ -1,16 +1,26 @@
 import { defineStore } from 'pinia';
+import type { PulseAnalysisModel } from '@/request/model';
 
 
 export const useVisitor = defineStore('visitor', () => {
   const patientId = ref<string>();
   const resultId = ref<string>();
   const reportId = ref<string>();
+  const pulseReport = shallowRef<PulseAnalysisModel>();
 
   const $reset = () => {
     patientId.value = '';
     resultId.value = '';
     reportId.value = '';
+    pulseReport.value = void 0;
   };
 
-  return { patientId, resultId, reportId, $reset };
+  function updatePulseReport(report: PulseAnalysisModel, patient?: string) {
+    if (patientId.value?.toString() !== patient || !pulseReport.value?.results) {
+      if (patient) patientId.value = patient;
+      pulseReport.value = report;
+    }
+  }
+
+  return { patientId, resultId, reportId, pulseReport, $reset, updatePulseReport };
 });

+ 1 - 0
src/tools/index.ts

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

+ 5 - 0
src/tools/url.tool.ts

@@ -3,6 +3,11 @@ export function getURLSearchParams(value?: string): URLSearchParams {
   return new URLSearchParams(value);
 }
 
+export function getURLSearchParamsByUrl(value: string): URLSearchParams {
+  const snippet = value.split('#');
+  return getURLSearchParams(snippet.map((param) => param.split('?')[1] || '').join('&'))
+}
+
 
 export function getClientURL(value: string, origin?: string) {
   if ( !value || /^https?:\/\//.test(value) ) return value;

+ 28 - 0
src/tools/uuid.tool.ts

@@ -0,0 +1,28 @@
+interface RandomUUID {
+  (): `${string}-${string}-${string}-${string}-${string}`;
+}
+
+let randomUUID: RandomUUID = () => {
+  try {
+    if (typeof crypto?.randomUUID === 'function') randomUUID = crypto.randomUUID.bind(crypto);
+    else {
+      randomUUID = () => {
+        const temp_url = URL.createObjectURL(new Blob());
+        const uuid = temp_url.toString();
+        return (URL.revokeObjectURL(temp_url), uuid.split('/').pop()) as ReturnType<typeof randomUUID>;
+      };
+    }
+    return randomUUID();
+  } catch (e) {
+    randomUUID = () => {
+      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+        const r = (Math.random() * 16) | 0;
+        const v = c === 'x' ? r : (r & 0x3) | 0x8;
+        return v.toString(16);
+      }) as ReturnType<typeof randomUUID>;
+    };
+  }
+  return randomUUID();
+};
+
+export { randomUUID };

+ 4 - 0
vite.config.ts

@@ -11,11 +11,15 @@ import vueDevTools               from 'vite-plugin-vue-devtools';
 
 // https://vitejs.dev/config/
 export default defineConfig((configEnv) => {
+  const argv = process.argv
   const envDir = './.env';
   const env = loadEnv(configEnv.mode, envDir, 'REQUEST_');
   return {
     envDir,
     envPrefix: 'SIX_',
+    define: {
+      __FORBID_AUTO_PROCESS_PULSE_AGENCY__: argv.includes('--legacy-pulse-agency')
+    },
     css: {
       preprocessorOptions: {
         scss: {

Some files were not shown because too many files changed in this diff