yangfeng 5 月之前
父节点
当前提交
94dcf8185d
共有 47 个文件被更改,包括 3087 次插入562 次删除
  1. 79 0
      apps/mobile/src/api/modules/ai-search.js
  2. 1 0
      apps/mobile/src/api/modules/index.js
  3. 二进制
      apps/mobile/src/assets/image/ai-search/bg.png
  4. 二进制
      apps/mobile/src/assets/image/ai-search/bySearch.png
  5. 二进制
      apps/mobile/src/assets/image/ai-search/bySearchIndex.png
  6. 二进制
      apps/mobile/src/assets/image/ai-search/disscale.png
  7. 二进制
      apps/mobile/src/assets/image/ai-search/empty.png
  8. 二进制
      apps/mobile/src/assets/image/ai-search/ip.png
  9. 二进制
      apps/mobile/src/assets/image/ai-search/like-active.png
  10. 二进制
      apps/mobile/src/assets/image/ai-search/like.png
  11. 二进制
      apps/mobile/src/assets/image/ai-search/logo-head.png
  12. 二进制
      apps/mobile/src/assets/image/ai-search/logo.png
  13. 二进制
      apps/mobile/src/assets/image/ai-search/mainAd-getPhoneNum.png
  14. 二进制
      apps/mobile/src/assets/image/ai-search/mainAd-searchResult.png
  15. 二进制
      apps/mobile/src/assets/image/ai-search/mainAd.png
  16. 二进制
      apps/mobile/src/assets/image/ai-search/scale.png
  17. 二进制
      apps/mobile/src/assets/image/ai-search/send.png
  18. 二进制
      apps/mobile/src/assets/image/ai-search/talk.png
  19. 二进制
      apps/mobile/src/assets/image/ai-search/剑鱼AI搜索.png
  20. 二进制
      apps/mobile/src/assets/image/ai-search/订单.png
  21. 二进制
      apps/mobile/src/assets/image/icon/icon-submit.png
  22. 二进制
      apps/mobile/src/assets/image/public/jy-ai-empty.png
  23. 二进制
      apps/mobile/src/assets/image/public/jy-ai-logo.png
  24. 7 0
      apps/mobile/src/assets/style/pic-icon.scss
  25. 18 9
      apps/mobile/src/components/search/Layout.vue
  26. 72 12
      apps/mobile/src/components/search/TopSearch.vue
  27. 12 0
      apps/mobile/src/router/modules/ai.js
  28. 4 2
      apps/mobile/src/ui/empty/index.vue
  29. 1 0
      apps/mobile/src/utils/directive/index.js
  30. 79 0
      apps/mobile/src/utils/directive/modules/touch.js
  31. 1871 0
      apps/mobile/src/views/ai-search/index.vue
  32. 18 0
      apps/mobile/src/views/ai-search/model/index.js
  33. 23 4
      apps/mobile/src/views/search/layout.vue
  34. 45 22
      apps/mobile/src/views/search/middle/bidding/index.vue
  35. 82 4
      apps/mobile/src/views/tabbar/Home.vue
  36. 22 20
      apps/mobile/vite.config.js
  37. 10 9
      plugins/login-auth/package.json
  38. 17 2
      plugins/login-auth/src/api/login.js
  39. 2 0
      plugins/login-auth/src/components.d.ts
  40. 124 0
      plugins/login-auth/src/components/dialog/async-captcha.js
  41. 122 0
      plugins/login-auth/src/components/dialog/captcha-dialog.vue
  42. 1 0
      plugins/login-auth/src/components/form/smsCaptchaInput.vue
  43. 84 0
      plugins/login-auth/src/components/teleport/index.vue
  44. 6 2
      plugins/login-auth/src/components/toast/Toast.vue
  45. 110 0
      plugins/login-auth/src/utils/useSmsVerify.js
  46. 41 73
      plugins/login-auth/src/views/form/login.vue
  47. 236 403
      pnpm-lock.yaml

+ 79 - 0
apps/mobile/src/api/modules/ai-search.js

@@ -0,0 +1,79 @@
+import request from '@/api'
+import qs from 'qs'
+
+export function ajaxGetPromptTypes() {
+  return request({
+    baseURL: '/aiChat',
+    url: '/aiSearch/problem/configuration',
+    method: 'post',
+    noToast: true
+  })
+}
+
+export function ajaxNewTask() {
+  return request({
+    baseURL: '/aiChat',
+    url: '/aiSearch/session/newCreate',
+    method: 'post',
+    noToast: true
+  })
+}
+
+export function ajaxGetMoreList(data) {
+  return request({
+    baseURL: '/aiChat',
+    url: '/aiSearch/bidding/List',
+    method: 'post',
+    noToast: true,
+    data
+  })
+}
+
+export function ajaxActionLike(data) {
+  return request({
+    baseURL: '/aiChat',
+    url: '/aiSearch/answer/like',
+    method: 'post',
+    noToast: true,
+    data
+  })
+}
+
+export function ajaxGetHistoryList(data) {
+  return request({
+    baseURL: '/aiChat',
+    url: '/aiSearch/session/history/list',
+    method: 'post',
+    noToast: true,
+    data
+  })
+}
+
+export function ajaxGetHistoryDetail(data) {
+  return request({
+    baseURL: '/aiChat',
+    url: '/aiSearch/session/detail',
+    method: 'post',
+    noToast: true,
+    params: data
+  })
+}
+
+export function ajaxSetMessage(data) {
+  return request({
+    baseURL: '/aiChat',
+    url: '/aiSearch/chat',
+    method: 'post',
+    noToast: true,
+    data
+  })
+}
+export function ajaxDelTask(data) {
+  return request({
+    baseURL: '/aiChat',
+    url: '/aiSearch/session/delete',
+    method: 'post',
+    noToast: true,
+    data
+  })
+}

+ 1 - 0
apps/mobile/src/api/modules/index.js

@@ -28,3 +28,4 @@ export * from './business'
 export * from './docs'
 export * from './reportanalysis'
 export * from './entgroup'
+export * from './ai-search'

二进制
apps/mobile/src/assets/image/ai-search/bg.png


二进制
apps/mobile/src/assets/image/ai-search/bySearch.png


二进制
apps/mobile/src/assets/image/ai-search/bySearchIndex.png


二进制
apps/mobile/src/assets/image/ai-search/disscale.png


二进制
apps/mobile/src/assets/image/ai-search/empty.png


二进制
apps/mobile/src/assets/image/ai-search/ip.png


二进制
apps/mobile/src/assets/image/ai-search/like-active.png


二进制
apps/mobile/src/assets/image/ai-search/like.png


二进制
apps/mobile/src/assets/image/ai-search/logo-head.png


二进制
apps/mobile/src/assets/image/ai-search/logo.png


二进制
apps/mobile/src/assets/image/ai-search/mainAd-getPhoneNum.png


二进制
apps/mobile/src/assets/image/ai-search/mainAd-searchResult.png


二进制
apps/mobile/src/assets/image/ai-search/mainAd.png


二进制
apps/mobile/src/assets/image/ai-search/scale.png


二进制
apps/mobile/src/assets/image/ai-search/send.png


二进制
apps/mobile/src/assets/image/ai-search/talk.png


二进制
apps/mobile/src/assets/image/ai-search/剑鱼AI搜索.png


二进制
apps/mobile/src/assets/image/ai-search/订单.png


二进制
apps/mobile/src/assets/image/icon/icon-submit.png


二进制
apps/mobile/src/assets/image/public/jy-ai-empty.png


二进制
apps/mobile/src/assets/image/public/jy-ai-logo.png


+ 7 - 0
apps/mobile/src/assets/style/pic-icon.scss

@@ -246,3 +246,10 @@
   background-image: url(@/assets/image/icon/sun-3.png);
   background-size: contain;
 }
+
+.icon-ai-by-search {
+  background-image: url(@/assets/image/ai-search/bySearch.png);
+}
+.icon-ai-by-search-index {
+  background-image: url(@/assets/image/ai-search/bySearchIndex.png);
+}

+ 18 - 9
apps/mobile/src/components/search/Layout.vue

@@ -1,13 +1,17 @@
 <template>
   <div class="j-container layout-search">
     <div
+      v-if="!isLogin"
       class="no-login-header"
       :class="{
         'app-no-login-header': $envs.inApp && !isLogin
       }"
-      v-if="!isLogin"
     >
-      <img class="img-logo" src="@/assets/image/public/logo-banner-full.png" alt="logo">
+      <img
+        class="img-logo"
+        src="@/assets/image/public/logo-banner-full.png"
+        alt="logo"
+      />
     </div>
     <top-search
       :class="{
@@ -16,21 +20,22 @@
       :value="value"
       :placeholder="placeholder"
       :show-button="showButton"
+      :show-ai-button="showAiButton"
       @back="onSearch('back')"
       @clear="onSearch('clear')"
       @submit="onSearch('submit', $event)"
       @input="onSearch('input', $event)"
-    ></top-search>
+    />
     <div class="j-main layout-search-content">
-      <slot name="default"></slot>
+      <slot name="default" />
     </div>
   </div>
 </template>
 
 <script>
-import TopSearch from '@/components/search/TopSearch'
 import { debounce } from 'lodash'
 import { mapGetters } from 'vuex'
+import TopSearch from '@/components/search/TopSearch'
 
 export default {
   name: 'LayoutSearch',
@@ -51,6 +56,10 @@ export default {
       type: Boolean,
       default: true
     },
+    showAiButton: {
+      type: Boolean,
+      default: false
+    },
     value: {
       type: String,
       default: ''
@@ -106,14 +115,14 @@ export default {
   .app-header-top {
     padding-top: $app-header-padding-top;
   }
-  .no-login-header{
+  .no-login-header {
     padding: 8px 0;
-    background: linear-gradient(#05B6CD, #4FCBDB);
+    background: linear-gradient(#05b6cd, #4fcbdb);
     text-align: center;
   }
-  .app-no-login-header{
+  .app-no-login-header {
     padding-top: $app-header-padding-top;
-    background: linear-gradient(#00AEE5, #05B6CD, #4FCBDB);;
+    background: linear-gradient(#00aee5, #05b6cd, #4fcbdb);
   }
 
   ::v-deep {

+ 72 - 12
apps/mobile/src/components/search/TopSearch.vue

@@ -1,17 +1,17 @@
 <template>
-  <div class="hd-search-group">
+  <div class="hd-search-group" :class="{ 'has-ai-button': showAiButton }">
     <div class="back-icon" @click="$emit('back')">
       <AppIcon name="back" />
     </div>
     <van-form
       class="search-form"
-      @submit="$emit('submit', value)"
       action="javascript:return true"
+      @submit="$emit('submit', value)"
     >
       <van-field
         clearable
         class="input-group"
-        :class="{ 'fix-right': !showButton, border: inputFocus }"
+        :class="{ 'fix-right': !showButton, 'border': inputFocus }"
         type="search"
         :value="value"
         :maxlength="maxlength"
@@ -25,17 +25,40 @@
         <template #left-icon>
           <AppIcon name="search" />
         </template>
+        <template v-if="showAiButton" #right-icon>
+          <button
+            v-show="showButton"
+            class="search-button clickable"
+            type="submit"
+          >
+            {{ buttonText }}
+          </button>
+        </template>
       </van-field>
-      <button v-show="showButton" class="search-button clickable" type="submit">
-        {{ buttonText }}
-      </button>
+      <div class="search-form-suffix">
+        <span
+          v-if="showAiButton"
+          class="j-icon j-base-icon icon-ai-by-search"
+          @click.stop.prevent="toAskAi"
+        />
+        <template v-else>
+          <button
+            v-show="showButton"
+            class="search-button clickable"
+            type="submit"
+          >
+            {{ buttonText }}
+          </button>
+        </template>
+      </div>
     </van-form>
   </div>
 </template>
 
 <script>
-import { AppIcon } from '@/ui'
 import { Field, Form } from 'vant'
+import { AppIcon } from '@/ui'
+
 export default {
   name: 'TopSearch',
   components: {
@@ -56,6 +79,10 @@ export default {
       type: Boolean,
       default: true
     },
+    showAiButton: {
+      type: Boolean,
+      default: false
+    },
     buttonText: {
       type: String,
       default: '搜索'
@@ -69,17 +96,26 @@ export default {
       default: ''
     }
   },
-  computed: {
-    getPlaceholder() {
-      return this.placeholder || '多个关键词用空格隔开'
-    }
-  },
   data() {
     return {
       inputFocus: false
     }
   },
+  computed: {
+    getPlaceholder() {
+      return this.placeholder || '多个关键词用空格隔开'
+    }
+  },
   methods: {
+    toAskAi() {
+      console.log(123123)
+      this.$router.push({
+        path: '/ai/search',
+        query: {
+          from: 'top-search'
+        }
+      })
+    },
     formatter(value) {
       return value.replace(/\s{2,}/g, ' ')
     },
@@ -107,6 +143,12 @@ $search-input-group-height: 36px;
 $search-line-height: 20px;
 $search-size--icon: 20px;
 $search-size--icon-back: 24px;
+
+::v-deep {
+  .van-field__control {
+  }
+}
+
 .hd-search-group {
   display: flex;
   flex-direction: row;
@@ -165,6 +207,7 @@ $search-size--icon-back: 24px;
         content: unset;
       }
       &__control::placeholder {
+        font-size: 14px;
         color: $search-color--placeholder;
       }
       .van-icon-clear {
@@ -173,5 +216,22 @@ $search-size--icon-back: 24px;
       }
     }
   }
+  &.has-ai-button {
+    ::v-deep {
+      .van-field {
+        padding-right: 0;
+      }
+    }
+  }
+  .search-form-suffix {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  .icon-ai-by-search {
+    margin: 0 8px;
+    width: 34px;
+    height: 34px;
+  }
 }
 </style>

+ 12 - 0
apps/mobile/src/router/modules/ai.js

@@ -0,0 +1,12 @@
+// 数据超市路由
+export default [
+  {
+    path: '/search',
+    name: 'ai-search',
+    component: () => import('@/views/ai-search/index.vue'),
+    meta: {
+      header: false,
+      title: '剑鱼AI搜索'
+    }
+  }
+]

+ 4 - 2
apps/mobile/src/ui/empty/index.vue

@@ -39,7 +39,9 @@ export default {
       chagrin: 'empty-chagrin',
       sleep: 'empty-sleep',
       smile: 'empty-smile',
-      box: 'empty-box'
+      box: 'empty-box',
+      ai: 'empty-ai-empty',
+      'ai-logo': 'empty-ai-logo'
     }
   })
 }
@@ -104,7 +106,7 @@ export default {
     margin-top: -50px;
   }
 
-  $empty-types: (back, cry, chagrin, sleep, smile, box);
+  $empty-types: (back, cry, chagrin, sleep, smile, box, 'ai-empty', 'ai-logo');
 
   @each $empty in $empty-types {
     .empty-#{$empty} {

+ 1 - 0
apps/mobile/src/utils/directive/index.js

@@ -3,3 +3,4 @@ import './modules/keep-label'
 import './modules/leave-info'
 import './modules/visited'
 import './modules/click-outside'
+import './modules/touch'

+ 79 - 0
apps/mobile/src/utils/directive/modules/touch.js

@@ -0,0 +1,79 @@
+import Vue from 'vue'
+
+Vue.directive('tap', {
+  bind(el, binding) {
+    let startX, startY;
+    let isTap = false;
+
+    const handleStart = (e) => {
+      if (e.touches) {
+        startX = e.touches[0].clientX;
+        startY = e.touches[0].clientY;
+      } else {
+        startX = e.clientX;
+        startY = e.clientY;
+      }
+
+      isTap = true;
+
+      // 添加点击事件监听器
+      el.addEventListener('click', handleClick, { once: true });
+    };
+
+    const handleEnd = (e) => {
+      if (!isTap) return;
+
+      let currentX, currentY;
+
+      if (e.changedTouches) {
+        currentX = e.changedTouches[0].clientX;
+        currentY = e.changedTouches[0].clientY;
+      } else {
+        currentX = e.clientX;
+        currentY = e.clientY;
+      }
+
+      const moveX = Math.abs(currentX - startX);
+      const moveY = Math.abs(currentY - startY);
+
+      if (moveX < 10 && moveY < 10) {
+        // 触发 tap 事件
+        binding.value();
+      }
+
+      isTap = false;
+      // 移除点击事件监听器
+      el.removeEventListener('click', handleClick);
+    };
+
+    const handleClick = (e) => {
+      if (!isTap) {
+        // 如果 tap 未触发,才触发 click
+        binding.arg === 'click' && el.clickHandler(e); // 如果是完整的 click 事件
+      }
+    };
+
+    // 添加触摸和鼠标事件监听
+    el.addEventListener('touchstart', handleStart);
+    el.addEventListener('touchend', handleEnd);
+    el.addEventListener('mousedown', handleStart);
+    el.addEventListener('mouseup', handleEnd);
+
+    // 存储事件处理函数以便在解绑时移除
+    el.__vTapHandlers = {
+      handleStart,
+      handleEnd,
+      handleClick
+    };
+  },
+  unbind(el) {
+    const handlers = el.__vTapHandlers;
+    el.removeEventListener('touchstart', handlers.handleStart);
+    el.removeEventListener('touchend', handlers.handleEnd);
+    el.removeEventListener('mousedown', handlers.handleStart);
+    el.removeEventListener('mouseup', handlers.handleEnd);
+    el.removeEventListener('click', handlers.handleClick);
+
+    delete el.__vTapHandlers;
+  }
+});

+ 1871 - 0
apps/mobile/src/views/ai-search/index.vue

@@ -0,0 +1,1871 @@
+<script setup>
+import { computed, getCurrentInstance, onMounted, ref } from 'vue'
+import { Field, Loading, Overlay, Popover, Popup, Swipe, SwipeItem } from 'vant'
+import qs from 'qs'
+import AppEmpty from '@/ui/empty/index.vue'
+import {
+  ajaxActionLike,
+  ajaxDelTask,
+  ajaxGetHistoryDetail,
+  ajaxGetHistoryList,
+  ajaxGetMoreList,
+  ajaxGetPromptTypes,
+  ajaxNewTask,
+  ajaxSetMessage,
+  getAccountInfo
+} from '@/api/modules'
+import ProjectCell from '@/ui/project-cell/index.vue'
+import {
+  dateFormatter,
+  formatMoney,
+  getRandomString,
+  openAppOrWxPage,
+  openLinkOfOther
+} from '@/utils'
+import { LINKS } from '@/data'
+
+const that = getCurrentInstance().proxy
+
+const rightAction = ref({
+  show: false,
+  actions: [
+    { text: '新对话', className: 'highlight-text' },
+    { text: '历史对话' }
+  ]
+})
+
+function doBack() {
+  trickClick(`退出页面`)
+  that.$router.back()
+}
+
+function onSelectRightAction(action) {
+  console.log('action', action)
+  trickClick(action)
+  if (action.text === '新对话') {
+    doNewQuestion()
+  } else {
+    doAjaxGetHistoryList()
+    historyModel.value.show = true
+  }
+}
+
+const historyModel = ref({
+  show: false,
+  loading: false,
+  list: []
+})
+
+const historyListLabel = computed(() => {
+  const periods = ['今天', '过去7天', '过去30天', '更早']
+  return periods.filter((period) =>
+    Array.isArray(historyModel.value.list[period])
+  )
+})
+
+const moreListModel = ref({
+  show: false,
+  loading: false,
+  selectId: '',
+  list: []
+})
+
+function doNewQuestion() {
+  trickClick(`新对话`)
+  historyModel.value.show = false
+  clearAskState()
+  doSelectQuestionType(promptModel.value.typeList[0])
+}
+
+function trickClick(name, opts = {}) {
+  console.log(`ai-${name}`, opts)
+  try {
+    window.__EasyJTrack.addTrack(name, {
+      breaker_name: name,
+      product_name: 'AI搜索',
+      breaker_id: 'ai-search',
+      desc: JSON.stringify(opts),
+      date: Date()
+    })
+  } catch (e) {
+    console.log(e)
+  }
+}
+
+async function goHistory(item) {
+  trickClick(`点击历史记录-${item.id}-${item.question}`, item.id)
+  historyModel.value.show = false
+  console.log('item', item)
+  return ajaxGetHistoryDetail({
+    sid: item.id
+  }).then((res) => {
+    clearAskState()
+    questionModel.value.nowId = item.id
+
+    if (Array.isArray(res.data)) {
+      res.data.forEach((v) => {
+        // 回显问题
+        doSendMessageOfUser(v.question)
+        // 回显答复
+        v.answer.id = v.id
+
+        const askItem = Object.assign(
+          {
+            message: v.question,
+            id: v.id,
+            list: [],
+            like: v.like || 0,
+            state: 0
+          },
+          formatAskAnswer(v.answer)
+        )
+
+        askModel.value.list.push({
+          ...askItem,
+          _data: v.answer
+        })
+      })
+      that.$nextTick(() => {
+        scrollToBottom()
+      })
+    }
+  })
+}
+
+const promptModel = ref({
+  show: true,
+  current: 0,
+  type: '',
+  typeList: [
+    // {
+    //   text: '看标讯',
+    //   key: 'bx',
+    //   icon: 'icon-wenjian',
+    //   disabled: false,
+    //   toast: ''
+    // }
+  ],
+  list: [
+    // {
+    //   text: '111我是 [XXX公司] 的 [采销员],我想看 [华北地区] 最近一个月与 [光刻胶] 相关的标讯。'
+    // }
+  ]
+})
+
+function onChangePrompt(index) {
+  trickClick(`切换提示词`)
+  promptModel.value.current = index
+}
+
+const promptEle = ref(null)
+function doChangePrompt(val) {
+  trickClick(`切换提示词`)
+  if (val) {
+    promptEle.value.next()
+  } else {
+    promptEle.value.prev()
+  }
+}
+
+function doSelectPrompt(item) {
+  changeInputHeight()
+
+  questionModel.value.input = item.text
+  promptModel.value.show = false
+  promptModel.value.type = ''
+
+  triggerFocus()
+
+  that.$nextTick(() => {
+    getQuestionInputHeight()
+  })
+
+  trickClick('选中提示词', {
+    text: item.text
+  })
+}
+
+const questionModel = ref({
+  nowId: '',
+  input: '',
+  inputTheme: 'scale',
+  style: {},
+  iconShow: false
+})
+
+const questionInputEl = ref(null)
+
+function getQuestionInputHeight() {
+  console.log('cccc height')
+  const result = {
+    height: '100%'
+  }
+  let canShow = false
+
+  if (questionInputEl?.value && questionModel.value.inputTheme === 'scale') {
+    const el = questionInputEl.value.$el.querySelector('textarea')
+    if (el.clientHeight > 72) {
+      result.height = `${el.clientHeight}px !important`
+    }
+    canShow = el.scrollHeight > 60
+  }
+  const nowKey = `${Date.now()}_${questionModel.value.input.length}`
+  questionModel.value.style = {
+    height: result.height,
+    'data-up': nowKey
+  }
+  questionModel.value.iconShow = canShow
+}
+
+function changeInputHeight() {
+  if (questionInputEl.value) {
+    questionInputEl.value.$el.querySelector('textarea').style.height = '24px'
+  }
+}
+
+onMounted(() => {
+  that.$nextTick(() => {
+    changeInputHeight()
+  })
+})
+
+const questionModelOptions = computed(() => {
+  if (questionModel.value.inputTheme === 'full') {
+    return {}
+  }
+  return {
+    row: 1,
+    autosize: {
+      maxHeight: 96,
+      minHeight: 24
+    }
+  }
+})
+
+function formatTypeofItemKey(params, type, spare = '-') {
+  let formatFn = () => spare
+  switch (type) {
+    case 'money': {
+      formatFn = (params) => formatMoney(Number(params))
+      break
+    }
+    case 'money-table': {
+      formatFn = (params) =>
+        formatMoney(Number(params), { type: 'number', level: 1 })
+      break
+    }
+    case 'date': {
+      formatFn = (params) => dateFormatter(Number(params) * 1000)
+      break
+    }
+    case 'date-ms': {
+      formatFn = (params) => dateFormatter(Number(params) * 1000, 'yyyy-MM-dd')
+      break
+    }
+  }
+  if (params) {
+    return typeof params === 'string' ? params : formatFn(params)
+  }
+  return spare
+}
+
+function preSortItem(item, splitKeys) {
+  console.log('cee', item)
+  if (!item) return {}
+  const { area, city, collect, projectInfo } = item
+  item.id = item.infoId
+  item.star = !!collect
+  item.splitKeys = splitKeys
+  // 参标参数
+  item.isCB = {
+    id: '',
+    value: 0
+  }
+  // 是否有附件
+  item.isFile = item?.fileExists || false
+  item.leftTopBadgeText = item.site === '剑鱼信息发布平台' ? '业主委托项目' : ''
+  // 拟建项目独有参数
+  if (projectInfo) {
+    Object.assign(item, projectInfo)
+  }
+  const region = city || area
+  const buyerClass =
+    item?.buyerClass && item?.buyerClass !== '其它'
+      ? item?.buyerClass
+      : undefined
+
+  // 标签
+  item.tagList = [
+    region || '',
+    buyerClass,
+    item.industry,
+    item?.type || item?.subtype,
+    // 有中标金额取中标金额,没有取预算,预算没有置空
+    formatTypeofItemKey(item?.bidamount || item?.budget, 'money', '')
+  ].filter((v) => v)
+
+  item.dateTime = item.publishtime ? item.publishtime * 1000 : ''
+
+  // 整理企业画像数据
+  let winnerList = Array.isArray(item.winnerInfo) ? item.winnerInfo : []
+  winnerList = winnerList.map((w) => {
+    return {
+      text: w.winner,
+      id: w.winnerId
+    }
+  })
+
+  item.vKid = `${item.id}--${getRandomString(8).toLowerCase()}`
+
+  // 详细列表数据
+  item.detailList = [
+    {
+      label: '采购单位',
+      splitter: ':',
+      text: Array.isArray(item.buyer) ? item.buyer.join(',') : item.buyer || '',
+      highlightText: true,
+      detailTextSlot: 'buyerText'
+    },
+    {
+      label: '预算金额',
+      splitter: ':',
+      text: formatTypeofItemKey(item?.budget, 'money', '')
+    },
+    {
+      label: '代理机构',
+      splitter: ':',
+      text: item.agency || ''
+    },
+    {
+      label: '中标单位',
+      splitter: ':',
+      text: Array.isArray(item.winnerInfo)
+        ? item.winnerInfo.map((w) => w.winner).join(',')
+        : '',
+      detailTextSlot: 'winnerText',
+      children: winnerList
+    },
+    {
+      label: '中标金额',
+      splitter: ':',
+      text: formatTypeofItemKey(item?.bidamount, 'money', '')
+    },
+    {
+      label: '报名截止日期',
+      splitter: ':',
+      text: item.signEndTime
+        ? dateFormatter(item.signEndTime * 1000, 'yyyy-MM-dd')
+        : ''
+    },
+    {
+      label: '投标截止日期',
+      splitter: ':',
+      text: item.bidEndTime
+        ? dateFormatter(item.bidEndTime * 1000, 'yyyy-MM-dd')
+        : ''
+    },
+    {
+      label: '开标日期',
+      splitter: ':',
+      text: formatTypeofItemKey(item.bidOpenTime, 'date-ms', '')
+    }
+  ]
+
+  return item
+}
+function formatCellItem(v, splitKeys) {
+  return preSortItem(v, splitKeys)
+}
+
+function formatAskAnswer(res) {
+  let resultState = 1
+  const askItem = {
+    list: [],
+    state: 0
+  }
+  // 接口成功
+  if (res.status === 1) {
+    if (res.count === 0) {
+      resultState = 2
+    } else {
+      resultState = 3
+      askItem.list = (res.list || []).map((v) => {
+        return formatCellItem(v, res.highlight || [])
+      })
+      if (res.id) {
+        askItem.id = res.id
+      }
+      askItem.count = res.count
+    }
+  } else {
+    resultState = 1
+  }
+
+  askItem.state = resultState
+  return askItem
+}
+
+async function doAjaxSendMessage(question, type = '') {
+  const trimQuestion = question.trim()
+  doSendMessageOfUser(trimQuestion)
+
+  let askItem = {
+    message: trimQuestion,
+    id: '',
+    like: 0,
+    list: [],
+    state: 0
+  }
+
+  askItem.fId = doSendMessageOfCustom(askItem)
+
+  await ajaxSetMessage({
+    sid: questionModel.value.nowId,
+    question: trimQuestion,
+    item: promptModel.value.type || '1',
+    type
+  })
+    .then((res) => {
+      console.log('x', res)
+      askItem = Object.assign(askItem, formatAskAnswer(res))
+    })
+    .catch((e) => {
+      console.log('e', e)
+      askItem.state = 1
+    })
+
+  changeMessageItem(askItem.fId, askItem)
+  setTimeout(() => {
+    scrollToBottom()
+  }, 200)
+}
+
+async function doSubmitMessage() {
+  if (!questionModel.value.nowId) {
+    await ajaxNewTask()
+      .then((res) => {
+        console.log('xxx', res)
+        if (res.data) {
+          questionModel.value.nowId = res.data
+        } else {
+          that.$toast('会话创建失败,请稍后再试')
+        }
+      })
+      .catch(() => {
+        that.$toast('会话创建失败,请稍后再试')
+      })
+  }
+  const question = questionModel.value.input
+  trickClick(`提交问题`, { text: question })
+  doAjaxSendMessage(question)
+}
+
+function clearAskState() {
+  // 清除会话
+  askModel.value.list = []
+  // 清除输入状态
+  questionModel.value.nowId = ''
+  questionModel.value.input = ''
+  questionModel.value.inputTheme = 'scale'
+  // 清除选中 prompt
+  promptModel.value.type = ''
+  promptModel.value.show = false
+  that.$nextTick(() => {
+    changeInputHeight()
+    getQuestionInputHeight()
+  })
+}
+
+function doSendMessageOfUser(message) {
+  askModel.value.list.push({
+    user: true,
+    message
+  })
+  questionModel.value.input = ''
+  questionModel.value.inputTheme = 'scale'
+
+  that.$nextTick(() => {
+    changeInputHeight()
+    getQuestionInputHeight()
+    scrollToBottom()
+  })
+}
+function doSendMessageOfCustom(data) {
+  const { message, state, list, count } = data
+  const findId = `f_${Date.now()}_${Math.random()}`
+  askModel.value.list.push({
+    fId: findId,
+    user: false,
+    state,
+    list,
+    count,
+    message,
+    _data: data
+  })
+  return findId
+}
+
+function scrollToBottom() {
+  that.$nextTick(() => {
+    contentBottomEl.value.scrollIntoView({ behavior: 'smooth' })
+  })
+}
+
+function changeMessageItem(id, data) {
+  const arr = askModel.value.list
+  // 使用 for 循环倒序查找
+  for (let i = askModel.value.list.length - 1; i >= 0; i--) {
+    const item = askModel.value.list[i]
+    if (item.fId === id) {
+      that.$set(askModel.value.list, i, { ...item, ...data })
+      console.log(`Item with id ${id} updated.`)
+      return
+    }
+  }
+  // 如果没有找到匹配的项,输出提示信息
+  console.log(`No item found with id ${id}.`)
+}
+
+function doClickInputIcon(type) {
+  console.log('t', type)
+  trickClick('发送', {
+    type
+  })
+  switch (type) {
+    case 'full': {
+      questionModel.value.inputTheme = 'full'
+      break
+    }
+    case 'scale': {
+      questionModel.value.inputTheme = 'scale'
+      break
+    }
+    case 'submit': {
+      if (questionModel.value.input !== '') {
+        questionModel.value.inputTheme = 'scale'
+        promptModel.value.show = false
+
+        doSubmitMessage()
+      }
+
+      break
+    }
+  }
+}
+
+function doSelectQuestionType(item) {
+  if (item.key === promptModel.value.type) {
+    promptModel.value.type = ''
+    promptModel.value.list = []
+    promptModel.value.show = false
+    return
+  }
+  if (item.disabled) {
+    that.$toast({
+      message: item.toast,
+      className: 'one-toast'
+    })
+  } else {
+    promptModel.value.current = 0
+    promptModel.value.type = item.key
+    promptModel.value.list = item.options
+    promptModel.value.show = true
+    fixSwipeResize()
+  }
+
+  trickClick('选择提问类型', {
+    text: item.text
+  })
+}
+
+function fixSwipeResize () {
+  that.$nextTick(() => {
+    if (promptEle.value) {
+      promptEle.value.resize()
+    }
+  })
+}
+
+const askStateMap = {
+  0: '搜索中...',
+  1: '服务器繁忙,请稍后再试',
+  2: '很抱歉,我没能搜索到您想要的信息,您可以调整描述,',
+  3: '搜索完毕'
+}
+
+const askModel = ref({
+  list: [
+    // {
+    //   user: true,
+    //   message: '我想看最近一个月与光刻胶相关的信息'
+    // },
+    // {
+    //   user: false,
+    //   state: 2
+    // }
+  ]
+})
+
+function resetQuestion(item) {
+  changeInputHeight()
+  questionModel.value.input = item.message
+  triggerFocus()
+  that.$nextTick(() => {
+    getQuestionInputHeight()
+  })
+
+  trickClick('重新提问', {
+    id: item.id,
+    message: item.message
+  })
+}
+
+function doAjaxGetHistoryList() {
+  ajaxGetHistoryList().then((res) => {
+    if (res?.data?.list) {
+      historyModel.value.list = res.data.list
+    }
+  })
+}
+
+function goToDetail(item) {
+  savePageState()
+  const query = {}
+  if (item.splitKeys) {
+    query.keywords = item.splitKeys.join(' ')
+  }
+  trickClick('点击跳转详情页', {
+    title: item.title,
+    id: item.id
+  })
+  openAppOrWxPage({
+    wx: `${LINKS.标讯详情页前缀.wx + item.id}.html?${qs.stringify(query)}`,
+    app: `${LINKS.标讯详情页前缀.app + item.id}.html?${qs.stringify(query)}`,
+    h5: `${LINKS.标讯详情页前缀.h5 + item.id}.html?${qs.stringify(query)}`
+  })
+}
+
+function doCollection(item, index) {
+  savePageState()
+  that.$keep.action({
+    status: item.star,
+    id: item.id,
+    complete: ({ type, message }) => {
+      if (type) {
+        item.star = !item.star
+        that.$forceUpdate()
+      }
+    }
+  })
+  trickClick('点击标讯收藏', {
+    title: item.title,
+    id: item.id
+  })
+}
+
+function doLookMoreList(item) {
+  console.log('xx', item)
+  moreListModel.value.show = true
+  moreListModel.value.loading = true
+  moreListModel.value.selectId = item.id
+  ajaxGetMoreList({
+    chatId: item.id
+  })
+    .then((res) => {
+      if (res.data) {
+        moreListModel.value.list = (res.data || []).map((v) => {
+          return formatCellItem(v, res.highlight || item._data?.highlight || [])
+        })
+      }
+      moreListModel.value.loading = false
+    })
+    .catch(() => {
+      moreListModel.value.loading = false
+    })
+  trickClick('查看更多', {
+    message: item.message,
+    id: item.id
+  })
+}
+function doCellAction(item, type) {
+  console.log(item)
+  if (type === 'reload') {
+    doAjaxSendMessage(item.message, type)
+    trickClick('cell-action-刷新', {
+      message: item.message,
+      id: item.id
+    })
+  } else {
+    item.like = Number(!item.like)
+    ajaxActionLike({
+      cid: item.id,
+      val: item.like
+    }).then((res) => {
+      // that.$toast('感谢')
+    })
+    trickClick('cell-action-点赞', {
+      message: item.message,
+      id: item.id
+    })
+  }
+}
+
+const moreLike = computed(() => {
+  const item = askModel.value.list.find(
+    (v) => v.id === moreListModel.value.selectId
+  )
+  console.log(item)
+  return item || {}
+})
+
+function doMoreLike() {
+  doCellAction(moreLike.value, 'zan')
+}
+
+const pageUserInfo = ref({})
+const needLogin = ref(false)
+const needBindPhone = ref(false)
+// 获取用户信息
+async function getUserInfo() {
+  try {
+    const { error_code: code, data = {}, error } = await getAccountInfo()
+    if (error === '需要登录!') {
+      needLogin.value = true
+    }
+    if (code === 0) {
+      pageUserInfo.value = data
+      const { phone } = pageUserInfo.value
+      needBindPhone.value = !phone
+      console.log('o', phone)
+    }
+    return data
+  } catch (error) {
+    return {}
+  }
+}
+// 绑定手机号
+function checkBindPhone() {
+  if (needLogin.value) {
+    return openLinkOfOther(LINKS.APP登录页.app, {
+      query: {
+        to: 'back'
+      }
+    })
+  }
+  if (needBindPhone.value) {
+    const query = {
+      mode: 'mergeBind'
+    }
+    return openAppOrWxPage(LINKS.绑定手机号, { query })
+  }
+}
+
+function doDelTask(item) {
+  trickClick('删除对话记录', { id: item.id })
+  console.log('item', item)
+  that.$dialog
+    .confirm({
+      message: '是否要删除该对话记录?',
+      confirmButtonColor: '#ee0a24'
+    })
+    .then(() => {
+      ajaxDelTask({ sid: item.id }).then((res) => {
+        if (res.data) {
+          that.$toast('删除成功')
+          doAjaxGetHistoryList()
+          if (item.id === questionModel.value.nowId) {
+            doNewQuestion()
+          }
+        }
+      })
+    })
+    .catch(() => {})
+}
+
+const cacheToLocalStorage = (key, state) => {
+  localStorage.setItem(key, JSON.stringify(state.value))
+}
+
+// 函数:通用读取函数,支持自定义 key
+const loadFromLocalStorage = (key, state) => {
+  const cachedData = localStorage.getItem(key)
+  if (cachedData) {
+    Object.assign(state.value, JSON.parse(cachedData))
+  }
+}
+
+const contentEl = ref(null)
+const contentBottomEl = ref(null)
+const moreContentEl = ref(null)
+
+function getSetScrollTop(top) {
+  if (contentEl.value) {
+    if (top) {
+      contentEl.value.scrollTo(0, top)
+    } else {
+      return contentEl.value.scrollTop
+    }
+  }
+}
+
+function getSetMoreScrollTop(top) {
+  if (moreContentEl.value) {
+    if (top) {
+      moreContentEl.value.scrollTo(0, top)
+    } else {
+      return moreContentEl.value.scrollTop
+    }
+  }
+}
+
+function savePageState() {
+  localStorage.setItem('ai-search-cache', 'use')
+  localStorage.setItem('ai-search-height', getSetScrollTop())
+  localStorage.setItem('ai-search-more-height', getSetMoreScrollTop())
+  cacheToLocalStorage('ai-search-historyModel', historyModel)
+  cacheToLocalStorage('ai-search-moreListModel', moreListModel)
+  cacheToLocalStorage('ai-search-promptModel', promptModel)
+  cacheToLocalStorage('ai-search-questionModel', questionModel)
+  cacheToLocalStorage('ai-search-askModel', askModel)
+}
+function echoPageState() {
+  loadFromLocalStorage('ai-search-historyModel', historyModel)
+  loadFromLocalStorage('ai-search-moreListModel', moreListModel)
+  loadFromLocalStorage('ai-search-promptModel', promptModel)
+  loadFromLocalStorage('ai-search-questionModel', questionModel)
+  loadFromLocalStorage('ai-search-askModel', askModel)
+  // 重置
+  localStorage.removeItem('ai-search-historyModel')
+  localStorage.removeItem('ai-search-moreListModel')
+  localStorage.removeItem('ai-search-promptModel')
+  localStorage.removeItem('ai-search-questionModel')
+  localStorage.removeItem('ai-search-askModel')
+  localStorage.removeItem('ai-search-cache')
+
+  if (moreListModel.value.show) {
+    const top = localStorage.getItem('ai-search-more-height')
+    console.log('moretop', top)
+    if (top) {
+      that.$nextTick(() => {
+        getSetMoreScrollTop(top)
+      })
+    }
+    localStorage.removeItem('ai-search-more-height')
+  }
+
+  if (questionModel.value.nowId) {
+    goHistory({
+      id: questionModel.value.nowId
+    }).then(() => {
+      const top = localStorage.getItem('ai-search-height')
+      if (top) {
+        that.$nextTick(() => {
+          getSetScrollTop(top)
+        })
+      }
+      localStorage.removeItem('ai-search-height')
+    })
+  }
+}
+
+function triggerFocus () {
+  questionInputEl.value.focus()
+  that.$nextTick(() => {
+    questionInputEl.value.focus()
+  })
+}
+
+// 初始化事件
+function init() {
+  if (localStorage.getItem('ai-search-cache') === 'use') {
+    echoPageState()
+  } else {
+    ajaxGetPromptTypes().then((res) => {
+      console.log('x', res)
+      if (res?.data) {
+        promptModel.value.typeList = res.data.map((v) => {
+          return {
+            text: v.goodsName,
+            key: v.goodsType,
+            options: (v.problem || []).map((s) => ({ text: s })),
+            icon: 'icon-wenjian',
+            disabled: !v.isUsed,
+            toast: '剑鱼正在冒着火星子搭建,敬请期待!'
+          }
+        })
+        doSelectQuestionType(promptModel.value.typeList[0])
+      }
+    })
+    doAjaxGetHistoryList()
+  }
+  getUserInfo()
+}
+
+init()
+</script>
+
+<template>
+  <div class="ai-search--page j-container">
+    <div
+      class="j-header ai-search--header flex flex-(items-center justify-between)"
+      :class="{ 'app-header-fill': $envs.inApp }"
+    >
+      <div class="flex flex-(items-center justify-between)">
+        <span class="back-icon" @click="doBack">
+          <i class="iconfont icon-back" />
+        </span>
+        <div class="flex flex-(items-center justify-between) header-left">
+          <img class="main-logo-img" src="@/assets/image/public/logo_new.png" />
+          <div class="main-logo">
+            <span class="main-text">剑鱼标讯</span>
+            <br />
+            <span>jianyu360.cn</span>
+          </div>
+        </div>
+      </div>
+      <Popover
+        v-model="rightAction.show"
+        placement="bottom-end"
+        :offset="[-16, 12]"
+        trigger="click"
+        :actions="rightAction.actions"
+        @select="onSelectRightAction"
+      >
+        <template #reference>
+          <span class="right-icon">
+            <i class="iconfont icon-gengduo-shuxiang" />
+          </span>
+        </template>
+      </Popover>
+    </div>
+
+    <div class="ai-search--content j-main" ref="contentEl">
+      <div
+        v-if="needBindPhone || needLogin"
+        class="bind-phone-popup"
+        @click="checkBindPhone"
+      />
+      <div v-if="askModel.list.length === 0" class="ai-search--empty">
+        <AppEmpty state="ai-logo">
+          我是剑鱼标讯AI助手想找什么
+          <br />
+          请开始问我吧!
+        </AppEmpty>
+      </div>
+
+      <div v-else class="ask-container">
+        <div
+          v-for="(item, index) in askModel.list"
+          :key="index"
+          class="ask-item"
+          :class="{ 'is-user': item.user }"
+        >
+          <div v-if="item.user" class="ask-item--default">
+            {{ item.message }}
+          </div>
+          <div v-else class="ask-item--custom flex flex-(col)">
+            <div class="flex flex-(items-center)">
+              <div class="ask-logo">
+                <img src="@/assets/image/ai-search/logo-head.png" alt="ai" />
+              </div>
+
+              <div v-if="item.state === 0" class="flex flex-(items-center)">
+                {{ askStateMap[item.state] }}
+                <Loading style="margin-left: 8px" color="#2abed1" size="14px" />
+              </div>
+              <div v-if="item.state === 1">
+                {{ askStateMap[item.state] }}
+              </div>
+              <div v-if="item.state === 2">
+                <span>{{ askStateMap[item.state] }}</span>
+                <span class="highlight-text" @click="resetQuestion(item)"
+                  >重新提问</span
+                >
+              </div>
+              <div v-if="item.state === 3">
+                {{ askStateMap[item.state] }}
+              </div>
+            </div>
+
+            <div v-if="item.state === 3">
+              <div class="cell-list-container">
+                <div class="cell-list-tip">以下是我为您整理的数据样例</div>
+                <div
+                  v-for="(cell, index) in item.list"
+                  :key="index"
+                  class="cell-list-item"
+                >
+                  <ProjectCell
+                    :key="cell.vKid"
+                    v-visited:content="cell.id"
+                    :class="cell.className"
+                    card-type="detailed"
+                    time-fmt="yyyy-MM-dd"
+                    :detail-list="cell.detailList"
+                    :title="cell.title"
+                    :detail="cell.detail || null"
+                    :filetext_search="cell.filetext_search"
+                    :fs_keys="cell.fs_word"
+                    :time="cell.dateTime"
+                    :is-file="cell.isFile"
+                    :keys="cell.splitKeys"
+                    :left-top-badge-text="cell.leftTopBadgeText"
+                    :tags="cell.tagList"
+                    @click="goToDetail(cell)"
+                  >
+                    <template slot="icon">
+                      <div @click.stop="doCollection(cell, index)">
+                        <span
+                          class="j-icon"
+                          :class="{
+                            'icon-star-fill': cell.star,
+                            'icon-star-streak': !cell.star
+                          }"
+                        />
+                        <span>&nbsp;{{ cell.star ? '已收藏' : '收藏' }}</span>
+                      </div>
+                    </template>
+                  </ProjectCell>
+                </div>
+              </div>
+              <div class="cell-list-more" @click="doLookMoreList(item)">
+                已为您搜索到
+                <span class="highlight-text">{{ item.count }}</span>
+                条,查看全部结果
+                <i class="iconfont icon-youbian" />
+              </div>
+              <div class="cell-list-actions flex flex-(items-center)">
+                <div
+                  class="cell-action-icon mini-tip-container"
+                  :class="{ 'is-active': item.like == 1 }"
+                  @click="doCellAction(item, 'zan')"
+                >
+                  <img
+                    v-show="item.like == 0"
+                    src="@/assets/image/ai-search/like.png"
+                    alt="like"
+                  />
+                  <img
+                    v-show="item.like == 1"
+                    src="@/assets/image/ai-search/like-active.png"
+                    alt="like-active"
+                  />
+                  <div class="tip-popver">感谢您的认可!</div>
+                </div>
+                <div
+                  class="cell-action-icon"
+                  @click="doCellAction(item, 'reload')"
+                >
+                  <i class="iconfont icon-kecheng_shuaxin" />
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div ref="contentBottomEl"></div>
+      </div>
+
+      <Overlay
+        :z-index="2"
+        :duration="0"
+        :show="questionModel.inputTheme === 'full'"
+        @click="doClickInputIcon('scale')"
+      />
+      <div
+        class="question-input-container"
+        :class="{
+          [questionModel.inputTheme]: true,
+          'in-ask': questionModel.nowId
+        }"
+      >
+        <div
+          v-show="
+            questionModel.inputTheme === 'scale' &&
+            promptModel.list.length > 0 &&
+            promptModel.show
+          "
+          class="question-prompt-container"
+        >
+          <Swipe
+            ref="promptEle"
+            :show-indicators="false"
+            :loop="false"
+            @change="onChangePrompt"
+          >
+            <SwipeItem v-for="(item, index) in promptModel.list" :key="index">
+              <div class="question-prompt-item">
+                <div
+                  class="custom-indicator flex flex-(items-center justify-between)"
+                >
+                  <span class="prompt-tip-text">您可以参照示例进行提问:</span>
+                  <div
+                    class="prompt-arrow flex flex-(items-center justify-between)"
+                  >
+                    <span
+                      class="prompt-arrow-icon"
+                      :class="{ active: index > 0 }"
+                      @click="doChangePrompt(false)"
+                    >
+                      <i class="iconfont icon-up" />
+                    </span>
+                    <div>
+                      {{ promptModel.current + 1 }} /
+                      {{ promptModel.list.length }}
+                    </div>
+                    <span
+                      class="prompt-arrow-icon"
+                      :class="{ active: index < promptModel.list.length - 1 }"
+                      @click="doChangePrompt(true)"
+                    >
+                      <i class="iconfont icon-down" />
+                    </span>
+                  </div>
+                </div>
+                <div @click="doSelectPrompt(item)">
+                  {{ item.text }}
+                </div>
+              </div>
+            </SwipeItem>
+          </Swipe>
+        </div>
+
+        <div
+          v-show="questionModel.inputTheme === 'scale'"
+          class="question-type-container flex flex-(row items-center)"
+        >
+          <div
+            v-for="(item, index) in promptModel.typeList"
+            :key="index"
+            class="question-type-item"
+            :class="{
+              'is-disable': item.disabled,
+              'is-active': promptModel.type === item.key
+            }"
+            @click="doSelectQuestionType(item)"
+          >
+            <i class="iconfont" :class="item.icon" />
+            <span>{{ item.text }}</span>
+          </div>
+        </div>
+
+        <div class="question-input-item">
+          <Field
+            ref="questionInputEl"
+            v-model="questionModel.input"
+            center
+            v-bind="questionModelOptions"
+            type="textarea"
+            placeholder="发消息..."
+            @input="getQuestionInputHeight"
+            @change="getQuestionInputHeight"
+          >
+            <template #button>
+              <div
+                class="input-icon-container flex flex-(col items-center justify-between)"
+                :style="
+                  questionModel.inputTheme === 'scale'
+                    ? questionModel.style
+                    : {}
+                "
+              >
+                <div
+                  v-if="
+                    questionModel.inputTheme === 'scale' &&
+                    questionModel.iconShow
+                  "
+                  class="input-icon"
+                  :style="{ 'margin-bottom': '8px' }"
+                  @click="doClickInputIcon('full')"
+                >
+                  <img
+                    class="full-icon"
+                    src="@/assets/image/ai-search/scale.png"
+                    alt="submit"
+                  />
+                </div>
+                <div
+                  v-if="questionModel.inputTheme === 'full'"
+                  class="input-icon"
+                  @click="doClickInputIcon('scale')"
+                >
+                  <img
+                    class="scale-icon"
+                    src="@/assets/image/ai-search/disscale.png"
+                    alt="submit"
+                  />
+                </div>
+                <div
+                  class="input-icon"
+                  :class="{ 'is-disabled': questionModel.input === '' }"
+                  @click="doClickInputIcon('submit')"
+                >
+                  <img
+                    class="submit-icon"
+                    src="@/assets/image/icon/icon-submit.png"
+                    alt="submit"
+                  />
+                </div>
+              </div>
+            </template>
+          </Field>
+        </div>
+        <div class="safe-area-inside-bottom"></div>
+      </div>
+    </div>
+
+    <Popup
+      v-model="moreListModel.show"
+      overlay-class="ai-search--history-overlay"
+      class="ai-search--more-popup"
+      :class="{ 'app-header-fill': $envs.inApp }"
+      position="right"
+    >
+      <div class="j-container">
+        <div
+          class="j-header more-list-header flex flex-(item-center justify-between)"
+        >
+          <div class="flex flex-(item-center)">
+            <div class="more-header-icon" @click="moreListModel.show = false">
+              <i class="iconfont icon-close_heidi" style="color: #c0c4cc" />
+            </div>
+            <div class="flex flex-(items-center)">
+              <img src="@/assets/image/ai-search/logo-head.png" alt="ai" />
+              <span class="tip-text">为您搜索到的结果</span>
+            </div>
+          </div>
+          <div
+            class="more-header-icon mini-tip-container"
+            :class="{ 'is-active': moreLike.like == 1 }"
+            @click="doMoreLike"
+          >
+            <img
+              v-show="moreLike.like == 0"
+              src="@/assets/image/ai-search/like.png"
+              alt="like"
+            />
+            <img
+              v-show="moreLike.like == 1"
+              src="@/assets/image/ai-search/like-active.png"
+              alt="like-active"
+            />
+            <div class="tip-popver in-right">感谢您的认可!</div>
+          </div>
+        </div>
+        <div class="j-main more-list-container" ref="moreContentEl">
+          <div class="cell-list-container">
+            <div
+              v-for="(cell, index) in moreListModel.list"
+              :key="index"
+              class="cell-list-item"
+            >
+              <ProjectCell
+                :key="cell.vKid"
+                v-visited:content="cell.id"
+                :class="cell.className"
+                card-type="detailed"
+                time-fmt="yyyy-MM-dd"
+                :detail-list="cell.detailList"
+                :title="cell.title"
+                :detail="cell.detail || null"
+                :filetext_search="cell.filetext_search"
+                :fs_keys="cell.fs_word"
+                :time="cell.dateTime"
+                :is-file="cell.isFile"
+                :keys="cell.splitKeys"
+                :left-top-badge-text="cell.leftTopBadgeText"
+                :tags="cell.tagList"
+                @click="goToDetail(cell)"
+              >
+                <template slot="icon">
+                  <div @click.stop="doCollection(cell, index)">
+                    <span
+                      class="j-icon"
+                      :class="{
+                        'icon-star-fill': cell.star,
+                        'icon-star-streak': !cell.star
+                      }"
+                    />
+                    <span>&nbsp;{{ cell.star ? '已收藏' : '收藏' }}</span>
+                  </div>
+                </template>
+              </ProjectCell>
+            </div>
+          </div>
+        </div>
+      </div>
+    </Popup>
+
+    <Popup
+      v-model="historyModel.show"
+      overlay-class="ai-search--history-overlay"
+      class="ai-search--history-popup"
+      :class="{ 'app-header-fill': $envs.inApp }"
+      position="right"
+    >
+      <div class="j-container">
+        <div
+          class="j-header history-list-header flex flex-(items-center justify-between)"
+        >
+          <span>历史对话</span>
+          <div class="item-del-icon" @click="historyModel.show = false">
+            <i class="iconfont icon-delete_gray" />
+          </div>
+        </div>
+        <div class="j-main">
+          <div
+            v-if="historyListLabel.length > 0"
+            class="history-list-container"
+          >
+            <div
+              v-for="(label, index) in historyListLabel"
+              :key="index"
+              class="history-item-container"
+            >
+              <div class="history-list--time">
+                {{ label }}
+              </div>
+              <div
+                v-for="(item, iIndex) in historyModel.list[label]"
+                :key="iIndex"
+                class="history-list--item"
+                @click="goHistory(item)"
+              >
+                <div class="flex flex-(items-center justify-between)">
+                  {{ item.question }}
+                  <div class="item-del-icon" @click="doDelTask(item)">
+                    <i class="iconfont icon-dataDelete" />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div
+            v-else
+            class="history-empty-container flex flex-(col items-center justify-center)"
+          >
+            <div class="flex flex-(col items-center align-center)">
+              <AppEmpty state="back"> 您还没有和我聊天呢,现在开始吧 </AppEmpty>
+              <div class="new-question-button" @click="doNewQuestion">
+                开启对话
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </Popup>
+  </div>
+</template>
+
+<style>
+.one-toast {
+  white-space: nowrap;
+}
+</style>
+<style lang="scss" scoped>
+.ai-search-- {
+  //
+  &page {
+    ::v-deep {
+    }
+
+    .bind-phone-popup {
+      width: 100%;
+      height: 100%;
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 6;
+    }
+
+    .new-question-button {
+      width: 160px;
+      height: 48px;
+      line-height: 48px;
+      border-radius: 8px;
+      background: linear-gradient(101.8deg, #2abed1 0%, #0a6cff 100%);
+      font-weight: 400;
+      font-size: 20px;
+      letter-spacing: 0px;
+      text-align: center;
+      color: #fff;
+    }
+
+    .mini-tip-container {
+      position: relative;
+
+      @keyframes showAndHide {
+        0% {
+          opacity: 1;
+        }
+        100% {
+          opacity: 0;
+        }
+      }
+
+      &.is-active {
+        .tip-popver {
+          display: inline-block;
+          opacity: 1;
+          transition: opacity 1.5s;
+          animation: showAndHide 2.2s forwards;
+        }
+      }
+
+      .tip-popver {
+        display: none;
+        opacity: 0;
+        transition: opacity 0s;
+        position: absolute;
+        top: -20px;
+        left: 30px;
+        border: 1px solid rgba(42, 190, 209, 0.12);
+        box-shadow: 0px 4px 12px 0px rgba(29, 29, 29, 0.1);
+        font-weight: 400;
+        font-size: 14px;
+        line-height: 20px;
+        color: rgba(95, 94, 100, 1);
+        padding: 6px 12px;
+        word-break: keep-all;
+        border-radius: 8px;
+        background: linear-gradient(
+          167.96deg,
+          #ffffff 0%,
+          #edfeff 45.31%,
+          rgba(237, 254, 255, 0) 100%
+        );
+        &.in-right {
+          left: -240%;
+          top: 20px;
+        }
+      }
+    }
+
+    .cell-list-more {
+      background: rgba(245, 246, 247, 1);
+      border-radius: 8px;
+      padding: 6px 12px;
+      margin: 12px 0;
+    }
+    .cell-list-actions {
+      .cell-action-icon {
+        margin-right: 2px;
+        width: 40px;
+        height: 40px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+
+        img {
+          width: 20px;
+        }
+        i {
+          display: inline-block;
+          font-size: 18px;
+          line-height: 18px;
+          color: #5f5e64;
+        }
+      }
+    }
+
+    .cell-list-container {
+      background: rgba(245, 246, 247, 1);
+      border-radius: 16px;
+      padding: 12px 6px;
+      margin-top: 12px;
+
+      .cell-list-tip {
+        color: #5f5e64;
+        font-size: 14px;
+        line-height: 24px;
+        margin-bottom: 8px;
+        margin-left: 10px;
+      }
+
+      .cell-list-item {
+        & + .cell-list-item {
+          margin-top: 8px;
+        }
+      }
+    }
+
+    .ask-container {
+      background-color: #fff;
+      padding-bottom: 220px;
+      .ask-item {
+        font-weight: 400;
+        font-size: 14px;
+        line-height: 24px;
+        width: 100%;
+        display: flex;
+        margin-top: 12px;
+
+        &.is-user {
+          justify-content: end;
+        }
+
+        &--default {
+          color: #fff;
+          background-color: #2abed1;
+          border-radius: 16px;
+          border-bottom-right-radius: 0;
+          margin: 0 16px;
+          padding: 12px 16px;
+          width: auto;
+        }
+        &--custom {
+          text-align: left;
+          margin-left: 16px;
+          padding-right: 16px;
+          color: #9b9ca3;
+        }
+      }
+      .ask-logo {
+        width: 40px;
+        margin-right: 8px;
+      }
+    }
+  }
+  &header {
+    background-color: #fff;
+    padding-top: 6px;
+    padding-bottom: 6px;
+
+    &.app-header-fill {
+      padding-top: $app-header-padding-top;
+      padding-bottom: unset;
+    }
+
+    &--left {
+    }
+
+    .main-logo {
+      color: rgba(155, 156, 163, 1);
+      font-size: 12px;
+      .main-text {
+        display: inline-block;
+        color: rgba(95, 94, 100, 1);
+        font-size: 16px;
+        line-height: 16px;
+        margin-bottom: 4px;
+      }
+    }
+
+    .main-logo-img {
+      width: 32px;
+      height: 32px;
+      margin-right: 8px;
+    }
+
+    .back-icon {
+      padding: 10px 12px;
+      margin-right: 24px;
+      i {
+        font-size: 24px;
+        color: #5f5e64;
+      }
+    }
+    .right-icon {
+      padding: 10px 12px;
+      i {
+        display: inline-block;
+        font-size: 24px;
+        color: rgba(39, 38, 54, 1);
+        transform: rotate(90deg);
+      }
+    }
+  }
+  &content {
+    height: 100vh;
+    background-color: #fff;
+
+    .question-input-container {
+      position: fixed;
+      background-color: #fff;
+      bottom: 0;
+      width: 100%;
+      z-index: 3;
+
+      .question-input-item {
+        margin: 16px;
+        border-radius: 8px;
+        box-shadow: 0px 4px 12px 0px rgba(29, 29, 29, 0.1);
+        border: 1px solid rgba(0, 0, 0, 0.05);
+      }
+
+      &.full {
+        bottom: 0;
+        height: 38vh;
+        border-top-left-radius: 8px;
+        border-top-right-radius: 8px;
+        background: url('@/assets/image/ai-search/bg.png') right no-repeat;
+        background-size: 100% 100%;
+        background-color: #fff;
+        .question-input-item {
+          box-shadow: unset;
+          border: unset;
+          margin: 0;
+        }
+        .van-field {
+          padding: 24px;
+          padding-right: 0;
+          background: transparent;
+        }
+        .input-icon {
+          padding-right: 24px;
+        }
+        ::v-deep {
+          .van-field__body,
+          .van-field__button,
+          .van-field__control {
+            height: 100% !important;
+          }
+        }
+      }
+      &.scale {
+      }
+      &.in-ask {
+        padding-top: 12px;
+        border-top: 1px solid rgba(0, 0, 0, 0.05);
+      }
+
+      ::v-deep {
+        .question-input-item,
+        .van-field__value,
+        .van-field__button,
+        .input-icon-container,
+        .van-field {
+          height: 100% !important;
+        }
+
+        .van-field {
+          border-radius: 8px;
+        }
+      }
+
+      .input-icon {
+        &.is-disabled {
+          opacity: 0.55;
+        }
+      }
+
+      .input-icon-container {
+      }
+      .input-icon img {
+        width: 24px;
+      }
+    }
+
+    .question-type-container {
+      flex-wrap: nowrap;
+      width: 100%;
+      overflow: auto;
+      padding: 0 16px;
+
+      &::-webkit-scrollbar {
+        /* 隐藏滚动条 - Chrome 和 Safari */
+        display: none;
+      }
+
+      .question-type-item {
+        flex-shrink: 0;
+        border: 1px solid rgba(0, 0, 0, 0.1);
+        background-color: #fff;
+        color: #5f5e64;
+        height: 28px;
+        border-radius: 8px;
+        border-width: 1px;
+        padding-top: 4px;
+        padding-right: 12px;
+        padding-bottom: 4px;
+        padding-left: 12px;
+        margin-right: 8px;
+        font-size: 14px;
+        line-height: 20px;
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+
+        i {
+          font-size: 20px;
+          margin-right: 2px;
+        }
+
+        &.is-active {
+          border-color: rgba(135, 223, 234, 1);
+          background-color: rgba(42, 190, 209, 0.08);
+          color: #2abed1;
+        }
+
+        &.is-disable {
+          border-color: rgba(0, 0, 0, 0.1);
+          background-color: rgba(255, 255, 255);
+          opacity: 0.3;
+          color: #5f5e64;
+        }
+      }
+    }
+
+    .question-prompt-container {
+      .question-prompt-item {
+        min-height: 100px;
+        border-radius: 8px;
+        border: 1px solid var(--brand-2-abed-112, rgba(42, 190, 209, 0.12));
+        box-shadow: 0px 4px 12px 0px rgba(29, 29, 29, 0.1);
+        background: linear-gradient(
+          167.96deg,
+          #ffffff 0%,
+          #edfeff 45.31%,
+          rgba(237, 254, 255, 0) 100%
+        );
+        font-size: 16px;
+        line-height: 24px;
+        padding: 12px;
+        margin: 16px;
+        color: rgba(23, 24, 38, 1);
+      }
+
+      .prompt-arrow {
+        .prompt-arrow-icon {
+          color: #c0c4cc;
+          &.active {
+            color: rgba(42, 190, 209, 1);
+          }
+          .icon-up {
+            display: inline-block;
+            transform: rotate(-90deg);
+          }
+          .icon-down {
+            display: inline-block;
+            transform: rotate(-90deg);
+          }
+        }
+      }
+
+      .custom-indicator {
+        font-size: 14px;
+        margin-bottom: 8px;
+        line-height: 20px;
+        color: rgba(95, 94, 100, 1);
+      }
+
+      ::v-deep {
+        .van-swipe__track {
+          align-items: end;
+        }
+      }
+    }
+  }
+  &empty {
+    margin-top: 69px;
+  }
+
+  &history-overlay {
+    background-color: rgba(0, 0, 0, 0.5);
+  }
+  &history-popup {
+    height: 100%;
+    width: 280px;
+    background: url('@/assets/image/ai-search/bg.png') right no-repeat;
+    background-size: 100% 100%;
+    background-color: #fff;
+
+    &.app-header-fill {
+      padding-top: $app-header-padding-top;
+    }
+
+    .history-list-header {
+      font-weight: 500;
+      font-size: 20px;
+      line-height: 24px;
+      color: rgba(23, 24, 38, 1);
+      padding: 24px;
+      padding-bottom: 0;
+      margin-bottom: 8px;
+
+      .item-del-icon i {
+        font-size: 22px;
+      }
+    }
+    .item-del-icon {
+      padding: 4px;
+      margin-left: 12px;
+      i {
+        font-size: 18px;
+        color: #c8c9cc;
+      }
+    }
+    .history-empty-container {
+      height: 100%;
+      margin-top: -80px;
+    }
+    .history-list-container {
+      padding: 12px;
+      padding-bottom: 40px;
+      .history-item-container {
+        margin-bottom: 12px;
+      }
+      .history-list--time {
+        color: rgba(155, 156, 163, 1);
+        font-size: 14px;
+        line-height: 24px;
+        margin-left: 12px;
+      }
+
+      .history-list--item {
+        margin-top: 8px;
+        font-size: 14px;
+        line-height: 20px;
+        color: #171826;
+        padding: 10px 12px;
+        background: linear-gradient(
+          167.96deg,
+          #ffffff 0%,
+          #edfeff 45.31%,
+          rgba(237, 254, 255, 0) 100%
+        );
+        border: 0.5px solid rgba(0, 0, 0, 0.1);
+      }
+    }
+  }
+
+  &more-popup {
+    width: 100vw;
+    height: 100vh;
+    background-color: #fff;
+
+    &.app-header-fill {
+      padding-top: $app-header-padding-top;
+    }
+
+    .more-list-container {
+      background: rgba(245, 246, 247, 1);
+      .cell-list-container {
+        border-radius: unset;
+      }
+    }
+
+    .more-list-header {
+      padding: 4px 10px;
+      .tip-text {
+        color: rgba(155, 156, 163, 1);
+        font-size: 14px;
+        line-height: 24px;
+      }
+
+      img {
+        width: 40px;
+        margin-right: 8px;
+      }
+      .more-header-icon {
+        padding: 10px;
+        img {
+          width: 22px;
+        }
+        i {
+          font-size: 20px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 18 - 0
apps/mobile/src/views/ai-search/model/index.js

@@ -0,0 +1,18 @@
+// 函数:通用缓存函数,支持自定义 key
+const cacheToLocalStorage = (key, state) => {
+  localStorage.setItem(key, JSON.stringify(state.value));
+};
+
+// 函数:通用读取函数,支持自定义 key
+const loadFromLocalStorage = (key, state) => {
+  const cachedData = localStorage.getItem(key);
+  if (cachedData) {
+    Object.assign(state.value, JSON.parse(cachedData));
+  }
+};
+
+
+
+// 使用示例:
+cacheToLocalStorage('historyModel', historyModel);
+loadFromLocalStorage('historyModel', historyModel);

+ 23 - 4
apps/mobile/src/views/search/layout.vue

@@ -3,6 +3,7 @@
     ref="layout"
     v-model="topSearch.input"
     :show-button="topSearch.searchButton"
+    :show-ai-button="topSearch.showAiButton"
     :empty-search="emptySearch"
     :placeholder="topSearch.placeholder"
     @top-event="onSearch"
@@ -33,8 +34,9 @@ export default {
       topSearch: {
         input: '',
         placeholder: '',
+        showAiButton: false,
         searchButton: true
-      },
+      }
     }
   },
   computed: {
@@ -43,10 +45,14 @@ export default {
       const routeName = this.$route.name
       if (routeName && routeName.includes('bidding-custom')) {
         return true
-      } else if(routeName && routeName.includes('search-middle-bidding') && this.isWhiteList) {
+      } else if (
+        routeName &&
+        routeName.includes('search-middle-bidding') &&
+        this.isWhiteList
+      ) {
         // 招标采购搜索白名单用户支持空搜索
         return true
-      } else{
+      } else {
         return false
       }
     }
@@ -68,6 +74,7 @@ export default {
         vm.$storage.set(CACHE_KEY, hasInput)
       }
       vm.updatePlaceholder(to.name)
+      vm.calcAiButtonState(to.name)
       if (vm.$store.state.direction !== 'forward') {
         vm.topSearch.input = vm.$storage.get(CACHE_KEY, vm.topSearch.input)
       } else {
@@ -84,6 +91,7 @@ export default {
   },
   beforeRouteUpdate(to, from, next) {
     this.updatePlaceholder(to.name)
+    this.calcAiButtonState(to.name)
     next()
   },
   beforeRouteLeave(to, from, next) {
@@ -91,7 +99,11 @@ export default {
     next()
   },
   methods: {
-    ...mapActions('user', ['getEntPower', 'userVipSwitchState', 'getWhiteListInfo']),
+    ...mapActions('user', [
+      'getEntPower',
+      'userVipSwitchState',
+      'getWhiteListInfo'
+    ]),
     ...mapActions('search', ['setHistory']),
     /**
      * 获取用户信息
@@ -100,6 +112,13 @@ export default {
       this.getEntPower()
       this.userVipSwitchState()
     },
+    calcAiButtonState(name) {
+      let show = false
+      if (name.includes('bidding')) {
+        show = true
+      }
+      this.topSearch.showAiButton = show
+    },
     getQueryString() {
       const { query } = this.$route
       const { input } = query

+ 45 - 22
apps/mobile/src/views/search/middle/bidding/index.vue

@@ -1,29 +1,38 @@
 <template>
-  <div class="page-search-bidding" v-if="showPageContent">
+  <div v-if="showPageContent" class="page-search-bidding">
     <div class="j-container bidding-search-middle-container">
       <div class="j-header">
         <div class="search-type-tab-container">
           <span
+            v-for="(tab, index) in searchTypeTab"
+            :key="index"
             class="search-type-item clickable"
             :class="{ 'j-button-badge': tab.badge }"
-            v-for="(tab, index) in searchTypeTab"
             :data-badge="tab.badge"
             @click="clickTabItem(tab)"
-            :key="index"
           >
             {{ tab.label }}
           </span>
         </div>
+        <div class="header-banner-container">
+          <AdSingle
+            ad="ai-search-middle-banner"
+            :show-close-icon="false"
+            :show-tag="false"
+            :before-open="beforeOpenAd"
+            class="ad-container"
+          />
+        </div>
       </div>
       <div class="j-main bidding-search-middle-main">
         <div class="bidding-search-middle-main-content">
-          <history-list
+          <HistoryList
+            v-if="filterHistory.length"
             class="search-filter-history content-module"
             type="list"
-            v-if="filterHistory.length"
             :list="filterHistory"
-            @click="clickFilterHistory"
             title="加载已存筛选条件"
+            @click="clickFilterHistory"
           >
             <div
               slot="header-right"
@@ -33,20 +42,20 @@
               <span>查看更多</span>
               <AppIcon name="youbian" />
             </div>
-          </history-list>
-          <history-list
-            class="content-module"
+          </HistoryList>
+          <HistoryList
             v-if="biddingSearchHistory.length"
+            class="content-module"
             :list="biddingSearchHistory"
             @click="goPage"
             @delete="deleteList"
-          ></history-list>
+          />
           <HotKeyCard class="content-module" @clickTag="clickHotKeyItem" />
-          <NoLoginBidList v-if="!isLogin"></NoLoginBidList>
+          <NoLoginBidList v-if="!isLogin" />
         </div>
       </div>
-      <div class="j-footer" v-if="!isLogin">
-        <NoLoginTabbar></NoLoginTabbar>
+      <div v-if="!isLogin" class="j-footer">
+        <NoLoginTabbar />
       </div>
     </div>
     <CheckUserDialog />
@@ -54,15 +63,18 @@
 </template>
 
 <script>
-import { HistoryList, AppIcon } from '@/ui'
+import { mapActions, mapGetters, mapState } from 'vuex'
+import useSearchHistoryModel from '@jy/data-models/modules/quick-search-history/model'
+import { AppIcon, HistoryList } from '@/ui'
 import HotKeyCard from '@/components/search/middle/HotKeyCard.vue'
 import CheckUserDialog from '@/views/identity/components/CheckUserDialog'
 import NoLoginBidList from '@/components/search/bidding/NoLoginBidList.vue'
 import NoLoginTabbar from '@/components/no-login/NoLoginTabbar.vue'
+import AdSingle from '@/components/ad/Ad'
 import {
-  getBiddingSearchHistory,
+  ajaxRemoveBiddingSearchHistory,
   getBiddingFilterList,
-  ajaxRemoveBiddingSearchHistory
+  getBiddingSearchHistory
 } from '@/api/modules'
 import {
   FilterHistoryAjaxModel2ViewModel,
@@ -70,27 +82,26 @@ import {
 } from '@/utils/'
 import { mixinPoints } from '@/utils/mixins/modules/points'
 import {
-  BIDDING_SEARCH_LAST_FILTERS_CACHE_KEY,
-  BIDDING_SEARCH_GROUP_LAST_CACHE_KEY
+  BIDDING_SEARCH_GROUP_LAST_CACHE_KEY,
+  BIDDING_SEARCH_LAST_FILTERS_CACHE_KEY
 } from '@/utils/constant'
-import { mapState, mapActions, mapGetters } from 'vuex'
 // 搜索历史业务模型
-import useSearchHistoryModel from '@jy/data-models/modules/quick-search-history/model'
 // 解构搜索历史业务数据模型
 const searchHistoryModel = useSearchHistoryModel()
 const { getHistoryQuery, clearHistoryQuery } = searchHistoryModel
 
 export default {
   name: 'SearchMiddleBidding',
-  mixins: [mixinPoints],
   components: {
     AppIcon,
     HotKeyCard,
     CheckUserDialog,
     HistoryList,
     NoLoginBidList,
+    AdSingle,
     NoLoginTabbar
   },
+  mixins: [mixinPoints],
   inject: {
     topSearch: {
       default: () => {}
@@ -195,9 +206,12 @@ export default {
       }
       Object.assign(this.queryCache, query)
     },
+    beforeOpenAd() {
+      return true
+    },
     getParams() {
       const { params } = this.$route
-      Object.assign(this.paramCache, params)      
+      Object.assign(this.paramCache, params)
     },
     restoreSearchGroupFromLocal() {
       const params = this.$storage.get(
@@ -554,4 +568,13 @@ export default {
   line-height: 20px;
   color: $main;
 }
+
+.header-banner-container {
+  .ad-container {
+    margin: 8px;
+    margin-bottom: 12px;
+    border-radius: 12px;
+    overflow: hidden;
+  }
+}
 </style>

+ 82 - 4
apps/mobile/src/views/tabbar/Home.vue

@@ -22,9 +22,36 @@
       :class="{ 'deep-color': !isLogin && $envs.inAppOrH5 }"
     >
       <div class="search-box" :class="{ 'app-header-top': appHeaderTop }">
-        <div class="search clickable" @click="goSearch">
-          <AppIcon name="search" />
-          <span>找项目 搜采购 拓客户 查企业</span>
+        <div class="search-box-content">
+          <div class="search clickable" @click="goSearch">
+            <AppIcon name="search" />
+            <span>找项目 搜采购 拓客户 查企业</span>
+          </div>
+        </div>
+        <div class="search-box-right">
+          <van-popover
+            v-model="showAIGuidePopover"
+            :offset="[10, -5]"
+            placement="bottom-end"
+          >
+            <div class="ai-guide-content-container">
+              <p>AI搜索,现已接入DeepSeek</p>
+              <van-button
+                class="ai-guide-content-button"
+                type="primary"
+                size="small"
+                @click="toAskAi"
+              >
+                即刻体验
+              </van-button>
+            </div>
+            <template #reference>
+              <span
+                class="j-icon j-base-icon icon-ai-by-search-index"
+                @click="toAskAi"
+              />
+            </template>
+          </van-popover>
         </div>
       </div>
     </van-sticky>
@@ -104,7 +131,7 @@
 </template>
 
 <script>
-import { Button, Cell, Image, Overlay, Popup, Sticky } from 'vant'
+import { Button, Cell, Image, Overlay, Popover, Popup, Sticky } from 'vant'
 import { mapGetters } from 'vuex'
 import { AppIcon } from '@/ui'
 import AdPopScreen from '@/components/ad/pop-screen'
@@ -159,6 +186,7 @@ export default {
     CustomerBid,
     [Side.name]: Side,
     [Button.name]: Button,
+    [Popover.name]: Popover,
     [Cell.name]: Cell,
     [Popup.name]: Popup,
     [Overlay.name]: Overlay,
@@ -222,6 +250,7 @@ export default {
       showNextFullPop: false,
       showBottomPop: false
     },
+    showAIGuidePopover: false,
     hasBottomSetTip: false,
     showSetTip: false,
     showTutorial: false
@@ -270,9 +299,11 @@ export default {
     this.showNoLoginHeader()
     this.setPageTdk()
     this.getConfigurationApi()
+    this.checkAIGuidePopover()
   },
   beforeRouteLeave(to, from, next) {
     this.toggleMessagePopShow(false)
+    this.showAIGuidePopover = false
     next()
   },
   beforeRouteEnter(to, from, next) {
@@ -311,6 +342,17 @@ export default {
     })
   },
   methods: {
+    checkAIGuidePopover() {
+      const key = 'AI_GUIDE_POPOVER'
+      // this.$storage.rm(key)
+      const cache = this.$storage.get(key, false)
+      if (cache) {
+        // do something
+      } else {
+        this.showAIGuidePopover = true
+        this.$storage.set(key, '1')
+      }
+    },
     async getConfigurationApi() {
       const { error_code: code, data } = await getConfiguration()
       if (code === 0) {
@@ -533,6 +575,14 @@ export default {
         }
       })
     },
+    toAskAi() {
+      this.$router.push({
+        path: '/ai/search',
+        query: {
+          from: 'home'
+        }
+      })
+    },
     toLogin() {
       return openLinkOfOther(LINKS.APP登录页.app, {
         query: {
@@ -800,9 +850,15 @@ export default {
     background-color: #f5f6f7;
   }
   .search-box {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
     padding: 8px 16px;
     background: transparent;
     transition: background-color 0.2s;
+    .search-box-content {
+      flex: 1;
+    }
     .search {
       display: flex;
       flex-direction: row;
@@ -872,4 +928,26 @@ export default {
 .miniprogram-follow-guide {
   margin: 0 16px;
 }
+
+.icon-ai-by-search-index {
+  margin-left: 8px;
+  width: 32px;
+  height: 32px;
+}
+.ai-guide-content-container {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  padding: 24px 0;
+  width: 192px;
+  height: 90px;
+  font-size: 12px;
+  color: #1d1d1d;
+  background: linear-gradient(0, #ffffff 0.04%, #cbfff9 99.84%);
+}
+.ai-guide-content-button {
+  margin-top: 12px;
+  height: 28px;
+}
 </style>

+ 22 - 20
apps/mobile/vite.config.js

@@ -77,21 +77,21 @@ export default defineConfig(({ mode, command }) => {
         threshold: 1024
       }),
       visualizer(),
-      sentryVitePlugin({
-        debug: false,
-        release: {
-          name: env.VITE_APP_GIT_BRANCH,
-          vcsRemote: env.VITE_APP_GIT_BRANCH
-        },
-        sourcemaps: {
-          filesToDeleteAfterUpload: ['../../dist/jy_mobile/**/*.js.map']
-        },
-        url: 'https://jysentry.jydev.jianyu360.cn',
-        org: 'jianyu',
-        project: 'jy-mobile',
-        authToken:
-          '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'
-      })
+      // sentryVitePlugin({
+      //   debug: false,
+      //   release: {
+      //     name: env.VITE_APP_GIT_BRANCH,
+      //     vcsRemote: env.VITE_APP_GIT_BRANCH
+      //   },
+      //   sourcemaps: {
+      //     filesToDeleteAfterUpload: ['../../dist/jy_mobile/**/*.js.map']
+      //   },
+      //   url: 'https://jysentry.jydev.jianyu360.cn',
+      //   org: 'jianyu',
+      //   project: 'jy-mobile',
+      //   authToken:
+      //     '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'
+      // })
     ],
     resolve: {
       alias: [
@@ -109,6 +109,7 @@ export default defineConfig(({ mode, command }) => {
     css: {
       preprocessorOptions: {
         scss: {
+          silenceDeprecations: ['legacy-js-api', 'import'], // 静默所有相关警告
           additionalData: `
           @import "@/assets/style/_variables.scss";
         `
@@ -127,21 +128,22 @@ export default defineConfig(({ mode, command }) => {
       proxy: {
         // 接口解密iframe
         '^/page_decrypt': {
-          target: 'https://jybx-webtest.jydev.jianyu360.com',
+          target: 'https://jybx2-webtest.jydev.jianyu360.com',
           changeOrigin: true
         },
         '/jyapi': {
-          target: 'https://app-jytest.jydev.jianyu360.com',
+          target: 'https://jybx2-webtest.jydev.jianyu360.com',
           changeOrigin: true,
           rewrite: path => path.replace(/^\/jyapi/, '')
         },
         '/api': {
-          target: 'https://app-jytest.jydev.jianyu360.com',
+          target: 'https://jybx2-webtest.jydev.jianyu360.com',
           changeOrigin: true,
           rewrite: path => path.replace(/^\/api/, '')
         },
-        '/commonFunctions': 'https://jybx-webtest.jydev.jianyu360.com',
-        '/common-module': 'https://jybx-webtest.jydev.jianyu360.com'
+        '/aiChat': 'https://jybx2-webtest.jydev.jianyu360.com',
+        '/commonFunctions': 'https://jybx2-webtest.jydev.jianyu360.com',
+        '/common-module': 'https://jybx2-webtest.jydev.jianyu360.com'
       }
     }
   }

+ 10 - 9
plugins/login-auth/package.json

@@ -1,18 +1,18 @@
 {
   "name": "@jy/plugin-login-auth",
-  "version": "1.0.5",
-  "files": [
-    "dist"
-  ],
-  "main": "./dist/plugin-login-auth.umd.js",
-  "module": "./dist/plugin-login-auth.mjs",
+  "version": "1.0.6",
+  "private": true,
   "exports": {
     ".": {
       "import": "./dist/plugin-login-auth.mjs",
       "require": "./dist/plugin-login-auth.umd.js"
     }
   },
-  "private": true,
+  "main": "./dist/plugin-login-auth.umd.js",
+  "module": "./dist/plugin-login-auth.mjs",
+  "files": [
+    "dist"
+  ],
   "scripts": {
     "dev": "vite",
     "build": "npm run build:external & npm run build:internal",
@@ -24,16 +24,17 @@
     "format": "prettier --write --cache ."
   },
   "dependencies": {
+    "@jy/emiiter": "workspace:^",
     "@vueuse/core": "^9.13.0",
     "@vueuse/integrations": "^9.13.0",
     "axios": "^1.3.5",
     "core-js": "^3.29.1",
     "element-ui": "^2.15.23-rc",
+    "go-captcha-vue": "^1",
     "lodash": "^4.17.21",
     "postcss-prefix-selector": "^1.16.0",
     "qs": "^6.11.2",
-    "vue": "^2.7.14",
-    "@jy/emiiter": "workspace:^"
+    "vue": "^2.7.14"
   },
   "devDependencies": {
     "@antfu/eslint-config": "^0.38.2",

+ 17 - 2
plugins/login-auth/src/api/login.js

@@ -1,5 +1,5 @@
-import request from './service'
 import qs from 'qs'
+import request from './service'
 
 export function ajaxSetLogin(data) {
   return request({
@@ -42,7 +42,7 @@ export function ajaxGetLoginPolling(data) {
 
 export function ajaxGetLoginNum(id, data) {
   return request({
-    url: '/front/getLoginNum/' + id,
+    url: `/front/getLoginNum/${id}`,
     method: 'post',
     data: qs.stringify(data)
   })
@@ -61,3 +61,18 @@ export function ajaxSetSignOut() {
     method: 'post'
   })
 }
+
+export function ajaxGetSMSCode(data) {
+  return request({
+    url: '/publicapply/captcha/get',
+    method: 'post',
+    data: qs.stringify(data)
+  })
+}
+export function ajaxCheckCaptchaCode(data) {
+  return request({
+    url: '/publicapply/captcha/check',
+    method: 'post',
+    data: qs.stringify(data)
+  })
+}

+ 2 - 0
plugins/login-auth/src/components.d.ts

@@ -10,11 +10,13 @@ declare module 'vue' {
     AutoCompleteInput: typeof import('./components/form/autoCompleteInput.vue')['default']
     BaseForm: typeof import('./components/form/baseForm.vue')['default']
     BaseInput: typeof import('./components/form/baseInput.vue')['default']
+    CaptchaDialog: typeof import('./components/dialog/captcha-dialog.vue')['default']
     ImgCaptchaInput: typeof import('./components/form/imgCaptchaInput.vue')['default']
     PassInput: typeof import('./components/form/passInput.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     SmsCaptchaInput: typeof import('./components/form/smsCaptchaInput.vue')['default']
+    Teleport: typeof import('./components/teleport/index.vue')['default']
     Toast: typeof import('./components/toast/Toast.vue')['default']
   }
 }

+ 124 - 0
plugins/login-auth/src/components/dialog/async-captcha.js

@@ -0,0 +1,124 @@
+import Vue, { ref } from 'vue'
+import CaptchaDialog from './captcha-dialog.vue'
+
+const CaptchaDialogConstructor = Vue.extend(CaptchaDialog)
+
+export class Captcha {
+  vm = null // 保存实例
+  // 实例
+  get $vm() {
+    if (this.vm) {
+      return this.vm
+    } else {
+      return this.install()
+    }
+  }
+
+  // 显示隐藏的状态
+  get show() {
+    if (!this.vm) {
+      return false
+    } else {
+      return this.$vm?.value || false
+    }
+  }
+
+  defaultConf = {
+    noop: () => {},
+    getContainer: '.login-auth--form'
+  }
+
+  conf = {}
+
+  constructor(conf = {}) {
+    Object.assign(this.conf, this.defaultConf, conf)
+  }
+
+  install() {
+    if (this.vm) {
+      return this.vm
+    }
+    this.vm = new CaptchaDialogConstructor()
+    this.vm.$mount()
+    this.vm.$on('input', this.onInput.bind(this))
+    document.querySelector(this.conf.getContainer).appendChild(this.$vm.$el)
+    return this.vm
+  }
+
+  verify(conf = {}) {
+    if (!this.vm) {
+      this.install()
+    }
+
+    const vm = this.$vm
+    const { noop } = this.conf
+    const {
+      value = false,
+      captchaData = {},
+      refresh = noop,
+      confirm = noop
+    } = conf
+
+    this.refreshConfig(captchaData)
+
+    // 移除所有事件监听
+    vm.$off('refresh')
+    vm.$off('confirm')
+
+    if (value) {
+      vm.$on('refresh', refresh.bind(this))
+      vm.$on('confirm', confirm.bind(this))
+    }
+    // 显示或隐藏
+    vm.value = value
+  }
+
+  refreshConfig(captchaData) {
+    const vm = this.$vm
+    Object.assign(vm.slideData, captchaData)
+    if (vm.slideData && vm.slideData.captKey) {
+      vm.$forceUpdate()
+    }
+  }
+
+  onInput(value) {
+    this.verify({ value })
+  }
+
+  trigger(name) {
+    const vm = this.$vm
+    vm.$emit(name)
+  }
+}
+
+export default function showAsyncCaptchaDialog(options = {}) {
+  const { captchaData, refresh, confirm } = options
+
+  return new Promise((resolve) => {
+    const instance = new Captcha()
+    instance.verify({
+      value: true,
+      captchaData,
+      async refresh() {
+        if (refresh) {
+          const result = await refresh()
+          if (result) {
+            instance.refreshConfig(result)
+          }
+        }
+      },
+      async confirm({ point, reset }) {
+        if (confirm) {
+          const pass = await confirm({ point })
+          if (pass) {
+            instance.onInput(false)
+            resolve()
+          } else {
+            reset()
+            instance.trigger('refresh')
+          }
+        }
+      }
+    })
+  })
+}

+ 122 - 0
plugins/login-auth/src/components/dialog/captcha-dialog.vue

@@ -0,0 +1,122 @@
+<script setup lang="ts">
+// http://gocaptcha.wencodes.com/package/vue/
+import { ref } from 'vue'
+import { Slide } from 'go-captcha-vue'
+// import type { Config, ExportMethods, SlideData, SlideEvents, SlidePoint } from 'go-captcha-vue'
+import Teleport from '@/components/teleport/index.vue'
+import 'go-captcha-vue/dist/style.css'
+
+// // data = {}
+// interface SlideData {
+//   thumbX: number;
+//   thumbY: number;
+//   thumbWidth: number;
+//   thumbHeight: number;
+//   image: string;
+//   thumb: string;
+// }
+
+// // export component method
+// interface ExportMethods {
+//   reset: () => void,
+//   clear: () => void,
+//   refresh: () => void,
+//   close: () => void,
+// }
+
+const props = defineProps({
+  value: {
+    type: Boolean,
+    default: false
+  },
+  showMask: {
+    type: Boolean,
+    default: true
+  },
+  slideConfig: {
+    type: Object,
+    default: () => ({})
+  },
+  slideData: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const emit = defineEmits(['input', 'refresh', 'confirm'])
+const captcha = ref(null)
+const captchaSlide = ref(null)
+
+const updateVisible = function (e: boolean) {
+  emit('input', e)
+}
+const captchaRefresh = function () {
+  emit('refresh')
+}
+
+const slideEvents = {
+  refresh() {
+    captchaRefresh()
+  },
+  close() {
+    updateVisible(false)
+  },
+  confirm(point: object, reset: () => void) {
+    emit('confirm', {
+      point,
+      reset
+    })
+  }
+}
+</script>
+
+<template>
+  <div ref="captcha" class="login-captcha-dialog-wrapper">
+    <Teleport v-if="props.value" to=".plugin-login-auth-container">
+      <div class="login-captcha-group">
+        <div v-if="props.showMask" class="login-captcha-mask" />
+        <div class="login-captcha-container">
+          <Slide
+            ref="captchaSlide"
+            :key="props.slideData.captKey"
+            :config="props.slideConfig"
+            :data="props.slideData"
+            :events="slideEvents"
+          />
+        </div>
+      </div>
+    </Teleport>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+@keyframes fadeIn {
+  to {
+    /* 动画结束时,遮罩完全显示,透明度为 1 */
+    opacity: 1;
+    pointer-events: auto;
+  }
+}
+
+.login-captcha-mask {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  z-index: 99999;
+  opacity: 0;
+  /* 禁用指针事件,避免影响下层元素操作 */
+  pointer-events: none;
+  /* 定义动画,名称为 fadeIn,持续时间 0.5 秒,使用 ease 缓动函数 */
+  animation: fadeIn 0.5s ease forwards;
+}
+.login-captcha-container {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 999999;
+}
+</style>

+ 1 - 0
plugins/login-auth/src/components/form/smsCaptchaInput.vue

@@ -38,6 +38,7 @@ async function doGetSms() {
   }
 }
 </script>
+
 <template>
   <div class="login-auth--form-after" :class="{ disable: !canDoSetSms }">
     <span v-if="!countDown.isCounting.value" @click="doGetSms">获取验证码</span>

+ 84 - 0
plugins/login-auth/src/components/teleport/index.vue

@@ -0,0 +1,84 @@
+<script lang="ts">
+import { defineComponent } from 'vue'
+
+export default defineComponent({
+  name: 'Teleport',
+  abstract: true,
+  props: {
+    to: {
+      type: [String, HTMLElement],
+      required: true
+    },
+    disabled: Boolean
+  },
+  data() {
+    const nonReactive = {
+      teleported: false,
+      lastTo: '',
+      rootEl: undefined,
+      originalEl: undefined,
+      dumb: document.createComment(' teleport ')
+    } as {
+      teleported: boolean
+      lastTo: string | HTMLElement
+      rootEl?: Node
+      originalEl?: Node
+      dumb: Node
+    }
+    Object.preventExtensions(nonReactive)
+    return nonReactive
+  },
+  mounted() {
+    this.onEnter()
+  },
+  updated() {
+    this.onEnter() // 除了 to、disabled 外,rootEl 以及 originalEl 也可能发生变化
+  },
+  beforeDestroy() {
+    this.dumb.parentNode?.removeChild(this.dumb)
+    this.rootEl?.parentNode?.removeChild(this.rootEl)
+  },
+  methods: {
+    onEnter() {
+      // @ts-ignore
+      this.rootEl = this._vnode.elm
+      // @ts-ignore
+      this.originalEl = this.teleported
+        ? this.dumb?.parentNode
+        : this.rootEl?.parentNode
+
+      this.disabled ? this.restore() : this.telport()
+    },
+    restore() {
+      if (!this.teleported) return
+      this.$vnode.elm = this.rootEl
+      this.teleported = false
+      this.lastTo = ''
+
+      if (!this.originalEl) return
+      this.rootEl && this.originalEl.insertBefore(this.rootEl, this.dumb)
+      this.dumb.parentNode?.removeChild(this.dumb)
+    },
+    telport() {
+      if (!this.rootEl || !this.originalEl) return
+      if (!this.teleported) this.originalEl.insertBefore(this.dumb, this.rootEl)
+      this.$vnode.elm = this.dumb
+      this.teleported = true
+
+      let targetEl
+      if (this.to !== this.lastTo)
+        targetEl =
+          typeof this.to === 'string'
+            ? document.querySelector(this.to)
+            : this.to
+      if (!targetEl) return
+      targetEl.appendChild(this.rootEl)
+      this.lastTo = this.to
+    }
+  },
+  render(h) {
+    const first = this.$slots.default?.[0]
+    return first
+  }
+})
+</script>

+ 6 - 2
plugins/login-auth/src/components/toast/Toast.vue

@@ -1,5 +1,9 @@
 <template>
-  <div v-if="showWrap" class="jy-toast" :class="showContent ? 'fadein' : 'fadeout'">
+  <div
+    v-if="showWrap"
+    class="jy-toast"
+    :class="showContent ? 'fadein' : 'fadeout'"
+  >
     {{ text }}
   </div>
 </template>
@@ -15,7 +19,7 @@
   transform: translate(-50%, -50%);
   color: #fff;
   font-size: 16px;
-  z-index: 9999;
+  z-index: 99999;
 }
 .fadein {
   animation: animate_in 0.25s;

+ 110 - 0
plugins/login-auth/src/utils/useSmsVerify.js

@@ -0,0 +1,110 @@
+import showAsyncCaptchaDialog from '../components/dialog/async-captcha'
+import { ajaxCheckCaptchaCode, ajaxGetSMSCode } from '@/api'
+import { useToast } from '@/utils/use'
+
+export async function getSMSVerifyCaptcha(phone, mold) {
+  const result = await ajaxGetSMSCode({ phone, mold })
+  // Object.assign(result.data.data, {
+  //   captcha_key: '2dbeb6c6db961938419408483bca30dc',
+  //   image_base64: 'data:image/jpeg;base64,/9j/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEI==',
+  //   tile_base64: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD8AAAA/CAYAAABXXxDfAAAWSElEQVR4nMx7W49kZ9Xe8x72qar6PNMznhnP2GPjzx4Ptj+fwP4iAsEJWMEBCyJAuUguo/wCbnKfm1zkKj8CRYqEiBAoyICxgwW+IDEk8Ql77PF4jl2HfXqPn9bau5pJggg==',
+  //   tile_width: 63,
+  //   tile_height: 63,
+  //   tile_x: 24,
+  //   tile_y: 24
+  // })
+  return result.data
+}
+
+function getCaptchaData(x = {}) {
+  return {
+    image: x.image_base64 || '',
+    thumb: x.tile_base64 || '',
+    captKey: x.captcha_key || '',
+    thumbX: x.tile_x || 0,
+    thumbY: x.tile_y || 0,
+    thumbWidth: x.tile_width || 0,
+    thumbHeight: x.tile_height || 0
+  }
+}
+
+async function refreshCaptcha(phone, mold) {
+  const { error_code: code, data } = await getSMSVerifyCaptcha(phone, mold)
+  if (code === 0 && data) {
+    if (data.code === 1) {
+      return getCaptchaData(data)
+    } else {
+      return false
+    }
+  } else {
+    return false
+  }
+}
+
+async function checkVerify(params = {}) {
+  const { data: axiosData } = await ajaxCheckCaptchaCode(params)
+  const { error_code: code, error_msg: msg, data } = axiosData
+  if (code === 0 && data && data.code === 0) {
+    return true
+  } else {
+    if (msg) {
+      useToast(msg)
+    }
+    return false
+  }
+}
+
+export async function useSMSVerify(options = {}) {
+  // /publicapply/captcha/get:mold:1:登录;2:注册;3:找回密码
+  const { phone, mold } = options
+  const captchaInfo = {}
+
+  const {
+    error_code: code,
+    error_msg: msg,
+    data
+  } = await getSMSVerifyCaptcha(phone, mold)
+  if (code === 0 && data) {
+    if (data.code === 0) {
+      return {
+        pass: true
+      }
+    } else if (data.code === 1) {
+      // 初始化值
+      const captchaData = getCaptchaData(data)
+      Object.assign(captchaInfo, captchaData)
+      await showAsyncCaptchaDialog({
+        captchaData,
+        async refresh() {
+          const refresh = await refreshCaptcha(phone, mold)
+          if (refresh) {
+            Object.assign(captchaInfo, refresh)
+          }
+          return refresh
+        },
+        async confirm({ point }) {
+          const pass = await checkVerify({
+            phone,
+            key: captchaInfo.captKey,
+            point: `${point.x},${point.y}`
+          })
+          return pass
+        }
+      })
+
+      return {
+        pass: true,
+        captKey: captchaInfo.captKey,
+        phone
+      }
+    }
+  } else {
+    if (msg) {
+      useToast(msg)
+    }
+  }
+
+  return {
+    pass: false
+  }
+}

+ 41 - 73
plugins/login-auth/src/views/form/login.vue

@@ -1,24 +1,23 @@
 <script setup lang="ts">
 import { some } from 'lodash'
-import { ajaxSetLogin } from '@/api'
 import { computed, ref } from 'vue'
-import { tabActive, doChangeTabActive } from '@/module-model/tab'
+import { ajaxSetLogin } from '@/api'
+import { doChangeTabActive, tabActive } from '@/module-model/tab'
 import { autoLoginState, doSelectAutoLogin } from '@/module-model/autoLogin'
 import { FormRules } from '@/data/constant'
 import { trackReport } from '@/utils/common'
 import { useToast } from '@/utils/use'
 import pluginLogin from '@/lib/pluginLogin'
+import { useSMSVerify } from '@/utils/useSmsVerify'
 
 // 埋点上报
 
 function track() {
   const href = window.location.href
   let sourceStr = ''
-  if (href.indexOf('/front/structed/pc_index.html?source=baidusem') > -1) {
+  if (href.includes('/front/structed/pc_index.html?source=baidusem')) {
     sourceStr = '结构化数据-pc-baidusem'
-  } else if (
-    href.indexOf('/bid/pc/page/bidfile_landpage?source=baidusem') > -1
-  ) {
+  } else if (href.includes('/bid/pc/page/bidfile_landpage?source=baidusem')) {
     sourceStr = '招标文件解读-pc-baidusem'
   }
 
@@ -41,11 +40,12 @@ const loginCodeFormState = ref({
     value: '',
     validate: false
   },
-  imgCaptcha: {
-    value: '',
-    validate: false
-  },
+  // imgCaptcha: {
+  //   value: '',
+  //   validate: true
+  // },
   smsCaptcha: {
+    captKey: '',
     value: '',
     validate: false
   }
@@ -76,6 +76,8 @@ const loginCodeForm = {
     const params = {
       reqType: 'identCodeLogin',
       identCode: loginCodeFormState.value.smsCaptcha.value,
+      phone: loginCodeFormState.value.phone.value,
+      captchaKey: loginCodeFormState.value.smsCaptcha.captKey,
       isAutoLogin: autoLoginState.value,
       source: urlParams.source
     }
@@ -126,19 +128,19 @@ const loginCodeFormSchema = {
         maxlength: 11
       }
     },
-    {
-      key: 'imgCaptcha',
-      type: 'imgCaptcha',
-      icon: 'icon-guard',
-      message: '图形验证码输入错误',
-      rule: FormRules.imgCode,
-      inputAttr: {
-        name: 'verify_code',
-        type: 'text',
-        placeholder: '输入图形验证码',
-        maxlength: 4
-      }
-    },
+    // {
+    //   key: 'imgCaptcha',
+    //   type: 'imgCaptcha',
+    //   icon: 'icon-guard',
+    //   message: '图形验证码输入错误',
+    //   rule: FormRules.imgCode,
+    //   inputAttr: {
+    //     name: 'verify_code',
+    //     type: 'text',
+    //     placeholder: '输入图形验证码',
+    //     maxlength: 4
+    //   }
+    // },
     {
       key: 'smsCaptcha',
       type: 'smsCaptcha',
@@ -153,58 +155,22 @@ const loginCodeFormSchema = {
       },
       expands: {
         preCheckState: computed(() => {
-          const result =
-            loginCodeFormState.value.phone.validate &&
-            loginCodeFormState.value.imgCaptcha.validate
+          const result = loginCodeFormState.value.phone.validate
           return result
         })
       },
       actions: {
-        preSubmit: async function () {
+        async preSubmit() {
           trackReport('huiju', 'c_register', {
             c_platform: 'pc',
             c_type: '注册行为-验证码登录/注册-获取验证码',
             date: new Date()
           })
 
-          const params = {
-            reqType: 'sendIdentCode',
-            phone: loginCodeFormState.value.phone.value,
-            code: loginCodeFormState.value.imgCaptcha.value
-          }
-          const result = await ajaxSetLogin(params)
-            .then(({ data }) => {
-              const type = data.status
-              if (type < 0) {
-                const errorMaps = {
-                  '-1': {
-                    key: 0,
-                    message: '手机号格式错误'
-                  },
-                  '-2': {
-                    key: 1,
-                    message: '图形验证码输入错误'
-                  },
-                  '-3': {
-                    key: 0,
-                    message: '手机号已被注册'
-                  }
-                }
-                loginCodeFormNode.value.showError(
-                  errorMaps[type].key,
-                  errorMaps[type].message
-                )
-                if (type === -2 || type === -3) {
-                  loginCodeFormNode.value.doRefreshCaptcha()
-                }
-                return false
-              }
-              return true
-            })
-            .catch(() => {
-              useToast('出现错误,请稍后重试')
-            })
-          return result
+          const phone = loginCodeFormState.value.phone.value
+          const { pass, captKey } = await useSMSVerify({ phone, mold: 1 })
+          loginCodeFormState.value.smsCaptcha.captKey = captKey
+          return pass
         }
       }
     }
@@ -308,6 +274,7 @@ const loginPassFormSchema = {
   ]
 }
 </script>
+
 <template>
   <!--  表单  -->
   <div class="login-auth--form">
@@ -335,15 +302,16 @@ const loginPassFormSchema = {
       </div>
       <!-- 验证码登录 -->
       <div
-        class="login-auth--switch-tab-content"
         v-if="tabActive === 'login-code'"
+        class="login-auth--switch-tab-content"
+        :class="{ mt40: tabActive === 'login-code' }"
       >
         <base-form
           ref="loginCodeFormNode"
-          @submit="loginCodeForm.doSubmit"
-          :schema="loginCodeFormSchema.fields"
           v-model="loginCodeFormState"
-        ></base-form>
+          :schema="loginCodeFormSchema.fields"
+          @submit="loginCodeForm.doSubmit"
+        />
         <button
           class="login-auth--submit-button"
           name="verify_submit"
@@ -356,16 +324,16 @@ const loginPassFormSchema = {
 
       <!-- 密码登录 -->
       <div
+        v-if="tabActive === 'login-pass'"
         class="login-auth--switch-tab-content"
         :class="{ mt40: tabActive === 'login-pass' }"
-        v-if="tabActive === 'login-pass'"
       >
         <base-form
           ref="loginPassFormNode"
-          @submit="loginPassForm.doSubmit"
-          :schema="loginPassFormSchema.fields"
           v-model="loginPassFormState"
-        ></base-form>
+          :schema="loginPassFormSchema.fields"
+          @submit="loginPassForm.doSubmit"
+        />
         <button
           class="login-auth--submit-button"
           name="verify_submit"

文件差异内容过多而无法显示
+ 236 - 403
pnpm-lock.yaml


部分文件因为文件数量过多而无法显示