Files
cheflinkuser/src/pages/ai/chat/index.vue
T
2026-06-05 17:15:06 +08:00

812 lines
22 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { appAiChatStreamGet, appMerchantCartAddCartPost } from '@/service'
import { useUserStore } from '@/store'
import GoCartPopup from './components/go-cart-popup.vue'
const { t } = useI18n()
const userStore = useUserStore()
function goPreference() {
const current = getCurrentPages?.().slice(-1)[0]
if (current?.route === 'pages/ai/diet-preference/index') return
uni.navigateTo({
url: '/pages/ai/diet-preference/index',
})
}
type ChatMessage = { from: 'ai' | 'me'; text: string }
type RecommendItem = {
id?: string
name?: string
img?: string
price?: string | number
specId?: string
specPrice?: string | number
specMemberPrice?: string | number
specActualSalePrice?: string | number
merchantId?: string | number
storeId?: string | number
dishId?: string | number
}
const PREF_DATA_KEY = 'ai_diet_pref_data'
const inputText = ref('')
const defaultAiMessage = computed(() => t('pages.ai.chat.defaultMessage'))
const messages = ref<ChatMessage[]>([
{ from: 'ai', text: defaultAiMessage.value },
])
const recommendItems = ref<RecommendItem[]>([])
const pendingRecommendItems = ref<RecommendItem[]>([])
const addedRecommendMap = ref<Record<string, boolean>>({})
const addingAll = ref(false)
const addingItemKey = ref('')
const streamTask = ref<UniApp.RequestTask | null>(null)
const isStreaming = ref(false)
const conversationId = ref('')
const toolStatus = ref('')
const thinkingStatus = ref('')
const textQueue = ref('')
const goCartPopupVisible = ref(false)
const goCartPopupContent = ref('')
let goCartPopupResolver: ((value: boolean) => void) | null = null
let typingRafId: ReturnType<typeof setTimeout> | 0 = 0
const TYPING_CHARS_PER_FRAME = 1
function scheduleFrame(cb: () => void): ReturnType<typeof setTimeout> {
const raf = (globalThis as any)?.requestAnimationFrame
if (typeof raf === 'function') {
return raf(cb)
}
// App 端部分运行环境没有 requestAnimationFrame,降级为 16ms 定时器
return setTimeout(cb, 16)
}
function cancelFrame(id: ReturnType<typeof setTimeout> | 0) {
if (!id) return
const caf = (globalThis as any)?.cancelAnimationFrame
if (typeof caf === 'function') {
caf(id)
return
}
clearTimeout(id)
}
const preferenceSummary = computed(() => {
const data = uni.getStorageSync(PREF_DATA_KEY) || {}
if (!data || typeof data !== 'object') return ''
const keys = Object.keys(data).slice(0, 3)
if (!keys.length) return ''
return keys
.map((k) => {
const v = data[k]
if (Array.isArray(v)) return `${k}: ${v.join(',') || '-'}`
return `${k}: ${String(v ?? '-')}`
})
.join(' | ')
})
function tryParseProductLine(line: string): RecommendItem | null {
const trimmed = line.trim()
if (!trimmed.startsWith('- {')) return null
const jsonRaw = trimmed.replace(/^-+\s*/, '')
try {
const parsed = JSON.parse(jsonRaw)
if (!parsed || typeof parsed !== 'object') return null
return parsed as RecommendItem
} catch (e) {
return null
}
}
function isSummaryLine(line: string): boolean {
const text = line.trim()
return /找到\s*\d+\s*件商品.*?预估\s*[0-9.]+.*?实际\s*[0-9.]+.*?优惠\s*[0-9.]+/.test(text)
}
function splitAiTextForRender(text: string): Array<{ text: string; strong: boolean }> {
if (!text) return []
const summaryReg = /(找到\s*\d+\s*件商品.*?预估\s*[0-9.]+.*?实际\s*[0-9.]+.*?优惠\s*[0-9.]+(?:元)?)/g
const result: Array<{ text: string; strong: boolean }> = []
let lastIndex = 0
let match: RegExpExecArray | null = null
while ((match = summaryReg.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push({ text: text.slice(lastIndex, match.index), strong: false })
}
result.push({ text: match[0], strong: true })
lastIndex = summaryReg.lastIndex
}
if (lastIndex < text.length) {
result.push({ text: text.slice(lastIndex), strong: false })
}
return result
}
function resolveDishId(item: RecommendItem): string {
const raw = item.id ?? item.dishId ?? item.specId
if (raw == null) return ''
return String(raw).trim()
}
function resolveMerchantId(item: RecommendItem): string {
const raw = item.merchantId ?? item.storeId
if (raw == null) return ''
return String(raw).trim()
}
function getRecommendKey(item: RecommendItem): string {
return `${resolveMerchantId(item)}__${resolveDishId(item)}`
}
function isRecommendAdded(item: RecommendItem): boolean {
return !!addedRecommendMap.value[getRecommendKey(item)]
}
function canRecommendAdd(item: RecommendItem): boolean {
return !!resolveDishId(item) && !!resolveMerchantId(item)
}
function hasInCart(item: RecommendItem): boolean {
const merchantId = resolveMerchantId(item)
const dishId = resolveDishId(item)
if (!merchantId || !dishId) return false
const merchants = userStore.userCartAllData as Array<any> | null
if (!Array.isArray(merchants)) return false
return merchants.some((merchant) => {
const mId = String(merchant?.id ?? merchant?.merchantId ?? '')
if (mId !== merchantId) return false
const list = merchant?.merchantCartVoList
if (!Array.isArray(list)) return false
return list.some((cartItem: any) => String(cartItem?.dishId ?? '') === dishId)
})
}
async function confirmDuplicateAdd(content: string): Promise<boolean> {
return new Promise((resolve) => {
uni.showModal({
title: t('pages.ai.chat.duplicateTitle'),
content,
confirmText: t('common.confirm'),
cancelText: t('common.cancel'),
success: (res) => {
resolve(!!res.confirm)
},
fail: () => resolve(false),
})
})
}
function handleGoCartConfirm() {
goCartPopupVisible.value = false
uni.navigateTo({
url: '/pages-user/pages/cart/index',
})
if (goCartPopupResolver) {
goCartPopupResolver(true)
goCartPopupResolver = null
}
}
function handleGoCartCancel() {
goCartPopupVisible.value = false
if (goCartPopupResolver) {
goCartPopupResolver(false)
goCartPopupResolver = null
}
}
async function confirmGoCart(content?: string): Promise<boolean> {
if (goCartPopupResolver) {
goCartPopupResolver(false)
goCartPopupResolver = null
}
goCartPopupContent.value = content || t('pages.ai.chat.goCartTip')
goCartPopupVisible.value = true
return new Promise((resolve) => {
goCartPopupResolver = resolve
})
}
function openDishDetail(item: RecommendItem) {
const dishId = resolveDishId(item)
const merchantId = resolveMerchantId(item)
if (!dishId || !merchantId) {
uni.showToast({ title: t('pages.ai.chat.missingDishNav'), icon: 'none' })
return
}
uni.navigateTo({
url: `/pages-store/pages/store/dishes?id=${dishId}&storeId=${merchantId}`,
})
}
async function addRecommendToCart(
item: RecommendItem,
options?: { silent?: boolean; forceDuplicate?: boolean; suppressDuplicatePrompt?: boolean }
): Promise<boolean> {
if (!userStore.checkLogin()) return false
const key = getRecommendKey(item)
if (isRecommendAdded(item) && !options?.forceDuplicate) return true
if (addingItemKey.value === key) return false
const dishId = resolveDishId(item)
const merchantId = resolveMerchantId(item)
if (!dishId || !merchantId) {
if (!options?.silent) {
uni.showToast({ title: t('pages.ai.chat.missingCartArgs'), icon: 'none' })
}
return false
}
if (!options?.forceDuplicate && hasInCart(item)) {
if (options?.suppressDuplicatePrompt) return false
const ok = await confirmDuplicateAdd(t('pages.ai.chat.duplicateSingleTip'))
if (!ok) return false
}
try {
addingItemKey.value = key
await appMerchantCartAddCartPost({
body: {
merchantId,
dishId,
count: 1,
merchantCartSideDishBoList: [],
} as any,
})
addedRecommendMap.value[key] = true
if (!options?.silent) {
await confirmGoCart()
}
userStore.getUserCartAllData()
return true
} catch (e) {
return false
} finally {
if (addingItemKey.value === key) addingItemKey.value = ''
}
}
const allRecommendAdded = computed(() => {
if (!recommendItems.value.length) return false
return recommendItems.value.every((item) => isRecommendAdded(item))
})
const recommendCountText = computed(() => {
const count = recommendItems.value.length
return `${t('pages.ai.chat.recommendCountPrefix')}${count}${t('pages.ai.chat.recommendCountSuffix')}`
})
function duplicateBatchTipText(count: number) {
return `${t('pages.ai.chat.duplicateBatchTipPrefix')}${count}${t('pages.ai.chat.duplicateBatchTipSuffix')}`
}
function addAllResultText(success: number, failed: number, skipped: number) {
return `${t('pages.ai.chat.addAllResultPrefix')}${success}${t('pages.ai.chat.addAllResultMiddle1')}${failed}${t('pages.ai.chat.addAllResultMiddle2')}${skipped}${t('pages.ai.chat.addAllResultSuffix')}`
}
async function addAllRecommendToCart() {
if (!recommendItems.value.length || addingAll.value) return
if (!userStore.checkLogin()) return
const duplicateCount = recommendItems.value.filter((item) => hasInCart(item)).length
let allowDuplicate = false
if (duplicateCount > 0) {
allowDuplicate = await confirmDuplicateAdd(
duplicateBatchTipText(duplicateCount)
)
}
addingAll.value = true
let success = 0
let failed = 0
let skipped = 0
for (const item of recommendItems.value) {
if (isRecommendAdded(item)) {
skipped += 1
continue
}
if (!canRecommendAdd(item)) {
failed += 1
continue
}
const ok = await addRecommendToCart(item, {
silent: true,
forceDuplicate: allowDuplicate,
suppressDuplicatePrompt: true,
})
if (ok) success += 1
else failed += 1
}
addingAll.value = false
if (success > 0) {
await confirmGoCart(
`${addAllResultText(success, failed, skipped)}\n${t('pages.ai.chat.goCartTip')}`
)
} else if (failed > 0) {
uni.showToast({ title: t('pages.ai.chat.addAllFailed'), icon: 'none' })
}
}
function appendAiText(aiMessageIndex: number, text: string) {
if (!text) return
messages.value[aiMessageIndex].text += text
}
function flushTypingQueue(aiMessageIndex: number) {
if (typingRafId) return
const tick = () => {
if (!textQueue.value) {
typingRafId = 0
return
}
const chunk = textQueue.value.slice(0, TYPING_CHARS_PER_FRAME)
textQueue.value = textQueue.value.slice(TYPING_CHARS_PER_FRAME)
appendAiText(aiMessageIndex, chunk)
typingRafId = scheduleFrame(tick)
}
typingRafId = scheduleFrame(tick)
}
function handleSend() {
const text = inputText.value.trim()
if (!text || isStreaming.value) return
messages.value.push({ from: 'me', text })
messages.value.push({ from: 'ai', text: '' })
const aiMessageIndex = messages.value.length - 1
inputText.value = ''
isStreaming.value = true
toolStatus.value = ''
thinkingStatus.value = t('pages.ai.chat.thinking')
recommendItems.value = []
pendingRecommendItems.value = []
addedRecommendMap.value = {}
textQueue.value = ''
if (typingRafId) {
cancelFrame(typingRafId)
typingRafId = 0
}
streamTask.value = appAiChatStreamGet({
query: {
content: text,
conversationId: conversationId.value || undefined,
},
callbacks: {
onStart(payload) {
if (payload?.conversationId) {
conversationId.value = String(payload.conversationId)
}
},
onTool(payload) {
const tool = String(payload?.tool || '')
const size = payload?.size != null ? String(payload.size) : ''
toolStatus.value = tool
? `${t('pages.ai.chat.toolStatusWithSizePrefix')}${tool}${t('pages.ai.chat.toolStatusWithSizeMiddle')}${size || '-'}${t('pages.ai.chat.toolStatusWithSizeSuffix')}`
: t('pages.ai.chat.searchingGoods')
thinkingStatus.value = t('pages.ai.chat.thinkingSearching')
},
onChunk(payload) {
if (payload?.conversationId) {
conversationId.value = String(payload.conversationId)
}
const chunkText = String(payload?.text || '')
if (!chunkText) return
let plainText = ''
chunkText.split('\n').forEach((line) => {
if (!line.trim()) return
if (isSummaryLine(line)) {
plainText += (plainText ? '\n' : '') + line
return
}
const lineProduct = tryParseProductLine(line)
if (lineProduct) {
pendingRecommendItems.value.push(lineProduct)
return
}
plainText += (plainText ? '\n' : '') + line
})
if (plainText) textQueue.value += plainText
flushTypingQueue(aiMessageIndex)
},
onEnd() {
toolStatus.value = ''
thinkingStatus.value = ''
const finalize = () => {
if (pendingRecommendItems.value.length > 0) {
recommendItems.value = [...pendingRecommendItems.value]
pendingRecommendItems.value = []
addedRecommendMap.value = {}
}
if (!messages.value[aiMessageIndex].text.trim() && recommendItems.value.length === 0) {
messages.value[aiMessageIndex].text = t('pages.ai.chat.emptyResult')
}
isStreaming.value = false
streamTask.value = null
}
if (textQueue.value) {
const waitDone = () => {
if (textQueue.value) {
scheduleFrame(waitDone)
return
}
finalize()
}
scheduleFrame(waitDone)
} else {
finalize()
}
},
onError() {
toolStatus.value = ''
thinkingStatus.value = ''
textQueue.value = ''
pendingRecommendItems.value = []
if (typingRafId) {
cancelFrame(typingRafId)
typingRafId = 0
}
if (!messages.value[aiMessageIndex].text.trim()) {
messages.value[aiMessageIndex].text = t('pages.ai.chat.requestFailed')
}
isStreaming.value = false
streamTask.value = null
},
// fallback: non-chunk response still rendered
onToken(token) {
textQueue.value += token
flushTypingQueue(aiMessageIndex)
},
},
})
}
onUnmounted(() => {
if (goCartPopupResolver) {
goCartPopupResolver(false)
goCartPopupResolver = null
}
if (streamTask.value && typeof streamTask.value.abort === 'function') {
streamTask.value.abort()
}
textQueue.value = ''
pendingRecommendItems.value = []
if (typingRafId) {
cancelFrame(typingRafId)
typingRafId = 0
}
})
</script>
<template>
<view class="page">
<navbar :title="t('navbar-ai-chat')">
<template #right>
<!-- <view class="nav-action" @click="goPreference">
{{ t('common.go-to-settings') }}
</view> -->
<image src="/static/app/images/gengduo.png" class="w-44rpx h-44rpx ml-8rpx shrink-0" @click="goPreference"></image>
</template>
</navbar>
<!-- <view class="header">
<text class="title">{{ t('pages.ai.chat.title') }}</text>
<text v-if="preferenceSummary" class="pref">{{ preferenceSummary }}</text>
</view> -->
<view class="chat">
<view v-for="(m, idx) in messages" :key="idx" class="bubble-row" :class="m.from === 'me' ? 'row-me' : 'row-ai'">
<view class="bubble" :class="m.from === 'me' ? 'bubble-me' : 'bubble-ai'">
<text v-if="m.from === 'me'" class="bubble-text">{{ m.text }}</text>
<text v-else class="bubble-text">
<text
v-for="(seg, sIdx) in splitAiTextForRender(m.text)"
:key="`${idx}-${sIdx}`"
:class="seg.strong ? 'bubble-strong' : ''"
>{{ seg.text }}</text>
</text>
</view>
</view>
<view v-if="thinkingStatus" class="thinking-status">
<text class="thinking-dot"></text>
<text class="thinking-text">{{ thinkingStatus }}</text>
</view>
<view v-if="toolStatus" class="tool-status">{{ toolStatus }}</view>
<view v-if="recommendItems.length > 0" class="product-card">
<view class="product-card__top">
<text class="price">{{ recommendCountText }}</text>
<text class="label">{{ t('pages.ai.chat.recommendSource') }}</text>
</view>
<view class="product-list">
<view class="product-item" v-for="(item, i) in recommendItems" :key="item.specId || item.id || i" @click="openDishDetail(item)">
<image class="thumb" mode="aspectFill" :src="item.img || ''" />
<view class="meta">
<text class="name">{{ item.name || t('pages.ai.chat.unnamed') }}</text>
<text class="money">$ {{ item.specActualSalePrice || item.price || '-' }}</text>
</view>
<button
class="add-one-btn"
:class="{ 'add-one-btn--added': isRecommendAdded(item) }"
:disabled="isRecommendAdded(item)"
@click.stop="addRecommendToCart(item)"
>
{{ isRecommendAdded(item) ? t('pages.ai.chat.added') : t('pages.ai.chat.quickAdd') }}
</button>
</view>
</view>
<button class="btn" :disabled="addingAll || allRecommendAdded" @click="addAllRecommendToCart">
{{ addingAll ? t('pages.ai.chat.addingAll') : (allRecommendAdded ? t('pages.ai.chat.allAdded') : t('pages.ai.chat.addAll')) }}
</button>
</view>
</view>
<view class="composer">
<input
v-model="inputText"
class="input"
:placeholder="t('pages.ai.chat.inputPlaceholder')"
/>
<button class="send" :disabled="isStreaming" @click="handleSend">
{{ isStreaming ? t('pages.ai.chat.generating') : t('common.send') }}
</button>
</view>
<go-cart-popup
v-model="goCartPopupVisible"
:content="goCartPopupContent"
:title="t('pages.ai.chat.goCartTitle')"
:cancel-text="t('pages.ai.chat.goCartLater')"
:confirm-text="t('pages.ai.chat.goCartNow')"
@confirm="handleGoCartConfirm"
@cancel="handleGoCartCancel"
/>
</view>
</template>
<style scoped lang="scss">
.page {
background: #f2f2f2;
min-height: 100vh;
// padding: 24rpx;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom, 0px));
}
.header {
margin-bottom: 18rpx;
}
.title {
font-size: 36rpx;
font-weight: 700;
color: #111;
display: block;
}
.pref {
margin-top: 10rpx;
font-size: 24rpx;
color: #888;
display: block;
}
.chat {
// background: #fff;
border-radius: 20rpx;
padding:24rpx;
// padding: 18rpx;
// box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.bubble-row {
display: flex;
margin-bottom: 16rpx;
}
.row-ai {
justify-content: flex-start;
}
.row-me {
justify-content: flex-end;
}
.bubble {
max-width: 78%;
padding: 14rpx 18rpx;
border-radius: 16rpx;
}
.bubble-ai {
background: #ffffff;
color: #14181b;
border-radius: 0 16rpx 16rpx 16rpx;
}
.bubble-me {
background: #14181b;
color: #ffffff;
border-radius: 16rpx 0 16rpx 16rpx;
}
.bubble-text {
font-size: 26rpx;
line-height: 1.4;
white-space: pre-wrap;
}
.bubble-strong {
font-weight: 700;
color: #b03d15;
}
.product-card {
margin-top: 10rpx;
border-radius: 16rpx;
border: 1rpx solid #f1f1f1;
padding: 16rpx;
background: #fafafa;
}
.product-card__top {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 12rpx;
}
.price {
color: #e23636;
font-size: 32rpx;
font-weight: 700;
}
.label {
color: #14181b;
font-size: 24rpx;
background: #e9eef6;
padding: 6rpx 14rpx;
border-radius: 999rpx;
}
.product-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.product-item {
display: flex;
gap: 12rpx;
align-items: center;
justify-content: space-between;
}
.thumb {
width: 88rpx;
height: 72rpx;
border-radius: 12rpx;
background: #f0f0f0;
}
.meta {
display: flex;
flex-direction: column;
gap: 6rpx;
flex: 1;
min-width: 0;
}
.name {
font-size: 24rpx;
color: #111;
line-height: 1.35;
}
.money {
font-size: 24rpx;
color: #e23636;
font-weight: 600;
}
.add-one-btn {
width: 128rpx;
height: 56rpx;
line-height: 56rpx;
border-radius: 999rpx;
border: 1rpx solid #14181b;
background: #fff;
color: #14181b;
font-size: 22rpx;
padding: 0;
}
.add-one-btn--added {
border-color: #9aa5b1;
color: #9aa5b1;
background: #f6f8fa;
}
.btn {
margin-top: 14rpx;
border-radius: 16rpx;
background: #14181b;
color: #fff;
font-size: 26rpx;
}
.composer {
position: fixed;
// left: 24rpx;
// right: 24rpx;
bottom: 0;
background: #fff;
// border-radius: 20rpx;
width: 100%;
padding: 24rpx 24rpx calc(52rpx + env(safe-area-inset-bottom, 0px));
display: flex;
gap: 12rpx;
align-items: center;
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.06);
}
.input {
flex: 1;
font-size: 26rpx;
background: #f7f7f7;
padding: 12rpx 14rpx;
border-radius: 14rpx;
}
.send {
width: 160rpx;
border-radius: 16rpx;
background: #14181b;
color: #fff;
font-size: 26rpx;
padding: 12rpx 0;
}
.tool-status {
margin-top: 8rpx;
margin-bottom: 8rpx;
font-size: 22rpx;
color: #4f667e;
background: #eef3fb;
border-radius: 999rpx;
padding: 8rpx 14rpx;
display: inline-flex;
}
.thinking-status {
margin-top: 8rpx;
margin-bottom: 8rpx;
display: flex;
align-items: center;
gap: 10rpx;
color: #5a6d85;
font-size: 24rpx;
}
.thinking-dot {
width: 14rpx;
height: 14rpx;
border-radius: 999rpx;
background: #5a6d85;
opacity: 0.75;
animation: ai-thinking-pulse 1s ease-in-out infinite;
}
.thinking-text {
color: #5a6d85;
}
.nav-action {
font-size: 24rpx;
color: #14181b;
padding: 8rpx 0 8rpx 16rpx;
}
@keyframes ai-thinking-pulse {
0% { transform: scale(0.85); opacity: 0.45; }
50% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(0.85); opacity: 0.45; }
}
</style>