Przeglądaj źródła

重构 step 类页面,添加浸泡管理

cc12458 1 miesiąc temu
rodzic
commit
e576e5ded9

+ 1 - 0
package.json

@@ -23,6 +23,7 @@
     "@tailwindcss/vite": "^4.0.15",
     "@vueuse/components": "^13.0.0",
     "@vueuse/core": "^13.0.0",
+    "@vueuse/router": "^13.4.0",
     "alova": "^3.2.10",
     "big.js": "^6.2.2",
     "eruda": "^3.4.1",

+ 24 - 57
pnpm-lock.yaml

@@ -17,6 +17,9 @@ importers:
       '@vueuse/core':
         specifier: ^13.0.0
         version: 13.0.0(vue@3.5.13(typescript@5.8.2))
+      '@vueuse/router':
+        specifier: ^13.4.0
+        version: 13.4.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
       alova:
         specifier: ^3.2.10
         version: 3.2.10
@@ -26,9 +29,6 @@ importers:
       eruda:
         specifier: ^3.4.1
         version: 3.4.1
-      mathjs:
-        specifier: ^14.3.1
-        version: 14.3.1
       pinia:
         specifier: ^3.0.1
         version: 3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
@@ -1521,11 +1521,22 @@ packages:
   '@vueuse/metadata@13.0.0':
     resolution: {integrity: sha512-TRNksqmvtvqsuHf7bbgH9OSXEV2b6+M3BSN4LR5oxWKykOFT9gV78+C2/0++Pq9KCp9KQ1OQDPvGlWNQpOb2Mw==}
 
+  '@vueuse/router@13.4.0':
+    resolution: {integrity: sha512-3NL9RFgTiWN4SVp6CUFK/9db10BnoLU3iX+TRgcG4HEuR7GC1g+yMqxe33L6kjUTv8Dc9WeaER714vGBc9Xyjg==}
+    peerDependencies:
+      vue: ^3.5.0
+      vue-router: ^4.0.0
+
   '@vueuse/shared@13.0.0':
     resolution: {integrity: sha512-9MiHhAPw+sqCF/RLo8V6HsjRqEdNEWVpDLm2WBRW2G/kSQjb8X901sozXpSCaeLG0f7TEfMrT4XNaA5m1ez7Dg==}
     peerDependencies:
       vue: ^3.5.0
 
+  '@vueuse/shared@13.4.0':
+    resolution: {integrity: sha512-+AxuKbw8R1gYy5T21V5yhadeNM7rJqb4cPaRI9DdGnnNl3uqXh+unvQ3uCaA2DjYLbNr1+l7ht/B4qEsRegX6A==}
+    peerDependencies:
+      vue: ^3.5.0
+
   abbrev@2.0.0:
     resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -1823,9 +1834,6 @@ packages:
     resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==}
     engines: {node: '>=4.0.0'}
 
-  complex.js@2.4.2:
-    resolution: {integrity: sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==}
-
   concat-map@0.0.1:
     resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
 
@@ -2036,9 +2044,6 @@ packages:
     resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
     engines: {node: '>=6'}
 
-  escape-latex@1.2.0:
-    resolution: {integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==}
-
   escape-string-regexp@1.0.5:
     resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
     engines: {node: '>=0.8.0'}
@@ -2252,10 +2257,6 @@ packages:
     resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
     engines: {node: '>= 6'}
 
-  fraction.js@5.2.1:
-    resolution: {integrity: sha512-Ah6t/7YCYjrPUFUFsOsRLMXAdnYM+aQwmojD2Ayb/Ezr82SwES0vuyQ8qZ3QO8n9j7W14VJuVZZet8U3bhSdQQ==}
-    engines: {node: '>= 12'}
-
   from@0.1.7:
     resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==}
 
@@ -2528,9 +2529,6 @@ packages:
   jackspeak@3.4.3:
     resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
 
-  javascript-natural-sort@0.7.1:
-    resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
-
   jiti@2.4.2:
     resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
     hasBin: true
@@ -2754,11 +2752,6 @@ packages:
     resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
     engines: {node: '>= 0.4'}
 
-  mathjs@14.3.1:
-    resolution: {integrity: sha512-tgN2/TQXWVHegcl/Yw/Rkd/yhq0CagK5KnWmKUJ2/Cz/2JZoq4K2Ili9B3wZqvxaof7TVNoXbyKmA3CV3P9ZsA==}
-    engines: {node: '>= 18'}
-    hasBin: true
-
   memorystream@0.3.1:
     resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
     engines: {node: '>= 0.10.0'}
@@ -3315,9 +3308,6 @@ packages:
   scule@1.3.0:
     resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
 
-  seedrandom@3.0.5:
-    resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
-
   semver@6.3.1:
     resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
     hasBin: true
@@ -3501,9 +3491,6 @@ packages:
   through@2.3.8:
     resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
 
-  tiny-emitter@2.1.0:
-    resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
-
   tinybench@2.9.0:
     resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
 
@@ -3580,10 +3567,6 @@ packages:
     resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
     engines: {node: '>=10'}
 
-  typed-function@4.2.1:
-    resolution: {integrity: sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==}
-    engines: {node: '>= 18'}
-
   typescript-eslint@8.27.0:
     resolution: {integrity: sha512-ZZ/8+Y0rRUMuW1gJaPtLWe4ryHbsPLzzibk5Sq+IFa2aOH1Vo0gPr1fbA6pOnzBke7zC2Da4w8AyCgxKXo3lqA==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -5455,10 +5438,20 @@ snapshots:
 
   '@vueuse/metadata@13.0.0': {}
 
+  '@vueuse/router@13.4.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))':
+    dependencies:
+      '@vueuse/shared': 13.4.0(vue@3.5.13(typescript@5.8.2))
+      vue: 3.5.13(typescript@5.8.2)
+      vue-router: 4.5.0(vue@3.5.13(typescript@5.8.2))
+
   '@vueuse/shared@13.0.0(vue@3.5.13(typescript@5.8.2))':
     dependencies:
       vue: 3.5.13(typescript@5.8.2)
 
+  '@vueuse/shared@13.4.0(vue@3.5.13(typescript@5.8.2))':
+    dependencies:
+      vue: 3.5.13(typescript@5.8.2)
+
   abbrev@2.0.0: {}
 
   acorn-jsx@5.3.2(acorn@8.14.1):
@@ -5740,8 +5733,6 @@ snapshots:
 
   common-tags@1.8.2: {}
 
-  complex.js@2.4.2: {}
-
   concat-map@0.0.1: {}
 
   confbox@0.1.8: {}
@@ -5981,8 +5972,6 @@ snapshots:
 
   escalade@3.2.0: {}
 
-  escape-latex@1.2.0: {}
-
   escape-string-regexp@1.0.5: {}
 
   escape-string-regexp@4.0.0: {}
@@ -6244,8 +6233,6 @@ snapshots:
       es-set-tostringtag: 2.1.0
       mime-types: 2.1.35
 
-  fraction.js@5.2.1: {}
-
   from@0.1.7: {}
 
   fs-extra@11.3.0:
@@ -6490,8 +6477,6 @@ snapshots:
     optionalDependencies:
       '@pkgjs/parseargs': 0.11.0
 
-  javascript-natural-sort@0.7.1: {}
-
   jiti@2.4.2: {}
 
   joi@17.13.3:
@@ -6704,18 +6689,6 @@ snapshots:
 
   math-intrinsics@1.1.0: {}
 
-  mathjs@14.3.1:
-    dependencies:
-      '@babel/runtime': 7.26.10
-      complex.js: 2.4.2
-      decimal.js: 10.5.0
-      escape-latex: 1.2.0
-      fraction.js: 5.2.1
-      javascript-natural-sort: 0.7.1
-      seedrandom: 3.0.5
-      tiny-emitter: 2.1.0
-      typed-function: 4.2.1
-
   memorystream@0.3.1: {}
 
   meow@13.2.0: {}
@@ -7216,8 +7189,6 @@ snapshots:
 
   scule@1.3.0: {}
 
-  seedrandom@3.0.5: {}
-
   semver@6.3.1: {}
 
   semver@7.7.1: {}
@@ -7406,8 +7377,6 @@ snapshots:
 
   through@2.3.8: {}
 
-  tiny-emitter@2.1.0: {}
-
   tinybench@2.9.0: {}
 
   tinyexec@0.3.2: {}
@@ -7465,8 +7434,6 @@ snapshots:
 
   type-fest@0.21.3: {}
 
-  typed-function@4.2.1: {}
-
   typescript-eslint@8.27.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2):
     dependencies:
       '@typescript-eslint/eslint-plugin': 8.27.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2)

+ 3 - 0
request/pharmacy.request.ts

@@ -18,6 +18,9 @@ const PharmacyHttpClient = createAlova({
     async onSuccess(response, method) {
       if (response.status === 200) {
         return analysisResponseBody(response, method);
+      } else {
+        const message = response.statusText;
+        throw method.meta?.exception?.(message, response, method) ?? message;
       }
     },
     async onError(error, method) {},

+ 1 - 1
src/api/account.api.ts

@@ -17,7 +17,7 @@ export function loginMethod(data: { username: string; password: string }) {
 
 export function getAccountMethod(token: string) {
   return requestMethodFactory(
-    SystemHttpClient.Get<{ token: string; account: AccountModel }, ResponseData>(`user/getInfo`, {
+    SystemHttpClient.Get<{ token: string; account: AccountModel }, ResponseData>(`/user/getInfo`, {
       headers: { Authorization: token },
       transform(data) {
         const user = data?.user;

+ 12 - 0
src/api/file.api.ts

@@ -0,0 +1,12 @@
+import FileHttpClient from '@request/file.request.ts';
+import type { ResponseData } from 'alova';
+
+export function uploadMethod(file: File) {
+  const data = new FormData();
+  data.append('file', file);
+  return FileHttpClient.Post<string, ResponseData>(`/upload`, data, {
+    transform(data) {
+      return data?.url;
+    },
+  });
+}

+ 15 - 11
src/api/pda.api.ts

@@ -1,12 +1,13 @@
 import type { ResponseData } from 'alova';
 import PharmacyHttpClient from '@request/pharmacy.request.ts';
-import type { StepMode } from '@/model/step.model.ts';
 import { requestMethodFactory } from '@/platform/request.ts';
-import FileHttpClient from '@request/file.request.ts';
+
+import type { SoakModel, StepModel } from '@/model/step.model.ts';
+import { fromSoakModel } from '@/model/step.model.ts';
 
 export function getDataMethod(no: string) {
   return requestMethodFactory(
-    PharmacyHttpClient.Get<StepMode, ResponseData>(`/web/pda/detailInfo`, {
+    PharmacyHttpClient.Get<StepModel, ResponseData>(`/web/pda/detailInfo`, {
       params: {
         orderNo: no,
         /**
@@ -91,12 +92,15 @@ export function setDataMethod(url: string, data: ResponseData) {
   return requestMethodFactory(PharmacyHttpClient.Post(url, data));
 }
 
-export function uploadMethod(file: File) {
-  const data = new FormData();
-  data.append('file', file);
-  return FileHttpClient.Post<string, ResponseData>(`/upload`, data, {
-    transform(data) {
-      return data?.url;
-    },
-  });
+export function getSoakDataMethod(id: string) {
+  return PharmacyHttpClient.Get<SoakModel, ResponseData>(`/web/pda/steep/${id}`, { transform: fromSoakModel });
+}
+
+export function setSoakDataMethod(model: Partial<SoakModel>, picture?: string[]) {
+  return requestMethodFactory(
+    PharmacyHttpClient.Put('/web/pda/steep/edit', {
+      ...model,
+      image: picture?.join(',') ?? '',
+    }),
+  );
 }

+ 2 - 1
src/components/StepDetailComponent.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { useForm } from 'alova/client';
-import { setDataMethod, uploadMethod } from '@/api/pda.api.ts';
+import { setDataMethod } from '@/api/pda.api.ts';
+import { uploadMethod } from '@/api/file.api.ts';
 import type { ToRef } from 'vue';
 import { showSuccessToast, type UploaderAfterRead, type UploaderFileListItem } from 'vant';
 import { useAccountStore, useStepStore } from '@/stores';

+ 41 - 0
src/core/hook/useScan.ts

@@ -0,0 +1,41 @@
+import { tryOnBeforeMount, tryOnUnmounted } from '@vueuse/core';
+import type { BridgeEventMap } from '../../../@types/bridge';
+
+export type ScanData = NonNullable<BridgeEventMap['scan']['detail']['data']>;
+
+export function useScan(callback: (data: ScanData) => void) {
+  let onCleanup = () => {};
+  tryOnBeforeMount(() => {
+    if (window.bridge) {
+      onCleanup = window.bridge.addEventListener('scan', (event) => {
+        event.stopPropagation();
+        event.preventDefault();
+        if (event.detail.code !== 0 || !event.detail.data?.code) return;
+        callback(event.detail.data);
+      });
+    } else if (window.platform) {
+      const update = (event: CustomEvent) => {
+        event.stopPropagation();
+        event.preventDefault();
+        if (!event.detail?.code) return;
+
+        callback({ code: event.detail.code, state: 0, type: -1 });
+      };
+      platform.addEventListener('scan', update);
+      onCleanup = () => platform.removeEventListener('scan', update);
+    }
+  });
+  tryOnUnmounted(() => onCleanup?.());
+
+  const scanValue = ref('');
+
+  async function scan(code?: string) {
+    if (code != null) return callback({ code, state: 0, type: -1 });
+    try {
+      const data = await Bridge.scan();
+      if (data?.code) callback(data);
+    } catch (e) {}
+  }
+
+  return { scanValue, scan };
+}

+ 22 - 0
src/core/hook/useStep.ts

@@ -0,0 +1,22 @@
+import { type Menu, type MenuPath, menus } from '@/router/menu.ts';
+
+export function useStep(mode: Ref<string>, id: Ref<string>) {
+  const menu = shallowRef<Menu | void>();
+  const tabTitle = computed(() => menu.value?.title.replace('管理', '确认') ?? '确认');
+
+  watchEffect(() => {
+    menu.value = menus[`/step/${mode.value}` as MenuPath] ?? { title: import.meta.env.SIX_TITLE };
+  });
+
+  const router = useRouter();
+
+  function next(value: string) {
+    return router.push({ path: `/step/${mode.value}/${value}`, replace: !!id.value });
+  }
+
+  function prev() {
+    return router.back();
+  }
+
+  return { menu, tabTitle, next, prev };
+}

+ 1 - 0
src/main.ts

@@ -2,6 +2,7 @@ import './themes/index.css';
 import { Lazyload } from 'vant';
 import 'vant/es/toast/style'
 import 'vant/es/notify/style'
+import 'vant/es/dialog/style'
 
 import './polyfill'
 

+ 0 - 4
src/model/menu.model.ts

@@ -1,4 +0,0 @@
-export const defaultMenus = [
-  { name: '调配管理', path: `/step/deploy` },
-  { name: '调配复核管理', path: `/step/deploy-recheck` },
-] as const;

+ 23 - 1
src/model/step.model.ts

@@ -1,6 +1,7 @@
 import type { DoctorModel, PatientModel } from '@model/account.model.ts';
+import type { ResponseData } from 'alova';
 
-export interface StepMode {
+export interface StepModel {
   id: string;
   no: string;
 
@@ -45,3 +46,24 @@ export interface StepMode {
     state: string;
   };
 }
+
+export interface SoakModel {
+  soakingNote: string;
+  soakingWaterValue: string;
+  soakingTime: string;
+  deviceCode: string;
+
+  images: string;
+}
+
+export function fromSoakModel(data?: ResponseData) {
+  return Object.assign(
+    {
+      soakingNote: data?.soakingNote ?? '',
+      soakingWaterValue: data?.soakingWaterValue ?? '',
+      soakingTime: data?.soakingTime ?? '',
+      deviceCode: data?.deviceCode ?? '',
+    },
+    data,
+  ) as SoakModel;
+}

+ 15 - 0
src/module/step/Step.vue

@@ -0,0 +1,15 @@
+<script setup lang="ts">
+defineOptions({
+  name: 'step-container',
+});
+</script>
+
+<template>
+  <van-form>
+    <van-cell-group inset>
+      <slot></slot>
+    </van-cell-group>
+  </van-form>
+</template>
+
+<style scoped lang="scss"></style>

+ 62 - 0
src/module/step/StepSoak.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import { useStepStore } from '@/stores';
+import { useForm, useWatcher } from 'alova/client';
+import { getSoakDataMethod, setSoakDataMethod } from '@/api/pda.api.ts';
+import { showNotify, showSuccessToast } from 'vant';
+import { fromSoakModel } from '@/model/step.model.ts';
+import type { ScanData } from '@/core/hook/useScan.ts';
+
+const emits = defineEmits<{ back: [delta?: number] }>();
+
+const storeStore = useStepStore();
+const { dataset } = storeToRefs(storeStore);
+
+const {
+  loading: submitting,
+  form: model,
+  send,
+} = useForm(setSoakDataMethod, { immediate: false, initialForm: fromSoakModel() }).onSuccess(() => {
+  showSuccessToast(`操作成功`);
+  emits('back');
+});
+
+const soakingTimeFieldReadonly = ref(false);
+useWatcher(() => getSoakDataMethod(dataset.value!.id), [dataset], { immediate: true })
+  .onSuccess(({ data }) => {
+    console.log(data);
+    model.value = data;
+    soakingTimeFieldReadonly.value = data?.soakingTime != null;
+  })
+  .onError(({ error: message }) => {
+    showDialog({ title: '温馨提示', message, closeOnClickOverlay: true }).then(() => emits('back'));
+  });
+
+function submit(picture: string[]) {
+  let message = '';
+  if (!+model.value.soakingWaterValue) {
+    message = '请输入浸泡水量';
+  } else if (!model.value.deviceCode) message = '请输入桶号';
+  if (message) {
+    showNotify({ message, type: 'warning' });
+  } else {
+    send(picture);
+  }
+}
+
+defineExpose({
+  scan(data: ScanData) {
+    model.value.deviceCode = data?.code ?? '';
+  },
+});
+</script>
+
+<template>
+  <van-field v-model="model.soakingWaterValue" :readonly="submitting" type="digit" label="浸泡水量" placeholder="请输入浸泡水量" />
+  <van-field v-model="model.soakingTime" :readonly="submitting || soakingTimeFieldReadonly" type="digit" label="浸泡时间" placeholder="请输入浸泡时间" />
+  <slot name="scanner" title="扫描桶号" :disabled="submitting"></slot>
+  <slot name="uploader" :disabled="submitting"></slot>
+  <van-field v-model="model.soakingNote" :readonly="submitting" rows="2" autosize label="浸泡备注" type="textarea" placeholder="请输入备注" />
+  <slot name="submit" title="浸泡节点上传" :submitting="submitting" :submit="submit"></slot>
+</template>
+
+<style scoped lang="scss"></style>

+ 1 - 3
src/pages/HomePage.vue

@@ -1,14 +1,12 @@
 <script setup lang="ts">
 import { useAccountStore, useStepStore } from '@/stores';
-import { defaultMenus } from '@/model/menu.model.ts';
 
 const route = useRoute();
 const router = useRouter();
-const { account } = storeToRefs(useAccountStore());
+const { account, menus } = storeToRefs(useAccountStore());
 
 const navTitle = computed(() => route.meta.title ?? import.meta.env.SIX_TITLE);
 const navText = computed(() => account.value?.name ?? '');
-const menus = shallowRef([...defaultMenus]);
 
 useStepStore().$reset();
 

+ 3 - 1
src/pages/LoginPage.vue

@@ -3,16 +3,18 @@ import { showSuccessToast } from 'vant';
 import { useSerialRequest } from 'alova/client';
 import { getAccountMethod, loginMethod } from '@/api/account.api.ts';
 import { useAccountStore } from '@/stores';
+import { defaultMenus } from '@/router/menu.ts';
 
 const router = useRouter();
 const store = useAccountStore();
-const { token, account } = storeToRefs(store);
+const { token, account, menus } = storeToRefs(store);
 
 const title = import.meta.env.SIX_TITLE;
 const { loading: submitting, send: submit } = useSerialRequest([() => loginMethod(toRaw(model)), (token) => getAccountMethod(token)], { immediate: false }).onSuccess(
   ({ data }) => {
     token.value = data.token;
     account.value = data.account;
+    menus.value = defaultMenus;
     router.replace({ name: 'home' }).then(() => {
       showSuccessToast(`登录成功`);
     });

+ 118 - 120
src/pages/StepPage.vue

@@ -1,148 +1,120 @@
 <script setup lang="ts">
-import { useStepStore } from '@/stores';
-import { useRequest } from 'alova/client';
 import { getDataMethod } from '@/api/pda.api.ts';
-import { defaultMenus } from '@/model/menu.model.ts';
-import { tryOnBeforeMount, tryOnUnmounted } from '@vueuse/core';
+import { uploadMethod } from '@/api/file.api.ts';
+import { type ScanData, useScan } from '@/core/hook/useScan.ts';
+import { useStep } from '@/core/hook/useStep.ts';
+import { useStepStore } from '@/stores';
+import type { UploaderAfterRead, UploaderFileListItem } from 'vant';
+import { nextTick } from 'vue';
 
-const router = useRouter();
-const route = useRoute();
 const stepStore = useStepStore();
-const { dataset } = storeToRefs(stepStore);
+const { dataset, id, mode } = storeToRefs(stepStore);
+const loaded = computed(() => !!id.value);
 
-const navTitle = computed(() => defaultMenus.find((menu) => menu.path === `/step/${route.params.mode}`)?.name ?? import.meta.env.SIX_TITLE);
-const tabTitle = computed(() => navTitle.value?.replace('管理', '确认'));
+const keyword = ref('');
 const tabIndex = ref(0);
+const { menu, tabTitle, next, prev } = useStep(mode, id);
+
+watchEffect(() => {
+  keyword.value = id.value;
+  nextTick(() => {
+    tabIndex.value = id.value ? 3 : 0;
+    if (!id.value) stepStore.$reset();
+  });
+});
 
-const loaded = computed(() => !!data.value?.no);
-const readonly = ref(false);
-const keyword = ref<string>(dataset.value?.no ?? '');
-
-const ignoreParentScanner = ref(false);
-provide('ignoreParentScanner', ignoreParentScanner);
-let onCleanup = () => {};
-tryOnBeforeMount(() => {
-  if (window.bridge) {
-    onCleanup = window.bridge.addEventListener('scan', event => {
-      const detail = event.detail;
-      if ( detail.code !== 0 || detail.data?.code == null ) return;
-      if (ignoreParentScanner.value) {
-        event.stopPropagation();
-        event.preventDefault();
-      } else {
-        keyword.value = event.detail.data?.code ?? '';
-        const toast = showLoadingToast({ message: '查询中...', duration: 0 });
-        search().finally(() => toast.close());
-      }
-    });
-  } else if (window.platform) {
-    const update = (event: CustomEvent) => {
-      if (ignoreParentScanner.value) {
-        event.stopPropagation();
-        event.preventDefault();
-      } else {
-        keyword.value = event.detail.code;
-        const toast = showLoadingToast({ message: '查询中...', duration: 0 });
-        search().finally(() => toast.close());
-      }
-    };
-    platform.addEventListener('scan', update)
-    onCleanup = () => { platform.removeEventListener('scan', update) }
+const step = useTemplateRef<{ scan?: (data: ScanData) => void }>('step');
+
+const { scanValue, scan } = useScan((data) => {
+  /* 组件内扫描按钮 */
+  if (tabIndex.value === 3 && typeof step.value?.scan === 'function') {
+    scanValue.value = data.code;
+    step.value.scan(data);
+  } else {
+    keyword.value = data.code;
+    onSearch();
   }
 });
-tryOnUnmounted(() => onCleanup?.());
-
-const {
-  data,
-  loading,
-  send: search,
-} = useRequest(() => getDataMethod(keyword.value?.trim()), {
-  immediate: false,
-  initialData: { ...dataset.value },
-})
-  .onSuccess(async ({ data }) => {
-    keyword.value = data.no;
-    dataset.value = data;
-
-    const { mode, value } = route.params;
-
-    if (value) {
-      await router.push({ path: `${keyword.value}`, replace: true });
-    } else {
-      await router.push({ path: `${mode}/${keyword.value}` });
-    }
-    tabIndex.value = 3;
-  })
-  .onError(() => {
-    keyword.value = '';
-  });
 
-watch(loading, (value) => {
+async function onSearch() {
+  const value = keyword.value?.trim?.();
   if (value) {
-    showLoadingToast({ forbidClick: true, duration: 0, message: '加载中...' });
-  } else {
+    showLoadingToast({ forbidClick: true, duration: 0, message: '查询中...' });
+    try {
+      dataset.value = await getDataMethod(keyword.value.trim());
+      await next(dataset.value.no);
+    } catch (_: unknown) {
+      keyword.value = '';
+    }
     closeToast();
+  } else {
+    showNotify({ message: '请使用设备按钮进行扫码', type: 'warning' });
   }
-});
+}
 
-function back(delta?: number) {
-  stepStore.$reset();
-  data.value = { ...dataset.value! };
-  keyword.value = '';
-  delta ??= route.params.value ? -2 : -1;
-  tabIndex.value = 0;
-  router.go(delta);
+function onBack() {
+  prev();
 }
+
+const files = ref<UploaderFileListItem[]>([]);
+const picture = computed(() => files.value.filter((file) => file.status === 'done' && file.url != null).map((file) => file.url));
+const afterRead: UploaderAfterRead = async (listItem) => {
+  if (listItem && !Array.isArray(listItem)) listItem = [listItem];
+  for (const item of listItem) {
+    if (!item?.file) continue;
+    item.status = 'uploading';
+    item.message = '上传中...';
+    try {
+      item.url = await uploadMethod(item.file!);
+      item.status = 'done';
+    } catch (error) {
+      console.error('[file] 文件上传失败', error);
+      item.status = 'failed';
+    }
+  }
+};
 </script>
 
 <template>
   <div class="page page__home flex flex-col size-full">
     <header class="flex-none">
-      <van-nav-bar :title="navTitle" left-text="返回" left-arrow @click-left="back()" />
-      <van-search
-        v-model="keyword"
-        input-align="center"
-        placeholder="请使用设备按钮进行扫码"
-        :readonly="readonly || loading || loaded"
-        :show-action="loaded"
-        @search="keyword && search()"
-        @cancel="back(-1)"
-      />
+      <van-nav-bar :title="menu?.title" left-text="返回" left-arrow @click-left="onBack()" />
+      <van-search v-model="keyword" input-align="center" placeholder="请使用设备按钮进行扫码" @search="onSearch()" :readonly="loaded" :show-action="loaded" @cancel="onBack()" />
     </header>
     <van-tabs class="content flex-auto overflow-hidden" v-model:active="tabIndex">
       <van-tab title="就诊信息">
         <van-cell-group>
-          <van-cell title="患者" :value="data.patient?.name" />
-          <van-cell title="性别" :value="data.patient?.gender" />
-          <van-cell title="年龄" :value="data.patient?.age" />
-          <van-cell title="手机号" :value="data.patient?.phone" />
-          <van-cell title="医院" :value="data.patient?.hospital" />
-          <van-cell title="门诊/住院" :value="data.patient?.category" />
-          <van-cell title="科室/病区" :value="[data.patient?.department, data.patient?.area].filter((v) => !!v).join('/')" />
-          <van-cell title="病床" :value="data.patient?.bed" />
-          <van-cell title="临床诊断" :value="data.patient?.name" />
-          <van-cell title="开方医生" :value="data.doctor?.name" />
+          <van-cell title="患者" :value="dataset?.patient?.name" />
+          <van-cell title="性别" :value="dataset?.patient?.gender" />
+          <van-cell title="年龄" :value="dataset?.patient?.age" />
+          <van-cell title="手机号" :value="dataset?.patient?.phone" />
+          <van-cell title="医院" :value="dataset?.patient?.hospital" />
+          <van-cell title="门诊/住院" :value="dataset?.patient?.category" />
+          <van-cell title="科室/病区" :value="[dataset?.patient?.department, dataset?.patient?.area].filter((v) => !!v).join('/')" />
+          <van-cell title="病床" :value="dataset?.patient?.bed" />
+          <van-cell title="临床诊断" :value="dataset?.patient?.name" />
+          <van-cell title="开方医生" :value="dataset?.doctor?.name" />
         </van-cell-group>
       </van-tab>
       <van-tab title="处方信息">
         <van-cell-group>
-          <van-cell title="处方类型" :value="data.prescription?.category" />
-          <van-cell title="处方状态" :value="data.order?.state" />
-          <van-cell title="总金额" :value="data.prescription?.totalPrice" />
-          <van-cell title="剂型" :value="data.prescription?.dosageForm" />
-          <van-cell title="剂数" :value="data.prescription?.count" />
-          <van-cell title="处方用法" :value="data.prescription?.method" />
-          <van-cell title="服药频次" :value="data.prescription?.frequency" />
-          <van-cell title="服药时间" :value="data.prescription?.frequencyTime" />
-          <van-cell title="煎药量" :value="data.prescription?.volume" />
-          <van-cell title="是否代煎" :value="data.prescription?.decoction" />
-          <van-cell title="开方医生备注" :value="data.prescription?.remark1" />
-          <van-cell title="配送方式" :value="data.prescription?.dispatch?.method" />
-          <van-cell title="收货人" :value="data.prescription?.dispatch?.name" />
-          <van-cell title="收货电话" :value="data.prescription?.dispatch?.phone" />
-          <van-cell title="收货地址" :value="data.prescription?.dispatch?.address" value-class="flex-2" />
-          <van-cell title="嘱托" :value="data.prescription?.entrust" />
-          <van-cell title="药师备注" :value="data.prescription?.remark2" />
+          <van-cell title="处方类型" :value="dataset?.prescription?.category" />
+          <van-cell title="处方状态" :value="dataset?.order?.state" />
+          <van-cell title="总金额" :value="dataset?.prescription?.totalPrice" />
+          <van-cell title="剂型" :value="dataset?.prescription?.dosageForm" />
+          <van-cell title="剂数" :value="dataset?.prescription?.count" />
+          <van-cell title="处方用法" :value="dataset?.prescription?.method" />
+          <van-cell title="服药频次" :value="dataset?.prescription?.frequency" />
+          <van-cell title="服药时间" :value="dataset?.prescription?.frequencyTime" />
+          <van-cell title="煎药量" :value="dataset?.prescription?.volume" />
+          <van-cell title="是否代煎" :value="dataset?.prescription?.decoction" />
+          <van-cell title="开方医生备注" :value="dataset?.prescription?.remark1" />
+          <van-cell title="配送方式" :value="dataset?.prescription?.dispatch?.method" />
+          <van-cell title="收货人" :value="dataset?.prescription?.dispatch?.name" />
+          <van-cell title="收货电话" :value="dataset?.prescription?.dispatch?.phone" />
+          <van-cell title="收货地址" :value="dataset?.prescription?.dispatch?.address" value-class="flex-2" />
+          <van-cell title="嘱托" :value="dataset?.prescription?.entrust" />
+          <van-cell title="药师备注" :value="dataset?.prescription?.remark2" />
         </van-cell-group>
       </van-tab>
       <van-tab title="药品信息">
@@ -161,7 +133,7 @@ function back(delta?: number) {
             </tr>
           </thead>
           <tbody>
-            <tr v-for="(medicine, index) in data.medicines" :key="medicine.id">
+            <tr v-for="(medicine, index) in dataset?.medicines" :key="medicine.id">
               <th scope="row">{{ index + 1 }}</th>
               <td class="w-24">{{ medicine.name }}</td>
               <td>{{ medicine.size }}</td>
@@ -175,8 +147,34 @@ function back(delta?: number) {
           </tbody>
         </table>
       </van-tab>
-      <van-tab :title="tabTitle">
-        <router-view :title="navTitle" @back="back"></router-view>
+      <van-tab :title="tabTitle" :disabled="!loaded">
+        <router-view>
+          <component ref="step" :is="menu?.component" @back="onBack()">
+            <template v-slot:scanner="{ title, disabled }">
+              <van-field
+                :label="title"
+                :readonly="disabled"
+                placeholder="请使用设备按钮进行扫码"
+                right-icon="scan"
+                @click-right-icon="!disabled && scan()"
+                v-model="scanValue"
+                @update:model-value="scan($event)"
+              />
+            </template>
+            <template v-slot:uploader="{ disabled }">
+              <van-field label="拍照上传">
+                <template #input>
+                  <van-uploader v-model="files" :after-read="afterRead" :disabled="disabled" />
+                </template>
+              </van-field>
+            </template>
+            <template v-slot:submit="{ title, submitting, submit }">
+              <div class="my-4 px-4">
+                <van-button type="primary" block :loading="submitting" @click="submit(picture)">{{ title }}</van-button>
+              </div>
+            </template>
+          </component>
+        </router-view>
       </van-tab>
     </van-tabs>
   </div>

+ 3 - 3
src/router/index.ts

@@ -20,12 +20,12 @@ const router = createRouter({
           meta: { title: '调配间' },
         },
         {
-          path: 'step/:mode',
-          component: () => import('@/pages/StepPage.vue'),
+          path: `step/:mode`,
+          component: () => import(`@/pages/StepPage.vue`),
           children: [
             {
               path: ':value',
-              component: () => import('@/components/StepDetailComponent.vue'),
+              component: () => import('@/module/step/Step.vue'),
             },
           ],
         },

+ 31 - 0
src/router/menu.ts

@@ -0,0 +1,31 @@
+export interface Menu {
+  index: number;
+  title: string;
+  component: Component;
+}
+
+export type MenuPath = '/step/deploy' | '/step/deploy-recheck' | '/step/soak';
+
+export const menus: Record<MenuPath, Menu> = {
+  '/step/deploy': {
+    index: 21,
+    title: '调配管理',
+    component: defineAsyncComponent(() => import(`@/module/step/StepDeploy.vue`)),
+  },
+  '/step/deploy-recheck': {
+    index: 22,
+    title: '调配复核管理',
+    component: defineAsyncComponent(() => import(`@/module/step/StepDeployRecheck.vue`)),
+  },
+  '/step/soak': {
+    index: 31,
+    title: '浸泡管理',
+    component: defineAsyncComponent(() => import(`@/module/step/StepSoak.vue`)),
+  },
+} as const;
+
+export const defaultMenus = Object.entries(menus)
+  .sort(([_1, { index: a }], [_2, { index: b }]) => a - b)
+  .map(([path, { title: name }]) => {
+    return { name, path };
+  });

+ 5 - 1
src/stores/account.store.ts

@@ -1,14 +1,18 @@
 import { defineStore } from 'pinia';
 import type { AccountModel } from '@model/account.model.ts';
+import { defaultMenus } from '@/router/menu.ts';
 
 export const useAccountStore = defineStore('account', () => {
   const token = ref<string>();
   const account = shallowRef<AccountModel>();
 
+  const menus = shallowRef(defaultMenus);
+
   function $reset() {
     token.value = void 0;
     account.value = void 0;
+    menus.value = [];
   }
 
-  return { token, account, $reset };
+  return { token, account, menus, $reset };
 });

+ 6 - 5
src/stores/step.store.ts

@@ -1,14 +1,15 @@
 import { defineStore } from 'pinia';
-import { defaultMenus } from '@/model/menu.model.ts';
-import type { StepMode } from '@/model/step.model.ts';
+import type { StepModel } from '@/model/step.model.ts';
+import { useRouteParams } from '@vueuse/router';
 
 export const useStepStore = defineStore('step', () => {
-  const route = useRoute();
-  const dataset = shallowRef<StepMode>();
+  const id = useRouteParams<string>('value');
+  const mode = useRouteParams<string>('mode');
+  const dataset = shallowRef<StepModel>();
 
   function $reset() {
     dataset.value = void 0;
   }
 
-  return { dataset, $reset };
+  return { dataset, id, mode, $reset };
 });