|
@@ -26,9 +26,25 @@
|
|
|
<div
|
|
<div
|
|
|
v-for="(msg, i) in messages"
|
|
v-for="(msg, i) in messages"
|
|
|
:key="i"
|
|
:key="i"
|
|
|
- :class="['chat-msg', msg.role]"
|
|
|
|
|
|
|
+ :class="['chat-msg', msg.role, { 'has-wish': msg.wish }]"
|
|
|
>
|
|
>
|
|
|
<div class="chat-bubble">{{ msg.content }}</div>
|
|
<div 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>
|
|
|
<div v-if="sending" class="chat-msg ai">
|
|
<div v-if="sending" class="chat-msg ai">
|
|
|
<div class="chat-bubble typing">...</div>
|
|
<div class="chat-bubble typing">...</div>
|
|
@@ -58,17 +74,35 @@
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
import { ref, nextTick, watch } from 'vue'
|
|
import { ref, nextTick, watch } from 'vue'
|
|
|
|
|
+import { showToast, showSuccessToast } from 'vant'
|
|
|
import { sendMessage, fetchHistory } from '@/api/chat'
|
|
import { sendMessage, fetchHistory } from '@/api/chat'
|
|
|
|
|
+import { submitWish } from '@/api/wish'
|
|
|
|
|
+import { useUserStore } from '@/stores/user'
|
|
|
|
|
+import { useLocationStore } from '@/stores/location'
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
const props = defineProps<{
|
|
|
treeId?: number
|
|
treeId?: number
|
|
|
|
|
+ treeName?: string
|
|
|
|
|
+ treeAddress?: string
|
|
|
|
|
+ isInRange?: boolean
|
|
|
|
|
+ distance?: number
|
|
|
}>()
|
|
}>()
|
|
|
|
|
|
|
|
|
|
+interface WishSuggestion {
|
|
|
|
|
+ content: string
|
|
|
|
|
+ tags: string[]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
interface ChatMsg {
|
|
interface ChatMsg {
|
|
|
role: 'user' | 'ai'
|
|
role: 'user' | 'ai'
|
|
|
content: string
|
|
content: string
|
|
|
|
|
+ wish?: WishSuggestion
|
|
|
|
|
+ wishSubmitting?: boolean
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const userStore = useUserStore()
|
|
|
|
|
+const locationStore = useLocationStore()
|
|
|
|
|
+
|
|
|
const showChat = ref(false)
|
|
const showChat = ref(false)
|
|
|
const input = ref('')
|
|
const input = ref('')
|
|
|
const sending = ref(false)
|
|
const sending = ref(false)
|
|
@@ -76,6 +110,31 @@ const messages = ref<ChatMsg[]>([])
|
|
|
const msgBody = ref<HTMLElement | null>(null)
|
|
const msgBody = ref<HTMLElement | null>(null)
|
|
|
const historyLoaded = ref(false)
|
|
const historyLoaded = ref(false)
|
|
|
|
|
|
|
|
|
|
+/** 从 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() {
|
|
async function loadHistory() {
|
|
|
try {
|
|
try {
|
|
|
const list = await fetchHistory(props.treeId)
|
|
const list = await fetchHistory(props.treeId)
|
|
@@ -112,7 +171,20 @@ async function send() {
|
|
|
sending.value = true
|
|
sending.value = true
|
|
|
try {
|
|
try {
|
|
|
const reply = await sendMessage(text, props.treeId)
|
|
const reply = await sendMessage(text, props.treeId)
|
|
|
- messages.value.push({ role: 'ai', content: reply })
|
|
|
|
|
|
|
+ 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 {
|
|
} catch {
|
|
|
messages.value.push({ role: 'ai', content: '抱歉,网络出了问题,请稍后再试~' })
|
|
messages.value.push({ role: 'ai', content: '抱歉,网络出了问题,请稍后再试~' })
|
|
|
} finally {
|
|
} finally {
|
|
@@ -121,6 +193,55 @@ async function send() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+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,
|
|
|
|
|
+ })
|
|
|
|
|
+ showSuccessToast('愿望已挂在树上!祝你心想事成 ✨')
|
|
|
|
|
+ delete msg.wish
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ showToast('许愿失败,请稍后再试')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ msg.wishSubmitting = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
watch(showChat, (val) => {
|
|
watch(showChat, (val) => {
|
|
|
if (val) {
|
|
if (val) {
|
|
|
if (!historyLoaded.value) {
|
|
if (!historyLoaded.value) {
|
|
@@ -200,6 +321,15 @@ watch(showChat, (val) => {
|
|
|
.chat-msg.ai {
|
|
.chat-msg.ai {
|
|
|
align-self: flex-start;
|
|
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 {
|
|
.chat-bubble {
|
|
|
padding: 10px 14px;
|
|
padding: 10px 14px;
|
|
@@ -239,4 +369,37 @@ watch(showChat, (val) => {
|
|
|
border-radius: 20px;
|
|
border-radius: 20px;
|
|
|
padding: 6px 14px;
|
|
padding: 6px 14px;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+/* 许愿建议卡 */
|
|
|
|
|
+.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>
|
|
</style>
|