Prechádzať zdrojové kódy

Merge branch 'release/1.1.0'

cc12458 2 týždňov pred
rodič
commit
40c4c238ae

+ 1 - 1
.env/.env.production

@@ -1,3 +1,3 @@
-APP_BASE=/
+APP_BASE=/pharmacy/pda/
 
 APP_API_PROXY=https://wx.hzliuzhi.com

+ 1 - 0
.gitignore

@@ -33,3 +33,4 @@ auto-imports.d.ts
 components.d.ts
 
 .env.**.local
+six-**.zip

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

@@ -0,0 +1,28 @@
+interface ScanData {
+  code: string;
+  state: number;
+  type: number;
+  message?: string;
+}
+
+export interface BridgeEventMap {
+  scan: CustomEvent<{code: number, data?: ScanData, message?: string}>;
+}
+
+export class Bridge extends EventTarget {
+  public static getInstance(): Bridge;
+
+  public static print(): Promise<void>;
+  public static print(params: { url?: string }): Promise<void>;
+
+  public static scan(params?: { timeout?: number; signal?: AbortSignal }): Promise<ScanData>;
+
+  /**
+   * 监听扫码事件
+   * @param type 事件类型 'scan'
+   * @param listener 事件回调,参数为 ScanEvent
+   * @param options
+   */
+  addEventListener<T extends keyof BridgeEventMap>(type: T, listener: (event: BridgeEventMap[T]) => void, options?: boolean | AddEventListenerOptions): () => void;
+  removeEventListener<T extends keyof BridgeEventMap>(type: T, listener: (event: BridgeEventMap[T]) => void, options?: boolean | AddEventListenerOptions): () => void;
+}

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

@@ -2,6 +2,7 @@
 
 interface ImportMetaEnv {
   readonly SIX_TITLE?: string;
+  readonly SIX_API?: string;
 }
 
 interface ImportMeta {

+ 33 - 1
@types/global.d.ts

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

+ 4 - 1
package.json

@@ -6,7 +6,8 @@
   "scripts": {
     "dev": "vite",
     "build": "run-p type-check \"build-only {@}\" --",
-    "preview": "vite preview",
+    "preview": "vite preview --base=/pharmacy/pda/ --mode development",
+    "device": "run-s build-only preview",
     "test:unit": "vitest",
     "prepare": "cypress install",
     "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
@@ -22,7 +23,9 @@
     "@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",
     "pinia": "^3.0.1",
     "pinia-plugin-persistedstate": "^4.2.0",

+ 32 - 0
pnpm-lock.yaml

@@ -17,9 +17,15 @@ 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
+      big.js:
+        specifier: ^6.2.2
+        version: 6.2.2
       eruda:
         specifier: ^3.4.1
         version: 3.4.1
@@ -1515,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}
@@ -1647,6 +1664,9 @@ packages:
   bcrypt-pbkdf@1.0.2:
     resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
 
+  big.js@6.2.2:
+    resolution: {integrity: sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==}
+
   binary-extensions@2.3.0:
     resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
     engines: {node: '>=8'}
@@ -5418,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):
@@ -5538,6 +5568,8 @@ snapshots:
     dependencies:
       tweetnacl: 0.14.5
 
+  big.js@6.2.2: {}
+
   binary-extensions@2.3.0: {}
 
   birpc@0.2.19: {}

+ 1 - 1
request/file.request.ts

@@ -8,7 +8,7 @@ import router from '@/router';
 const FileHttpClient = createAlova({
   requestAdapter: adapterFetch(),
   statesHook: VueHook,
-  baseURL: '/manager/file/',
+  baseURL: `${ import.meta.env.SIX_API ?? '' }/manager/file/`,
   async beforeRequest(method) {
     if (!method.config.meta?.ignoreToken) {
       method.config.headers.Authorization ??= useAccountStore(pinia).token;

+ 4 - 1
request/pharmacy.request.ts

@@ -8,7 +8,7 @@ import router from '@/router';
 const PharmacyHttpClient = createAlova({
   requestAdapter: adapterFetch(),
   statesHook: VueHook,
-  baseURL: '/manager/yfc-admin/',
+  baseURL: `${ import.meta.env.SIX_API ?? '' }/manager/yfc-admin/`,
   async beforeRequest(method) {
     if (!method.config.meta?.ignoreToken) {
       method.config.headers.Authorization ??= useAccountStore(pinia).token;
@@ -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
request/system.request.ts

@@ -6,7 +6,7 @@ import pinia, { useAccountStore } from '@/stores';
 const SystemHttpClient = createAlova({
   requestAdapter: adapterFetch(),
   statesHook: VueHook,
-  baseURL: '/manager/system/',
+  baseURL: `${ import.meta.env.SIX_API ?? '' }/manager/system/`,
   async beforeRequest(method) {
     if (!method.config.meta?.ignoreToken) method.config.headers.Authorization ??= useAccountStore(pinia).token;
   },

+ 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;
+    },
+  });
+}

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

@@ -1,14 +1,15 @@
 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';
 
-export function getDataMethod(no: string) {
+import type { DecoctionModel, PackModel, SoakModel, StepModel } from '@/model/step.model.ts';
+import { fromDecoctionModel, fromPackModel, fromSoakModel } from '@/model/step.model.ts';
+
+export function getDataMethod(no: string, mode?: string) {
   return requestMethodFactory(
-    PharmacyHttpClient.Get<StepMode, ResponseData>(`/web/pda/detailInfo`, {
+    PharmacyHttpClient.Get<StepModel, ResponseData>(`/web/pda/detailInfo`, {
       params: {
-        orderNo: no,
+        mode, orderNo: no,
         /**
          * @deprecated 历史遗留
          */
@@ -87,16 +88,69 @@ export function getDataMethod(no: string) {
   );
 }
 
-export function setDataMethod(url: string, data: ResponseData) {
-  return requestMethodFactory(PharmacyHttpClient.Post(url, data));
+/**
+ * 调配节点保存
+ * @param data
+ */
+export function setDeployDataMethod(data: ResponseData) {
+  return requestMethodFactory(PharmacyHttpClient.Post('/file/saveCoreFile', data));
+}
+
+/**
+ * 调配复核节点保存
+ * @param data
+ */
+export function setDeployRecheckDataMethod(data: ResponseData) {
+  return requestMethodFactory(PharmacyHttpClient.Post('/prescription/prescriptionCore/reviewPrescription', data));
+}
+
+export function getSoakDataMethod(id: string) {
+  return PharmacyHttpClient.Get<SoakModel, ResponseData>(`/web/pda/steep/${id}`, { transform: fromSoakModel, hitSource: /^soak/ });
+}
+
+export function setSoakDataMethod(model: Partial<SoakModel>, picture?: string[]) {
+  return requestMethodFactory(PharmacyHttpClient.Put('/web/pda/steep/edit', { ...model, image: picture?.join(',') ?? '' }, { name: 'soak-edit' }));
+}
+
+/**
+ * 煎煮节点获取
+ * @param id
+ */
+export function getDecoctionDataMethod(id: string) {
+  return PharmacyHttpClient.Get<DecoctionModel, ResponseData>(`/web/pda/decoct/${id}`, { transform: fromDecoctionModel, hitSource: /^decoction/ });
+}
+
+/**
+ * 煎煮节点保存
+ * @param model
+ * @param picture
+ */
+export function setDecoctionDataMethod(model: Partial<DecoctionModel>, picture?: string[]) {
+  return requestMethodFactory(PharmacyHttpClient.Put('/web/pda/decoct/edit', { ...model, image: picture?.join(',') ?? '' }, { name: 'decoction-edit' }));
+}
+
+/**
+ * 打包节点获取
+ * @param id
+ */
+export function getPackDataMethod(id: string) {
+  return PharmacyHttpClient.Get<PackModel, ResponseData>(`/web/pda/Pack/${id}`, { transform: fromPackModel, hitSource: /^pack/ });
+}
+
+/**
+ * 打包节点保存
+ * @param model
+ * @param picture
+ */
+export function setPackDataMethod(model: Partial<PackModel>, picture?: string[]) {
+  return requestMethodFactory(PharmacyHttpClient.Put('/web/pda/Pack/edit', { ...model, image: picture?.join(',') ?? '' }, { name: 'pack-edit' }));
 }
 
-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;
-    },
-  });
+/**
+ * 打包复核节点保存
+ * @param model
+ * @param picture
+ */
+export function setPackRecheckDataMethod(model: Partial<PackModel>, picture?: string[]) {
+  return requestMethodFactory(PharmacyHttpClient.Put('/web/pda/Pack/addPackReview', { ...model, image: picture?.join(',') ?? '' }, { name: 'pack-edit-recheck' }));
 }

+ 0 - 92
src/components/StepDetailComponent.vue

@@ -1,92 +0,0 @@
-<script setup lang="ts">
-import { useForm } from 'alova/client';
-import { setDataMethod, uploadMethod } from '@/api/pda.api.ts';
-import type { ToRef } from 'vue';
-import { showSuccessToast, type UploaderAfterRead, type UploaderFileListItem } from 'vant';
-import { useAccountStore, useStepStore } from '@/stores';
-
-const { title } = defineProps<{ title: string }>();
-const emits = defineEmits<{ back: [delta?: number] }>();
-const route = useRoute();
-const mode = toRef(route.params, 'mode') as ToRef<'deploy' | 'deploy-recheck'>;
-
-const storeStore = useStepStore();
-const accountStore = useAccountStore();
-
-const files = ref<UploaderFileListItem[]>([]);
-const {
-  loading: submitting,
-  form: model,
-  send: submit,
-} = useForm(
-  (model) => {
-    const data = Object.assign(
-      {
-        urls: files.value.filter((file) => file.status === 'done').map((file) => file.url),
-        coreId: storeStore.dataset?.id,
-        preNo: storeStore.dataset?.no,
-        userId: accountStore.account?.id,
-      },
-      model,
-    );
-    switch (mode.value) {
-      case 'deploy':
-        Reflect.deleteProperty(data, 'weight');
-        return setDataMethod('/file/saveCoreFile', data);
-      case 'deploy-recheck':
-        return setDataMethod('/prescription/prescriptionCore/reviewPrescription', data);
-    }
-  },
-  {
-    initialForm: {
-      comments: '',
-      weight: '',
-    },
-  },
-).onSuccess(() => {
-  showSuccessToast(`操作成功`);
-  emits('back', -1);
-});
-
-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 (e) {
-      console.error('上传文件-->失败', e);
-      item.status = 'failed';
-    }
-  }
-};
-
-const remarkLabel = computed(() => title.replace('管理', '备注'));
-const buttonText = computed(() => title.replace('管理', '节点上传'));
-</script>
-
-<template>
-  <div>
-    <van-cell-group>
-      <van-form required="auto">
-        <template v-if="mode === 'deploy-recheck'">
-          <van-field v-model="model.weight" type="number" label="复核重量" placeholder="请输入重量" />
-        </template>
-        <van-field label="拍照上传">
-          <template #input>
-            <van-uploader v-model="files" :after-read="afterRead" />
-          </template>
-        </van-field>
-        <van-field v-model="model.comments" rows="2" autosize :label="remarkLabel" type="textarea" placeholder="请输入备注" />
-        <div class="my-4 px-4">
-          <van-button type="primary" block :loading="submitting" @click="submit()">{{ buttonText }} </van-button>
-        </div>
-      </van-form>
-    </van-cell-group>
-  </div>
-</template>
-
-<style scoped lang="scss"></style>

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

@@ -0,0 +1,42 @@
+import { tryOnBeforeMount, tryOnUnmounted, useDebounceFn } from '@vueuse/core';
+import type { BridgeEventMap } from '../../../@types/bridge';
+
+export type ScanData = NonNullable<BridgeEventMap['scan']['detail']['data']>;
+
+export function useScan(fn: (data: ScanData) => void) {
+  const callback = useDebounceFn(fn, 300)
+  let onCleanup = () => {};
+  tryOnBeforeMount(async () => {
+    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/core/launch/index.ts

@@ -23,3 +23,4 @@ export default async function launch(component: Component, ...launcher: (Launche
 }
 
 export { debugLaunch } from './debug.launch.ts';
+export { default as platformLaunch } from './platform.launch.ts';

+ 28 - 0
src/core/launch/platform.launch.ts

@@ -0,0 +1,28 @@
+import { platformIsPDA } from '@/platform';
+import type { Launcher } from '@/core/launch/index.ts';
+
+export function waitFor(condition: () => boolean | Promise<boolean>, timeout: number = 300 * 1000) {
+  const start = Date.now();
+  const { promise, resolve, reject } = Promise.withResolvers<void>();
+  const check = async () => {
+    try {
+      if (await condition()) resolve();
+      else if (timeout && Date.now() - start >= timeout) reject({ message: 'waitForBridge timeout' });
+      else requestAnimationFrame(check);
+    } catch (e) {
+      reject(e);
+    }
+  };
+  return check().then(
+    () => promise,
+    () => promise,
+  );
+}
+
+export default function bridgeLoader(): Launcher {
+  return async function () {
+    if (platformIsPDA()) {
+      await waitFor(() => window.bridge != null);
+    }
+  };
+}

+ 5 - 1
src/main.ts

@@ -2,12 +2,16 @@ 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'
 
 import App from './App.vue';
-import launch, { debugLaunch } from '@/core/launch';
+import launch, { debugLaunch, platformLaunch } from '@/core/launch';
 
 launch(App, [
   debugLaunch('debug', true),
+  platformLaunch()
 ]).then(
   (app) => {
     app.use(Lazyload, { lazyComponent: true });

+ 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;

+ 65 - 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,66 @@ 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;
+}
+
+export interface DecoctionModel extends ResponseData {
+  decoctNote: string;
+  startConcentrationDose: string;
+  endConcentrationDose: string;
+  deviceCode: string;
+
+  images: string;
+}
+
+export function fromDecoctionModel(data?: ResponseData) {
+  return Object.assign(
+    {
+      decoctNote: data?.decoctNote ?? '',
+      startConcentrationDose: data?.startConcentrationDose ?? '',
+      endConcentrationDose: data?.endConcentrationDose ?? '',
+      deviceCode: data?.deviceCode ?? '',
+    },
+    data,
+  ) as DecoctionModel;
+}
+
+export interface PackModel extends ResponseData {
+  packingNote: string;
+  packageDose: string;
+  packageNumber: string;
+  deviceCode: string;
+
+  images: string;
+}
+
+export function fromPackModel(data?: ResponseData) {
+  return Object.assign(
+    {
+      packingNote: data?.packingNote ?? '',
+      packageDose: data?.packageDose ?? '',
+      packageNumber: data?.packageNumber ?? '',
+      deviceCode: data?.deviceCode ?? '',
+    },
+    data,
+  ) as PackModel;
+}

+ 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>

+ 88 - 0
src/module/step/StepDecoction.vue

@@ -0,0 +1,88 @@
+<script setup lang="ts">
+import { showSuccessToast } from 'vant';
+
+import { useForm, useWatcher } from 'alova/client';
+import { getDecoctionDataMethod, setDecoctionDataMethod } from '@/api/pda.api.ts';
+import { type DecoctionModel, fromDecoctionModel } from '@/model/step.model.ts';
+import { useStepStore } from '@/stores';
+
+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, reset,
+} = useForm(setDecoctionDataMethod, { immediate: false, initialForm: fromDecoctionModel() }).onSuccess(() => {
+  showSuccessToast(`操作成功`);
+  emits('back');
+});
+
+const { data, loading } = useWatcher(() => getDecoctionDataMethod(dataset.value?.id!), [dataset], {
+  immediate: true,
+  initialData: {},
+  middleware: (_, next) => dataset.value?.id && next(),
+})
+  .onSuccess(({ data }) => {
+    for (const [key, value] of Object.entries(data)) model.value[key as keyof DecoctionModel] = value;
+  })
+  .onError(({ error: message }) => {
+    showDialog({ title: '温馨提示', message, closeOnClickOverlay: true }).then(() => emits('back'));
+  });
+
+defineExpose({
+  scan(data: ScanData) {
+    model.value.deviceCode = data?.code ?? '';
+  },
+  reset,
+});
+</script>
+
+<template>
+  <van-toast :show="loading" type="loading" forbid-click />
+
+  <van-cell title="煎药方案" :value="data.schemeName" />
+  <van-cell title="压力模式" :value="data.pressurePattern" />
+  <van-cell title="剂型" :value="data.dosageForm" />
+  <van-cell title="煎煮时间" :value="data.decoctTime ? `${data.decoctTime} min` : ''" />
+  <van-cell title="先煎时间" :value="data.preDecoctTime ? `${data.preDecoctTime} min` : ''" />
+  <van-cell title="一煎时间" :value="data.firstDecoctTime ? `${data.firstDecoctTime} min` : ''" />
+  <van-cell title="二煎时间" :value="data.secondDecoctTime ? `${data.secondDecoctTime} min` : ''" />
+  <van-cell title="后下时间" :value="data.backDownTime ? `${data.backDownTime} min` : ''" />
+
+  <van-field class="suffix" v-model="model.startConcentrationDose" :readonly="submitting" type="digit" label="开始浓缩药量" placeholder="请输入开始浓缩药量">
+    <template #extra>
+      <div v-if="model.startConcentrationDose">ml</div>
+    </template>
+  </van-field>
+  <van-field class="suffix" v-model="model.endConcentrationDose" :readonly="submitting" type="digit" label="结束浓缩药量" placeholder="请输入结束浓缩药量">
+    <template #extra>
+      <div v-if="model.endConcentrationDose">ml</div>
+    </template>
+  </van-field>
+
+  <slot name="scanner" title="扫描设备" :disabled="submitting"></slot>
+  <slot name="uploader" :disabled="submitting"></slot>
+
+  <van-field v-model="model.decoctNote" :readonly="submitting" rows="2" autosize label="煎煮备注" type="textarea" placeholder="请输入备注内容" />
+  <slot name="submit" title="煎煮节点上传" :submitting="submitting" :submit="send"></slot>
+</template>
+
+<style scoped lang="scss">
+.van-field.suffix {
+  :deep(.van-field__value) {
+    flex: none;
+    //width: min-content;
+    //min-width: 120px;
+    max-width: 170px;
+
+    input {
+      text-align: center;
+    }
+  }
+}
+</style>

+ 46 - 0
src/module/step/StepDeploy.vue

@@ -0,0 +1,46 @@
+<script setup lang="ts">
+import { useForm } from 'alova/client';
+import { setDeployDataMethod } from '@/api/pda.api.ts';
+import { showSuccessToast } from 'vant';
+import { useAccountStore, useStepStore } from '@/stores';
+
+const emits = defineEmits<{ back: [delta?: number] }>();
+
+const accountStore = useAccountStore();
+const storeStore = useStepStore();
+
+const {
+  loading: submitting,
+  form: model,
+  send, reset,
+} = useForm(
+  (model, picture) => {
+    const data = Object.assign(
+      {
+        urls: picture,
+        coreId: storeStore.dataset?.id,
+        preNo: storeStore.dataset?.no,
+        userId: accountStore.account?.id,
+      },
+      model,
+    );
+    return setDeployDataMethod(data);
+  },
+  { immediate: false, initialForm: { comments: '' } },
+).onSuccess(() => {
+  showSuccessToast(`操作成功`);
+  emits('back');
+});
+
+defineExpose({
+  reset,
+});
+</script>
+
+<template>
+  <slot name="uploader" :disabled="submitting"></slot>
+  <van-field v-model="model.comments" :readonly="submitting" rows="2" autosize label="调配备注" type="textarea" placeholder="请输入备注内容" />
+  <slot name="submit" title="调配节点上传" :submitting="submitting" :submit="send"></slot>
+</template>
+
+<style scoped lang="scss"></style>

+ 90 - 0
src/module/step/StepDeployRecheck.vue

@@ -0,0 +1,90 @@
+<script setup lang="ts">
+import { useForm } from 'alova/client';
+import { setDeployRecheckDataMethod } from '@/api/pda.api.ts';
+import { showNotify, showSuccessToast } from 'vant';
+import { useAccountStore, useStepStore } from '@/stores';
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
+import Big from 'big.js';
+
+const emits = defineEmits<{ back: [delta?: number] }>();
+
+const accountStore = useAccountStore();
+const storeStore = useStepStore();
+
+const {
+  loading: submitting,
+  form: model,
+  send, reset,
+} = useForm(
+  (model, picture) => {
+    const data = Object.assign(
+      {
+        urls: picture,
+        coreId: storeStore.dataset?.id,
+        preNo: storeStore.dataset?.no,
+        userId: accountStore.account?.id,
+      },
+      model,
+    );
+    return setDeployRecheckDataMethod(data);
+  },
+  { immediate: false, initialForm: { comments: '', weight: '' } },
+).onSuccess(() => {
+  showSuccessToast(`操作成功`);
+  emits('back');
+});
+
+const update = () => {
+  const {
+    medicines,
+    prescription: { count },
+  } = storeStore.dataset!;
+  const weight = medicines.reduce((total, medicine) => total.plus(Big(medicine.dosage || 0)), Big(0));
+  model.value.weight = weight.times(Big(count)).toString();
+};
+onBeforeMount(() => update());
+onBeforeRouteUpdate(() => update());
+
+function submit(picture: string[]) {
+  let message = '';
+  if (!+model.value.weight) {
+    message = '请输入复核重量';
+  }
+  if (message) {
+    showNotify({ message, type: 'warning' });
+  } else {
+    send(picture);
+  }
+}
+
+defineExpose({
+  reset,
+});
+</script>
+
+<template>
+  <van-field class="suffix" v-model="model.weight" :readonly="submitting" type="number" label="复核重量" placeholder="请输入重量">
+    <template #extra>
+      <div v-if="model.weight">g</div>
+    </template>
+  </van-field>
+  <slot name="uploader" :disabled="submitting"></slot>
+  <van-field v-model="model.comments" :readonly="submitting" rows="2" autosize label="调配复核备注" type="textarea" placeholder="请输入备注内容" />
+  <slot name="submit" title="调配复核节点上传" :submitting="submitting" :submit="submit"></slot>
+</template>
+
+<style scoped lang="scss">
+.van-field.suffix {
+  :deep(.van-field__value) {
+    flex: none;
+    width: min-content;
+    min-width: 80px;
+
+    input {
+      text-align: center;
+    }
+  }
+}
+</style>

+ 72 - 0
src/module/step/StepPack.vue

@@ -0,0 +1,72 @@
+<script setup lang="ts">
+import { showSuccessToast } from 'vant';
+
+import { useForm, useWatcher } from 'alova/client';
+import { getPackDataMethod, setPackDataMethod } from '@/api/pda.api.ts';
+import { fromPackModel, type PackModel } from '@/model/step.model.ts';
+import { useStepStore } from '@/stores';
+
+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, reset,
+} = useForm(setPackDataMethod, { immediate: false, initialForm: fromPackModel() }).onSuccess(() => {
+  showSuccessToast(`操作成功`);
+  emits('back');
+});
+
+const { data, loading } = useWatcher(() => getPackDataMethod(dataset.value?.id!), [dataset], {
+  immediate: true,
+  initialData: {},
+  middleware: (_, next) => dataset.value?.id && next(),
+})
+  .onSuccess(({ data }) => {
+    for (const [key, value] of Object.entries(data)) model.value[key as keyof PackModel] = value;
+  })
+  .onError(({ error: message }) => {
+    showDialog({ title: '温馨提示', message, closeOnClickOverlay: true }).then(() => emits('back'));
+  });
+
+defineExpose({
+  scan(data: ScanData) {
+    model.value.deviceCode = data?.code ?? '';
+  },
+  reset,
+});
+</script>
+
+<template>
+  <van-toast :show="loading" type="loading" forbid-click />
+
+  <van-cell title="包装量" :value="data.packageDose ? `${data.packageDose} ml` : ''" />
+  <van-cell title="包数" :value="data.packageNumber ? `${data.packageNumber} 包` : ''" />
+
+  <slot name="scanner" title="扫描设备" :disabled="submitting"></slot>
+  <slot name="uploader" :disabled="submitting"></slot>
+
+  <van-field v-model="model.packingNote" :readonly="submitting" rows="2" autosize label="打包备注" type="textarea" placeholder="请输入备注内容" />
+
+  <slot name="submit" title="打包节点上传" :submitting="submitting" :submit="send"></slot>
+</template>
+
+<style scoped lang="scss">
+.van-field.suffix {
+  :deep(.van-field__value) {
+    flex: none;
+    //width: min-content;
+    //min-width: 120px;
+    max-width: 170px;
+
+    input {
+      text-align: center;
+    }
+  }
+}
+</style>

+ 91 - 0
src/module/step/StepPackRecheck.vue

@@ -0,0 +1,91 @@
+<script setup lang="ts">
+import { showNotify, showSuccessToast } from 'vant';
+
+import { useForm, useWatcher } from 'alova/client';
+import { getPackDataMethod, setPackRecheckDataMethod } from '@/api/pda.api.ts';
+import { fromPackModel, type PackModel } from '@/model/step.model.ts';
+import { useStepStore } from '@/stores';
+
+const emits = defineEmits<{ back: [delta?: number] }>();
+
+const storeStore = useStepStore();
+const { dataset } = storeToRefs(storeStore);
+
+const {
+  loading: submitting,
+  form: model,
+  send, reset,
+} = useForm(setPackRecheckDataMethod, { immediate: false, initialForm: fromPackModel() }).onSuccess(() => {
+  showSuccessToast(`操作成功`);
+  emits('back');
+});
+
+const { loading } = useWatcher(() => getPackDataMethod(dataset.value?.id!), [dataset], {
+  immediate: true,
+  initialData: {},
+  middleware: (_, next) => dataset.value?.id && next(),
+})
+  .onSuccess(({ data }) => {
+    for (const [key, value] of Object.entries(data)) model.value[key as keyof PackModel] = value;
+  })
+  .onError(({ error: message }) => {
+    showDialog({ title: '温馨提示', message, closeOnClickOverlay: true }).then(() => emits('back'));
+  });
+
+function submit(picture: string[]) {
+  let message = '';
+  if (!+model.value.packageDose) {
+    message = '请输入包装量';
+  } else if (!+model.value.packageNumber) {
+    message = '请输入包数';
+  }
+  if (message) {
+    showNotify({ message, type: 'warning' });
+  } else {
+    send(picture);
+  }
+}
+
+defineExpose({
+  reset,
+});
+</script>
+
+<template>
+  <van-toast :show="loading" type="loading" forbid-click />
+
+  <van-field class="suffix" v-model="model.packageDose" :readonly="submitting" type="digit" label="包装量" placeholder="请输入包装量">
+    <template #extra>
+      <div v-if="model.packageDose">ml</div>
+    </template>
+  </van-field>
+  <van-field class="suffix" v-model="model.packageNumber" :readonly="submitting" type="digit" label="包数" placeholder="请输入包数">
+    <template #extra>
+      <div v-if="model.packageNumber">包</div>
+    </template>
+  </van-field>
+
+  <slot name="uploader" :disabled="submitting"></slot>
+
+  <van-field v-model="model.packingNote" :readonly="submitting" rows="2" autosize label="打包复核备注" type="textarea" placeholder="请输入备注内容" />
+
+  <slot name="submit" title="打包复核节点上传" :submitting="submitting" :submit="submit"></slot>
+  <!--<teleport to="#bottom-handle">
+    <van-button block :loading="submitting">打印标签</van-button>
+  </teleport>-->
+</template>
+
+<style scoped lang="scss">
+.van-field.suffix {
+  :deep(.van-field__value) {
+    flex: none;
+    //width: min-content;
+    //min-width: 120px;
+    max-width: 170px;
+
+    input {
+      text-align: center;
+    }
+  }
+}
+</style>

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

@@ -0,0 +1,92 @@
+<script setup lang="ts">
+import { showNotify, showSuccessToast } from 'vant';
+
+import { useForm, useWatcher } from 'alova/client';
+import { getSoakDataMethod, setSoakDataMethod } from '@/api/pda.api.ts';
+import { fromSoakModel, type SoakModel } from '@/model/step.model.ts';
+import { useStepStore } from '@/stores';
+
+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, reset,
+} = useForm(setSoakDataMethod, { immediate: false, initialForm: fromSoakModel() }).onSuccess(() => {
+  showSuccessToast(`操作成功`);
+  emits('back');
+});
+
+const { data, loading } = useWatcher(() => getSoakDataMethod(dataset.value?.id!), [dataset], {
+  immediate: true,
+  initialData: {},
+  middleware: (_, next) => dataset.value?.id && next(),
+})
+  .onSuccess(({ data }) => {
+    for (const [key, value] of Object.entries(data)) model.value[key as keyof SoakModel] = value;
+  })
+  .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 ?? '';
+  },
+  reset,
+});
+</script>
+
+<template>
+  <van-toast :show="loading" type="loading" forbid-click />
+
+  <van-field class="suffix" v-model="model.soakingWaterValue" :readonly="submitting" type="digit" label="浸泡水量" placeholder="请输入浸泡水量">
+    <template #extra>
+      <div v-if="model.soakingWaterValue">ml</div>
+    </template>
+  </van-field>
+  <van-field class="suffix" v-model="model.soakingTime" :readonly="submitting || !!data.soakingTime" type="digit" label="浸泡时间" placeholder="请输入浸泡时间">
+    <template #extra>
+      <div v-if="model.soakingTime">min</div>
+    </template>
+  </van-field>
+
+  <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">
+.van-field.suffix {
+  :deep(.van-field__value) {
+    flex: none;
+    //width: min-content;
+    //min-width: 120px;
+    max-width: 170px;
+
+    input {
+      text-align: center;
+    }
+  }
+}
+</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(`登录成功`);
     });

+ 129 - 80
src/pages/StepPage.vue

@@ -1,113 +1,131 @@
 <script setup lang="ts">
-import { useStepStore } from '@/stores';
+import type { UploaderAfterRead, UploaderFileListItem } from 'vant';
+
 import { useRequest } from 'alova/client';
 import { getDataMethod } from '@/api/pda.api.ts';
-import { defaultMenus } from '@/model/menu.model.ts';
+import { uploadMethod } from '@/api/file.api.ts';
+import { useStepStore } from '@/stores';
+
+import { useStep } from '@/core/hook/useStep.ts';
+import { type ScanData, useScan } from '@/core/hook/useScan.ts';
 
-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();
+      step.value?.reset?.();
+      files.value = []
+      scanValue.value = ''
+    }
+  });
+});
 
-const loaded = computed(() => !!data.value?.no);
-const readonly = ref(false);
-const keyword = ref<string>(dataset.value?.no ?? '');
-
-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 step = useTemplateRef<{ scan?: (data: ScanData) => void; reset?: () => void; }>('step');
 
-    const { mode, value } = route.params;
+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();
+  }
+});
 
-    if (value) {
-      await router.push({ path: `${keyword.value}`, replace: true });
-    } else {
-      await router.push({ path: `${mode}/${keyword.value}` });
-    }
-    tabIndex.value = 3;
+const { loading, send: search } = useRequest(getDataMethod, { immediate: false })
+  .onSuccess(({ data }) => {
+    dataset.value = data;
+    next(dataset.value.no);
   })
   .onError(() => {
     keyword.value = '';
   });
 
-watch(loading, (value) => {
+function onSearch() {
+  const value = keyword.value?.trim?.();
   if (value) {
-    showLoadingToast({ forbidClick: true, duration: 0, message: '加载中...' });
+    search(value, mode.value);
   } else {
-    closeToast();
+    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">
+    <van-toast :show="loading" type="loading" forbid-click message="查询中..." />
+
     <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" />
-          <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="药品信息">
@@ -126,7 +144,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>
@@ -140,8 +158,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" capture="camera" />
+                </template>
+              </van-field>
+            </template>
+            <template v-slot:submit="{ title, submitting, submit }">
+              <div class="flex my-4 px-4 gap-4" id="bottom-handle">
+                <van-button type="primary" block :loading="submitting" @click="submit(picture)">{{ title }}</van-button>
+              </div>
+            </template>
+          </component>
+        </router-view>
       </van-tab>
     </van-tabs>
   </div>
@@ -185,3 +229,8 @@ function back(delta?: number) {
   }
 }
 </style>
+<style>
+.flex-2 {
+  flex: 2;
+}
+</style>

+ 5 - 0
src/platform/index.ts

@@ -0,0 +1,5 @@
+const userAgent = navigator.userAgent;
+
+export function platformIsPDA() {
+  return /Six\/applet \(PDA;.+\)/i.test(userAgent);
+}

+ 15 - 0
src/polyfill.ts

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

+ 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'),
             },
           ],
         },

+ 53 - 0
src/router/menu.ts

@@ -0,0 +1,53 @@
+export interface Menu {
+  index: number;
+  title: string;
+  component: Component;
+}
+
+export type MenuPath =
+  | '/step/deploy'
+  | '/step/deploy-recheck'
+  | '/step/soak'
+  | '/step/decoction'
+  | '/step/pack'
+  | '/step/pack-recheck'
+  ;
+
+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`)),
+  },
+  '/step/decoction': {
+    index: 41,
+    title: '煎煮管理',
+    component: defineAsyncComponent(() => import(`@/module/step/StepDecoction.vue`)),
+  },
+  '/step/pack': {
+    index: 51,
+    title: '打包管理',
+    component: defineAsyncComponent(() => import(`@/module/step/StepPack.vue`)),
+  },
+  '/step/pack-recheck': {
+    index: 52,
+    title: '打包复核管理',
+    component: defineAsyncComponent(() => import(`@/module/step/StepPackRecheck.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 };
 });

+ 1 - 0
src/themes/index.css

@@ -4,3 +4,4 @@
   height: 100vh;
   overflow: hidden;
 }
+

+ 0 - 8
vite.config.ts

@@ -42,14 +42,6 @@ export default defineConfig(({ mode, command }) => {
         resolvers: [VantResolver()],
         dts: '@types/components.d.ts',
       }),
-
-
-      legacy({
-        targets: ['chrome >=49'],
-        modernTargets: ['chrome >=61'],
-        polyfills: true,
-        modernPolyfills: true,
-      }),
     ],
     resolve: {
       alias: {