812 lines
22 KiB
Vue
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>
|
|
|