修复bug

This commit is contained in:
2026-05-11 22:22:18 +08:00
parent 03bc46df29
commit 7d891c9f7b
22 changed files with 2198 additions and 181 deletions
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: boolean
title: string
content: string
cancelText: string
confirmText: string
}>(),
{
modelValue: false,
title: '',
content: '',
cancelText: '',
confirmText: '',
}
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}>()
const show = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
function handleCancel() {
emit('cancel')
}
function handleConfirm() {
emit('confirm')
}
function handleClose() {
emit('cancel')
}
</script>
<template>
<wd-popup v-model="show" position="center" round="true" custom-style="border-radius: 20rpx;" @close="handleClose">
<view class="go-cart-popup">
<view class="go-cart-popup__title">{{ title }}</view>
<view class="go-cart-popup__content">{{ content }}</view>
<view class="go-cart-popup__actions">
<button class="go-cart-popup__btn go-cart-popup__btn--ghost" @click="handleCancel">
{{ cancelText }}
</button>
<button class="go-cart-popup__btn go-cart-popup__btn--primary" @click="handleConfirm">
{{ confirmText }}
</button>
</view>
</view>
</wd-popup>
</template>
<style scoped lang="scss">
.go-cart-popup {
width: 620rpx;
background: #fff;
border-radius: 20rpx;
padding: 36rpx 30rpx 30rpx;
}
.go-cart-popup__title {
font-size: 34rpx;
font-weight: 600;
color: #111;
text-align: center;
}
.go-cart-popup__content {
margin-top: 22rpx;
font-size: 28rpx;
line-height: 1.5;
color: #444;
text-align: center;
white-space: pre-wrap;
}
.go-cart-popup__actions {
margin-top: 34rpx;
display: flex;
gap: 18rpx;
}
.go-cart-popup__btn {
flex: 1;
height: 84rpx;
line-height: 84rpx;
border-radius: 14rpx;
font-size: 28rpx;
padding: 0;
}
.go-cart-popup__btn--ghost {
border: 1rpx solid #d7d7d7;
background: #fff;
color: #555;
}
.go-cart-popup__btn--primary {
border: 1rpx solid #14181b;
background: #14181b;
color: #fff;
}
</style>
+811
View File
@@ -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>
+426
View File
@@ -0,0 +1,426 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
appDiningProfileConfigGet,
appDiningProfileGet,
appDiningProfileSavePost,
} from '@/service'
const { t } = useI18n()
type Option = { id: string; label: string }
type Field = {
fieldKey: string
fieldName: string
groupKey: string
groupName: string
sortNum: number
isRequired: boolean
multiple: boolean
options: Option[]
}
type Group = {
key: string
title: string
sortNum: number
fields: Field[]
}
type SelectedMap = Record<string, string[]>
type ProfileMap = Record<string, unknown>
const PREF_FLAG_KEY = 'ai_diet_pref_done'
const PREF_DATA_KEY = 'ai_diet_pref_data'
const loading = ref(false)
const groups = ref<Group[]>([])
const selectedMap = ref<SelectedMap>({})
function getFallbackGroups(): Group[] {
return [
{
key: 'taste',
title: t('pages.ai.dietPreference.fallback.groupTaste'),
sortNum: 1,
fields: [
{
fieldKey: 'taste_like',
fieldName: t('pages.ai.dietPreference.fallback.tasteLike'),
groupKey: 'taste',
groupName: t('pages.ai.dietPreference.fallback.groupTaste'),
sortNum: 1,
isRequired: false,
multiple: false,
options: [
{ id: 'zkw', label: t('pages.ai.dietPreference.fallback.optionHeavy') },
{ id: 'qd', label: t('pages.ai.dietPreference.fallback.optionLight') },
{ id: 'ml', label: t('pages.ai.dietPreference.fallback.optionSpicy') },
],
},
{
fieldKey: 'taste_taboo',
fieldName: t('pages.ai.dietPreference.fallback.tasteTaboo'),
groupKey: 'taste',
groupName: t('pages.ai.dietPreference.fallback.groupTaste'),
sortNum: 2,
isRequired: false,
multiple: true,
options: [
{ id: 'seafood', label: t('pages.ai.dietPreference.fallback.optionSeafood') },
{ id: 'organ', label: t('pages.ai.dietPreference.fallback.optionOrgan') },
{ id: 'onion', label: t('pages.ai.dietPreference.fallback.optionOnion') },
],
},
],
},
]
}
const hasGroups = computed(() => groups.value.length > 0)
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',
})
}
function parseOptionsJson(optionsJson: unknown): Option[] {
if (!optionsJson) return []
let rawList: any[] = []
if (Array.isArray(optionsJson)) {
rawList = optionsJson
} else if (typeof optionsJson === 'string') {
try {
rawList = JSON.parse(optionsJson)
} catch (e) {
rawList = []
}
}
return rawList.map((opt, idx) => ({
id: String(opt?.k ?? opt?.key ?? opt?.id ?? idx),
label: String(opt?.v ?? opt?.value ?? opt?.name ?? `选项${idx + 1}`),
}))
}
function normalizeConfig(data: any): Group[] {
const list = Array.isArray(data) ? data : Array.isArray(data?.list) ? data.list : []
if (!list.length) return getFallbackGroups()
const groupMap = new Map<string, Group>()
list.forEach((item: any, idx: number) => {
const groupKey = String(item?.groupKey ?? `group_${idx}`)
const groupName = String(item?.groupName ?? `分组${idx + 1}`)
const groupSort = Number(item?.sortNum ?? idx)
const fieldKey = String(item?.fieldKey ?? `${groupKey}_field_${idx}`)
const options = parseOptionsJson(item?.optionsJson)
if (!options.length) return
const field: Field = {
fieldKey,
fieldName: String(item?.fieldName ?? fieldKey),
groupKey,
groupName,
sortNum: Number(item?.sortNum ?? idx),
isRequired: Number(item?.isRequired ?? 0) === 1,
multiple: Number(item?.multiSelect ?? 0) === 1,
options,
}
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, {
key: groupKey,
title: groupName,
sortNum: groupSort,
fields: [field],
})
} else {
groupMap.get(groupKey)!.fields.push(field)
}
})
const groupsBySort = Array.from(groupMap.values())
.map((group) => ({
...group,
fields: group.fields.sort((a, b) => a.sortNum - b.sortNum),
}))
.sort((a, b) => a.sortNum - b.sortNum)
return groupsBySort.length ? groupsBySort : getFallbackGroups()
}
function collectSelectedKeys(profileData: any): Record<string, Set<string>> {
const map: Record<string, Set<string>> = {}
if (!profileData || typeof profileData !== 'object') return map
const normalizeValues = (val: unknown): string[] => {
if (Array.isArray(val)) return val.map((v) => String(v))
if (val == null || val === '') return []
// 兼容后端返回 "a,b,c" 形式
if (typeof val === 'string' && val.includes(',')) {
return val.split(',').map((v) => v.trim()).filter(Boolean)
}
return [String(val)]
}
Object.keys(profileData).forEach((key) => {
map[key] = new Set(normalizeValues(profileData[key]))
})
return map
}
function initSelectionByGroups(profileData: any) {
const selectedFromServer = collectSelectedKeys(profileData)
const nextMap: SelectedMap = {}
groups.value.forEach((group) => {
group.fields.forEach((field) => {
const matched =
selectedFromServer[field.fieldKey] ||
selectedFromServer[field.fieldName] ||
selectedFromServer[field.fieldKey.toLowerCase()]
if (matched && matched.size > 0) {
nextMap[field.fieldKey] = field.options
.map((o) => o.id)
.filter((id) => matched.has(id) || matched.has(String(id)))
} else {
nextMap[field.fieldKey] = []
}
})
})
selectedMap.value = nextMap
}
function toggleOption(field: Field, optionId: string) {
const arr = selectedMap.value[field.fieldKey] || []
const idx = arr.indexOf(optionId)
if (field.multiple) {
if (idx >= 0) arr.splice(idx, 1)
else arr.push(optionId)
} else {
selectedMap.value[field.fieldKey] = idx >= 0 ? [] : [optionId]
return
}
selectedMap.value[field.fieldKey] = [...arr]
}
function isSelected(groupKey: string, optionId: string) {
return (selectedMap.value[groupKey] || []).includes(optionId)
}
function buildSavePayload() {
const fields: ProfileMap = {}
groups.value.forEach((group) => {
group.fields.forEach((field) => {
const values = selectedMap.value[field.fieldKey] || []
if (!values.length) return
fields[field.fieldKey] = field.multiple ? values : values[0]
})
})
return { fields }
}
async function loadData() {
loading.value = true
try {
const [configRes, profileRes] = await Promise.all([
appDiningProfileConfigGet({}),
appDiningProfileGet({}),
])
groups.value = normalizeConfig(configRes?.data)
initSelectionByGroups(profileRes?.data)
} catch (e) {
groups.value = getFallbackGroups()
initSelectionByGroups({})
} finally {
loading.value = false
}
}
async function handleComplete() {
try {
const body = buildSavePayload()
await appDiningProfileSavePost({ body })
uni.setStorageSync(PREF_DATA_KEY, selectedMap.value)
uni.setStorageSync(PREF_FLAG_KEY, true)
uni.redirectTo({
url: '/pages/ai/chat/index',
})
} catch (e) {
// 保存失败时沿用项目全局错误提示
}
}
onMounted(() => {
loadData()
})
</script>
<template>
<view class="page">
<navbar :title="t('navbar-ai-diet-preference')">
</navbar>
<view class="header">
<view class="header-title" style="display: flex; align-items: center;">
<image src="/static/app/images/ai-diet-preference-title.png" class="w-44rpx h-44rpx ml-8rpx shrink-0"></image>
<text class="title">{{ t('pages.ai.dietPreference.title') }}</text>
</view>
<text class="sub">{{ t('pages.ai.dietPreference.sub') }}</text>
</view>
<view style="padding: 22rpx;">
<view v-if="loading" class="section">
<view class="section-title">{{ t('pages.ai.dietPreference.loadingConfig') }}</view>
</view>
<view v-else-if="!hasGroups" class="section">
<view class="section-title">{{ t('pages.ai.dietPreference.empty') }}</view>
</view>
<template v-else>
<view class="section" v-for="group in groups" :key="group.key">
<view class="section-title">{{ group.title }}</view>
<view class="field-block" v-for="field in group.fields" :key="field.fieldKey">
<view class="field-title">
<text>{{ field.fieldName }}</text>
<text v-if="field.isRequired" class="required">*</text>
<text class="mode-tag">{{ field.multiple ? t('pages.ai.dietPreference.multi') : t('pages.ai.dietPreference.single') }}</text>
</view>
<view class="chips">
<view
v-for="opt in field.options"
:key="opt.id"
class="chip"
:class="isSelected(field.fieldKey, opt.id) ? 'chip--active' : ''"
@click="toggleOption(field, opt.id)"
>
<text>{{ opt.label }}</text>
</view>
</view>
</view>
</view>
</template>
<view class="footer">
<button class="btn" @click="handleComplete">
{{ t('pages.ai.dietPreference.submit') }}
</button>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.page {
background: #f2f2f2;
min-height: 100vh;
// padding: 24rpx;
}
.header {
background: #EFFDF5;
// border-radius: 20rpx;
padding: 22rpx 22rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.title {
font-size: 28rpx;
font-weight: 500;
color: #065E46;
display: block;
margin-left: 12rpx;
}
.sub {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: #027857;
line-height: 1.4;
}
.section {
margin-top: 22rpx;
background: #fff;
border-radius: 20rpx;
padding: 22rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #111;
margin-bottom: 16rpx;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.field-block + .field-block {
margin-top: 20rpx;
}
.field-title {
margin-bottom: 14rpx;
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
color: #333;
}
.required {
color: #e23636;
}
.mode-tag {
color: #999;
font-size: 22rpx;
}
.chip {
padding: 14rpx 18rpx;
border-radius: 999rpx;
background: #f6f6f6;
color: #333;
font-size: 24rpx;
line-height: 1;
}
.chip--active {
background: #14181b;
color: #fff;
}
.footer {
margin-top: 28rpx;
background: transparent;
}
.btn {
width: 100%;
border-radius: 16rpx;
background: #14181b;
color: #fff;
font-size: 28rpx;
padding: 18rpx 0;
}
.tip {
margin-top: 16rpx;
font-size: 22rpx;
color: #999;
text-align: center;
}
.nav-action {
font-size: 24rpx;
color: #14181b;
padding: 8rpx 0 8rpx 16rpx;
}
</style>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { appDiningProfileGet } from '@/service'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
function hasProfileData(data: unknown) {
if (!data) return false
if (typeof data === 'object') {
const map = data as Record<string, unknown>
return Object.keys(map).some((k) => {
const v = map[k]
if (Array.isArray(v)) return v.length > 0
return v != null && String(v).trim() !== ''
})
}
return false
}
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',
})
}
async function redirectToTarget() {
let done = false
try {
const res = await appDiningProfileGet({})
done = hasProfileData(res?.data)
} catch (e) {
// 查询失败时兜底读本地标记,避免阻塞入口
done = !!uni.getStorageSync('ai_diet_pref_done')
}
uni.redirectTo({
url: done ? '/pages/ai/chat/index' : '/pages/ai/diet-preference/index',
})
}
onMounted(() => {
redirectToTarget()
})
</script>
<template>
<view>
<navbar :title="t('navbar-ai-recommend')">
<template #right>
<view class="nav-action" @click="goPreference">
{{ t('common.go-to-settings') }}
</view>
</template>
</navbar>
<view class="ai-recommend-loading">
<text>{{ t('pages.ai.recommend.loading') }}</text>
</view>
</view>
</template>
<style scoped lang="scss">
.ai-recommend-loading {
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #333;
}
.nav-action {
font-size: 24rpx;
color: #14181b;
padding: 8rpx 0 8rpx 16rpx;
}
</style>
@@ -1,20 +1,26 @@
<template>
<view class="home-skeleton">
<!-- 头部区域 -->
<!-- 顶部头部 home-top-header 对齐 -->
<view class="header-section">
<view class="app-title-skeleton skeleton-item"></view>
<view class="delivery-info-skeleton skeleton-item"></view>
<view class="header-left">
<view class="brand-row">
<view class="logo-skeleton skeleton-item"></view>
<view class="app-title-skeleton skeleton-item"></view>
</view>
<view class="delivery-info-skeleton skeleton-item"></view>
</view>
<view class="header-right">
<view class="location-pill-skeleton skeleton-item"></view>
<view class="cart-btn-skeleton skeleton-item"></view>
</view>
</view>
<!-- 位置和通知区域 -->
<view class="location-notification">
<view class="location-skeleton skeleton-item"></view>
<view class="notification-skeleton skeleton-item"></view>
</view>
<!-- 搜索栏 -->
<!-- 搜索栏 + 消息按钮 -->
<view class="search-section">
<view class="search-bar-skeleton skeleton-item"></view>
<view class="search-row">
<view class="search-bar-skeleton skeleton-item"></view>
<view class="message-btn-skeleton skeleton-item"></view>
</view>
</view>
<!-- 分类滚动区域 -->
@@ -107,59 +113,91 @@
}
.home-skeleton {
background-color: #fff;
background-color: #f2f2f2;
min-height: 100vh;
}
// 头部区域
.header-section {
padding: 18rpx 30rpx 0;
padding: 18rpx 24rpx 0;
display: flex;
align-items: center;
gap: 14rpx;
.app-title-skeleton {
width: 241rpx;
align-items: flex-start;
justify-content: space-between;
gap: 12rpx;
.header-left {
flex: 1;
min-width: 0;
}
.brand-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.logo-skeleton {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
flex-shrink: 0;
}
.app-title-skeleton {
width: 210rpx;
height: 36rpx;
border-radius: 8rpx;
}
.delivery-info-skeleton {
width: 266rpx;
height: 28rpx;
border-radius: 6rpx;
}
}
// 位置和通知区域
.location-notification {
padding: 22rpx 30rpx 0;
display: flex;
justify-content: space-between;
align-items: center;
.location-skeleton {
width: 329rpx;
margin-top: 12rpx;
width: 300rpx;
height: 30rpx;
border-radius: 6rpx;
border-radius: 8rpx;
}
.notification-skeleton {
width: 132rpx;
height: 60rpx;
border-radius: 30rpx;
.header-right {
display: flex;
align-items: center;
gap: 10rpx;
}
.location-pill-skeleton {
width: 200rpx;
height: 72rpx;
border-radius: 999rpx;
}
.cart-btn-skeleton {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
}
}
// 搜索栏
// 搜索栏 + 消息按钮
.search-section {
padding: 32rpx 30rpx 0;
padding: 20rpx 24rpx 0;
.search-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.search-bar-skeleton {
width: 690rpx;
flex: 1;
min-width: 0;
height: 88rpx;
border-radius: 44rpx;
}
.message-btn-skeleton {
width: 74rpx;
height: 74rpx;
border-radius: 50%;
flex-shrink: 0;
}
}
// 分类滚动区域
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
appName: string
locationText: string
appointmentTimeShow?: string
cartBadgeTotal?: number
isLogin?: boolean
}>()
const emit = defineEmits<{
(e: 'clickAddress'): void
(e: 'clickLocation'): void
(e: 'clickCart'): void
}>()
</script>
<template>
<view class="home-top-header px-24rpx pt-12rpx pb-8rpx">
<view class="flex items-center justify-between gap-12rpx">
<view class="min-w-0 flex-1">
<view class="flex items-center gap-14rpx min-w-0">
<image src="@img/logo.png" class="w-52rpx h-52rpx shrink-0" style="border-radius: 50%;"></image>
<text class="text-32rpx lh-36rpx text-#1a1a1a font-bold tracking-tight shrink-0">{{ appName }}</text>
</view>
<view @click="emit('clickAddress')" class="home-delivery-row mt-10rpx flex items-center min-w-0 text-26rpx lh-32rpx text-#00A76D">
<text v-if="appointmentTimeShow">{{ t('pages.address.appTime') }}: {{ appointmentTimeShow }}</text>
<text v-else>{{ t('pages.address.reservation') }}</text>
<image src="@img/chef/119.png" class="w-22rpx h-22rpx ml-8rpx shrink-0"></image>
</view>
</view>
<view class="flex items-center gap-10rpx shrink-0">
<view class="home-loc-pill" @click="emit('clickLocation')">
<text class="home-loc-pill__text line-clamp-1">{{ locationText || t('pages.home.default-location') }}</text>
<image src="@img/chef/119.png" class="home-loc-pill__arrow w-20rpx h-20rpx shrink-0 op-50 mt-2rpx"></image>
</view>
<view class="home-cart-btn" @click="emit('clickCart')">
<view class="i-carbon:shopping-cart text-36rpx text-#14181b"></view>
<view v-if="isLogin && (cartBadgeTotal || 0) > 0" class="home-cart-badge">
{{ (cartBadgeTotal || 0) > 99 ? '99+' : cartBadgeTotal }}
</view>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.home-top-header {
background: #f2f2f2;
}
.home-loc-pill {
display: flex;
align-items: center;
gap: 8rpx;
max-width: 200rpx;
padding: 12rpx 18rpx;
background: #fff;
border-radius: 999rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.home-loc-pill__text {
flex: 1;
min-width: 0;
font-size: 22rpx;
line-height: 28rpx;
font-weight: 500;
color: #333;
}
.home-cart-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
background: #fff;
border-radius: 50%;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.07);
}
.home-cart-badge {
position: absolute;
top: 4rpx;
right: 4rpx;
min-width: 28rpx;
height: 28rpx;
padding: 0 6rpx;
font-size: 18rpx;
line-height: 28rpx;
font-weight: 600;
color: #fff;
text-align: center;
background: #e23636;
border-radius: 999rpx;
}
</style>
@@ -1,14 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from "@/store";
const props = withDefaults(
defineProps<{
/** 首页顶栏紧凑模式:更小图标与间距 */
compact?: boolean
}>(),
{ compact: false }
)
const emit = defineEmits(['toggleNotOpen']);
const userStore = useUserStore();
const expanded = ref(true)
const touchStartX = ref(0)
function navigateTo(url: string) {
if(userStore.checkLogin()) {
uni.navigateTo({
@@ -16,31 +12,98 @@ function navigateTo(url: string) {
});
}
}
function togglePanel() {
expanded.value = !expanded.value
}
function onFabClick() {
if (!expanded.value) {
expanded.value = true
return
}
navigateTo('/pages/ai/recommend/index')
}
function onHandleTouchStart(e: any) {
touchStartX.value = Number(e?.touches?.[0]?.clientX || 0)
}
function onHandleTouchEnd(e: any) {
const endX = Number(e?.changedTouches?.[0]?.clientX || 0)
const delta = endX - touchStartX.value
if (delta > 20) {
expanded.value = true
} else if (delta < -20) {
expanded.value = false
}
}
</script>
<template>
<view class="flex items-center" :class="compact ? 'gap-20rpx' : ''">
<view
@click="navigateTo('/pages-user/pages/message/index')"
:class="compact ? 'w-34rpx h-34rpx mr-0' : 'w-40rpx h-40rpx mr-42rpx'"
class="relative shrink-0"
>
<view
v-if="userStore.isLogin && userStore.unreadMessageCount > 0"
:class="compact ? 'h-26rpx top--10rpx right--10rpx text-20rpx px-8rpx' : 'w-32rpx h-32rpx top--16rpx right--16rpx text-24rpx line-height-32rpx'"
class="bg-#E23636 absolute z-2 rounded-50% text-#fff text-center font-500"
>{{ userStore.unreadMessageCount }}</view>
<image src="@img/chef/114.png" :class="compact ? 'w-34rpx h-34rpx' : 'w-40rpx h-40rpx'"></image>
<!-- AI 跳转做右侧球体悬浮 -->
<view
class="ai-floating"
:class="{ 'is-collapsed': !expanded }"
@touchstart.stop="onHandleTouchStart"
@touchend.stop="onHandleTouchEnd"
@touchcancel.stop="onHandleTouchEnd"
>
<view v-if="expanded" class="ai-close" @click.stop="expanded = false">×</view>
<view class="ai-fab" @click="onFabClick">
<image src="@img/chef/115.png" class="ai-icon"></image>
</view>
<image
@click="emit('toggleNotOpen')"
src="@img/chef/115.png"
:class="compact ? 'w-34rpx h-34rpx' : 'w-40rpx h-40rpx'"
class="shrink-0"
></image>
</view>
</template>
<style scoped lang="scss">
.ai-floating {
position: fixed;
right: 12rpx;
top: 83%;
z-index: 88;
display: block;
transform: translateY(-50%) translateX(0);
transition: transform 0.22s ease;
}
.ai-floating.is-collapsed {
transform: translateY(-50%) translateX(55rpx);
}
.ai-close {
position: absolute;
left: -18rpx;
top: -15rpx;
width: 34rpx;
height: 34rpx;
z-index: 2;
border-radius: 999rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: #7b8794;
font-size: 24rpx;
line-height: 1;
}
.ai-fab {
width: 92rpx;
height: 92rpx;
border-radius: 50%;
background: #e2e7eb;
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.18);
display: flex;
align-items: center;
justify-content: center;
}
.ai-icon {
width: 44rpx;
height: 44rpx;
}
</style>
@@ -1,5 +1,7 @@
<script setup lang="ts">
const { t } = useI18n()
import { useUserStore } from '@/store'
const userStore = useUserStore()
const props = defineProps({
isAutoJump: {
@@ -20,17 +22,36 @@ function handleClickSearch() {
emit('clickSearch')
}
}
function goMessage() {
if (userStore.checkLogin()) {
uni.navigateTo({
url: '/pages-user/pages/message/index',
})
}
}
</script>
<template>
<view
@click="handleClickSearch"
class="home-search-bar flex items-center h-88rpx rounded-44rpx pl-36rpx pr-28rpx bg-white"
>
<image src="@img/chef/100222.png" class="w-28rpx h-28rpx shrink-0"></image>
<text class="home-search-placeholder text-28rpx ml-16rpx tracking-[.04em] font-500 truncate">{{
t('components.search.placeholder')
}}</text>
<view class="home-search-row flex items-center gap-14rpx">
<view
@click="handleClickSearch"
class="home-search-bar flex-1 min-w-0 flex items-center h-88rpx rounded-44rpx pl-36rpx pr-28rpx bg-white"
>
<image src="@img/chef/100222.png" class="w-28rpx h-28rpx shrink-0"></image>
<text class="home-search-placeholder text-28rpx ml-16rpx tracking-[.04em] font-500 truncate">{{
t('components.search.placeholder')
}}</text>
</view>
<view class="home-msg-btn" @click="goMessage">
<view
v-if="userStore.isLogin && userStore.unreadMessageCount > 0"
class="home-msg-badge"
>
{{ userStore.unreadMessageCount > 99 ? '99+' : userStore.unreadMessageCount }}
</view>
<image src="@img/chef/114.png" class="w-34rpx h-34rpx"></image>
</view>
</view>
</template>
@@ -41,4 +62,31 @@ function handleClickSearch() {
.home-search-placeholder {
color: #9a9a9a;
}
.home-msg-btn {
position: relative;
width: 74rpx;
height: 74rpx;
border-radius: 50%;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.06);
flex-shrink: 0;
}
.home-msg-badge {
position: absolute;
right: -6rpx;
top: -6rpx;
min-width: 26rpx;
height: 26rpx;
padding: 0 5rpx;
border-radius: 999rpx;
background: #E23636;
color: #fff;
font-size: 16rpx;
line-height: 26rpx;
text-align: center;
font-weight: 500;
}
</style>
@@ -6,6 +6,7 @@ import Config from '@/config/index'
import { debounce } from 'throttle-debounce'
import MsgBox from "@/pages/home/components/tabbar-home/components/msg-box.vue";
import Search from "@/pages/home/components/tabbar-home/components/search.vue";
import HomeTopHeader from "@/pages/home/components/tabbar-home/components/home-top-header.vue";
import ClassBullet from "./components/class-bullet.vue";
import TabsType from "./components/tabs-type.vue";
import FeaturedOn from "./components/featured-on/index.vue";
@@ -309,36 +310,16 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
<z-paging @onRefresh="onRefresh" ref="paging" v-model="dataList" :auto="false" @query="queryList" @scroll="onPageScroll" :refresher-enabled="true" :auto-show-back-to-top="false">
<template #top>
<status-bar />
<!-- 设计稿品牌行 + 右侧地址胶囊消息/客服购物车 -->
<view class="home-top-header px-24rpx pt-12rpx pb-8rpx">
<view class="flex items-center justify-between gap-12rpx">
<view class="flex items-center gap-14rpx min-w-0 flex-1">
<image src="@img/logo.png" class="w-52rpx h-52rpx shrink-0" style="border-radius: 50%;"></image>
<text class="text-32rpx lh-36rpx text-#1a1a1a font-bold tracking-tight shrink-0">{{ Config.appName }}</text>
</view>
<view class="flex items-center gap-10rpx shrink-0">
<view class="home-loc-pill" @click="navigateTo('/pages-user/pages/search-address/index')">
<!-- <image src="@img/chef/101.png" class="home-loc-pill__pin w-22rpx h-22rpx shrink-0"></image> -->
<text class="home-loc-pill__text line-clamp-1">{{
userStore.userLocation.location || t('pages.home.default-location')
}}</text>
<image src="@img/chef/119.png" class="home-loc-pill__arrow w-20rpx h-20rpx shrink-0 op-50 mt-2rpx"></image>
</view>
<view class="home-cart-btn" @click="goCart">
<view class="i-carbon:shopping-cart text-36rpx text-#14181b"></view>
<view v-if="userStore.isLogin && cartBadgeTotal > 0" class="home-cart-badge">{{ cartBadgeTotal > 99 ? '99+' : cartBadgeTotal }}</view>
</view>
</view>
</view>
<view class="home-delivery-actions-row mt-14rpx flex items-center justify-between gap-16rpx">
<view @click="navigateTo('/pages/address/index')" class="home-delivery-row flex items-center min-w-0 flex-1 text-26rpx lh-32rpx text-#00A76D">
<text v-if="userStore.appointmentTimeShow">{{ t('pages.address.appTime') }}: {{ userStore.appointmentTimeShow }}</text>
<text v-else>{{ t('pages.address.reservation') }}</text>
<image src="@img/chef/119.png" class="w-22rpx h-22rpx ml-8rpx shrink-0"></image>
</view>
<msg-box compact class="shrink-0" @toggleNotOpen="toggleNotOpen" />
</view>
</view>
<home-top-header
:app-name="Config.appName"
:location-text="userStore.userLocation.location"
:appointment-time-show="userStore.appointmentTimeShow"
:cart-badge-total="cartBadgeTotal"
:is-login="userStore.isLogin"
@click-address="navigateTo('/pages/address/index')"
@click-location="navigateTo('/pages-user/pages/search-address/index')"
@click-cart="goCart"
/>
</template>
<view
class="animate-in fade-in animate-ease-out animate-duration-300"
@@ -348,6 +329,7 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
</view>
<view class="px-24rpx pt-12rpx pb-8rpx">
<search />
<msg-box />
<!-- 分类标签双行横滑 -->
<view class="mt-28rpx" v-if="appMerchantLabelList.length > 0">
<class-bullet :categories="appMerchantLabelList" @itemClick="handleItemClick" />
@@ -452,7 +434,7 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
<view class="featured-dish-body">
<view class="featured-dish-meta flex items-start justify-between gap-12rpx mb-14rpx">
<view class="min-w-0 flex-1">
<text class="featured-dish-price">US${{ getFeaturedDishDisplayPrice(item) }}</text>
<text class="featured-dish-price">US${{item?.originalPrice }}</text>
<!-- <text
v-if="Number(item?.originalPrice) > Number(item?.discountPrice)"
class="featured-dish-original"
@@ -506,59 +488,6 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
background: #f2f2f2;
}
.home-top-header {
background: #f2f2f2;
}
.home-loc-pill {
display: flex;
align-items: center;
gap: 8rpx;
max-width: 200rpx;
padding: 12rpx 18rpx;
background: #fff;
border-radius: 999rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.home-loc-pill__text {
flex: 1;
min-width: 0;
font-size: 22rpx;
line-height: 28rpx;
font-weight: 500;
color: #333;
}
.home-cart-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
background: #fff;
border-radius: 50%;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.07);
}
.home-cart-badge {
position: absolute;
top: 4rpx;
right: 4rpx;
min-width: 28rpx;
height: 28rpx;
padding: 0 6rpx;
font-size: 18rpx;
line-height: 28rpx;
font-weight: 600;
color: #fff;
text-align: center;
background: #e23636;
border-radius: 999rpx;
}
.home-delivery-row {
padding-left: 2rpx;
}
@@ -633,7 +562,7 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
width: 100%;
height: 100%;
z-index: 2;
// background: rgba(62, 66, 70, 0.6);
background: rgba(201, 197, 197, 0.6);
pointer-events: none;
}