ChatWidget.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. <template>
  2. <!-- 悬浮按钮 -->
  3. <div class="chat-fab" @click="toggleChat">
  4. <van-icon :name="showChat ? 'cross' : 'chat-o'" size="22" color="#fff" />
  5. </div>
  6. <!-- 聊天面板 -->
  7. <van-popup
  8. v-model:show="showChat"
  9. position="bottom"
  10. round
  11. :style="{ height: '65vh' }"
  12. teleport="body"
  13. >
  14. <div class="chat-panel">
  15. <div class="chat-header">
  16. <span class="chat-title">🤖 小愿 · 智能客服</span>
  17. <van-icon name="cross" size="18" @click="showChat = false" />
  18. </div>
  19. <div class="chat-body" ref="msgBody">
  20. <!-- 加载更多历史记录 -->
  21. <div v-if="!historyFinished && historyLoaded" class="load-more" @click="loadHistory">
  22. <span v-if="historyLoading">加载中...</span>
  23. <span v-else>点击加载更多历史</span>
  24. </div>
  25. <div v-if="messages.length === 0" class="chat-empty">
  26. <p>👋 你好,我是许愿树小助手"小愿"</p>
  27. <p>有什么关于许愿的问题都可以问我~</p>
  28. </div>
  29. <div
  30. v-for="(msg, i) in messages"
  31. :key="i"
  32. :class="['chat-msg', msg.role, { 'has-wish': msg.wish }]"
  33. >
  34. <div v-if="msg.type === 'image'" class="chat-bubble chat-bubble-image">
  35. <van-image :src="msg.content" fit="cover" radius="8" style="max-width:200px" />
  36. </div>
  37. <div v-else class="chat-bubble">{{ msg.content }}</div>
  38. <!-- AI 许愿建议卡 -->
  39. <div v-if="msg.wish" class="wish-card">
  40. <div class="wish-card-header">✨ 愿望概览</div>
  41. <p class="wish-card-content">{{ msg.wish.content }}</p>
  42. <div class="wish-card-tags" v-if="msg.wish.tags?.length">
  43. <van-tag v-for="t in msg.wish.tags" :key="t" plain type="primary" size="medium">
  44. {{ t }}
  45. </van-tag>
  46. </div>
  47. <div class="wish-card-actions">
  48. <van-button size="small" round plain @click="cancelWish(i)">再想想</van-button>
  49. <van-button size="small" round type="primary" :loading="msg.wishSubmitting" @click="confirmWish(i)">
  50. 确认许愿 🙏
  51. </van-button>
  52. </div>
  53. </div>
  54. </div>
  55. <div v-if="sending" class="chat-msg ai">
  56. <div class="chat-bubble typing">...</div>
  57. </div>
  58. </div>
  59. <div class="chat-footer">
  60. <van-field
  61. v-model="input"
  62. type="textarea"
  63. rows="1"
  64. autosize
  65. placeholder="输入你的问题..."
  66. :border="false"
  67. />
  68. <van-button
  69. type="primary"
  70. size="small"
  71. round
  72. :disabled="!input.trim() || sending"
  73. @click="send"
  74. >
  75. 发送
  76. </van-button>
  77. </div>
  78. </div>
  79. </van-popup>
  80. </template>
  81. <script setup lang="ts">
  82. import { ref, nextTick, watch } from 'vue'
  83. import { showToast, showSuccessToast } from 'vant'
  84. import { sendMessage, fetchHistory } from '@/api/chat'
  85. import { submitWish } from '@/api/wish'
  86. import { useUserStore } from '@/stores/user'
  87. import { useLocationStore } from '@/stores/location'
  88. const props = defineProps<{
  89. treeId?: number
  90. treeName?: string
  91. treeAddress?: string
  92. isInRange?: boolean
  93. distance?: number
  94. }>()
  95. interface WishSuggestion {
  96. content: string
  97. tags: string[]
  98. }
  99. interface ChatMsg {
  100. role: 'user' | 'ai'
  101. content: string
  102. type?: string
  103. wish?: WishSuggestion
  104. wishSubmitting?: boolean
  105. }
  106. const userStore = useUserStore()
  107. const locationStore = useLocationStore()
  108. const showChat = ref(false)
  109. const input = ref('')
  110. const sending = ref(false)
  111. const messages = ref<ChatMsg[]>([])
  112. const msgBody = ref<HTMLElement | null>(null)
  113. const historyLoaded = ref(false)
  114. const historyLoading = ref(false)
  115. const historyFinished = ref(false)
  116. let historyPage = 1
  117. const conversationId = ref(crypto.randomUUID())
  118. /** 从 AI 回复中提取 [WISH]...[/WISH] 标记,兼容各种格式变体 */
  119. function parseWish(content: string): { text: string; wish: WishSuggestion | null } {
  120. // 尝试多种可能的标记格式
  121. const patterns = [
  122. /\[WISH\]\s*(\{[\s\S]*?\})\s*\[\/WISH\]/i,
  123. /\[WISH\]\s*([\s\S]*?)\s*\[\/WISH\]/i,
  124. /\【WISH\】\s*(\{[\s\S]*?\})\s*\【\/WISH\】/i,
  125. ]
  126. for (const regex of patterns) {
  127. const match = content.match(regex)
  128. if (match) {
  129. try {
  130. const wish = JSON.parse(match[1])
  131. const text = content.replace(regex, '').trim()
  132. if (wish.content) {
  133. return { text, wish: { content: wish.content, tags: wish.tags || [] } }
  134. }
  135. } catch {
  136. // 继续尝试下一个模式
  137. }
  138. }
  139. }
  140. return { text: content, wish: null }
  141. }
  142. async function loadHistory() {
  143. if (!props.treeId || historyLoading.value || historyFinished.value) return
  144. historyLoading.value = true
  145. try {
  146. const { records, total } = await fetchHistory(props.treeId, historyPage, 15)
  147. if (records.length > 0) {
  148. // 后端返回 DESC(最新在前),反转后插入消息列表开头
  149. const oldMessages = messages.value
  150. const historyMsgs = records.reverse().map((m) => ({ role: m.role, content: m.content, type: m.type }))
  151. messages.value = [...historyMsgs, ...oldMessages]
  152. }
  153. historyFinished.value = records.length < 15 || messages.value.length >= total
  154. historyPage++
  155. } catch {
  156. historyFinished.value = true
  157. } finally {
  158. historyLoading.value = false
  159. historyLoaded.value = true
  160. }
  161. }
  162. function toggleChat() {
  163. showChat.value = !showChat.value
  164. }
  165. function scrollToBottom() {
  166. nextTick(() => {
  167. const el = msgBody.value
  168. if (el) {
  169. el.scrollTop = el.scrollHeight
  170. }
  171. })
  172. }
  173. async function send() {
  174. const text = input.value.trim()
  175. if (!text || sending.value) return
  176. messages.value.push({ role: 'user', content: text })
  177. input.value = ''
  178. scrollToBottom()
  179. sending.value = true
  180. try {
  181. const reply = await sendMessage(text, conversationId.value, props.treeId)
  182. const { text: cleanText, wish } = parseWish(reply)
  183. // 如果 AI 生成了一键许愿建议,但用户不在许愿范围内,直接拒绝
  184. if (wish && props.isInRange === false) {
  185. const distText = props.distance
  186. ? (props.distance >= 1000 ? (props.distance / 1000).toFixed(1) + 'km' : props.distance + 'm')
  187. : '较远'
  188. messages.value.push({
  189. role: 'ai',
  190. content: `抱歉哦~ 你当前距离许愿树还有 ${distText},超出了许愿范围,暂时无法帮你一键许愿。请先靠近许愿树后再来找我帮忙吧!📿`,
  191. })
  192. } else {
  193. messages.value.push({ role: 'ai', content: cleanText || reply, wish: wish || undefined })
  194. }
  195. } catch {
  196. messages.value.push({ role: 'ai', content: '抱歉,网络出了问题,请稍后再试~' })
  197. } finally {
  198. sending.value = false
  199. scrollToBottom()
  200. }
  201. }
  202. function cancelWish(index: number) {
  203. delete messages.value[index].wish
  204. }
  205. async function confirmWish(index: number) {
  206. const msg = messages.value[index]
  207. if (!msg.wish) return
  208. if (!userStore.isLoggedIn) {
  209. showToast('请先登录再许愿')
  210. showChat.value = false
  211. return
  212. }
  213. // 检查是否在许愿范围内
  214. if (props.isInRange === false) {
  215. const distText = props.distance
  216. ? (props.distance >= 1000 ? (props.distance / 1000).toFixed(1) + 'km' : props.distance + 'm')
  217. : '太远'
  218. delete msg.wish
  219. messages.value.push({
  220. role: 'ai',
  221. content: `抱歉哦~ 你当前距离许愿树还有 ${distText},超出了许愿范围。请靠近许愿树后再来找我许愿吧!📿`,
  222. })
  223. scrollToBottom()
  224. return
  225. }
  226. msg.wishSubmitting = true
  227. try {
  228. await submitWish({
  229. treeId: props.treeId || 0,
  230. content: msg.wish.content,
  231. images: [],
  232. lng: locationStore.lng || 0,
  233. lat: locationStore.lat || 0,
  234. address: props.treeAddress || '',
  235. isPublic: true,
  236. tags: msg.wish.tags,
  237. type: 2,
  238. })
  239. showSuccessToast('愿望已挂在树上!祝你心想事成 ✨')
  240. delete msg.wish
  241. } catch {
  242. showToast('许愿失败,请稍后再试')
  243. } finally {
  244. msg.wishSubmitting = false
  245. }
  246. }
  247. watch(showChat, (val) => {
  248. if (val) {
  249. if (!historyLoaded.value) {
  250. loadHistory()
  251. }
  252. scrollToBottom()
  253. }
  254. })
  255. </script>
  256. <style scoped>
  257. .chat-fab {
  258. position: fixed;
  259. right: 16px;
  260. bottom: 80px;
  261. width: 48px;
  262. height: 48px;
  263. border-radius: 50%;
  264. background: linear-gradient(135deg, #07c160, #05a650);
  265. box-shadow: 0 4px 12px rgba(7, 193, 96, 0.4);
  266. display: flex;
  267. align-items: center;
  268. justify-content: center;
  269. z-index: 999;
  270. cursor: pointer;
  271. transition: transform 0.2s;
  272. }
  273. .chat-fab:active {
  274. transform: scale(0.9);
  275. }
  276. .chat-panel {
  277. display: flex;
  278. flex-direction: column;
  279. height: 100%;
  280. background: #f7f8fa;
  281. }
  282. .chat-header {
  283. display: flex;
  284. align-items: center;
  285. justify-content: space-between;
  286. padding: 14px 16px;
  287. background: #fff;
  288. border-bottom: 1px solid #ebedf0;
  289. flex-shrink: 0;
  290. }
  291. .chat-title {
  292. font-size: 16px;
  293. font-weight: 600;
  294. }
  295. .chat-body {
  296. flex: 1;
  297. overflow-y: auto;
  298. padding: 12px 14px;
  299. display: flex;
  300. flex-direction: column;
  301. gap: 10px;
  302. }
  303. .load-more {
  304. text-align: center;
  305. color: #999;
  306. font-size: 12px;
  307. padding: 8px;
  308. cursor: pointer;
  309. }
  310. .chat-empty {
  311. text-align: center;
  312. color: #999;
  313. margin-top: 40px;
  314. font-size: 14px;
  315. line-height: 2;
  316. }
  317. .chat-msg {
  318. display: flex;
  319. max-width: 80%;
  320. }
  321. .chat-msg.user {
  322. align-self: flex-end;
  323. }
  324. .chat-msg.ai {
  325. align-self: flex-start;
  326. }
  327. .chat-msg.has-wish {
  328. width: 100%;
  329. max-width: 100%;
  330. align-self: stretch;
  331. flex-direction: column;
  332. }
  333. .chat-msg.has-wish .chat-bubble {
  334. width: 100%;
  335. }
  336. .chat-bubble {
  337. padding: 10px 14px;
  338. border-radius: 16px;
  339. font-size: 14px;
  340. line-height: 1.6;
  341. word-break: break-word;
  342. }
  343. .chat-msg.user .chat-bubble {
  344. background: #07c160;
  345. color: #fff;
  346. border-bottom-right-radius: 4px;
  347. }
  348. .chat-msg.ai .chat-bubble {
  349. background: #fff;
  350. color: #333;
  351. border-bottom-left-radius: 4px;
  352. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
  353. }
  354. .chat-bubble.typing {
  355. color: #999;
  356. padding: 10px 18px;
  357. }
  358. .chat-bubble-image {
  359. padding: 4px !important;
  360. background: transparent !important;
  361. box-shadow: none !important;
  362. }
  363. .chat-footer {
  364. display: flex;
  365. align-items: flex-end;
  366. padding: 8px 12px;
  367. background: #fff;
  368. border-top: 1px solid #ebedf0;
  369. gap: 8px;
  370. flex-shrink: 0;
  371. }
  372. .chat-footer :deep(.van-cell) {
  373. flex: 1;
  374. background: #f5f5f5;
  375. border-radius: 12px;
  376. padding: 8px 14px;
  377. }
  378. .chat-footer :deep(.van-field__control) {
  379. max-height: 120px;
  380. overflow-y: auto;
  381. }
  382. /* 许愿建议卡 */
  383. .wish-card {
  384. margin: 8px 0 0 0;
  385. background: #fff;
  386. border-radius: 12px;
  387. padding: 16px;
  388. border: 1px solid #07c160;
  389. box-shadow: 0 2px 8px rgba(7, 193, 96, 0.15);
  390. }
  391. .wish-card-header {
  392. font-size: 13px;
  393. font-weight: 600;
  394. color: #07c160;
  395. margin-bottom: 8px;
  396. }
  397. .wish-card-content {
  398. font-size: 14px;
  399. line-height: 1.6;
  400. color: #333;
  401. margin-bottom: 8px;
  402. }
  403. .wish-card-tags {
  404. display: flex;
  405. flex-wrap: wrap;
  406. gap: 4px;
  407. margin-bottom: 10px;
  408. }
  409. .wish-card-actions {
  410. display: flex;
  411. justify-content: flex-end;
  412. gap: 8px;
  413. }
  414. </style>