瀏覽代碼

重构 flow 流程配置

cc12458 2 周之前
父節點
當前提交
d4b9bd42c0

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

@@ -23,4 +23,8 @@ declare global {
       reject: (reason?: any) => void;
     };
   }
+
+  interface Array<T> {
+    at(index: number): T;
+  }
 }

+ 20 - 25
src/components/Start.vue

@@ -1,38 +1,33 @@
 <script setup lang="ts">
-import { useRequest } from 'alova/client';
 import { registerVisitorMethod } from '@/request/api';
 import { Notify } from '@/platform';
-import { useVisitor } from '@/stores';
-import { useRouteNext, getRoutePath } from '@/computable/useRouteNext';
+import { useFlowStore, useVisitor } from '@/stores';
 
-const router = useRouter();
 const Visitor = useVisitor();
+const Flow = useFlowStore();
+const { flow } = storeToRefs(Flow);
+const handle = async () => {
+  Visitor.$reset();
+  if (flow.value.next === '/register' || (await register())) await Flow.router.push();
+};
 
-const { data: visitor, loading: registering, send: register } = useRequest(registerVisitorMethod, { immediate: false });
-const { handle, loading } = useRouteNext({
-  async onSuccess(flow) {
-    Visitor.$reset();
-    await router.push({ path: getRoutePath(flow.next), replace: true });
-    if (visitor.value) Visitor.patientId = visitor.value;
-  },
-  onError(error) {
-    Notify.warning(error.message);
-  },
-});
-
-onBeforeRouteLeave((to, from) => {
-  if (to.path === '/register' || Visitor.patientId) return true;
-  return register().then(
-    (data) => !!data,
-    () => false
-  );
-});
+const registering = ref(false);
+const register = async () => {
+  registering.value = true;
+  try {
+    Visitor.patientId = await registerVisitorMethod().send(true);
+  } catch (error: any) {
+    Notify.error(error.message);
+  }
+  registering.value = false;
+  return Visitor.patientId;
+};
 </script>
 
 <template>
-  <div class="van-button decorate" @click="!(loading || registering) && handle()">
+  <div class="van-button decorate" @click="!registering && handle()">
     <div class="van-button__content">
-      <van-loading v-if="loading || registering" />
+      <van-loading v-if="registering" />
       <span v-else class="van-button__text">开始检测</span>
     </div>
   </div>

+ 0 - 60
src/computable/useRouteNext.ts

@@ -1,60 +0,0 @@
-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 - 2
src/loader/bridge.loader.ts

@@ -1,4 +1,5 @@
-import { getNextProcess, scanAccountMethod } from '@/request/api';
+import { scanAccountMethod } from '@/request/api';
+import { useFlowStore } from '@/stores';
 import router                                from '@/router';
 
 import { platformIsAIO, platformIsAIO_1 } from '@/platform';
@@ -48,7 +49,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 getNextProcess().catch(() => route?.path) : route?.path);
+            const path = data?.path ?? (route?.path === '/screen' ? useFlowStore().flow.next : route?.path);
             const key = Date.now();
             sessionStorage.setItem(`scan_${key}`, JSON.stringify(data));
             await router.replace({ path, query: { scan: key } });

+ 2 - 1
src/loader/launch.loader.ts

@@ -1,5 +1,5 @@
 import router from '@/router';
-import pinia from '@/stores';
+import pinia, { useFlowStore } from '@/stores';
 
 const components = import.meta.glob('@/pages/**/*.vue');
 
@@ -19,6 +19,7 @@ export default function launchLoader(container = '#app'): DEV.Loader {
       });
     }
     if (config.image.page && config.image.page !== router.currentRoute.value.fullPath) await router.replace(config.image.page);
+    useFlowStore().$init(config.flowConfig)
 
     app.mount(container);
   };

+ 37 - 35
src/modules/alcohol/alcohol.page.vue

@@ -1,39 +1,52 @@
 <script setup lang="ts">
 import { Notify } from '@/platform';
-import { tryOnBeforeMount, tryOnUnmounted, useCountdown } from '@vueuse/core';
+import { tryOnUnmounted, useCountdown } from '@vueuse/core';
 
+import { useRequest } from 'alova/client';
 import { getAlcoholReportMethod } from '@/request/api';
-import type { Flow, FlowRoute } from '@/request/model';
-import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
+import { useFlowStore } from '@/stores';
 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>();
+type Operate = { title: string; countdown?: number; handle?: () => void };
 
 const title = useRouteMeta('title');
-const { handle, flow, loading } = useRouteNext({
-  onSuccess(flow) { return load(flow); },
-  onError(error) { Notify.warning(error.message); },
-});
+const Flow = useFlowStore();
+const { flow } = storeToRefs(Flow);
+/* 倒计时完成动作 */
+const done = shallowRef<Operate>();
+/* 下一动作可选 */
+const next = shallowRef<Operate>();
 
 const { remaining, start, stop } = useCountdown(5, {
-  onComplete() { replace(done.value!); },
+  onComplete() {
+    done.value?.handle?.();
+  },
   immediate: false,
 });
 const countdown = computed(() => remaining.value.toString().padStart(2, '0'));
 
-tryOnBeforeMount(() => handle());
-tryOnUnmounted(() => stop());
+tryOnUnmounted(stop);
+
+const { data: report, loading } = useRequest(getAlcoholReportMethod)
+  .onSuccess(() => {
+    done.value = { title: '返回首页', countdown: flow.value.done?.countdown ?? 10, handle: () => Flow.router.done() };
+    next.value = flow.value.next ? { title: '获取健康调理方案', handle: () => Flow.router.push() } : void 0;
+    if (!flow.value.done) {
+      done.value = { ...done.value, ...next.value };
+      next.value = void 0;
+    }
+    if (done.value.countdown) start(done.value.countdown);
+  })
+  .onError(({ error }) => {
+    done.value = { title: '返回首页', countdown: flow.value.done?.countdown ?? 5, handle: () => Flow.router.done() };
+    next.value = flow.value.optional && flow.value.next ? { title: '下一步', handle: () => Flow.router.push() } : void 0;
+    if (error.message) Notify.warning(error.message);
+  });
 
-const report = ref<Awaited<ReturnType<typeof getAlcoholReportMethod>>>();
 const tips = '建议您每日饮酒';
 const description = computed(() => report.value?.alcohol?.description
   ?.replace?.(new RegExp(`^${tips}`), '')
@@ -45,17 +58,6 @@ const description = computed(() => report.value?.alcohol?.description
   ?.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>
@@ -63,7 +65,7 @@ const replace = (flow: Flow) => router.push({ path: getRoutePath(flow), replace:
       <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 }}
+          {{ title }}
         </div>
       </div>
       <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
@@ -103,12 +105,12 @@ const replace = (flow: Flow) => router.push({ path: getRoutePath(flow), replace:
           </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 v-if="next" class="decorate !text-xl" @click="next.handle">
+            {{ next.title }}
           </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 v-if="done" class="decorate !text-xl !text-primary-400" @click="done.handle">
+            {{ done.title }}
+            <template v-if="done.countdown">({{ countdown }}s)</template>
           </van-button>
         </footer>
       </template>
@@ -148,4 +150,4 @@ main {
   border-radius: 24px;
   box-shadow: inset 0 0 80px 0 #34a76b60;
 }
-</style>
+</style>

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

@@ -1,69 +0,0 @@
-<script setup lang="ts">
-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 actionText = computed(() => `获取健康调理方案`);
-/* 倒计时完成动作 */
-const done = shallowRef<Flow>();
-/* 下一动作可选 */
-const next = shallowRef<Flow>();
-
-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 { 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 router = useRouter();
-const replace = (flow: Flow) => router.push({ path: getRoutePath(flow), replace: true });
-</script>
-<template>
-  <div>
-    <div class="page-content flex flex-col">
-      <header class="flex flex-col justify-center px-24" style="flex: 1 1 20%">
-        <div class="text-3xl text-center">拍摄完成</div>
-      </header>
-      <main class="flex flex-col justify-evenly px-24" style="flex: 1 1 50%">
-        <img class="size-40 mx-auto" src="@/assets/images/tips.png">
-        <div>
-          <div class="text-3xl text-center">恭喜您!</div>
-          <div class="text-3xl text-center my-8">完成舌面象的采集</div>
-        </div>
-      </main>
-      <footer class="flex flex-col items-center gap-4" style="flex: 1 1 30%">
-        <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>
-      </footer>
-    </div>
-  </div>
-</template>
-<style scoped lang="scss">
-</style>

+ 3 - 22
src/modules/camera/camera.page.vue

@@ -3,42 +3,23 @@ import { Dialog, Toast }                    from '@/platform';
 import { saveFileMethod, uploadFileMethod } from '@/request/api/camera.api';
 import { tryOnMounted, tryOnUnmounted }     from '@vueuse/core';
 import { useForm, useRequest }              from 'alova/client';
+import { useFlowStore }                     from '@/stores';
 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';
 
 
 let audio: HTMLAudioElement | void;
 
-const router = useRouter();
-
-const { flow } = useRouteNext({ immediate: true });
+const Flow = useFlowStore();
 const { form: dataset, loading: submitting, send: submit } = useForm(data => saveFileMethod(data), {
   initialForm: { } as Record<string, any>,
-}).onSuccess(({ data }) => replace()).onError(() => {
+}).onSuccess(({ data }) => Flow.router.push()).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>();
 const config = shallowRef<ConfigProps>();

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

@@ -23,6 +23,7 @@ const cameraFrameRef = useTemplateRef<
     contentWindow: {
       loadCamera(props: { width: number; height: number; zoom?: number }): Promise<void>;
       handle(promise?: Promise<void>): string;
+      updateCoordinate(offsetX: number, offsetY: number): void;
     };
   }
 >('camera-frame');

+ 13 - 28
src/modules/pulse/pulse-result.page.vue

@@ -1,39 +1,28 @@
 <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 { useFlowStore } from '@/stores';
 
 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>();
+const Flow = useFlowStore();
+const { flow } = storeToRefs(Flow);
+const handle = () => Flow.router.push();
+const done = () => Flow.router.done();
 
-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;
+const { data, error, loading, send } = useRequest(getPulseMethod, { immediate: false, initialData: { date: '', pulse: {}, } });
 
+async function load() {
   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);
+watch(id, (value, oldValue) => { if (value !== oldValue || value == null) load(); }, { immediate: true });
 
 const scrollable = computed(() => true);
 </script>
@@ -57,14 +46,10 @@ const scrollable = computed(() => true);
           <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 class="flex-none flex justify-between py-2 nav-wrapper" style="background-color: #12312c">
+        <div v-if="flow.next" class="nav m-auto min-w-16 text-center hover:text-primary" @click="handle">
+          <img class="nav-img" :src="NavNextSelect" alt="下一步" />
+          <div class="mt-2">获取健康调理方案</div>
         </div>
       </div>
     </div>

+ 52 - 54
src/modules/pulse/pulse.page.vue

@@ -1,78 +1,77 @@
 <script setup lang="ts">
 import { Notify } from '@/platform';
-import { tryOnBeforeMount, tryOnUnmounted, useCountdown } from '@vueuse/core';
-
+import { tryOnMounted, tryOnUnmounted, useCountdown } from '@vueuse/core';
 import { useRequest } from 'alova/client';
+import { useFlowStore, useVisitor } from '@/stores';
 import { putPulseMethod } from '@/request/api/pulse.api';
-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';
 
-const router = useRouter();
-const Visitor = useVisitor();
+type Operate = { title: string; countdown?: number; handle?: () => void };
 
-const finished = ref(false);
-const supported = ref(true);
-const actionText = computed(() => supported.value ? `获取健康调理方案` : `下一步`);
+const Visitor = useVisitor();
+const Flow = useFlowStore();
+const { flow } = storeToRefs(Flow);
 /* 倒计时完成动作 */
-const done = shallowRef<Flow>();
+const done = shallowRef<Operate>();
 /* 下一动作可选 */
-const next = shallowRef<Flow>();
-
-const { handle, loading } = useRouteNext({
-  onSuccess(flow) { return pulse(flow); },
-  onError(error) { Notify.warning(error.message); },
-});
+const next = shallowRef<Operate>();
 
 const { remaining, start, stop } = useCountdown(5, {
-  onComplete() { replace(done.value!); },
+  onComplete() {
+    done.value?.handle?.();
+  },
   immediate: false,
 });
 const countdown = computed(() => remaining.value.toString().padStart(2, '0'));
 
-tryOnBeforeMount(() => handle());
-tryOnUnmounted(() => stop());
+tryOnMounted(pulse);
+tryOnUnmounted(stop);
 
-async function pulse(flow: FlowRoute) {
-  stop();
-  const showReport = flow.next.route === '/pulse/result';
+const supported = ref(true);
+const loading = ref(false);
+const error = shallowRef({ message: '' });
+async function pulse() {
+  loading.value = true;
   try {
     const patientId = Visitor.patientId;
     const result = await Bridge.pulse(patientId!!);
     await submit(patientId, result);
-    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;
-
+    done.value = { title: '返回首页', countdown: flow.value.done?.countdown ?? 20, handle: () => Flow.router.done() };
+    next.value = flow.value.optional && !Flow.router.hasNext('/pulse/result') ? { title: '下一步', handle: () => Flow.router.push() } : void 0;
     if (e instanceof ReferenceError) {
       supported.value = false;
-      message = `当前环境不支持脉诊设备,请联系管理员!`;
-      countdown = 5;
+      error.value = { message: `当前环境不支持脉诊设备,请联系管理员!` };
+      done.value.countdown = 5;
     } else {
-      message = `请再次测量脉诊!`;
-      countdown = 10;
+      error.value = { message: `请再次测量脉诊` };
+      done.value.countdown = 10;
     }
-    done.value = flow.value.optional && !showReport ? flow.next : { title: '返回首页', route: '/screen' };
-    start(countdown);
-    Notify.warning(message);
+    if (done.value.countdown) start(done.value.countdown);
+    if (error.value?.message) Notify.warning(error.value.message);
   }
+  loading.value = false;
 }
 
+const finished = ref(false);
+const previewable = ref(false);
+const loadReport = computed(() => flow.value?.next === '/pulse/result');
 const { data: report, loading: submitting, send: submit, } = useRequest((id, result) => putPulseMethod(id, result), { immediate: false }).onSuccess(({ data }) => {
   Visitor.updatePulseReport(data);
+  if (loadReport.value) Flow.router.push();
+  else {
+    finished.value = true;
+    done.value = { title: '返回首页', countdown: flow.value.done?.countdown ?? 10, handle: () => Flow.router.done() };
+    next.value = flow.value.next ? { title: '获取健康调理方案', handle: () => Flow.router.push() } : void 0;
+    if (!flow.value.done) {
+      done.value = { ...done.value, ...next.value };
+      next.value = void 0;
+    }
+    if (previewable.value) done.value.countdown = 0;
+    if (done.value.countdown) start(done.value.countdown);
+  }
 });
-
-const replace = (flow: Flow) => router.push({ path: getRoutePath(flow), replace: true });
 </script>
 <template>
   <div>
@@ -98,29 +97,28 @@ const replace = (flow: Flow) => router.push({ path: getRoutePath(flow), replace:
             <div class="text-3xl text-center">恭喜您!</div>
             <div class="text-3xl text-center my-8">完成脉诊采集</div>
           </div>
-          <AnalysisPulseComponent title="" v-bind="report" disabled>
+          <AnalysisPulseComponent title="" v-bind="report" :disabled="!previewable">
             <template #exception>
               <div><!--空占位符--></div>
             </template>
           </AnalysisPulseComponent>
         </template>
+        <div v-else-if="error.message" class="text-center">{{ error.message }}</div>
       </main>
       <footer class="flex flex-col justify-center items-center">
-        <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 v-if="supported && !report" class="van-button decorate" @click="!loading && pulse()">
           <div class="van-button__content">
             <van-loading v-if="loading || submitting" />
             <span v-else class="van-button__text">连接脉诊</span>
           </div>
         </div>
+        <van-button v-if="next" class="decorate !text-xl" @click="next.handle">
+          {{ next.title }}
+        </van-button>
+        <van-button v-if="done" class="decorate !text-xl !text-primary-400" @click="done.handle">
+          {{ done.title }}
+          <template v-if="done.countdown">({{ countdown }}s)</template>
+        </van-button>
       </footer>
     </div>
   </div>

+ 5 - 6
src/modules/questionnaire/page.vue

@@ -2,7 +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 { useFlowStore } from '@/stores';
 
 import TierSelectField from './TierSelect.field.vue';
 
@@ -12,7 +12,7 @@ defineOptions({
   name: 'QuestionnairePage',
 });
 
-const router = useRouter();
+const Flow = useFlowStore();
 
 const first = ref(true);
 
@@ -43,13 +43,12 @@ 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.push({ path: getRoutePath(flow.value?.next!), replace: true });
+    if (reportId) return Flow.router.push();
     showTitle.value = _first;
     data.value = [];
     // TODO 延迟渲染
@@ -63,7 +62,7 @@ async function load() {
       confirmButtonText: '好的',
       width: '350px',
     });
-    await router.replace(`/camera`);
+    await Flow.router.push(`/camera`);
   } finally {
     loading.value = false;
     first.value = false;
@@ -102,7 +101,7 @@ load();
             :disabled="loading"
           />
         </div>
-        <van-button class="decorate" block :loading :disabled="nextLoading" @click="handle()">提交</van-button>
+        <van-button class="decorate" block :loading @click="handle()">提交</van-button>
       </template>
       <van-toast v-else-if="first" :show="loading" type="loading" message="加载中" />
     </div>

+ 31 - 65
src/modules/report/report-analyse.page.vue

@@ -1,20 +1,27 @@
 <script setup lang="ts">
-import { useRouter } from 'vue-router';
 import { useRequest } from 'alova/client';
 import { getAnalysisResultsMethod } from '@/request/api';
-import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
-
+import { useFlowStore } from '@/stores';
 import { Dialog } from '@/platform';
+
 import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+import NavNextSelect from '@/assets/images/next-step.svg?url';
 
+import MiniProgram from '@/components/MiniProgram.vue';
 
-const router = useRouter();
+const Flow = useFlowStore();
+const { flow } = storeToRefs(Flow);
+const handle = () => Flow.router.push();
+const done = () => Flow.router.done();
 
+const miniProgramRef = useTemplateRef<InstanceType<typeof MiniProgram>>('mini-program');
+const scrollable = ref(true);
+const closeable = computed(() => !data.value.payLock || !!flow.value.next);
 const { data, loading, error } = useRequest(getAnalysisResultsMethod, { initialData: { tongue: {}, face: {} } })
   .onSuccess(({ data }) => {
-    if ( data?.miniProgramURL ) {
-      panelProps.anchors[ 0 ] = 100;
-      if ( data.payLock ) panelOpen(100);
+    if (data?.miniProgramURL && data.payLock) {
+      scrollable.value = false;
+      nextTick(() => miniProgramRef.value?.open());
     }
   })
   .onError(async ({ error }) => {
@@ -25,38 +32,15 @@ const { data, loading, error } = useRequest(getAnalysisResultsMethod, { initialD
       confirmButtonText: '好的',
       width: '350px',
     });
-    await router.replace(`/camera`);
-});
-
-const panelHeight = ref(0);
-const panelProps = reactive({
-  anchors: [0, window.innerWidth],
-  contentDraggable: false,
-  lockScroll: true,
-});
-const panelOpen = (min?: number) => {
-  if ( min ) panelProps.anchors[ 0 ] = min;
-  panelHeight.value = panelProps.anchors[1];
-};
-
-const scrollable = computed(() => !data.value.payLock && panelHeight.value < panelProps.anchors[ 1 ] || panelHeight.value === 0);
-
-const { flow, loading: nextLoading } = useRouteNext({ immediate: true });
-
-const showNext = computed(() => flow.value?.next.route && flow.value?.next.route !== '/screen');
-
-function next() {
-  router.push({ path: getRoutePath(flow.value?.next), replace: true })
-}
+    await Flow.router.push(`/camera`);
+  });
 </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 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>
@@ -66,47 +50,29 @@ function next() {
     </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="flex-auto" :class="[scrollable ? 'overflow-y-auto' : 'overflow-hidden']">
           <div class="my-6 text-primary text-2xl text-center" v-if="data.date">报告日期:{{ data.date }}</div>
           <AnalysisComponent title="舌象分析" exception-type="list" v-bind="data.tongue"></AnalysisComponent>
           <AnalysisComponent title="面象分析" exception-type="group" v-bind="data.face"></AnalysisComponent>
-          <div class="m-4" v-if="!nextLoading && showNext">
-            <div class="m-auto size-16 cursor-pointer">
-              <img class="size-full" src="@/assets/images/next-step.svg" alt="提交" @click="next()">
-            </div>
-          </div>
-          <div :style="{ height: panelHeight + 'px' }"><!--补偿面板打开高度--></div>
         </div>
       </van-skeleton>
-    </div>
-    <van-floating-panel v-model:height="panelHeight" v-bind="panelProps">
-      <template #header>
-        <div class="van-floating-panel__header !justify-between">
-          <div></div>
-          <div class="van-floating-panel__header-bar"></div>
-          <div>
-            <van-icon v-if="!data.payLock" name="cross" @click.stop="panelHeight = panelProps.anchors[0];" />
-          </div>
-        </div>
-      </template>
-      <Transition>
-        <div class="panel-content">
-          <img
-            class="size-full object-contain"
-            v-if="panelHeight === panelProps.anchors[1] || panelHeight > panelProps.anchors[0] * 1.5"
-            :src="data.miniProgramURL"
-            alt="小程序码"
-          />
-          <div class="flex justify-center items-center" v-else @click="panelOpen()">
-            <img class="h-10 w-10" src="@/assets/images/mini-program.svg" alt="小程序" />
-            <span class="text-lg ml-2">小程序</span>
-          </div>
+      <div class="flex-none flex justify-between py-2 nav-wrapper" style="background-color: #12312c">
+        <mini-program ref="mini-program" :url="data.miniProgramURL" :closeable="closeable"></mini-program>
+        <div v-if="flow.next && !loading" class="nav m-auto min-w-16 text-center hover:text-primary" @click="handle">
+          <img class="nav-img" :src="NavNextSelect" alt="下一步" />
+          <div class="mt-2">获取健康调理方案</div>
         </div>
-      </Transition>
-    </van-floating-panel>
+      </div>
+    </div>
   </div>
 </template>
 <style scoped lang="scss">
+.nav-img {
+  margin: auto;
+  width: 36px;
+  height: 36px;
+  object-fit: scale-down;
+}
 .report-wrapper {
   .panel-content {
     padding: 0 var(--van-floating-panel-header-height) var(--van-floating-panel-header-height);

+ 13 - 11
src/modules/report/report.page.vue

@@ -1,5 +1,4 @@
 <script setup lang="ts">
-import NavMiniProgram    from '@/assets/images/mini-program.svg?url';
 import NavHomeSelect     from '@/assets/images/nav-home.select.png?url';
 import NavPrint          from '@/assets/images/nav-print.png?url';
 import NavScheme         from '@/assets/images/nav-scheme.png?url';
@@ -11,14 +10,17 @@ 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';
+import { useFlowStore, useVisitor } from '@/stores';
 const miniProgramRef = useTemplateRef<InstanceType<typeof MiniProgram>>('mini-program');
 
 const hidePulseExceptionTemplate = computed(() => platformIsAIO())
 
-const id = useRouteParams<string>('id');
+const Visitor = useVisitor();
+const id = useRouteParams<string>('id', Visitor.reportId);
+const scrollable = ref(true);
+const closeable = computed(() => !data.value.payLock);
 const { data, loading } = useWatcher(() => getReportMethod(id.value), [ id ], {
   initialData: {
     descriptionsTable: { column: [], data: [] },
@@ -29,6 +31,7 @@ const { data, loading } = useWatcher(() => getReportMethod(id.value), [ id ], {
   immediate: true,
 }).onSuccess(({ data }) => {
   if ( data?.miniProgramURL && data.payLock ) {
+    scrollable.value = false;
     nextTick(() => miniProgramRef.value?.open());
   }
 })
@@ -77,11 +80,10 @@ async function print() {
   }
 }
 
-const router = useRouter();
-
+const Flow = useFlowStore();
 function toggle() {
-  const path = `${ router.currentRoute.value.fullPath }/scheme`.replace(/\/{2,}/g, '/');
-  router.replace({ path });
+  const path = `${Flow.current}/${id.value}/scheme`.replace(/\/{2,}/g, '/') as any
+  Flow.router.push(path);
 }
 </script>
 <template>
@@ -101,7 +103,7 @@ function toggle() {
     </div>
     <div class="page-content flex flex-col overflow-hidden">
       <van-skeleton class="flex-auto" title :row="3" :loading>
-        <div class="flex-auto overflow-y-auto">
+        <div class="flex-auto" :class="[scrollable ? 'overflow-y-auto' : 'overflow-hidden']">
           <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>
@@ -196,12 +198,12 @@ function toggle() {
         </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" v-if="data.scheme" @click="toggle()">
+        <div v-if="data.page?.jumpable && data.scheme" 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 class="m-auto min-w-16 text-center hover:text-primary" @click="print()">
+        <mini-program ref="mini-program" :url="data.miniProgramURL" :closeable="closeable"></mini-program>
+        <div v-if="data.page?.printable" 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 class="nav-img" v-else :src="NavPrint" alt="打印">
           <div class="mt-2">打印</div>

+ 7 - 2
src/modules/report/scheme.page.vue

@@ -12,11 +12,15 @@ import MiniProgram from '@/components/MiniProgram.vue';
 import type { SchemeGoodsProps } from '@/request/model';
 import { createReusableTemplate } from '@vueuse/core';
 import { Toast } from '@/platform';
+import { useVisitor } from '@/stores';
 
 const miniProgramRef = useTemplateRef<InstanceType<typeof MiniProgram>>('mini-program');
 
 const route = useRoute();
-const id = useRouteParams<string>('id');
+const Visitor = useVisitor();
+const id = useRouteParams<string>('id', Visitor.reportId);
+const scrollable = ref(true);
+const closeable = computed(() => !data.value.payLock);
 const { data, loading } = useWatcher(() => getReportSchemeMethod(id.value, !route.meta.toggle), [id], {
   initialData: {
     children: [],
@@ -24,6 +28,7 @@ const { data, loading } = useWatcher(() => getReportSchemeMethod(id.value, !rout
   immediate: true,
 }).onSuccess(({ data }) => {
   if ( data?.miniProgramURL && data.payLock ) {
+    scrollable.value = false;
     nextTick(() => miniProgramRef.value?.open());
   }
 });
@@ -106,7 +111,7 @@ async function openGoodsPanel(goods: SchemeGoodsProps, event?: Event | string) {
     <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" ref="container">
+        <div class="flex-auto px-6" :class="[scrollable ? 'overflow-y-auto' : 'overflow-hidden']" 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">

+ 49 - 0
src/pages/crossing.page.vue

@@ -0,0 +1,49 @@
+<script setup lang="ts">
+import { tryOnUnmounted, useCountdown } from '@vueuse/core';
+import { useFlowStore } from '@/stores';
+
+const Flow = useFlowStore();
+const { flow } = storeToRefs(Flow);
+
+const handle = () => Flow.router.push();
+const done = () => Flow.router.done();
+
+const { remaining, start, stop } = useCountdown(5, {
+  onComplete: done,
+  immediate: false,
+});
+const countdown = computed(() => remaining.value.toString().padStart(2, '0'));
+
+watchEffect(() => {
+  const countdown = flow.value?.done?.countdown;
+  if (countdown) start(countdown);
+});
+
+tryOnUnmounted(stop);
+</script>
+
+<template>
+  <div class="page-content flex flex-col">
+    <header v-if="flow?.crossing" class="flex flex-col justify-center px-24" style="flex: 1 1 20%">
+      <div class="text-3xl text-center">{{ flow.crossing?.title }}</div>
+    </header>
+    <main class="flex flex-col justify-evenly px-24" style="flex: 1 1 50%">
+      <img class="size-40 mx-auto" src="@/assets/images/tips.png" />
+      <div>
+        <div class="text-3xl text-center">恭喜您!</div>
+        <div class="text-3xl text-center my-8">{{ flow?.crossing?.description }}</div>
+      </div>
+    </main>
+    <footer class="flex flex-col items-center gap-4" style="flex: 1 1 30%">
+      <van-button v-if="flow?.next" class="decorate !text-xl" @click="handle">
+        {{ flow?.done?.title ?? '下一步' }}
+      </van-button>
+      <van-button v-if="flow?.done" class="decorate !text-xl !text-primary-400" @click="done">
+        返回首页
+        <template v-if="flow.done?.countdown">({{ countdown }}s)</template>
+      </van-button>
+    </footer>
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

+ 4 - 12
src/pages/register.page.vue

@@ -1,18 +1,17 @@
 <script setup lang="ts">
 import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
-import { Notify, Toast } from '@/platform';
+import { Toast } from '@/platform';
 
 import { getCaptchaMethod, registerAccountMethod, registerFieldsMethod, dictionariesMethod, searchAccountMethod } from '@/request/api';
 import type { CascaderOption, 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 { RadioGroup as vanRadioGroup } from 'vant';
 import PickerDialog from '@/components/PickerDialog.vue';
 import CascaderDialog from '@/components/CascaderDialog.vue';
+import { useFlowStore } from '@/stores';
 
 const formRef = useTemplateRef<FormInstance>('register-form');
 const modelRef = ref<Partial<RegisterModel>>({ code: '' });
@@ -27,16 +26,9 @@ const { data: fields, loading } = useSerialRequest([dictionariesMethod, (diction
   }
 });
 
-const router = useRouter();
-
+const flow = useFlowStore();
 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; },
+  flow.router.push();
 });
 
 const { loading: searching, send: search } = useRequest((data) => searchAccountMethod(data), {

+ 6 - 0
src/polyfill.ts

@@ -12,4 +12,10 @@ if (typeof Promise.withResolvers !== 'function') {
   };
 }
 
+if (typeof Array.prototype.at !== 'function') {
+  Array.prototype.at = function (index: number) {
+    return index < 0 ? this[this.length + index] : this[index];
+  };
+}
+
 export {};

+ 3 - 2
src/request/api/account.api.ts

@@ -1,6 +1,7 @@
 import { cacheFor } from '@/request/api/index';
 import type { CascaderOption, Dictionaries, Fields, RegisterModel } from '@/request/model';
-import { fromRegisterFields, getPath} from '@/request/model';
+import { fromRegisterFields } from '@/request/model';
+import { getRoutePath } from '@/router';
 import { useVisitor } from '@/stores';
 import { getClientURL } from '@/tools';
 
@@ -67,7 +68,7 @@ export function scanAccountMethod(key: string) {
       const { id: patientId, processModule, ..._data } = data;
       if ( patientId ) useVisitor().patientId = patientId;
       return {
-        key, patientId, path: getPath(processModule),
+        key, patientId, path: getRoutePath(processModule),
         model: Object.fromEntries(Object.entries(_data).filter(([ item, value ]) => !!value)) as Partial<RegisterModel>,
       };
     },

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

@@ -1,35 +0,0 @@
-import { cacheFor }     from '@/request/api/index';
-import { FlowMap }      from '@/request/model';
-import type { FlowKey } from '@/request/model';
-import Router           from '@/router';
-
-import HTTP from '../alova';
-
-
-export function copyrightMethod() {
-  return HTTP.Post(`/fdhb-tablet/warrantManage/getPageSets`, void 0, {
-    cacheFor, name: `variate:copyright`,
-    params: { k: 'copyright' },
-    meta: { ignoreException: true },
-    transform(data: any, headers) {
-      if ( !data ) { throw { message: `无效的证书` }; }
-      return [ data?.partner, data?.technicalSupporter ].filter(Boolean).join('<br>');
-    },
-  });
-}
-
-export function processMethod() {
-  return HTTP.Post(`/fdhb-tablet/warrantManage/getPageSets`, void 0, {
-    cacheFor, name: `variate:process`,
-    params: { k: 'process' },
-    transform(data: any, headers) { return new FlowMap(data?.tabletProcessModules ?? []); },
-  });
-}
-
-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;
-}

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

@@ -2,7 +2,6 @@ import HTTP from '@/request/alova';
 import { application } from '@/request/model';
 
 export * from './account.api';
-export * from './flow.api';
 export * from './questionnaire.api';
 export * from './report.api';
 

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

@@ -1,12 +1,18 @@
-import { useVisitor } from '@/stores';
+import { useFlowStore, useVisitor }                   from '@/stores';
 import HTTP                                           from '../alova';
 import type { QuestionnaireStorage }                  from '../model';
-import { getAnalysisExtendFlowValue }                 from '../model';
 import { fromQuestionnaireData, toQuestionnaireData } from '../model';
 
 
 let storage: Pick<QuestionnaireStorage, 'dialogId'> & { questions: QuestionnaireStorage['questions'][] } = { questions: [] };
 
+function getAnalysisExtendFlowValue() {
+  const Flow = useFlowStore();
+  return [
+    Flow.router.hasNext('/alcohol/result') ? 'alcohol' : void 0,
+  ].filter(Boolean).join(',');
+}
+
 export function questionnaireMethod(data = []) {
   const Visitor = useVisitor();
   if ( !data?.length ) { storage = { questions: [] }; }

+ 33 - 28
src/request/api/report.api.ts

@@ -23,11 +23,11 @@ export function getAnalysisResultsMethod() {
           if (!tongue.result && !face.result) {
             message = data.content;
           } else {
-            const { show, force } = await miniProgramMethod('report');
+            const { miniProgram, payLock } = await getReportOptionsMethod('tongueface_upload_report_page');
             return {
-              id, date, miniProgramURL: show ? miniProgramURL : void 0,
+              id, date,
               tongue, face,
-              payLock: show && force,
+              miniProgramURL: miniProgram ? miniProgramURL : void 0, payLock,
             };
           }
         }
@@ -47,9 +47,10 @@ export function getReportMethod(id: string) {
       if (params.patientId !== patientId ) Visitor.$reset()
       const report = fromReportData(<any> data);
       Visitor.updatePulseReport(report.pulse, patientId);
-      const { show, force } = await miniProgramMethod('report');
-      if ( !show ) { report.miniProgramURL = void 0; }
-      report.payLock = show && force;
+      const { miniProgram, payLock, ...page } = await getReportOptionsMethod('health_analysis_report_page');
+      if ( !miniProgram ) { report.miniProgramURL = void 0; }
+      report.payLock = payLock;
+      report.page = { jumpable: true, printable: true, ...page };
       return report;
     },
   });
@@ -87,34 +88,38 @@ export function getReportSchemeMethod(id: string, standalone?: boolean) {
     params,
     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;
+      if (standalone) {
+        const { miniProgram, payLock } = await getReportOptionsMethod('health_analysis_scheme_page');
+        if ( !miniProgram ) { scheme.miniProgramURL = void 0; }
+        scheme.payLock = payLock;
+      } else {
+        scheme.miniProgramURL = void 0;
+      }
       return scheme;
     },
   });
 }
 
-
-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_${type}` },
-      transform(data, headers) {
+export function getReportOptionsMethod(prefix: 'health_analysis_report_page' | 'health_analysis_scheme_page' | 'tongueface_upload_report_page') {
+  return HTTP.Post<{ miniProgram: boolean; payLock: boolean; jumpable?: boolean; printable?: boolean }, { tabletRequiredPageOperationElements: string[] }>(
+    `/fdhb-tablet/warrantManage/getPageSets?${prefix}`,
+    void 0,
+    {
+      cacheFor,
+      transform(data) {
         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: cfg.includes(`health_analysis_${type}_page_appletbutton`),
-          force: cfg.includes(`health_analysis_${type}_page_appletscan`),
-        };
+        const miniProgram = cfg.includes(`${prefix}_appletbutton`);
+        const payLock = miniProgram && cfg.includes(`${prefix}_appletscan`);
+        return prefix === 'health_analysis_report_page'
+          ? {
+            miniProgram,
+            payLock,
+            jumpable: !cfg.includes(`${prefix}_notjump`),
+            printable: !cfg.includes(`${prefix}_notprint`),
+          }
+          : { miniProgram, payLock };
       },
-    });
+    }
+  );
 }
 

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

@@ -1,88 +0,0 @@
-const ROUTE_START = '/screen';
-
-const Routes = {
-  'patient_file': /* 建档页 */ '/register',
-  'tongueface_upload': /*拍照页*/ '/camera',
-  'tongueface_upload_result': /* [虚拟] 拍照结果页 */ '/camera/result',
-  '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': 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: FlowKey;
-  title?: string;
-  optional?: boolean;
-}
-
-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 length = options.length;
-    if (length === 0) return;
-
-    // 修正虚拟路由
-    const k1 = 'tongueface_upload';
-    const k2 = 'tongueface_upload_result';
-    if ( !options.includes(k2) && options[ length - 1 ] === k1 ) options.push(k2);
-
-    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(','));
-  }
-
-  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()];
-
-    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) || ''
-}

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

@@ -1,6 +1,5 @@
 import { analysis } from '@/tools/regex';
 
-export * from './flow.model';
 export * from './register.model';
 export * from './analysis.model';
 export * from './questionnaire.model';

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

@@ -43,6 +43,8 @@ export function fromReportData(data: Record<string, any>) {
     reportURL: data?.reportPdfUrl,
     miniProgramURL: data?.appletImg,
     payLock: data?.payLock,
+
+    page: { jumpable: true, printable: true },
   };
 }
 

+ 23 - 4
src/router/index.ts

@@ -9,15 +9,34 @@ const router = createRouter({
     { 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: '/camera/result', component: () => import('@/modules/report/report-analyse.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/analysis', redirect: '/camera/result' },
     { 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: '/report/:id(\\w+)?', component: () => import('@/modules/report/report.page.vue'), meta: { title: '健康分析报告' } },
+    { path: '/scheme/:id(\\w+)?', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案', toggle: false } },
+    { path: '/crossing/:flow(\\w+)?', component: () => import('@/pages/crossing.page.vue') },
     { path: '/', redirect: '/screen' },
   ],
 });
 
 export default router;
+export const flow_start = 'start';
+export const flow_router = {
+  'patient_file': '/register',
+  'pulse_upload': '/pulse',
+  'pulse_upload_result': '/pulse/result',
+  'tongueface_upload': '/camera',
+  'tongueface_analysis_result': '/camera/result',
+  'tongueface_analysis': '/questionnaire',
+  'alcohol_upload_result': '/alcohol/result',
+  'health_analysis': '/report',
+  'health_analysis_scheme': '/scheme',
+  [flow_start]: '/screen',
+} as const;
+
+export function getRoutePath(key?: string) {
+  key = key?.split('?')?.[0];
+  return flow_router[key as keyof typeof flow_router];
+}

+ 135 - 0
src/stores/flow.store.ts

@@ -0,0 +1,135 @@
+import { defineStore } from 'pinia';
+import { flow_router, flow_start } from '@/router';
+
+type Record<K extends keyof any, T> = {
+  [P in K]?: T;
+};
+
+type Values<T> = T[keyof T];
+
+type CrossingKey = '/register' | '/camera' | '/questionnaire';
+export type FlowKey = Values<typeof flow_router> | `/crossing${CrossingKey}`;
+export type Flow = {
+  optional?: boolean;
+  done?: { path: '/screen'; countdown: number; title: string };
+  crossing?: { title: string; description?: string };
+  prev: FlowKey;
+  next: FlowKey;
+};
+
+export const useFlowStore = defineStore('flow', () => {
+  const _router = useRouter();
+  const _route = useRoute();
+  const _flow = ref<Record<FlowKey, Flow>>({});
+
+  const current = computed(() => {
+    let path = _route.path;
+    if (!path.startsWith('/crossing')) {
+      const source = Object.values(_route.params)
+        .filter(Boolean)
+        .map((value) => `(?:\\/${value})`)
+        .join('');
+      path = _route.path.replace(new RegExp(source, 'g'), '');
+    }
+    return path as FlowKey;
+  });
+  const flow = computed(() => {
+    return _flow.value[current.value] as Flow;
+  });
+
+  const $init = (config: string[]) => {
+    config = polyfill(config);
+    const crossing_ref: Record<CrossingKey, Flow['crossing']> = {
+      '/register': {
+        title: `建档完成`,
+        description: `完成建档的填写`,
+      },
+      '/camera': {
+        title: `拍摄完成`,
+        description: `完成舌面象的采集`,
+      },
+      '/questionnaire': {
+        title: `问卷完成`,
+        description: `完成问卷的采集`,
+      },
+    };
+    const countdown_ref: Record<FlowKey, number> = {
+      '/register': 10,
+      '/questionnaire': 10,
+      '/pulse': 20,
+      '/camera': 20,
+      '/alcohol/result': 60,
+    };
+    const optional_ref: Record<FlowKey, { optional: true }> = {};
+
+    /* 设置 flow 流程节点数据 */
+    const set = (prev: FlowKey | void, current: FlowKey, next: string, { optional, ...value }: Partial<Flow> = {}) => {
+      _flow.value[current] = Object.assign(value, { prev, next: flow_router[next as keyof typeof flow_router] ?? next }, optional_ref[current]) as Flow;
+      if (value.next === '/screen') Reflect.deleteProperty(value, 'next');
+      else if (optional) optional_ref[value.next!] = { optional };
+      if (value.crossing == null) Reflect.deleteProperty(value, 'crossing');
+      return [current, value.next!];
+    };
+
+    /* 统一参数配置 */
+    if (config.at(-1) !== flow_start) config.push(`${flow_start}?`);
+    /* 遍历参数配置 */
+    const start = config[0] === flow_start ? config.shift()! : flow_start;
+    let current: FlowKey = flow_router[start as keyof typeof flow_router];
+    let prev: FlowKey | void = void 0;
+    do {
+      let next = config.shift();
+      if (next?.includes('?')) {
+        const [value, title = '获取健康调理方案', countdown = countdown_ref[current] ?? 0] = next?.split('?').map((value) => value || void 0) ?? [];
+        // 检查是否添加 [虚拟结果] 节点
+        const crossing = crossing_ref[current as CrossingKey];
+        if (crossing) [prev, current] = set(prev, current, `/crossing${current}`);
+        [prev, current] = set(prev, current, value!, { optional: true, done: { path: '/screen', countdown: +countdown, title }, crossing });
+      } else if (next) {
+        [prev, current] = set(prev, current, next);
+      }
+    } while (config.length);
+  };
+
+  const $reset = () => {
+    _flow.value = {};
+  };
+
+  const router = {
+    push(path?: FlowKey) {
+      path ??= flow.value?.next ?? '/screen';
+      return _router.push({ path, replace: true });
+    },
+    done(path: FlowKey = '/screen') {
+      return _router.push({ path, replace: true });
+    },
+    hasNext(key: FlowKey) {
+      const flow = _flow.value;
+      let value: FlowKey | undefined = current.value;
+      do {
+        const next: FlowKey | undefined = flow[value]?.next;
+        console.log('log', value, next, key);
+        if (next === key) return true;
+        value = next;
+      } while (value);
+      console.log(current.value, _flow.value);
+      return false;
+    },
+  };
+
+  return { flow, current, router, $init, $reset };
+});
+
+/**
+ * 移除虚拟节点:tongueface_upload_result
+ */
+function polyfill(config: string[]) {
+  const index = config.findIndex((value) => value.startsWith('tongueface_upload_result'));
+  if (index >= 0) {
+    const prev = config[index - 1]?.startsWith('tongueface_upload');
+    const next = config[index + 1];
+    if (prev && next && !next.includes('?')) config.splice(index, 2, `${next}?`);
+    else config.splice(index, 1);
+  }
+  return [...config];
+}

+ 1 - 0
src/stores/index.ts

@@ -14,4 +14,5 @@ pinia.use(persistedState);
 export default pinia;
 export { usePlatformStore }     from './platform.store';
 export { useAccountStore }      from './account.store';
+export { useFlowStore }         from './flow.store';
 export { useVisitor }     from './visitor.store';

+ 4 - 1
src/themes/index.scss

@@ -20,7 +20,10 @@
     flex: auto;
   }
 }
-
+.page-header {
+  position: relative;
+  z-index: 2;
+}
 :root:root {
   --primary-color: #34a76b;
   --primary-color-hover: #38ff6e;