修复bug
This commit is contained in:
@@ -0,0 +1,811 @@
|
||||
<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.toolStatusWithSize', { tool, size: size || '-' })
|
||||
: 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>
|
||||
|
||||
Reference in New Issue
Block a user