Browse Source

添加智能导诊模块

cc12458 2 months ago
parent
commit
e0a53d0f94
60 changed files with 2360 additions and 9 deletions
  1. 8 0
      .env/.env.development
  2. 11 0
      .env/.env.production
  3. 2 0
      .gitignore
  4. 23 0
      @types/alova.d.ts
  5. 12 0
      @types/ant.d.ts
  6. 15 0
      @types/components.d.ts
  7. 22 0
      @types/env.d.ts
  8. 13 0
      @types/global.d.ts
  9. 11 0
      @types/router.d.ts
  10. 15 0
      @types/vant.d.ts
  11. 2 1
      index.html
  12. 2 1
      package.json
  13. 3 0
      pnpm-lock.yaml
  14. BIN
      src/assets/images/chat-bg@2x.png
  15. BIN
      src/assets/images/chat-robot.png
  16. BIN
      src/assets/images/face-center@3x.png
  17. BIN
      src/assets/images/tongue-down@3x.png
  18. BIN
      src/assets/images/tongue-up@3x.png
  19. 177 0
      src/modules/chat/HospitalGuide.vue
  20. 87 0
      src/modules/chat/components/Messages.vue
  21. 4 0
      src/modules/chat/composables/index.ts
  22. 81 0
      src/modules/chat/composables/tool.ts
  23. 70 0
      src/modules/chat/composables/useMessages.ts
  24. 36 0
      src/modules/chat/config/analysis-health.config.ts
  25. 58 0
      src/modules/chat/config/basic-info.config.ts
  26. 3 0
      src/modules/chat/config/index.ts
  27. 29 0
      src/modules/chat/config/message.config.ts
  28. 46 0
      src/modules/chat/index.vue
  29. 312 0
      src/modules/chat/renderer/AnalysisHealthSender.vue
  30. 214 0
      src/modules/chat/renderer/BasicInfoSender.vue
  31. 87 0
      src/modules/chat/renderer/ChatSender.vue
  32. 122 0
      src/modules/chat/renderer/GuideContent.vue
  33. 11 0
      src/modules/chat/renderer/index.ts
  34. 13 0
      src/modules/chat/types/index.ts
  35. 70 0
      src/modules/chat/types/message.ts
  36. 1 0
      src/platform/index.ts
  37. 107 0
      src/platform/vant.ts
  38. 19 0
      src/polyfill.ts
  39. 53 0
      src/request/api/analysis.ts
  40. 22 0
      src/request/api/chat.ts
  41. 62 0
      src/request/api/guide.api.ts
  42. 3 0
      src/request/api/index.ts
  43. 41 0
      src/request/factory.ts
  44. 59 0
      src/request/index.ts
  45. 17 0
      src/request/model/analysis-health.model.ts
  46. 68 0
      src/request/model/guide.model.ts
  47. 1 0
      src/request/sse/index.ts
  48. 184 0
      src/request/sse/useSSE.ts
  49. 20 0
      src/request/types.ts
  50. 11 2
      src/router/index.ts
  51. 10 2
      src/router/routes.ts
  52. 46 0
      src/stores/guide.store.ts
  53. 2 0
      src/stores/index.ts
  54. 8 0
      src/tools/file.ts
  55. 5 0
      src/tools/index.ts
  56. 6 0
      src/tools/markdown.ts
  57. 3 0
      src/tools/string.ts
  58. 25 0
      src/tools/url.ts
  59. 6 0
      tsconfig.app.json
  60. 22 3
      vite.config.ts

+ 8 - 0
.env/.env.development

@@ -0,0 +1,8 @@
+# 智能导诊接口地址
+SIX_API_HOSPITAL_GUIDE=/api/dz
+# 舌诊分析接口地址
+SIX_API_AI_ANALYSIS=/api/analysis
+# 智能 AI 接口地址
+SIX_API_AI_CHAT=/api/chat
+# 舌象图片前缀
+SIX_ANALYSIS_IMAGE_PREFIX=https://static.hzliuzhi.com:62006/tongue_diagnosis/

+ 11 - 0
.env/.env.production

@@ -0,0 +1,11 @@
+# 智能导诊接口地址
+SIX_API_HOSPITAL_GUIDE=/dz
+# 舌诊分析接口地址
+SIX_API_AI_ANALYSIS=https://tongue.hzliuzhi.com:62006
+# 智能 AI 接口地址
+SIX_API_AI_CHAT=https://dev.hzliuzhi.com:62006
+# 舌象图片前缀
+SIX_ANALYSIS_IMAGE_PREFIX=https://static.hzliuzhi.com:62006/tongue_diagnosis/
+
+# vue-router 的模式
+SIX_ROUTER_HISTORY=hash

+ 2 - 0
.gitignore

@@ -28,3 +28,5 @@ coverage
 *.sw?
 *.sw?
 
 
 *.tsbuildinfo
 *.tsbuildinfo
+
+six**.zip

+ 23 - 0
@types/alova.d.ts

@@ -0,0 +1,23 @@
+import 'alova';
+import { Method } from 'alova';
+
+declare module 'alova' {
+  export interface AlovaCustomTypes {
+    meta: {
+      /* Token认证拦截器 */
+      authRole?: 'none' | 'login' | 'logout' | 'refreshToken';
+      login?: true;
+      logout?: true;
+      refreshToken?: true;
+      visitor?: true;
+
+      /* 解析响应 */
+      interceptByGlobalResponded?: boolean;
+      interceptByBodyResponded?: boolean;
+
+      sseTransform?: (line: string, value: Method<AG>) => any;
+    };
+  }
+}
+
+export {};

+ 12 - 0
@types/ant.d.ts

@@ -0,0 +1,12 @@
+import { BubbleList, Sender } from 'ant-design-x-vue';
+import type { SenderProps } from 'ant-design-x-vue';
+import type { ButtonProps, AvatarProps } from 'ant-design-vue';
+
+declare global {
+  declare type AntXBubbleListInstance = InstanceType<typeof BubbleList>;
+  declare type AntXSenderInstance = InstanceType<typeof Sender>;
+  declare type AntXSenderProps = SenderProps;
+
+  declare type AntButtonProps = ButtonProps;
+  declare type AntAvatarProps = AvatarProps;
+}

+ 15 - 0
@types/components.d.ts

@@ -8,8 +8,23 @@ export {}
 /* prettier-ignore */
 /* prettier-ignore */
 declare module 'vue' {
 declare module 'vue' {
   export interface GlobalComponents {
   export interface GlobalComponents {
+    AAvatar: typeof import('ant-design-vue/es')['Avatar']
+    ABadge: typeof import('ant-design-vue/es')['Badge']
+    AButton: typeof import('ant-design-vue/es')['Button']
+    ACol: typeof import('ant-design-vue/es')['Col']
+    ACollapse: typeof import('ant-design-vue/es')['Collapse']
+    ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
+    AFlex: typeof import('ant-design-vue/es')['Flex']
+    AImage: typeof import('ant-design-vue/es')['Image']
+    ARow: typeof import('ant-design-vue/es')['Row']
+    ASpace: typeof import('ant-design-vue/es')['Space']
+    ASpin: typeof import('ant-design-vue/es')['Spin']
+    ATag: typeof import('ant-design-vue/es')['Tag']
+    ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     RouterView: typeof import('vue-router')['RouterView']
     VanConfigProvider: typeof import('vant/es')['ConfigProvider']
     VanConfigProvider: typeof import('vant/es')['ConfigProvider']
+    VanNavBar: typeof import('vant/es')['NavBar']
+    VanPicker: typeof import('vant/es')['Picker']
   }
   }
 }
 }

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

@@ -1 +1,23 @@
 /// <reference types="vite/client" />
 /// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+  readonly SIX_APP_NAME: string;
+  readonly SIX_APP_TITLE: string;
+
+  /**
+   * 导诊接口地址
+   */
+  readonly SIX_API_HOSPITAL_GUIDE?: string;
+  /**
+   * 导诊 AI 接口地址
+   */
+  readonly SIX_API_GUIDE_CHAT?: string;
+  /**
+   * 智能 AI 接口地址
+   */
+  readonly SIX_API_AI_CHAT?: string;
+  /**
+   * 舌象图片前缀
+   */
+  readonly SIX_ANALYSIS_IMAGE_PREFIX?: string;
+}

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

@@ -0,0 +1,13 @@
+declare type Data = Record<string, any>;
+
+declare type Default<T, K extends keyof T> = Partial<T> & Pick<T, K>;
+
+declare type KebabToCamel<S extends string> = S extends `${infer P1}-${infer P2}` ? `${P1}${Capitalize<KebabToCamel<P2>>}` : S;
+
+declare type EmitsToProps<T extends Record<string, any>> = {
+  [K in keyof T as `on${Capitalize<KebabToCamel<string & K>>}`]: T[K] extends [...infer Args] ? (...args: Args) => void : never;
+};
+
+declare interface ReadableStream<R = any> {
+  [Symbol.asyncIterator](): AsyncIterableIterator<R>;
+}

+ 11 - 0
@types/router.d.ts

@@ -0,0 +1,11 @@
+import 'vue-router';
+
+
+export {};
+
+/* prettier-ignore */
+declare module 'vue-router' {
+  interface RouteMeta {
+    title?: string;
+  }
+}

+ 15 - 0
@types/vant.d.ts

@@ -0,0 +1,15 @@
+import type { PickerCancelEventParams, PickerChangeEventParams, PickerColumn, PickerConfirmEventParams, PickerInstance, PickerOption, PickerProps } from 'vant';
+
+declare global {
+  declare type VantPickEmits = {
+    'update:modelValue': [$event: string[]];
+    confirm: [$event: PickerConfirmEventParams];
+    cancel: [$event: PickerCancelEventParams];
+    change: [$event: PickerChangeEventParams];
+    'click-option': [$event: PickerChangeEventParams & { currentOption: PickerOption }];
+  };
+  declare type VantPickProps = Partial<PickerProps & EmitsToProps<VantPickEmits>>;
+
+  declare type VantPickerInstance = PickerInstance;
+  declare type VantPickerColumn = PickerColumn;
+}

+ 2 - 1
index.html

@@ -3,12 +3,13 @@
 <head>
 <head>
     <meta charset="UTF-8">
     <meta charset="UTF-8">
     <link rel="icon" href="/favicon.ico">
     <link rel="icon" href="/favicon.ico">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
     <title>%SIX_APP_TITLE%</title>
     <title>%SIX_APP_TITLE%</title>
     <script>document.addEventListener('gesturestart', function(event) { event.preventDefault(); }, false);</script>
     <script>document.addEventListener('gesturestart', function(event) { event.preventDefault(); }, false);</script>
 </head>
 </head>
 <body>
 <body>
 <div id="app"></div>
 <div id="app"></div>
+<script type="module" src="/src/polyfill.ts"></script>
 <script type="module" src="/src/main.ts"></script>
 <script type="module" src="/src/main.ts"></script>
 </body>
 </body>
 </html>
 </html>

+ 2 - 1
package.json

@@ -8,7 +8,7 @@
   },
   },
   "scripts": {
   "scripts": {
     "dev": "vite",
     "dev": "vite",
-    "build": "run-p type-check \"build-only {@}\" --",
+    "build": "vite build --base=/dz/m/",
     "preview": "vite preview",
     "preview": "vite preview",
     "build-only": "vite build",
     "build-only": "vite build",
     "type-check": "vue-tsc --build",
     "type-check": "vue-tsc --build",
@@ -24,6 +24,7 @@
     "alova": "^3.3.4",
     "alova": "^3.3.4",
     "ant-design-vue": "~4.2.6",
     "ant-design-vue": "~4.2.6",
     "ant-design-x-vue": "^1.3.2",
     "ant-design-x-vue": "^1.3.2",
+    "core-js": "^3.45.1",
     "eruda": "^3.4.3",
     "eruda": "^3.4.3",
     "markdown-it": "^14.1.0",
     "markdown-it": "^14.1.0",
     "pinia": "^3.0.3",
     "pinia": "^3.0.3",

+ 3 - 0
pnpm-lock.yaml

@@ -26,6 +26,9 @@ importers:
       ant-design-x-vue:
       ant-design-x-vue:
         specifier: ^1.3.2
         specifier: ^1.3.2
         version: 1.3.2(ant-design-vue@4.2.6(vue@3.5.21(typescript@5.8.3)))(vue@3.5.21(typescript@5.8.3))
         version: 1.3.2(ant-design-vue@4.2.6(vue@3.5.21(typescript@5.8.3)))(vue@3.5.21(typescript@5.8.3))
+      core-js:
+        specifier: ^3.45.1
+        version: 3.45.1
       eruda:
       eruda:
         specifier: ^3.4.3
         specifier: ^3.4.3
         version: 3.4.3
         version: 3.4.3

BIN
src/assets/images/chat-bg@2x.png


BIN
src/assets/images/chat-robot.png


BIN
src/assets/images/face-center@3x.png


BIN
src/assets/images/tongue-down@3x.png


BIN
src/assets/images/tongue-up@3x.png


+ 177 - 0
src/modules/chat/HospitalGuide.vue

@@ -0,0 +1,177 @@
+<script setup lang="ts">
+import { h } from 'vue';
+import { tryOnBeforeMount } from '@vueuse/core';
+
+import { CloseOutlined } from '@ant-design/icons-vue';
+import { Welcome, type WelcomeProps } from 'ant-design-x-vue';
+import type { NavBarProps } from 'vant';
+
+import { useRequest } from 'alova/client';
+import { guideChatMethod, guideRecordSavaMethod, guideRegisterSessionMethod } from '@/request/api';
+import { getGuideRoleLabel, useGuideStore } from '@/stores';
+import { getBackReferrerUrl, getClientURL, getURLSearchParams, isWechat } from '@/tools';
+
+import { useMessages } from '@/modules/chat/composables';
+
+import RobotUrl from '@/assets/images/chat-robot.png?url';
+import ChartMessages from '@/modules/chat/components/Messages.vue';
+import AnalysisHealthSender from '@/modules/chat/renderer/AnalysisHealthSender.vue';
+import BasicInfoSender from '@/modules/chat/renderer/BasicInfoSender.vue';
+import ChatSender from '@/modules/chat/renderer/ChatSender.vue';
+
+const route = useRoute();
+const charRef = useTemplateRef<InstanceType<typeof ChartMessages>>('chat-messages');
+
+const navProps = reactive<Partial<NavBarProps> & { show: boolean }>({
+  show: false,
+  title: route.meta.title,
+});
+const welcomeProps = reactive<WelcomeProps & { show?: boolean }>({
+  show: true,
+  icon: getClientURL(RobotUrl),
+  title: `你好,我是智能导诊机器人`,
+  description: `我可以帮您推荐合适的医生`,
+  style: {
+    backgroundImage: 'linear-gradient(97deg, rgba(90,196,255,0.12) 0%, rgba(174,136,255,0.12) 100%)',
+    borderStartStartRadius: 4,
+  },
+});
+
+tryOnBeforeMount(() => {
+  const searchParams = getURLSearchParams();
+  if (searchParams.has('back') || getBackReferrerUrl()) {
+    navProps.show = true;
+    navProps.leftArrow = true;
+    navProps.leftText = '返回';
+    searchParams.delete('back');
+  }
+  if (isWechat()) navProps.show = false;
+  register(searchParams);
+});
+
+const back = () => {
+  const url = getBackReferrerUrl();
+  if (url) location.href = url;
+  history.back();
+};
+
+const Guide = useGuideStore();
+const { session, user } = storeToRefs(Guide);
+const { append, messages } = useMessages(Guide.messages, {
+  mergeAvatar: false,
+  renderer: {
+    '#AnalysisHealthSender'(message, props) {
+      const complete = ref<boolean>(false);
+      message.events.typingComplete.on((value) => (complete.value = value));
+      return () => h(AnalysisHealthSender, { complete: complete.value, ...props });
+    },
+    '#BasicInfoSender'(message, props) {
+      const complete = ref<boolean>(false);
+      message.events.typingComplete.on((value) => (complete.value = value));
+      return () => h(BasicInfoSender, { complete: complete.value, ...props });
+    },
+    '#ChatSender'(message, props) {
+      const complete = ref<boolean>(false);
+      message.events.typingComplete.on((value) => (complete.value = value));
+      return () => h(ChatSender, { complete: complete.value, ...props });
+    },
+  },
+});
+
+const sendBasicInfoMessage = () => {
+  const message = {
+    role: 'chat',
+    content: `请问您是为自己还是为他人咨询?`,
+    sender: {
+      template: '#BasicInfoSender',
+      props: {
+        group: ['role', 'gender', 'age'],
+        onNext: sendAnalysisHealthMessage,
+      },
+    },
+  };
+  append(message);
+};
+
+const sendAnalysisHealthMessage = () => {
+  const { role } = unref(user);
+  const label = getGuideRoleLabel(role);
+  const message = {
+    role: 'chat',
+    content: `我想采集${label}舌和面照片?`,
+    sender: {
+      template: '#AnalysisHealthSender',
+      props: {
+        group: ['tongueUp', 'tongueDown', 'faceCenter'],
+        capture: { 1: 'user', 2: 'environment' }[role],
+        onNext: sendGuideChatMessage,
+      },
+    },
+  };
+  append(message);
+};
+const sendGuideChatMessage = () => {
+  const role = getGuideRoleLabel(unref(user));
+  const message = {
+    role: 'chat',
+    content: `请问${role}主要问题是什么?`,
+    sender: {
+      template: '#ChatSender',
+      props: {
+        handler: (message: string) => guideChatMethod(message, { session_id: unref(session) }),
+        onNext: () => {
+          save(messages);
+        },
+      },
+    },
+  };
+  append(message);
+};
+
+const { send: save } = useRequest((messages) => guideRecordSavaMethod(messages, unref(session)), { immediate: false });
+
+const { send: register } = useRequest(guideRegisterSessionMethod, { immediate: false }).onSuccess(({ data }) => {
+  Guide.register(data.session, data.user);
+  sendBasicInfoMessage();
+});
+</script>
+
+<template>
+  <chart-messages class="hospital-guide-wrapper" ref="chat-messages">
+    <template #chat-header>
+      <van-nav-bar v-if="navProps.show" v-bind="navProps" @click-left="back()" />
+      <Welcome class="welcome-container" v-if="welcomeProps.show" v-bind="welcomeProps">
+        <template #extra>
+          <a-space>
+            <a-button type="text" :icon="h(CloseOutlined)" @click="welcomeProps.show = false"></a-button>
+          </a-space>
+        </template>
+      </Welcome>
+      <van-nav-bar v-else-if="!navProps.show" v-bind="navProps" @click-left="back()" />
+    </template>
+  </chart-messages>
+</template>
+
+<style scoped lang="scss">
+.hospital-guide-wrapper {
+  --van-nav-bar-background: transparent;
+
+  .welcome-container {
+    border-top-left-radius: 0;
+    border-top-right-radius: 0;
+  }
+
+  :deep(.ant-sender-prefix) {
+    .ant-btn-text {
+      &:not(.ant-btn-icon-only) {
+        padding-inline-start: 8px;
+        padding-inline-end: 8px;
+      }
+
+      > span {
+        transform: scale(1.143);
+      }
+    }
+  }
+}
+</style>

+ 87 - 0
src/modules/chat/components/Messages.vue

@@ -0,0 +1,87 @@
+<script setup lang="ts">
+import { useScroll } from '@vueuse/core';
+import { BubbleList } from 'ant-design-x-vue';
+import { useMessages } from '@/modules/chat/composables';
+
+defineOptions({
+  name: 'ChartMessages',
+});
+
+const { bubbles, sender } = useMessages();
+
+const messagesRef = useTemplateRef<AntXBubbleListInstance>('messages-ref');
+const scroll = useScroll(() => messagesRef.value?.nativeElement);
+
+const onClickMessage = (event: PointerEvent) => {
+  /*const [_, key] = (<HTMLElement>event.target)?.className?.match(/key:([\w-]+)/) ?? [];
+  if (key) {
+    const message = messages.value.find((message) => message.key);
+    console.log('log: 点击消息', message);
+  }*/
+};
+</script>
+
+<template>
+  <div class="chat-container">
+    <header class="header-container" :class="{ 'empty-slot': !$slots['chat-header'] }">
+      <slot name="chat-header" v-bind="scroll"></slot>
+    </header>
+    <BubbleList ref="messages-ref" class="main" auto-scroll :items="bubbles" @click.stop="onClickMessage"></BubbleList>
+    <footer class="footer-container" :class="{ 'empty-slot': !$slots['chat-footer'] }">
+      <slot name="chat-footer">
+        <component :is="sender"></component>
+      </slot>
+    </footer>
+  </div>
+</template>
+
+<style scoped lang="scss">
+@use 'sass:math';
+
+$p: 12px;
+.chat-container {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+  > header,
+  > footer {
+    flex: none;
+  }
+
+  > main,
+  > :deep(.main) {
+    flex: auto;
+    padding: $p $p math.div($p, 2);
+    overflow-y: auto;
+  }
+}
+
+.footer-container {
+  --offset: constant(safe-area-inset-bottom);
+  --offset: env(safe-area-inset-bottom);
+
+  padding-top: math.div($p, 2);
+  padding-inline: $p;
+  padding-bottom: max(calc(var(--offset) - 12px), math.div($p, 2));
+}
+
+:deep(.chat-markdown-container) {
+  p {
+    margin: 0;
+  }
+  div > ul:last-of-type {
+    margin-bottom: 0;
+  }
+
+  .image-gather {
+    display: flex;
+    gap: 8px;
+    img {
+      width: 100px;
+      height: 100px;
+      object-fit: cover;
+    }
+  }
+}
+</style>

+ 4 - 0
src/modules/chat/composables/index.ts

@@ -0,0 +1,4 @@
+import type { UseMessagesReturn } from '@/modules/chat/types';
+
+export * from './useMessages.ts';
+export const UseMessagesToken = Symbol() as InjectionKey<UseMessagesReturn>;

+ 81 - 0
src/modules/chat/composables/tool.ts

@@ -0,0 +1,81 @@
+import type { DefaultMessageRole, MessageBubbles, MessageEntity, MessageModel, MessageStatus, RenderBubblesOptions } from '@/modules/chat/types';
+import { createEventHook } from '@vueuse/core';
+import { defaultMessageRender, defaultMessageRole } from '@/modules/chat/config';
+import { randomUUID } from '@/tools';
+
+export function isVNode(value: unknown): value is VNode {
+  return value != null && typeof value === 'object' && (<{ __v_isVNode: boolean }>value)['__v_isVNode'];
+}
+
+export function findAndIndex<S>(array: S[], predicate: (value: S, index: number, obj: S[]) => unknown): [S, number] | [void, void] {
+  const index = array.findIndex(predicate);
+  return index === -1 ? [void 0, void 0] : [array[index], index];
+}
+
+export function unifyMessageToEntity<M extends MessageModel = MessageModel>(message: Default<M, 'role'>, props?: Partial<M>): MessageEntity {
+  const key = message.key ?? props?.key ?? randomUUID();
+  const role = message.role ?? props?.role;
+
+  const events = {
+    typingComplete: createEventHook(),
+    status: createEventHook<MessageStatus>(),
+  };
+
+  const entity = { ...defaultMessageRole[<DefaultMessageRole>role], ...message, ...props, key, role, events } satisfies MessageEntity;
+
+  // 移除 Model 中的 on 事件监听
+  if (props?.onTypingComplete) events.typingComplete.on(props.onTypingComplete);
+  if (props?.onStatus) events.typingComplete.on(props.onStatus);
+  if (message.onTypingComplete) events.typingComplete.on(message.onTypingComplete);
+  if (message.onStatus) events.typingComplete.on(message.onStatus);
+  Object.keys(entity).forEach((key) => key.startsWith('on') && Reflect.deleteProperty(entity, key));
+
+  // 修正 状态
+  if (entity.status !== 'loading') entity.status = entity.typing ? 'typing' : 'done';
+  events.status.trigger(entity.status || 'done').then();
+
+  return entity;
+}
+
+export function renderEntityToBubbles<E extends MessageEntity>(
+  entities: E[] | E,
+  lastBubbles?: MessageBubbles,
+  options?: RenderBubblesOptions,
+): [bubbles: MessageBubbles[], last?: MessageBubbles] {
+  if (!Array.isArray(entities)) entities = [entities];
+
+  return [
+    entities.map((entry) => {
+      const { events, sender, ..._entry } = entry;
+      const bubble = {
+        ..._entry,
+        loading: entry.status === 'loading',
+        onTypingComplete() {
+          bubble.status = 'done';
+          entry.status = 'done';
+          events.typingComplete.trigger(true);
+        },
+      } as MessageBubbles;
+
+      // 取 null 特殊,没有输入组件
+      if (sender === null) bubble.sender = null;
+      else {
+        const [senderTemplate, senderProps] = typeof sender === 'string' ? [sender, {}] : [sender?.template, sender?.props];
+        const renderer = senderTemplate ? options?.renderer?.[senderTemplate] : void 0;
+        if (bubble.sender !== null) bubble.sender = renderer?.(entry, senderProps);
+      }
+
+      // 修正内容渲染器
+      if (typeof bubble.content === 'string') bubble.messageRender ??= defaultMessageRender;
+
+      // 修正头像
+      if (options?.hideAvatar) bubble.avatar = {};
+      else if (options?.mergeAvatar && !isVNode(bubble.avatar)) {
+        if (bubble.role === lastBubbles?.role) bubble.avatar = { ...bubble.avatar, style: { ...bubble.avatar.style, visibility: 'hidden' } };
+      }
+
+      return ((lastBubbles = bubble), bubble);
+    }),
+    lastBubbles,
+  ];
+}

+ 70 - 0
src/modules/chat/composables/useMessages.ts

@@ -0,0 +1,70 @@
+import { randomUUID } from '@/tools';
+import { UseMessagesToken } from '@/modules/chat/composables/index.ts';
+import type { MessageBubbles, MessageEntity, MessageModel, MessageRenderer, RenderBubblesOptions, UseMessagesReturn } from '@/modules/chat/types';
+import { findAndIndex, renderEntityToBubbles, unifyMessageToEntity } from '@/modules/chat/composables/tool.ts';
+
+export const useMessages = (list?: MaybeRef<MessageModel[]>, config?: RenderBubblesOptions): UseMessagesReturn => {
+  if (list == null) return inject(UseMessagesToken)!;
+
+  const entities = shallowReactive<MessageEntity[]>([]);
+  const bubbles = shallowReactive<MessageBubbles[]>([]);
+
+  const sender = shallowRef<ReturnType<MessageRenderer> | null>();
+
+  const append: UseMessagesReturn['append'] = (target, props, options) => {
+    const entity = unifyMessageToEntity(target, props);
+    unref(entities).push(entity);
+
+    const [list, last] = renderEntityToBubbles(entity, unref(bubbles).at(-1), {
+      hideAvatar: options?.hideAvatar ?? config?.hideAvatar ?? false,
+      mergeAvatar: options?.mergeAvatar ?? config?.mergeAvatar ?? true,
+      renderer: { ...config?.renderer, ...options?.renderer },
+    });
+
+    if (last?.sender || last?.sender === null) sender.value = last.sender;
+
+    unref(bubbles).push(...list);
+  };
+
+  const update: UseMessagesReturn['update'] = (message, props, options) => {
+    const key = message?.key ?? unref(bubbles).at(-1)?.key ?? randomUUID();
+    const [target, index] = findAndIndex<MessageEntity>(unref(entities), (entity) => entity.key === key);
+    if (target && index != null) {
+      const content = target.content;
+      const entity = unifyMessageToEntity({ role: target.role, ...message }, props);
+
+      if (props?.onTypingComplete) target.events.typingComplete.on(props.onTypingComplete);
+      if (props?.onStatus) target.events.typingComplete.on(props.onStatus);
+      if (message?.onTypingComplete) target.events.typingComplete.on(message.onTypingComplete);
+      if (message?.onStatus) target.events.typingComplete.on(message.onStatus);
+
+      target.typing = entity?.typing ?? target.typing;
+      target.content = entity?.content ?? target.content;
+      if (typeof target.content === 'string' && target.content !== content) {
+        target.status = target.typing ? 'typing' : 'done';
+        target.events.status.trigger(target.status).then();
+      }
+
+      const [list, last] = renderEntityToBubbles(entity, bubbles.at(index - 1), {
+        hideAvatar: options?.hideAvatar ?? config?.hideAvatar ?? false,
+        mergeAvatar: options?.mergeAvatar ?? config?.mergeAvatar ?? true,
+      });
+
+      console.log('log: 重新渲染 bubble', list);
+      bubbles.splice(index, 1, ...list);
+
+      if (last?.sender || last?.sender === null) sender.value = last.sender;
+    } else {
+      append({ ...message, key, role: 'chat' }, props, config);
+    }
+  };
+
+  const exposure = { bubbles, messages: entities, append, update, sender } as unknown as UseMessagesReturn;
+
+  provide(UseMessagesToken, exposure);
+  return exposure;
+};
+
+export function useMessagesContext() {
+  return inject(UseMessagesToken)!;
+}

+ 36 - 0
src/modules/chat/config/analysis-health.config.ts

@@ -0,0 +1,36 @@
+import type { AnalysisHealthKey } from '@/modules/chat/types';
+import TongueUpExample from '@/assets/images/tongue-up@3x.png?url';
+import TongueDownExample from '@/assets/images/tongue-down@3x.png?url';
+import FaceCenterExample from '@/assets/images/face-center@3x.png?url';
+
+export const analysisHealthConfig = {
+  tongueUp: {
+    label: '舌面',
+    description: '请确保舌面无食物残渣、没有染色,舌尖向下伸直、 舌体放松、舌面平展、口张大,请避免在有色光线下拍摄。',
+    example: TongueUpExample,
+    required: true,
+  },
+  tongueDown: {
+    label: '舌下',
+    example: TongueDownExample,
+    required: true,
+    description: '舌尖向上抵住上颚、舌体放松、口张大、露出舌下,请避免在有色光线下拍摄。',
+  },
+  faceCenter: {
+    label: '面部',
+    example: FaceCenterExample,
+    required: false,
+    description: '请摘下眼镜、平视前方、不要浓妆、不要遮挡面部,请避免在有色光线下拍摄。',
+  },
+
+  /* faceLeft: { }, */
+  /* faceRight: { }, */
+} satisfies Record<string, Omit<AnalysisHealthValue, 'key'>>;
+
+export interface AnalysisHealthValue {
+  key: AnalysisHealthKey;
+  label: string;
+  required?: boolean;
+  example?: string;
+  description?: string;
+}

+ 58 - 0
src/modules/chat/config/basic-info.config.ts

@@ -0,0 +1,58 @@
+import type { BasicInfoPickerKey, BasicInfoPickerModel } from '@/modules/chat/types';
+import { getGuideRoleLabel, useGuideStore } from '@/stores';
+
+export const basicInfoPickerGroup = {
+  role: {
+    title: `请问您是为谁咨询?`,
+    columns: [
+      { text: '为自己', value: '1' },
+      { text: '为他人', value: '2' },
+    ],
+    confirmButtonText: '',
+    clickOnConfirm: true,
+    onConfirm(model) {
+      if (isRef<BasicInfoPickerModel>(model) && model.value.role === '1') {
+        model.value = { ...useGuideStore().user, role: '1' };
+        return /* 直接提交 */ true;
+      }
+      return false;
+    },
+  },
+  gender: {
+    title: (model) => {
+      const role = getGuideRoleLabel(model.role);
+      return `请问${role}性别?`;
+    },
+    columns: [
+      { text: '男', value: '1' },
+      { text: '女', value: '2' },
+    ],
+    confirmButtonText: '下一步',
+    clickOnConfirm: true,
+  },
+  age: {
+    title: (model) => {
+      const role = getGuideRoleLabel(model.role);
+      return `请问${role}年龄?`;
+    },
+    columns: Array.from({ length: 200 }, (_, i) => {
+      const value = (i + 1).toString();
+      return {
+        text: `${value}岁`,
+        value,
+      };
+    }),
+    defaultValue: '35',
+    confirmButtonText: '确定',
+  },
+} satisfies Record<string, Omit<BasicInfoPickerValue, 'key'>>;
+
+export interface BasicInfoPickerValue {
+  key: BasicInfoPickerKey;
+  title?: string | ((model: Data) => string);
+  columns?: { text: string; value: string }[];
+  defaultValue?: string;
+  confirmButtonText?: string;
+  clickOnConfirm?: boolean;
+  onConfirm?: (model: Data) => Promise<boolean> | boolean;
+}

+ 3 - 0
src/modules/chat/config/index.ts

@@ -0,0 +1,3 @@
+export * from './analysis-health.config.ts';
+export * from './basic-info.config.ts';
+export * from './message.config.ts';

+ 29 - 0
src/modules/chat/config/message.config.ts

@@ -0,0 +1,29 @@
+import type { MessageBubbles } from '@/modules/chat/types';
+
+import { h } from 'vue';
+import { Typography } from 'ant-design-vue';
+import { UserOutlined } from '@ant-design/icons-vue';
+
+import { markdown } from '@/tools';
+import RobotAvatarUrl from '@/assets/images/chat-robot.png?url';
+
+export const defaultMessageRole = {
+  chat: {
+    placement: 'start',
+    typing: { step: 2, interval: 50 },
+    avatar: { src: RobotAvatarUrl, size: 46 }
+  },
+  user: {
+    placement: 'end',
+    avatar: { icon: h(UserOutlined), size: 46 }
+  }
+} satisfies Record<string, Partial<Omit<MessageBubbles, 'role'>>>;
+
+export const defaultMessageRender = (content: string) =>
+  h(
+    Typography,
+    { class: 'chat-markdown-container' },
+    {
+      default: () => h('div', { innerHTML: markdown.render(content) })
+    }
+  );

+ 46 - 0
src/modules/chat/index.vue

@@ -0,0 +1,46 @@
+<script setup lang="ts">
+defineOptions({
+  name: `chat`,
+  inheritAttrs: false,
+});
+</script>
+
+<template>
+  <div class="container">
+    <img src="../../assets/images/chat-bg@2x.png" alt="chat" />
+    <router-view class="chat-container" v-bind="$attrs" v-slot="{ Component }">
+      <component v-if="Component" :is="Component" />
+      <div v-else class="chat-container" v-bind="$attrs">
+        <!-- 默认 -->
+      </div>
+    </router-view>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.container {
+  position: relative;
+  flex: auto;
+  > img,
+  > div {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: calc(var(--page-width) * 1px);
+    height: calc(var(--page-height) * 1px);
+  }
+
+  > img {
+    object-fit: cover;
+    z-index: -1;
+  }
+}
+
+.chat-container {
+  display: flex;
+  flex-direction: column;
+  overflow-x: hidden;
+  overflow-y: auto;
+  z-index: 1;
+}
+</style>

+ 312 - 0
src/modules/chat/renderer/AnalysisHealthSender.vue

@@ -0,0 +1,312 @@
+<script setup lang="ts">
+import { h } from 'vue';
+import { useFileDialog } from '@vueuse/core';
+
+import { CloudUploadOutlined, DeleteOutlined, LinkOutlined, NumberOutlined, PlusOutlined } from '@ant-design/icons-vue';
+import { Sender } from 'ant-design-x-vue';
+import { theme as Theme } from 'ant-design-vue';
+
+import { useForm } from 'alova/client';
+import { analysisHealthMethod } from '@/request/api';
+
+import { Dialog, Toast } from '@/platform';
+import { blob2Base64, randomUUID } from '@/tools';
+import { useMessagesContext } from '@/modules/chat/composables';
+import { analysisHealthConfig, type AnalysisHealthValue } from '@/modules/chat/config';
+import type { MessageRendererEmits, MessageRendererProps } from '@/modules/chat/renderer/index.ts';
+import type { AnalysisHealthKey, AnalysisHealthModel } from '@/modules/chat/types';
+
+const { token } = Theme.useToken();
+
+const command = {
+  upload: '开始上传',
+  no: '不采集',
+} as const;
+
+defineOptions({ inheritAttrs: false });
+
+interface Props extends MessageRendererProps {
+  group?: Array<AnalysisHealthKey | AnalysisHealthValue>;
+  capture?: 'user' | 'environment'
+}
+
+type Emits = MessageRendererEmits;
+
+const { group = [], complete, capture = 'user' } = defineProps<Props>();
+const emits = defineEmits<Emits>();
+
+const open = defineModel('open', { default: false });
+const senderInstance = useTemplateRef<AntXSenderInstance>('sender-ref');
+
+const items = shallowRef<AnalysisHealthValue[]>([]);
+const index = ref(0);
+watchEffect(() => {
+  for (let item of group) {
+    if (typeof item === 'string') item = Object.assign({ key: item }, analysisHealthConfig[item]);
+    if (item) items.value.push(item);
+  }
+  triggerRef(items);
+  index.value = 0;
+});
+
+const pending = ref(false);
+watchEffect(() => {
+  pending.value = !complete;
+  if (complete) {
+    onTrigger(true);
+    nextTick(() => senderInstance.value?.focus({ cursor: 'end' }));
+  }
+});
+
+const { append, update } = useMessagesContext();
+
+const {
+  loading,
+  form: model,
+  send: analysis,
+} = useForm(analysisHealthMethod, {
+  initialForm: {} as AnalysisHealthModel,
+  initialData: {},
+})
+  .onSuccess(({ data }) => {
+    open.value = false;
+    const cover = items.value
+      .map((item) => {
+        const url = data.model?.[item.key]?.url;
+        return url ? `<img src="${url}" alt="${item.label}">` : void 0;
+      })
+      .filter(Boolean);
+
+    append({ role: 'user', content: `<div class="image-gather">${cover.join('')}</div>` });
+
+    let gather = [];
+    if (data.result?.tongue?.result) gather.push(`**舌象**:${data.result.tongue.result}`);
+    if (data.result?.face?.result) gather.push(`**面象**:${data.result.face.result}`);
+    if (gather.length > 1) { gather = gather.map(item => `- ${item}`);  }
+
+    append({
+      role: 'chat',
+      content: gather.join('\n'),
+      onTypingComplete: () => {
+        emits('next');
+      },
+    });
+  })
+  .onError(({ error }) => {
+    onTrigger(true);
+    Toast.error(error.message || '请重试');
+  });
+
+const submittable = computed(() => items.value.every((item) => !item.required || model.value[item.key]?.thumbnail));
+const dirty = computed(() => items.value.some((item) => model.value[item.key]?.thumbnail));
+
+function onTrigger(key?: AnalysisHealthKey | boolean) {
+  if (key == null) open.value = !open.value;
+  else if (typeof key === 'boolean') open.value = key;
+  else {
+    open.value = true;
+    index.value = items.value.findIndex((item) => item.key === key);
+  }
+}
+
+const displayValue = computed(() => {
+  return Object.values(model.value).some((value) => value.url || value.thumbnail) ? command.upload : command.no;
+});
+
+const gatherLabel = computed(() => items.value.map((item) => item.label).join('、'));
+const senderProps = computed(() => {
+  return {
+    readOnly: true,
+    value: ' ',
+    loading: loading.value,
+    disabled: pending.value,
+    async onSubmit(value, confirm = true) {
+      value = value?.trim() || displayValue.value;
+      if (value === command.no) {
+        open.value = false;
+        if (
+          confirm &&
+          !(await Dialog.confirm({
+            title: '提示',
+            message: `确定不采集${gatherLabel.value}?`,
+            confirmButtonColor: token.value.red,
+            confirmButtonText: '不采集',
+            cancelButtonText: '采集',
+            showCancelButton: true,
+          }))
+        ) {
+          open.value = true;
+          return;
+        }
+        append({ role: 'user', content: value });
+        emits('next');
+      } else if (submittable.value) {
+        // open.value = false;
+        await analysis();
+      } else {
+        open.value = true;
+      }
+    },
+  } satisfies AntXSenderProps;
+});
+
+const senderHeaderProps = computed(() => {
+  const gather = gatherLabel.value;
+  return {
+    open: open.value,
+    title: gather ? `请您按照提示要求,拍摄${gather}照片` : `请按照提示要求拍摄照片`,
+    tips: gather ? `注意下面是${gather}的拍摄标准哦!` : `注意下面是拍摄的标准哦!`,
+    closable: true,
+    onOpenChange(value: boolean) {
+      open.value = value;
+    },
+  };
+});
+
+const prefixProps = computed(() => {
+  return {
+    type: 'text',
+    icon: h(NumberOutlined),
+    disabled: pending.value,
+  } satisfies AntButtonProps;
+});
+
+let lastFileDialogKey: AnalysisHealthKey | void;
+const fileDialog = useFileDialog({ accept: 'image/*', capture });
+fileDialog.onChange(async (files) => {
+  const file = files?.[0];
+  if (!file || !lastFileDialogKey) return;
+  if (!file.type.startsWith('image')) return Toast.warning(`请选择图片格式`);
+  const thumbnail = await blob2Base64(file);
+  model.value[lastFileDialogKey] = { thumbnail, file };
+});
+
+const openFileDialog = (key: AnalysisHealthKey) => {
+  fileDialog.open({
+    reset: lastFileDialogKey !== key,
+    capture,
+  });
+  lastFileDialogKey = key;
+};
+const deleteFile = (key: AnalysisHealthKey) => {
+  Reflect.deleteProperty(model.value, key);
+  lastFileDialogKey = void 0;
+};
+</script>
+
+<template>
+  <Sender ref="sender-ref" v-bind="senderProps">
+    <template #header>
+      <Sender.Header v-bind="senderHeaderProps">
+        <a-spin :spinning="loading">
+          <a-flex vertical align="center" gap="small" :style="{ marginBlock: token.paddingLG }">
+            <a-typography-text style="margin: 0" :level="5" type="warning">{{ senderHeaderProps.tips }} </a-typography-text>
+            <a-row class="row" :gutter="8">
+              <a-col v-for="item in items" :key="item.key" :span="8">
+                <div class="square-container">
+                  <div class="square">
+                    <a-image :id="'e_' + item.key" :src="item.example" />
+                  </div>
+                </div>
+                <label :for="'e_' + item.key">{{ item.label }}举例</label>
+              </a-col>
+            </a-row>
+            <a-row class="row" style="margin-top: 20px" :gutter="8">
+              <a-col v-for="item in items" :key="item.key" :span="8">
+                <div class="square-container">
+                  <a-badge class="square" :dot="item.required">
+                    <div class="square">
+                      <a-image v-if="model[item.key]?.url" :id="'url_' + item.key" :src="model[item.key].url" />
+                      <a-image v-else-if="model[item.key]?.thumbnail" :id="'tmpl_' + item.key" :src="model[item.key].thumbnail" />
+                      <a-button v-else class="upload-button" :id="'U_' + item.key" :disabled="loading" @click="openFileDialog(item.key)">
+                        <PlusOutlined></PlusOutlined>
+                        <div style="margin-top: 8px">{{ item.label }}照片</div>
+                      </a-button>
+                    </div>
+                  </a-badge>
+                </div>
+                <div style="min-height: 19px; margin-top: 4px">
+                  <label v-if="model[item.key]?.thumbnail" :for="'u_' + item.key" @click="openFileDialog(item.key)">
+                    <a-space>
+                      <span>{{ item.label }}</span>
+                      <a-button type="text" size="small" :icon="h(DeleteOutlined)" :style="{ color: token.red }" :disabled="loading" @click.stop="deleteFile(item.key)"></a-button>
+                    </a-space>
+                  </label>
+                </div>
+              </a-col>
+            </a-row>
+            <a-button :icon="h(CloudUploadOutlined)" :disabled="!submittable" :loading @click="senderProps.onSubmit(command.upload)">{{ command.upload }} </a-button>
+          </a-flex>
+        </a-spin>
+      </Sender.Header>
+    </template>
+    <template #prefix>
+      <a-space size="small">
+        <a-button v-bind="prefixProps" :class="{ active: open }" :icon="h(LinkOutlined)" @click="onTrigger()"></a-button>
+        <a-button v-if="!dirty" v-bind="prefixProps" @click="senderProps.onSubmit(command.no, false)">
+          {{ command.no }}
+        </a-button>
+      </a-space>
+    </template>
+  </Sender>
+</template>
+
+<style scoped lang="scss">
+:deep(.ant-sender-prefix) {
+  .ant-btn-text {
+    &.active {
+      color: #1677ff;
+    }
+  }
+}
+
+.row {
+  justify-content: space-evenly;
+  width: 100%;
+
+  .square-container {
+    position: relative;
+    padding-bottom: 100%;
+
+    .square {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  .upload-button {
+    width: 100%;
+    height: 100%;
+    //color: #999;
+    background-color: transparent;
+    box-shadow: 0 2px 0 rgba(255, 255, 255, 0.04);
+    border-style: dashed;
+
+    &:not(:hover) {
+      border-color: #424242;
+    }
+  }
+
+  :deep(.ant-col) {
+    $size: 256px;
+    max-width: $size;
+    text-align: center;
+
+    > label {
+      display: block;
+      margin-top: 8px;
+    }
+
+    .ant-image,
+    img {
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+      object-fit: cover;
+    }
+  }
+}
+</style>

+ 214 - 0
src/modules/chat/renderer/BasicInfoSender.vue

@@ -0,0 +1,214 @@
+<script setup lang="ts">
+import { h } from 'vue';
+
+import { NumberOutlined } from '@ant-design/icons-vue';
+import { Button, Flex } from 'ant-design-vue';
+import { Sender } from 'ant-design-x-vue';
+
+import { useMessagesContext } from '@/modules/chat/composables';
+import { useGuideStore } from '@/stores';
+import type { GuideUser } from '@/stores/guide.store.ts';
+import type { MessageRendererEmits, MessageRendererProps } from '@/modules/chat/renderer/index.ts';
+import type { BasicInfoPickerKey, BasicInfoPickerModel } from '@/modules/chat/types';
+import type { BasicInfoPickerValue } from '@/modules/chat/config';
+import { basicInfoPickerGroup } from '@/modules/chat/config';
+
+const STRING_SEPARATOR = ', ';
+
+defineOptions({ inheritAttrs: false });
+
+interface Props extends MessageRendererProps {
+  group?: Array<BasicInfoPickerKey | BasicInfoPickerValue>;
+}
+
+type Emits = MessageRendererEmits;
+
+const { group = [], complete } = defineProps<Props>();
+const emits = defineEmits<Emits>();
+
+const open = defineModel('open', { default: false });
+const senderInstance = useTemplateRef<AntXSenderInstance>('sender-ref');
+
+const pickers = shallowRef<BasicInfoPickerValue[]>([]);
+const pickerIndex = ref(0);
+const picker = computed(() => pickers.value.at(pickerIndex.value));
+watchEffect(() => {
+  for (let item of group) {
+    if (typeof item === 'string') item = Object.assign({ key: item }, basicInfoPickerGroup[item]);
+    if (item) pickers.value.push(item);
+  }
+  triggerRef(pickers);
+  pickerIndex.value = 0;
+});
+
+const pending = ref(false);
+watchEffect(() => {
+  pending.value = !complete;
+  if (complete) {
+    onTrigger(true);
+    nextTick(() => senderInstance.value?.focus({ cursor: 'end' }));
+  }
+});
+
+const guide = useGuideStore();
+const { append } = useMessagesContext();
+
+const { loading, model } = toRefs(reactive({ loading: false, model: {} as BasicInfoPickerModel }));
+
+function onTrigger(key?: BasicInfoPickerKey | boolean) {
+  if (key == null) open.value = !open.value;
+  else if (typeof key === 'boolean') open.value = key;
+  else {
+    open.value = true;
+    pickerIndex.value = pickers.value.findIndex((picker) => picker.key === key);
+  }
+}
+
+const getDisplayValue = (values: string[] | string | undefined, columns: VantPickerColumn): string => {
+  if (!Array.isArray(values)) values = values ? values.split(STRING_SEPARATOR) : [];
+  return values
+    .map((value, index) => {
+      const column = Array.isArray(columns[index]) ? columns[index] : columns;
+      return column.find((col) => col.value === value)?.text;
+    })
+    .join(STRING_SEPARATOR);
+};
+
+const displayValue = ref();
+
+const pickerInstance = useTemplateRef<VantPickerInstance>('picker-ref');
+const pickerProps = computed(() => {
+  const { key, title, confirmButtonText = '确定', columns = [], defaultValue, clickOnConfirm = false, onConfirm } = picker.value ?? {};
+  const value = model.value[key!] ?? defaultValue;
+
+  displayValue.value = getDisplayValue(value, columns);
+
+  return {
+    title: typeof title === 'function' ? title(model.value) : title,
+    columns,
+    confirmButtonText,
+    showToolbar: false,
+    loading: pending.value,
+    modelValue: value ? value.split(STRING_SEPARATOR) : [],
+    'onUpdate:modelValue'(value) {
+      displayValue.value = getDisplayValue(value, columns);
+    },
+    onClickOption(event) {
+      if (clickOnConfirm) setTimeout(() => unref(pickerProps).onConfirm(event), 0);
+    },
+    async onConfirm(event) {
+      model.value[key!] = event.selectedValues.join(STRING_SEPARATOR);
+      if (await onConfirm?.(model)) {
+        open.value = false;
+        senderProps.value.onSubmit();
+      } else {
+        const index = pickerIndex.value + 1;
+        if (index === pickers.value.length) {
+          open.value = false;
+          senderProps.value.onSubmit();
+        } else pickerIndex.value = index;
+      }
+    },
+  } satisfies VantPickProps;
+});
+
+const senderProps = computed(() => {
+  return {
+    readOnly: true,
+    value: open.value ? displayValue.value : ' ',
+    loading: loading.value,
+    disabled: pending.value,
+    onSubmit() {
+      if (open.value) return pickerInstance.value?.confirm();
+      const key = pickers.value.find((picker) => model.value[picker.key] == null)?.key;
+      if (key) onTrigger(key);
+      else {
+        pickerIndex.value = pickers.value.length - 1;
+        open.value = false;
+        onSubmit(pickers.value.map((picker) => getDisplayValue(model.value[picker.key], picker.columns!)).join(STRING_SEPARATOR));
+      }
+    },
+  } satisfies AntXSenderProps;
+});
+
+const senderHeaderProps = computed(() => {
+  const { title, confirmButtonText } = pickerProps.value;
+  return {
+    open: open.value,
+    title: title
+      ? h(Flex, { justify: 'space-between' }, () => [
+          h('span', null, title),
+          !!confirmButtonText &&
+            h(
+              Button,
+              {
+                type: 'text',
+                size: 'small',
+                style: { color: '#1677ff' },
+                onClick() {
+                  pickerInstance.value?.confirm();
+                },
+              },
+              () => confirmButtonText,
+            ),
+        ])
+      : void 0,
+  };
+});
+
+const prefixProps = computed(() => {
+  return {
+    type: 'text',
+    icon: h(NumberOutlined),
+    disabled: pending.value,
+  } satisfies AntButtonProps;
+});
+
+async function onSubmit(content: string | VNode) {
+  guide.updateUser(<GuideUser>model.value);
+  append({ role: 'user', content });
+  emits('next');
+}
+</script>
+
+<template>
+  <Sender ref="sender-ref" v-bind="senderProps">
+    <template #header>
+      <Sender.Header v-bind="senderHeaderProps">
+        <van-picker ref="picker-ref" v-bind="pickerProps" :key="picker?.key" />
+      </Sender.Header>
+    </template>
+    <template #prefix>
+      <a-flex>
+        <template v-for="(picker, i) in pickers" :key="picker.key">
+          <a-button v-if="i <= pickerIndex" :class="{ active: open && pickerIndex === i }" v-bind="prefixProps" @click="onTrigger(picker.key)">
+            {{ open && i === pickerIndex ? '' : getDisplayValue(model[picker.key], picker.columns!) }}
+          </a-button>
+        </template>
+      </a-flex>
+    </template>
+  </Sender>
+</template>
+
+<style scoped lang="scss">
+:deep(.ant-sender-prefix) {
+  .ant-btn-text {
+    &.active {
+      color: #1677ff;
+    }
+  }
+}
+
+:deep(.ant-sender-header-content) {
+  --van-picker-background: transparent;
+  --van-picker-mask-color: transparent;
+  --van-picker-group-background: transparent;
+  --van-picker-loading-mask-color: transparent;
+
+  .van-picker-column__item {
+    &--selected {
+      color: #1677ff;
+    }
+  }
+}
+</style>

+ 87 - 0
src/modules/chat/renderer/ChatSender.vue

@@ -0,0 +1,87 @@
+<script setup lang="ts">
+import { Sender } from 'ant-design-x-vue';
+import type { MessageRendererEmits, MessageRendererProps } from '@/modules/chat/renderer/index.ts';
+import { useFetchForSSE } from '@/request/sse';
+import type { Method } from 'alova';
+import { useMessagesContext } from '@/modules/chat/composables';
+import { randomUUID } from '@/tools';
+import GuideContent from '@/modules/chat/renderer/GuideContent.vue';
+
+defineOptions({ inheritAttrs: false });
+
+interface Props extends MessageRendererProps {
+  handler: (message: string) => unknown;
+}
+
+type Emits = MessageRendererEmits;
+
+const { handler, complete } = defineProps<Props>();
+const emits = defineEmits<Emits>();
+
+const content = defineModel('content', { default: '' });
+const senderInstance = useTemplateRef<AntXSenderInstance>('sender-ref');
+
+const focus = () => {
+  senderInstance.value?.focus({ cursor: 'end' });
+  setTimeout(() => {
+    const input = document.querySelector<HTMLInputElement>(`.ant-input` )
+    input?.focus();
+  }, 100)
+}
+
+const pending = ref(false);
+watchEffect(() => {
+  pending.value = !complete;
+  if (complete) nextTick(() => focus());
+});
+
+const key = ref<string>();
+
+const { append, update } = useMessagesContext();
+const { send, loading, onMessage, on } = useFetchForSSE(<(message: string) => Method>handler)
+  .onOpen(() => {
+    key.value = randomUUID();
+    append({ key: key.value, role: 'chat', content: `加载中...`, status: 'loading' });
+  })
+
+const message = ref<string>('');
+const over = ref(false);
+onMessage<{ content: string }>(({ data }) => {
+  if (data?.content) {
+    message.value += data.content;
+    update({
+      key: key.value,
+      content: message.value,
+      onTypingComplete() {
+        if (over.value) {
+          update({
+            key: key.value,
+            messageRender: () => {
+              return h(GuideContent, { content: message.value });
+            },
+            sender: null,
+          });
+        }
+      },
+    });
+  }
+});
+on('over', () => {
+  over.value = true;
+  emits('next');
+});
+
+const onSubmit = (value: string) => {
+  append({ role: 'user', content: value });
+  content.value = '';
+  message.value = '';
+  over.value = false;
+  send(value);
+};
+</script>
+
+<template>
+  <Sender ref="sender-ref" :loading :disabled="pending" v-model:value="content" placeholder="请输入..." @submit="onSubmit($event)"></Sender>
+</template>
+
+<style scoped lang="scss"></style>

+ 122 - 0
src/modules/chat/renderer/GuideContent.vue

@@ -0,0 +1,122 @@
+<script setup lang="ts">
+import { defaultMessageRender } from '@/modules/chat/config';
+import { useWatcher } from 'alova/client';
+import { guideDepartments, guideDoctors } from '@/request/api';
+import { useGuideStore } from '@/stores';
+
+const { content } = defineProps<{ content: string }>();
+const markdown = computed(() => defaultMessageRender(content));
+
+const Guide = useGuideStore();
+const { session } = storeToRefs(Guide);
+const activeKey = ref<Array<'departments' | 'doctors'>>([]);
+
+const { data: departments } = useWatcher(() => guideDepartments(content, { session_id: session.value }), [() => content], {
+  immediate: true,
+  initialData: [],
+}).onSuccess(({ data }) => {
+  if (data.length > 0) activeKey.value.push('departments');
+});
+const { data: doctors } = useWatcher(() => guideDoctors(content, { session_id: session.value }), [() => content], {
+  immediate: true,
+  initialData: [],
+}).onSuccess(({ data }) => {
+  if (data.length > 0) activeKey.value.push('doctors');
+});
+
+const open = (url: string) => {
+  if (url) window.open(url);
+}
+</script>
+
+<template>
+  <div>
+    <component :is="markdown"></component>
+    <a-collapse class="recommended-wrapper" v-model:activeKey="activeKey">
+      <a-collapse-panel key="doctors" header="中医专家推荐" :collapsible="doctors.length ? 'header' : 'disabled'">
+        <div v-for="doctor in doctors" :key="doctor.id" class="doctor">
+          <div class="row">
+            <a-space>
+              <a-avatar :src="doctor.avatar" style="color: #f56a00; background-color: #fde3cf">
+                {{ doctor.name?.slice(0, 1) }}
+              </a-avatar>
+              <span>{{ doctor.name }}</span>
+              <span v-if="doctor?.department?.name">{{ doctor.department.name }}</span>
+            </a-space>
+            <div>
+              <a-button v-if="doctor.registerLink" type="primary" @click="open(doctor.registerLink)">预约</a-button>
+            </div>
+          </div>
+          <div>
+            <a-tag color="pink" v-if="doctor.titleOfClinical">{{ doctor.titleOfClinical }}</a-tag>
+            <a-tag color="cyan" v-if="doctor.titleOfTeach">{{ doctor.titleOfTeach }}</a-tag>
+            <a-tag color="purple" v-if="doctor.titleOf">{{ doctor.titleOf }}</a-tag>
+          </div>
+          <div class="van-multi-ellipsis--l3">
+            <span v-if="doctor.description">{{ doctor.description }}</span>
+            <span v-if="doctor.adeptAt">{{ doctor.adeptAt }}</span>
+          </div>
+        </div>
+      </a-collapse-panel>
+      <a-collapse-panel key="departments" header="中医科室推荐" :collapsible="departments.length ? 'header' : 'disabled'">
+        <div v-for="department in departments" :key="department.id" class="department">
+          <div class="row">
+            <a-space>
+              <a-avatar :src="department.avatar" style="color: #7265e6; background-color: #fde3cf">
+                {{ department.name?.slice(0, 2) }}
+              </a-avatar>
+              <span>{{ department.name }}</span>
+            </a-space>
+            <div>
+              <a-button v-if="department.registerLink" type="primary" @click="open(department.registerLink)">预约</a-button>
+            </div>
+          </div>
+          <div class="van-multi-ellipsis--l3" v-if="department.description">
+            <span>{{ department.description }}</span>
+          </div>
+        </div>
+      </a-collapse-panel>
+    </a-collapse>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.recommended-wrapper {
+  margin-top: 12px;
+
+  .row {
+    display: flex;
+    justify-content: space-between;
+  }
+
+  :deep(.ant-collapse-header-text) {
+    flex: auto!important;
+    font-weight: 700;
+    text-align: center;
+    transform: translateX(-17px);
+  }
+}
+
+.doctor {
+  > div + div {
+    margin-top: 8px;
+  }
+  & + .doctor {
+    border-top: 1px cadetblue dashed;
+    margin-top: 16px;
+    padding-top: 16px;
+  }
+}
+
+.department {
+  > div + div {
+    margin-top: 8px;
+  }
+
+  & + .department {
+    border-top: 1px cadetblue dashed;
+    margin-top: 16px;
+    padding-top: 16px;
+  }
+}
+</style>

+ 11 - 0
src/modules/chat/renderer/index.ts

@@ -0,0 +1,11 @@
+import type { MessageModel } from '@/modules/chat/types';
+
+export interface MessageRendererProps {
+  message?: MessageModel;
+  status?: MessageModel['status'];
+  complete?: boolean;
+}
+
+export interface MessageRendererEmits {
+  next: [];
+}

+ 13 - 0
src/modules/chat/types/index.ts

@@ -0,0 +1,13 @@
+import { analysisHealthConfig, basicInfoPickerGroup, defaultMessageRole } from '../config';
+
+export * from './message.ts';
+
+export type DefaultMessageRole = keyof typeof defaultMessageRole;
+
+export type AnalysisHealthKey = keyof typeof analysisHealthConfig;
+export type AnalysisHealthModel = Record<AnalysisHealthKey, { file?: File; url?: string; thumbnail?: string }>;
+export type BasicInfoPickerKey = keyof typeof basicInfoPickerGroup;
+export type BasicInfoPickerModel = Partial<Record<BasicInfoPickerKey, string>>;
+
+
+

+ 70 - 0
src/modules/chat/types/message.ts

@@ -0,0 +1,70 @@
+import type { ShallowReactive } from 'vue';
+import type { EventHookReturn } from '@vueuse/core';
+
+import type { DefaultMessageRole } from './index.ts';
+
+export type MessageRole = DefaultMessageRole | string;
+export type MessageStatus = 'loading' | 'done' | 'typing' | 'error';
+
+export interface MessageModel<R extends MessageRole = MessageRole> {
+  role: R;
+  key: string;
+  content?: string | number | boolean | VNode;
+
+  status?: MessageStatus;
+
+  typing?: boolean | { step?: number; interval?: number };
+
+  onTypingComplete?: (event: boolean) => void;
+  onStatus?: (event: boolean) => void;
+
+  sender?: MessageTemplate | null;
+
+  messageRender?: (content: string) => VNode;
+}
+
+export interface MessageEntity extends Omit<MessageModel, `on${string}`> {
+  events: {
+    typingComplete: EventHookReturn<boolean>;
+    status: EventHookReturn<MessageStatus>;
+  };
+}
+
+export interface MessageBubbles {
+  key: string;
+  role: string;
+  placement?: 'start' | 'end';
+  status?: MessageStatus;
+  typing?: boolean | { step?: number; interval?: number };
+  onTypingComplete: () => void;
+
+  messageRender?: (content: string) => VNode;
+  content?: string | number | boolean | VNode;
+  avatar?: VNode | AntAvatarProps | any;
+
+  sender?: ReturnType<MessageRenderer> | null;
+}
+
+export type MessageRenderer = (entity: MessageEntity, props?: Data) => VNode | (() => VNode);
+
+export type MessageTemplate =
+  | string
+  | {
+      template: string;
+      props?: Data;
+    };
+
+export interface RenderBubblesOptions {
+  hideAvatar?: boolean;
+  mergeAvatar?: boolean;
+  renderer?: Record<string, MessageRenderer>;
+}
+
+export interface UseMessagesReturn {
+  readonly bubbles: ShallowReactive<MessageBubbles[]>;
+  readonly messages: ShallowReactive<MessageEntity[]>;
+  readonly append: (target: Default<MessageModel, 'role'>, props?: Partial<MessageModel>, options?: RenderBubblesOptions) => void;
+  readonly update: (message?: Partial<MessageModel>, props?: Partial<Omit<MessageModel, 'key'>>, options?: RenderBubblesOptions) => void;
+
+  readonly sender: ReturnType<MessageRenderer>;
+}

+ 1 - 0
src/platform/index.ts

@@ -0,0 +1 @@
+export * from './vant.ts';

+ 107 - 0
src/platform/vant.ts

@@ -0,0 +1,107 @@
+import type {
+  DialogOptions as VantDialogOptions,
+  NotifyOptions as VantNotifyOptions,
+  NotifyType as VantNotifyType,
+  ToastOptions as VantToastOptions,
+  ToastWrapperInstance as VantToastWrapperInstance,
+} from 'vant';
+import { closeNotify, closeToast, showDialog, showNotify, showToast } from 'vant';
+import 'vant/es/toast/style';
+import 'vant/es/dialog/style';
+import 'vant/es/notify/style';
+
+type ToastOptions = Omit<VantToastOptions, 'message'>;
+
+export class Toast {
+  static show(message: string, options?: ToastOptions) {
+    const toastRef = showToast({ message, ...options });
+    return { toastRef, close: () => toastRef.close() };
+  }
+
+  static close() {
+    closeToast(true);
+  }
+
+  static success(message: string, options?: ToastOptions) {
+    return this.show(message, { ...options, closeOnClick: true, closeOnClickOverlay: true, type: 'success' });
+  }
+
+  static warning(message: string, options?: ToastOptions) {
+    return this.show(message, { ...options, icon: 'warning-o' });
+  }
+
+  static error(message: string, options?: ToastOptions) {
+    return this.show(message, { ...options, type: 'fail' });
+  }
+
+  static loading(delay = 0, options?: ToastOptions & { message?: string }) {
+    const fn = () =>
+      this.show(options?.message ?? '加载中...', {
+        ...options,
+        type: 'loading',
+        closeOnClick: false,
+        closeOnClickOverlay: false,
+        forbidClick: true,
+        duration: 0,
+      });
+    if (delay === 0) {
+      return fn();
+    }
+    const timer = setTimeout(() => {
+      ({ toastRef: ref.toastRef, close: ref.close } = fn());
+    }, delay);
+    const ref = {
+      toastRef: null as VantToastWrapperInstance | null,
+      cancel: () => clearTimeout(timer),
+      close: () => clearTimeout(timer),
+    };
+    return ref;
+  }
+}
+
+export class Dialog {
+  static show(options: VantDialogOptions) {
+    return showDialog(options);
+  }
+
+  static confirm(options: VantDialogOptions) {
+    return showDialog(options).then((action) => action === 'confirm', () => false);
+  }
+}
+
+type NotifyType = 'info' | 'success' | 'error' | 'warning';
+type NotifyOptions = Omit<VantNotifyOptions, 'type' | 'message'>;
+
+export class Notify {
+  static Type: Record<NotifyType, string> = {
+    info: 'primary',
+    success: 'success',
+    error: 'danger',
+    warning: 'warning',
+  };
+
+  static show(type: NotifyType, message: string, options?: NotifyOptions) {
+    const notifyRef = showNotify({ type: this.Type[type] as VantNotifyType, message, ...options });
+    return { notifyRef, close: Notify.close };
+  }
+
+  static close() {
+    closeNotify();
+  }
+
+  static info(message: string, options?: NotifyOptions) {
+    return this.show('info', message, options);
+  }
+
+  static success(message: string, options?: NotifyOptions) {
+    return this.show('success', message, options);
+  }
+
+  static warning(message: string, options?: NotifyOptions) {
+    return this.show('warning', message, options);
+  }
+
+  static error(message: string, options?: NotifyOptions) {
+    return this.show('error', message, options);
+  }
+}

+ 19 - 0
src/polyfill.ts

@@ -0,0 +1,19 @@
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-nocheck
+import 'core-js/modules/es.array.at.js';
+import 'core-js/modules/es.symbol.async-iterator.js';
+
+if (!ReadableStream.prototype[Symbol.asyncIterator]) {
+  ReadableStream.prototype[Symbol.asyncIterator] = async function* () {
+    const reader = this.getReader();
+    try {
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) return;
+        yield value;
+      }
+    } finally {
+      reader.releaseLock();
+    }
+  };
+}

+ 53 - 0
src/request/api/analysis.ts

@@ -0,0 +1,53 @@
+import { analysis } from '@/request';
+import type { AnalysisHealthKey, AnalysisHealthModel } from '@/modules/chat/types';
+import type { AnalysisHealthReportModel } from '@/request/model/analysis-health.model.ts';
+import { camelToUnderline } from '@/tools';
+
+export function analysisHealthMethod(model: AnalysisHealthModel) {
+  const ref: Record<AnalysisHealthKey, string> = {
+    tongueUp: 'tongueTop',
+    tongueDown: 'tongueUnder',
+    faceCenter: 'face',
+  };
+
+  const formData = new FormData();
+  for (const entry of Object.entries(model)) {
+    const key = ref[<AnalysisHealthKey>entry[0]] ?? entry[0];
+    if (entry[1].file) formData.append(key, entry[1].file);
+  }
+
+  return analysis.Post(`/tongue_diagnosis`, formData, {
+    // headers: { 'content-type': 'multipart/form-data' },
+    transform(data: Data) {
+      const getUrl = (value?: string) => {
+        if (value?.startsWith('http')) return value;
+        return value ? import.meta.env.SIX_ANALYSIS_IMAGE_PREFIX + value : void 0;
+      };
+
+      for (const entry of Object.entries(model)) {
+        const key = ref[<AnalysisHealthKey>entry[0]] ?? entry[0];
+        const url = data?.[camelToUnderline(`${key}_origin_path`)];
+        entry[1].url = getUrl(url);
+      }
+
+      const result: AnalysisHealthReportModel = {};
+      if (data.tongue_str)
+        result.tongue = {
+          result: data.tongue_str,
+          cover: {
+            up: getUrl(data.tongue_top_origin_path)!,
+            down: getUrl(data.tongue_under_origin_path)!,
+          },
+        };
+      if (data.face_str)
+        result.face = {
+          result: data.face_str,
+          cover: {
+            center: getUrl(data.face_origin_path)!,
+          },
+        };
+
+      return { model, result } as { model: AnalysisHealthModel; result: AnalysisHealthReportModel };
+    },
+  });
+}

+ 22 - 0
src/request/api/chat.ts

@@ -0,0 +1,22 @@
+import { chat } from '@/request';
+
+export function guideChatMethod(message: string, options?: { session_id?: string }) {
+  return chat.Post(
+    `/hospital_guide/chat/stream`,
+    { ...options, message },
+    {
+      meta: {
+        sseTransform(line) {
+          const [_, event, content] = line.match(/([^:]*):\s*(.*)/) ?? [];
+          const data = JSON.parse(content.trim());
+          if (data?.end) return [data.content ? { event, data } : void 0, { event: 'over' }].filter(Boolean);
+          return { event, data };
+        }
+      },
+    },
+  );
+}
+
+export function tcmChatMethod(message: string, options?: { session_id?: string }) {
+  return chat.Post(`/tcm_chat/chat/tcm`, { ...options, message });
+}

+ 62 - 0
src/request/api/guide.api.ts

@@ -0,0 +1,62 @@
+import { chat, http } from '@/request';
+import type { GuideUser } from '@/stores/guide.store.ts';
+import { type DepartmentModel, type DoctorModel, fromDepartment, fromDoctor } from '@/request/model/guide.model.ts';
+import type { MessageEntity } from '@/modules/chat/types';
+
+export function guideRegisterSessionMethod(searchParams?: URLSearchParams) {
+  const data: Data = {};
+  const keys = ['id', 'age', 'sex'];
+  for (const key of keys) { if (searchParams?.has(key)) data[key] = searchParams?.get(key); }
+  return http.Post<{ session: string; user: GuideUser }, Data>(
+    `/mobile/login?${searchParams?.toString()}`,
+    data,
+    {
+      transform(data) {
+        return {
+          session: data?.sessionid,
+          user: { role: '1', gender: data?.sex, age: data?.age },
+        };
+      },
+    },
+  );
+}
+
+export function guideRecordSavaMethod(messages: MessageEntity[], session: string) {
+  return http.Post(`/mobile/saveChatInfo`, {
+    sessionid: session,
+    chatInfo: JSON.stringify(messages, (k, v) => {
+      const vueKeys = ['vnode', 'component'];
+      const keys = ['event'];
+
+      return typeof v === 'function' || vueKeys.includes(k) || keys.includes(k) ? void 0 : v;
+    }),
+  });
+}
+
+export function guideDepartments(keyword: string, options?: { session_id?: string }) {
+  return chat.Post<DepartmentModel[], { results: Data[]; error?: { message: string } }>(
+    `/hospital_guide/api/search/departments`,
+    { query: keyword, ...options },
+    {
+      meta: { interceptByBodyResponded: false },
+      transform(data) {
+        if (Array.isArray(data.results)) return data.results.map(fromDepartment);
+        throw data.error;
+      },
+    },
+  );
+}
+
+export function guideDoctors(keyword: string, options?: { session_id?: string }) {
+  return chat.Post<DoctorModel[], { results: Data[]; error?: { message: string } }>(
+    `/hospital_guide/api/search/doctors`,
+    { query: keyword, ...options },
+    {
+      meta: { interceptByBodyResponded: false },
+      transform(data) {
+        if (Array.isArray(data.results)) return data.results.map(fromDoctor);
+        throw data.error;
+      },
+    },
+  );
+}

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

@@ -0,0 +1,3 @@
+export * from './analysis.ts';
+export * from './chat.ts';
+export * from './guide.api.ts';

+ 41 - 0
src/request/factory.ts

@@ -0,0 +1,41 @@
+import type { AlovaFactoryOptions } from './types';
+
+import { createAlova } from 'alova';
+import VueHook from 'alova/vue';
+import adapterFetch from 'alova/fetch';
+
+export function createChatAlovaFactory({ beforeRequest, responded, ...options }: AlovaFactoryOptions) {
+  return createAlova({
+    statesHook: VueHook,
+    requestAdapter: adapterFetch(),
+    async beforeRequest(method) {
+      beforeRequest?.(method);
+    },
+    responded: {
+      async onSuccess(response, method) {
+        if (method.meta?.interceptByGlobalResponded === false) return response;
+        if (response.status > 400) throw { message: response.statusText, code: response.status };
+
+        const onSuccess = () => responded?.onSuccess?.(response, method);
+
+        const [contentType] = response.headers.get('content-type')?.split(';') ?? [];
+        switch (contentType) {
+          case 'application/json': {
+            let body = await response.clone().json();
+            if (body === null || typeof body !== 'object' || method.meta?.interceptByBodyResponded === false) return body;
+            body = (await onSuccess()) ?? body;
+            if (body.code === 0) return body.data;
+            throw body;
+          }
+          case 'text/event-stream':
+            return (await onSuccess()) ?? response.body;
+        }
+
+        return (await onSuccess()) ?? response;
+      },
+      async onError(error, method) {},
+      onComplete() {},
+    },
+    ...options,
+  });
+}

+ 59 - 0
src/request/index.ts

@@ -0,0 +1,59 @@
+import { createChatAlovaFactory } from './factory';
+
+/**
+ * 导诊后端请求
+ */
+export const http = createChatAlovaFactory({
+  id: 'hospital_guide',
+  baseURL: import.meta.env.SIX_API_HOSPITAL_GUIDE,
+  responded: {
+    async onSuccess(response) {
+      const [contentType] = response.headers.get('content-type')?.split(';') ?? [];
+      switch (contentType) {
+        case 'application/json': {
+          const { ResultCode: code, ResultInfo: message, Data: data, ...r } = await response.json();
+          const body = { code, message, data, ...r };
+
+          if (body.data?.TotalPageCount !== void 0 && Array.isArray(body.data?.Items)) {
+            const { TotalPageCount: total, PageIndex: page, PageSize: size, Items: items } = body.data;
+            body.data = { total, items, data: { page, size, total } };
+          }
+          return body;
+        }
+      }
+    },
+  },
+});
+
+/**
+ * 舌象分析请求
+ */
+export const analysis = createChatAlovaFactory({
+  id: 'ai_analysis',
+  baseURL: import.meta.env.SIX_API_AI_ANALYSIS,
+  responded: {
+    async onSuccess(response) {
+      const [contentType] = response.headers.get('content-type')?.split(';') ?? [];
+      switch (contentType) {
+        case 'application/json': {
+          const { status, code, message, data, ...r } = await response.json();
+          const body = { code, message, data, ...r };
+          if (status && +code === 200) body.code = 0;
+          return body;
+        }
+      }
+    },
+  },
+});
+
+/**
+ * 智能 AI 请求
+ */
+export const chat = createChatAlovaFactory({
+  id: 'ai_chat',
+  baseURL: import.meta.env.SIX_API_AI_CHAT,
+});
+
+export const database = createChatAlovaFactory({
+  baseURL: `${import.meta.env.BASE_URL}database`,
+});

+ 17 - 0
src/request/model/analysis-health.model.ts

@@ -0,0 +1,17 @@
+export interface AnalysisHealthReportModel {
+  tongue?: {
+    result: string;
+    cover: {
+      up: string;
+      down: string;
+    };
+  };
+  face?: {
+    result: string;
+    cover: {
+      center: string;
+      left?: string;
+      right?: string;
+    };
+  };
+}

+ 68 - 0
src/request/model/guide.model.ts

@@ -0,0 +1,68 @@
+import { randomUUID } from '@/tools';
+
+export interface DepartmentModel {
+  id: string;
+  name: string;
+  code?: string;
+  description?: string;
+  organization?: { name: string };
+  registerLink?: string;
+  score?: string;
+}
+
+export interface DoctorModel {
+  id: string;
+  name: string;
+  code?: string;
+  avatar?: string;
+  description?: string;
+  worker?: string;
+  department?: DepartmentModel;
+  titleOfClinical?: string;
+  titleOfTeach?: string;
+  titleOf?: string;
+  adeptAt?: string;
+  registerLink?: string;
+
+  score?: number;
+}
+
+export function fromDepartment(data: Data): DepartmentModel {
+  return {
+    id: data?.id ?? randomUUID(),
+    name: data?.deptName,
+    code: data?.deptCode,
+    description: data?.introduce,
+    organization: data?.hospitalName
+      ? <{ name: string }>{
+          code: data?.hospitalCode,
+          name: data?.hospitalName,
+        }
+      : void 0,
+    registerLink: data?.registerUrl,
+    score: data?.score,
+  };
+}
+
+export function fromDoctor(data: Data): DoctorModel {
+  return {
+    id: data?.id ?? randomUUID(),
+    name: data?.doctorName,
+    code: data?.doctorCode,
+    description: data?.introduce,
+    titleOf: data?.title,
+    titleOfClinical: data?.clinicalTitle,
+    titleOfTeach: data?.teachTitle,
+    avatar: data?.headImage,
+    worker: data?.doctorCode,
+    adeptAt: data?.expertiseArea,
+    department: data?.deptName
+      ? <DepartmentModel>{
+          code: data?.deptCode,
+          name: data?.deptName,
+        }
+      : void 0,
+    registerLink: data?.registerUrl,
+    score: data?.score,
+  };
+}

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

@@ -0,0 +1 @@
+export { default as useFetchForSSE, SSEHookReadyState } from './useSSE.ts';

+ 184 - 0
src/request/sse/useSSE.ts

@@ -0,0 +1,184 @@
+import type { AlovaGenerics, Method } from 'alova';
+import type {
+  AlovaCompleteEvent,
+  AlovaErrorEvent,
+  AlovaMethodHandler,
+  AlovaSSEMessageEvent,
+  AlovaSuccessEvent,
+  SSEHookConfig,
+  SSEOnMessageTrigger,
+  SSEOnOpenTrigger,
+} from 'alova/client';
+
+import { tryOnMounted, tryOnUnmounted } from '@vueuse/core';
+
+export const enum SSEHookReadyState {
+  CONNECTING = 0,
+  OPEN = 1,
+  CLOSED = 2,
+}
+
+export default <Data = any, AG extends AlovaGenerics = AlovaGenerics, Args extends any[] = any[]>(handler: AlovaMethodHandler<AG, Args>, config: SSEHookConfig = {}) => {
+  const { immediate = false } = config;
+
+  const loading = ref(false);
+  const data = shallowRef<{ event: string; data: Data }[]>([]);
+  const error = shallowRef();
+  const readyState = ref<SSEHookReadyState>(SSEHookReadyState.CLOSED);
+  const method = shallowRef<Method<AG>>();
+
+  const decoder = new TextDecoder();
+  const transform = (line: string, method: Method) => {
+    const [_, event, content] = line.match(/([^:]*):\s*(.*)/) ?? [];
+    return { event, data: JSON.parse(content.trim()) };
+  };
+
+  const close = async () => {
+    method.value?.abort();
+    readyState.value = SSEHookReadyState.CLOSED;
+    error.value = false;
+    loading.value = false;
+  };
+  const send = async (...args: [...Args]) => {
+    await close();
+    loading.value = true;
+    readyState.value = SSEHookReadyState.CONNECTING;
+    method.value = getHandlerMethod(handler, args);
+    const alovaEvent = { method: method.value, args: [...args] };
+    try {
+      const responded = await method.value.send();
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      //@ts-ignore
+
+      data.value = [];
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      //@ts-ignore
+      const body = responded instanceof Response ? responded.body : responded;
+      loading.value = false;
+      readyState.value = SSEHookReadyState.OPEN;
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      //@ts-ignore
+      for (const fn of eventOpen) fn(alovaEvent);
+
+      let last = '';
+      for await (const value of body) {
+        console.group('log: 读取流', value);
+        const lines = decoder.decode(value, { stream: true }).split('\n');
+        for (let line of lines) {
+          if (!line.trim()) continue;
+          if (last) [line, last] = [last + line, ''];
+
+          try {
+            const result = method.value?.meta?.sseTransform?.(line, method.value!) ?? transform(line, method.value);
+            console.log('[成功]: 读取行', line, '结果: ', result);
+            if (Array.isArray(result)) {
+              data.value.push(...result);
+              // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+              //@ts-ignore
+              for (const item of result) emit(item.event === 'data' ? 'message' : item.event, { ...alovaEvent, args, data: item.data });
+            } else {
+              data.value.push(result);
+              // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+              //@ts-ignore
+              emit(result.event === 'data' ? 'message' : result.event, { ...alovaEvent, args, data: result.data });
+            }
+          } catch (e) {
+            last = line;
+            console.log('[失败]: 读取行', line);
+          }
+        }
+        console.groupEnd();
+      }
+      triggerRef(data);
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      //@ts-ignore
+      for (const fn of eventSuccess) fn({ ...alovaEvent, data: data.value, fromCache: false });
+      return data.value;
+    } catch (e) {
+      console.log(e, 'log-->');
+      error.value = e;
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      //@ts-ignore
+      for (const fn of eventError) fn({ ...alovaEvent, error: error.value });
+      throw error;
+    } finally {
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      //@ts-ignore
+      for (const fn of eventComplete) fn({ ...alovaEvent, error: error.value, data: data.value, status: error.value ? 'error' : 'success' });
+      loading.value = false;
+      readyState.value = SSEHookReadyState.CLOSED;
+    }
+  };
+
+  const eventMap = new Map<string, Set<SSEOnMessageTrigger<any, AG, Args>>>();
+  const on = <T extends Data>(type: string, fn: SSEOnMessageTrigger<T, AG, Args>) => {
+    if (!eventMap.has(type)) eventMap.set(type, new Set());
+    eventMap.get(type)?.add(fn);
+    return () => eventMap.get(type)?.delete(fn);
+  };
+  const emit = <T extends Data>(type: string, event: AlovaSSEMessageEvent<T, AG, Args>) => {
+    const set = eventMap.get(type) ?? [];
+    for (const fn of set) fn(event);
+  };
+
+  const onMessage = <T extends Data>(fn: SSEOnMessageTrigger<T, AG, Args>) => {
+    on('message', fn);
+  };
+
+  const eventOpen = new Set<SSEOnOpenTrigger<AG, Args>>();
+  const onOpen = (fn: SSEOnOpenTrigger<AG, Args>) => {
+    eventOpen.add(fn);
+    return exposure;
+  };
+
+  const eventSuccess = new Set<(event: AlovaSuccessEvent<AG, Args>) => void>();
+  const eventError = new Set<(event: AlovaErrorEvent<AG, Args>) => void>();
+  const eventComplete = new Set<(event: AlovaCompleteEvent<AG, Args>) => void>();
+  const onSuccess = (fn: (event: AlovaSuccessEvent<AG, Args>) => void) => {
+    eventSuccess.add(fn);
+    return exposure;
+  };
+  const onError = (fn: (event: AlovaErrorEvent<AG, Args>) => void) => {
+    eventError.add(fn);
+    return exposure;
+  };
+  const onComplete = (fn: (event: AlovaCompleteEvent<AG, Args>) => void) => {
+    eventComplete.add(fn);
+    return exposure;
+  };
+
+  tryOnMounted(() => {
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    //@ts-ignore
+    if (immediate) return send();
+  });
+
+  tryOnUnmounted(() => {
+    eventMap.clear();
+    eventSuccess.clear();
+    eventError.clear();
+    eventComplete.clear();
+    eventOpen.clear();
+  });
+
+  const exposure = {
+    readyState,
+    source: method,
+    data,
+    loading,
+    onSuccess,
+    onError,
+    onComplete,
+    send,
+    close,
+
+    on,
+    onOpen,
+    onMessage,
+  };
+  return exposure;
+};
+
+export const getHandlerMethod = (methodHandler: Method | AlovaMethodHandler, args: any[] = []) => {
+  return typeof methodHandler === 'function' ? methodHandler(...args) : methodHandler;
+};

+ 20 - 0
src/request/types.ts

@@ -0,0 +1,20 @@
+import type { AlovaGenerics, AlovaOptions, Method, RespondedHandler, ResponseCompleteHandler, ResponseErrorHandler } from 'alova';
+
+export interface ResponseBody<T> {
+  code: number;
+  data: T;
+  message?: string;
+}
+
+
+type CustomOptions = 'requestAdapter' | 'beforeRequest' | 'responded';
+export type AFG = AlovaGenerics<Response, ResponseBody<Date>>;
+
+export interface AlovaFactoryOptions<AG extends AFG = AFG> extends Omit<AlovaOptions<AG>, CustomOptions> {
+  beforeRequest?: (method: Method<AG>) => void | Promise<void>;
+  responded?: {
+    onSuccess?: RespondedHandler<AG>;
+    onError?: ResponseErrorHandler<AG>;
+    onComplete?: ResponseCompleteHandler<AG>;
+  };
+}

+ 11 - 2
src/router/index.ts

@@ -1,8 +1,17 @@
-import { createRouter, createWebHistory } from 'vue-router';
+import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
 import routes from './routes';
 import routes from './routes';
 const router = createRouter({
 const router = createRouter({
-  history: createWebHistory(import.meta.env.BASE_URL),
+  history:
+    import.meta.env.SIX_ROUTER_HISTORY === 'hash'
+      ? createWebHashHistory(import.meta.env.VITE_BASE)
+      : createWebHistory(import.meta.env.VITE_BASE),
   routes,
   routes,
 });
 });
 
 
+router.beforeEach((to, from, next) => {
+  // 修改标题
+  document.title = to.meta.title ?? import.meta.env.SIX_APP_TITLE;
+  next();
+})
+
 export default router;
 export default router;

+ 10 - 2
src/router/routes.ts

@@ -5,6 +5,14 @@ export default [
   {
   {
     path: '/',
     path: '/',
     component: PageLayout,
     component: PageLayout,
-    children: [],
-  },
+    children: [
+      {
+        path: 'chat',
+        component: () => import('@/modules/chat/index.vue'),
+        children: [
+          { path: 'guide', component: () => import('@/modules/chat/HospitalGuide.vue'), meta: { title: '智能导诊' } }
+        ]
+      }
+    ]
+  }
 ] satisfies RouteRecordRaw[];
 ] satisfies RouteRecordRaw[];

+ 46 - 0
src/stores/guide.store.ts

@@ -0,0 +1,46 @@
+import type { MessageModel } from '@/modules/chat/types';
+
+export interface GuideUser {
+  role: '1' | '2';
+  gender?: '1' | '2';
+  age?: string;
+}
+
+export const useGuideStore = defineStore(
+  'guide',
+  () => {
+    const session = ref<string>(Date.now().toString());
+    const user = ref<GuideUser>({ role: '1' });
+    const messages = shallowReactive<MessageModel[]>([]);
+
+    const register = (id: string, data?: Partial<GuideUser>) => {
+      session.value = id;
+      user.value = { role: '1', ...data };
+      messages.length = 0;
+    };
+
+    const updateUser = (data: Partial<GuideUser>) => {
+      for (const entry of Object.entries(data)) {
+        const key = entry[0];
+        user.value[<keyof GuideUser>key] = <never>entry[1];
+      }
+    };
+
+    function $reset() {
+      register('');
+    }
+
+    return { session, user, messages, register, updateUser, $reset };
+  },
+  {
+    persist: {
+      storage: sessionStorage,
+      omit: ['messages'],
+    },
+  },
+);
+
+export const getGuideRoleLabel = (value: GuideUser['role'] | GuideUser) => {
+  const role = typeof value === 'string' ? value : value?.role;
+  return ({ 1: '您的', 2: '他的' } as const)[role];
+};

+ 2 - 0
src/stores/index.ts

@@ -11,3 +11,5 @@ const pinia = createPinia();
 pinia.use(persistedState);
 pinia.use(persistedState);
 
 
 export default pinia;
 export default pinia;
+
+export { useGuideStore, getGuideRoleLabel } from './guide.store';

+ 8 - 0
src/tools/file.ts

@@ -0,0 +1,8 @@
+export function blob2Base64(blob: Blob | File) {
+  const reader = new FileReader();
+  reader.readAsDataURL(blob);
+  return new Promise<string>((resolve, reject) => {
+    reader.addEventListener("load", () => { resolve(<string>reader.result); }, { once: true });
+    reader.addEventListener("error", reject);
+  });
+}

+ 5 - 0
src/tools/index.ts

@@ -1 +1,6 @@
+export * from './file.ts'
 export * from './random.ts';
 export * from './random.ts';
+export * from './url.ts'
+export * from './string.ts';
+
+export { default as markdown, markdownit } from './markdown.ts'

+ 6 - 0
src/tools/markdown.ts

@@ -0,0 +1,6 @@
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-nocheck
+import markdownit from 'markdown-it';
+export default markdownit({ html: true, breaks: true });
+
+export { markdownit };

+ 3 - 0
src/tools/string.ts

@@ -0,0 +1,3 @@
+export function camelToUnderline(value: string): string {
+  return value.replace(/([A-Z])/g, '_$1').toLowerCase();
+}

+ 25 - 0
src/tools/url.ts

@@ -0,0 +1,25 @@
+export function getURLSearchParams(value?: string): URLSearchParams {
+  value ??= `${location.search}&${location.hash.split('?')[1] || ''}`;
+  return new URLSearchParams(value);
+}
+
+export function getClientURL(value: string, origin?: string) {
+  if (!value || /^https?:\/\//.test(value)) return value;
+  if (value.startsWith('~')) value = value.slice(1);
+  return `${location.origin}${value}`;
+}
+
+export function isFrameOpen() {
+  return window.parent !== window
+}
+
+export function isWechat() {
+  const userAgent = navigator.userAgent;
+  return userAgent.indexOf('miniprogram') > -1 && ( userAgent.indexOf('wx') || userAgent.indexOf('wechat') );
+}
+
+export function getBackReferrerUrl() {
+  const back = getURLSearchParams().get('back');
+  if (back?.startsWith('http')) return back;
+  if (!isFrameOpen() && document.referrer) return document.referrer;
+}

+ 6 - 0
tsconfig.app.json

@@ -4,7 +4,13 @@
   "exclude": ["src/**/__tests__/*"],
   "exclude": ["src/**/__tests__/*"],
   "compilerOptions": {
   "compilerOptions": {
     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+    "lib": [
+      "ES2022",
+      "ES2022.Array",
 
 
+      "DOM",
+      "DOM.Iterable",
+    ],
     "paths": {
     "paths": {
       "@/*": ["./src/*"]
       "@/*": ["./src/*"]
     }
     }

+ 22 - 3
vite.config.ts

@@ -10,6 +10,7 @@ import Components from 'unplugin-vue-components/vite';
 import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
 import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
 import { AntDesignXVueResolver } from 'ant-design-x-vue/resolver';
 import { AntDesignXVueResolver } from 'ant-design-x-vue/resolver';
 import { VantResolver } from '@vant/auto-import-resolver';
 import { VantResolver } from '@vant/auto-import-resolver';
+import { analysis, chat } from './src/request';
 
 
 // https://vite.dev/config/
 // https://vite.dev/config/
 export default defineConfig((configEnv) => {
 export default defineConfig((configEnv) => {
@@ -41,9 +42,27 @@ export default defineConfig((configEnv) => {
     },
     },
     server: {
     server: {
       host: true,
       host: true,
-      open: true,
-      port: 5173,
-      proxy: {},
+      port: 8080,
+      proxy: {
+        '/api/dz': {
+          changeOrigin: true,
+          rewrite: (path) => path.replace(/^\/api\/dz/, '/dz'),
+          target: `https://wx.hzliuzhi.com:4433`,
+          ws: true,
+        },
+        '/api/analysis': {
+          changeOrigin: true,
+          rewrite: (path) => path.replace(/^\/api\/analysis/, ''),
+          target: `https://tongue.hzliuzhi.com:62006`,
+          ws: true,
+        },
+        '/api/chat': {
+          changeOrigin: true,
+          rewrite: (path) => path.replace(/^\/api\/chat/, ''),
+          target: `https://dev.hzliuzhi.com:62006`,
+          ws: true,
+        },
+      },
     },
     },
   };
   };
 });
 });