修复bug
This commit is contained in:
Vendored
+2
-2
@@ -5,6 +5,6 @@ VITE_DELETE_CONSOLE=false
|
|||||||
|
|
||||||
#本地环境
|
#本地环境
|
||||||
VITE_SERVER_BASEURL=https://howhowfresh.com/prod-api
|
VITE_SERVER_BASEURL=https://howhowfresh.com/prod-api
|
||||||
#VITE_SERVER_BASEURL=http://192.168.5.11:8080
|
#VITE_SERVER_BASEURL=http://192.168.0.158:8889
|
||||||
#VITE_SERVER_BASEURL=http://192.168.0.148:8888
|
#VITE_SERVER_BASEURL=http://192.168.0.158:8888
|
||||||
#VITE_SERVER_BASEURL=http://liuyao.nat100.top/meiguowaimai
|
#VITE_SERVER_BASEURL=http://liuyao.nat100.top/meiguowaimai
|
||||||
@@ -59,7 +59,7 @@ function handleClickLeft() {
|
|||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
|
// box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.wd-navbar) {
|
:deep(.wd-navbar) {
|
||||||
|
|||||||
@@ -135,6 +135,9 @@
|
|||||||
"navbar-forget-password": "Forgot password",
|
"navbar-forget-password": "Forgot password",
|
||||||
"navbar-forget-payment-password": "Forgot your payment password",
|
"navbar-forget-payment-password": "Forgot your payment password",
|
||||||
"navbar-invited-person": "My invitation",
|
"navbar-invited-person": "My invitation",
|
||||||
|
"navbar-ai-recommend": "AI Recommend Entry",
|
||||||
|
"navbar-ai-diet-preference": "Diet Preference Setup",
|
||||||
|
"navbar-ai-chat": "AI Ingredient Chat",
|
||||||
"navbar-nickname": "Name",
|
"navbar-nickname": "Name",
|
||||||
"navbar-personal-information": "personal information",
|
"navbar-personal-information": "personal information",
|
||||||
"navbar-set-payment-password": "Set a payment password",
|
"navbar-set-payment-password": "Set a payment password",
|
||||||
@@ -230,6 +233,70 @@
|
|||||||
"featured-dishes": "Featured Dishes",
|
"featured-dishes": "Featured Dishes",
|
||||||
"nearby-merchants": "Nearby Merchants"
|
"nearby-merchants": "Nearby Merchants"
|
||||||
},
|
},
|
||||||
|
"ai": {
|
||||||
|
"recommend": {
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"dietPreference": {
|
||||||
|
"title": "Complete Your Diet Preferences",
|
||||||
|
"sub": "We will recommend dishes and ingredients that better match your taste based on your information, allowing AI to understand your stomach better.",
|
||||||
|
"loadingConfig": "Loading profile configuration...",
|
||||||
|
"empty": "No configurable items",
|
||||||
|
"multi": "(Multiple)",
|
||||||
|
"single": "(Single)",
|
||||||
|
"submit": "Save Configuration",
|
||||||
|
"fallback": {
|
||||||
|
"groupTaste": "Taste & Taboos",
|
||||||
|
"tasteLike": "Taste Preference",
|
||||||
|
"tasteTaboo": "Absolute Taboos",
|
||||||
|
"optionHeavy": "Heavy Flavor",
|
||||||
|
"optionLight": "Light",
|
||||||
|
"optionSpicy": "Spicy",
|
||||||
|
"optionSeafood": "Seafood Allergy",
|
||||||
|
"optionOrgan": "No Offal",
|
||||||
|
"optionOnion": "No Onion/Ginger/Garlic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "AI Ingredient Recommendation",
|
||||||
|
"defaultMessage": "Hi, I can recommend dishes based on your budget, taboos, and taste preferences.",
|
||||||
|
"thinking": "Thinking...",
|
||||||
|
"thinkingSearching": "AI is searching matching items...",
|
||||||
|
"searchingGoods": "Searching products...",
|
||||||
|
"toolStatusWithSize": "Calling {tool}, total {size} items",
|
||||||
|
"emptyResult": "Completed, but no displayable content returned.",
|
||||||
|
"requestFailed": "Request failed, please try again later.",
|
||||||
|
"recommendCountPrefix": "Recommended items: ",
|
||||||
|
"recommendCountSuffix": "",
|
||||||
|
"recommendSource": "Selected by AI in real time",
|
||||||
|
"summaryFound": "{count} items found",
|
||||||
|
"summaryBudget": "Estimate {estimate}, actual {actual}, saved {save}",
|
||||||
|
"unnamed": "Unnamed Item",
|
||||||
|
"quickAdd": "Quick Add",
|
||||||
|
"added": "Added",
|
||||||
|
"addAll": "Add All",
|
||||||
|
"addingAll": "Adding...",
|
||||||
|
"allAdded": "All Added",
|
||||||
|
"addAllResultPrefix": "Added ",
|
||||||
|
"addAllResultMiddle1": ", failed ",
|
||||||
|
"addAllResultMiddle2": ", skipped ",
|
||||||
|
"addAllResultSuffix": "",
|
||||||
|
"addAllFailed": "Failed to add, please try again",
|
||||||
|
"goCartTitle": "Added Successfully",
|
||||||
|
"goCartTip": "Go to cart now?",
|
||||||
|
"goCartLater": "Maybe later",
|
||||||
|
"goCartNow": "Go now",
|
||||||
|
"duplicateTitle": "Duplicate Add Confirmation",
|
||||||
|
"duplicateSingleTip": "This item already exists in the cart. Add again?",
|
||||||
|
"duplicateBatchTipPrefix": "",
|
||||||
|
"duplicateBatchTipSuffix": " recommended item(s) already exist in the cart. Add them again?",
|
||||||
|
"missingDishNav": "Missing merchant info, unable to open detail",
|
||||||
|
"missingCartArgs": "Missing merchant or dish info, unable to add cart",
|
||||||
|
"addCartPending": "Add to Cart (pending integration)",
|
||||||
|
"inputPlaceholder": "Send a message or hold to speak (demo)",
|
||||||
|
"generating": "Generating..."
|
||||||
|
}
|
||||||
|
},
|
||||||
"mine": {
|
"mine": {
|
||||||
"activity-description": "Activity description",
|
"activity-description": "Activity description",
|
||||||
"activity-description-tip": "Share the QR code and invite new users to register successfully. Successful invitation",
|
"activity-description-tip": "Share the QR code and invite new users to register successfully. Successful invitation",
|
||||||
|
|||||||
@@ -135,6 +135,9 @@
|
|||||||
"navbar-forget-password": "忘记密码",
|
"navbar-forget-password": "忘记密码",
|
||||||
"navbar-forget-payment-password": "忘记支付密码",
|
"navbar-forget-payment-password": "忘记支付密码",
|
||||||
"navbar-invited-person": "我的邀请",
|
"navbar-invited-person": "我的邀请",
|
||||||
|
"navbar-ai-recommend": "AI推荐入口",
|
||||||
|
"navbar-ai-diet-preference": "饮食喜好设置",
|
||||||
|
"navbar-ai-chat": "AI食材推荐",
|
||||||
"navbar-nickname": "昵称",
|
"navbar-nickname": "昵称",
|
||||||
"navbar-personal-information": "个人信息",
|
"navbar-personal-information": "个人信息",
|
||||||
"navbar-set-payment-password": "设置支付密码",
|
"navbar-set-payment-password": "设置支付密码",
|
||||||
@@ -230,6 +233,70 @@
|
|||||||
"featured-dishes": "精选菜品",
|
"featured-dishes": "精选菜品",
|
||||||
"nearby-merchants": "附近商家"
|
"nearby-merchants": "附近商家"
|
||||||
},
|
},
|
||||||
|
"ai": {
|
||||||
|
"recommend": {
|
||||||
|
"loading": "加载中..."
|
||||||
|
},
|
||||||
|
"dietPreference": {
|
||||||
|
"title": "完善您的饮食偏好",
|
||||||
|
"sub": "我们将根据您的信息为您推荐更符合口味的菜品和食材,让AI更懂你的胃。",
|
||||||
|
"loadingConfig": "正在加载画像配置...",
|
||||||
|
"empty": "暂无可配置项",
|
||||||
|
"multi": "(可多选)",
|
||||||
|
"single": "(单选)",
|
||||||
|
"submit": "保存配置",
|
||||||
|
"fallback": {
|
||||||
|
"groupTaste": "口味与禁忌",
|
||||||
|
"tasteLike": "口味偏好",
|
||||||
|
"tasteTaboo": "绝对忌口",
|
||||||
|
"optionHeavy": "重口味",
|
||||||
|
"optionLight": "清淡",
|
||||||
|
"optionSpicy": "麻辣",
|
||||||
|
"optionSeafood": "海鲜过敏",
|
||||||
|
"optionOrgan": "不吃内脏",
|
||||||
|
"optionOnion": "不吃葱姜蒜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "AI食材推荐",
|
||||||
|
"defaultMessage": "你好,我可以根据你的预算、忌口和口味推荐菜品。",
|
||||||
|
"thinking": "思考中...",
|
||||||
|
"thinkingSearching": "AI 正在检索匹配商品...",
|
||||||
|
"searchingGoods": "正在检索商品...",
|
||||||
|
"toolStatusWithSize": "正在调用 {tool},共 {size} 条",
|
||||||
|
"emptyResult": "已完成,但未返回可展示内容。",
|
||||||
|
"requestFailed": "请求失败,请稍后重试。",
|
||||||
|
"recommendCountPrefix": "推荐商品 ",
|
||||||
|
"recommendCountSuffix": " 件",
|
||||||
|
"recommendSource": "来自 AI 实时筛选",
|
||||||
|
"summaryFound": "找到 {count} 件商品",
|
||||||
|
"summaryBudget": "预估 {estimate},实际 {actual},优惠 {save} 元",
|
||||||
|
"unnamed": "未命名商品",
|
||||||
|
"quickAdd": "一键加购",
|
||||||
|
"added": "已加购",
|
||||||
|
"addAll": "一键加入全部",
|
||||||
|
"addingAll": "加购中...",
|
||||||
|
"allAdded": "已全部加购",
|
||||||
|
"addAllResultPrefix": "已加购",
|
||||||
|
"addAllResultMiddle1": "件,失败",
|
||||||
|
"addAllResultMiddle2": "件,跳过",
|
||||||
|
"addAllResultSuffix": "件",
|
||||||
|
"addAllFailed": "加入失败,请稍后重试",
|
||||||
|
"goCartTitle": "加入成功",
|
||||||
|
"goCartTip": "是否前往购物车?",
|
||||||
|
"goCartLater": "下次再说",
|
||||||
|
"goCartNow": "直接前往",
|
||||||
|
"duplicateTitle": "重复加入确认",
|
||||||
|
"duplicateSingleTip": "当前购物车已存在该商品,是否重复加入?",
|
||||||
|
"duplicateBatchTipPrefix": "当前购物车已有",
|
||||||
|
"duplicateBatchTipSuffix": "件推荐商品,是否重复加入这些商品?",
|
||||||
|
"missingDishNav": "缺少商家信息,暂无法查看详情",
|
||||||
|
"missingCartArgs": "缺少商家或菜品信息,暂无法加购",
|
||||||
|
"addCartPending": "加入购物车(待接入)",
|
||||||
|
"inputPlaceholder": "输入消息后点击发送",
|
||||||
|
"generating": "生成中..."
|
||||||
|
}
|
||||||
|
},
|
||||||
"mine": {
|
"mine": {
|
||||||
"activity-description": "活动说明:",
|
"activity-description": "活动说明:",
|
||||||
"activity-description-tip": "分享专属二维码邀请新用户注册即邀请成功。一起来注册体验点餐功能。",
|
"activity-description-tip": "分享专属二维码邀请新用户注册即邀请成功。一起来注册体验点餐功能。",
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
"name" : "CHEFLINK delivery",
|
"name" : "CHEFLINK delivery",
|
||||||
"appid" : "__UNI__06509BE",
|
"appid" : "__UNI__06509BE",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "3.1.4",
|
"versionName" : "3.1.8",
|
||||||
"versionCode" : 314,
|
"versionCode" : 318,
|
||||||
"transformPx" : false,
|
"transformPx" : false,
|
||||||
/* 5+App特有相关 */
|
/* 5+App特有相关 */
|
||||||
"app-plus" : {
|
"app-plus" : {
|
||||||
|
|||||||
@@ -388,10 +388,10 @@ const specPopupDisplayPrice = computed(() => {
|
|||||||
|
|
||||||
const detailDisplayPrice = computed(() => {
|
const detailDisplayPrice = computed(() => {
|
||||||
const dish = dishDetailData.value as any;
|
const dish = dishDetailData.value as any;
|
||||||
const firstSpecPrice = dish?.merchantSideDishVoList?.[0]?.merchantSideDishItemVoList?.[0]?.actualSalePrice;
|
// const firstSpecPrice = dish?.merchantSideDishVoList?.[0]?.merchantSideDishItemVoList?.[0]?.actualSalePrice;
|
||||||
if (firstSpecPrice != null && String(firstSpecPrice) !== "") {
|
// if (firstSpecPrice != null && String(firstSpecPrice) !== "") {
|
||||||
return firstSpecPrice;
|
// return firstSpecPrice;
|
||||||
}
|
// }
|
||||||
return dish?.actualSalePrice;
|
return dish?.actualSalePrice;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -771,21 +771,19 @@ function getStoreDetail() {
|
|||||||
<view class="dish-info">
|
<view class="dish-info">
|
||||||
<view class="price-block">
|
<view class="price-block">
|
||||||
<view class="price-block__row">
|
<view class="price-block__row">
|
||||||
<view class="price-current">
|
<view class="price-current" v-if="dishDetailData?.originalPrice != null && dishDetailData?.originalPrice !== ''">
|
||||||
<text class="price-current__sym">$</text>
|
<text class="price-current__sym">$</text>
|
||||||
<text class="price-current__num">{{
|
<text class="price-current__num">{{
|
||||||
detailDisplayPrice
|
dishDetailData?.originalPrice
|
||||||
}}</text>
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
<text
|
<text
|
||||||
v-if="
|
v-if="
|
||||||
dishDetailData?.originalPrice != null &&
|
dishDetailData?.discountPrice != null &&
|
||||||
dishDetailData?.originalPrice !== '' &&
|
dishDetailData?.discountPrice !== ''
|
||||||
String(dishDetailData.originalPrice) !==
|
|
||||||
String(dishDetailData?.discountPrice ?? '')
|
|
||||||
"
|
"
|
||||||
class="price-original"
|
class="price-original"
|
||||||
>${{ dishDetailData?.originalPrice }}</text
|
>${{ dishDetailData?.discountPrice }}</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
|
|||||||
@@ -79,6 +79,27 @@
|
|||||||
"navigationBarTitleText": "%navbar-customer-service%",
|
"navigationBarTitleText": "%navbar-customer-service%",
|
||||||
"navigationStyle": "default"
|
"navigationStyle": "default"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ai/recommend/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "%navbar-ai-recommend%",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ai/diet-preference/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "%navbar-ai-diet-preference%",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ai/chat/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "%navbar-ai-chat%",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subPackages": [
|
"subPackages": [
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
<template>
|
||||||
<view class="home-skeleton">
|
<view class="home-skeleton">
|
||||||
<!-- 头部区域 -->
|
<!-- 顶部头部(与 home-top-header 对齐) -->
|
||||||
<view class="header-section">
|
<view class="header-section">
|
||||||
|
<view class="header-left">
|
||||||
|
<view class="brand-row">
|
||||||
|
<view class="logo-skeleton skeleton-item"></view>
|
||||||
<view class="app-title-skeleton skeleton-item"></view>
|
<view class="app-title-skeleton skeleton-item"></view>
|
||||||
|
</view>
|
||||||
<view class="delivery-info-skeleton skeleton-item"></view>
|
<view class="delivery-info-skeleton skeleton-item"></view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="header-right">
|
||||||
<!-- 位置和通知区域 -->
|
<view class="location-pill-skeleton skeleton-item"></view>
|
||||||
<view class="location-notification">
|
<view class="cart-btn-skeleton skeleton-item"></view>
|
||||||
<view class="location-skeleton skeleton-item"></view>
|
</view>
|
||||||
<view class="notification-skeleton skeleton-item"></view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 搜索栏 -->
|
<!-- 搜索栏 + 消息按钮 -->
|
||||||
<view class="search-section">
|
<view class="search-section">
|
||||||
|
<view class="search-row">
|
||||||
<view class="search-bar-skeleton skeleton-item"></view>
|
<view class="search-bar-skeleton skeleton-item"></view>
|
||||||
|
<view class="message-btn-skeleton skeleton-item"></view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 分类滚动区域 -->
|
<!-- 分类滚动区域 -->
|
||||||
@@ -107,59 +113,91 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-skeleton {
|
.home-skeleton {
|
||||||
background-color: #fff;
|
background-color: #f2f2f2;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 头部区域
|
// 头部区域
|
||||||
.header-section {
|
.header-section {
|
||||||
padding: 18rpx 30rpx 0;
|
padding: 18rpx 24rpx 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-skeleton {
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.app-title-skeleton {
|
.app-title-skeleton {
|
||||||
width: 241rpx;
|
width: 210rpx;
|
||||||
height: 52rpx;
|
height: 36rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delivery-info-skeleton {
|
.delivery-info-skeleton {
|
||||||
width: 266rpx;
|
margin-top: 12rpx;
|
||||||
height: 28rpx;
|
width: 300rpx;
|
||||||
border-radius: 6rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 位置和通知区域
|
|
||||||
.location-notification {
|
|
||||||
padding: 22rpx 30rpx 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.location-skeleton {
|
|
||||||
width: 329rpx;
|
|
||||||
height: 30rpx;
|
height: 30rpx;
|
||||||
border-radius: 6rpx;
|
border-radius: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-skeleton {
|
.header-right {
|
||||||
width: 132rpx;
|
display: flex;
|
||||||
height: 60rpx;
|
align-items: center;
|
||||||
border-radius: 30rpx;
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-pill-skeleton {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-btn-skeleton {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索栏
|
// 搜索栏 + 消息按钮
|
||||||
.search-section {
|
.search-section {
|
||||||
padding: 32rpx 30rpx 0;
|
padding: 20rpx 24rpx 0;
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.search-bar-skeleton {
|
.search-bar-skeleton {
|
||||||
width: 690rpx;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
border-radius: 44rpx;
|
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">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useUserStore } from "@/store";
|
import { useUserStore } from "@/store";
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
/** 首页顶栏紧凑模式:更小图标与间距 */
|
|
||||||
compact?: boolean
|
|
||||||
}>(),
|
|
||||||
{ compact: false }
|
|
||||||
)
|
|
||||||
const emit = defineEmits(['toggleNotOpen']);
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const expanded = ref(true)
|
||||||
|
const touchStartX = ref(0)
|
||||||
|
|
||||||
function navigateTo(url: string) {
|
function navigateTo(url: string) {
|
||||||
if(userStore.checkLogin()) {
|
if(userStore.checkLogin()) {
|
||||||
uni.navigateTo({
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="flex items-center" :class="compact ? 'gap-20rpx' : ''">
|
<!-- 仅 AI 跳转做右侧球体悬浮 -->
|
||||||
<view
|
<view
|
||||||
@click="navigateTo('/pages-user/pages/message/index')"
|
class="ai-floating"
|
||||||
:class="compact ? 'w-34rpx h-34rpx mr-0' : 'w-40rpx h-40rpx mr-42rpx'"
|
:class="{ 'is-collapsed': !expanded }"
|
||||||
class="relative shrink-0"
|
@touchstart.stop="onHandleTouchStart"
|
||||||
|
@touchend.stop="onHandleTouchEnd"
|
||||||
|
@touchcancel.stop="onHandleTouchEnd"
|
||||||
>
|
>
|
||||||
<view
|
<view v-if="expanded" class="ai-close" @click.stop="expanded = false">×</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'"
|
<view class="ai-fab" @click="onFabClick">
|
||||||
class="bg-#E23636 absolute z-2 rounded-50% text-#fff text-center font-500"
|
<image src="@img/chef/115.png" class="ai-icon"></image>
|
||||||
>{{ userStore.unreadMessageCount }}</view>
|
|
||||||
<image src="@img/chef/114.png" :class="compact ? 'w-34rpx h-34rpx' : 'w-40rpx h-40rpx'"></image>
|
|
||||||
</view>
|
</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<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>
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isAutoJump: {
|
isAutoJump: {
|
||||||
@@ -20,18 +22,37 @@ function handleClickSearch() {
|
|||||||
emit('clickSearch')
|
emit('clickSearch')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goMessage() {
|
||||||
|
if (userStore.checkLogin()) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages-user/pages/message/index',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<view class="home-search-row flex items-center gap-14rpx">
|
||||||
<view
|
<view
|
||||||
@click="handleClickSearch"
|
@click="handleClickSearch"
|
||||||
class="home-search-bar flex items-center h-88rpx rounded-44rpx pl-36rpx pr-28rpx bg-white"
|
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>
|
<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">{{
|
<text class="home-search-placeholder text-28rpx ml-16rpx tracking-[.04em] font-500 truncate">{{
|
||||||
t('components.search.placeholder')
|
t('components.search.placeholder')
|
||||||
}}</text>
|
}}</text>
|
||||||
</view>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -41,4 +62,31 @@ function handleClickSearch() {
|
|||||||
.home-search-placeholder {
|
.home-search-placeholder {
|
||||||
color: #9a9a9a;
|
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>
|
</style>
|
||||||
@@ -6,6 +6,7 @@ import Config from '@/config/index'
|
|||||||
import { debounce } from 'throttle-debounce'
|
import { debounce } from 'throttle-debounce'
|
||||||
import MsgBox from "@/pages/home/components/tabbar-home/components/msg-box.vue";
|
import MsgBox from "@/pages/home/components/tabbar-home/components/msg-box.vue";
|
||||||
import Search from "@/pages/home/components/tabbar-home/components/search.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 ClassBullet from "./components/class-bullet.vue";
|
||||||
import TabsType from "./components/tabs-type.vue";
|
import TabsType from "./components/tabs-type.vue";
|
||||||
import FeaturedOn from "./components/featured-on/index.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">
|
<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>
|
<template #top>
|
||||||
<status-bar />
|
<status-bar />
|
||||||
<!-- 设计稿:品牌行 + 右侧地址胶囊、消息/客服、购物车 -->
|
<home-top-header
|
||||||
<view class="home-top-header px-24rpx pt-12rpx pb-8rpx">
|
:app-name="Config.appName"
|
||||||
<view class="flex items-center justify-between gap-12rpx">
|
:location-text="userStore.userLocation.location"
|
||||||
<view class="flex items-center gap-14rpx min-w-0 flex-1">
|
:appointment-time-show="userStore.appointmentTimeShow"
|
||||||
<image src="@img/logo.png" class="w-52rpx h-52rpx shrink-0" style="border-radius: 50%;"></image>
|
:cart-badge-total="cartBadgeTotal"
|
||||||
<text class="text-32rpx lh-36rpx text-#1a1a1a font-bold tracking-tight shrink-0">{{ Config.appName }}</text>
|
:is-login="userStore.isLogin"
|
||||||
</view>
|
@click-address="navigateTo('/pages/address/index')"
|
||||||
<view class="flex items-center gap-10rpx shrink-0">
|
@click-location="navigateTo('/pages-user/pages/search-address/index')"
|
||||||
<view class="home-loc-pill" @click="navigateTo('/pages-user/pages/search-address/index')">
|
@click-cart="goCart"
|
||||||
<!-- <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>
|
|
||||||
</template>
|
</template>
|
||||||
<view
|
<view
|
||||||
class="animate-in fade-in animate-ease-out animate-duration-300"
|
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>
|
||||||
<view class="px-24rpx pt-12rpx pb-8rpx">
|
<view class="px-24rpx pt-12rpx pb-8rpx">
|
||||||
<search />
|
<search />
|
||||||
|
<msg-box />
|
||||||
<!-- 分类标签(双行横滑) -->
|
<!-- 分类标签(双行横滑) -->
|
||||||
<view class="mt-28rpx" v-if="appMerchantLabelList.length > 0">
|
<view class="mt-28rpx" v-if="appMerchantLabelList.length > 0">
|
||||||
<class-bullet :categories="appMerchantLabelList" @itemClick="handleItemClick" />
|
<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-body">
|
||||||
<view class="featured-dish-meta flex items-start justify-between gap-12rpx mb-14rpx">
|
<view class="featured-dish-meta flex items-start justify-between gap-12rpx mb-14rpx">
|
||||||
<view class="min-w-0 flex-1">
|
<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
|
<!-- <text
|
||||||
v-if="Number(item?.originalPrice) > Number(item?.discountPrice)"
|
v-if="Number(item?.originalPrice) > Number(item?.discountPrice)"
|
||||||
class="featured-dish-original"
|
class="featured-dish-original"
|
||||||
@@ -506,59 +488,6 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
|
|||||||
background: #f2f2f2;
|
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 {
|
.home-delivery-row {
|
||||||
padding-left: 2rpx;
|
padding-left: 2rpx;
|
||||||
}
|
}
|
||||||
@@ -633,7 +562,7 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
// background: rgba(62, 66, 70, 0.6);
|
background: rgba(201, 197, 197, 0.6);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-ignore
|
||||||
|
import type { CustomRequestOptions } from '@/http/types';
|
||||||
|
|
||||||
|
type AnyRecord = Record<string, any>;
|
||||||
|
|
||||||
|
type StreamCallbacks = {
|
||||||
|
onToken?: (text: string) => void;
|
||||||
|
onDone?: () => void;
|
||||||
|
onError?: (err: unknown) => void;
|
||||||
|
onStart?: (payload: Record<string, any>) => void;
|
||||||
|
onTool?: (payload: Record<string, any>) => void;
|
||||||
|
onChunk?: (payload: Record<string, any>) => void;
|
||||||
|
onEnd?: (payload: Record<string, any>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiChatStreamQuery = {
|
||||||
|
content: string;
|
||||||
|
conversationId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeChunk(data: ArrayBuffer | string): string {
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8').decode(data);
|
||||||
|
} catch (e) {
|
||||||
|
const arr = new Uint8Array(data);
|
||||||
|
let str = '';
|
||||||
|
for (let i = 0; i < arr.length; i += 1) str += String.fromCharCode(arr[i]);
|
||||||
|
return decodeURIComponent(escape(str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSsePayload(raw: string): string {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) return '';
|
||||||
|
if (!line.startsWith('data:')) return line;
|
||||||
|
const payload = line.slice(5).trim();
|
||||||
|
if (!payload || payload === '[DONE]') return '';
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(payload);
|
||||||
|
return (
|
||||||
|
json?.content ??
|
||||||
|
json?.delta?.content ??
|
||||||
|
json?.message ??
|
||||||
|
json?.data?.content ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseEventBlock(block: string): { event: string; payload: Record<string, any> | null } | null {
|
||||||
|
const lines = block
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!lines.length) return null;
|
||||||
|
|
||||||
|
let eventName = 'message';
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
lines.forEach((line) => {
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
eventName = line.slice(6).trim() || 'message';
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dataLines.length) return { event: eventName, payload: null };
|
||||||
|
const raw = dataLines.join('\n');
|
||||||
|
try {
|
||||||
|
return { event: eventName, payload: JSON.parse(raw) };
|
||||||
|
} catch (e) {
|
||||||
|
return { event: eventName, payload: { text: raw } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchSseEvent(
|
||||||
|
parsed: { event: string; payload: Record<string, any> | null } | null,
|
||||||
|
callbacks?: StreamCallbacks,
|
||||||
|
) {
|
||||||
|
if (!parsed) return;
|
||||||
|
const payload = parsed.payload || {};
|
||||||
|
switch (parsed.event) {
|
||||||
|
case 'start':
|
||||||
|
callbacks?.onStart?.(payload);
|
||||||
|
break;
|
||||||
|
case 'tool':
|
||||||
|
callbacks?.onTool?.(payload);
|
||||||
|
break;
|
||||||
|
case 'chunk':
|
||||||
|
callbacks?.onChunk?.(payload);
|
||||||
|
if (!callbacks?.onChunk && typeof payload.text === 'string' && payload.text) {
|
||||||
|
callbacks?.onToken?.(payload.text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'end':
|
||||||
|
callbacks?.onEnd?.(payload);
|
||||||
|
callbacks?.onDone?.();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 兼容未知 event 或普通 message
|
||||||
|
if (typeof payload.text === 'string' && payload.text) {
|
||||||
|
callbacks?.onToken?.(payload.text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 流式对话 GET /app/ai/chat/stream
|
||||||
|
* 注意:不同端对 chunk 支持程度不同,不支持时会在 success 中返回完整文本。
|
||||||
|
*/
|
||||||
|
export function appAiChatStreamGet({
|
||||||
|
query,
|
||||||
|
callbacks,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
query: AiChatStreamQuery;
|
||||||
|
callbacks?: StreamCallbacks;
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
let buffer = '';
|
||||||
|
let gotChunk = false;
|
||||||
|
|
||||||
|
const task = uni.request({
|
||||||
|
url: '/app/ai/chat/stream',
|
||||||
|
method: 'GET',
|
||||||
|
data: query,
|
||||||
|
enableChunked: true as any,
|
||||||
|
responseType: 'text',
|
||||||
|
timeout: options?.timeout ?? 60000,
|
||||||
|
header: {
|
||||||
|
...(options?.header || {}),
|
||||||
|
},
|
||||||
|
success(res) {
|
||||||
|
// 部分端不支持 chunk 回调,这里兜底按整段文本处理
|
||||||
|
if (gotChunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = typeof res.data === 'string' ? res.data : JSON.stringify(res.data || '');
|
||||||
|
const normalized = raw.replace(/\r\n/g, '\n');
|
||||||
|
const blocks = normalized.split('\n\n').filter(Boolean);
|
||||||
|
if (!blocks.length) {
|
||||||
|
if (raw) callbacks?.onToken?.(raw);
|
||||||
|
callbacks?.onDone?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocks.forEach((block) => {
|
||||||
|
dispatchSseEvent(parseSseEventBlock(block), callbacks);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail(err) {
|
||||||
|
callbacks?.onError?.(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskAny = task as any;
|
||||||
|
if (taskAny && typeof taskAny.onChunkReceived === 'function') {
|
||||||
|
taskAny.onChunkReceived((chunk: { data: ArrayBuffer | string }) => {
|
||||||
|
gotChunk = true;
|
||||||
|
buffer += decodeChunk(chunk.data).replace(/\r\n/g, '\n');
|
||||||
|
const blocks = buffer.split('\n\n');
|
||||||
|
buffer = blocks.pop() || '';
|
||||||
|
blocks.forEach((block) => {
|
||||||
|
dispatchSseEvent(parseSseEventBlock(block), callbacks);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧调用名,避免其他页面引用报错
|
||||||
|
export const appAiChatStreamPost = appAiChatStreamGet;
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-ignore
|
||||||
|
import request from '@/http/vue-query';
|
||||||
|
import type { CustomRequestOptions } from '@/http/types';
|
||||||
|
|
||||||
|
type AnyRecord = Record<string, any>;
|
||||||
|
export type DiningProfileFields = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type DiningProfileConfigItem = {
|
||||||
|
groupKey: string;
|
||||||
|
groupName: string;
|
||||||
|
fieldKey: string;
|
||||||
|
fieldName: string;
|
||||||
|
optionsJson: string;
|
||||||
|
sortNum: number;
|
||||||
|
isRequired: number | null;
|
||||||
|
multiSelect: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 查询当前可用画像配置 GET /app/diningProfile/config */
|
||||||
|
export async function appDiningProfileConfigGet({
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
return request<{ code: number; data: DiningProfileConfigItem[]; msg?: string }>(
|
||||||
|
'/app/diningProfile/config',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询当前登录用户画像 GET /app/diningProfile */
|
||||||
|
export async function appDiningProfileGet({
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
return request<{ code: number; data: DiningProfileFields | null; msg?: string }>(
|
||||||
|
'/app/diningProfile',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存画像配置 POST /app/diningProfile/save */
|
||||||
|
export async function appDiningProfileSavePost({
|
||||||
|
body,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
body: { fields: DiningProfileFields };
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
return request<{ code: number; data: AnyRecord | null; msg?: string }>(
|
||||||
|
'/app/diningProfile/save',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,8 @@ export * from './types';
|
|||||||
export * from './displayEnumLabel';
|
export * from './displayEnumLabel';
|
||||||
|
|
||||||
export * from './filtersConfig';
|
export * from './filtersConfig';
|
||||||
|
export * from './aiChat';
|
||||||
|
export * from './diningProfile';
|
||||||
export * from './merchant';
|
export * from './merchant';
|
||||||
export * from './agreement';
|
export * from './agreement';
|
||||||
export * from './version';
|
export * from './version';
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 439 B |
@@ -10,6 +10,7 @@ import * as R from 'ramda'
|
|||||||
import {EventEnum} from "@/constant/enums";
|
import {EventEnum} from "@/constant/enums";
|
||||||
import {dayjs} from '@/plugin'
|
import {dayjs} from '@/plugin'
|
||||||
import {useConfigStore} from "@/store";
|
import {useConfigStore} from "@/store";
|
||||||
|
import {i18n} from "@/locale";
|
||||||
|
|
||||||
export const useUserStore = defineStore(
|
export const useUserStore = defineStore(
|
||||||
'user',
|
'user',
|
||||||
@@ -173,9 +174,13 @@ export const useUserStore = defineStore(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const {locale} = useI18n()
|
|
||||||
function getCityName(latitude: number, longitude: number) {
|
function getCityName(latitude: number, longitude: number) {
|
||||||
const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${Config.googleMapKey}&language=${locale.value === 'zh-Hans' ? 'zh-CN' : locale.value}`;
|
const currentLocale =
|
||||||
|
typeof i18n.global.locale === 'string'
|
||||||
|
? i18n.global.locale
|
||||||
|
: i18n.global.locale.value
|
||||||
|
const language = currentLocale === 'zh-Hans' ? 'zh-CN' : currentLocale
|
||||||
|
const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${Config.googleMapKey}&language=${language}`;
|
||||||
|
|
||||||
uni.request({
|
uni.request({
|
||||||
url,
|
url,
|
||||||
|
|||||||
Reference in New Issue
Block a user