Pārlūkot izejas kodu

Merge branch 'feature/pulse' into develop

cc12458 1 mēnesi atpakaļ
vecāks
revīzija
0cc265dbd8

+ 1 - 0
.gitignore

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

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

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

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

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

+ 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


+ 50 - 46
src/loader/bridge.loader.ts

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

+ 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-8">
+        <template v-if="finished && report">
+          <img class="size-40 mx-auto" src="@/assets/images/tips.png" alt="" />
+          <div>
+            <div class="text-3xl text-center">恭喜您!</div>
+            <div class="text-3xl text-center my-8">完成脉诊采集</div>
+          </div>
+          <div v-if="report">
+            <div v-if="report.summaryLabel" class="flex justify-evenly">
+              <div v-if="report.summaryLabel?.left" class="flex flex-row-reverse justify-center">
+                <img style="width: 100px;height: 200px;" :src="HandLeft" alt="左手" />
+                <div class="flex flex-col justify-evenly translate-y-2 h-40 text-xl">
+                  <div>
+                    寸:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.cun }}</span>
+                  </div>
+                  <div>
+                    关:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.guan }}</span>
+                  </div>
+                  <div>
+                    尺:<span style="letter-spacing: 4px">{{ report.summaryLabel.left.chi }}</span>
+                  </div>
+                </div>
+              </div>
+              <div v-if="report.summaryLabel?.right">
+                <div class="flex justify-center">
+                  <img style="width: 100px;height: 200px;"  :src="HandRight" alt="右手" />
+                  <div class="flex flex-col justify-evenly translate-y-2 h-40 text-xl">
+                    <div>
+                      寸:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.cun }}</span>
+                    </div>
+                    <div>
+                      关:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.guan }}</span>
+                    </div>
+                    <div>
+                      尺:<span style="letter-spacing: 4px">{{ report.summaryLabel.right.chi }}</span>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <p v-if="report.results" class="text-2xl text-center">
+              总体脉象:<span class="text-primary-400" style="letter-spacing: 4px">{{ report.results }}</span>
+            </p>
+          </div>
+        </template>
+      </main>
+      <footer class="flex flex-col justify-center items-center">
+        <van-button
+          v-if="!pending && finished && next"
+          class="decorate !text-xl !text-primary-400"
+          @click="replace(next.route)"
+        >
+          {{ next.title }}
+        </van-button>
+        <van-button
+          v-if="!pending && done"
+          class="decorate !text-xl !text-primary-400 !mb-6"
+          @click="replace(done.route)"
+        >
+          {{ done.title }}({{ countdown }})
+        </van-button>
+        <div v-if="supported && !finished" class="van-button decorate" @click="handle()">
+          <div class="van-button__content">
+            <van-loading v-if="loading || pending || submitting" />
+            <span v-else class="van-button__text">连接脉诊</span>
+          </div>
+        </div>
+      </footer>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+header {
+  flex: 1 1 10%;
+}
+
+footer {
+  flex: 1 1 30%;
+}
+
+main {
+  position: relative;
+  flex: 1 1 50%;
+}
+
+.decorate {
+  margin: 24px 0;
+}
+</style>

+ 6 - 2
src/modules/report/report.page.vue

@@ -52,8 +52,12 @@ async function print() {
   }
 
   try {
-    // @ts-ignore
-    window.AIO.print(url);
+    try {
+      await Bridge.print({ url });
+    } catch (e) {
+      window.AIO?.print?.(url)
+    }
+    Toast.success(`开始打印`);
     Toast.success(`开始打印`);
   } catch ( e ) {
     Notify.warning(`打印失败 (${ e.message })`, { duration: 1500 });

+ 1 - 1
src/platform/index.ts

@@ -4,7 +4,7 @@ import { getURLSearchParams } from '@/tools';
 const userAgent = navigator.userAgent;
 
 export function platformIsAIO() {
-  return /aio\/\w+/i.test(userAgent);
+  return /Six\/applet \(AIO;.+\)/i.test(userAgent) || /aio\/\w+/i.test(userAgent);
 }
 
 export function getSerialNumberSync() {

+ 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: '问卷' } },