Browse Source

Merge branch 'feature/drink' into feature/next

cc12458 2 tháng trước cách đây
mục cha
commit
847cede1d8

BIN
src/assets/images/alcohol-0.png


BIN
src/assets/images/alcohol-1.png


+ 212 - 0
src/components/PickerDialog.vue

@@ -0,0 +1,212 @@
+<script setup lang="ts">
+import type { Option } from '@/request/model';
+import { tryOnBeforeMount, tryOnUnmounted } from '@vueuse/core';
+import type { DialogProps } from 'vant';
+
+export interface Props {
+  options: Option[];
+  title?: string;
+  multiple?: boolean;
+  /* 单选点击选项直接确定 */
+  immediate?: boolean;
+  selected?: (string | EditableOption)[];
+}
+
+type EditableOption = Option & { inputValue?: string; showLabel?: string };
+
+let cache: WeakMap<Option[], EditableOption[]>;
+const onCleanup = () => {
+  cache = new WeakMap();
+};
+
+const props = defineProps<Props>();
+const emits = defineEmits<{ closed: []; selected: [{ label: string; value: string }[]] }>();
+const show = defineModel('show', { default: false });
+
+const options = ref<EditableOption[]>([]);
+const dialog = reactive<Partial<DialogProps>>({
+  showCancelButton: true,
+});
+
+watchEffect(() => {
+  dialog.title = props.title ?? void 0;
+  dialog.showConfirmButton = props.multiple || !props.immediate;
+});
+const selectedInput = useTemplateRef<{ focus(): void; $el: HTMLInputElement; }>('input');
+const selectedOptions = ref<EditableOption>();
+const dialogConfirmHandle = (emit?: boolean) => {
+  if (selectedOptions.value && !emit) {
+    const option = selectedOptions.value;
+    if (!option.inputValue) option.inputValue = option.showLabel;
+    if (!option.inputValue || option.inputValue === option.label) return dialogCancelHandle();
+
+    selectedOptions.value.showLabel = option.inputValue;
+    selectedOptions.value.checked = true;
+    if (props.immediate) handle(selectedOptions.value, true);
+    else selectedOptions.value = void 0;
+  } else {
+    emits('selected', options.value.filter((option) => option.checked).map((option) => ({
+      label: option.showLabel ?? option.label,
+      value: option.editable ? `${option.value}:${option.showLabel ?? option.label}` : option.value,
+    })));
+    show.value = false;
+  }
+};
+const dialogCancelHandle = () => {
+  if (selectedOptions.value) {
+    dialog.showConfirmButton = props.multiple || !props.immediate;
+    delete dialog.confirmButtonText;
+    delete dialog.cancelButtonText;
+    const option = selectedOptions.value;
+    selectedOptions.value.inputValue = option.showLabel !== option.label ? option.showLabel : ``;
+    selectedOptions.value.checked = false;
+    selectedOptions.value = void 0;
+  } else {
+    show.value = false;
+  }
+};
+const dialogOpenHandle = (select?: EditableOption[]) => {
+  select ??= (props.selected?.map((option) => {
+    if (typeof option === 'string') {
+      const [value, inputValue] = option.split(':');
+      if (!value) return false;
+      option = { value, inputValue } as EditableOption;
+    }
+    return option;
+  })?.filter(Boolean) as EditableOption[]) ?? [];
+
+  const value = props.options;
+  if (!cache.has(value)) cache.set(value, value.map((item) => Object.assign({ inputValue: '', showLabel: item.label }, item)));
+  const values = cache.get(value)!;
+
+  if (select.length) for (const option of values) option.checked = false;
+  for (const item of select) {
+    if (!item) continue;
+    const option = values.find((v) => v.value === item.value);
+    if (!option) continue;
+    if (option.editable) {
+      option.inputValue = item.inputValue;
+      option.showLabel = item.inputValue ?? option.label;
+    }
+    option.checked = true;
+  }
+
+  options.value = values;
+  show.value = true;
+};
+const dialogClosedHandle = () => {
+  emits('closed');
+  selectedOptions.value = void 0;
+};
+const handle = (option: EditableOption, checked?: boolean) => {
+  if (option.disabled) return;
+
+  if (option.editable && checked == null) {
+    selectedOptions.value = option;
+    dialog.showConfirmButton = true;
+    dialog.confirmButtonText = '确认';
+    dialog.cancelButtonText = '返回';
+    return nextTick(() => {
+      /*try { selectedInput.value?.$el?.querySelector('input')?.select(); } catch (e) {}*/
+      try { selectedInput.value?.focus?.(); } catch (e) {}
+    })
+  }
+
+  checked ??= !option.checked;
+  if (checked) {
+    if (!props.multiple || option.single) for (const option of options.value) option.checked = false;
+    else {
+      const single = options.value.find((option) => option.single);
+      if (single) single.checked = false;
+    }
+  }
+
+  option.checked = checked;
+  if (props.immediate) setTimeout(() => dialogConfirmHandle(true), 100);
+};
+
+tryOnBeforeMount(() => onCleanup());
+tryOnUnmounted(() => onCleanup());
+defineExpose({ onCleanup });
+</script>
+
+<template>
+  <div>
+    <slot :handle="dialogOpenHandle"></slot>
+    <van-dialog
+      class="sub-dialog scrollable"
+      :show="show"
+      v-bind="dialog"
+      @confirm="dialogConfirmHandle"
+      @cancel="dialogCancelHandle"
+      @open="dialogOpenHandle"
+      @closed="dialogClosedHandle"
+    >
+      <template v-if="selectedOptions">
+        <van-cell-group inset class="form-wrapper">
+          <van-field ref="input" v-model="selectedOptions.inputValue" :placeholder="selectedOptions.showLabel" maxlength="10" />
+        </van-cell-group>
+      </template>
+      <template v-else>
+        <slot name="options" :options="options" :handle="handle">
+          <div class="grid grid-rows-1 grid-cols-2 gap-4 py-4 px-12">
+            <div
+              v-for="(option, index) in options"
+              :key="option.value"
+              class="sub-option flex justify-center items-center min-h-16 text-lg text-primary hover:text-white rounded-xl border border-primary hover:border-primary-400"
+              :class="{ checked: option.checked, disabled: option.disabled }"
+              @click="handle(option)"
+            >
+              <div class="p-2 text-center">{{ option.showLabel }}</div>
+            </div>
+          </div>
+        </slot>
+      </template>
+    </van-dialog>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.form-wrapper {
+  .van-field {
+    margin: 0;
+    padding: 0;
+  }
+
+  :deep(.van-field__control) {
+    margin-bottom: 24px;
+    padding: 8px;
+    border: 1px solid #38ff6e;
+    border-radius: 8px;
+    text-align: center;
+  }
+}
+
+.sub-option.checked {
+  color: #fff;
+  background-color: var(--primary-color);
+}
+</style>
+<style lang="scss">
+.sub-dialog {
+  --van-dialog-width: 60vw;
+  --van-dialog-font-size: 24px;
+  --van-dialog-button-height: 60px;
+  --van-button-default-font-size: 24px;
+
+  &.scrollable {
+    max-height: 80vh;
+    display: flex;
+    flex-direction: column;
+
+    .van-dialog__header {
+      padding-bottom: var(--van-dialog-header-padding-top);
+    }
+
+    .van-dialog__content {
+      flex: auto;
+      overflow-y: auto;
+    }
+  }
+}
+</style>

+ 142 - 0
src/modules/alcohol/alcohol.page.vue

@@ -0,0 +1,142 @@
+<script setup lang="ts">
+import { Notify } from '@/platform';
+import { tryOnBeforeMount, tryOnUnmounted, useCountdown } from '@vueuse/core';
+
+import { getAlcoholReportMethod } from '@/request/api';
+import type { Flow, FlowRoute } from '@/request/model';
+import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
+import { useRouteMeta } from '@/router/hooks/useRouteMeta';
+
+import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
+import alcohol_0 from '@/assets/images/alcohol-0.png?url';
+import alcohol_1 from '@/assets/images/alcohol-1.png?url';
+
+const router = useRouter();
+const actionText = computed(() => `获取健康调理方案`);
+/* 倒计时完成动作 */
+const done = shallowRef<Flow>();
+/* 下一动作可选 */
+const next = shallowRef<Flow>();
+
+const title = useRouteMeta('title');
+const { handle, flow, loading } = useRouteNext({
+  onSuccess(flow) { return load(flow); },
+  onError(error) { Notify.warning(error.message); },
+});
+
+const { remaining, start, stop } = useCountdown(5, {
+  onComplete() { replace(done.value!); },
+  immediate: false,
+});
+const countdown = computed(() => remaining.value.toString().padStart(2, '0'));
+
+tryOnBeforeMount(() => handle());
+tryOnUnmounted(() => stop());
+
+const report = ref<Awaited<ReturnType<typeof getAlcoholReportMethod>>>();
+const tips = '建议您每日饮酒';
+const description = computed(() => report.value?.alcohol?.description?.replace?.(new RegExp(`^${tips}`), '')?.replace?.(/(\S)\s*(或)/, '$1\n$2') ?? '');
+
+async function load(flow: FlowRoute) {
+  stop();
+  report.value = await getAlcoholReportMethod();
+
+  done.value = flow.next.optional ? { title: '返回首页', route: '/screen' } : flow.next;
+  next.value = flow.next.optional ? flow.next : void 0;
+  start(report.value?.alcohol?.description ? 10 : 5);
+}
+
+const replace = (flow: Flow) => router.push({ path: getRoutePath(flow), replace: true });
+</script>
+<template>
+  <div>
+    <div class="page-header flex py-4 px-4">
+      <div class="grow shrink-0 h-full min-w-16"></div>
+      <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
+        <div class="font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
+          {{ flow?.value.title ?? title }}
+        </div>
+      </div>
+      <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
+        <router-link :to="{ path: '/screen' }" replace>
+          <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页" />
+        </router-link>
+      </div>
+    </div>
+    <div class="page-content flex flex-col">
+      <van-toast v-if="loading" show type="loading" message="加载中" />
+      <template v-else>
+        <header>
+          <div class="my-6 text-primary text-2xl text-center" v-if="report?.date">报告日期:{{ report.date }}</div>
+        </header>
+        <main class="flex flex-col justify-evenly">
+          <div class="report-wrapper" v-if="report">
+            <div class="card m-6 text-lg" v-if="report.alcohol?.condition">
+              <div v-if="title" class="card__title mb-3 text-primary text-2xl font-bold">您的情况为</div>
+              <div class="card__content">
+                <div class="text-center text-4xl font-bold pre" style="letter-spacing: 4px">
+                  {{ report.alcohol?.condition }}
+                </div>
+              </div>
+            </div>
+            <div class="card m-6 text-lg">
+              <div v-if="title" class="card__title mb-3 text-primary text-2xl">{{ tips }}</div>
+              <div class="card__content">
+                <div class="flex items-center justify-center min-h-32" v-if="report.alcohol?.description">
+                  <div class="text-center text-5xl font-semibold whitespace-pre" style="letter-spacing: 4px; line-height: 1.25em">
+                    {{ description }}
+                  </div>
+                  <img v-if="report.alcohol?.volume?.length" class="image-container object-scale-down" :src="alcohol_1" alt="可饮酒" />
+                </div>
+                <van-empty v-else :image="alcohol_0" image-size="160" description="暂无建议" />
+              </div>
+            </div>
+          </div>
+        </main>
+        <footer class="flex flex-col justify-center items-center gap-4">
+          <van-button v-if="next" class="decorate !text-xl" @click="replace(next)">
+            {{ next.title ?? actionText }}
+          </van-button>
+          <van-button v-if="done" class="decorate !text-xl !text-primary-400" @click="replace(done)">
+            {{ done.title ?? actionText }}
+            <template v-if="remaining">({{ countdown }}s)</template>
+          </van-button>
+        </footer>
+      </template>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+header {
+  flex: 1 1 10%;
+}
+
+footer {
+  flex: 1 1 30%;
+}
+
+main {
+  position: relative;
+  flex: 1 1 50%;
+}
+
+.image-container {
+  width: 30vw;
+  height: 30vw;
+  max-width: 216px;
+  max-height: 216px;
+}
+
+.card__content {
+  --van-empty-description-color: #fff;
+  --van-empty-description-margin-top: 36px;
+  --van-empty-description-font-size: 18px;
+}
+</style>
+<style scoped lang="scss">
+.report-wrapper .card {
+  padding: 24px;
+  border-radius: 24px;
+  box-shadow: inset 0 0 80px 0 #34a76b60;
+}
+</style>

+ 125 - 58
src/pages/register.page.vue

@@ -2,26 +2,29 @@
 import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
 import { Notify, Toast } from '@/platform';
 
-import {
-  getCaptchaMethod,
-  registerAccountMethod,
-  registerFieldsMethod,
-  searchAccountMethod,
-}                                     from '@/request/api';
-import type { Fields, RegisterModel } from '@/request/model';
-import { useRouteQuery }              from '@vueuse/router';
+import { getCaptchaMethod, registerAccountMethod, registerFieldsMethod, dictionariesMethod, searchAccountMethod } from '@/request/api';
+import type { Fields, Option, RegisterModel } from '@/request/model';
+import { useRouteQuery } from '@vueuse/router';
 
 import { getRoutePath, useRouteNext } from '@/computable/useRouteNext';
-import { useCaptcha, useRequest } from 'alova/client';
+import { useCaptcha, useRequest, useSerialRequest } from 'alova/client';
 
-import type { FormInstance }           from 'vant';
+import type { FormInstance } from 'vant';
 import { RadioGroup as vanRadioGroup } from 'vant';
-
-
-const { data: fields, loading } = useRequest(registerFieldsMethod);
+import PickerDialog from '@/components/PickerDialog.vue';
 
 const formRef = useTemplateRef<FormInstance>('register-form');
 const modelRef = ref<Partial<RegisterModel>>({ code: '' });
+const modelValueRef = ref<Partial<RegisterModel>>({});
+const model = computed(() => ({ ...modelRef.value, ...modelValueRef.value }));
+
+const { data: fields, loading } = useSerialRequest([dictionariesMethod, (dictionaries) => registerFieldsMethod(dictionaries)]).onSuccess(({ data }) => {
+  const sex = data.find((field) => field.name === 'sex');
+  if (sex) {
+    const unknown = (<any>sex).component?.options?.find((option: any) => option.value === '2');
+    modelRef.value.sex = unknown?.value;
+  }
+});
 
 const router = useRouter();
 
@@ -38,7 +41,25 @@ const { handle } = useRouteNext({
 const { loading: searching, send: search } = useRequest((data) => searchAccountMethod(data), {
   immediate: false,
 }).onSuccess(({ data }) => {
-  modelRef.value = { ...modelRef.value, ...data };
+  const modelLabel = {} as Record<string, any>;
+  const modelValue = {} as Record<string, any>;
+
+  for (const [key, value] of Object.entries(data)) {
+    const field = fields.value?.find((field) => field.name === key);
+    if (typeof value === 'string' && field?.component?.name === 'picker') {
+      const result = value.split(',').map((value) => {
+        const [v, l] = value.split(':');
+        return { value, label: l ?? (field.component as { options: Option[] })?.options?.find((option) => option.value === v)?.label ?? v };
+      });
+      modelValue[key] = result.map((t) => t.value).join(',');
+      modelLabel[key] = result.map((t) => t.label).join(',');
+    } else {
+      modelLabel[key] = value;
+    }
+  }
+
+  modelRef.value = { ...modelRef.value, ...modelLabel };
+  modelValueRef.value = { ...modelValueRef.value, ...modelValue };
 });
 
 let captchaLoaded = false;
@@ -52,6 +73,7 @@ const { loading: captchaLoading, countdown, send: getCaptcha } = useCaptcha(
 const getCaptchaHandle = async () => {
   try {
     await formRef.value?.validate('phone');
+    if ( !modelRef.value.phone ) throw { message: `请输入手机号码` };
     await getCaptcha();
     const field = fields.value.find(field => field.name === 'code');
     if ( field?.keyboard ) { field.keyboard.show = true; }
@@ -71,12 +93,12 @@ const searchHandle = async (key: 'cardno' | 'code') => {
 
 function onKeyboardBlur(field: Fields[number]) {
   if ( field?.name === 'phone' && !captchaLoaded ) { getCaptchaHandle(); }
-  if ( field?.name === 'cardno' ) { searchHandle('cardno'); }
-  if ( field?.name === 'code' ) { searchHandle('code'); }
+  if ( field?.name === 'cardno' && modelRef.value.cardno ) { searchHandle('cardno'); }
+  if ( field?.name === 'code' && modelRef.value.phone ) { searchHandle('code'); }
 }
 
 function onSubmitHandle() {
-  submit(toValue(modelRef));
+  submit(model.value);
 }
 
 function fix(key: string) {
@@ -101,6 +123,51 @@ onBeforeUnmount(() => {
     if ( key?.startsWith('scan_') ) sessionStorage.removeItem(key);
   }
 });
+
+const keyboardProps = reactive({
+  key: '',
+  props: {},
+  show: false,
+});
+
+const pickerProps = reactive({
+  key: '',
+  props: { options: [], selected: [] },
+  show: false,
+  handle(options: Option[]) {
+    const key = (this ?? pickerProps).key;
+    (modelRef.value as Record<string, any>)[key] = options.map(option => option.label).join(',');
+    (modelValueRef.value as Record<string, any>)[key] = options.map(option => option.value).join(',');
+  }
+});
+function onFieldFocus(field: any) {
+  if (field.keyboard) {
+    keyboardProps.key = field.name;
+    keyboardProps.show = true;
+    keyboardProps.props = {
+      ...field.keyboard,
+      maxlength: field.control?.maxlength ?? Number.POSITIVE_INFINITY,
+      onBlur() {
+        keyboardProps.show = false;
+        onKeyboardBlur(field);
+      },
+    }
+  } else if (field.component?.name === 'picker') {
+    pickerProps.key = field.name;
+    pickerProps.show = true;
+    pickerProps.props = {
+      ...field.component.props,
+      title: field.control.label,
+      options: field.component.options,
+      selected: (modelValueRef.value as Record<string, string>)[field.name]?.split(','),
+    }
+    return;
+  }
+}
+function onFieldBlur(field: any) {
+  keyboardProps.show = false;
+  pickerProps.show = false;
+}
 </script>
 <template>
   <div>
@@ -124,55 +191,50 @@ onBeforeUnmount(() => {
       >
         <van-cell-group :border="false">
           <template v-for="field in fields" :key="field.name">
-            <van-field v-model="modelRef[field.name]" :name="field.name" :id="field.name"
-                       :rules="field.rules" v-bind="field.control"
-                       :class="{'no-border': field.control?.border === false}"
-                       :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true)"
-                       @blur="field.keyboard && (field.keyboard.show = false)"
-                       :readonly="field.control.readonly" @click="field.keyboard && (field.keyboard.show = true)"
-            >
-              <template #input v-if="field.component?.name === 'radio'">
-                <van-radio-group v-model="modelRef[field.name]" direction="horizontal" shape="dot">
-                  <van-radio v-for="option in field.component?.options" :key="option.value" :name="option.value">
-                    {{ option.label }}
-                  </van-radio>
-                </van-radio-group>
-              </template>
-              <template #input v-else-if="field.component?.name === 'code'">
-                <van-password-input
-                  style="width: 100%;"
-                  v-model:value="modelRef[field.name]" v-bind="(field.component as any)!.props"
-                  :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true);fix('code')"
-                />
-              </template>
-              <template #button>
-                <div class="text-primary cursor-pointer">
-                  <template v-if="field.component?.name === 'code'">
-                    <div class="text-primary cursor-pointer" @click="getCaptchaHandle()">
-                      {{ captchaLoading ? '发送中...' : countdown > 0 ? `${ countdown }后可重发` : '获取验证码' }}
-                    </div>
-                  </template>
-                  <template v-else>{{ field.suffix }}</template>
-                </div>
-              </template>
-            </van-field>
-            <van-number-keyboard
-              v-if="field.keyboard"
-              v-model="modelRef[field.name]"
-              v-bind="field.keyboard" :maxlength="field.control.maxlength"
-              @blur="field.keyboard.show = false; onKeyboardBlur(field)"
-            />
+            <template v-if="!field.control?.hide || (typeof field.control?.hide === 'function' && !field.control.hide(model))">
+              <van-field v-model="modelRef[field.name]" :name="field.name" :id="field.name"
+                         :rules="field.rules" v-bind="field.control"
+                         :class="{'no-border': field.control?.border === false}"
+                         @focus="onFieldFocus(field)" @blur="onFieldBlur(field)"
+                         :readonly="field.control?.readonly" @click="onFieldFocus(field)"
+              >
+                <template #input v-if="field.component?.name === 'radio'">
+                  <van-radio-group v-model="modelRef[field.name]" direction="horizontal" shape="dot">
+                    <van-radio v-for="option in field.component?.options" :key="option.value" :name="option.value">
+                      {{ option.label }}
+                    </van-radio>
+                  </van-radio-group>
+                </template>
+                <template #input v-else-if="field.component?.name === 'code'">
+                  <van-password-input
+                    style="width: 100%;"
+                    v-model:value="modelRef[field.name]" v-bind="(field.component as any)!.props"
+                    :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true);fix('code')"
+                  />
+                </template>
+                <template #button>
+                  <div class="text-primary cursor-pointer">
+                    <template v-if="field.component?.name === 'code'">
+                      <div class="text-primary cursor-pointer" @click="getCaptchaHandle()">
+                        {{ captchaLoading ? '发送中...' : countdown > 0 ? `${ countdown }后可重发` : '获取验证码' }}
+                      </div>
+                    </template>
+                    <template v-else>{{ field.suffix }}</template>
+                  </div>
+                </template>
+              </van-field>
+            </template>
           </template>
         </van-cell-group>
       </van-form>
       <div class="m-4">
         <div class="m-auto size-16 cursor-pointer">
           <van-loading v-if="submitting || loading" type="spinner" size="64" color="#38ff6e" />
-          <img v-else class="size-full"
-               src="@/assets/images/next-step.svg" alt="提交" @click="formRef?.submit()"
-          >
+          <img v-else class="size-full" src="@/assets/images/next-step.svg" alt="提交" @click="formRef?.submit()">
         </div>
       </div>
+      <van-number-keyboard v-bind="keyboardProps.props" :show="keyboardProps.show" v-model="modelRef[keyboardProps.key]"></van-number-keyboard>
+      <PickerDialog v-bind="pickerProps.props" immediate v-model:show="pickerProps.show" @selected="pickerProps.handle($event)"></PickerDialog>
     </div>
   </div>
 </template>
@@ -255,4 +317,9 @@ onBeforeUnmount(() => {
 .van-radio-group {
   height: 40px + 2px;
 }
+
+.sub-option.checked {
+  color: #fff;
+  background-color: var(--primary-color);
+}
 </style>

+ 17 - 4
src/request/api/account.api.ts

@@ -1,5 +1,6 @@
-import { cacheFor }                                                     from '@/request/api/index';
-import { type Fields, fromRegisterFields, getPath, type RegisterModel } from '@/request/model';
+import { cacheFor } from '@/request/api/index';
+import type { Dictionaries, Fields, RegisterModel } from '@/request/model';
+import { fromRegisterFields, getPath} from '@/request/model';
 import { useVisitor } from '@/stores';
 
 import HTTP from '../alova';
@@ -11,13 +12,13 @@ export function getCaptchaMethod(mobile: string) {
   });
 }
 
-export function registerFieldsMethod() {
+export function registerFieldsMethod(dictionaries?: Dictionaries) {
   return HTTP.Post<Fields, { tabletFileFields: string[] }>(`/fdhb-tablet/warrantManage/getPageSets`, void 0, {
     cacheFor, name: `variate:register_fields`,
     params: { k: 'register_fields' },
     transform(data, headers) {
       const options = data?.tabletFileFields ?? [];
-      return fromRegisterFields(options);
+      return fromRegisterFields(options, dictionaries);
     },
   });
 }
@@ -64,3 +65,15 @@ export function scanAccountMethod(key: string) {
     },
   });
 }
+
+export function dictionariesMethod() {
+  return HTTP.Get<Dictionaries | void, any>(`/fdhb-tablet/dict/getDicts`, {
+    cacheFor, name: `variate:dict`,
+    transform(data) {
+      if (!Array.isArray(data)) return void 0;
+      const dictionaries: Dictionaries = new Map();
+      for (const { dictType, items } of data) dictionaries.set(dictType, items.map((item: any) => ({label: item.dictLabel, value: item.dictValue })));
+      return dictionaries.size ? dictionaries : void 0;
+    },
+  });
+}

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

@@ -1,6 +1,7 @@
 import { useVisitor } from '@/stores';
 import HTTP                                           from '../alova';
 import type { QuestionnaireStorage }                  from '../model';
+import { getAnalysisExtendFlowValue }                 from '../model';
 import { fromQuestionnaireData, toQuestionnaireData } from '../model';
 
 
@@ -11,7 +12,7 @@ export function questionnaireMethod(data = []) {
   if ( !data?.length ) { storage = { questions: [] }; }
   return HTTP.Post(
     `/fdhb-tablet/dialogueManage/dialog/${ Visitor.patientId }/${ Visitor.resultId }`,
-    { ...toQuestionnaireData (data, storage), asyncTongueResult: true },
+    { ...toQuestionnaireData (data, storage), asyncTongueResult: true, extendFlow: getAnalysisExtendFlowValue() },
     {
       meta: { ignoreException: true },
       transform(data: Record<string, any>, headers) {

+ 14 - 0
src/request/api/report.api.ts

@@ -55,6 +55,20 @@ export function getReportMethod(id: string) {
   });
 }
 
+export function getAlcoholReportMethod() {
+  const Visitor = useVisitor();
+  const params = { healthAnalysisReportId: Visitor.reportId, patientId: Visitor.patientId };
+  return HTTP.Get(`/fdhb-tablet/analysisManage/getHealRepDetailById`, {
+    params,
+    transform(data: Record<string, any>) {
+      return {
+        date: data?.alcoholAnalysisReportDate /*?? data?.reportTime*/,
+        alcohol: fromAnalysisModel('alcohol', data),
+      }
+    }
+  });
+}
+
 export function updateReportMethod(id: string, data: Record<string, any>) {
   const Visitor = useVisitor();
   const params = {

+ 20 - 2
src/request/model/analysis.model.ts

@@ -2,10 +2,11 @@ import { groupBy } from '@/tools';
 
 export function fromAnalysisModel(mode: 'tongue' | 'face', data: Record<string, any>): AnalysisModel;
 export function fromAnalysisModel(mode: 'pulse', data: Record<string, any>): PulseAnalysisModel;
+export function fromAnalysisModel(mode: 'alcohol', data: Record<string, any>): AlcoholAnalysisModel;
 export function fromAnalysisModel(
-  mode: 'tongue' | 'face' | 'pulse',
+  mode: 'tongue' | 'face' | 'pulse' | 'alcohol',
   data: Record<string, any>
-): AnalysisModel | PulseAnalysisModel {
+): AnalysisModel | AlcoholAnalysisModel | PulseAnalysisModel {
   let model: AnalysisModel;
   switch (mode) {
     case 'tongue':
@@ -16,6 +17,8 @@ export function fromAnalysisModel(
       break;
     case 'pulse':
       return fromPulseAnalysisModel(data);
+    case 'alcohol':
+      return fromAlcoholAnalysisModel(data?.extendFlowData);
   }
 
   const group = groupBy<AnalysisException>(model.exception, (item) => item.cover ?? '');
@@ -29,6 +32,12 @@ export interface PulseAnalysisModel extends Pick<Awaited<ReturnType<typeof Bridg
   url?: string;
 }
 
+export interface AlcoholAnalysisModel {
+  condition: string;
+  description: string;
+  volume: [min: number, max?: number];
+}
+
 export interface AnalysisModel {
   table: {
     columns: string[];
@@ -77,6 +86,15 @@ function fromPulseAnalysisModel(data: Record<string, any>): PulseAnalysisModel {
   };
 }
 
+function fromAlcoholAnalysisModel(data: Record<string, any>): AlcoholAnalysisModel {
+  const volume: [number, number] = data?.alcoholCapacity?.match(/(\d+)/g)?.slice(0, 2)?.filter(Boolean)?.map((v: string) => +v) ?? [];
+  return {
+    condition: data?.alcoholCondition,
+    description: data?.alcoholCapacity,
+    volume: volume.sort((a, b) => a - b),
+  };
+}
+
 function fromTongueAnalysisModel(data: Record<string, any>): AnalysisModel {
   const exception: AnalysisException[] = [];
   const fromTongueException = fromAnalysisException(exception);

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

@@ -9,9 +9,14 @@ const Routes = {
   'health_analysis': /* 健康报告页 */ '/report',
   'pulse_upload': /* 脉诊页 */ '/pulse',
   'pulse_upload_result': /* 脉诊结果页 */ '/pulse/result',
+  'alcohol_upload_result': /* 酒精结果页 */ '/alcohol/result',
 
   'screen': ROUTE_START,
 } as const;
+const analysisFlowMap = new Map<FlowKey, string>([
+  ['/alcohol/result', 'alcohol'],
+]);
+const ANALYSIS_EXTEND_FLOW = 'ANALYSIS_EXTEND_FLOW';
 
 export type FlowKey = (typeof Routes)[keyof typeof Routes];
 
@@ -41,13 +46,16 @@ export class FlowMap extends Map<FlowKey, Flow> {
     options.unshift(ROUTE_START);
     options.push('screen?返回首页');
 
+    const analysisFlowSet = new Set<string>();
     for ( let i = 1; i < options.length; i++ ) {
       const path = options[i];
       const optional = path.endsWith('?');
       const [name, title] = path.split('?');
       const route = (options[i] = getPath(name));
       this.set(<FlowKey>options[i - 1], { route, optional, title: title || void 0});
+      if (analysisFlowMap.has(route)) analysisFlowSet.add(analysisFlowMap.get(route)!);
     }
+    sessionStorage.setItem(ANALYSIS_EXTEND_FLOW, Array.from(analysisFlowSet).filter(Boolean).join(','));
   }
 
   parse(key: FlowKey): FlowRoute {
@@ -73,3 +81,7 @@ export class FlowMap extends Map<FlowKey, Flow> {
 export function getPath(value?: string): FlowKey {
   return Routes[value as keyof typeof Routes];
 }
+
+export function getAnalysisExtendFlowValue(key = ANALYSIS_EXTEND_FLOW) {
+  return sessionStorage.getItem(key) || ''
+}

+ 50 - 12
src/request/model/register.model.ts

@@ -2,6 +2,9 @@ import { toCamelCase } from '@/tools';
 
 import type { FieldRule, NumberKeyboardProps, PasswordInputProps } from 'vant';
 
+export type Option = { label: string; value: string; checked?: boolean;  disabled?: boolean; editable?: boolean; single?: boolean; }
+export type Dictionaries = Map<string, Option[]>;
+
 
 export interface RegisterModel {
   cardno: string;
@@ -13,6 +16,8 @@ export interface RegisterModel {
   height: number;
   weight: number;
   isEasyAllergy: boolean;
+  womenSpecialPeriod: string;
+  job: string;
 }
 
 export interface Field {
@@ -20,10 +25,13 @@ export interface Field {
     label: string; placeholder?: string;
     type?: string; min?: number; max?: number; minlength?: number; maxlength?: number;
     clearable?: boolean; border?: boolean; readonly?: boolean;
+    hide?: boolean | ((model: Record<string, any>) => boolean);
   };
-  component?: |
-    { name: 'radio', options: { label: string; value: string; }[] } |
-    { name: 'code', props?: Partial<PasswordInputProps> };
+  component?:
+    | { name: 'picker', options: Option[] , props?: Partial<{ multiple: boolean; }> }
+    | { name: 'radio', options: Option[] }
+    | { name: 'code', props?: Partial<PasswordInputProps> }
+  ;
   keyboard?: { show: boolean; } & Partial<NumberKeyboardProps>;
   suffix?: string;
   rules: FieldRule[];
@@ -67,10 +75,28 @@ const Fields: Record<FieldKey, Field> = {
     control: { label: '性别', border: false },
     component: {
       name: 'radio' as const,
-      options: [
-        { label: '男', value: '0' },
-        { label: '女', value: '1' },
-      ],
+      options: 'sys_user_sex' as unknown as any,
+    },
+    rules: [],
+  },
+  womenSpecialPeriod: {
+    control: {
+      label: '女性特殊期', readonly: true,
+      hide(model) { return model?.sex !== '1'; },
+    },
+    component: {
+      name: 'picker' as const,
+      options: 'women_special_period' as unknown as any,
+      props: { multiple: false },
+    },
+    rules: [],
+  },
+  job: {
+    control: { label: '职业', readonly: true },
+    component: {
+      name: 'picker' as const,
+      options: 'job' as unknown as any,
+      props: { multiple: false },
     },
     rules: [],
   },
@@ -78,10 +104,7 @@ const Fields: Record<FieldKey, Field> = {
     control: { label: '容易过敏', border: false },
     component: {
       name: 'radio' as const,
-      options: [
-        { label: '是', value: 'Y' },
-        { label: '否', value: 'N' },
-      ],
+      options: 'sys_yes_no' as unknown as any,
     },
     rules: [],
   },
@@ -141,7 +164,21 @@ const Fields: Record<FieldKey, Field> = {
   },
 };
 
-export function fromRegisterFields(options: string[]): Fields {
+export function fromRegisterFields(options: string[], dictionaries?: Dictionaries): Fields {
+  dictionaries ??= new Map([
+    ['sys_yes_no', [{ label: '是', value: 'Y' }, { label: '否', value: 'N' }]],
+    ['sys_user_sex', [{ label: '男', value: '0' }, { label: '女', value: '1' }]]
+  ]);
+
+  const getOptions = (options?: Option[] | string): Option[] => {
+    if (options == null) options = [];
+    if (typeof options === 'string') options = dictionaries?.get(options) ?? [];
+    return options.map((option) => Object.assign({
+      editable: option.label === `其他`,
+      single: option.label === `无`,
+    } ,option));
+  };
+
   // 修正 phone,code
   const k1 = 'phone';
   const k2 = 'code';
@@ -158,6 +195,7 @@ export function fromRegisterFields(options: string[]): Fields {
       control: { label: name, type: 'text' },
       rules: [],
     };
+    if ((field.component as any)?.options) (field.component as any) = { ...field.component, options: getOptions((field.component as any).options) }
     if ( values[ 1 ] === 'required' ) field.rules.push({ required: true, message: field.control.placeholder ?? '请补充完整' });
     return { ...field, name } as Fields[number];
   });

+ 1 - 1
src/router/hooks/useRouteMeta.ts

@@ -9,7 +9,7 @@ export interface ReactiveRouteOptionsWithTransform<V, R> {
 }
 
 export function useRouteMeta<T extends RouteMeta = RouteMeta, K = T>(
-  name: string,
+  name: keyof T,
   defaultValue?: MaybeRefOrGetter,
   options: ReactiveRouteOptionsWithTransform<T, K> = {},
 ): Ref<K> {

+ 1 - 0
src/router/index.ts

@@ -11,6 +11,7 @@ const router = createRouter({
     { path: '/camera', component: () => import('@/modules/camera/camera.page.vue'), meta: { title: '拍摄' } },
     { path: '/camera/result', component: () => import('@/modules/camera/camera-result.page.vue'), meta: { title: '拍摄完成' } },
     { path: '/questionnaire', component: () => import('@/modules/questionnaire/page.vue'), meta: { title: '问卷' } },
+    { path: '/alcohol/result', component: () => import('@/modules/alcohol/alcohol.page.vue'), meta: { title: '黄酒建议' } },
     { path: '/report/analysis', component: () => import('@/modules/report/report-analyse.page.vue'), meta: { title: '舌面象分析报告' } },
     { path: '/report/:id/scheme', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案' } },
     { path: '/report/:id', component: () => import('@/modules/report/report.page.vue'), meta: { title: '健康分析报告' } },