فهرست منبع

添加脉诊功能,对接 taiyi 脉诊仪

cc12458 1 ماه پیش
والد
کامیت
acee8d923c

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

@@ -0,0 +1,28 @@
+interface HandSummary {
+  chi: string;
+  cun: string;
+  guan: string;
+  summary: string[];
+}
+
+type HandKeys = 'chen' | 'chi' | 'fu' | 'hua' | 'kong' | 'ruan' | 'shi' | 'shu' | 'xi' | 'xian';
+
+export class Bridge extends EventTarget {
+  public static getInstance(): Bridge;
+
+  public static pulse(userId: string): Promise<{
+    measureId: string;
+    summaryLabel: {
+      summary?: HandSummary['summary'];
+      left?: HandSummary;
+      right?: HandSummary;
+    };
+    summaryValue: Record<HandKeys, number[]>;
+    time: string;
+
+    appId?: string;
+    userId?: string;
+    url: string;
+    report: string;
+  }>;
+}

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

@@ -0,0 +1,21 @@
+export {};
+
+declare global {
+  declare const Bridge: typeof import('./bridge').Bridge;
+
+  interface Window {
+    /* six-aio 设备注入 */
+    bridge: InstanceType<typeof import('./bridge').Bridge>;
+  }
+
+  /**
+   * Promise 扩展
+   */
+  interface PromiseConstructor {
+    withResolvers<T>(): {
+      promise: Promise<T>;
+      resolve: (value: T | PromiseLike<T>) => void;
+      reject: (reason?: any) => void;
+    };
+  }
+}

+ 2 - 1
package.json

@@ -6,8 +6,9 @@
   "scripts": {
     "dev": "vite",
     "build": "run-p type-check \"build-aio {@}\" --",
-    "preview": "vite preview",
     "build-aio": "vite build --base=/aio/",
+    "preview": "vite preview --base=/aio/ --mode development",
+    "device": "run-s build-aio preview",
     "type-check": "vue-tsc --build --force",
     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
     "format": "prettier --write src/"

BIN
src/assets/images/pulse-hand-left.png


BIN
src/assets/images/pulse-hand-right.png


+ 2 - 0
src/main.ts

@@ -3,6 +3,8 @@ import '@/themes/index.scss';
 import Loader, { bridgeLoader, debugLoader, launchLoader } from '@/loader';
 import { platformIsAIO }                                   from '@/platform';
 
+import './polyfill'
+
 import App from './App.vue';
 
 /* prettier-ignore */

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

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

+ 15 - 0
src/polyfill.ts

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

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

@@ -47,3 +47,21 @@ export function processMethod2() {
   });
 }
 
+
+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;
+    }
+  })
+}
+

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

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

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

@@ -5,6 +5,8 @@ const Routes = {
   'tongueface_analysis': /* 问卷页 */ '/questionnaire',
   'tongueface_analysis_result': /* 舌面象分析报告页 */ '/report/analysis',
   'health_analysis': /* 健康报告页 */ '/report',
+  'pulse_upload': /* 脉诊页 */ '/pulse',
+  'pulse_upload_result': /* 脉诊结果页 */ '/pulse/result',
 
   'screen': '/screen',
 } as const;

+ 1 - 0
src/router/index.ts

@@ -6,6 +6,7 @@ const router = createRouter({
   routes: [
     { path: '/screen', component: () => import('@/pages/screen.page.vue'), meta: { scan: true } },
     { path: '/register', component: () => import('@/pages/register.page.vue'), meta: { title: '建档', scan: true } },
+    { path: '/pulse', component: () => import('@/modules/pulse/pulse.page.vue'), meta: { title: '脉诊' } },
     { path: '/camera', component: () => import('@/modules/camera/camera.page.vue'), meta: { title: '拍摄' } },
     { path: '/camera/result', component: () => import('@/modules/camera/camera-result.page.vue'), meta: { title: '拍摄完成' } },
     { path: '/questionnaire', component: () => import('@/modules/questionnaire/page.vue'), meta: { title: '问卷' } },