Explorar o código

feat: 完成项目基本布局

sunxiao hai 3 días
pai
achega
5375bd3fd7
Modificáronse 33 ficheiros con 2211 adicións e 310 borrados
  1. 16 1
      package-lock.json
  2. 3 1
      package.json
  3. 34 0
      src/api/chat.js
  4. 23 0
      src/api/water.js
  5. 204 0
      src/components/RecodeItem/index.vue
  6. 78 15
      src/components/chat/ChatAnswer.vue
  7. 1 0
      src/components/chat/ChatAsk.vue
  8. 332 28
      src/components/chat/ChatInput.vue
  9. 227 0
      src/components/chat/ChatLoading.vue
  10. 34 10
      src/components/chat/ChatTaskGroup.vue
  11. 7 1
      src/components/chat/ChatWelcome.vue
  12. 0 3
      src/components/chat/icon-send.svg
  13. 0 1
      src/components/layout/BaseHeaderTools.vue
  14. 22 2
      src/components/layout/BaseNavBar.vue
  15. 31 33
      src/components/layout/BasePublicLayout.vue
  16. 7 2
      src/composables/useRecommend.js
  17. 44 9
      src/pages.json
  18. 295 0
      src/pages/analyse/water/details.vue
  19. 53 0
      src/pages/analyse/water/index.vue
  20. 165 0
      src/pages/answer/history.vue
  21. 194 69
      src/pages/answer/index.vue
  22. 17 2
      src/pages/login/index.vue
  23. 159 0
      src/pages/user/index.vue
  24. 2 2
      src/stores/modules/chatStore.js
  25. 1 1
      src/stores/modules/userStore.js
  26. 4 4
      src/uni_modules/zero-markdown-view/components/mp-html/highlight/config.js
  27. 5 5
      src/uni_modules/zero-markdown-view/components/mp-html/mp-html.vue
  28. 104 104
      src/uni_modules/zero-markdown-view/components/mp-html/node/node.vue
  29. 4 4
      src/uni_modules/zero-markdown-view/components/zero-markdown-view/zero-markdown-view.vue
  30. 38 0
      src/utils/enum.js
  31. 92 0
      src/utils/format.js
  32. 7 7
      src/utils/https.js
  33. 8 6
      src/utils/streamRequest.js

+ 16 - 1
package-lock.json

@@ -25,11 +25,13 @@
         "@dcloudio/uni-quickapp-webview": "3.0.0-4020420240722002",
         "@dcloudio/uni-ui": "^1.5.6",
         "crypto-js": "^4.2.0",
+        "dayjs": "^1.11.13",
         "pinia": "^2.2.0",
         "pinia-plugin-persistedstate": "^3.2.1",
         "sass": "^1.77.8",
         "vue": "^3.4.31",
-        "vue-i18n": "^9.1.9"
+        "vue-i18n": "^9.1.9",
+        "z-paging": "^2.8.6"
       },
       "devDependencies": {
         "@dcloudio/types": "^3.4.8",
@@ -5898,6 +5900,11 @@
         "node": ">=10"
       }
     },
+    "node_modules/dayjs": {
+      "version": "1.11.13",
+      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz",
+      "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
+    },
     "node_modules/debug": {
       "version": "4.3.5",
       "resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.5.tgz",
@@ -12018,6 +12025,14 @@
       "engines": {
         "node": ">=10"
       }
+    },
+    "node_modules/z-paging": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmmirror.com/z-paging/-/z-paging-2.8.6.tgz",
+      "integrity": "sha512-UdmHuu08p0TFQQX+JVW5vj2/VKyBb0g3NWJkCjIzWcCwdWQjaNOCOjodISvfpriDdjB2IvFo0cdWc6XYkyg3DA==",
+      "engines": {
+        "HBuilderX": "^3.0.7"
+      }
     }
   }
 }

+ 3 - 1
package.json

@@ -59,11 +59,13 @@
     "@dcloudio/uni-quickapp-webview": "3.0.0-4020420240722002",
     "@dcloudio/uni-ui": "^1.5.6",
     "crypto-js": "^4.2.0",
+    "dayjs": "^1.11.13",
     "pinia": "^2.2.0",
     "pinia-plugin-persistedstate": "^3.2.1",
     "sass": "^1.77.8",
     "vue": "^3.4.31",
-    "vue-i18n": "^9.1.9"
+    "vue-i18n": "^9.1.9",
+    "z-paging": "^2.8.6"
   },
   "devDependencies": {
     "@dcloudio/types": "^3.4.8",

+ 34 - 0
src/api/chat.js

@@ -17,4 +17,38 @@ export const chatApi = {
     method: 'GET',
     url: '/front/bigModel/chat/generateSessionId'
   }),
+
+  /**
+   * 点赞 or 取消点赞
+   */
+  putIsSatisfiedAnswer: data => http({
+    method: 'PUT',
+    url: '/front/bigModel/chat/isSatisfiedAnswer',
+    data
+  }),
+
+  /**
+   * 点赞 or 取消点赞
+   */
+  getQaHistoryList: () => http({
+    method: 'GET',
+    url: '/front/bigModel/qa/wx/list',
+  }),
+  
+  /**
+   * 通过sessionId获取某个用户的问答列表
+   */
+  getAnswerHistoryDetail: (data) => http({
+    method: 'GET',
+    url: '/front/bigModel/qa/qaListBySessionId',
+    data
+  }),
+
+  /**
+   * 获取助手列表
+   */
+  getHelperList: () => http({
+    method: 'GET',
+    url: '/front/bigModel/agentAssistant/list'
+  })
 }

+ 23 - 0
src/api/water.js

@@ -0,0 +1,23 @@
+
+
+import { http } from '@/utils/https';
+
+export const waterApi = {
+  /**
+   * 水质报警列表
+   */
+  getWaterWarningList: data => http({
+    method: 'GET',
+    url: '/front/bigModel/warning/pageList',
+    data
+  }),
+
+  /**
+   * 预警详情
+   */
+  getWaringDetails: data => http({
+    method: 'GET',
+    url: '//front/bigModel/warning/qaDetailByWarningId/' + data
+  }),
+
+}

+ 204 - 0
src/components/RecodeItem/index.vue

@@ -0,0 +1,204 @@
+<script setup>
+import { computed } from 'vue';
+import { truncateDecimals } from '@/utils/format';
+
+const props = defineProps({
+  item: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const emit = defineEmits(['on-click']);
+
+const warningType = computed(() => {
+  const item = props.item;
+  const waterEnum = {
+    '0': {
+      label: '报警中',
+      cls: 'tips_warning'
+    },
+    '1': {
+      label: '用户关闭',
+      cls: 'tips_success'
+    },
+    '2': {
+      label: '系统关闭',
+      cls: 'tips_close'
+    },
+    '3': {
+      label: '应急处理中',
+      cls: 'tips_warning'
+    }
+  }
+  const earlyEnum = {
+    '0': {
+      label: '预警中',
+      cls: 'tips_warning'
+    },
+    '2': {
+      label: '已完成',
+      cls: 'tips_success'
+    },
+  }
+
+  switch(item.type) {
+    case 0:
+      return waterEnum[item.status + ''];
+    case 1:
+      return waterEnum[item.status + ''];
+    case 2:
+      return earlyEnum[item.status + ''];
+    default:
+      return {
+        label: '未知',
+        cls: 'tips_close'
+      }
+  }
+})
+
+const dataSources = computed(() => {
+  const item = props.item;
+  if (item.type == 0) {
+    return [
+      { label: '报警时间', value: item.time },
+      { label: '报警值',  value: truncateDecimals(item.warningVal), type: 'wraning', unit: 'mg/L' },
+      { label: '报警类型', value: item.symbolDesc },
+      { label: '持续时间', value: item.counts + "小时" },
+    ]
+  }
+  if (item.type == 1) {
+    return [
+      { label: '报警时间', value: item.time },
+      { label: '报警值', value: item.warningValStr, type: 'wraning' },
+      { label: '报警次数', value: item.counts },
+    ]
+  }
+  if (item.type == 2) {
+    return [
+      { label: '预警时间', value: item.updateTime },
+      { label: '超标时间', value: item.time },
+      { label: '现在值', value: Number(item?.warningVal?.toFixed(2)) ?? '', unit: 'mg/L' },
+      { label: '预测值', value: Number(item?.forecastVal?.toFixed(2)) ?? '', type: 'wraning', unit: 'mg/L' },
+      { label: '标准值', value: item.designVal, unit: 'mg/L' }
+    ]
+  }
+});
+
+const handleEmitParent = () => {
+  emit('on-click', props.item)
+};
+</script>
+
+<template>
+  <view class="warning-item" @click="handleEmitParent">
+    <view class="title">
+      <view class="left">
+        <view class="name">{{ item.reason }}</view>
+        <view class="status">{{ warningType?.label }}</view>
+      </view>
+      <view class="right">
+        <view class="btn">立即处理</view>
+        <uni-icons type="right" size="12" color="#2454FF"></uni-icons>
+      </view>
+    </view>
+    <view class="content">
+      <view class="info" v-for="(item, index) in dataSources" :key="index">
+        <view class="label">{{ item.label }}</view>
+        <view class="value">
+          <text>{{ item.value }} {{ item.unit }}</text>
+          <TheSvgIcon class="icon" src="icon-go-up" size="28" v-if="item.type"></TheSvgIcon>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<style lang="scss" scoped>
+.warning-item {
+  .title {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: 102rpx;
+    padding: 0 28rpx;
+    padding-bottom: 18rpx;
+    border-radius: 40rpx 40rpx 0px 0px;
+    background: linear-gradient(180deg, #FFF 50.98%, #F6F6F6 94.34%);
+    box-sizing: border-box;
+
+    .left {
+      display: flex;
+      align-items: center;
+
+      .name {
+        color: #212121;
+        font-size: 28rpx;
+        font-weight: 500;
+        margin-right: 8px;
+      }
+
+      .status {
+        border-radius: 8rpx;
+        padding: 2rpx 10rpx;
+        background: #FFF0ED;
+        color: #FD5D4D;
+        font-size: 20rpx;
+        font-style: normal;
+        font-weight: 400;
+      }
+    }
+
+    .right {
+      display: flex;
+      align-items: center;
+      color: #2454FF;
+      font-size: 24rpx;
+
+      .btn {
+        margin-right: 6rpx;
+      }
+    }
+  }
+
+  .content {
+    padding: 30rpx 50rpx;
+    margin-top: -18rpx;
+    border-radius: 20px;
+    background: #FFF;
+    box-sizing: border-box;
+
+    .info {
+      display: flex;
+      align-items: center;
+      color: #212121;
+      font-size: 28rpx;
+      font-weight: 400;
+
+      .label {
+        width: 120rpx;
+        margin-right: 116rpx;
+        color: #8C9091;
+      }
+
+      .value {
+        display: flex;
+        align-items: center;
+        color: #4F4F4F;
+
+        .icon {
+          margin-left: 8rpx;
+        }
+      }
+
+      &:not(:last-child) {
+        margin-bottom: 16rpx;
+      }
+    }
+  }
+
+  &:not(:last-child) {
+    margin-bottom: 32rpx;
+  }
+}
+</style>

+ 78 - 15
src/components/chat/ChatAnswer.vue

@@ -1,5 +1,6 @@
 <script setup>
-import { ref } from 'vue';
+import { computed, unref } from 'vue';
+import { chatApi } from '@/api/chat';
 
 const props = defineProps({
   id: {
@@ -37,27 +38,86 @@ const props = defineProps({
   isVisibleResetBtn: {
     type: Boolean,
     default: false
+  },
+  isRound: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const emit = defineEmits(['on-click-icon', 'on-click-stop', 'on-click-reset']);
+
+const markdownContent = computed(() => {
+  if (!props.content) {
+    return
+  }
+  let htmlString = ''
+  // 判断markdown中代码块标识符的数量是否为偶数
+  if (props.content.split("```").length % 2) {
+    let content = props.content
+    if (content[content.length - 1] != '\n') {
+      content += '\n'
+    }
+    htmlString = content
+  } else {
+    htmlString = props.content
   }
+  return htmlString
 })
+
+// icon - 重新生成
+const handleChatReset = () => emit('on-click-reset');
+
+// icon - 点赞图标
+const handlLeToggleLike = async (state) => {
+  const { id } = unref(props);
+  const isSatisfied = props.isSatisfied === state ? 2 : state;
+  const params = { id, isSatisfied };
+
+  await chatApi.putIsSatisfiedAnswer(params);
+
+  uni.showToast({ title: isSatisfied < 2 ? '感谢您的反馈' : '已取消反馈', icon: 'none', duration: 2000 });
+
+  emit('on-click-icon', params);
+}
+
+// icon - 复制
+const handleCopy = () => {
+  uni.setClipboardData({
+    data: props.content,
+    success: () => {
+      uni.showToast({ title: '复制成功', icon: 'success', duration: 2000 });
+    },
+    fail: () => {
+      uni.showToast({ title: '复制失败', icon: 'none', duration: 2000 });
+    }
+  });
+}
 </script>
 
 <template>
-  <view class="answer-container">
+  <view :class="['answer-container', { 'round': isRound }]">
     <view class="answer-inner">
-      <view class="markdown-wrap">
-        <zero-markdown-view :markdown="content"></zero-markdown-view>
+
+      <ChatLoading :delayLoading="delayLoading" :loading="loading">{{ loadingText }}</ChatLoading>
+
+      <view class="markdown-wrap" v-show="content">
+        <zero-markdown-view :markdown="markdownContent"></zero-markdown-view>
       </view>
-      <view class="tools-wrap">
+
+      <view class="tools-wrap" v-if="toggleVisibleIcons && !loading">
         <view class="btn-stream">
           <TheSvgIcon class="icon" src="icon-refresh" size="26"></TheSvgIcon>
-          <text>重新生成</text>
+          <text @click="handleChatReset">重新生成</text>
         </view>
         <view class="btn-group">
-          <TheSvgIcon class="icon" src="icon-like-yes" size="28"></TheSvgIcon>
+          <TheSvgIcon class="icon" :src="isSatisfied == 1 ? 'icon-like-yes_active' : 'icon-like-yes'" size="28"
+            @on-click="handlLeToggleLike(1)"></TheSvgIcon>
           <view class="line"></view>
-          <TheSvgIcon class="icon" src="icon-like-no" size="28"></TheSvgIcon>
+          <TheSvgIcon class="icon" :src="isSatisfied == 0 ? 'icon-like-no_active' : 'icon-like-no'" size="28"
+            @on-click="handlLeToggleLike(0)"></TheSvgIcon>
           <view class="line"></view>
-          <TheSvgIcon class="icon" src="icon-like-copy" size="28"></TheSvgIcon>
+          <TheSvgIcon class="icon" src="icon-like-copy" size="28" @on-click="handleCopy"></TheSvgIcon>
         </view>
       </view>
     </view>
@@ -66,15 +126,9 @@ const props = defineProps({
 
 <style lang="scss" scoped>
 .answer-container {
-  padding-right: 64rpx;
-  padding-left: 20rpx;
 
   .answer-inner {
     padding: 24rpx;
-    border-radius: 4rpx 30rpx 30rpx 30rpx;
-    background: #fff;
-
-    .markdown-wrap {}
 
     .tools-wrap {
       padding-top: 20rpx;
@@ -107,4 +161,13 @@ const props = defineProps({
     }
   }
 }
+
+.round {
+  padding-right: 64rpx;
+  padding-left: 20rpx;
+  .answer-inner {
+    border-radius: 4rpx 30rpx 30rpx 30rpx;
+    background: #fff;
+  }
+}
 </style>

+ 1 - 0
src/components/chat/ChatAsk.vue

@@ -38,6 +38,7 @@ defineProps({
     color: #fff;
     .ask-content {
       display: inline-flex;
+      min-width: 40rpx;
       padding: 20rpx 24rpx;
       border-radius: 30rpx 30rpx 4rpx 30rpx;
       background: linear-gradient(88deg, #2A67F8 4.95%, #4892FF 93.07%);

+ 332 - 28
src/components/chat/ChatInput.vue

@@ -1,46 +1,52 @@
 <script setup>
-import { ref, unref } from 'vue';
+import { unref } from 'vue';
 
-const emit = defineEmits(['on-submit']); 
-// const modelInpValue = defineModel();
+const modelInpValue = defineModel();
+const emit = defineEmits(['on-submit']);
+const modelLoading = defineModel('loading');
 
-const inpVal = ref('');
+// 提交问题
+const onSubmit = () => {
+  const val = unref(modelInpValue);
 
+  if (modelLoading.value) {
+    return uni.showToast({ title: '当前对话进行中', duration: 3000, icon: 'none' });
+  }
 
-// 提交问题
-const onEmitSubmit = () => {
-  const val = unref(inpVal);
-  // if (!val) {
-  //   return uni.showToast({ title: '请输入您的问题或需求', duration: 3000, icon: 'none' });
-  // }
+  if (!val) {
+    return uni.showToast({ title: '请输入您的问题或需求', duration: 3000, icon: 'none' });
+  }
 
-  // if (val.length > 2000) {
-  //   return uni.showToast({ title: '问题限制2000个字以内', duration: 3000, icon: 'none' });
-  // }
+  if (val.length > 2000) {
+    return uni.showToast({ title: '问题限制2000个字以内', duration: 3000, icon: 'none' });
+  }
+
+  modelInpValue.value = '';
 
-  emit('on-submit', { showVal: val,  question: val, selectedOption: {} });
+  emit('on-submit', { showVal: val, question: val });
 };
 </script>
 
 <template>
   <view class="chat-inp-container">
     <view class="chat-inp-inner">
-      <div class="voice-btn">
+      <view class="voice-btn">
         <TheSvgIcon class="icon" src="icon-voice" size="42"></TheSvgIcon>
-      </div>
+      </view>
       <view class="inp-inner">
-        <textarea
-          v-model.trim="inpVal"
-          auto-height
-          :maxlength="2000"
-          class="chat-inp"
-          placeholder="输入您的问题或需求"
-          placeholder-style="color:#9A9A9A"
-        >
+        <textarea v-model.trim="modelInpValue" auto-height :maxlength="2000" class="chat-inp" placeholder="输入您的问题或需求"
+          placeholder-style="color:#9A9A9A">
         </textarea>
       </view>
-      <view class="send-btn" @click="onEmitSubmit">
-        <TheSvgIcon class="icon" src="icon-send-plane" size="30"></TheSvgIcon>
+      <view class="send-btn" @click="onSubmit">
+        <TheSvgIcon class="icon" src="icon-send-plane" size="30" v-show="!modelLoading"></TheSvgIcon>
+        <view class="la-ball-running-dots la-sm" v-show="modelLoading">
+          <view class="item"></view>
+          <view class="item"></view>
+          <view class="item"></view>
+          <view class="item"></view>
+          <view class="item"></view>
+        </view>
       </view>
     </view>
   </view>
@@ -55,7 +61,6 @@ const onEmitSubmit = () => {
     display: flex;
     padding: 26rpx 28rpx;
     align-items: flex-end;
-    // align-items: center;
     border-radius: 32rpx;
     background: #FFF;
     box-shadow: 0px 8rpx 16rpx 2rpx rgba(172, 200, 224, 0.20);
@@ -93,8 +98,307 @@ const onEmitSubmit = () => {
       width: 56rpx;
       height: 56rpx;
       border-radius: 100%;
-      background: #879AAF;
+      background: #212121;
+      .icon {
+        transition: all 0.3s ease-in-out;
+      }
     }
   }
 }
+
+.la-ball-running-dots,
+.la-ball-running-dots>.item {
+  position: relative;
+  -webkit-box-sizing: border-box;
+  -moz-box-sizing: border-box;
+  box-sizing: border-box;
+}
+
+.la-ball-running-dots {
+  // display: block;
+  font-size: 0;
+  color: #fff;
+}
+
+.la-ball-running-dots.la-dark {
+  color: #333;
+}
+
+.la-ball-running-dots>.item {
+  display: inline-block;
+  float: none;
+  background-color: currentColor;
+  border: 0 solid currentColor;
+}
+
+.la-ball-running-dots {
+  width: 10px;
+  height: 10px;
+}
+
+.la-ball-running-dots>.item {
+  position: absolute;
+  width: 20rpx;
+  height: 20rpx;
+  margin-left: -25px;
+  border-radius: 100%;
+  -webkit-animation: ball-running-dots-animate 2s linear infinite;
+  -moz-animation: ball-running-dots-animate 2s linear infinite;
+  -o-animation: ball-running-dots-animate 2s linear infinite;
+  animation: ball-running-dots-animate 2s linear infinite;
+}
+
+.la-ball-running-dots>.item:nth-child(1) {
+  -webkit-animation-delay: 0s;
+  -moz-animation-delay: 0s;
+  -o-animation-delay: 0s;
+  animation-delay: 0s;
+}
+
+.la-ball-running-dots>.item:nth-child(2) {
+  -webkit-animation-delay: -.4s;
+  -moz-animation-delay: -.4s;
+  -o-animation-delay: -.4s;
+  animation-delay: -.4s;
+}
+
+.la-ball-running-dots>.item:nth-child(3) {
+  -webkit-animation-delay: -.8s;
+  -moz-animation-delay: -.8s;
+  -o-animation-delay: -.8s;
+  animation-delay: -.8s;
+}
+
+.la-ball-running-dots>.item:nth-child(4) {
+  -webkit-animation-delay: -1.2s;
+  -moz-animation-delay: -1.2s;
+  -o-animation-delay: -1.2s;
+  animation-delay: -1.2s;
+}
+
+.la-ball-running-dots>.item:nth-child(5) {
+  -webkit-animation-delay: -1.6s;
+  -moz-animation-delay: -1.6s;
+  -o-animation-delay: -1.6s;
+  animation-delay: -1.6s;
+}
+
+.la-ball-running-dots>.item:nth-child(6) {
+  -webkit-animation-delay: -2s;
+  -moz-animation-delay: -2s;
+  -o-animation-delay: -2s;
+  animation-delay: -2s;
+}
+
+.la-ball-running-dots>.item:nth-child(7) {
+  -webkit-animation-delay: -2.4s;
+  -moz-animation-delay: -2.4s;
+  -o-animation-delay: -2.4s;
+  animation-delay: -2.4s;
+}
+
+.la-ball-running-dots>.item:nth-child(8) {
+  -webkit-animation-delay: -2.8s;
+  -moz-animation-delay: -2.8s;
+  -o-animation-delay: -2.8s;
+  animation-delay: -2.8s;
+}
+
+.la-ball-running-dots>.item:nth-child(9) {
+  -webkit-animation-delay: -3.2s;
+  -moz-animation-delay: -3.2s;
+  -o-animation-delay: -3.2s;
+  animation-delay: -3.2s;
+}
+
+.la-ball-running-dots>.item:nth-child(10) {
+  -webkit-animation-delay: -3.6s;
+  -moz-animation-delay: -3.6s;
+  -o-animation-delay: -3.6s;
+  animation-delay: -3.6s;
+}
+
+.la-ball-running-dots.la-sm {
+  width: 8rpx;
+  height: 8rpx;
+}
+
+.la-ball-running-dots.la-sm>.item {
+  width: 8rpx;
+  height: 8rpx;
+  margin-left: -12px;
+}
+
+.la-ball-running-dots.la-2x {
+  width: 20px;
+  height: 20px;
+}
+
+.la-ball-running-dots.la-2x>.item {
+  width: 20px;
+  height: 20px;
+  margin-left: -50px;
+}
+
+.la-ball-running-dots.la-3x {
+  width: 30px;
+  height: 30px;
+}
+
+.la-ball-running-dots.la-3x>.item {
+  width: 30px;
+  height: 30px;
+  margin-left: -75px;
+}
+
+/*
+ * Animation
+ */
+@-webkit-keyframes ball-running-dots-animate {
+
+  0%,
+  100% {
+    width: 100%;
+    height: 100%;
+    -webkit-transform: translateY(0) translateX(500%);
+    transform: translateY(0) translateX(500%);
+  }
+
+  80% {
+    -webkit-transform: translateY(0) translateX(0);
+    transform: translateY(0) translateX(0);
+  }
+
+  85% {
+    width: 100%;
+    height: 100%;
+    -webkit-transform: translateY(-125%) translateX(0);
+    transform: translateY(-125%) translateX(0);
+  }
+
+  90% {
+    width: 200%;
+    height: 75%;
+  }
+
+  95% {
+    width: 100%;
+    height: 100%;
+    -webkit-transform: translateY(-100%) translateX(500%);
+    transform: translateY(-100%) translateX(500%);
+  }
+}
+
+@-moz-keyframes ball-running-dots-animate {
+
+  0%,
+  100% {
+    width: 100%;
+    height: 100%;
+    -moz-transform: translateY(0) translateX(500%);
+    transform: translateY(0) translateX(500%);
+  }
+
+  80% {
+    -moz-transform: translateY(0) translateX(0);
+    transform: translateY(0) translateX(0);
+  }
+
+  85% {
+    width: 100%;
+    height: 100%;
+    -moz-transform: translateY(-125%) translateX(0);
+    transform: translateY(-125%) translateX(0);
+  }
+
+  90% {
+    width: 200%;
+    height: 75%;
+  }
+
+  95% {
+    width: 100%;
+    height: 100%;
+    -moz-transform: translateY(-100%) translateX(500%);
+    transform: translateY(-100%) translateX(500%);
+  }
+}
+
+@-o-keyframes ball-running-dots-animate {
+
+  0%,
+  100% {
+    width: 100%;
+    height: 100%;
+    -o-transform: translateY(0) translateX(500%);
+    transform: translateY(0) translateX(500%);
+  }
+
+  80% {
+    -o-transform: translateY(0) translateX(0);
+    transform: translateY(0) translateX(0);
+  }
+
+  85% {
+    width: 100%;
+    height: 100%;
+    -o-transform: translateY(-125%) translateX(0);
+    transform: translateY(-125%) translateX(0);
+  }
+
+  90% {
+    width: 200%;
+    height: 75%;
+  }
+
+  95% {
+    width: 100%;
+    height: 100%;
+    -o-transform: translateY(-100%) translateX(500%);
+    transform: translateY(-100%) translateX(500%);
+  }
+}
+
+@keyframes ball-running-dots-animate {
+
+  0%,
+  100% {
+    width: 100%;
+    height: 100%;
+    -webkit-transform: translateY(0) translateX(500%);
+    -moz-transform: translateY(0) translateX(500%);
+    -o-transform: translateY(0) translateX(500%);
+    transform: translateY(0) translateX(500%);
+  }
+
+  80% {
+    -webkit-transform: translateY(0) translateX(0);
+    -moz-transform: translateY(0) translateX(0);
+    -o-transform: translateY(0) translateX(0);
+    transform: translateY(0) translateX(0);
+  }
+
+  85% {
+    width: 100%;
+    height: 100%;
+    -webkit-transform: translateY(-125%) translateX(0);
+    -moz-transform: translateY(-125%) translateX(0);
+    -o-transform: translateY(-125%) translateX(0);
+    transform: translateY(-125%) translateX(0);
+  }
+
+  90% {
+    width: 200%;
+    height: 75%;
+  }
+
+  95% {
+    width: 100%;
+    height: 100%;
+    -webkit-transform: translateY(-100%) translateX(500%);
+    -moz-transform: translateY(-100%) translateX(500%);
+    -o-transform: translateY(-100%) translateX(500%);
+    transform: translateY(-100%) translateX(500%);
+  }
+}
 </style>

+ 227 - 0
src/components/chat/ChatLoading.vue

@@ -0,0 +1,227 @@
+<script setup>
+
+const props = defineProps({
+  loading: {
+    type: Boolean,
+    default: false
+  },
+  delayLoading: {
+    type: Boolean,
+    default: false
+  },
+})
+</script>
+
+<template>
+  <view class="chat-answer_loading" v-if="delayLoading">
+    <view class="loading-circus-wrapper">
+      <view class="loading-icon">
+        <view class="loading-circus">
+          <view class="la-ball-circus la-sm la-dark">
+            <view class="item"></view>
+            <view class="item"></view>
+            <view class="item"></view>
+            <view class="item"></view>
+            <view class="item"></view>
+          </view>
+        </view>
+      </view>
+      <text class="loading-text">
+        <slot></slot>
+      </text>
+    </view>
+  </view>
+</template>
+
+<style lang="scss" scoped>
+.loading-circus-wrapper {
+  display: flex;
+  align-items: center;
+
+  .loading-icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 68rpx;
+    height: 68rpx;
+    border: 1px solid #A8D3F1;
+    border-radius: 50%;
+  }
+
+  .loading-text {
+    padding: 0 20rpx; 
+    font-size: 28rpx;
+    font-weight: bold;
+    color: #666;
+  }
+}
+
+.la-ball-circus,
+.la-ball-circus>.item {
+  position: relative;
+  -webkit-box-sizing: border-box;
+  -moz-box-sizing: border-box;
+  box-sizing: border-box;
+}
+
+.la-ball-circus {
+  display: block;
+  font-size: 0;
+  color: #fff;
+}
+
+.la-ball-circus.la-dark {
+  color: #333;
+}
+
+.la-ball-circus>.item {
+  display: inline-block;
+  float: none;
+  background-color: #007aff;
+  border: 0 solid currentColor;
+}
+
+.la-ball-circus {
+  width: 16px;
+  height: 16px;
+}
+
+.la-ball-circus>.item {
+  position: absolute;
+  top: 0;
+  left: -100%;
+  display: block;
+  width: 16px;
+  width: 100%;
+  height: 16px;
+  height: 100%;
+  border-radius: 100%;
+  opacity: .5;
+  -webkit-animation: ball-circus-position 2.5s infinite cubic-bezier(.25, 0, .75, 1), ball-circus-size 2.5s infinite cubic-bezier(.25, 0, .75, 1);
+  -moz-animation: ball-circus-position 2.5s infinite cubic-bezier(.25, 0, .75, 1), ball-circus-size 2.5s infinite cubic-bezier(.25, 0, .75, 1);
+  -o-animation: ball-circus-position 2.5s infinite cubic-bezier(.25, 0, .75, 1), ball-circus-size 2.5s infinite cubic-bezier(.25, 0, .75, 1);
+  animation: ball-circus-position 2.5s infinite cubic-bezier(.25, 0, .75, 1), ball-circus-size 2.5s infinite cubic-bezier(.25, 0, .75, 1);
+}
+
+.la-ball-circus>.item:nth-child(1) {
+  -webkit-animation-delay: 0s, -.5s;
+  -moz-animation-delay: 0s, -.5s;
+  -o-animation-delay: 0s, -.5s;
+  animation-delay: 0s, -.5s;
+}
+
+.la-ball-circus>.item:nth-child(2) {
+  -webkit-animation-delay: -.5s, -1s;
+  -moz-animation-delay: -.5s, -1s;
+  -o-animation-delay: -.5s, -1s;
+  animation-delay: -.5s, -1s;
+}
+
+.la-ball-circus>.item:nth-child(3) {
+  -webkit-animation-delay: -1s, -1.5s;
+  -moz-animation-delay: -1s, -1.5s;
+  -o-animation-delay: -1s, -1.5s;
+  animation-delay: -1s, -1.5s;
+}
+
+.la-ball-circus>.item:nth-child(4) {
+  -webkit-animation-delay: -1.5s, -2s;
+  -moz-animation-delay: -1.5s, -2s;
+  -o-animation-delay: -1.5s, -2s;
+  animation-delay: -1.5s, -2s;
+}
+
+.la-ball-circus>.item:nth-child(5) {
+  -webkit-animation-delay: -2s, -2.5s;
+  -moz-animation-delay: -2s, -2.5s;
+  -o-animation-delay: -2s, -2.5s;
+  animation-delay: -2s, -2.5s;
+}
+
+.la-ball-circus.la-sm {
+  width: 8px;
+  height: 8px;
+}
+
+.la-ball-circus.la-sm>.item {
+  width: 8px;
+  height: 8px;
+}
+
+.la-ball-circus.la-2x {
+  width: 32px;
+  height: 32px;
+}
+
+.la-ball-circus.la-2x>.item {
+  width: 32px;
+  height: 32px;
+}
+
+.la-ball-circus.la-3x {
+  width: 48px;
+  height: 48px;
+}
+
+.la-ball-circus.la-3x>.item {
+  width: 48px;
+  height: 48px;
+}
+
+/*
+ * Animations
+ */
+@-webkit-keyframes ball-circus-position {
+  50% {
+    left: 100%;
+  }
+}
+
+@-moz-keyframes ball-circus-position {
+  50% {
+    left: 100%;
+  }
+}
+
+@-o-keyframes ball-circus-position {
+  50% {
+    left: 100%;
+  }
+}
+
+@keyframes ball-circus-position {
+  50% {
+    left: 100%;
+  }
+}
+
+@-webkit-keyframes ball-circus-size {
+  50% {
+    -webkit-transform: scale(.3, .3);
+    transform: scale(.3, .3);
+  }
+}
+
+@-moz-keyframes ball-circus-size {
+  50% {
+    -moz-transform: scale(.3, .3);
+    transform: scale(.3, .3);
+  }
+}
+
+@-o-keyframes ball-circus-size {
+  50% {
+    -o-transform: scale(.3, .3);
+    transform: scale(.3, .3);
+  }
+}
+
+@keyframes ball-circus-size {
+  50% {
+    -webkit-transform: scale(.3, .3);
+    -moz-transform: scale(.3, .3);
+    -o-transform: scale(.3, .3);
+    transform: scale(.3, .3);
+  }
+}
+</style>

+ 34 - 10
src/components/chat/ChatTaskGroup.vue

@@ -1,17 +1,31 @@
 <script setup>
-const data = [
-  { label: '智能查数', key: 'smart' },
-  { label: '运行诊断', key: 'runing' },
-  { label: '公文往来', key: 'offical' },
-]
+
+const emit = defineEmits(['on-click']);
+const modelValueIndex = defineModel('index');
+
+defineProps({
+  options: {
+    type: Array,
+    default: () => []
+  },
+  activeIndex: {
+    type: Number,
+    default: null
+  }
+})
+
+const handleClick = (item, index) => {
+  modelValueIndex.value = index;
+  emit('on-click', item)
+}
 </script>
 
 <template>
   <!-- <scroll-view scroll-x="true"> </scroll-view> -->
   <view class="task-group">
-    <view class="task-btn" v-for="item in data" :key="item.key">
-      <TheSvgIcon class="icon" :src="'icon-task-' + item.key" size="36"></TheSvgIcon>
-      <text class="text">{{ item.label }}</text>
+    <view :class="['task-btn', { active: item.active }] " v-for="item, index in options" :key="item.id" @click="handleClick(item, index)">
+      <image :src="item.remark" class="icon" />
+      <text class="text">{{ item.title }}</text>
     </view>
   </view>
 </template>
@@ -20,10 +34,10 @@ const data = [
 .task-group {
   display: flex;
   width: 100%;
-  padding: 0 60rpx 16rpx 60rpx;
+  height: 78rpx;
+  padding: 0 60rpx 0 60rpx;
   overflow: hidden;
 
-
   .task-btn {
     display: flex;
     align-items: center;
@@ -39,6 +53,11 @@ const data = [
       url('https://static.fuxicarbon.com/bigModel/wechat/layout/bg-task-right.svg') right bottom no-repeat, #fff;
     background-size: contain, contain;
 
+    .icon {
+      width: 36rpx;
+      height: 36rpx;
+    }
+
     .text {
       margin-left: 8rpx;
     }
@@ -47,5 +66,10 @@ const data = [
       margin-right: 16rpx;
     }
   }
+  .active {
+    border-bottom: 1px solid #2454FF;
+    // background: #fff;
+    // color: #2454FF;
+  }
 }
 </style>

+ 7 - 1
src/components/chat/ChatWelcome.vue

@@ -17,6 +17,12 @@ defineProps({
     default: () => []
   }
 })
+
+const emit = defineEmits(['on-click']);
+
+const handleEmit = ({ content: realQuestion, question }) => {
+  emit('on-click', { realQuestion, question } );
+}
 </script>
 
 <template>
@@ -34,7 +40,7 @@ defineProps({
           <text class="title">{{ cardTitle }}</text>
         </view>
         <view class="list">
-          <view class="item" v-for="item in cardContent" :key="item.id">
+          <view class="item" v-for="item in cardContent" :key="item.id" @click="handleEmit(item)">
             <text class="text">{{ item.question }}</text>
             <TheSvgIcon src="icon-right-arrow" size="28"></TheSvgIcon>
           </view>

+ 0 - 3
src/components/chat/icon-send.svg

@@ -1,3 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
-  <circle cx="14" cy="14" r="14" fill="#879AAF"/>
-</svg>

+ 0 - 1
src/components/layout/BaseHeaderTools.vue

@@ -1,5 +1,4 @@
 <script setup>
-
 const emit = defineEmits(['on-history', 'on-add']);
 
 const onHistoryClick = () => emit('on-history');

+ 22 - 2
src/components/layout/BaseNavBar.vue

@@ -11,6 +11,10 @@ defineProps({
   isDropdown: {
     type: Boolean,
     default: false
+  },
+  isBack: {
+    type: Boolean,
+    default: false
   }
 })
 
@@ -21,8 +25,19 @@ const { dropdownList, titleActiveIndex, title } = storeToRefs(chatStore);
 const dropVisible = ref(false);
 
 const onDropdownChange = (index) => {
+
+  if ( titleActiveIndex.value === index ) return; 
+
   titleActiveIndex.value = index;
   dropVisible.value = false;
+
+  const url = dropdownList.value[index].url
+
+  uni.navigateTo({ url });
+}
+
+const onBackPage = () => {
+  uni.navigateBack({ delta: 1 });
 }
 </script>
 
@@ -30,7 +45,12 @@ const onDropdownChange = (index) => {
   <view class="nav-bar-container">
     <BaseStatusBar></BaseStatusBar>
     <uni-nav-bar :border="false" backgroundColor="rgba(0,0,0,0)" leftWidth="200rpx" rightWidth="200rpx">
-      <template #left><slot></slot></template>
+      <template #left>
+        <slot>
+          <uni-icons type="left" size="26" @click="onBackPage" color="#000" v-if="isBack"></uni-icons>
+        </slot>
+      </template>
+
       <view class="title" v-if="!isDropdown">{{ titleText }}</view>
       <view class="title-dropdown" v-if="isDropdown" @click="dropVisible = !dropVisible">
         <text class="en">LibraAI</text>
@@ -69,7 +89,7 @@ const onDropdownChange = (index) => {
     justify-content: center;
     align-items: center;
     font-size: 34rpx;
-    color: #000;
+    color: #333;
   }
 
   .title-dropdown {

+ 31 - 33
src/components/layout/BasePublicLayout.vue

@@ -1,9 +1,8 @@
 <script setup>
-import { ref, onMounted, getCurrentInstance, watch, nextTick } from 'vue';
+import { ref, onMounted, getCurrentInstance, nextTick } from 'vue';
 
 const autoScroll = ref(true);
 
-
 const scrollViewHeight = ref(0);
 const scrollTop = ref(0);
 
@@ -12,8 +11,6 @@ const scrollView = ref(null);
 const scrollIntoView = ref(null);
 const instance = getCurrentInstance();
 
-const redColor = ref('red')
-
 const props = defineProps({
   bgColor: {
     type: String,
@@ -26,21 +23,13 @@ const props = defineProps({
   nums: {
     type: Number,
     default: 0
+  },
+  isSafeBottom: {
+    type: Boolean,
+    default: true
   }
 });
 
-
-onMounted(() => {
-  const domQuery = uni.createSelectorQuery();
-  domQuery.in(instance).select('.scroll-view').boundingClientRect();
-
-  domQuery.exec(res => {
-    const [{ height }] = res;
-    scrollViewHeight.value = height;
-  });
-
-})
-
 // 处理滚动事件
 const handleScroll = (e) => {
   const { scrollHeight, scrollTop } = e.detail;
@@ -49,23 +38,32 @@ const handleScroll = (e) => {
 
 // 滚动到底部
 const scrollToBottom = () => {
-  if (!autoScroll.value) return;
-  // scrollTop.value = scrollTop.value + 1;
-  scrollTop.value += 1
+  scrollTop.value += 1;
   nextTick(() => scrollTop.value = 999999);
 }
 
-defineExpose({
-  scrollToBottom
-})
+// 滚动到底部 - 50px 阈值
+const scrollToBottomIfAtBottom = () => {
+  if (!autoScroll.value) return;
+  scrollToBottom();
+}
 
+onMounted(() => {
+  const domQuery = uni.createSelectorQuery();
+  domQuery.in(instance).select('.scroll-view').boundingClientRect();
+
+  domQuery.exec(res => {
+    const [{ height }] = res;
+    scrollViewHeight.value = height;
+    console.log( "scrollViewHeight.value", scrollViewHeight.value );
+  });
+})
 
-const lowerThreshold = () => {
+defineExpose({
+  scrollToBottom,
+  scrollToBottomIfAtBottom
+})
 
- 
-  // autoScroll.value = true;
-  console.log( "lowerThreshold" );
-}
 </script>
 
 <template>
@@ -79,13 +77,12 @@ const lowerThreshold = () => {
       :scrollTop="scrollTop"
       :scroll-into-view="scrollIntoView"
       @scroll="handleScroll"
-      @scrolltolower="lowerThreshold"
     >
       <view class="scroll-content">
         <slot name="content" class="content"></slot>
       </view>
     </scroll-view>
-    <view class="footer" >
+    <view :class="['footer', { safeBottom: isSafeBottom }]">
       <slot name="footer"></slot>
       <!-- 这里需拆分出去 -->
       <!-- <BaseTabBar></BaseTabBar> -->
@@ -106,19 +103,20 @@ const lowerThreshold = () => {
   .scroll-view {
     flex: 1;
     overflow: hidden;
-    background: black;
 
     .scroll-content {
       display: flex;
       width: 100vw;
-      // height: 100%;
-      background: red;
+      height: 100%;
     }
   }
 
   .footer {
-    width: 100%;
     flex-shrink: 0;
+    width: 100%;
+    padding-top: 16rpx;
+  }
+  .safeBottom {
     padding-bottom: env(safe-area-inset-bottom);
   }
 }

+ 7 - 2
src/composables/useRecommend.js

@@ -5,12 +5,17 @@ export const useRecommend = ({ type }) => {
 
   const recommendList = ref([]);
 
-  onMounted(async () => {
+  const reloadRecommend = async() => {
     const { data } = await chatApi.getWelcomeRecommend(type);
     recommendList.value = data;
+  }
+
+  onMounted(async () => {
+    await reloadRecommend()    
   })
 
   return {
-    recommendList
+    recommendList,
+    reloadRecommend
   }
 }

+ 44 - 9
src/pages.json

@@ -8,6 +8,48 @@
         "disableScroll": true
       }
     },
+    {
+      "path": "pages/answer/history",
+      "style": {
+        "navigationStyle": "custom",
+        "navigationBarTitleText": "问答 - 历史记录",
+        "disableScroll": true
+      }
+    },
+    {
+      "path": "pages/analyse/water/index",
+      "style": {
+        "navigationStyle": "custom",
+        "navigationBarTitleText": "水质报警 - 列表",
+        "disableScroll": true
+      }
+    },
+    {
+      "path": "pages/analyse/water/details",
+      "style": {
+        "navigationStyle": "custom",
+        "navigationBarTitleText": "水质报警 - 详情",
+        "disableScroll": true
+      }
+    },
+    {
+      "path": "pages/user/index",
+      "style": {
+        "navigationStyle": "custom",
+        "navigationBarTitleText": "我的",
+        "disableScroll": true
+      }
+    },
+    {
+      "path": "pages/login/index",
+      "style": {
+        "disableScroll": true,
+        "navigationStyle": "custom",
+        "navigationBarTitleText": "登录"
+      }
+    },
+
+
     {
       "path": "pages/home/index",
       "style": {
@@ -55,14 +97,6 @@
         "navigationBarTitleText": "我的",
         "disableScroll": true
       }
-    },
-    {
-      "path": "pages/login/index",
-      "style": {
-        "disableScroll": true,
-        "navigationStyle": "custom",
-        "navigationBarTitleText": "登录"
-      }
     }
   ],
   "globalStyle": {
@@ -77,7 +111,8 @@
       "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue",
       "^The(.*)": "@/components/The$1.vue",
       "^Base(.*)": "@/components/layout/Base$1.vue",
-      "^Chat(.*)": "@/components/chat/Chat$1.vue"
+      "^Chat(.*)": "@/components/chat/Chat$1.vue",
+      "^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)": "z-paging/components/z-paging$1/z-paging$1.vue"
     }
   }
 }

+ 295 - 0
src/pages/analyse/water/details.vue

@@ -0,0 +1,295 @@
+<script setup>
+import { onLoad } from '@dcloudio/uni-app';
+import { ref } from 'vue';
+import { waterApi } from '@/api/water';
+import { formatToData } from "@/utils/format";
+
+const isExpand = ref(true);
+const answerResult = ref([]);
+const textDataSources = ref(null);
+const warningActive = ref(0);
+
+// 进出水数据
+const jsTableData = ref([]);
+const csTableData = ref([]);
+
+const inColumns = [
+  {label: '流量(m³/h)', key: '流量'}, 
+  {label: 'COD(mg/L)', key: 'COD'}, 
+  {label: 'TN(mg/L)', key: 'TN'}, 
+  {label: 'NH₃-N(mg/L)', key: 'NH3-N'}, 
+  {label: 'TP(mg/L)', key: 'TP'}, 
+  {label: 'SS(mg/L)', key: 'SS'}
+]
+
+const outColumns = [
+  {label: '流量(m³/h)', key: '流量'}, 
+  {label: 'COD(mg/L)', key: 'COD'}, 
+  {label: '#1NO₃⁻(mg/L)', key: 'HYC1'}, 
+  {label: '#2NO₃⁻(mg/L)', key: 'HYC2'}, 
+  {label: 'NH₃-N(mg/L)', key: 'NH3-N'}, 
+  {label: 'PO₄³⁻(mg/L)', key: 'RCC'}, 
+  {label: 'SS(mg/L)', key: 'SS'}
+]
+
+const formatRowData = (data, columns) => {
+  return columns.map((item) => ({
+    label: item.label,
+    ...data[item.key]
+  }))
+} 
+const onTaggleCollapse = () => {
+  isExpand.value = !isExpand.value;
+}
+
+onLoad(async ({ id, title }) => {
+  const { data } = await waterApi.getWaringDetails(id)
+  const showVal = JSON.parse(data.showVal);
+  const { basic, jsData, csData } = showVal;
+
+
+  try {
+    const answer = JSON.parse(data.answer);
+    const reportList = [];
+    const alertList = [];
+    let simulateObj = null;
+
+    answer.map(item => {
+      const answerObjItem = JSON.parse(item);
+      switch (answerObjItem.biz) {
+        case "DECISION_REPORT":
+          reportList.push(answerObjItem.message);
+          break
+        case "DECISION_ALERT":
+          alertList.push(answerObjItem);
+          break
+        case "DECISION_SIMULATE":
+          if (warningActive.value === 1) return;
+          const { off, on, pred } = JSON.parse(answerObjItem.message);
+          simulateObj = {
+            biz: 'DECISION_SIMULATE',
+            off,
+            on,
+            pred,
+            isDisable: false
+          }
+          modalData.value = simulateObj;
+      }
+    })
+
+    if (reportList.length) {
+      answerResult.value.push({
+        biz: 'DECISION_REPORT',
+        answer: reportList.join(""),
+        loading: false,
+        delayLoading: false
+      })
+    }
+
+    if (alertList.length) {
+      const [parseAnswer] = alertList.map(item => {
+        item.message = Object.keys(item.message).map(key => ({ ...item.message[key], isActive: null }));
+        return item;
+      })
+      answerResult.value.push({
+        biz: 'DECISION_ALERT',
+        loading: false,
+        delayLoading: false,
+        isAllSelect: false,
+        list: parseAnswer?.message
+      })
+    }
+
+    if (simulateObj) {
+      answerResult.value.push(simulateObj);
+    }
+
+  } catch (error) {
+    answerResult.value.push({
+      biz: 'DECISION_REPORT',
+      answer: data.answer,
+      loading: false,
+      delayLoading: false
+    })
+  }
+
+  basic.title = title;
+  textDataSources.value = formatToData({
+    dataSource: basic,
+    warnKey: '报警值',
+    statusVal: !!warningActive.value ? '系统关闭' : basic['状态']
+  });
+
+  jsTableData.value = formatRowData(jsData, inColumns);
+  csTableData.value = formatRowData(csData, outColumns);;
+
+})
+</script>
+
+<template>
+  <BasePublicLayout ref="scrollRef">
+    <template #header>
+      <BaseNavBar titleText="水质报警" isBack></BaseNavBar>
+    </template>
+    <template #content>
+      <view class="water-content_wrapper">
+        <view :class="['content', { expand: !isExpand }]">
+          <view class="warning-info_card">
+            <view class="basic-info">
+              <view class="title">{{ textDataSources?.title }}</view>
+              <view class="warning-list">
+                <view
+                  class="item" 
+                  :style="{ color: item.isWarning ? '#F44C49' : '#4F4F4F' }"
+                  v-for="item, index in textDataSources?.list" :key="index"
+                >
+                  <view class="label">{{ item.label }}:</view>
+                  <view class="value">{{ item.value }}</view>
+                </view>
+              </view>
+            </view>
+            <view class="water-table">
+              <view class="title">当前进水数据:</view>
+              <view>
+                <uni-table border stripe emptyText="暂无更多数据">
+                  <uni-tr>
+                    <uni-th align="center" v-for="item, index in inColumns" :key="index">{{ item.label }}</uni-th>
+                  </uni-tr>
+                  <uni-tr>
+                    <uni-td align="center" v-for="item, index in jsTableData" :key="index">
+                      <text :class="[{'is-exceed': item.exceed}, 'water-table_text']">{{ item.value }}</text>
+                      <text :class="[{'is-exceed': item.exceed}, 'water-table_text']" v-show="item.exceed">↑</text>
+                    </uni-td>
+                  </uni-tr>
+                </uni-table>
+              </view>
+            </view>
+            <view class="water-table">
+              <view class="title">当前进水数据:</view>
+              <view>
+                <uni-table border stripe emptyText="暂无更多数据">
+                  <uni-tr>
+                    <uni-th align="center" v-for="item, index in outColumns" :key="index">{{ item.label }}</uni-th>
+                  </uni-tr>
+                  <uni-tr>
+                    <uni-td align="center" v-for="item, index in csTableData" :key="index" class="water-table_td">
+                      <text :class="[{'is-exceed': item.exceed}, 'water-table_text']">{{ item.value }}</text>
+                      <text :class="[{'is-exceed': item.exceed}, 'water-table_text']" v-show="item.exceed">↑</text>
+                    </uni-td>
+                  </uni-tr>
+                </uni-table>
+              </view>
+            </view>
+          </view>
+        </view>
+        <view class="collapse" @click="onTaggleCollapse">
+          <TheSvgIcon class="icon" src="icon-drop-up" size="28"></TheSvgIcon>
+        </view>
+      </view>
+      <view class="answer-content_wrapper">
+        <view class="" v-for="item, index in answerResult" :key="index">
+          <template v-if="item.biz === 'DECISION_REPORT'">
+            <ChatAnswer
+              :isRound="false"
+              :loading="item.loading"
+              :delay-loading="item.delayLoading"
+              :toggleVisibleIcons="false"
+              :content="item.answer">
+            </ChatAnswer>
+          </template>
+        </view>
+      </view>
+    </template>
+  </BasePublicLayout>
+</template>
+
+<style lang="scss" scoped>
+.collapse {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24rpx 0;
+  border-radius: 0rpx 0rpx 30rpx 30rpx;
+  background-color: #fff;
+}
+
+.btn {
+  width: 14px;
+  height: 14px;
+  border-radius: 100%;
+  background: red;
+}
+
+.water-content_wrapper {
+  width: 100vw;
+  padding: 60rpx 40rpx 40rpx 40rpx;
+  box-sizing: border-box;
+
+  .content {
+    min-height: 50px;
+    padding: 22rpx 44rpx 0 44rpx;
+    border-radius: 4rpx 30rpx 0rpx 0rpx;
+    background: #FFF;
+    transition: all 2.3s ease-in-out;
+
+    .warning-info_card {
+      line-height: 56rpx;
+
+      .basic-info {
+        .title {
+          color: #212121;
+          font-size: 30rpx;
+          font-weight: 500;
+        }
+
+        .item {
+          display: flex;
+          align-items: center;
+          color: #4F4F4F;
+          font-size: 28rpx;
+          
+          .label {
+            padding-right: 20rpx;
+          }
+        }
+      }
+
+      .water-table {
+        .title {
+          padding: 16rpx 0;
+          color: #212121;
+          font-size: 28rpx;
+          font-weight: 500;
+          line-height: 48rpx;
+        }
+        .is-exceed {
+          color: #F44C49;
+          font-weight: bold;
+        }
+        :deep(.uni-table-th) {
+          padding: 14rpx 20rpx;
+          background: #E6E9F2;
+          color: #212121;
+          font-size: 28rpx;
+        }
+        :deep(.table--border) {
+          border-color: #D0D7DE;
+        }
+        .water-table_text {
+          font-size: 28rpx;
+          color: #212121;
+        }
+      }
+    }
+  }
+
+  .expand {
+    height: 500rpx;
+    overflow: hidden;
+  }
+}
+
+.answer-content_wrapper {
+  padding: 0 24rpx;
+}
+</style>

+ 53 - 0
src/pages/analyse/water/index.vue

@@ -0,0 +1,53 @@
+<script setup>
+import { ref } from 'vue';
+import { waterApi } from '@/api/water';
+
+import RecodeItem from "@/components/RecodeItem"
+
+const pagingRef = ref(null);
+const recordList = ref([]);
+
+// 点击报警列表
+const handleOpenContent = ({ id, category, reason: title, }) => {
+  uni.navigateTo({
+    url: `/pages/analyse/water/details?id=${id}&title=${title}`
+  });
+}
+
+// 查询数据
+const queryList = (pageNum, pageSize) => {
+  waterApi.getWaterWarningList({ type: 0, warningStatus: 0, pageNum, pageSize }).then(({ rows }) => {
+    pagingRef.value.complete(rows);
+  })
+}
+</script>
+
+<template>
+  <z-paging ref="pagingRef" bg-color="linear-gradient(240deg, #dce8fd 0%, #f6fbfe 100%)" v-model="recordList"
+    @query="queryList" refresher-enabled>
+    <template #top>
+      <BaseNavBar titleText="水质报警" isDropdown>
+        <view></view>
+      </BaseNavBar>
+    </template>
+    <view class="warning-list">
+      <RecodeItem :item="item" v-for="item in recordList" :key="item.id" @on-click="handleOpenContent"></RecodeItem>
+    </view>
+    <template #bottom>
+      <view class="safety-area"></view>
+    </template>
+  </z-paging>
+</template>
+
+<style lang="scss" scoped>
+.warning-list {
+  padding: 60rpx 40rpx 40rpx 40rpx;
+  :deep(.warning-item) {
+    margin-bottom: 32rpx;
+  }
+}
+
+.safety-area {
+  height: env(safe-area-inset-bottom);
+}
+</style>

+ 165 - 0
src/pages/answer/history.vue

@@ -0,0 +1,165 @@
+<script setup>
+import { onMounted, ref } from 'vue';
+import { useUserStore } from '@/stores/modules/userStore';
+import { chatApi } from '@/api/chat';
+import dayjs from 'dayjs';
+
+const userStore = useUserStore();
+
+const historyList = ref({});
+
+const hanldeItemClick = ({ sessionId }) => {
+  const pages = getCurrentPages();
+  const prevPage = pages[pages.length - 2];
+
+  if (prevPage && typeof prevPage.$vm.handleChatDetail === "function") {
+    prevPage.$vm.handleChatDetail({ sessionId }); 
+  }
+
+  uni.navigateBack({ delta: 1 });
+}
+
+// 前往个人中心
+const onJumpToUser = () => {
+  uni.navigateTo({ url: '/pages/user/index' })
+}
+
+onMounted(() => {
+  chatApi.getQaHistoryList().then(({ data }) => {
+    historyList.value = Object.keys(data).map(key => {
+      const children = data[key];
+      return {
+        title: key,
+        children: children.map(item => {
+          return {
+            ...item,
+            time: dayjs(item.createTime).format('HH:mm')
+          }
+        })
+      }
+    })?.sort((a, b) => dayjs(b.title).diff(dayjs(a.title)));
+  });
+})
+</script>
+
+<template>
+  <BasePublicLayout ref="scrollRef" bgColor="linear-gradient(180deg, #EEF4FF 0%, #F5FBFE 23.71%);" :isSafeBottom="false">
+    <template #header>
+      <BaseNavBar titleText="历史会话" isBack></BaseNavBar>
+    </template>
+    <template #content>
+      <view class="history-list">
+        <view class="history-item" v-for="item, index in historyList" :key="index">
+          <view class="title">{{ item.title }}</view>
+          <view class="item-inner" v-for="val, i in item.children" :key="i" @click="hanldeItemClick(val)">
+            <view class="time">{{ val.time }}</view>
+            <view class="info">
+              <view class="circle"></view>
+              <view class="name">{{ val.showVal }}</view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </template>
+    <template #footer>
+      <view class="history-footer" @click="onJumpToUser">
+        <view class="user-avator">
+          <image :src="userStore.userInfo?.avatar" class="avator"></image> 
+        </view>    
+        <view class="user-name">{{userStore.userInfo?.nickName}}</view>
+        <uni-icons type="right" size="16"></uni-icons>
+      </view>
+    </template>
+  </BasePublicLayout>
+</template>
+
+<style lang="scss">
+.history-list {
+  width: 100%;
+  padding: 0 38rpx;
+  box-sizing: border-box;
+  .history-item {
+    width: 100%;
+    padding-top: 40rpx;
+
+    .title {
+      margin-bottom: 40rpx;
+      color: #929292;
+      font-size: 26rpx;
+      font-weight: 500;
+    }
+
+    .item-inner {
+      display: flex;
+      flex-flow: column;
+      justify-content: space-between;
+      height: 148rpx;
+      padding: 30rpx;
+      border-radius: 20rpx;
+      background: #fff;
+      box-sizing: border-box;
+
+      .time {
+        color: #7B909C;
+        font-size: 24rpx;
+        line-height: 18px;
+      }
+
+      .info {
+        display: flex;
+        align-items: center;
+        .circle {
+          flex-shrink: 0;
+          width: 12rpx;
+          height: 12rpx;
+          border-radius: 100%;
+          background: #2454FF;
+        }
+        .name {
+          padding-left: 20rpx;
+          color: #212121;
+          font-size: 28rpx;
+          font-weight: 500;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+      }
+    }
+
+    .item-inner:not(:first-child) {
+      margin-top: 20rpx;
+    }
+  }
+}
+
+.history-footer {
+  display: flex;
+  align-items: center;
+  // height: 180rpx;
+  padding: 0rpx 0 0 38rpx;
+  padding-top: env(safe-area-inset-bottom);
+  padding-bottom: env(safe-area-inset-bottom);
+  box-sizing: border-box;
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, rgba(116, 232, 255, 0.30) 100%);
+
+  .user-avator {
+    width: 64rpx;
+    height: 64rpx;
+    border-radius: 50%;
+    border: 1px solid #b1d2ee;
+    background: #fff;
+
+    .avator {
+      width: 100%;
+      height: 100%;
+    }
+  }
+  .user-name {
+    margin: 0 16rpx 0 20rpx;
+    color: #212121;
+    font-size: 30rpx;
+    font-weight: 500;
+  }
+}
+</style>

+ 194 - 69
src/pages/answer/index.vue

@@ -1,90 +1,119 @@
 <script setup>
-import { onMounted, ref, unref } from 'vue';
-import { useUserStore } from '@/stores/modules/userStore';
+import { onMounted, ref, unref, computed } from 'vue';
 import { useRecommend } from '@/composables/useRecommend';
 
 import { chatApi } from '@/api/chat';
 import { streamChatRequest } from '@/utils/streamRequest';
 import { useChat } from '@/composables/useChat';
-
-const str = `信义污水厂采用AAO(厌氧-缺氧-好氧)与移动床生物膜反应器(MBBR)相结合的工艺流程,其设计目标是高效处理污水并达到严格的排放标准。整个工艺流程可以分为预处理、二级生化处理和深度处理三个阶段,具体如下:
-
-![XYWSCGYLCT](https://static.fuxicarbon.com/modelData/XYWSCGYLCT.png)
-
-### 一、预处理阶段
-1. **粗格栅间**:污水经过粗格栅去除较大悬浮物,确保后续设备的正常运行。
-2. **进水泵房**:污水通过泵提升至适当高度,为后续处理提供动力。
-3. **细格栅间**:进一步去除更小的悬浮物和纤维物质。
-4. **旋流沉砂池**:利用机械力控制水流流态与流速,加速沙粒沉淀,降低后续设备的负荷。
-5. **初次沉淀池**:污水初步沉淀,分离出一部分悬浮固体。
-
-### 二、二级生化处理阶段
-1. **AAO+MBBR生化池**:通过AAO技术,微生物群体在好氧和厌氧条件下协同降解有机物;同时利用MBBR技术增加生物膜面积,提高硝化和反硝化效率。
-2. **二沉池**:处理后的混合液进行二次沉淀,进一步去除剩余的悬浮物和部分溶解性有机物。
-
-### 三、深度处理阶段
-1. **活性砂滤池**:通过过滤和微生物的附着作用,去除污水中的残余有机物和微量元素。
-2. **反冲洗配水深度处理(磁混凝)**:使用磁性混凝剂强化沉淀过程,提高悬浮物的去除效果。
-3. **次氯酸钠消毒**:对处理后的水进行化学消毒,杀灭剩余的病原体,确保出水达到安全标准。
-4. **除磷加药间**:通过添加磷去除剂,控制出水中总磷含量,防止水体富营养化。
-
-### 四、污泥处理和排放
-1. **污泥脱水间**:收集的污泥经过浓缩和脱水处理,减少体积,便于后续处置或资源化利用。
-
-以上流程的每个环节都经过精心设计,确保了污水处理的高效、稳定和环保,同时满足了法规要求和用户期望。`
+import { getFormatYesterDay } from '@/utils/format';
 
 const ANSWER_ID_KEY = '@@id@@';
 
-const userStore = useUserStore();
-
 // chat 数据
 const { chatDataSource, addChat, updateChat, clearChat, updateById } = useChat();
 
-// 滚动条
-// const { scrollRef, scrollToBottom, scrollToBottomIfAtBottom } = useScroll();
-
 const scrollRef = ref(null);
 
-const showRight = ref(null);
-
-const { recommendList } = useRecommend({ type: 0 });
+const { recommendList, reloadRecommend } = useRecommend({ type: 0 });
 
+// 选中的智能体
+let selectedOption = null;
 const currenSessionId = ref('');
+const helperList = ref([]);
 
+const helperActiveIndex = ref(null);
+const isLoading = ref(false);
 const inpValue = ref('');
 
-// const jumpChatView = () => {
-//   console.log("执行了");
-//   uni.navigateTo({ url: '/pages/chat/chatView' });
-// }
-
-
-// 点击历史记录
+// 前往 - 历史记录
 const onHistory = () => {
-  console.log("history");
-  showRight.value.open();
-}
+  if ( isLoading.value ) {
+    return uni.showToast({ title: '当前有会话进行中', duration: 3000, icon: 'none' });
+  }
+  uni.navigateTo({ url: '/pages/answer/history' })
+};
 
 // 新建会话
 const onAdd = () => {
-  console.log("add");
+  if ( isLoading.value ) {
+    return uni.showToast({ title: '当前有会话进行中', duration: 3000, icon: 'none' });
+  }
+
+  if (!unref(chatDataSource).length) {
+    return uni.showToast({ title: '已切换最新会话', duration: 3000, icon: 'none' });
+  }
+
+  // 清空选中的智能体
+  helperList.value.forEach(val => val.active = false);
+  selectedOption = null;
+  
+  // 清空输入框的值
+  inpValue.value = '';
+
+  // 清空会话id
+  currenSessionId.value = null;
+
+  clearChat();
 }
 
-const closeDrawer = () => {
-  showRight.value.close();
+// 任务项点击
+const onTaskClick = (item) => {
+
+  const index = unref(helperActiveIndex);
+
+  helperList.value.forEach(val => {
+    if ( val.id != item.id ) val.active = false;
+  })
+
+  item.active = !item.active
+
+  if ( item.active ) {
+    inpValue.value = item.content;
+    selectedOption = item;
+  } else {
+    selectedOption = null;
+  }
 }
 
+// 渲染markdown数据
 const onRegenerate = ({ showVal, question, realQuestion, tools, uploadFileList }) => {
 
   // 参数相关先不组合 - 后续统一整理
   // const sessionId = unref(currenSessionId);
-  
+
+  /**
+      sessionId":currenSessionId.value,
+      "showVal":"",
+      "question":"",
+      "module":0,
+
+      "modelType":0,  大模型 LibraAI 或者 deepSeek 这里默认使用 0
+
+      "isStrong":0,   是否强, 待后续支持deepseek的时候
+
+      "tools":null,   智能体
+      "
+      "onlineSearch":false,   是否在线搜索
+      "prompt":null
+   * */ 
+
   streamChatRequest({
-    data: {"sessionId":currenSessionId.value,"showVal":"信义污水厂工艺流程是什么?" + new Date().getTime,"question":"信义污水厂工艺流程是什么?"  + new Date().getTime,"module":0,"modelType":0,"isStrong":0,"tools":null,"onlineSearch":false,"prompt":null},
+    data: {
+      sessionId: currenSessionId.value,
+      showVal,
+      question: realQuestion || question,
+      module: 0,
+      modelType: 0,
+      isStrong: 0,
+      tools: selectedOption?.tools || null,
+      onlineSearch: false,
+      prompt: null
+    },
     onProgress: (responseText) => {
       const [ answer ] = responseText.split(ANSWER_ID_KEY);
-      
+
       updateChat({
+        id: '',
         sessionId: currenSessionId.value,
         showVal: showVal,
         question,
@@ -93,11 +122,28 @@ const onRegenerate = ({ showVal, question, realQuestion, tools, uploadFileList }
         delayLoading: false,
         uploadFileList
       })
+      
+      scrollRef.value.scrollToBottomIfAtBottom();
+    },
+    onSuccess: (data) => {
+      const [answer, id] = data.split(ANSWER_ID_KEY);
 
-      scrollRef.value.scrollToBottom();
+      updateChat({
+        id,
+        sessionId: currenSessionId.value,
+        showVal: showVal,
+        question,
+        answer,
+        loading: false,
+        delayLoading: false,
+        uploadFileList
+      });
+  
+      setTimeout(() => scrollRef.value.scrollToBottomIfAtBottom(), 100);
     },
     onComplete: () => {
-    },
+      isLoading.value = false;
+    }
   })
 }
 
@@ -108,10 +154,14 @@ const handleSubmit = async ({ showVal, question, selectedOption, realQuestion =
    * question:        问题 - 用于传给大模型使用
    * selectedOption: 智能体-智能差数,这个需要后续完善
    * **/ 
+
+  isLoading.value = true;
+
   const { data: sessionId } = await chatApi.getChatSessionTag();
   currenSessionId.value = sessionId;
 
   addChat({
+    id: '',
     sessionId,
     showVal,
     question,
@@ -122,13 +172,87 @@ const handleSubmit = async ({ showVal, question, selectedOption, realQuestion =
     uploadFileList
   })
 
+  scrollRef.value.scrollToBottom();
+
   onRegenerate({ showVal, question, realQuestion, tools: selectedOption?.tools || null, uploadFileList });
+}
+
+// 处理推荐问题
+const handleWelcomeRecommend = ({ question, realQuestion }) => {
+  handleSubmit({showVal: question, question, realQuestion});
+}
+
+// 重新生成问题
+const onChatResetStream = (item) => {
+  const { question, uploadFileList, showVal } = item;
+  handleSubmit({showVal, question, uploadFileList});
+}
+
+const handleChatDetail = async ({ sessionId }) => {
+
+  isLoading.value = false;
+
+  inpValue.value = '';
+
+  const { data } = await chatApi.getAnswerHistoryDetail({ sessionId });
+
+  chatDataSource.value = data.map(item => {
+
+    const uploadFileList = []
+
+    if ( item.question.includes('file:') ) {
+      
+      const fileInfo = item.question.split("||");
+      const fileArr = fileInfo[0].split(":");
+      const file = fileArr[1];
+      const url = fileInfo[1];
+      const suffix = file.substring( file.lastIndexOf('.') + 1 ).toUpperCase();
+      const originSuffix = file.substring( file.lastIndexOf('.') );
+      const name = file.substring(0, file.lastIndexOf('.'))
+
+      uploadFileList.push({
+        name,
+        originSuffix,
+        suffix,
+        url
+      })
+    }
+
+    return ({ ...item, loading: false, uploadFileList});
+  });
+
+  currenSessionId.value = sessionId;
+
+  setTimeout(() => scrollRef.value.scrollToBottom(), 100)
+
+}
+
+// 获取智能体
+const getHelperList = async () => {
+  const { data } = await chatApi.getHelperList();
+  const result = getFormatYesterDay(data);
+
+  helperList.value = result.filter(({ tools }) => tools).map(item => ({
+    ...item,
+    title: item.tools === 'work_order' ? '运行诊断' : item.title
+  }));
 
 }
 
+// 初始化
+const init = async () => {
+  getHelperList();
+  reloadRecommend();
+}
+
 onMounted(() => {
+  init();
 })
 
+defineExpose({
+  handleChatDetail,
+  init
+})
 </script>
 
 <template>
@@ -140,7 +264,7 @@ onMounted(() => {
     </template>
 
     <template #content>
-      <view class="chat-front-card" v-if="false">
+      <view class="chat-front-card" :style="{ display: chatDataSource.length !== 0 ? 'none' : 'flex' }">
         <ChatWelcome
           title="您好,我是LibraAI专家问答"
           card-title="您可以试着问我:"
@@ -149,12 +273,13 @@ onMounted(() => {
             '有任何重点或需讨论的事项,随时告诉我'
           ]"
           :card-content="recommendList"
+          @on-click="handleWelcomeRecommend"
         >
         </ChatWelcome>
-        <ChatTaskGroup ></ChatTaskGroup>
+        <ChatTaskGroup @on-click="onTaskClick" :options="helperList" v-model:index="helperActiveIndex"></ChatTaskGroup>
       </view>
 
-      <view class="qa-container">
+      <view class="qa-container" v-if="chatDataSource.length">
         <view class="qa-item" v-for="item, index in chatDataSource" :key="item.id">
           <ChatAsk :content="item.showVal" :sessionId="item.sessionId" :uploadFileList="item.uploadFileList"></ChatAsk>
           <ChatAnswer
@@ -164,26 +289,22 @@ onMounted(() => {
             :delay-loading="item.delayLoading"
             :isSatisfied="item.isSatisfied"
             :isVisibleResetBtn="chatDataSource.length - 1 === index"
+            @on-click-icon="params => updateById(params)"
+            @on-click-reset="onChatResetStream(item)"
           ></ChatAnswer>
         </view>
       </view>
     </template>
 
     <template #footer>
-      <ChatInput v-model="inpValue" @on-submit="handleSubmit"></ChatInput>
+      <ChatInput
+        v-model="inpValue"
+        @on-submit="handleSubmit"
+        v-model:loading="isLoading"
+      ></ChatInput>
     </template>
   </BasePublicLayout>
 
-  <!-- 
-    TODO: 抽屉组件 
-    后续完善
-  -->
-  <!-- <uni-drawer ref="showRight" mode="right" :mask-click="false" width="640">
-    <scroll-view style="height: 100%;" scroll-y="true">
-      <button @click="closeDrawer" type="primary">关闭Drawer</button>
-      <view v-for="item in 60" :key="item">可滚动内容 {{ item }}</view>
-    </scroll-view>
-  </uni-drawer> -->
 </template>
 
 <style lang="scss" scoped>
@@ -197,7 +318,11 @@ onMounted(() => {
 
 .qa-container {
   width: 100vw;
-  padding: 64rpx 0;
+  padding: 64rpx 0 24rpx 0;
+
+  .qa-item:not(:last-child) {
+    margin-bottom: 24rpx;
+  }
 }
 
 </style>

+ 17 - 2
src/pages/login/index.vue

@@ -47,9 +47,24 @@ const handleSubmit = async () => {
 
     await uni.showToast({ icon: 'none', title: "登录成功", duration: 2 * 1000});
 
-    setTimeout(() => uni.redirectTo({url: '/pages/home/index'}), 2 * 1000);
+    setTimeout(() => {
+      const pages = getCurrentPages();
+      const prevPage = pages[pages.length - 2];
+
+      if (prevPage && typeof prevPage.$vm.init === "function") {
+        prevPage.$vm.init(); 
+      }
+      
+      uni.navigateBack({ delta: 1 });
+    }, 2000)
+    
+    
+    
+    
+    // uni.redirectTo({url: '/pages/home/index'}), 2 * 1000);
+
   } catch (error) {
-    console.log("打印error", error);
+    console.log("error", error);
   } finally {
     loading.value = false;
   }

+ 159 - 0
src/pages/user/index.vue

@@ -0,0 +1,159 @@
+<script setup>
+import { storeToRefs } from 'pinia';
+import { useUserStore } from '@/stores/modules/userStore';
+
+const userStore = useUserStore();
+const { userInfo: user } = storeToRefs(userStore);
+
+const onLogout = () => {
+  return uni.showModal({
+    title: "提示",
+    content: "请确认,是否退出登录",
+    success: async (status) => {
+      if (status.confirm) {
+        uni.showToast({ title: '退出成功', icon: 'none', duration: 3000, mask: true });
+        setTimeout(() => {
+          userStore.clearUserInfo();
+          uni.navigateTo({ url: '/pages/login/index' });
+        }, 2 * 1000)
+      }
+    }
+  })
+}
+</script>
+
+<template>
+  <BasePublicLayout>
+    <template #header>
+      <BaseNavBar titleText="个人中心" isBack></BaseNavBar>
+    </template>
+    <template #content>
+      <view class="content">
+        <view class="user-avatar">
+          <image :src="userStore.userInfo?.avatar" class="avatar"></image>
+          <view class="user-msg">
+            <text class="username">{{ userStore.userInfo?.nickName }}</text>
+          </view>
+        </view>
+        <view class="user-info">
+          <view class="item">
+            <view class="label">用户名称</view>
+            <view class="value">{{ user.userName }}</view>
+          </view>
+          <view class="item">
+            <view class="label">手机号</view>
+            <view class="value">{{ user.phonenumber }}</view>
+          </view>
+          <view class="item">
+            <view class="label">姓名</view>
+            <view class="value">{{ user.nickName }}</view>
+          </view>
+          <view class="item">
+            <view class="label">职位</view>
+            <view class="value">{{ user.position }}</view>
+          </view>
+          <view class="item">
+            <view class="label">水厂</view>
+            <view class="value">LibraAI污水厂</view>
+          </view>
+          <view class="item">
+            <view class="label">报警手机号</view>
+            <view class="value">{{ user.emergencyPhone }}</view>
+          </view>
+        </view>
+      </view>
+    </template>
+    <template #footer>
+      <view class="footer">
+        <view class="logout-btn" @click="onLogout">退出登录</view>
+      </view>
+    </template>
+  </BasePublicLayout>
+</template>
+
+<style lang="scss">
+.content {
+  width: 100%;
+  padding: 60rpx 50rpx 50rpx 50rpx;
+  box-sizing: border-box;
+
+  .user-avatar {
+    display: flex;
+    align-items: center;
+
+    .avatar {
+      flex-shrink: 0;
+      width: 128rpx;
+      height: 128rpx;
+      margin-right: 36rpx;
+      border: 1px solid #9ecdef;
+      border-radius: 100%;
+      overflow: hidden;
+    }
+
+    .user-msg {
+      display: flex;
+      flex-flow: column;
+
+      .username {
+        color: #212121;
+        font-size: 32rpx;
+        font-weight: 500;
+      }
+
+      .id-code {
+        color: #7B909C;
+        font-size: 26rpx;
+      }
+    }
+
+  }
+
+  .user-info {
+    padding-top: 80rpx;
+
+    .item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      line-height: 22px;
+
+      .label {
+        color: #212121;
+        font-size: 30rpx;
+        font-weight: 500;
+      }
+
+      .value {
+        color: #4F4F4F;
+        font-size: 28rpx;
+      }
+
+      &:not(:first-child) {
+        margin-top: 72rpx;
+      }
+    }
+  }
+}
+
+.footer {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding-bottom: 100rpx;
+  box-sizing: border-box;
+
+  .logout-btn {
+    width: 290rpx;
+    height: 96rpx;
+    flex-shrink: 0;
+    border-radius: 48px;
+    background: #F6FCFF;
+    text-align: center;
+    line-height: 96rpx;
+    color: #979797;
+    font-size: 34rpx;
+    font-weight: 400;
+  }
+}
+</style>

+ 2 - 2
src/stores/modules/chatStore.js

@@ -4,11 +4,11 @@ import { defineStore } from "pinia";
 const dropdown = [
   {
     text: "专家问答",
-    url: "/pages/index/index"
+    url: "/pages/answer/index"
   },
   {
     text: "水质报警",
-    url: "/pages/index/index"
+    url: "/pages/analyse/water/index"
   }
 ]
 

+ 1 - 1
src/stores/modules/userStore.js

@@ -36,6 +36,6 @@ export const useUserStore = defineStore(
           return uni.getStorageSync(key);
         },
       },
-    },
+    }
   }
 );

+ 4 - 4
src/uni_modules/zero-markdown-view/components/mp-html/highlight/config.js

@@ -1,5 +1,5 @@
-export default {
-  copyByClickCode: true, // 点击代码块复制
-  showLanguageName: true, // 是否在代码块右上角显示语言的名称
+export default {
+  copyByClickCode: false, // 点击代码块复制
+  showLanguageName: false, // 是否在代码块右上角显示语言的名称
   showLineNumber: false // 是否显示行号
-}
+}

+ 5 - 5
src/uni_modules/zero-markdown-view/components/mp-html/mp-html.vue

@@ -41,9 +41,9 @@
 import node from './node/node'
 // #endif
 import Parser from './parser'
-import markdown from './markdown/index.js'
-import highlight from './highlight/index.js'
-import style from './style/index.js'
+import markdown from './markdown/index.js'
+import highlight from './highlight/index.js'
+import style from './style/index.js'
 const plugins=[markdown,highlight,style,]
 // #ifdef APP-PLUS-NVUE
 const dom = weex.requireModule('dom')
@@ -58,7 +58,7 @@ export default {
       // #endif
     }
   },
-  props: {
+  props: {
     markdown: Boolean,
     containerStyle: {
       type: String,
@@ -158,7 +158,7 @@ export default {
      * @param {Number} offset 跳转位置的偏移量
      * @returns {Promise}
      */
-    navigateTo (id, offset) {
+    navigateTo (id, offset) {
       id = this._ids[decodeURI(id)] || id
       return new Promise((resolve, reject) => {
         if (!this.useAnchor) {

+ 104 - 104
src/uni_modules/zero-markdown-view/components/mp-html/node/node.vue

@@ -105,7 +105,7 @@ module.exports = {
   }
 }
 </script>
-<script>
+<script>
 
 import node from './node'
 export default {
@@ -137,7 +137,7 @@ export default {
     childs: Array,
     opts: Array
   },
-  components: {
+  components: {
 
     // #ifndef (H5 || APP-PLUS) && VUE3
     node
@@ -175,49 +175,49 @@ export default {
     }
     // #endif
   },
-  methods:{
-	  codeLongTap(e){
-	  	if(e.attrs.class=='hl-pre'){
-			uni.setClipboardData({
-				data: e.attrs['data-content'],
-				showToast:false,
-				success: () => {
-					uni.showToast({
-						title: '代码复制成功',
-						duration: 1000
-					});
-				},
-				fail: (err) => {
-					console.log('err', err);
-				}
-			});
-	  	}
-	  },
-	// codeLongTap(e){
-	// 	console.log('codeLongTap',e.attrs);
-	// 	if(e.attrs.class=='hl-pre'){
-	// 		uni.showActionSheet({
-	// 			itemList: ['复制代码'],
-	// 			success: function (res) {
-	// 				uni.setClipboardData({
-	// 					data: e.attrs['data-content'],
-	// 					showToast:false,
-	// 					success: () => {
-	// 						uni.showToast({
-	// 							title: '代码复制成功',
-	// 							duration: 1000
-	// 						});
-	// 					},
-	// 					fail: (err) => {
-	// 						console.log('err', err);
-	// 					}
-	// 				});
-	// 			},
-	// 			fail: function (res) {
-	// 				console.log(res.errMsg);
-	// 			}
-	// 		});
-	// 	}
+  methods:{
+	  codeLongTap(e){
+	  	if(e.attrs.class=='hl-pre'){
+			uni.setClipboardData({
+				data: e.attrs['data-content'],
+				showToast:false,
+				success: () => {
+					uni.showToast({
+						title: '代码复制成功',
+						duration: 1000
+					});
+				},
+				fail: (err) => {
+					console.log('err', err);
+				}
+			});
+	  	}
+	  },
+	// codeLongTap(e){
+	// 	console.log('codeLongTap',e.attrs);
+	// 	if(e.attrs.class=='hl-pre'){
+	// 		uni.showActionSheet({
+	// 			itemList: ['复制代码'],
+	// 			success: function (res) {
+	// 				uni.setClipboardData({
+	// 					data: e.attrs['data-content'],
+	// 					showToast:false,
+	// 					success: () => {
+	// 						uni.showToast({
+	// 							title: '代码复制成功',
+	// 							duration: 1000
+	// 						});
+	// 					},
+	// 					fail: (err) => {
+	// 						console.log('err', err);
+	// 					}
+	// 				});
+	// 			},
+	// 			fail: function (res) {
+	// 				console.log(res.errMsg);
+	// 			}
+	// 		});
+	// 	}
 	// },
     // #ifdef MP-WEIXIN
     toJSON () { return this },
@@ -445,65 +445,65 @@ export default {
   }
 }
 </script>
-<style>/deep/ .hl-code,/deep/ .hl-pre{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}/deep/ .hl-pre{padding:1em;margin:.5em 0;overflow:auto}/deep/ .hl-pre{background:#2d2d2d}/deep/ .hl-block-comment,/deep/ .hl-cdata,/deep/ .hl-comment,/deep/ .hl-doctype,/deep/ .hl-prolog{color:#999}/deep/ .hl-punctuation{color:#ccc}/deep/ .hl-attr-name,/deep/ .hl-deleted,/deep/ .hl-namespace,/deep/ .hl-tag{color:#e2777a}/deep/ .hl-function-name{color:#6196cc}/deep/ .hl-boolean,/deep/ .hl-function,/deep/ .hl-number{color:#f08d49}/deep/ .hl-class-name,/deep/ .hl-constant,/deep/ .hl-property,/deep/ .hl-symbol{color:#f8c555}/deep/ .hl-atrule,/deep/ .hl-builtin,/deep/ .hl-important,/deep/ .hl-keyword,/deep/ .hl-selector{color:#cc99cd}/deep/ .hl-attr-value,/deep/ .hl-char,/deep/ .hl-regex,/deep/ .hl-string,/deep/ .hl-variable{color:#7ec699}/deep/ .hl-entity,/deep/ .hl-operator,/deep/ .hl-url{color:#67cdcc}/deep/ .hl-bold,/deep/ .hl-important{font-weight:700}/deep/ .hl-italic{font-style:italic}/deep/ .hl-entity{cursor:help}/deep/ .hl-inserted{color:green}/deep/ .md-p {
-  margin-block-start: 1em;
-  margin-block-end: 1em;
-}
-
-/deep/.hl-copy{
-			color:#cccccc;
-		}
-/deep/ .md-table,
-/deep/ .md-blockquote {
-  margin-bottom: 16px;
-}
-
-/deep/ .md-table {
-  box-sizing: border-box;
-  width: 100%;
-  overflow: auto;
-  border-spacing: 0;
-  border-collapse: collapse;
-}
-
-/deep/ .md-tr {
-  background-color: #fff;
-  border-top: 1px solid #c6cbd1;
-}
-
-.md-table .md-tr:nth-child(2n) {
-  background-color: #f6f8fa;
-}
-
-/deep/ .md-th,
-/deep/ .md-td {
-  padding: 6px 13px !important;
-  border: 1px solid #dfe2e5;
-}
-
-/deep/ .md-th {
-  font-weight: 600;
-}
-
-/deep/ .md-blockquote {
-  padding: 0 1em;
-  color: #6a737d;
-  border-left: 0.25em solid #dfe2e5;
-}
-
-/deep/ .md-code {
-  padding: 0.2em 0.4em;
-  font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
-  font-size: 85%;
-  background-color: rgba(27, 31, 35, 0.05);
-  border-radius: 3px;
-}
-
-/deep/ .md-pre .md-code {
-  padding: 0;
-  font-size: 100%;
-  background: transparent;
-  border: 0;
+<style>/deep/ .hl-code,/deep/ .hl-pre{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}/deep/ .hl-pre{padding:1em;margin:.5em 0;overflow:auto}/deep/ .hl-pre{background:#2d2d2d}/deep/ .hl-block-comment,/deep/ .hl-cdata,/deep/ .hl-comment,/deep/ .hl-doctype,/deep/ .hl-prolog{color:#999}/deep/ .hl-punctuation{color:#ccc}/deep/ .hl-attr-name,/deep/ .hl-deleted,/deep/ .hl-namespace,/deep/ .hl-tag{color:#e2777a}/deep/ .hl-function-name{color:#6196cc}/deep/ .hl-boolean,/deep/ .hl-function,/deep/ .hl-number{color:#f08d49}/deep/ .hl-class-name,/deep/ .hl-constant,/deep/ .hl-property,/deep/ .hl-symbol{color:#f8c555}/deep/ .hl-atrule,/deep/ .hl-builtin,/deep/ .hl-important,/deep/ .hl-keyword,/deep/ .hl-selector{color:#cc99cd}/deep/ .hl-attr-value,/deep/ .hl-char,/deep/ .hl-regex,/deep/ .hl-string,/deep/ .hl-variable{color:#7ec699}/deep/ .hl-entity,/deep/ .hl-operator,/deep/ .hl-url{color:#67cdcc}/deep/ .hl-bold,/deep/ .hl-important{font-weight:700}/deep/ .hl-italic{font-style:italic}/deep/ .hl-entity{cursor:help}/deep/ .hl-inserted{color:green}/deep/ .md-p {
+  margin-block-start: 1em;
+  margin-block-end: 1em;
+}
+
+/deep/.hl-copy{
+			color:#cccccc;
+		}
+/deep/ .md-table,
+/deep/ .md-blockquote {
+  margin-bottom: 16px;
+}
+
+/deep/ .md-table {
+  box-sizing: border-box;
+  width: 100%;
+  overflow: auto;
+  border-spacing: 0;
+  border-collapse: collapse;
+}
+
+/deep/ .md-tr {
+  background-color: #fff;
+  border-top: 1px solid #c6cbd1;
+}
+
+.md-table .md-tr:nth-child(2n) {
+  background-color: #f6f8fa;
+}
+
+/deep/ .md-th,
+/deep/ .md-td {
+  padding: 6px 13px !important;
+  border: 1px solid #dfe2e5;
+}
+
+/deep/ .md-th {
+  font-weight: 600;
+}
+
+/deep/ .md-blockquote {
+  padding: 0 1em;
+  color: #6a737d;
+  border-left: 0.25em solid #dfe2e5;
+}
+
+/deep/ .md-code {
+  padding: 0.2em 0.4em;
+  font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
+  font-size: 85%;
+  background-color: rgba(27, 31, 35, 0.05);
+  border-radius: 3px;
+}
+
+/deep/ .md-pre .md-code {
+  padding: 0;
+  font-size: 100%;
+  background: transparent;
+  border: 0;
 }
 /* a 标签默认效果 */
 ._a {

+ 4 - 4
src/uni_modules/zero-markdown-view/components/zero-markdown-view/zero-markdown-view.vue

@@ -81,9 +81,9 @@
 				`,
 					// 二级标题
 					h2: `
-				margin:40px 0 20px 0;	
+				margin:10px 0 20px 0;	
 				font-size: 20px;
-				text-align:center;
+				text-align:left;
 				color:${themeColor};
 				font-weight:bolder;
 				padding-left:10px;
@@ -148,6 +148,7 @@
 					th: `
 				border: 1px solid #202121;
 				color: #555;
+        white-space: nowrap;
 				`,
 					td: `
 				color:#555;
@@ -157,7 +158,7 @@
 				border-radius: 5px;
 				white-space: pre;
 				background: ${codeBgColor};
-				font-size:12px;
+				font-size:14px;
 				position: relative;
 				`,
 				}
@@ -169,7 +170,6 @@
 
 <style lang="scss">
 	.zero-markdown-view {
-		padding: 15rpx;
 		position: relative;
 	}
 </style>

+ 38 - 0
src/utils/enum.js

@@ -0,0 +1,38 @@
+export const ORDER_OPTION_ENUM = {
+  time:         '日期',
+  jsSlq:        '进水水量',
+  jsCod:        'COD',
+  jsTn:         '总氮',
+  jsTp:         '总磷',
+  jsNh3:        '氨氮',
+  jsSs:         'SS',
+  csSlqc:       '出水水量',
+  csCod:        'COD',
+  csTn:         '总氮',
+  csTp:         '总磷',
+  csNh3:        '氨氮',
+  csSs:         'SS',
+  no3Hlj1Jqr:   '1#好氧池硝酸盐',
+  no3Hlj2Jqr:   '2#好氧池硝酸盐',
+  nh31Jqr:      '1#缺氧氨氮',
+  nh32Jqr:      '2#缺氧氨氮',
+  no3Qyc1Jqr:   '1#缺氧池硝酸盐',
+  no3Qyc2Jqr:   '2#缺氧池硝酸盐',
+  tpRccJqr:     '二沉池正磷酸盐'
+}
+
+export const SIMULATE_ENUM = {
+  COD_in:       '进水COD mg/L',
+  DO_O:         '好氧池末端DO (#1 #2) mg/L',
+  MLSS:         'MLSS (#1 #2) mg/L',
+  Q_in:         '进水流量 m3/h',
+  tyjyl:        '碳源药剂投加量 m³/h',
+  r:            '内回流比(#1 #2) %',
+  cltjl:        '除磷药剂投加量m³/h',
+  gwnl:         '干污泥量',
+  hycxsy_all:   '好氧硝酸盐(#1 #2) mg/L',
+  qyan_all:     '缺氧氨氮(#1 #2) mg/L',
+  qyckxsy_all:  '缺氧硝酸盐 mg/L',
+  T:            '水温 ℃',
+  pH:           'pH'
+}

+ 92 - 0
src/utils/format.js

@@ -0,0 +1,92 @@
+import dayjs from "dayjs";
+import { ORDER_OPTION_ENUM } from "./enum";
+
+export const formatToData = ({ dataSource, warnKey, isNoUnit, statusVal }) => {
+  const reuslt = {
+    title: dataSource?.title,
+    list: []
+  }
+  delete dataSource.title;
+  reuslt.list = Object.entries(dataSource).map(([key, value]) => {
+    if ( Number.isFinite(value) ) value = Number(value.toFixed(2));
+    if ( key.includes("值") && !isNoUnit) value = value? value + 'mg/L' : '';
+    if ( key === '状态' ) value =  statusVal;
+    if ( key === '持续时间' ) value = value + "小时"
+    return { label: key, value, isWarning: warnKey === key };
+  });
+  return reuslt;
+}
+
+export const format = {
+  textSorting(dataSource, rule) {
+    const title = dataSource.title || dataSource['进水SS超标报警'];
+
+    const list = rule.map(item => {
+      Object.keys(dataSource).forEach(key => {
+        if (item.realKey === key ) {
+          if ( dataSource[key] !== null ) {
+            item.value = (isNaN(dataSource[key]) ? dataSource[key]  : truncateDecimals(dataSource[key])) + item.value
+          } else{
+            item.value = ''
+          }
+          
+        }
+      })
+      return item;
+    }).filter(({ value }) => value)
+    return { title, list };
+  }
+}
+
+export const truncateDecimals = num => {
+  return Number((Math.floor(num * 100) / 100).toFixed(2));
+}
+
+export const replaceArray = (array, startIndex, length, replacementValue) => {
+  array.splice(startIndex, length, ...Array(length).fill(replacementValue));
+  return array;
+}
+
+export const formatEchart = (data) => {
+  const keys = Array.from(new Set(data.flatMap(item => Object.keys(item).filter(key => key !== 'time'))));
+
+  const xAxisData = data.map(item => item.time);
+  const yAxisData = keys.map(key => ({
+    title: ORDER_OPTION_ENUM[key],
+    key,
+    list: data.map(item => !item[key] ? 0 : Number(item[key].toFixed(2)))
+  }))
+
+  return [xAxisData, yAxisData];
+}
+
+export const colorToRgba = (color, alpha) => {
+  const r = parseInt(color.slice(1, 3), 16);
+  const g = parseInt(color.slice(3, 5), 16);
+  const b = parseInt(color.slice(5, 7), 16);
+
+  return `rgba(${r}, ${g}, ${b}, ${alpha})`
+}
+
+export const isNumberComprehensive = (value) => {
+  return isFinite(value) && !isNaN(parseFloat(value));
+}
+
+
+// 获取昨日时间
+export const getFormatYesterDay = ( data ) => {
+  return data.map(item => {
+    const { tools } = item;
+    if ( tools === 'work_order' ) {
+      const yesterday = dayjs().subtract(1, 'day').format('M月D日');
+      item.content = `帮我生成${yesterday}的工单`;
+      item.prompt = `帮我生成${yesterday}的工单`;;
+    }
+    return item;
+  })
+}
+
+// 格式化小数
+export const formatDecimals = ( num, digits = 2 ) => {
+  return !(typeof num !== 'number' || isNaN(num)) ? Number(Number(num).toFixed(digits)) : ''
+}

+ 7 - 7
src/utils/https.js

@@ -20,10 +20,10 @@ const httpInterceptor = {
 uni.addInterceptor('request', httpInterceptor);
 
 export const http = (options) => {
-  // uni.showLoading({
-  //   title: "加载中...",
-  //   mask: true
-  // })
+  uni.showLoading({
+    title: "加载中...",
+    mask: true
+  })
   return new Promise((resolve, reject) => {
     uni.request({
       ...options,
@@ -31,7 +31,7 @@ export const http = (options) => {
       success(result) {
         const { data: res } = result;
         uni.hideLoading();
-
+        
         switch(res.code){
           case 200:
             resolve(res);
@@ -42,8 +42,8 @@ export const http = (options) => {
             break;
           case 401:
             uni.showToast({ icon: 'none', title: '登录失效, 请重新登录', duration: 2 * 1000, mask: true});
-            setTimeout(_ => uni.navigateTo({ url: "/pages/login/login" }), 2000);
-            // memberStore.clearProfile();
+            setTimeout(_ => uni.navigateTo({ url: "/pages/login/index" }), 2000);
+            userStore.clearUserInfo();
             reject(res)
             break;
            default:

+ 8 - 6
src/utils/streamRequest.js

@@ -10,11 +10,12 @@ const decoder = new TextDecoder("utf-8");
 const decodeUTF8 = arrBuff => decoder.decode(new Uint8Array(arrBuff));
 
 // stream request - chat
-export const streamChatRequest = async ({ data, onProgress, messages, onComplete, onError, onAbort } ) => {
+export const streamChatRequest = async ({ data, onProgress, onSuccess, onComplete, onError, onAbort } ) => {
 
   const url = baseURL + '/grpc/inferStreamRag'
   const Authorization = "Bearer " + token;
 
+  let accumulatedText = "";
   let isStopped = false;
   let typedResult = "";
 
@@ -27,16 +28,17 @@ export const streamChatRequest = async ({ data, onProgress, messages, onComplete
       Authorization,
     },
     data,
-    success: (res) => {
-      onComplete && onComplete();
-      console.log("请求完成", res);
+    success: () => {
+      onSuccess && onSuccess(accumulatedText);
     },
     fail: (err) => {
-      onError(err);
+      onError && onError(err);
     },
+    complete: () => {
+      onComplete && onComplete();
+    }
   });
 
-  let accumulatedText = "";
   let fullMessage = "";
 
   requestTask.onChunkReceived(async (res) => {