| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446 |
- <template>
- <!-- 悬浮按钮 -->
- <div class="chat-fab" @click="toggleChat">
- <van-icon :name="showChat ? 'cross' : 'chat-o'" size="22" color="#fff" />
- </div>
- <!-- 聊天面板 -->
- <van-popup
- v-model:show="showChat"
- position="bottom"
- round
- :style="{ height: '65vh' }"
- teleport="body"
- >
- <div class="chat-panel">
- <div class="chat-header">
- <span class="chat-title">🤖 小愿 · 智能客服</span>
- <van-icon name="cross" size="18" @click="showChat = false" />
- </div>
- <div class="chat-body" ref="msgBody">
- <!-- 加载更多历史记录 -->
- <div v-if="!historyFinished && historyLoaded" class="load-more" @click="loadHistory">
- <span v-if="historyLoading">加载中...</span>
- <span v-else>点击加载更多历史</span>
- </div>
- <div v-if="messages.length === 0" class="chat-empty">
- <p>👋 你好,我是许愿树小助手"小愿"</p>
- <p>有什么关于许愿的问题都可以问我~</p>
- </div>
- <div
- v-for="(msg, i) in messages"
- :key="i"
- :class="['chat-msg', msg.role, { 'has-wish': msg.wish }]"
- >
- <div v-if="msg.type === 'image'" class="chat-bubble chat-bubble-image">
- <van-image :src="msg.content" fit="cover" radius="8" style="max-width:200px" />
- </div>
- <div v-else class="chat-bubble">{{ msg.content }}</div>
- <!-- AI 许愿建议卡 -->
- <div v-if="msg.wish" class="wish-card">
- <div class="wish-card-header">✨ 愿望概览</div>
- <p class="wish-card-content">{{ msg.wish.content }}</p>
- <div class="wish-card-tags" v-if="msg.wish.tags?.length">
- <van-tag v-for="t in msg.wish.tags" :key="t" plain type="primary" size="medium">
- {{ t }}
- </van-tag>
- </div>
- <div class="wish-card-actions">
- <van-button size="small" round plain @click="cancelWish(i)">再想想</van-button>
- <van-button size="small" round type="primary" :loading="msg.wishSubmitting" @click="confirmWish(i)">
- 确认许愿 🙏
- </van-button>
- </div>
- </div>
- </div>
- <div v-if="sending" class="chat-msg ai">
- <div class="chat-bubble typing">...</div>
- </div>
- </div>
- <div class="chat-footer">
- <van-field
- v-model="input"
- type="textarea"
- rows="1"
- autosize
- placeholder="输入你的问题..."
- :border="false"
- />
- <van-button
- type="primary"
- size="small"
- round
- :disabled="!input.trim() || sending"
- @click="send"
- >
- 发送
- </van-button>
- </div>
- </div>
- </van-popup>
- </template>
- <script setup lang="ts">
- import { ref, nextTick, watch } from 'vue'
- import { showToast, showSuccessToast } from 'vant'
- import { sendMessage, fetchHistory } from '@/api/chat'
- import { submitWish } from '@/api/wish'
- import { useUserStore } from '@/stores/user'
- import { useLocationStore } from '@/stores/location'
- const props = defineProps<{
- treeId?: number
- treeName?: string
- treeAddress?: string
- isInRange?: boolean
- distance?: number
- }>()
- interface WishSuggestion {
- content: string
- tags: string[]
- }
- interface ChatMsg {
- role: 'user' | 'ai'
- content: string
- type?: string
- wish?: WishSuggestion
- wishSubmitting?: boolean
- }
- const userStore = useUserStore()
- const locationStore = useLocationStore()
- const showChat = ref(false)
- const input = ref('')
- const sending = ref(false)
- const messages = ref<ChatMsg[]>([])
- const msgBody = ref<HTMLElement | null>(null)
- const historyLoaded = ref(false)
- const historyLoading = ref(false)
- const historyFinished = ref(false)
- let historyPage = 1
- const conversationId = ref(crypto.randomUUID())
- /** 从 AI 回复中提取 [WISH]...[/WISH] 标记,兼容各种格式变体 */
- function parseWish(content: string): { text: string; wish: WishSuggestion | null } {
- // 尝试多种可能的标记格式
- const patterns = [
- /\[WISH\]\s*(\{[\s\S]*?\})\s*\[\/WISH\]/i,
- /\[WISH\]\s*([\s\S]*?)\s*\[\/WISH\]/i,
- /\【WISH\】\s*(\{[\s\S]*?\})\s*\【\/WISH\】/i,
- ]
- for (const regex of patterns) {
- const match = content.match(regex)
- if (match) {
- try {
- const wish = JSON.parse(match[1])
- const text = content.replace(regex, '').trim()
- if (wish.content) {
- return { text, wish: { content: wish.content, tags: wish.tags || [] } }
- }
- } catch {
- // 继续尝试下一个模式
- }
- }
- }
- return { text: content, wish: null }
- }
- async function loadHistory() {
- if (!props.treeId || historyLoading.value || historyFinished.value) return
- historyLoading.value = true
- try {
- const { records, total } = await fetchHistory(props.treeId, historyPage, 15)
- if (records.length > 0) {
- // 后端返回 DESC(最新在前),反转后插入消息列表开头
- const oldMessages = messages.value
- const historyMsgs = records.reverse().map((m) => ({ role: m.role, content: m.content, type: m.type }))
- messages.value = [...historyMsgs, ...oldMessages]
- }
- historyFinished.value = records.length < 15 || messages.value.length >= total
- historyPage++
- } catch {
- historyFinished.value = true
- } finally {
- historyLoading.value = false
- historyLoaded.value = true
- }
- }
- function toggleChat() {
- showChat.value = !showChat.value
- }
- function scrollToBottom() {
- nextTick(() => {
- const el = msgBody.value
- if (el) {
- el.scrollTop = el.scrollHeight
- }
- })
- }
- async function send() {
- const text = input.value.trim()
- if (!text || sending.value) return
- messages.value.push({ role: 'user', content: text })
- input.value = ''
- scrollToBottom()
- sending.value = true
- try {
- const reply = await sendMessage(text, conversationId.value, props.treeId)
- const { text: cleanText, wish } = parseWish(reply)
- // 如果 AI 生成了一键许愿建议,但用户不在许愿范围内,直接拒绝
- if (wish && props.isInRange === false) {
- const distText = props.distance
- ? (props.distance >= 1000 ? (props.distance / 1000).toFixed(1) + 'km' : props.distance + 'm')
- : '较远'
- messages.value.push({
- role: 'ai',
- content: `抱歉哦~ 你当前距离许愿树还有 ${distText},超出了许愿范围,暂时无法帮你一键许愿。请先靠近许愿树后再来找我帮忙吧!📿`,
- })
- } else {
- messages.value.push({ role: 'ai', content: cleanText || reply, wish: wish || undefined })
- }
- } catch {
- messages.value.push({ role: 'ai', content: '抱歉,网络出了问题,请稍后再试~' })
- } finally {
- sending.value = false
- scrollToBottom()
- }
- }
- function cancelWish(index: number) {
- delete messages.value[index].wish
- }
- async function confirmWish(index: number) {
- const msg = messages.value[index]
- if (!msg.wish) return
- if (!userStore.isLoggedIn) {
- showToast('请先登录再许愿')
- showChat.value = false
- return
- }
- // 检查是否在许愿范围内
- if (props.isInRange === false) {
- const distText = props.distance
- ? (props.distance >= 1000 ? (props.distance / 1000).toFixed(1) + 'km' : props.distance + 'm')
- : '太远'
- delete msg.wish
- messages.value.push({
- role: 'ai',
- content: `抱歉哦~ 你当前距离许愿树还有 ${distText},超出了许愿范围。请靠近许愿树后再来找我许愿吧!📿`,
- })
- scrollToBottom()
- return
- }
- msg.wishSubmitting = true
- try {
- await submitWish({
- treeId: props.treeId || 0,
- content: msg.wish.content,
- images: [],
- lng: locationStore.lng || 0,
- lat: locationStore.lat || 0,
- address: props.treeAddress || '',
- isPublic: true,
- tags: msg.wish.tags,
- type: 2,
- })
- showSuccessToast('愿望已挂在树上!祝你心想事成 ✨')
- delete msg.wish
- } catch {
- showToast('许愿失败,请稍后再试')
- } finally {
- msg.wishSubmitting = false
- }
- }
- watch(showChat, (val) => {
- if (val) {
- if (!historyLoaded.value) {
- loadHistory()
- }
- scrollToBottom()
- }
- })
- </script>
- <style scoped>
- .chat-fab {
- position: fixed;
- right: 16px;
- bottom: 80px;
- width: 48px;
- height: 48px;
- border-radius: 50%;
- background: linear-gradient(135deg, #07c160, #05a650);
- box-shadow: 0 4px 12px rgba(7, 193, 96, 0.4);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 999;
- cursor: pointer;
- transition: transform 0.2s;
- }
- .chat-fab:active {
- transform: scale(0.9);
- }
- .chat-panel {
- display: flex;
- flex-direction: column;
- height: 100%;
- background: #f7f8fa;
- }
- .chat-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 14px 16px;
- background: #fff;
- border-bottom: 1px solid #ebedf0;
- flex-shrink: 0;
- }
- .chat-title {
- font-size: 16px;
- font-weight: 600;
- }
- .chat-body {
- flex: 1;
- overflow-y: auto;
- padding: 12px 14px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
- .load-more {
- text-align: center;
- color: #999;
- font-size: 12px;
- padding: 8px;
- cursor: pointer;
- }
- .chat-empty {
- text-align: center;
- color: #999;
- margin-top: 40px;
- font-size: 14px;
- line-height: 2;
- }
- .chat-msg {
- display: flex;
- max-width: 80%;
- }
- .chat-msg.user {
- align-self: flex-end;
- }
- .chat-msg.ai {
- align-self: flex-start;
- }
- .chat-msg.has-wish {
- width: 100%;
- max-width: 100%;
- align-self: stretch;
- flex-direction: column;
- }
- .chat-msg.has-wish .chat-bubble {
- width: 100%;
- }
- .chat-bubble {
- padding: 10px 14px;
- border-radius: 16px;
- font-size: 14px;
- line-height: 1.6;
- word-break: break-word;
- }
- .chat-msg.user .chat-bubble {
- background: #07c160;
- color: #fff;
- border-bottom-right-radius: 4px;
- }
- .chat-msg.ai .chat-bubble {
- background: #fff;
- color: #333;
- border-bottom-left-radius: 4px;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
- }
- .chat-bubble.typing {
- color: #999;
- padding: 10px 18px;
- }
- .chat-bubble-image {
- padding: 4px !important;
- background: transparent !important;
- box-shadow: none !important;
- }
- .chat-footer {
- display: flex;
- align-items: flex-end;
- padding: 8px 12px;
- background: #fff;
- border-top: 1px solid #ebedf0;
- gap: 8px;
- flex-shrink: 0;
- }
- .chat-footer :deep(.van-cell) {
- flex: 1;
- background: #f5f5f5;
- border-radius: 12px;
- padding: 8px 14px;
- }
- .chat-footer :deep(.van-field__control) {
- max-height: 120px;
- overflow-y: auto;
- }
- /* 许愿建议卡 */
- .wish-card {
- margin: 8px 0 0 0;
- background: #fff;
- border-radius: 12px;
- padding: 16px;
- border: 1px solid #07c160;
- box-shadow: 0 2px 8px rgba(7, 193, 96, 0.15);
- }
- .wish-card-header {
- font-size: 13px;
- font-weight: 600;
- color: #07c160;
- margin-bottom: 8px;
- }
- .wish-card-content {
- font-size: 14px;
- line-height: 1.6;
- color: #333;
- margin-bottom: 8px;
- }
- .wish-card-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
- margin-bottom: 10px;
- }
- .wish-card-actions {
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- }
- </style>
|