Browse Source

Merge branch 'feature/story-124' into develop

cc12458 2 tuần trước cách đây
mục cha
commit
4b3f4b01ff

+ 1 - 0
model/account.model.ts

@@ -7,6 +7,7 @@ export interface AccountModel {
   gender?: string;
   age?: string;
   ageUnit?: string;
+  birthdate?: string;
   phone?: string;
 }
 

+ 1 - 1
request/pharmacy.request.ts

@@ -38,5 +38,5 @@ async function analysisResponseBody(response: Response, method: Method) {
   }
   if (code !== 200) throw method.meta?.exception?.(msg, response, method) ?? msg;
   ['success', 'warn', 'error'].forEach((key) => Reflect.deleteProperty(external, key));
-  return Object.assign({}, external, data);
+  return Array.isArray(data) ? Object.assign(data, external) : Object.assign({}, external, typeof data === 'string' ? { message: data } : data);
 }

+ 1 - 1
request/system.request.ts

@@ -28,5 +28,5 @@ async function analysisResponseBody(response: Response, method: Method) {
   const { code, msg, data, ...external } = body;
   if (code !== 200) throw method.meta?.exception?.(msg, response, method) ?? msg;
   ['success', 'warn', 'error'].forEach((key) => Reflect.deleteProperty(external, key));
-  return Array.isArray(data) ? Object.assign(data, external) : Object.assign({}, external, data);
+  return Array.isArray(data) ? Object.assign(data, external) : Object.assign({}, external, typeof data === 'string' ? { message: data } : data);
 }

+ 95 - 2
src/api/pda.api.ts

@@ -2,8 +2,8 @@ import type { ResponseData } from 'alova';
 import PharmacyHttpClient from '@request/pharmacy.request.ts';
 import { requestMethodFactory } from '@/platform/request.ts';
 
-import type { DecoctionModel, PackModel, SoakModel, StepModel } from '@/model/step.model.ts';
-import { fromDecoctionModel, fromPackModel, fromSoakModel } from '@/model/step.model.ts';
+import type { DecoctionModel, PackModel, PutawayModel, SoakModel, StepModel } from '@/model/step.model.ts';
+import { fromDecoctionModel, fromPackModel, fromPutawayModel, fromSoakModel } from '@/model/step.model.ts';
 
 export function getDataMethod(no: string, mode?: string) {
   return requestMethodFactory(
@@ -157,3 +157,96 @@ export function setPackDataMethod(model: Partial<PackModel>, picture?: string[])
 export function setPackRecheckDataMethod(model: Partial<PackModel>, picture?: string[]) {
   return requestMethodFactory(PharmacyHttpClient.Put('/web/pda/Pack/addPackReview', { ...model, image: picture?.join(',') ?? '' }, { name: 'pack-edit-recheck' }));
 }
+
+/**
+ * 上架节点获取
+ * @param no 订单号
+ */
+export function getPutawayDataMethod (no: string) {
+  return PharmacyHttpClient.Post<PutawayModel, ResponseData>(
+    `/prescription/medicineChestAPI/scanLabel`,
+    {},
+    {
+      params: {
+        mode: 'putaway',
+        orderNo: no,
+        /**
+         * @deprecated 历史遗留
+         */
+        preNo: no
+      },
+      transform (data) {
+        data.orderNo = no;
+        return fromPutawayModel(data);
+      },
+      hitSource: /^(putaway|getaway)/,
+      cacheFor: null
+    }
+  );
+}
+
+/**
+ * 上架节点操作
+ */
+export function setPutawayDataMethod (model: { no: string; cabinet?: string }, picture?: string[]) {
+  return PharmacyHttpClient.Post<PutawayModel, ResponseData>(
+    '/prescription/medicineChestAPI/putaway',
+    {},
+    {
+      params: {
+        preNo: model.no,
+        containerNo: model.cabinet
+      },
+      transform (data) {
+        return fromPutawayModel({
+          ...data,
+          inBox: '1'
+        });
+      },
+      name: 'putaway-edit'
+    }
+  );
+}
+
+/**
+ * 取药节点操作
+ * @param model 处方数据,使用订单号
+ */
+export function setGetawayDataMethod (model?: PutawayModel) {
+  return requestMethodFactory(
+    PharmacyHttpClient.Post<{ message: string }, ResponseData>(
+      `/prescription/medicineChestAPI/getawayVerify`,
+      {},
+      {
+        params: {
+          orderNo: model?.no,
+          /**
+           * @deprecated 历史遗留
+           */
+          preNo: model?.prescription?.no
+        },
+        name: 'getaway-edit'
+      }
+    )
+  );
+}
+
+/**
+ * 通知取药
+ * @param model 处方数据,使用订单号
+ */
+export function notifyGetawayDataMethod (model?: PutawayModel) {
+  return PharmacyHttpClient.Post<{ message: string }, ResponseData>(
+    `/prescription/medicineChestAPI/callForMedicationCollection`,
+    {},
+    {
+      params: {
+        orderNo: model?.no,
+        /**
+         * @deprecated 历史遗留
+         */
+        preNo: model?.prescription?.no
+      },
+    }
+  );
+}

+ 62 - 0
src/components/StepWrapper.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import { useRequest } from 'alova/client';
+import { getDataMethod } from '@/api/pda.api.ts';
+import { useStepStore } from '@/stores';
+
+import { useStep } from '@/core/hook/useStep.ts';
+
+const stepStore = useStepStore();
+const { dataset, id, mode, title } = storeToRefs(stepStore);
+const loaded = computed(() => !!id.value);
+
+const keyword = ref('');
+const { menu, tabTitle, next, prev } = useStep(mode, id, title);
+
+watchEffect(() => {
+  keyword.value = id.value;
+  nextTick(() => {
+    if (!id.value) stepStore.$reset();
+  });
+});
+
+const { loading, send: search } = useRequest(getDataMethod, { immediate: false })
+  .onSuccess(({ data }) => {
+    dataset.value = data;
+    next(dataset.value.no);
+  })
+  .onError(() => {
+    keyword.value = '';
+  });
+
+function onSearch(value?: string) {
+  if (value) {
+    keyword.value = value?.trim();
+  } else {
+    value = keyword.value;
+  }
+  if (value?.trim()) {
+    search(value.trim(), mode.value);
+  } else {
+    showNotify({ message: '请使用设备按钮进行扫码', type: 'warning' });
+  }
+}
+
+defineExpose({
+  search: onSearch,
+  back: prev,
+});
+</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="menu?.title" left-text="返回" left-arrow @click-left="prev()" />
+      <van-search v-model="keyword" input-align="center" placeholder="请使用设备按钮进行扫码" @search="onSearch()" :readonly="loaded" :show-action="loaded" @cancel="prev()" />
+    </header>
+    <slot :title="tabTitle" :component="menu?.component"></slot>
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

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

@@ -3,6 +3,8 @@ import type { BridgeEventMap } from '../../../@types/bridge';
 
 export type ScanData = NonNullable<BridgeEventMap['scan']['detail']['data']>;
 
+export const scanKey = Symbol() as InjectionKey<() => Promise<void>>;
+
 export function useScan(fn: (data: ScanData) => void) {
   const callback = useDebounceFn(fn, 300)
   let onCleanup = () => {};

+ 1 - 1
src/core/hook/useStep.ts

@@ -1,6 +1,6 @@
 import { type Menu, type MenuPath, menus } from '@/router/menu.ts';
 
-export function useStep(mode: Ref<string>, id: Ref<string>, title?: Ref<string>) {
+export function useStep(mode: Ref<string>, id: Ref<string>, title?: Ref<string | void>) {
   const menu = shallowRef<Menu | void>();
   const tabTitle = computed(() => menu.value?.title.replace('管理', '确认') ?? '确认');
 

+ 46 - 0
src/model/step.model.ts

@@ -11,7 +11,16 @@ export interface StepModel {
   prescription: {
     diagnose: string;
 
+    /**
+     *  1: '中药处方',
+     *  2: '中药制剂'
+     */
     category: string;
+    /**
+     * 散装颗粒
+     * ...
+     */
+    classes?: string;
     dosageForm: string;
     totalPrice: string;
 
@@ -112,3 +121,40 @@ export function fromPackModel(data?: ResponseData) {
     data,
   ) as PackModel;
 }
+
+export interface PutawayModel extends ResponseData {
+  no: string;
+  patient: {
+    birthdate?: string;
+  };
+  prescription: {
+    classes?: string;
+    no?: string;
+  };
+  cabinet?: {
+    row: string;
+    col: string;
+    no: string;
+  };
+}
+
+export function fromPutawayModel(data?: ResponseData) {
+  return Object.assign(
+    {
+      no: data?.orderNo,
+      patient: {
+        birthdate: data?.dateOfBirth ?? '',
+      },
+      prescription: {
+        classes: data?.type,
+        no: data?.preNo,
+      },
+      cabinet: data?.inBox === '1' ? {
+        row: data?.lineNumber,
+        col: data?.columnNumber,
+        no: data?.containerNumber,
+      } : void 0,
+    },
+    data,
+  ) as PutawayModel;
+}

+ 227 - 1
src/module/step/StepPutaway.vue

@@ -1,11 +1,237 @@
 <script setup lang="ts">
+import { createReusableTemplate } from '@vueuse/core';
 
+import { useForm, useRequest, useWatcher } from 'alova/client';
+import { getPutawayDataMethod, notifyGetawayDataMethod, setGetawayDataMethod, setPutawayDataMethod } from '@/api/pda.api.ts';
+
+import { useStepStore } from '@/stores';
+
+import { type ScanData, scanKey } from '@/core/hook/useScan.ts';
+import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant';
+
+const emits = defineEmits<{ back: [delta?: number] }>();
+
+const storeStore = useStepStore();
+const { dataset } = storeToRefs(storeStore);
+
+const {
+  loading: submitting,
+  form: model,
+  send, reset,
+} = useForm(setPutawayDataMethod, { immediate: false, initialForm: { no: '', cabinet: '' } })
+  .onSuccess(({ data: { cabinet } }) => {
+    data.value.cabinet = cabinet;
+    showSuccessToast(`操作成功`);
+  })
+  .onError(({ error: message }) => {
+    model.value.cabinet = '';
+    showFailToast({ message });
+  })
+  .onComplete(() => {
+    panelHeight.value = panelAnchors[panelAnchors.length - 1];
+  });
+
+const { data, loading } = useWatcher(() => getPutawayDataMethod(dataset.value?.no!), [dataset], {
+  immediate: true,
+  initialData: {},
+  middleware: (_, next) => dataset.value?.no && next(),
+})
+  .onSuccess(({ data }) => {
+    const patient = dataset.value?.patient;
+    const prescription = dataset.value?.prescription;
+    if (patient) Object.assign(patient, data.patient);
+    if (prescription) Object.assign(prescription, data.prescription);
+
+    model.value.no = data.no;
+  })
+  .onError(({ error: message }) => {
+    showDialog({ title: '温馨提示', message, closeOnClickOverlay: true }).then(() => emits('back'));
+  });
+
+const { loading: getawayLoading, send: _getaway } = useRequest(() => setGetawayDataMethod(data.value), { immediate: false }).onSuccess(({ data }) => {
+  showSuccessToast(data);
+  emits('back');
+});
+const { loading: broadcastLoading, send: _broadcast } = useRequest(() => notifyGetawayDataMethod(data.value), { immediate: false }).onSuccess(({ data }) => {
+  showSuccessToast(data);
+});
+
+const getaway = () => {
+  showConfirmDialog({
+    title: '确认取药吗?',
+    confirmButtonText: '取药',
+  }).then(_getaway)
+}
+const broadcast = () => {
+  showConfirmDialog({
+    title: '确认通知取药吗?',
+    confirmButtonText: '通知',
+  }).then(_broadcast)
+}
+
+/* 可上架 */
+const setable = computed(() => data.value?.cabinet == null);
+/* 可扫描 */
+const scannable = computed(() => setable && panelHeight.value > 0);
+const scan = inject(scanKey, void 0);
+
+defineExpose({
+  getScan: () =>
+    scannable.value
+      ? (data: ScanData) => {
+          model.value.cabinet = data.code;
+          send();
+        }
+      : void 0,
+  reset,
+});
+
+const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{ show?: boolean }>();
+
+const panelHeight = ref(0);
+const panelAnchors = reactive([0, 100, Math.min(window.innerHeight * 0.8, 480)]);
+
+const start = () => {
+  if (!data.value.cabinet) {
+    panelHeight.value = 100;
+    nextTick(() => scan?.());
+  }
+};
 </script>
 
 <template>
-  $END$
+  <van-toast :show="loading" type="loading" forbid-click />
+
+  <DefineTemplate v-slot="{ show }">
+    <div class="card-wrapper">
+      <div class="flex" v-if="data?.cabinet || show">
+        <div class="cell"></div>
+        <div class="row flex-auto text-center">行{{ data?.cabinet?.row }}</div>
+      </div>
+      <div class="flex">
+        <div v-if="data?.cabinet || show" class="col flex flex-col justify-center text-center">列{{ data?.cabinet?.col }}</div>
+        <div class="flex-auto">
+          <van-button v-if="data?.cabinet" type="success" block @click="getaway()">柜号:{{ data?.cabinet?.no }}</van-button>
+          <van-cell-group>
+            <van-cell title="患者">
+              <template #value>
+                <div>
+                  <span v-if="dataset?.patient?.name" class="font-semibold" data-snippet=",">{{ dataset?.patient?.name }}</span>
+                  <span v-if="dataset?.patient?.gender" class="font-semibold" data-snippet=",">{{ dataset?.patient?.gender }}</span>
+                  <span v-if="dataset?.patient?.age" class="font-semibold" data-snippet=",">{{ dataset?.patient?.age }}</span>
+                </div>
+              </template>
+            </van-cell>
+            <van-cell title="出生日期" :value="dataset?.patient?.birthdate" />
+            <van-cell title="类型" :value="dataset?.prescription?.classes ?? dataset?.prescription?.category" />
+            <van-cell title="剂数" :value="dataset?.prescription?.count" />
+            <van-cell title="是否代煎" :value="dataset?.prescription?.decoction" />
+            <van-cell title="开方医生" :value="dataset?.doctor?.name" />
+          </van-cell-group>
+        </div>
+      </div>
+    </div>
+    <van-cell v-if="data?.cabinet" title="温馨提示:" label="若需要解绑处方,请通过 PC 端处理!" />
+  </DefineTemplate>
+
+  <div class="wrapper">
+    <ReuseTemplate />
+
+    <template v-if="!loading">
+      <div class="flex my-4 px-4 gap-4">
+        <van-button type="warning" block :loading="broadcastLoading" @click="broadcast()">通知取药</van-button>
+        <van-button type="success" block :loading="getawayLoading" @click="getaway()">取药</van-button>
+      </div>
+      <div v-if="setable" class="flex my-4 px-4 gap-4">
+        <van-button type="primary" block @click="start()">上柜</van-button>
+      </div>
+    </template>
+
+    <van-overlay :show="scannable" @click.self="panelHeight = 0">
+      <van-floating-panel lock-scroll v-model:height="panelHeight" :anchors="panelAnchors">
+        <template #header>
+          <div class="van-floating-panel__header flex-col">
+            <div class="van-floating-panel__header-bar"></div>
+            <div class="mt-2">上柜</div>
+          </div>
+        </template>
+
+        <van-field
+          v-if="setable"
+          label="扫描柜号"
+          :readonly="submitting || !!data.cabinet"
+          placeholder="请使用设备按钮进行扫码"
+          input-align="center"
+          v-model="model.cabinet"
+          enterkeyhint="done"
+          @keydown.enter="send()"
+          :right-icon="setable ? 'scan' : ''"
+          @click-right-icon="scan && scan()"
+        />
+        <div class="mt-4 px-4">
+          <ReuseTemplate :show="true" />
+          <div v-if="setable" class="mt-4">
+            <van-button class="mt-2" type="primary" block :loading="submitting" @click="send()">上柜</van-button>
+          </div>
+        </div>
+      </van-floating-panel>
+    </van-overlay>
+  </div>
 </template>
 
 <style scoped lang="scss">
+span[data-snippet] + span[data-snippet]::before {
+  content: attr(data-snippet);
+}
+
+.wrapper {
+  padding: var(--van-cell-vertical-padding) var(--van-cell-horizontal-padding);
+}
+
+.card-wrapper {
+  $border: 1px solid var(--van-cell-border-color);
+
+  --p: 8px;
+  --s: calc(var(--p) * 2 + 1em);
+  --van-cell-border-color: var(--van-border-color);
+
+  .cell {
+    width: var(--s);
+    height: var(--s);
+    border-right: $border;
+    border-bottom: $border;
+  }
+
+  .row {
+    padding: calc(var(--p) - 1px) 0;
+    font-weight: 600;
+    line-height: 1em;
+    letter-spacing: 0.5em;
+  }
+
+  .col {
+    padding: 0 calc(var(--p) - 1px);
+    width: calc(1em + 1px);
+    font-weight: 600;
+    line-height: 2em;
+    box-sizing: content-box;
+    border-right: $border;
+    border-bottom: $border;
+  }
+
+  :deep(.van-cell) {
+    .van-cell__title {
+      color: var(--van-cell-value-color);
+    }
+
+    .van-cell__value {
+      color: var(--van-cell-text-color);
+    }
+  }
+}
 
+.van-floating-panel__header {
+  --van-floating-panel-header-height: 50px;
+  color: var(--van-text-color-2);
+}
 </style>

+ 0 - 0
src/pages/NotFound.vue → src/pages/NotFoundPage.vue


+ 0 - 236
src/pages/StepPage.vue

@@ -1,236 +0,0 @@
-<script setup lang="ts">
-import type { UploaderAfterRead, UploaderFileListItem } from 'vant';
-
-import { useRequest } from 'alova/client';
-import { getDataMethod } from '@/api/pda.api.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 stepStore = useStepStore();
-const { dataset, id, mode, title } = storeToRefs(stepStore);
-const loaded = computed(() => !!id.value);
-
-const keyword = ref('');
-const tabIndex = ref(0);
-const { menu, tabTitle, next, prev } = useStep(mode, id, title);
-
-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 step = useTemplateRef<{ scan?: (data: ScanData) => void; reset?: () => 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();
-  }
-});
-
-const { loading, send: search } = useRequest(getDataMethod, { immediate: false })
-  .onSuccess(({ data }) => {
-    dataset.value = data;
-    next(dataset.value.no);
-  })
-  .onError(() => {
-    keyword.value = '';
-  });
-
-function onSearch() {
-  const value = keyword.value?.trim?.();
-  if (value) {
-    search(value, mode.value);
-  } else {
-    showNotify({ message: '请使用设备按钮进行扫码', type: 'warning' });
-  }
-}
-
-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="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="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="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="药品信息">
-        <table class="min-w-[600px] w-full">
-          <thead>
-            <tr>
-              <th scope="col"></th>
-              <th scope="col">药品名称</th>
-              <th scope="col">药品规格</th>
-              <th scope="col">剂量</th>
-              <th scope="col">单位</th>
-              <th scope="col">用法</th>
-              <th scope="col">零售价</th>
-              <th scope="col">产地</th>
-              <th scope="col">小计</th>
-            </tr>
-          </thead>
-          <tbody>
-            <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>
-              <td style="text-align: right">{{ medicine.dosage }}</td>
-              <td>{{ medicine.unit }}</td>
-              <td>{{ medicine.usage }}</td>
-              <td>{{ medicine.place }}</td>
-              <td style="text-align: right">{{ medicine.unitPrice }}</td>
-              <td style="text-align: right">{{ medicine.totalPrice }}</td>
-            </tr>
-          </tbody>
-        </table>
-      </van-tab>
-      <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>
-</template>
-
-<style scoped lang="scss">
-.content {
-  display: flex;
-  flex-direction: column;
-
-  :deep(.van-tabs__wrap) {
-    flex: none;
-  }
-
-  :deep(.van-tabs__content) {
-    flex: auto;
-    overflow: auto;
-  }
-
-  //--van-cell-text-color: #fff
-  --van-cell-value-color: var(--van-text-color);
-  --van-cell-text-color: var(--van-text-color-2);
-
-  table {
-    border-collapse: collapse;
-    border: 2px solid rgb(140 140 140);
-    font-size: 14px;
-    letter-spacing: 1px;
-  }
-
-  thead,
-  tfoot {
-    background-color: rgb(228 240 245);
-  }
-
-  th,
-  td {
-    border: 1px solid rgb(160 160 160);
-    padding: 8px 10px;
-    text-align: center;
-  }
-}
-</style>
-<style>
-.flex-2 {
-  flex: 2;
-}
-</style>

+ 103 - 0
src/pages/StepSimplePage.vue

@@ -0,0 +1,103 @@
+<script setup lang="ts">
+import type { UploaderAfterRead, UploaderFileListItem } from 'vant';
+
+import { uploadMethod } from '@/api/file.api.ts';
+
+import { useStepStore } from '@/stores';
+import { type ScanData, scanKey, useScan } from '@/core/hook/useScan.ts';
+
+import type StepWrapper from '@/components/StepWrapper.vue';
+
+const stepStore = useStepStore();
+const { id } = storeToRefs(stepStore);
+const loaded = computed(() => !!id.value);
+
+const stepWrapper = useTemplateRef<InstanceType<typeof StepWrapper>>('step-wrapper');
+const stepChild = useTemplateRef<{ getScan?: () => (data: ScanData) => void; reset?: () => void }>('step-child');
+const { scanValue, scan } = useScan((data) => {
+  /* 组件内扫描按钮 */
+  const scan = stepChild.value?.getScan?.();
+  if (typeof scan === 'function') {
+    scanValue.value = data.code;
+    scan(data);
+  } else {
+    stepWrapper.value?.search(data.code);
+  }
+});
+provide(scanKey, scan)
+
+const onBack = () => stepWrapper?.value?.back?.();
+
+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';
+    }
+  }
+};
+
+watch(
+  id,
+  (value, oldValue) => {
+    if (value !== oldValue) {
+      stepChild?.value?.reset?.();
+      scanValue.value = '';
+      files.value = [];
+    }
+  },
+  { immediate: true },
+);
+</script>
+
+<template>
+  <StepWrapper ref="step-wrapper">
+    <template #default="{ title, component }">
+      <div v-if="loaded" class="content flex-auto overflow-auto">
+        <router-view>
+          <component ref="step-child" :is="component" :title="title" @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>
+      </div>
+    </template>
+  </StepWrapper>
+</template>
+
+<style scoped lang="scss">
+.content {
+  display: flex;
+  flex-direction: column;
+}
+</style>

+ 210 - 0
src/pages/StepTabPage.vue

@@ -0,0 +1,210 @@
+<script setup lang="ts">
+import type { UploaderAfterRead, UploaderFileListItem } from 'vant';
+
+import { uploadMethod } from '@/api/file.api.ts';
+
+import { useStepStore } from '@/stores';
+import { type ScanData, useScan } from '@/core/hook/useScan.ts';
+
+import type StepWrapper from '@/components/StepWrapper.vue';
+
+const stepStore = useStepStore();
+const { dataset, id } = storeToRefs(stepStore);
+const loaded = computed(() => !!id.value);
+
+const stepWrapper = useTemplateRef<InstanceType<typeof StepWrapper>>('step-wrapper');
+const stepChild = useTemplateRef<{ scan?: (data: ScanData) => void; reset?: () => void }>('step-child');
+
+const tabIndex = ref(0);
+
+const { scanValue, scan } = useScan((data) => {
+  /* 组件内扫描按钮 */
+  if (tabIndex.value === 3 && typeof stepChild.value?.scan === 'function') {
+    scanValue.value = data.code;
+    stepChild.value?.scan?.(data);
+  } else {
+    stepWrapper.value?.search(data.code);
+  }
+});
+
+const onBack = () => stepWrapper?.value?.back?.()
+
+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';
+    }
+  }
+};
+
+watch(
+  id,
+  (value, oldValue) => {
+    setTimeout(() => { tabIndex.value = value ? 3 : 0; }, 20);
+
+    if (value !== oldValue) {
+      stepChild?.value?.reset?.();
+      scanValue.value = '';
+      files.value = [];
+    }
+  },
+  { immediate: true },
+);
+</script>
+
+<template>
+  <StepWrapper ref="step-wrapper">
+    <template #default="{ title, component }">
+      <van-tabs class="content flex-auto overflow-hidden" v-model:active="tabIndex">
+        <van-tab title="就诊信息">
+          <van-cell-group>
+            <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="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="药品信息">
+          <table class="min-w-[600px] w-full">
+            <thead>
+              <tr>
+                <th scope="col"></th>
+                <th scope="col">药品名称</th>
+                <th scope="col">药品规格</th>
+                <th scope="col">剂量</th>
+                <th scope="col">单位</th>
+                <th scope="col">用法</th>
+                <th scope="col">零售价</th>
+                <th scope="col">产地</th>
+                <th scope="col">小计</th>
+              </tr>
+            </thead>
+            <tbody>
+              <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>
+                <td style="text-align: right">{{ medicine.dosage }}</td>
+                <td>{{ medicine.unit }}</td>
+                <td>{{ medicine.usage }}</td>
+                <td>{{ medicine.place }}</td>
+                <td style="text-align: right">{{ medicine.unitPrice }}</td>
+                <td style="text-align: right">{{ medicine.totalPrice }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </van-tab>
+        <van-tab :title="title" :disabled="!loaded">
+          <router-view>
+            <component ref="step-child" :is="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>
+    </template>
+  </StepWrapper>
+</template>
+
+<style scoped lang="scss">
+.content {
+  display: flex;
+  flex-direction: column;
+
+  :deep(.van-tabs__wrap) {
+    flex: none;
+  }
+
+  :deep(.van-tabs__content) {
+    flex: auto;
+    overflow: auto;
+  }
+
+  //--van-cell-text-color: #fff
+  --van-cell-value-color: var(--van-text-color);
+  --van-cell-text-color: var(--van-text-color-2);
+
+  table {
+    border-collapse: collapse;
+    border: 2px solid rgb(140 140 140);
+    font-size: 14px;
+    letter-spacing: 1px;
+  }
+
+  thead,
+  tfoot {
+    background-color: rgb(228 240 245);
+  }
+
+  th,
+  td {
+    border: 1px solid rgb(160 160 160);
+    padding: 8px 10px;
+    text-align: center;
+  }
+}
+</style>
+<style>
+.flex-2 {
+  flex: 2;
+}
+</style>

+ 15 - 5
src/router/index.ts

@@ -1,8 +1,8 @@
 import { createRouter, createWebHistory } from 'vue-router';
 import { AccountGuard } from '@/router/account.guard.ts';
-import { stepMode } from '@/router/menu.ts';
+import { stepSimpleMode, stepTabMode } from '@/router/menu.ts';
 
-import NotFound from '@/pages/NotFound.vue';
+import NotFoundPage from '@/pages/NotFoundPage.vue';
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -23,8 +23,18 @@ const router = createRouter({
           meta: { title: '' },
         },
         {
-          path: `step/:mode(${stepMode.join('|')})`,
-          component: () => import(`@/pages/StepPage.vue`),
+          path: `step/:mode(${stepSimpleMode.join('|')})`,
+          component: () => import(`@/pages/StepSimplePage.vue`),
+          children: [
+            {
+              path: ':value',
+              component: () => import('@/module/step/Step.vue'),
+            },
+          ],
+        },
+        {
+          path: `step/:mode(${stepTabMode.join('|')})`,
+          component: () => import(`@/pages/StepTabPage.vue`),
           children: [
             {
               path: ':value',
@@ -36,7 +46,7 @@ const router = createRouter({
       beforeEnter: [AccountGuard.grant],
     },
     { path: '', redirect: '/home' },
-    { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
+    { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundPage },
   ],
 });
 

+ 9 - 2
src/router/menu.ts

@@ -4,7 +4,11 @@ export interface Menu {
   component: Component;
 }
 
-export const stepMode = [
+export const stepSimpleMode = [
+  'putaway',
+] as const;
+
+export const stepTabMode = [
   'deploy', 'deploy-recheck',
   'soak',
   'decoction',
@@ -12,7 +16,10 @@ export const stepMode = [
   'putaway',
 ] as const;
 
-export type MenuPath = `/step/${(typeof stepMode)[number]}`;
+export type MenuPath =
+  | `/step/${(typeof stepSimpleMode)[number]}`
+  | `/step/${(typeof stepTabMode)[number]}`
+  ;
 
 export const menus: Record<MenuPath, Menu> = {
   '/step/deploy': {