Compare commits
14 Commits
master
...
f214a61735
| Author | SHA1 | Date | |
|---|---|---|---|
| f214a61735 | |||
| 3fb5db48f2 | |||
| 41099f4190 | |||
| fbf40a1575 | |||
| eeffab9a06 | |||
| 8ed9bf2e1a | |||
| 3421b7006f | |||
| 7503ac4f42 | |||
| 068b09d272 | |||
| f2cde43bf4 | |||
| 7d891c9f7b | |||
| 03bc46df29 | |||
| 2a2af01097 | |||
| 51d016655c |
@@ -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.4:8080
|
#VITE_SERVER_BASEURL=http://192.168.0.197: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
|
||||||
@@ -10,15 +10,12 @@ onLaunch(initConfig);
|
|||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
console.log('%c外卖用户端--用户端', 'background: #00A76D; color: white; padding: 3px; border-radius: 2px;');
|
console.log('%c外卖用户端--用户端', 'background: #00A76D; color: white; padding: 3px; border-radius: 2px;');
|
||||||
console.log("App Show");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
console.log("App Hide");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onError((error: string) => {
|
onError((error: string) => {
|
||||||
console.log('App Error', error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -29,12 +26,9 @@ function initApp() {
|
|||||||
const color = plus.android.newObject("android.graphics.Color");
|
const color = plus.android.newObject("android.graphics.Color");
|
||||||
const ac = plus.android.runtimeMainActivity();
|
const ac = plus.android.runtimeMainActivity();
|
||||||
const c2int = plus.android.invoke(color, "parseColor", "#FFFFFF");
|
const c2int = plus.android.invoke(color, "parseColor", "#FFFFFF");
|
||||||
console.log("c2int===" + JSON.stringify(c2int))
|
|
||||||
const win = plus.android.invoke(ac, "getWindow");
|
const win = plus.android.invoke(ac, "getWindow");
|
||||||
console.log("win===" + JSON.stringify(win))
|
|
||||||
plus.android.invoke(win, "setNavigationBarColor", c2int);
|
plus.android.invoke(win, "setNavigationBarColor", c2int);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('error', e)
|
|
||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
@@ -87,12 +81,10 @@ function initConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(!configStore.isShowedLanguageSelectPage) {
|
if(!configStore.isShowedLanguageSelectPage) {
|
||||||
console.log('未展示过语言选择页面,导航到语言选择页面')
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: '/pages-login/pages/choose-language/index',
|
url: '/pages-login/pages/choose-language/index',
|
||||||
success() {
|
success() {
|
||||||
configStore.isShowedLanguageSelectPage = true
|
configStore.isShowedLanguageSelectPage = true
|
||||||
console.log('导航到语言选择页面成功')
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,8 +236,6 @@ let commentPlaceholder = ref("说点什么..."); // 输入框占位符
|
|||||||
|
|
||||||
// 发送评论
|
// 发送评论
|
||||||
function sendClick({ item1, index1, item2, index2 } = replyTemp) {
|
function sendClick({ item1, index1, item2, index2 } = replyTemp) {
|
||||||
console.log('replyTemp', replyTemp.item1)
|
|
||||||
console.log('replyTemp', replyTemp.item2)
|
|
||||||
const data = replyTemp.item2 || replyTemp.item1
|
const data = replyTemp.item2 || replyTemp.item1
|
||||||
appCommentPublishCommentPost({
|
appCommentPublishCommentPost({
|
||||||
body: {
|
body: {
|
||||||
@@ -248,7 +246,6 @@ function sendClick({ item1, index1, item2, index2 } = replyTemp) {
|
|||||||
content: commentValue.value,
|
content: commentValue.value,
|
||||||
}
|
}
|
||||||
}).then(res=> {
|
}).then(res=> {
|
||||||
console.log(res)
|
|
||||||
emit("update");
|
emit("update");
|
||||||
cPopupShow.value = false;
|
cPopupShow.value = false;
|
||||||
commentValue.value = ""; // 清空输入框值
|
commentValue.value = ""; // 清空输入框值
|
||||||
@@ -263,7 +260,6 @@ function sendClick({ item1, index1, item2, index2 } = replyTemp) {
|
|||||||
|
|
||||||
function expandReply(item1) {
|
function expandReply(item1) {
|
||||||
// item1.childrenShow = item1.children
|
// item1.childrenShow = item1.children
|
||||||
console.log(item1)
|
|
||||||
appCommentReplyListPost({
|
appCommentReplyListPost({
|
||||||
params: {
|
params: {
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
@@ -273,7 +269,6 @@ function expandReply(item1) {
|
|||||||
parentId: item1.id,
|
parentId: item1.id,
|
||||||
}
|
}
|
||||||
}).then(res=> {
|
}).then(res=> {
|
||||||
console.log('回复列表', res)
|
|
||||||
item1.children = res.rows.map(item => {
|
item1.children = res.rows.map(item => {
|
||||||
let userInfo = {}
|
let userInfo = {}
|
||||||
// 普通用户
|
// 普通用户
|
||||||
@@ -314,7 +309,6 @@ function shrinkReply(item1) {
|
|||||||
const delPopupRef = ref(null);
|
const delPopupRef = ref(null);
|
||||||
let delTemp = reactive({}); // 临时数据
|
let delTemp = reactive({}); // 临时数据
|
||||||
function deleteClick({ item1, index1, item2, index2 }) {
|
function deleteClick({ item1, index1, item2, index2 }) {
|
||||||
console.log('删123除', item1, index1, item2, index2)
|
|
||||||
message
|
message
|
||||||
.confirm({
|
.confirm({
|
||||||
title: t("common.prompt.system-prompt"),
|
title: t("common.prompt.system-prompt"),
|
||||||
@@ -336,7 +330,6 @@ function deleteClick({ item1, index1, item2, index2 }) {
|
|||||||
id: item2.id ? item2.id : item1.id,
|
id: item2.id ? item2.id : item1.id,
|
||||||
}
|
}
|
||||||
}).then(res=> {
|
}).then(res=> {
|
||||||
console.log('删除成功', res)
|
|
||||||
emit("deleteFun");
|
emit("deleteFun");
|
||||||
shrinkReply(item1)
|
shrinkReply(item1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ function chooseImage(type: 'album' | 'camera') {
|
|||||||
console.log(results)
|
console.log(results)
|
||||||
emits('change', results)
|
emits('change', results)
|
||||||
})
|
})
|
||||||
|
.catch(() => {
|
||||||
|
uni.showToast({ icon: 'none', title: t('common.prompt.up-failed') })
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
})
|
})
|
||||||
@@ -138,6 +141,9 @@ function chooseImage(type: 'album' | 'camera') {
|
|||||||
.then((results) => {
|
.then((results) => {
|
||||||
emits('change', results)
|
emits('change', results)
|
||||||
})
|
})
|
||||||
|
.catch(() => {
|
||||||
|
uni.showToast({ icon: 'none', title: t('common.prompt.up-failed') })
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -219,6 +222,7 @@
|
|||||||
"browse": {
|
"browse": {
|
||||||
"brandTag": "CHEFLINK Selected",
|
"brandTag": "CHEFLINK Selected",
|
||||||
"moreRecipes": "More Recipes",
|
"moreRecipes": "More Recipes",
|
||||||
|
"nearbyEmpty": "No nearby data",
|
||||||
"titleCuisine": "Nearby Cuisine",
|
"titleCuisine": "Nearby Cuisine",
|
||||||
"titleRecipes": "Selected Recipes"
|
"titleRecipes": "Selected Recipes"
|
||||||
},
|
},
|
||||||
@@ -228,13 +232,106 @@
|
|||||||
"deliveryFee": "Shipping Fee",
|
"deliveryFee": "Shipping Fee",
|
||||||
"featured-on": "Featured on CHEFLINK",
|
"featured-on": "Featured on CHEFLINK",
|
||||||
"featured-dishes": "Featured Dishes",
|
"featured-dishes": "Featured Dishes",
|
||||||
"nearby-merchants": "Nearby Merchants"
|
"nearby-merchants": "Nearby Merchants",
|
||||||
|
"open-member": "Become a Member",
|
||||||
|
"recharge-now": "Deposit Now",
|
||||||
|
"quickTabs": {
|
||||||
|
"memberZone": "Members' Picks",
|
||||||
|
"liveSeafoodAir": "Live Seafood – Limited Supply",
|
||||||
|
"mustEatList": "Must-Try List",
|
||||||
|
"newCalendar": "New Arrival",
|
||||||
|
"newCalendarNav": "Today's New",
|
||||||
|
"freshSeafoodToday": "Day-Boat Fresh Catch",
|
||||||
|
"groupCatering": "Catering & Group Orders",
|
||||||
|
"energyMeal": "Energy Meals"
|
||||||
|
},
|
||||||
|
"mustEatListTabs": {
|
||||||
|
"merchant": "Merchant Rank",
|
||||||
|
"dish": "Product Rank",
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
|
"noticeBar": {
|
||||||
|
"thursday": "Every Thursday: Fresh seafood + New York store delivery",
|
||||||
|
"sunday": "Every Sunday: Boston local store delivery",
|
||||||
|
"freshCatch": "Freshly caught seafood is listed based on daily catch. Limited quantity, while supplies last."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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...",
|
||||||
|
"toolStatusWithSizePrefix": "Calling ",
|
||||||
|
"toolStatusWithSizeMiddle": ", total ",
|
||||||
|
"toolStatusWithSizeSuffix": " 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",
|
||||||
|
"summaryFoundPrefix": "",
|
||||||
|
"summaryFoundSuffix": " items found",
|
||||||
|
"summaryBudgetPrefix": "Estimate ",
|
||||||
|
"summaryBudgetMiddle1": ", actual ",
|
||||||
|
"summaryBudgetMiddle2": ", saved ",
|
||||||
|
"summaryBudgetSuffix": "",
|
||||||
|
"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",
|
||||||
"collection": "My Collection",
|
"collection": "My Collection",
|
||||||
"collectionBatchDeleteConfirm": "Remove {count} selected favorite(s)?",
|
"collectionBatchDeleteConfirmPrefix": "Remove ",
|
||||||
|
"collectionBatchDeleteConfirmSuffix": " selected favorite(s)?",
|
||||||
"collectionBatchModeHint": "Select items to remove, then tap the trash again to delete",
|
"collectionBatchModeHint": "Select items to remove, then tap the trash again to delete",
|
||||||
"collectionSelectFirst": "Please select favorites to remove",
|
"collectionSelectFirst": "Please select favorites to remove",
|
||||||
"complaintsAndSuggestions": "Complaints and suggestions",
|
"complaintsAndSuggestions": "Complaints and suggestions",
|
||||||
@@ -274,7 +371,8 @@
|
|||||||
"confirmReady": "CONF/READY",
|
"confirmReady": "CONF/READY",
|
||||||
"onTheWay": "OTW",
|
"onTheWay": "OTW",
|
||||||
"title": "History",
|
"title": "History",
|
||||||
"totalItemCount": "{count} items"
|
"totalItemCountPrefix": "",
|
||||||
|
"totalItemCountSuffix": " items"
|
||||||
},
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"all": "All"
|
"all": "All"
|
||||||
@@ -379,15 +477,22 @@
|
|||||||
"addCoupon": "Add coupon",
|
"addCoupon": "Add coupon",
|
||||||
"confirmOrder": "Confirm order",
|
"confirmOrder": "Confirm order",
|
||||||
"couponInputPlaceholder": "Enter coupon code",
|
"couponInputPlaceholder": "Enter coupon code",
|
||||||
"deliverBefore": "Arriving before {time}",
|
"deliverBeforePrefix": "Arriving before ",
|
||||||
|
"deliverBeforeSuffix": "",
|
||||||
"deliveryInfo": "Delivery",
|
"deliveryInfo": "Delivery",
|
||||||
"driverTip": "Driver tip",
|
"driverTip": "Driver tip",
|
||||||
"driverTipNote": "100% of your tip goes to the driver.",
|
"driverTipNote": "100% of your tip goes to the driver.",
|
||||||
|
"tipByDeliveryDatePrefix": "Delivery date ",
|
||||||
|
"tipNextTime": "Maybe next time",
|
||||||
"fillAddressHint": "Add delivery address and contact",
|
"fillAddressHint": "Add delivery address and contact",
|
||||||
"freeTag": "Free",
|
"freeTag": "Free",
|
||||||
"localDelivery": "Local delivery",
|
"localDelivery": "Local delivery",
|
||||||
"localDeliverySubtitle": "Delivered by {name}.",
|
"localDeliverySubtitlePrefix": "Delivered by ",
|
||||||
"memberThanks": "Thanks for being a {name} member.",
|
"localDeliverySubtitleSuffix": ".",
|
||||||
|
"memberThanksPrefix": "Thanks for being a ",
|
||||||
|
"memberThanksSuffix": " member.",
|
||||||
|
"missingParamsHint": "Missing checkout parameters. Please return to cart and try again.",
|
||||||
|
"emptyCartHint": "Your cart is empty. Please add items before checkout.",
|
||||||
"orderTotalLine": "Order total",
|
"orderTotalLine": "Order total",
|
||||||
"orderBreakdown": "Order details",
|
"orderBreakdown": "Order details",
|
||||||
"pay": "Pay",
|
"pay": "Pay",
|
||||||
@@ -396,20 +501,24 @@
|
|||||||
"pickupTitle": "Pickup",
|
"pickupTitle": "Pickup",
|
||||||
"scheduledDeliveryHint": "Delivery fee refunded if we miss the promised window.",
|
"scheduledDeliveryHint": "Delivery fee refunded if we miss the promised window.",
|
||||||
"scheduledDeliveryTitle": "Scheduled delivery",
|
"scheduledDeliveryTitle": "Scheduled delivery",
|
||||||
"scheduledDeliveryWindow": "By {time}",
|
"scheduledDeliveryWindowPrefix": "By ",
|
||||||
|
"scheduledDeliveryWindowSuffix": "",
|
||||||
"scheduledDeliveryAddon": "+ $ 2.99",
|
"scheduledDeliveryAddon": "+ $ 2.99",
|
||||||
"scheduledDeliveryStandardTitle": "Standard delivery",
|
"scheduledDeliveryStandardTitle": "Standard delivery",
|
||||||
"scheduledDeliveryStandardDesc": "Delivered in your selected window. No rush fee.",
|
"scheduledDeliveryStandardDesc": "Delivered in your selected window. No rush fee.",
|
||||||
"serviceFee": "Service fee",
|
"serviceFee": "Service fee",
|
||||||
"shippingFee": "Delivery fee",
|
"shippingFee": "Delivery fee",
|
||||||
"subtotalBar": "Subtotal",
|
"subtotalBar": "Subtotal",
|
||||||
"subtotalOneLine": "Subtotal: $ {amount}",
|
"subtotalOneLinePrefix": "Subtotal: $ ",
|
||||||
"subtotalWithPieces": "Items total ({count})",
|
"subtotalWithPiecesPrefix": "Items total (",
|
||||||
"itemsGoodsTotalWithPrice": "{count} items total ${amount}",
|
"subtotalWithPiecesSuffix": ")",
|
||||||
|
"itemsGoodsTotalWithPriceMiddle": " items total $",
|
||||||
"useDiscount": "Discounts",
|
"useDiscount": "Discounts",
|
||||||
"appointmentDelivery": "Appointment delivery",
|
"appointmentDelivery": "Appointment delivery",
|
||||||
"appointmentPickup": "Appointment pickup",
|
"appointmentPickup": "Appointment pickup",
|
||||||
"chooseTime": "Choose the time",
|
"chooseTime": "Choose the time",
|
||||||
|
"chooseTimeForStorePrefix": "Please choose a delivery time for \"",
|
||||||
|
"chooseTimeForStoreSuffix": "\"",
|
||||||
"chooseTips": "Choose tips",
|
"chooseTips": "Choose tips",
|
||||||
"contactPhone": "Contact phone",
|
"contactPhone": "Contact phone",
|
||||||
"deliveryPreference": "Delivery preference",
|
"deliveryPreference": "Delivery preference",
|
||||||
@@ -445,8 +554,10 @@
|
|||||||
"cancellationReasonDesc": "30 minutes without processing automatically agreed by the system.",
|
"cancellationReasonDesc": "30 minutes without processing automatically agreed by the system.",
|
||||||
"cancellationTime": "Cancellation time",
|
"cancellationTime": "Cancellation time",
|
||||||
"cancellationTitle": "Merchant processing in progress",
|
"cancellationTitle": "Merchant processing in progress",
|
||||||
|
"cancelSuccess": "Order cancelled successfully",
|
||||||
"cancelled": "Cancelled",
|
"cancelled": "Cancelled",
|
||||||
"deliveryAddress": "Delivery address",
|
"deliveryAddress": "Delivery address",
|
||||||
|
"deliveryDate": "Delivery date",
|
||||||
"deliveryPhotos": "Delivery of photos",
|
"deliveryPhotos": "Delivery of photos",
|
||||||
"deliveryTime": "Delivery time",
|
"deliveryTime": "Delivery time",
|
||||||
"beforeDeadline": " before",
|
"beforeDeadline": " before",
|
||||||
@@ -481,12 +592,88 @@
|
|||||||
"useCode": "Use code",
|
"useCode": "Use code",
|
||||||
"writeOff": "Meal Code",
|
"writeOff": "Meal Code",
|
||||||
"uploadPaidVoucher": "I've paid — upload proof",
|
"uploadPaidVoucher": "I've paid — upload proof",
|
||||||
"voucherSubmitSuccess": "Proof submitted",
|
"voucherSubmitSuccess": "Proof submitted — pending review",
|
||||||
"voucherSubmitFailed": "Submit failed, please try again",
|
"voucherSubmitFailed": "Submit failed, please try again",
|
||||||
"voucherUploadFailed": "Upload failed, please try again"
|
"voucherUploadFailed": "Upload failed, please try again",
|
||||||
|
"payMethodZip": "ZIP voucher payment",
|
||||||
|
"zipPayHint": "Scan the QR code to pay, then upload your payment proof",
|
||||||
|
"zipPayAmountLabel": "Amount due",
|
||||||
|
"zipPayAmountInvalid": "Invalid payment amount. Please return to checkout.",
|
||||||
|
"zipPayAmountChanged": "Payment group amount changed. Please upload proof again.",
|
||||||
|
"zipPayPendingReview": "A ZIP payment for this delivery date is pending review.",
|
||||||
|
"pleaseBindCreditCard": "No bank card linked. Please add a card before paying."
|
||||||
|
},
|
||||||
|
"energyMeal": {
|
||||||
|
"title": "Energy Meals",
|
||||||
|
"addAllToCart": "Add all to cart",
|
||||||
|
"empty": "No energy meals yet",
|
||||||
|
"unavailable": "This bundle is unavailable"
|
||||||
|
},
|
||||||
|
"groupCatering": {
|
||||||
|
"cancelAction": "Cancel Reservation",
|
||||||
|
"cancelConfirm": "Are you sure you want to cancel this group meal reservation?",
|
||||||
|
"cancelReasonPlaceholder": "Enter a cancellation reason (optional)",
|
||||||
|
"cancelSuccess": "Cancelled successfully",
|
||||||
|
"cancelTitle": "Cancel Reservation",
|
||||||
|
"contactNamePlaceholder": "Enter contact name",
|
||||||
|
"contactPhonePlaceholder": "Enter contact phone",
|
||||||
|
"detailTitle": "Reservation Details",
|
||||||
|
"fieldCancelReason": "Cancellation Reason",
|
||||||
|
"fieldContactName": "Contact Name",
|
||||||
|
"fieldContactPhone": "Contact Phone",
|
||||||
|
"fieldEstimatedTotal": "Estimated Total",
|
||||||
|
"fieldExpectedTime": "Expected Meal/Delivery Time",
|
||||||
|
"fieldHandleRemark": "Merchant Notes",
|
||||||
|
"fieldMerchant": "Merchant",
|
||||||
|
"fieldPeopleCount": "Headcount",
|
||||||
|
"fieldPerCapitaPrice": "Price Per Person",
|
||||||
|
"fieldRemark": "Remarks",
|
||||||
|
"fieldScene": "Occasion",
|
||||||
|
"intro": "Group meal reservations are inquiry requests sent to merchants. They do not go through cart checkout or payment. Fill in headcount, budget, and expected time, and the merchant will follow up with menu and pricing details.",
|
||||||
|
"listEmpty": "No group meal reservations yet",
|
||||||
|
"emptyAction": "Submit a Reservation",
|
||||||
|
"merchantEmpty": "No merchants available",
|
||||||
|
"peopleCountPlaceholder": "Enter headcount",
|
||||||
|
"perCapitaPricePlaceholder": "Enter price per person",
|
||||||
|
"remarkPlaceholder": "e.g. delivery address or dietary notes",
|
||||||
|
"sceneOptions": {
|
||||||
|
"birthday": "Birthday Party",
|
||||||
|
"company": "Company Meeting",
|
||||||
|
"other": "Other",
|
||||||
|
"party": "Party / Celebration",
|
||||||
|
"school": "School Event"
|
||||||
|
},
|
||||||
|
"scenePlaceholder": "Custom occasion",
|
||||||
|
"selectDate": "Select date",
|
||||||
|
"selectExpectedTime": "Select expected time",
|
||||||
|
"selectMerchant": "Select merchant",
|
||||||
|
"selectTime": "Select time",
|
||||||
|
"statusCancelled": "Cancelled by User",
|
||||||
|
"statusCompleted": "Completed",
|
||||||
|
"statusConfirmed": "Confirmed",
|
||||||
|
"statusContacted": "Contacted",
|
||||||
|
"statusPending": "Pending",
|
||||||
|
"statusRejected": "Rejected",
|
||||||
|
"submitAction": "Submit Reservation",
|
||||||
|
"submitSuccess": "Submitted successfully",
|
||||||
|
"tabList": "My Reservations",
|
||||||
|
"tabSubmit": "New Reservation",
|
||||||
|
"tips": "Note: Estimated total = headcount × price per person. Final pricing is subject to merchant confirmation.",
|
||||||
|
"title": "Catering & Group Orders",
|
||||||
|
"validation": {
|
||||||
|
"contactName": "Please enter contact name",
|
||||||
|
"contactPhone": "Please enter contact phone",
|
||||||
|
"expectedTime": "Please select expected meal/delivery time",
|
||||||
|
"merchant": "Please select a merchant",
|
||||||
|
"peopleCount": "Headcount must be greater than 0",
|
||||||
|
"perCapitaPrice": "Price per person cannot be less than 0",
|
||||||
|
"scene": "Please enter an occasion"
|
||||||
|
},
|
||||||
|
"viewAndCancel": "View details / cancellable"
|
||||||
},
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"addToCart": "Add to cart",
|
"addToCart": "Add to cart",
|
||||||
|
"quantity": "Quantity",
|
||||||
"appetizers": "Appetizers",
|
"appetizers": "Appetizers",
|
||||||
"merchantDiscounts": "Merchant discounts",
|
"merchantDiscounts": "Merchant discounts",
|
||||||
"claimNow": "Claim now",
|
"claimNow": "Claim now",
|
||||||
@@ -548,7 +735,7 @@
|
|||||||
"allergen": "Allergen",
|
"allergen": "Allergen",
|
||||||
"weeklySales": "Weekly sales",
|
"weeklySales": "Weekly sales",
|
||||||
"forYou": "Recommended for you",
|
"forYou": "Recommended for you",
|
||||||
"memberPriceLabel": "{name} member price",
|
"memberPriceLabelSuffix": " member price",
|
||||||
"dash": "—",
|
"dash": "—",
|
||||||
"lbUnit": "lb",
|
"lbUnit": "lb",
|
||||||
"hotSalesBadge": "Fresh picks",
|
"hotSalesBadge": "Fresh picks",
|
||||||
@@ -566,6 +753,7 @@
|
|||||||
"tips3": "membership to enjoy a ",
|
"tips3": "membership to enjoy a ",
|
||||||
"tips4": "Enjoy delivery service over",
|
"tips4": "Enjoy delivery service over",
|
||||||
"tips5": "Delivery fee",
|
"tips5": "Delivery fee",
|
||||||
|
"deliveryScheduleDaysPrefix": "Delivery days: ",
|
||||||
"start": " and up",
|
"start": " and up",
|
||||||
"title": "The minimum order amount for this store is",
|
"title": "The minimum order amount for this store is",
|
||||||
"toast": {
|
"toast": {
|
||||||
@@ -624,8 +812,9 @@
|
|||||||
"viewCart": "View Shopping Cart",
|
"viewCart": "View Shopping Cart",
|
||||||
"viewStore": "View Store",
|
"viewStore": "View Store",
|
||||||
"localDelivery": "Local delivery",
|
"localDelivery": "Local delivery",
|
||||||
"deliveryUnified": "Orders are delivered by {name}.",
|
"deliveryUnifiedPrefix": "Orders are delivered by ",
|
||||||
"itemsTotalWithPrice": "{count} items total ${amount}",
|
"deliveryUnifiedSuffix": ".",
|
||||||
|
"itemsTotalWithPriceMiddle": " items total $",
|
||||||
"deliveryFeeLine": "Delivery",
|
"deliveryFeeLine": "Delivery",
|
||||||
"serviceFeeLine": "Service fee",
|
"serviceFeeLine": "Service fee",
|
||||||
"subtotalLine": "Subtotal",
|
"subtotalLine": "Subtotal",
|
||||||
@@ -636,9 +825,13 @@
|
|||||||
"removeCartTitle": "Delete shopping cart?",
|
"removeCartTitle": "Delete shopping cart?",
|
||||||
"removeCartDesc": "Are you sure you want to delete this cart?",
|
"removeCartDesc": "Are you sure you want to delete this cart?",
|
||||||
"removeProductTitle": "Remove the product?",
|
"removeProductTitle": "Remove the product?",
|
||||||
"removeProductDesc": "Are you sure you want to remove {name} from your shopping cart?",
|
"removeProductDescPrefix": "Are you sure you want to remove ",
|
||||||
|
"removeProductDescSuffix": " from your shopping cart?",
|
||||||
"emptyTitle": "Your cart is empty",
|
"emptyTitle": "Your cart is empty",
|
||||||
"emptyAction": "Explore Restaurants"
|
"emptyAction": "Explore Restaurants",
|
||||||
|
"orderNoticeTitle": "Order Notice",
|
||||||
|
"multiStoreCheckoutNoticeTitle": "Check delivery dates before checkout",
|
||||||
|
"multiStoreCheckoutNoticeDesc": "Your cart contains multiple stores, and items may have different delivery dates. Please confirm each item's delivery date before placing the order."
|
||||||
},
|
},
|
||||||
"choosePaymethod": {
|
"choosePaymethod": {
|
||||||
"creditCard": "Credit card payment",
|
"creditCard": "Credit card payment",
|
||||||
@@ -666,7 +859,8 @@
|
|||||||
"coupon": {
|
"coupon": {
|
||||||
"all-merchants": "Applicable to all merchants",
|
"all-merchants": "Applicable to all merchants",
|
||||||
"expiry-date": "Expiry date: ",
|
"expiry-date": "Expiry date: ",
|
||||||
"merchant-only": "Only available at {name}",
|
"merchantOnlyPrefix": "Only available at ",
|
||||||
|
"merchantOnlySuffix": "",
|
||||||
"merchant-specific": "For specific merchant use",
|
"merchant-specific": "For specific merchant use",
|
||||||
"no-coupons": "You currently do not have any coupons",
|
"no-coupons": "You currently do not have any coupons",
|
||||||
"redeem-now": "Redeem now",
|
"redeem-now": "Redeem now",
|
||||||
@@ -689,8 +883,10 @@
|
|||||||
"paymentMethod": "Payment method",
|
"paymentMethod": "Payment method",
|
||||||
"renewalNow": "Renewal now",
|
"renewalNow": "Renewal now",
|
||||||
"weeks": "weeks",
|
"weeks": "weeks",
|
||||||
"billedAnnually": "Billed at ${amount}/year",
|
"billedAnnuallyPrefix": "Billed at $",
|
||||||
"savingsPerYear": "It can save an additional ${amount} per year."
|
"billedAnnuallySuffix": "/year",
|
||||||
|
"savingsPerYearPrefix": "It can save an additional $",
|
||||||
|
"savingsPerYearSuffix": " per year."
|
||||||
},
|
},
|
||||||
"member-center": {
|
"member-center": {
|
||||||
"chooseLike": "Choose 3 items you like",
|
"chooseLike": "Choose 3 items you like",
|
||||||
@@ -725,10 +921,22 @@
|
|||||||
"password-length-limit": "Password must be 8-16 characters long"
|
"password-length-limit": "Password must be 8-16 characters long"
|
||||||
},
|
},
|
||||||
"recharge": {
|
"recharge": {
|
||||||
|
"activityDetailTitle": "Recharge Promotion",
|
||||||
|
"activityPlaceholderDesc": "Promotion details are coming soon.",
|
||||||
|
"activityRechargeBtn": "Deposit Now",
|
||||||
|
"activityRulesTitle": "Promotion Rules",
|
||||||
"amount": "Recharge amount",
|
"amount": "Recharge amount",
|
||||||
"amount-invalid": "The recharge amount cannot be less than 0",
|
"amount-invalid": "The recharge amount cannot be less than 0",
|
||||||
"description": "Please enter your recharge amount",
|
"description": "Please enter your recharge amount",
|
||||||
"pay-method": "Please select a payment method",
|
"pay-method": "Please select a payment method",
|
||||||
|
"payMethodStripe": "Credit card (Stripe)",
|
||||||
|
"payMethodZip": "ZIP voucher payment",
|
||||||
|
"zipPayHint": "Scan the QR code to pay, then upload your payment proof",
|
||||||
|
"uploadVoucher": "Upload payment proof",
|
||||||
|
"voucherRequired": "Please upload payment proof first",
|
||||||
|
"voucherUploaded": "Proof uploaded",
|
||||||
|
"zipSubmitSuccess": "Proof submitted — pending review",
|
||||||
|
"promotionTier": "Deposit {recharge}, get {gift} bonus",
|
||||||
"success": "Recharge successful",
|
"success": "Recharge successful",
|
||||||
"title": "Recharge Balance"
|
"title": "Recharge Balance"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": "设置支付密码",
|
||||||
@@ -219,7 +222,8 @@
|
|||||||
"browse": {
|
"browse": {
|
||||||
"brandTag": "CHEFLINK 严选",
|
"brandTag": "CHEFLINK 严选",
|
||||||
"moreRecipes": "更多食谱",
|
"moreRecipes": "更多食谱",
|
||||||
"titleCuisine": "附近的美食",
|
"nearbyEmpty": "附近暂无美食",
|
||||||
|
"titleCuisine": "附近美食",
|
||||||
"titleRecipes": "选择食谱"
|
"titleRecipes": "选择食谱"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
@@ -228,13 +232,106 @@
|
|||||||
"deliveryFee": "配送费",
|
"deliveryFee": "配送费",
|
||||||
"featured-on": "精选商家",
|
"featured-on": "精选商家",
|
||||||
"featured-dishes": "精选菜品",
|
"featured-dishes": "精选菜品",
|
||||||
"nearby-merchants": "附近商家"
|
"nearby-merchants": "附近商家",
|
||||||
|
"open-member": "开通会员",
|
||||||
|
"recharge-now": "立即充值",
|
||||||
|
"quickTabs": {
|
||||||
|
"memberZone": "会员精选",
|
||||||
|
"liveSeafoodAir": "限量活海鲜",
|
||||||
|
"mustEatList": "必吃榜单",
|
||||||
|
"newCalendar": "上新日历",
|
||||||
|
"newCalendarNav": "今日上新",
|
||||||
|
"freshSeafoodToday": "今日现打海鲜",
|
||||||
|
"groupCatering": "团餐预定",
|
||||||
|
"energyMeal": "能量餐食"
|
||||||
|
},
|
||||||
|
"mustEatListTabs": {
|
||||||
|
"merchant": "商家榜单",
|
||||||
|
"dish": "产品榜单",
|
||||||
|
"all": "综合"
|
||||||
|
},
|
||||||
|
"noticeBar": {
|
||||||
|
"thursday": "每周四:好好鲜生生鲜 + 纽约店铺配送",
|
||||||
|
"sunday": "每周日:波士顿本地店铺配送",
|
||||||
|
"freshCatch": "渔船现打海鲜将根据当天捕捞情况临时上新,数量有限,售完即止。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "正在检索商品...",
|
||||||
|
"toolStatusWithSizePrefix": "正在调用 ",
|
||||||
|
"toolStatusWithSizeMiddle": ",共 ",
|
||||||
|
"toolStatusWithSizeSuffix": " 条",
|
||||||
|
"emptyResult": "已完成,但未返回可展示内容。",
|
||||||
|
"requestFailed": "请求失败,请稍后重试。",
|
||||||
|
"recommendCountPrefix": "推荐商品 ",
|
||||||
|
"recommendCountSuffix": " 件",
|
||||||
|
"recommendSource": "来自 AI 实时筛选",
|
||||||
|
"summaryFoundPrefix": "找到 ",
|
||||||
|
"summaryFoundSuffix": " 件商品",
|
||||||
|
"summaryBudgetPrefix": "预估 ",
|
||||||
|
"summaryBudgetMiddle1": ",实际 ",
|
||||||
|
"summaryBudgetMiddle2": ",优惠 ",
|
||||||
|
"summaryBudgetSuffix": " 元",
|
||||||
|
"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": "分享专属二维码邀请新用户注册即邀请成功。一起来注册体验点餐功能。",
|
||||||
"collection": "我的收藏",
|
"collection": "我的收藏",
|
||||||
"collectionBatchDeleteConfirm": "确定删除已选中的 {count} 条收藏?",
|
"collectionBatchDeleteConfirmPrefix": "确定删除已选中的 ",
|
||||||
|
"collectionBatchDeleteConfirmSuffix": " 条收藏?",
|
||||||
"collectionBatchModeHint": "请选择要删除的收藏,再次点击垃圾桶执行删除",
|
"collectionBatchModeHint": "请选择要删除的收藏,再次点击垃圾桶执行删除",
|
||||||
"collectionSelectFirst": "请先选择要删除的收藏",
|
"collectionSelectFirst": "请先选择要删除的收藏",
|
||||||
"complaintsAndSuggestions": "投诉建议",
|
"complaintsAndSuggestions": "投诉建议",
|
||||||
@@ -274,7 +371,8 @@
|
|||||||
"confirmReady": "确认/已准备",
|
"confirmReady": "确认/已准备",
|
||||||
"onTheWay": "在路上",
|
"onTheWay": "在路上",
|
||||||
"title": "订单",
|
"title": "订单",
|
||||||
"totalItemCount": "共{count}件"
|
"totalItemCountPrefix": "共",
|
||||||
|
"totalItemCountSuffix": "件"
|
||||||
},
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"all": "全部"
|
"all": "全部"
|
||||||
@@ -379,15 +477,22 @@
|
|||||||
"addCoupon": "添加优惠券",
|
"addCoupon": "添加优惠券",
|
||||||
"confirmOrder": "确认订单",
|
"confirmOrder": "确认订单",
|
||||||
"couponInputPlaceholder": "请输入优惠券",
|
"couponInputPlaceholder": "请输入优惠券",
|
||||||
"deliverBefore": "将在 {time} 之前送达",
|
"deliverBeforePrefix": "将在 ",
|
||||||
|
"deliverBeforeSuffix": " 之前送达",
|
||||||
"deliveryInfo": "配送信息",
|
"deliveryInfo": "配送信息",
|
||||||
"driverTip": "司机小费",
|
"driverTip": "司机小费",
|
||||||
"driverTipNote": "您所支付的小费 100% 归司机所有",
|
"driverTipNote": "您所支付的小费 100% 归司机所有",
|
||||||
|
"tipByDeliveryDatePrefix": "配送日期 ",
|
||||||
|
"tipNextTime": "下次再说吧",
|
||||||
"fillAddressHint": "请填写您的送货地址及联系方式",
|
"fillAddressHint": "请填写您的送货地址及联系方式",
|
||||||
"freeTag": "免费",
|
"freeTag": "免费",
|
||||||
"localDelivery": "本地配送",
|
"localDelivery": "本地配送",
|
||||||
"localDeliverySubtitle": "商品将由 {name} 统一配送!",
|
"localDeliverySubtitlePrefix": "商品将由 ",
|
||||||
"memberThanks": "感谢您成为 {name} 会员。",
|
"localDeliverySubtitleSuffix": " 统一配送!",
|
||||||
|
"memberThanksPrefix": "感谢您成为 ",
|
||||||
|
"memberThanksSuffix": " 会员。",
|
||||||
|
"missingParamsHint": "页面参数缺失,请返回购物车重新结算",
|
||||||
|
"emptyCartHint": "购物车暂无商品,请返回添加后再结算",
|
||||||
"orderTotalLine": "订单总计",
|
"orderTotalLine": "订单总计",
|
||||||
"orderBreakdown": "订单明细",
|
"orderBreakdown": "订单明细",
|
||||||
"pay": "付款",
|
"pay": "付款",
|
||||||
@@ -396,20 +501,24 @@
|
|||||||
"pickupTitle": "门店自取",
|
"pickupTitle": "门店自取",
|
||||||
"scheduledDeliveryHint": "若未在承诺时间内送达,运费将予以退还。",
|
"scheduledDeliveryHint": "若未在承诺时间内送达,运费将予以退还。",
|
||||||
"scheduledDeliveryTitle": "定时送达服务",
|
"scheduledDeliveryTitle": "定时送达服务",
|
||||||
"scheduledDeliveryWindow": "{time} 前送达",
|
"scheduledDeliveryWindowPrefix": "",
|
||||||
|
"scheduledDeliveryWindowSuffix": " 前送达",
|
||||||
"scheduledDeliveryAddon": "+ $ 2.99",
|
"scheduledDeliveryAddon": "+ $ 2.99",
|
||||||
"scheduledDeliveryStandardTitle": "标准送达",
|
"scheduledDeliveryStandardTitle": "标准送达",
|
||||||
"scheduledDeliveryStandardDesc": "按预约时段配送,无加急费用。",
|
"scheduledDeliveryStandardDesc": "按预约时段配送,无加急费用。",
|
||||||
"serviceFee": "服务费",
|
"serviceFee": "服务费",
|
||||||
"shippingFee": "运费",
|
"shippingFee": "运费",
|
||||||
"subtotalBar": "小计",
|
"subtotalBar": "小计",
|
||||||
"subtotalOneLine": "小计: $ {amount}",
|
"subtotalOneLinePrefix": "小计: $ ",
|
||||||
"subtotalWithPieces": "总计({count}件)",
|
"subtotalWithPiecesPrefix": "总计(",
|
||||||
"itemsGoodsTotalWithPrice": "{count}件商品总共${amount}",
|
"subtotalWithPiecesSuffix": "件)",
|
||||||
|
"itemsGoodsTotalWithPriceMiddle": "件商品总共$",
|
||||||
"useDiscount": "使用优惠",
|
"useDiscount": "使用优惠",
|
||||||
"appointmentDelivery": "预约配送",
|
"appointmentDelivery": "预约配送",
|
||||||
"appointmentPickup": "预约自取",
|
"appointmentPickup": "预约自取",
|
||||||
"chooseTime": "选择时间",
|
"chooseTime": "选择时间",
|
||||||
|
"chooseTimeForStorePrefix": "请为「",
|
||||||
|
"chooseTimeForStoreSuffix": "」选择配送时间",
|
||||||
"chooseTips": "选择小费",
|
"chooseTips": "选择小费",
|
||||||
"contactPhone": "联系电话",
|
"contactPhone": "联系电话",
|
||||||
"deliveryPreference": "配送偏好",
|
"deliveryPreference": "配送偏好",
|
||||||
@@ -445,8 +554,10 @@
|
|||||||
"cancellationReasonDesc": "30分钟不处理,系统自动同意",
|
"cancellationReasonDesc": "30分钟不处理,系统自动同意",
|
||||||
"cancellationTime": "取消时间",
|
"cancellationTime": "取消时间",
|
||||||
"cancellationTitle": "商户处理中",
|
"cancellationTitle": "商户处理中",
|
||||||
|
"cancelSuccess": "取消订单成功",
|
||||||
"cancelled": "已取消",
|
"cancelled": "已取消",
|
||||||
"deliveryAddress": "配送地址",
|
"deliveryAddress": "配送地址",
|
||||||
|
"deliveryDate": "配送日期",
|
||||||
"deliveryPhotos": "送达照片",
|
"deliveryPhotos": "送达照片",
|
||||||
"deliveryTime": "送达时间",
|
"deliveryTime": "送达时间",
|
||||||
"beforeDeadline": "前",
|
"beforeDeadline": "前",
|
||||||
@@ -481,12 +592,88 @@
|
|||||||
"useCode": "核销码",
|
"useCode": "核销码",
|
||||||
"writeOff": "核销",
|
"writeOff": "核销",
|
||||||
"uploadPaidVoucher": "我已支付,上传凭证",
|
"uploadPaidVoucher": "我已支付,上传凭证",
|
||||||
"voucherSubmitSuccess": "凭证已提交",
|
"voucherSubmitSuccess": "凭证已提交,待审核",
|
||||||
"voucherSubmitFailed": "提交失败,请重试",
|
"voucherSubmitFailed": "提交失败,请重试",
|
||||||
"voucherUploadFailed": "上传失败,请重试"
|
"voucherUploadFailed": "上传失败,请重试",
|
||||||
|
"payMethodZip": "ZIP 凭证支付",
|
||||||
|
"zipPayHint": "请扫码完成转账后,上传支付凭证截图",
|
||||||
|
"zipPayAmountLabel": "应付金额",
|
||||||
|
"zipPayAmountInvalid": "支付金额无效,请返回结算页重新确认",
|
||||||
|
"zipPayAmountChanged": "支付组金额已变化,请重新上传凭证",
|
||||||
|
"zipPayPendingReview": "该配送日期已有 ZIP 支付待审核,请等待审核",
|
||||||
|
"pleaseBindCreditCard": "未绑定银行卡,请先绑定后再支付"
|
||||||
|
},
|
||||||
|
"energyMeal": {
|
||||||
|
"title": "能量餐",
|
||||||
|
"addAllToCart": "一键加入购物车",
|
||||||
|
"empty": "暂无能量餐",
|
||||||
|
"unavailable": "套餐暂不可购买"
|
||||||
|
},
|
||||||
|
"groupCatering": {
|
||||||
|
"cancelAction": "取消预定",
|
||||||
|
"cancelConfirm": "确定要取消这条团餐预定吗?",
|
||||||
|
"cancelReasonPlaceholder": "请输入取消原因(选填)",
|
||||||
|
"cancelSuccess": "取消成功",
|
||||||
|
"cancelTitle": "取消预定",
|
||||||
|
"contactNamePlaceholder": "请输入联系人",
|
||||||
|
"contactPhonePlaceholder": "请输入联系电话",
|
||||||
|
"detailTitle": "预定详情",
|
||||||
|
"fieldCancelReason": "取消原因",
|
||||||
|
"fieldContactName": "联系人",
|
||||||
|
"fieldContactPhone": "联系电话",
|
||||||
|
"fieldEstimatedTotal": "预计总价",
|
||||||
|
"fieldExpectedTime": "期望用餐/配送时间",
|
||||||
|
"fieldHandleRemark": "商家备注",
|
||||||
|
"fieldMerchant": "选择商家",
|
||||||
|
"fieldPeopleCount": "团餐人数",
|
||||||
|
"fieldPerCapitaPrice": "人均单价",
|
||||||
|
"fieldRemark": "备注",
|
||||||
|
"fieldScene": "用餐场景",
|
||||||
|
"intro": "团餐预定是向商家提交的咨询线索,不进入购物车或支付流程。请填写人数、预算和期望时间,商家会与您进一步沟通菜单与报价。",
|
||||||
|
"listEmpty": "暂无团餐预定记录",
|
||||||
|
"emptyAction": "去提交预定",
|
||||||
|
"merchantEmpty": "暂无可选商家",
|
||||||
|
"peopleCountPlaceholder": "请输入人数",
|
||||||
|
"perCapitaPricePlaceholder": "请输入人均单价",
|
||||||
|
"remarkPlaceholder": "例如配送地址、忌口要求等",
|
||||||
|
"sceneOptions": {
|
||||||
|
"birthday": "生日宴",
|
||||||
|
"company": "公司会议",
|
||||||
|
"other": "其他",
|
||||||
|
"party": "派对宴请",
|
||||||
|
"school": "学校活动"
|
||||||
|
},
|
||||||
|
"scenePlaceholder": "可自定义场景",
|
||||||
|
"selectDate": "选择日期",
|
||||||
|
"selectExpectedTime": "请选择期望时间",
|
||||||
|
"selectMerchant": "请选择商家",
|
||||||
|
"selectTime": "选择时间",
|
||||||
|
"statusCancelled": "用户取消",
|
||||||
|
"statusCompleted": "已完成",
|
||||||
|
"statusConfirmed": "已确认",
|
||||||
|
"statusContacted": "已联系",
|
||||||
|
"statusPending": "待处理",
|
||||||
|
"statusRejected": "已拒绝",
|
||||||
|
"submitAction": "提交预定",
|
||||||
|
"submitSuccess": "提交成功",
|
||||||
|
"tabList": "我的预定",
|
||||||
|
"tabSubmit": "提交预定",
|
||||||
|
"tips": "温馨提示:预计总价 = 团餐人数 × 人均单价,最终以商家确认报价为准。",
|
||||||
|
"title": "团餐预定",
|
||||||
|
"validation": {
|
||||||
|
"contactName": "请输入联系人",
|
||||||
|
"contactPhone": "请输入联系电话",
|
||||||
|
"expectedTime": "请选择期望用餐/配送时间",
|
||||||
|
"merchant": "请选择商家",
|
||||||
|
"peopleCount": "团餐人数必须大于 0",
|
||||||
|
"perCapitaPrice": "人均单价不能小于 0",
|
||||||
|
"scene": "请输入用餐场景"
|
||||||
|
},
|
||||||
|
"viewAndCancel": "查看详情 / 可取消"
|
||||||
},
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"addToCart": "加入购物车",
|
"addToCart": "加入购物车",
|
||||||
|
"quantity": "数量",
|
||||||
"appetizers": "开胃菜",
|
"appetizers": "开胃菜",
|
||||||
"merchantDiscounts": "商家折扣",
|
"merchantDiscounts": "商家折扣",
|
||||||
"claimNow": "立即领取",
|
"claimNow": "立即领取",
|
||||||
@@ -548,7 +735,7 @@
|
|||||||
"allergen": "过敏源",
|
"allergen": "过敏源",
|
||||||
"weeklySales": "周销量",
|
"weeklySales": "周销量",
|
||||||
"forYou": "为您推荐",
|
"forYou": "为您推荐",
|
||||||
"memberPriceLabel": "{name}会员价",
|
"memberPriceLabelSuffix": "会员价",
|
||||||
"dash": "—",
|
"dash": "—",
|
||||||
"lbUnit": "磅",
|
"lbUnit": "磅",
|
||||||
"hotSalesBadge": "生鲜热销榜",
|
"hotSalesBadge": "生鲜热销榜",
|
||||||
@@ -566,6 +753,7 @@
|
|||||||
"tips3": "会员可享受",
|
"tips3": "会员可享受",
|
||||||
"tips4": "最低起送金额",
|
"tips4": "最低起送金额",
|
||||||
"tips5": "配送费",
|
"tips5": "配送费",
|
||||||
|
"deliveryScheduleDaysPrefix": "配送日:",
|
||||||
"start": "起",
|
"start": "起",
|
||||||
"title": "这家店的起送金额是",
|
"title": "这家店的起送金额是",
|
||||||
"toast": {
|
"toast": {
|
||||||
@@ -624,8 +812,9 @@
|
|||||||
"viewCart": "查看购物车",
|
"viewCart": "查看购物车",
|
||||||
"viewStore": "查看店铺",
|
"viewStore": "查看店铺",
|
||||||
"localDelivery": "本地配送",
|
"localDelivery": "本地配送",
|
||||||
"deliveryUnified": "商品将由 {name} 统一配送!",
|
"deliveryUnifiedPrefix": "商品将由 ",
|
||||||
"itemsTotalWithPrice": "{count}件商品总共${amount}",
|
"deliveryUnifiedSuffix": " 统一配送!",
|
||||||
|
"itemsTotalWithPriceMiddle": "件商品总共$",
|
||||||
"deliveryFeeLine": "运费",
|
"deliveryFeeLine": "运费",
|
||||||
"serviceFeeLine": "服务费",
|
"serviceFeeLine": "服务费",
|
||||||
"subtotalLine": "小计",
|
"subtotalLine": "小计",
|
||||||
@@ -636,9 +825,13 @@
|
|||||||
"removeCartTitle": "删除购物车?",
|
"removeCartTitle": "删除购物车?",
|
||||||
"removeCartDesc": "确定要删除该店铺购物车吗?",
|
"removeCartDesc": "确定要删除该店铺购物车吗?",
|
||||||
"removeProductTitle": "移除商品?",
|
"removeProductTitle": "移除商品?",
|
||||||
"removeProductDesc": "确定要将 {name} 从购物车中移除吗?",
|
"removeProductDescPrefix": "确定要将 ",
|
||||||
|
"removeProductDescSuffix": " 从购物车中移除吗?",
|
||||||
"emptyTitle": "购物车为空",
|
"emptyTitle": "购物车为空",
|
||||||
"emptyAction": "去逛逛餐厅"
|
"emptyAction": "去逛逛餐厅",
|
||||||
|
"orderNoticeTitle": "下单须知",
|
||||||
|
"multiStoreCheckoutNoticeTitle": "下单前请确认配送日期",
|
||||||
|
"multiStoreCheckoutNoticeDesc": "当前购物车有多个店铺,商品可能不是同一天配送。请先确认每个商品的配送日期,再提交订单。"
|
||||||
},
|
},
|
||||||
"choosePaymethod": {
|
"choosePaymethod": {
|
||||||
"creditCard": "信用卡支付",
|
"creditCard": "信用卡支付",
|
||||||
@@ -666,7 +859,8 @@
|
|||||||
"coupon": {
|
"coupon": {
|
||||||
"all-merchants": "适用于所有商户使用",
|
"all-merchants": "适用于所有商户使用",
|
||||||
"expiry-date": "到期日:",
|
"expiry-date": "到期日:",
|
||||||
"merchant-only": "仅{name}可用",
|
"merchantOnlyPrefix": "仅",
|
||||||
|
"merchantOnlySuffix": "可用",
|
||||||
"merchant-specific": "指定商户使用",
|
"merchant-specific": "指定商户使用",
|
||||||
"no-coupons": "您目前没有任何优惠券",
|
"no-coupons": "您目前没有任何优惠券",
|
||||||
"redeem-now": "立即兑换",
|
"redeem-now": "立即兑换",
|
||||||
@@ -689,8 +883,10 @@
|
|||||||
"paymentMethod": "支付方式",
|
"paymentMethod": "支付方式",
|
||||||
"renewalNow": "立即加入",
|
"renewalNow": "立即加入",
|
||||||
"weeks": "周",
|
"weeks": "周",
|
||||||
"billedAnnually": "按${amount}/年 结算",
|
"billedAnnuallyPrefix": "按$",
|
||||||
"savingsPerYear": "它每年可以额外节省${amount}"
|
"billedAnnuallySuffix": "/年 结算",
|
||||||
|
"savingsPerYearPrefix": "它每年可以额外节省$",
|
||||||
|
"savingsPerYearSuffix": ""
|
||||||
},
|
},
|
||||||
"member-center": {
|
"member-center": {
|
||||||
"chooseLike": "选择您喜欢的3个项目",
|
"chooseLike": "选择您喜欢的3个项目",
|
||||||
@@ -725,10 +921,22 @@
|
|||||||
"password-length-limit": "密码长度必须为8-16位"
|
"password-length-limit": "密码长度必须为8-16位"
|
||||||
},
|
},
|
||||||
"recharge": {
|
"recharge": {
|
||||||
|
"activityDetailTitle": "充值活动",
|
||||||
|
"activityPlaceholderDesc": "活动详情即将上线,敬请期待。",
|
||||||
|
"activityRechargeBtn": "立即充值",
|
||||||
|
"activityRulesTitle": "活动规则",
|
||||||
"amount": "充值金额",
|
"amount": "充值金额",
|
||||||
"amount-invalid": "充值金额不能小于0",
|
"amount-invalid": "充值金额不能小于0",
|
||||||
"description": "请输入您的充值金额",
|
"description": "请输入您的充值金额",
|
||||||
"pay-method": "请选择支付方式",
|
"pay-method": "请选择支付方式",
|
||||||
|
"payMethodStripe": "信用卡(Stripe)",
|
||||||
|
"payMethodZip": "ZIP 凭证支付",
|
||||||
|
"zipPayHint": "请扫码完成转账后,上传支付凭证截图",
|
||||||
|
"uploadVoucher": "上传支付凭证",
|
||||||
|
"voucherRequired": "请先上传支付凭证",
|
||||||
|
"voucherUploaded": "凭证已上传",
|
||||||
|
"zipSubmitSuccess": "已提交凭证,等待后台审核",
|
||||||
|
"promotionTier": "充{recharge}送{gift}",
|
||||||
"success": "充值成功",
|
"success": "充值成功",
|
||||||
"title": "余额充值"
|
"title": "余额充值"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"name" : "CHEFLINK delivery",
|
"name" : "CHEFLINK delivery",
|
||||||
"appid" : "__UNI__06509BE",
|
"appid" : "__UNI__06509BE",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "3.1.2",
|
"versionName" : "3.3.2",
|
||||||
"versionCode" : 312,
|
"versionCode" : 332,
|
||||||
"transformPx" : false,
|
"transformPx" : false,
|
||||||
/* 5+App特有相关 */
|
/* 5+App特有相关 */
|
||||||
"app-plus" : {
|
"app-plus" : {
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { debounce } from 'throttle-debounce'
|
||||||
|
import { getFeaturedDishList } from '@/pages-store/service'
|
||||||
|
import { appCollectCollectPost } from '@/service'
|
||||||
|
import { CollectionType } from '@/constant/enums'
|
||||||
|
import { useConfigStore } from '@/store'
|
||||||
|
import { formatSalesCount } from '@/utils/utils'
|
||||||
|
import {
|
||||||
|
getFeaturedDishImage,
|
||||||
|
getFeaturedDishPromoLabel,
|
||||||
|
isFeaturedDishSoldOut,
|
||||||
|
parseFeaturedDishListRes,
|
||||||
|
} from '@/utils/featuredDish'
|
||||||
|
import type { FeaturedDishQueryBody, RouteQueryMap } from '../utils/featured-dish-query'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
buildQuery: (routeQuery?: RouteQueryMap) => FeaturedDishQueryBody
|
||||||
|
routeQuery?: RouteQueryMap
|
||||||
|
pageTitle?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const paging = ref<ZPagingInstance | null>(null)
|
||||||
|
const dataList = ref<Record<string, unknown>[]>([])
|
||||||
|
|
||||||
|
async function onQuery(pageNum: number, pageSize: number) {
|
||||||
|
try {
|
||||||
|
const filterBody = props.buildQuery(props.routeQuery)
|
||||||
|
const res = await getFeaturedDishList({
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
...filterBody,
|
||||||
|
})
|
||||||
|
const { rows, total } = parseFeaturedDishListRes(res)
|
||||||
|
await paging.value?.completeByTotal(rows, total)
|
||||||
|
} catch {
|
||||||
|
await paging.value?.complete(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuredDishColumns = computed(() => {
|
||||||
|
const list = dataList.value
|
||||||
|
return [
|
||||||
|
list.filter((_, i) => i % 2 === 0),
|
||||||
|
list.filter((_, i) => i % 2 === 1),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
function navigateToDishes(item: Record<string, unknown>) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-store/pages/store/dishes?id=${item.id}&storeId=${item.merchantId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectDish = debounce(
|
||||||
|
1300,
|
||||||
|
(item: Record<string, unknown>) => {
|
||||||
|
appCollectCollectPost({
|
||||||
|
body: {
|
||||||
|
targetId: String(item.id),
|
||||||
|
targetType: CollectionType.DISH,
|
||||||
|
},
|
||||||
|
}).then(() => {
|
||||||
|
item.isCollect = !item.isCollect
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ atBegin: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
refresh: () => paging.value?.refresh(),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<z-paging
|
||||||
|
ref="paging"
|
||||||
|
v-model="dataList"
|
||||||
|
bg-color="#f2f2f2"
|
||||||
|
:auto="true"
|
||||||
|
@query="onQuery"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<navbar :title="pageTitle || ''" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view class="featured-dishes-section px-24rpx pb-40rpx pt-16rpx">
|
||||||
|
<view class="waterfall-row flex gap-16rpx items-start">
|
||||||
|
<view
|
||||||
|
v-for="(col, colIndex) in featuredDishColumns"
|
||||||
|
:key="colIndex"
|
||||||
|
class="waterfall-col flex-1 min-w-0 flex flex-col gap-16rpx"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
v-for="item in col"
|
||||||
|
:key="String(item.id ?? item.merchantId) + '-' + String(item.dishName)"
|
||||||
|
class="featured-dish-card w-full"
|
||||||
|
@click="navigateToDishes(item)"
|
||||||
|
>
|
||||||
|
<view class="featured-dish-image">
|
||||||
|
<view v-if="item.isNew == 1" class="dish-new-ribbon">
|
||||||
|
<text class="dish-new-ribbon__text">NEW</text>
|
||||||
|
</view>
|
||||||
|
<image
|
||||||
|
:src="getFeaturedDishImage(item)"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="featured-dish-img"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-if="isFeaturedDishSoldOut(item.stock)"
|
||||||
|
class="featured-dish-sold-dim"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="isFeaturedDishSoldOut(item.stock)"
|
||||||
|
src="/static/app/images/SoldOut.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="featured-dish-sold-overlay"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
class="featured-dish-collect w-56rpx h-56rpx absolute z-4 top-12rpx right-12rpx center"
|
||||||
|
@click.stop="collectDish(item)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="!item.isCollect"
|
||||||
|
src="@img-store/1334.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="w-44rpx h-44rpx featured-dish-collect-icon"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-else
|
||||||
|
src="@img-store/1337.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="w-44rpx h-44rpx featured-dish-collect-icon"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="featured-dish-body">
|
||||||
|
<view class="featured-dish-meta flex items-start justify-between gap-12rpx mb-14rpx">
|
||||||
|
<view class="min-w-0 flex-1">
|
||||||
|
<text class="featured-dish-price">US${{ item.originalPrice }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="featured-dish-sales shrink-0">
|
||||||
|
{{ t('pages-store.store.sales') }}: {{ formatSalesCount(item.salesCount) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="featured-dish-title line-clamp-2 mb-16rpx">
|
||||||
|
{{ item.dishName }}
|
||||||
|
</view>
|
||||||
|
<view class="flex items-center justify-between gap-12rpx">
|
||||||
|
<view
|
||||||
|
v-if="Number(item.memberPrice) > 0"
|
||||||
|
class="featured-dish-member shrink min-w-0"
|
||||||
|
>
|
||||||
|
<text class="featured-dish-member-inner">
|
||||||
|
{{ t('pages-store.store.members') }}: US${{ item.memberPrice }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="flex-1 min-w-0"></view>
|
||||||
|
<view class="featured-dish-add shrink-0">
|
||||||
|
<image src="/static/app/images/add_cart.png" class="featured-dish-add__icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="getFeaturedDishPromoLabel(item)"
|
||||||
|
class="featured-dish-promo mt-16rpx"
|
||||||
|
>
|
||||||
|
<text class="featured-dish-promo-text">{{ getFeaturedDishPromoLabel(item) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template #bottom>
|
||||||
|
<view class="h-24rpx"></view>
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]"></view>
|
||||||
|
</template>
|
||||||
|
</z-paging>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.featured-dish-collect-icon {
|
||||||
|
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-image {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-bottom: 100%;
|
||||||
|
background: #f0f0f0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-img {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-sold-dim {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgba(201, 197, 197, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-sold-overlay {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-body {
|
||||||
|
padding: 20rpx 20rpx 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-price {
|
||||||
|
color: #e02e24;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-sales {
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
max-width: 48%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-title {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-member {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: calc(100% - 88rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-member-inner {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: linear-gradient(180deg, #fff5eb 0%, #ffe8d6 100%);
|
||||||
|
color: #c45c1a;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-add {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-add__icon {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-promo {
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #fff0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-promo-text {
|
||||||
|
color: #e02e24;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dish-new-ribbon {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 184rpx;
|
||||||
|
height: 184rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
position: absolute;
|
||||||
|
top: 26rpx;
|
||||||
|
left: -66rpx;
|
||||||
|
width: 240rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
background: #e23636;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { debounce } from 'throttle-debounce'
|
||||||
|
import usePage from '@/hooks/usePage'
|
||||||
|
import { getFeaturedDishList } from '@/pages-store/service'
|
||||||
|
import { appCollectCollectPost } from '@/service'
|
||||||
|
import { CollectionType } from '@/constant/enums'
|
||||||
|
import { formatSalesCount } from '@/utils/utils'
|
||||||
|
import type { FeaturedDishQueryBody, RouteQueryMap } from '../utils/featured-dish-query'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
buildQuery: (routeQuery?: RouteQueryMap) => FeaturedDishQueryBody
|
||||||
|
routeQuery?: RouteQueryMap
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function isSoldOutStock(stockLike: unknown) {
|
||||||
|
const n = Number(stockLike)
|
||||||
|
return !Number.isNaN(n) && n <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDishPromoLabel(item: Record<string, unknown>): string {
|
||||||
|
const raw = item.marketingLabel ?? item.hotSaleTag ?? item.rankTag ?? item.promotionLabel
|
||||||
|
return typeof raw === 'string' && raw.trim() ? raw.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function dishImageSrc(item: Record<string, unknown>) {
|
||||||
|
const url = item.dishImageUrl ?? item.dishImage
|
||||||
|
if (typeof url === 'string' && url) return url.split(',')[0]
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const { paging, dataList, queryList } = usePage((pageNum, pageSize) => {
|
||||||
|
const body = props.buildQuery(props.routeQuery)
|
||||||
|
return getFeaturedDishList({
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
...body,
|
||||||
|
}).then((res: { rows?: unknown[]; total?: number }) => ({
|
||||||
|
rows: res?.rows ?? [],
|
||||||
|
total: res?.total ?? 0,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const featuredDishColumns = computed(() => {
|
||||||
|
const list = dataList.value as Record<string, unknown>[]
|
||||||
|
return [
|
||||||
|
list.filter((_, i) => i % 2 === 0),
|
||||||
|
list.filter((_, i) => i % 2 === 1),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
function navigateToDishes(item: Record<string, unknown>) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-store/pages/store/dishes?id=${item.id}&storeId=${item.merchantId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectDish = debounce(
|
||||||
|
1300,
|
||||||
|
(item: Record<string, unknown>) => {
|
||||||
|
appCollectCollectPost({
|
||||||
|
body: {
|
||||||
|
targetId: String(item.id),
|
||||||
|
targetType: CollectionType.DISH,
|
||||||
|
},
|
||||||
|
}).then(() => {
|
||||||
|
item.isCollect = !item.isCollect
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ atBegin: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
refresh: () => paging.value?.refresh(),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<z-paging
|
||||||
|
ref="paging"
|
||||||
|
v-model="dataList"
|
||||||
|
:fixed="false"
|
||||||
|
:auto="true"
|
||||||
|
bg-color="#f2f2f2"
|
||||||
|
@query="queryList"
|
||||||
|
>
|
||||||
|
<view class="featured-dishes-section px-24rpx pb-40rpx pt-16rpx">
|
||||||
|
<view class="waterfall-row flex gap-16rpx items-start">
|
||||||
|
<view
|
||||||
|
v-for="(col, colIndex) in featuredDishColumns"
|
||||||
|
:key="colIndex"
|
||||||
|
class="waterfall-col flex-1 min-w-0 flex flex-col gap-16rpx"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
v-for="item in col"
|
||||||
|
:key="String(item.id ?? item.merchantId) + '-' + String(item.dishName)"
|
||||||
|
class="featured-dish-card w-full"
|
||||||
|
@click="navigateToDishes(item)"
|
||||||
|
>
|
||||||
|
<view class="featured-dish-image">
|
||||||
|
<view v-if="item.isNew == 1" class="dish-new-ribbon">
|
||||||
|
<text class="dish-new-ribbon__text">NEW</text>
|
||||||
|
</view>
|
||||||
|
<image
|
||||||
|
:src="dishImageSrc(item)"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="featured-dish-img"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-if="isSoldOutStock(item.stock)"
|
||||||
|
class="featured-dish-sold-dim"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="isSoldOutStock(item.stock)"
|
||||||
|
src="/static/app/images/SoldOut.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="featured-dish-sold-overlay"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
class="featured-dish-collect w-56rpx h-56rpx absolute z-4 top-12rpx right-12rpx center"
|
||||||
|
@click.stop="collectDish(item)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="!item.isCollect"
|
||||||
|
src="@img-store/1334.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="w-44rpx h-44rpx featured-dish-collect-icon"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-else
|
||||||
|
src="@img-store/1337.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="w-44rpx h-44rpx featured-dish-collect-icon"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="featured-dish-body">
|
||||||
|
<view class="featured-dish-meta flex items-start justify-between gap-12rpx mb-14rpx">
|
||||||
|
<view class="min-w-0 flex-1">
|
||||||
|
<text class="featured-dish-price">US${{ item.originalPrice }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="featured-dish-sales shrink-0">
|
||||||
|
{{ t('pages-store.store.sales') }}: {{ formatSalesCount(item.salesCount) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="featured-dish-title line-clamp-2 mb-16rpx">
|
||||||
|
{{ item.dishName }}
|
||||||
|
</view>
|
||||||
|
<view class="flex items-center justify-between gap-12rpx">
|
||||||
|
<view
|
||||||
|
v-if="Number(item.memberPrice) > 0"
|
||||||
|
class="featured-dish-member shrink min-w-0"
|
||||||
|
>
|
||||||
|
<text class="featured-dish-member-inner">
|
||||||
|
{{ t('pages-store.store.members') }}: US${{ item.memberPrice }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="flex-1 min-w-0"></view>
|
||||||
|
<view class="featured-dish-add shrink-0">
|
||||||
|
<image src="/static/app/images/add_cart.png" class="featured-dish-add__icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="getDishPromoLabel(item)"
|
||||||
|
class="featured-dish-promo mt-16rpx"
|
||||||
|
>
|
||||||
|
<text class="featured-dish-promo-text">{{ getDishPromoLabel(item) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</z-paging>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.featured-dish-collect-icon {
|
||||||
|
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-image {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-bottom: 100%;
|
||||||
|
background: #f0f0f0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-img {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-sold-dim {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgba(201, 197, 197, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-sold-overlay {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-body {
|
||||||
|
padding: 20rpx 20rpx 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-price {
|
||||||
|
color: #e02e24;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-sales {
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
max-width: 48%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-title {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-member-inner {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #ce7138;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-member {
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
background: rgba(206, 113, 56, 0.1);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-add {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-add__icon {
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-promo-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dish-new-ribbon {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #e02e24);
|
||||||
|
border-radius: 0 0 16rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dish-new-ribbon__text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FeaturedDishTopicPage from './featured-dish-topic-page.vue'
|
||||||
|
import { buildFreshSeafoodTodayQuery, type RouteQueryMap } from '../utils/featured-dish-query'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
routeQuery?: RouteQueryMap
|
||||||
|
pageTitle?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<featured-dish-topic-page
|
||||||
|
:build-query="buildFreshSeafoodTodayQuery"
|
||||||
|
:page-title="pageTitle"
|
||||||
|
:route-query="routeQuery"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FeaturedDishTopicPage from './featured-dish-topic-page.vue'
|
||||||
|
import { buildLiveSeafoodAirQuery, type RouteQueryMap } from '../utils/featured-dish-query'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
routeQuery?: RouteQueryMap
|
||||||
|
pageTitle?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<featured-dish-topic-page
|
||||||
|
:build-query="buildLiveSeafoodAirQuery"
|
||||||
|
:page-title="pageTitle"
|
||||||
|
:route-query="routeQuery"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FeaturedDishTopicPage from './featured-dish-topic-page.vue'
|
||||||
|
import { buildMemberZoneQuery, type RouteQueryMap } from '../utils/featured-dish-query'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
routeQuery?: RouteQueryMap
|
||||||
|
pageTitle?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<featured-dish-topic-page
|
||||||
|
:build-query="buildMemberZoneQuery"
|
||||||
|
:page-title="pageTitle"
|
||||||
|
:route-query="routeQuery"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { appMerchantFeaturedListPost, appRecipeCategoryListGet } from '@/service'
|
||||||
|
import { getFeaturedDishList } from '@/pages-store/service'
|
||||||
|
import { useUserStore, useConfigStore } from '@/store'
|
||||||
|
import SearchDishResultItem from '@/pages/search/components/search-dish-result-item.vue'
|
||||||
|
import { buildMustEatListQuery, type RouteQueryMap } from '../utils/featured-dish-query'
|
||||||
|
import { parseFeaturedDishListRes } from '@/utils/featuredDish'
|
||||||
|
|
||||||
|
type MustEatTab = 'merchant' | 'dish'
|
||||||
|
type RecipeCategoryItem = { id?: string | number; categoryName?: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
routeQuery?: RouteQueryMap
|
||||||
|
pageTitle?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const activeTab = ref<MustEatTab>('merchant')
|
||||||
|
const tabIndex = ref(0)
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ label: t('pages.home.mustEatListTabs.merchant'), value: 'merchant' as MustEatTab },
|
||||||
|
{ label: t('pages.home.mustEatListTabs.dish'), value: 'dish' as MustEatTab },
|
||||||
|
])
|
||||||
|
|
||||||
|
const paging = ref<ZPagingInstance | null>(null)
|
||||||
|
const dataList = ref<Record<string, unknown>[]>([])
|
||||||
|
const merchantSourceList = ref<Record<string, unknown>[]>([])
|
||||||
|
const recipeCategoryList = ref<RecipeCategoryItem[]>([])
|
||||||
|
const activeCategoryId = ref<string | number>('')
|
||||||
|
|
||||||
|
const categoryTabs = computed(() => [
|
||||||
|
{ id: '' as const, name: t('pages.home.mustEatListTabs.all') },
|
||||||
|
...recipeCategoryList.value.map((c) => ({
|
||||||
|
id: c.id ?? '',
|
||||||
|
name: String(c.categoryName ?? ''),
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
|
||||||
|
const activeCategoryIndex = computed(() => {
|
||||||
|
const idx = categoryTabs.value.findIndex(
|
||||||
|
(c) => String(c.id) === String(activeCategoryId.value),
|
||||||
|
)
|
||||||
|
return idx >= 0 ? idx : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
appRecipeCategoryListGet({}).then((res) => {
|
||||||
|
recipeCategoryList.value = (res.data as RecipeCategoryItem[]) || []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTabChange(index: number) {
|
||||||
|
tabIndex.value = index
|
||||||
|
activeTab.value = tabs.value[index]?.value ?? 'merchant'
|
||||||
|
dataList.value = []
|
||||||
|
paging.value?.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategoryChange(id: string | number) {
|
||||||
|
if (String(activeCategoryId.value) === String(id)) return
|
||||||
|
activeCategoryId.value = id
|
||||||
|
dataList.value = []
|
||||||
|
paging.value?.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryNameById(id: string | number): string {
|
||||||
|
if (id === '' || id == null) return ''
|
||||||
|
const hit = recipeCategoryList.value.find((c) => String(c.id) === String(id))
|
||||||
|
return String(hit?.categoryName ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMerchantsByCategory(list: Record<string, unknown>[]) {
|
||||||
|
const name = categoryNameById(activeCategoryId.value)
|
||||||
|
if (!name) return list
|
||||||
|
return list.filter((item) => {
|
||||||
|
const zh = item.merchantCategoryNamesZh as string[] | undefined
|
||||||
|
const en = item.merchantCategoryNamesEn as string[] | undefined
|
||||||
|
const names = [...(zh || []), ...(en || [])]
|
||||||
|
return names.some((n) => n.includes(name) || name.includes(n))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onQuery(pageNum: number, pageSize: number) {
|
||||||
|
try {
|
||||||
|
if (activeTab.value === 'merchant') {
|
||||||
|
if (pageNum > 1) {
|
||||||
|
await paging.value?.completeByNoMore([], true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await appMerchantFeaturedListPost({
|
||||||
|
body: {
|
||||||
|
lat: userStore.userLocation.latitude,
|
||||||
|
lng: userStore.userLocation.longitude,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const list = Array.isArray((res as { data?: unknown[] })?.data)
|
||||||
|
? ((res as { data: unknown[] }).data as Record<string, unknown>[])
|
||||||
|
: []
|
||||||
|
merchantSourceList.value = list
|
||||||
|
const filtered = filterMerchantsByCategory(list)
|
||||||
|
await paging.value?.completeByTotal(filtered, filtered.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterBody = buildMustEatListQuery(props.routeQuery)
|
||||||
|
if (activeCategoryId.value !== '' && activeCategoryId.value != null) {
|
||||||
|
filterBody.recipeCategoryId = activeCategoryId.value
|
||||||
|
}
|
||||||
|
const res = await getFeaturedDishList({
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
...filterBody,
|
||||||
|
})
|
||||||
|
const { rows, total } = parseFeaturedDishListRes(res)
|
||||||
|
await paging.value?.completeByTotal(rows, total)
|
||||||
|
} catch {
|
||||||
|
await paging.value?.complete(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function merchantImagePair(item: Record<string, unknown>): [string, string] {
|
||||||
|
const shop = String(item.shopImages ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const logo = String(item.logo ?? '').trim()
|
||||||
|
let a = shop[0] || logo || ''
|
||||||
|
let b = shop[1] || shop[0] || logo || ''
|
||||||
|
if (!a && !b) return ['', '']
|
||||||
|
if (!b) b = a
|
||||||
|
if (!a) a = b
|
||||||
|
return [a, b]
|
||||||
|
}
|
||||||
|
|
||||||
|
function merchantCategoryLine(item: Record<string, unknown>): string {
|
||||||
|
const zh = item.merchantCategoryNamesZh as string[] | undefined
|
||||||
|
const en = item.merchantCategoryNamesEn as string[] | undefined
|
||||||
|
if (uni.getLocale() === 'zh-Hans' && zh?.length) return zh[0] || ''
|
||||||
|
if (en?.length) return en[0] || ''
|
||||||
|
if (zh?.length) return zh[0] || ''
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingStars(rating?: unknown) {
|
||||||
|
return Math.min(5, Math.max(0, Math.round(Number(rating) || 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingText(rating?: unknown) {
|
||||||
|
const n = Number(rating)
|
||||||
|
return Number.isFinite(n) ? n.toFixed(1) : '0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMerchant(item: Record<string, unknown>) {
|
||||||
|
if (item?.id == null) return
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-store/pages/store/index?id=${item.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<z-paging
|
||||||
|
ref="paging"
|
||||||
|
v-model="dataList"
|
||||||
|
bg-color="#f2f2f2"
|
||||||
|
:auto="true"
|
||||||
|
@query="onQuery"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<navbar :title="pageTitle || ''" />
|
||||||
|
<view class="must-eat-tabbar">
|
||||||
|
<view class="must-eat-segment">
|
||||||
|
<view
|
||||||
|
class="must-eat-segment__thumb"
|
||||||
|
:style="{ transform: `translateX(${tabIndex * 100}%)` }"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-for="(tab, index) in tabs"
|
||||||
|
:key="tab.value"
|
||||||
|
class="must-eat-segment__item"
|
||||||
|
:class="{ 'must-eat-segment__item--on': tabIndex === index }"
|
||||||
|
@click="onTabChange(index)"
|
||||||
|
>
|
||||||
|
<text class="must-eat-segment__text">{{ tab.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
class="must-eat-categories"
|
||||||
|
scroll-x
|
||||||
|
:show-scrollbar="false"
|
||||||
|
enable-flex
|
||||||
|
>
|
||||||
|
<view class="must-eat-categories__track">
|
||||||
|
<view
|
||||||
|
v-for="(cat, index) in categoryTabs"
|
||||||
|
:key="String(cat.id === '' ? 'all' : cat.id)"
|
||||||
|
class="must-eat-categories__item"
|
||||||
|
:class="{
|
||||||
|
'must-eat-categories__item--on': activeCategoryIndex === index,
|
||||||
|
}"
|
||||||
|
@click="onCategoryChange(cat.id)"
|
||||||
|
>
|
||||||
|
<text class="must-eat-categories__text">{{ cat.name }}</text>
|
||||||
|
<view
|
||||||
|
v-if="activeCategoryIndex === index"
|
||||||
|
class="must-eat-categories__line"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="must-eat-categories__tail" />
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 商家:精选商家,一行一条(同 home-store 卡片) -->
|
||||||
|
<view v-if="activeTab === 'merchant'" class="must-eat-list px-24rpx pb-40rpx pt-8rpx">
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in dataList"
|
||||||
|
:key="String(item.id ?? index)"
|
||||||
|
class="merchant-card"
|
||||||
|
@click="openMerchant(item)"
|
||||||
|
>
|
||||||
|
<view class="merchant-card__imgs">
|
||||||
|
<view
|
||||||
|
v-for="(src, ii) in merchantImagePair(item)"
|
||||||
|
:key="ii"
|
||||||
|
class="merchant-card__img-slot"
|
||||||
|
:class="
|
||||||
|
ii === 0
|
||||||
|
? 'merchant-card__img-slot--first'
|
||||||
|
: 'merchant-card__img-slot--last'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="src"
|
||||||
|
class="merchant-card__img"
|
||||||
|
:src="src"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="merchant-card__img merchant-card__img--empty" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="merchant-card__body">
|
||||||
|
<text class="merchant-card__name">{{ item.merchantName }}</text>
|
||||||
|
<text
|
||||||
|
v-if="merchantCategoryLine(item)"
|
||||||
|
class="merchant-card__cate"
|
||||||
|
>{{ merchantCategoryLine(item) }}</text>
|
||||||
|
<view class="merchant-card__rating">
|
||||||
|
<text
|
||||||
|
v-for="si in [1, 2, 3, 4, 5]"
|
||||||
|
:key="si"
|
||||||
|
class="merchant-card__star"
|
||||||
|
:class="{
|
||||||
|
'merchant-card__star--on': si <= ratingStars(item.rating),
|
||||||
|
}"
|
||||||
|
>★</text>
|
||||||
|
<text class="merchant-card__score">{{ ratingText(item.rating) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="merchant-card__tag">{{ t('pages.home.brandTag') }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="merchant-card__go" @click.stop="openMerchant(item)">
|
||||||
|
<view class="i-carbon:chevron-right merchant-card__go-icon"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 商品:销量排序,一行一条 -->
|
||||||
|
<view v-else class="must-eat-dish-list pb-40rpx pt-8rpx">
|
||||||
|
<search-dish-result-item
|
||||||
|
v-for="item in dataList"
|
||||||
|
:key="String(item.id ?? item.merchantId)"
|
||||||
|
:item="item as any"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template #bottom>
|
||||||
|
<view class="h-24rpx"></view>
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]"></view>
|
||||||
|
</template>
|
||||||
|
</z-paging>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.must-eat-tabbar {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-segment {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 8rpx;
|
||||||
|
background: #ececec;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-segment__thumb {
|
||||||
|
position: absolute;
|
||||||
|
left: 8rpx;
|
||||||
|
top: 8rpx;
|
||||||
|
width: calc((100% - 16rpx) / 2);
|
||||||
|
height: calc(100% - 16rpx);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-segment__item {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-segment__text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
transition: color 0.2s, font-weight 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-segment__item--on .must-eat-segment__text {
|
||||||
|
color: #14181b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-categories {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-categories__track {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
min-width: 100%;
|
||||||
|
padding: 8rpx 0 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-categories__item {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 28rpx 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-categories__item--on {
|
||||||
|
padding-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-categories__text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-categories__item--on .must-eat-categories__text {
|
||||||
|
color: #14181b;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-categories__line {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
background: #14181b;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-categories__tail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24rpx;
|
||||||
|
height: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__imgs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
width: 152rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__img-slot {
|
||||||
|
width: 152rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #e8eaed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__img-slot--first {
|
||||||
|
border-radius: 12rpx 12rpx 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__img-slot--last {
|
||||||
|
border-radius: 0 0 12rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 20rpx 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #14181b;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__cate {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__rating {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__star {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__star--on {
|
||||||
|
color: #ffb400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__score {
|
||||||
|
margin-left: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__tag {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #ce7138;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__go {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card__go-icon {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.must-eat-dish-list {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import FeaturedDishTopicPage from './featured-dish-topic-page.vue'
|
||||||
|
import { buildNewCalendarQuery, type RouteQueryMap } from '../utils/featured-dish-query'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
routeQuery?: RouteQueryMap
|
||||||
|
pageTitle?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/** 导航栏固定「今日上新」,不使用路由 categoryName */
|
||||||
|
const navTitle = computed(() => t('pages.home.quickTabs.newCalendarNav'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<featured-dish-topic-page
|
||||||
|
:build-query="buildNewCalendarQuery"
|
||||||
|
:page-title="navTitle"
|
||||||
|
:route-query="routeQuery"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onLoad, onReady } from '@dcloudio/uni-app'
|
||||||
|
import { computed, ref, type Component } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import TopicMemberZone from './components/topic-member-zone.vue'
|
||||||
|
import TopicLiveSeafoodAir from './components/topic-live-seafood-air.vue'
|
||||||
|
import TopicMustEatList from './components/topic-must-eat-list.vue'
|
||||||
|
import TopicNewCalendar from './components/topic-new-calendar.vue'
|
||||||
|
import TopicFreshSeafoodToday from './components/topic-fresh-seafood-today.vue'
|
||||||
|
import type { RouteQueryMap } from './utils/featured-dish-query'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const topic = ref('')
|
||||||
|
const routeQuery = ref<RouteQueryMap>({})
|
||||||
|
|
||||||
|
const VALID_TOPICS = [
|
||||||
|
'member-zone',
|
||||||
|
'live-seafood-air',
|
||||||
|
'must-eat-list',
|
||||||
|
'new-calendar',
|
||||||
|
'fresh-seafood-today',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type QuickTopic = (typeof VALID_TOPICS)[number]
|
||||||
|
|
||||||
|
const topicComponentMap: Record<QuickTopic, Component> = {
|
||||||
|
'member-zone': TopicMemberZone,
|
||||||
|
'live-seafood-air': TopicLiveSeafoodAir,
|
||||||
|
'must-eat-list': TopicMustEatList,
|
||||||
|
'new-calendar': TopicNewCalendar,
|
||||||
|
'fresh-seafood-today': TopicFreshSeafoodToday,
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicTitleKeyMap: Record<QuickTopic, string> = {
|
||||||
|
'member-zone': 'pages.home.quickTabs.memberZone',
|
||||||
|
'live-seafood-air': 'pages.home.quickTabs.liveSeafoodAir',
|
||||||
|
'must-eat-list': 'pages.home.quickTabs.mustEatList',
|
||||||
|
'new-calendar': 'pages.home.quickTabs.newCalendar',
|
||||||
|
'fresh-seafood-today': 'pages.home.quickTabs.freshSeafoodToday',
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryNameFromRoute = computed(() => {
|
||||||
|
const name = routeQuery.value.categoryName
|
||||||
|
return name ? decodeURIComponent(name) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
if (categoryNameFromRoute.value) return categoryNameFromRoute.value
|
||||||
|
const key = topicTitleKeyMap[topic.value as QuickTopic]
|
||||||
|
return key ? t(key) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValidTopic = computed(() =>
|
||||||
|
VALID_TOPICS.includes(topic.value as QuickTopic),
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeTopicComponent = computed(() => {
|
||||||
|
if (!isValidTopic.value) return null
|
||||||
|
return topicComponentMap[topic.value as QuickTopic] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad((query?: Record<string, string | undefined>) => {
|
||||||
|
topic.value = String(query?.topic ?? '')
|
||||||
|
const { topic: _t, ...rest } = query ?? {}
|
||||||
|
routeQuery.value = rest
|
||||||
|
})
|
||||||
|
|
||||||
|
onReady(() => {
|
||||||
|
if (!isValidTopic.value) {
|
||||||
|
uni.showToast({ title: '参数无效', icon: 'none' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 1500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="quick-topic-root">
|
||||||
|
<component
|
||||||
|
v-if="activeTopicComponent"
|
||||||
|
:is="activeTopicComponent"
|
||||||
|
:page-title="pageTitle"
|
||||||
|
:route-query="routeQuery"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.quick-topic-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/** 精选菜品列表 POST body,见 docs/app-featured-dish-list.md */
|
||||||
|
export type FeaturedDishQueryBody = {
|
||||||
|
operationalEntry?: string
|
||||||
|
hasMemberPrice?: any
|
||||||
|
stockMin?: any
|
||||||
|
stockMax?: any
|
||||||
|
recipeCategoryId?: any
|
||||||
|
createBeginTime?: any
|
||||||
|
createEndTime?: any
|
||||||
|
salesSort?: 'asc' | 'desc'
|
||||||
|
isNew?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 运营入口固定参数(精选菜品列表 operationalEntry) */
|
||||||
|
export const OPERATIONAL_ENTRY = {
|
||||||
|
FEATURED: 'featured',
|
||||||
|
FRESH_SEAFOOD_TODAY: 'fresh-seafood-today',
|
||||||
|
LIMITED_AIR_SEAFOOD: 'limited-air-seafood',
|
||||||
|
NEW_DISH_CALENDAR: 'new-dish-calendar',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type RouteQueryMap = Record<string, string | undefined>
|
||||||
|
/** 将 URL 上可选筛选参数合并进 body(分页仍走 query) */
|
||||||
|
export function mergeRouteQueryIntoBody(
|
||||||
|
base: FeaturedDishQueryBody,
|
||||||
|
routeQuery: RouteQueryMap = {},
|
||||||
|
): FeaturedDishQueryBody {
|
||||||
|
const body: FeaturedDishQueryBody = { ...base }
|
||||||
|
const recipeCategoryId = routeQuery.recipeCategoryId
|
||||||
|
const stockMin = routeQuery.stockMin
|
||||||
|
const stockMax = routeQuery.stockMax
|
||||||
|
const createBeginTime = routeQuery.createBeginTime
|
||||||
|
const createEndTime = routeQuery.createEndTime
|
||||||
|
const hasMemberPrice = routeQuery.hasMemberPrice
|
||||||
|
const salesSort = routeQuery.salesSort
|
||||||
|
const operationalEntry = routeQuery.operationalEntry
|
||||||
|
|
||||||
|
if (operationalEntry != null) body.operationalEntry = operationalEntry
|
||||||
|
if (recipeCategoryId != null) body.recipeCategoryId = recipeCategoryId
|
||||||
|
if (stockMin != null) body.stockMin = stockMin
|
||||||
|
if (stockMax != null) body.stockMax = stockMax
|
||||||
|
if (createBeginTime != null) body.createBeginTime = createBeginTime
|
||||||
|
if (createEndTime != null) body.createEndTime = createEndTime
|
||||||
|
if (hasMemberPrice != null) body.hasMemberPrice = hasMemberPrice
|
||||||
|
if (salesSort === 'asc' || salesSort === 'desc') body.salesSort = salesSort
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会员专区:仅查有会员价菜品 */
|
||||||
|
export function buildMemberZoneQuery(routeQuery?: RouteQueryMap): FeaturedDishQueryBody {
|
||||||
|
return mergeRouteQueryIntoBody({ hasMemberPrice: true }, routeQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 限量空运活海鲜:固定运营入口 */
|
||||||
|
export function buildLiveSeafoodAirQuery(routeQuery?: RouteQueryMap): FeaturedDishQueryBody {
|
||||||
|
return mergeRouteQueryIntoBody(
|
||||||
|
{ operationalEntry: OPERATIONAL_ENTRY.LIMITED_AIR_SEAFOOD },
|
||||||
|
routeQuery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 必吃榜:按销量降序 */
|
||||||
|
export function buildMustEatListQuery(routeQuery?: RouteQueryMap): FeaturedDishQueryBody {
|
||||||
|
return mergeRouteQueryIntoBody({ salesSort: 'desc' }, routeQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今日上新/上新日历:运营入口 new-dish-calendar(全量新品,按上架时间倒序) */
|
||||||
|
export function buildNewCalendarQuery(routeQuery?: RouteQueryMap): FeaturedDishQueryBody {
|
||||||
|
return mergeRouteQueryIntoBody(
|
||||||
|
{ operationalEntry: OPERATIONAL_ENTRY.NEW_DISH_CALENDAR },
|
||||||
|
routeQuery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今日现打海鲜:固定运营入口 */
|
||||||
|
export function buildFreshSeafoodTodayQuery(routeQuery?: RouteQueryMap): FeaturedDishQueryBody {
|
||||||
|
return mergeRouteQueryIntoBody(
|
||||||
|
{ operationalEntry: OPERATIONAL_ENTRY.FRESH_SEAFOOD_TODAY },
|
||||||
|
routeQuery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOPIC_QUERY_BUILDERS: Record<
|
||||||
|
string,
|
||||||
|
(routeQuery?: RouteQueryMap) => FeaturedDishQueryBody
|
||||||
|
> = {
|
||||||
|
'member-zone': buildMemberZoneQuery,
|
||||||
|
'live-seafood-air': buildLiveSeafoodAirQuery,
|
||||||
|
'must-eat-list': buildMustEatListQuery,
|
||||||
|
'new-calendar': buildNewCalendarQuery,
|
||||||
|
'fresh-seafood-today': buildFreshSeafoodTodayQuery,
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/** 首页 tabs-type 对应精选菜品专题 slug(与 quick-topic 子组件一致) */
|
||||||
|
export const QUICK_TOPIC_SLUGS = [
|
||||||
|
'member-zone',
|
||||||
|
'live-seafood-air',
|
||||||
|
'must-eat-list',
|
||||||
|
'new-calendar',
|
||||||
|
'fresh-seafood-today',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type QuickTopicSlug = (typeof QUICK_TOPIC_SLUGS)[number]
|
||||||
|
|
||||||
|
export function isQuickTopicSlug(value: string): value is QuickTopicSlug {
|
||||||
|
return (QUICK_TOPIC_SLUGS as readonly string[]).includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 /app/merchantCategory/list 单项映射为 quick-topic 的 topic。
|
||||||
|
* 优先读后端字段 topicKey / code / topic;否则按列表顺序与五个专题一一对应。
|
||||||
|
*/
|
||||||
|
export function resolveQuickTopicFromMerchantCategory(
|
||||||
|
item: Record<string, unknown>,
|
||||||
|
index: number,
|
||||||
|
): QuickTopicSlug {
|
||||||
|
const raw = item.topicKey ?? item.code ?? item.topic
|
||||||
|
if (typeof raw === 'string' && isQuickTopicSlug(raw)) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
const safeIndex = index >= 0 && index < QUICK_TOPIC_SLUGS.length ? index : 0
|
||||||
|
return QUICK_TOPIC_SLUGS[safeIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快捷入口 topic 与 operationalEntry 固定映射 */
|
||||||
|
export const TOPIC_OPERATIONAL_ENTRY: Partial<Record<QuickTopicSlug, string>> = {
|
||||||
|
'live-seafood-air': 'limited-air-seafood',
|
||||||
|
'new-calendar': 'new-dish-calendar',
|
||||||
|
'fresh-seafood-today': 'fresh-seafood-today',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildQuickTopicUrl(
|
||||||
|
topic: QuickTopicSlug,
|
||||||
|
extra: {
|
||||||
|
merchantCategoryId?: string | number
|
||||||
|
categoryName?: string
|
||||||
|
operationalEntry?: string
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const parts = [`topic=${encodeURIComponent(topic)}`]
|
||||||
|
const operationalEntry = extra.operationalEntry ?? TOPIC_OPERATIONAL_ENTRY[topic]
|
||||||
|
if (operationalEntry) {
|
||||||
|
parts.push(`operationalEntry=${encodeURIComponent(operationalEntry)}`)
|
||||||
|
}
|
||||||
|
if (extra.merchantCategoryId != null && extra.merchantCategoryId !== '') {
|
||||||
|
parts.push(`merchantCategoryId=${encodeURIComponent(String(extra.merchantCategoryId))}`)
|
||||||
|
}
|
||||||
|
if (extra.categoryName) {
|
||||||
|
parts.push(`categoryName=${encodeURIComponent(extra.categoryName)}`)
|
||||||
|
}
|
||||||
|
return `/pages-store/pages/dishes/quick-topic?${parts.join('&')}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<view class="energy-meal-skeleton">
|
||||||
|
<view v-for="i in 2" :key="i" class="energy-meal-skeleton__card">
|
||||||
|
<view class="energy-meal-skeleton__head">
|
||||||
|
<view class="energy-meal-skeleton__title skeleton-item"></view>
|
||||||
|
<view class="energy-meal-skeleton__merchant skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-for="j in 2"
|
||||||
|
:key="j"
|
||||||
|
class="energy-meal-skeleton__row"
|
||||||
|
:class="{ 'energy-meal-skeleton__row--last': j === 2 }"
|
||||||
|
>
|
||||||
|
<view class="energy-meal-skeleton__img skeleton-item"></view>
|
||||||
|
<view class="energy-meal-skeleton__main">
|
||||||
|
<view class="energy-meal-skeleton__name skeleton-item"></view>
|
||||||
|
<view class="energy-meal-skeleton__price skeleton-item"></view>
|
||||||
|
<view class="energy-meal-skeleton__member skeleton-item"></view>
|
||||||
|
<view class="energy-meal-skeleton__sales skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="energy-meal-skeleton__action">
|
||||||
|
<view class="energy-meal-skeleton__btn skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.skeleton-item {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton {
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__card {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__head {
|
||||||
|
padding: 24rpx 24rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__title {
|
||||||
|
width: 280rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__merchant {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__row {
|
||||||
|
display: flex;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #ebebeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__row--last {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__img {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__name {
|
||||||
|
width: 100%;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__price {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__member {
|
||||||
|
width: 320rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__sales {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 8rpx 24rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-skeleton__btn {
|
||||||
|
width: 280rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Config from '@/config/index'
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
|
import EnergyMealSkeleton from './components/energy-meal-skeleton.vue'
|
||||||
|
import {
|
||||||
|
appEnergyMealAddCartPost,
|
||||||
|
appEnergyMealListPost,
|
||||||
|
type EnergyMealItemVo,
|
||||||
|
type EnergyMealVo,
|
||||||
|
} from '@/service'
|
||||||
|
import { formatSalesCount, thumbnailImg } from '@/utils/utils'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const filterMerchantId = ref<string>('')
|
||||||
|
const mealList = ref<EnergyMealVo[]>([])
|
||||||
|
const paging = ref<ZPagingInstance | null>(null)
|
||||||
|
const addingMealId = ref<string | number | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onLoad((options: Record<string, string | undefined>) => {
|
||||||
|
if (options?.merchantId) {
|
||||||
|
filterMerchantId.value = String(options.merchantId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function firstImage(raw?: string) {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return ''
|
||||||
|
return raw.split(',')[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedItems(meal: EnergyMealVo): EnergyMealItemVo[] {
|
||||||
|
const list = Array.isArray(meal.itemList) ? [...meal.itemList] : []
|
||||||
|
return list.sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAddMeal(meal: EnergyMealVo) {
|
||||||
|
return sortedItems(meal).some((item) => item.merchantDishVo?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dishCover(item: EnergyMealItemVo) {
|
||||||
|
return firstImage(item.merchantDishVo?.dishImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dishName(item: EnergyMealItemVo) {
|
||||||
|
const name = String(item.merchantDishVo?.dishName ?? '').trim()
|
||||||
|
const qty = Number(item.quantity) || 1
|
||||||
|
if (!name) return '--'
|
||||||
|
if (qty > 1) {
|
||||||
|
return `${name} × ${qty}`
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
function dishPrice(item: EnergyMealItemVo) {
|
||||||
|
const dish = item.merchantDishVo ?? {}
|
||||||
|
const discount = Number(dish.discountPrice)
|
||||||
|
const original = Number(dish.originalPrice)
|
||||||
|
if (Number.isFinite(discount) && discount > 0) return discount.toFixed(2)
|
||||||
|
if (Number.isFinite(original) && original > 0) return original.toFixed(2)
|
||||||
|
return '0.00'
|
||||||
|
}
|
||||||
|
|
||||||
|
function dishOriginalPrice(item: EnergyMealItemVo) {
|
||||||
|
const dish = item.merchantDishVo ?? {}
|
||||||
|
const discount = Number(dish.discountPrice)
|
||||||
|
const original = Number(dish.originalPrice)
|
||||||
|
if (
|
||||||
|
Number.isFinite(original) &&
|
||||||
|
original > 0 &&
|
||||||
|
Number.isFinite(discount) &&
|
||||||
|
original > discount
|
||||||
|
) {
|
||||||
|
return original.toFixed(2)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function dishMemberPrice(item: EnergyMealItemVo) {
|
||||||
|
const member = Number(item.merchantDishVo?.memberPrice)
|
||||||
|
return Number.isFinite(member) && member > 0 ? member.toFixed(2) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDishDetail(item: EnergyMealItemVo, meal: EnergyMealVo) {
|
||||||
|
const dishId = item.merchantDishVo?.id ?? item.dishId
|
||||||
|
const merchantId = meal.merchantId ?? item.merchantDishVo?.merchantId
|
||||||
|
if (!dishId || !merchantId) return
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-store/pages/store/dishes?id=${dishId}&storeId=${merchantId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onQuery(pageNum: number, pageSize: number) {
|
||||||
|
if (pageNum === 1) {
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body: Record<string, string> = {}
|
||||||
|
if (filterMerchantId.value) {
|
||||||
|
body.merchantId = filterMerchantId.value
|
||||||
|
}
|
||||||
|
const res = await appEnergyMealListPost({
|
||||||
|
params: { pageNum, pageSize },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
const rows = Array.isArray(res.rows) ? res.rows : []
|
||||||
|
const total = Number(res.total ?? rows.length)
|
||||||
|
await paging.value?.completeByTotal(rows, total)
|
||||||
|
} catch {
|
||||||
|
await paging.value?.complete(false)
|
||||||
|
} finally {
|
||||||
|
if (pageNum === 1) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddAllToCart(meal: EnergyMealVo) {
|
||||||
|
if (!userStore.checkLogin()) return
|
||||||
|
if (!meal.id) return
|
||||||
|
if (!canAddMeal(meal)) {
|
||||||
|
uni.showToast({
|
||||||
|
title: t('pages-store.energyMeal.unavailable'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (addingMealId.value != null) return
|
||||||
|
|
||||||
|
addingMealId.value = meal.id
|
||||||
|
try {
|
||||||
|
await appEnergyMealAddCartPost({
|
||||||
|
body: {
|
||||||
|
energyMealId: meal.id,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
options: { hideErrorToast: true },
|
||||||
|
})
|
||||||
|
uni.showToast({
|
||||||
|
title: t('toast.addCartSuccess'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
userStore.getUserCartAllData()
|
||||||
|
} catch (err: any) {
|
||||||
|
uni.showToast({
|
||||||
|
title: err?.msg || err?.message || t('common.prompt.request-failed-please-try-again-later'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (addingMealId.value === meal.id) {
|
||||||
|
addingMealId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<z-paging
|
||||||
|
ref="paging"
|
||||||
|
v-model="mealList"
|
||||||
|
bg-color="#f2f2f2"
|
||||||
|
:auto="true"
|
||||||
|
:hide-empty-view="loading"
|
||||||
|
@query="onQuery"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<navbar :title="t('pages-store.energyMeal.title')" circle-back />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-show="loading"
|
||||||
|
class="animate-in fade-in animate-ease-out animate-duration-300"
|
||||||
|
>
|
||||||
|
<energy-meal-skeleton />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-show="!loading"
|
||||||
|
class="animate-in fade-in animate-ease-in animate-duration-300 energy-meal-page"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
v-for="meal in mealList"
|
||||||
|
:key="meal.id"
|
||||||
|
class="energy-meal-card"
|
||||||
|
>
|
||||||
|
<view v-if="meal.mealName" class="energy-meal-card__head">
|
||||||
|
<text class="energy-meal-card__title line-clamp-1">{{ meal.mealName }}</text>
|
||||||
|
<text
|
||||||
|
v-if="meal.merchant?.merchantName"
|
||||||
|
class="energy-meal-card__merchant line-clamp-1"
|
||||||
|
>
|
||||||
|
{{ meal.merchant.merchantName }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in sortedItems(meal)"
|
||||||
|
:key="item.id || `${meal.id}-${index}`"
|
||||||
|
class="energy-dish-row"
|
||||||
|
:class="{ 'energy-dish-row--last': index === sortedItems(meal).length - 1 }"
|
||||||
|
@click="openDishDetail(item, meal)"
|
||||||
|
>
|
||||||
|
<view class="energy-dish-row__img-wrap">
|
||||||
|
<image
|
||||||
|
:src="thumbnailImg(dishCover(item))"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="energy-dish-row__img"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="energy-dish-row__main">
|
||||||
|
<text class="energy-dish-row__name line-clamp-2">{{ dishName(item) }}</text>
|
||||||
|
<view class="energy-dish-row__price-row">
|
||||||
|
<text class="energy-dish-row__price-current">${{ dishPrice(item) }}</text>
|
||||||
|
<text
|
||||||
|
v-if="dishOriginalPrice(item)"
|
||||||
|
class="energy-dish-row__price-old"
|
||||||
|
>
|
||||||
|
${{ dishOriginalPrice(item) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="dishMemberPrice(item)"
|
||||||
|
class="energy-dish-row__member"
|
||||||
|
>
|
||||||
|
<text class="energy-dish-row__member-diamond">◆</text>
|
||||||
|
<text class="energy-dish-row__member-text">
|
||||||
|
{{ Config.appName }} {{ t('pages.search.member-price-line') }}: ${{ dishMemberPrice(item) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="energy-dish-row__sales-wrap">
|
||||||
|
<text class="energy-dish-row__sales-tag">
|
||||||
|
{{ t('pages.search.weekly-sales') }}:{{ formatSalesCount(item.merchantDishVo?.salesCount) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="canAddMeal(meal)"
|
||||||
|
class="energy-meal-card__action"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="energy-meal-card__btn"
|
||||||
|
:class="{ 'energy-meal-card__btn--loading': addingMealId === meal.id }"
|
||||||
|
@click.stop="handleAddAllToCart(meal)"
|
||||||
|
>
|
||||||
|
{{ t('pages-store.energyMeal.addAllToCart') }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<view v-if="!loading" class="energy-meal-empty">
|
||||||
|
<image class="energy-meal-empty__img" src="@img/chef/100.png" mode="aspectFit" />
|
||||||
|
<text class="energy-meal-empty__text">{{ t('pages-store.energyMeal.empty') }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</z-paging>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.energy-meal-page {
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-card {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-card__head {
|
||||||
|
padding: 24rpx 24rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-card__title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 42rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-card__merchant {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #ebebeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row--last {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__img-wrap {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__price-current {
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e02e24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__price-old {
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__member {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__member-diamond {
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1;
|
||||||
|
color: #b8860b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__member-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #8b6914;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__sales-wrap {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-dish-row__sales-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 28rpx;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-card__action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 8rpx 24rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-card__btn {
|
||||||
|
min-width: 280rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-card__btn--loading {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-empty__img {
|
||||||
|
width: 250rpx;
|
||||||
|
height: 250rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy-meal-empty__text {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { dayjs } from '@/plugin/index'
|
||||||
|
import {
|
||||||
|
appGroupMealReservationCancelPost,
|
||||||
|
appGroupMealReservationIdGet,
|
||||||
|
type GroupMealReservationVo,
|
||||||
|
} from '@/service'
|
||||||
|
import { useConfigStore } from '@/store'
|
||||||
|
import { formatTimestampWithMonthName } from '@/utils/utils'
|
||||||
|
import {
|
||||||
|
calcGroupMealEstimatedTotal,
|
||||||
|
canCancelGroupMeal,
|
||||||
|
formatGroupMealMoney,
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const reservationId = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const detail = ref<GroupMealReservationVo>({})
|
||||||
|
const cancelReason = ref('')
|
||||||
|
|
||||||
|
const estimatedTotal = computed(() =>
|
||||||
|
formatGroupMealMoney(calcGroupMealEstimatedTotal(detail.value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
const status = Number(detail.value.status)
|
||||||
|
const keyMap: Record<number, string> = {
|
||||||
|
1: 'statusPending',
|
||||||
|
2: 'statusContacted',
|
||||||
|
3: 'statusConfirmed',
|
||||||
|
4: 'statusRejected',
|
||||||
|
5: 'statusCompleted',
|
||||||
|
6: 'statusCancelled',
|
||||||
|
}
|
||||||
|
const key = keyMap[status]
|
||||||
|
return key ? t(`pages-store.groupCatering.${key}`) : '--'
|
||||||
|
})
|
||||||
|
|
||||||
|
const canCancel = computed(() => canCancelGroupMeal(Number(detail.value.status)))
|
||||||
|
|
||||||
|
function formatExpectedTime(value?: number) {
|
||||||
|
if (!value) return '--'
|
||||||
|
return formatTimestampWithMonthName(Number(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!reservationId.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await appGroupMealReservationIdGet({ id: reservationId.value })
|
||||||
|
detail.value = res.data ?? {}
|
||||||
|
} catch {
|
||||||
|
detail.value = {}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
if (!canCancel.value || submitting.value || !reservationId.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await appGroupMealReservationCancelPost({
|
||||||
|
body: {
|
||||||
|
id: reservationId.value,
|
||||||
|
cancelReason: cancelReason.value.trim() || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
uni.showToast({
|
||||||
|
title: t('pages-store.groupCatering.cancelSuccess'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}, 800)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCancel() {
|
||||||
|
uni.showModal({
|
||||||
|
title: t('pages-store.groupCatering.cancelTitle'),
|
||||||
|
content: t('pages-store.groupCatering.cancelConfirm'),
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query?: Record<string, string | undefined>) => {
|
||||||
|
reservationId.value = String(query?.id ?? '')
|
||||||
|
loadDetail()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="group-detail-page">
|
||||||
|
<navbar :title="t('pages-store.groupCatering.detailTitle')" circle-back />
|
||||||
|
|
||||||
|
<view v-if="loading" class="center py-120rpx text-28rpx text-#999">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view class="group-detail-page__content px-30rpx pt-20rpx">
|
||||||
|
<view class="group-detail-card">
|
||||||
|
<view class="group-detail-card__head">
|
||||||
|
<text class="group-detail-card__merchant">
|
||||||
|
{{ detail.merchant?.merchantName || '--' }}
|
||||||
|
</text>
|
||||||
|
<text class="group-detail-card__status">{{ statusText }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-detail-row">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldScene') }}</text>
|
||||||
|
<text class="group-detail-row__value">{{ detail.scene || '--' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-detail-row">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldPeopleCount') }}</text>
|
||||||
|
<text class="group-detail-row__value">{{ detail.peopleCount ?? '--' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-detail-row">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldPerCapitaPrice') }}</text>
|
||||||
|
<text class="group-detail-row__value">${{ formatGroupMealMoney(Number(detail.perCapitaPrice) || 0) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-detail-row">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldEstimatedTotal') }}</text>
|
||||||
|
<text class="group-detail-row__value group-detail-row__value--accent">${{ estimatedTotal }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-detail-row">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldExpectedTime') }}</text>
|
||||||
|
<text class="group-detail-row__value">{{ formatExpectedTime(detail.expectedTime) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-detail-row">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldContactName') }}</text>
|
||||||
|
<text class="group-detail-row__value">{{ detail.contactName || '--' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-detail-row">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldContactPhone') }}</text>
|
||||||
|
<text class="group-detail-row__value">{{ detail.contactPhone || '--' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="detail.remark" class="group-detail-row group-detail-row--column">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldRemark') }}</text>
|
||||||
|
<text class="group-detail-row__value">{{ detail.remark }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="detail.handleRemark" class="group-detail-row group-detail-row--column">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldHandleRemark') }}</text>
|
||||||
|
<text class="group-detail-row__value">{{ detail.handleRemark }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="detail.cancelReason" class="group-detail-row group-detail-row--column">
|
||||||
|
<text class="group-detail-row__label">{{ t('pages-store.groupCatering.fieldCancelReason') }}</text>
|
||||||
|
<text class="group-detail-row__value">{{ detail.cancelReason }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="canCancel" class="group-detail-card mt-24rpx">
|
||||||
|
<view class="text-28rpx text-#333 font-500 mb-20rpx">
|
||||||
|
{{ t('pages-store.groupCatering.fieldCancelReason') }}
|
||||||
|
</view>
|
||||||
|
<textarea
|
||||||
|
v-model="cancelReason"
|
||||||
|
class="group-detail-textarea"
|
||||||
|
:placeholder="t('pages-store.groupCatering.cancelReasonPlaceholder')"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="h-180rpx" />
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]" />
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="canCancel"
|
||||||
|
class="group-detail-page__footer fixed bottom-0 left-0 right-0 px-30rpx pt-16rpx bg-#fff"
|
||||||
|
>
|
||||||
|
<wd-button
|
||||||
|
custom-class="!h-98rpx !text-30rpx !text-#fff !lh-98rpx !rounded-16rpx"
|
||||||
|
block
|
||||||
|
:loading="submitting"
|
||||||
|
@click="confirmCancel"
|
||||||
|
>
|
||||||
|
{{ t('pages-store.groupCatering.cancelAction') }}
|
||||||
|
</wd-button>
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
page {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.group-detail-card {
|
||||||
|
padding: 28rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail-card__merchant {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail-card__status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #fff7ef;
|
||||||
|
color: #ce7138;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24rpx;
|
||||||
|
padding: 18rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f3f3f3;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--column {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail-row__label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail-row__value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
&--accent {
|
||||||
|
color: #e02e24;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail-row--column .group-detail-row__value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #f7f7f7;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-detail-page__footer {
|
||||||
|
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,893 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { dayjs } from '@/plugin/index'
|
||||||
|
import {
|
||||||
|
appGroupMealReservationMyListPost,
|
||||||
|
appGroupMealReservationSubmitPost,
|
||||||
|
appMerchantDetailMerchantIdGet,
|
||||||
|
appMerchantFeaturedListPost,
|
||||||
|
appMerchantNearbyListPost,
|
||||||
|
type GroupMealReservationVo,
|
||||||
|
} from '@/service'
|
||||||
|
import { useConfigStore, useUserStore } from '@/store'
|
||||||
|
import { formatTimestampWithMonthName } from '@/utils/utils'
|
||||||
|
import {
|
||||||
|
calcGroupMealEstimatedTotal,
|
||||||
|
canCancelGroupMeal,
|
||||||
|
formatGroupMealMoney,
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
type PageTab = 'submit' | 'list'
|
||||||
|
const activeTab = ref<PageTab>('submit')
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const selectedMerchant = ref<{ id: string; merchantName: string } | null>(null)
|
||||||
|
const merchantPickerVisible = ref(false)
|
||||||
|
const merchantOptions = ref<Array<{ id: string; merchantName: string }>>([])
|
||||||
|
const merchantLoading = ref(false)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
peopleCount: '1',
|
||||||
|
perCapitaPrice: '',
|
||||||
|
scene: '',
|
||||||
|
contactName: '',
|
||||||
|
contactPhone: '',
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
const expectedDate = ref('')
|
||||||
|
const expectedTime = ref('')
|
||||||
|
|
||||||
|
const sceneOptions = computed(() => [
|
||||||
|
t('pages-store.groupCatering.sceneOptions.company'),
|
||||||
|
t('pages-store.groupCatering.sceneOptions.birthday'),
|
||||||
|
t('pages-store.groupCatering.sceneOptions.school'),
|
||||||
|
t('pages-store.groupCatering.sceneOptions.party'),
|
||||||
|
t('pages-store.groupCatering.sceneOptions.other'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const estimatedTotal = computed(() => {
|
||||||
|
const people = Number(form.value.peopleCount)
|
||||||
|
const price = Number(form.value.perCapitaPrice)
|
||||||
|
return formatGroupMealMoney(calcGroupMealEstimatedTotal({
|
||||||
|
peopleCount: people,
|
||||||
|
perCapitaPrice: price,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const expectedTimeText = computed(() => {
|
||||||
|
if (!expectedDate.value || !expectedTime.value) {
|
||||||
|
return t('pages-store.groupCatering.selectExpectedTime')
|
||||||
|
}
|
||||||
|
return `${expectedDate.value} ${expectedTime.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
const reservationList = ref<GroupMealReservationVo[]>([])
|
||||||
|
const listLoading = ref(false)
|
||||||
|
const listLoadingMore = ref(false)
|
||||||
|
const listQueried = ref(false)
|
||||||
|
const listPageNum = ref(1)
|
||||||
|
const listHasMore = ref(true)
|
||||||
|
|
||||||
|
const showListEmpty = computed(
|
||||||
|
() => listQueried.value && !listLoading.value && reservationList.value.length === 0,
|
||||||
|
)
|
||||||
|
const showListLoading = computed(
|
||||||
|
() => listLoading.value && reservationList.value.length === 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
function prefillContact() {
|
||||||
|
const info = userStore.userInfo ?? {}
|
||||||
|
const nameParts = [info.firstName, info.lastName].filter(Boolean).join(' ').trim()
|
||||||
|
form.value.contactName = nameParts || String(info.nickName ?? '').trim()
|
||||||
|
const phone = String(info.phone ?? '').trim()
|
||||||
|
const areaCode = String(info.areaCode ?? '').trim()
|
||||||
|
form.value.contactPhone = phone
|
||||||
|
? `${areaCode ? `${areaCode} ` : ''}${phone}`.trim()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExpectedTimestamp() {
|
||||||
|
if (!expectedDate.value || !expectedTime.value) return 0
|
||||||
|
const ts = dayjs(`${expectedDate.value} ${expectedTime.value}`).valueOf()
|
||||||
|
return Number.isFinite(ts) ? ts : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
if (!userStore.checkLogin()) return false
|
||||||
|
if (!selectedMerchant.value?.id) {
|
||||||
|
uni.showToast({ title: t('pages-store.groupCatering.validation.merchant'), icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const peopleCount = Number(form.value.peopleCount)
|
||||||
|
if (!Number.isFinite(peopleCount) || peopleCount <= 0) {
|
||||||
|
uni.showToast({ title: t('pages-store.groupCatering.validation.peopleCount'), icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const perCapitaPrice = Number(form.value.perCapitaPrice)
|
||||||
|
if (!Number.isFinite(perCapitaPrice) || perCapitaPrice < 0) {
|
||||||
|
uni.showToast({ title: t('pages-store.groupCatering.validation.perCapitaPrice'), icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.value.scene.trim()) {
|
||||||
|
uni.showToast({ title: t('pages-store.groupCatering.validation.scene'), icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.value.contactName.trim()) {
|
||||||
|
uni.showToast({ title: t('pages-store.groupCatering.validation.contactName'), icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.value.contactPhone.trim()) {
|
||||||
|
uni.showToast({ title: t('pages-store.groupCatering.validation.contactPhone'), icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const expectedTimestamp = buildExpectedTimestamp()
|
||||||
|
if (!expectedTimestamp) {
|
||||||
|
uni.showToast({ title: t('pages-store.groupCatering.validation.expectedTime'), icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!validateForm() || submitting.value || !selectedMerchant.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await appGroupMealReservationSubmitPost({
|
||||||
|
body: {
|
||||||
|
merchantId: selectedMerchant.value.id,
|
||||||
|
peopleCount: Number(form.value.peopleCount),
|
||||||
|
perCapitaPrice: Number(form.value.perCapitaPrice),
|
||||||
|
scene: form.value.scene.trim(),
|
||||||
|
contactName: form.value.contactName.trim(),
|
||||||
|
contactPhone: form.value.contactPhone.trim(),
|
||||||
|
expectedTime: buildExpectedTimestamp(),
|
||||||
|
remark: form.value.remark.trim() || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
uni.showToast({
|
||||||
|
title: t('pages-store.groupCatering.submitSuccess'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
reloadList()
|
||||||
|
activeTab.value = 'list'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMerchantOptions() {
|
||||||
|
merchantLoading.value = true
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
lat: userStore.userLocation.latitude,
|
||||||
|
lng: userStore.userLocation.longitude,
|
||||||
|
}
|
||||||
|
const [featuredRes, nearbyRes] = await Promise.all([
|
||||||
|
appMerchantFeaturedListPost({ body }),
|
||||||
|
appMerchantNearbyListPost({ body }),
|
||||||
|
])
|
||||||
|
const merged = new Map<string, { id: string; merchantName: string }>()
|
||||||
|
const appendList = (list: unknown) => {
|
||||||
|
if (!Array.isArray(list)) return
|
||||||
|
list.forEach((item: any) => {
|
||||||
|
const id = String(item?.id ?? '')
|
||||||
|
const merchantName = String(item?.merchantName ?? '').trim()
|
||||||
|
if (id && merchantName && !merged.has(id)) {
|
||||||
|
merged.set(id, { id, merchantName })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
appendList(featuredRes?.data)
|
||||||
|
appendList(nearbyRes?.data)
|
||||||
|
merchantOptions.value = Array.from(merged.values())
|
||||||
|
} catch {
|
||||||
|
merchantOptions.value = []
|
||||||
|
} finally {
|
||||||
|
merchantLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMerchantPicker() {
|
||||||
|
merchantPickerVisible.value = true
|
||||||
|
if (merchantOptions.value.length === 0) {
|
||||||
|
loadMerchantOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMerchant(item: { id: string; merchantName: string }) {
|
||||||
|
selectedMerchant.value = item
|
||||||
|
merchantPickerVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initMerchantFromQuery(merchantId: string) {
|
||||||
|
if (!merchantId) return
|
||||||
|
try {
|
||||||
|
const res = await appMerchantDetailMerchantIdGet({
|
||||||
|
params: { merchantId },
|
||||||
|
})
|
||||||
|
const name = String(res.data?.merchantName ?? '').trim()
|
||||||
|
if (name) {
|
||||||
|
selectedMerchant.value = { id: merchantId, merchantName: name }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
selectedMerchant.value = { id: merchantId, merchantName: merchantId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReservationRows(res: unknown) {
|
||||||
|
const payload = res as Record<string, unknown> | null | undefined
|
||||||
|
if (!payload) return { rows: [] as GroupMealReservationVo[], total: 0 }
|
||||||
|
const rows = Array.isArray(payload.rows)
|
||||||
|
? payload.rows
|
||||||
|
: Array.isArray((payload.data as Record<string, unknown> | undefined)?.rows)
|
||||||
|
? ((payload.data as Record<string, unknown>).rows as GroupMealReservationVo[])
|
||||||
|
: []
|
||||||
|
const total = Number(
|
||||||
|
payload.total ?? (payload.data as Record<string, unknown> | undefined)?.total ?? rows.length,
|
||||||
|
)
|
||||||
|
return { rows, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReservationList(reset = false) {
|
||||||
|
if (reset) {
|
||||||
|
listPageNum.value = 1
|
||||||
|
listHasMore.value = true
|
||||||
|
listLoading.value = true
|
||||||
|
listQueried.value = false
|
||||||
|
} else if (!listHasMore.value || listLoadingMore.value || listLoading.value) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
listLoadingMore.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userStore.isLogin) {
|
||||||
|
reservationList.value = []
|
||||||
|
listHasMore.value = false
|
||||||
|
listLoading.value = false
|
||||||
|
listLoadingMore.value = false
|
||||||
|
listQueried.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await appGroupMealReservationMyListPost({
|
||||||
|
params: { pageNum: listPageNum.value, pageSize: PAGE_SIZE },
|
||||||
|
body: {},
|
||||||
|
})
|
||||||
|
const { rows, total } = parseReservationRows(res)
|
||||||
|
reservationList.value = reset ? rows : [...reservationList.value, ...rows]
|
||||||
|
listHasMore.value = reservationList.value.length < total
|
||||||
|
if (listHasMore.value) {
|
||||||
|
listPageNum.value += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (reset) {
|
||||||
|
reservationList.value = []
|
||||||
|
}
|
||||||
|
listHasMore.value = false
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false
|
||||||
|
listLoadingMore.value = false
|
||||||
|
listQueried.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadList() {
|
||||||
|
fetchReservationList(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab: PageTab) {
|
||||||
|
activeTab.value = tab
|
||||||
|
if (tab === 'list') {
|
||||||
|
reloadList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(status?: number) {
|
||||||
|
const keyMap: Record<number, string> = {
|
||||||
|
1: 'statusPending',
|
||||||
|
2: 'statusContacted',
|
||||||
|
3: 'statusConfirmed',
|
||||||
|
4: 'statusRejected',
|
||||||
|
5: 'statusCompleted',
|
||||||
|
6: 'statusCancelled',
|
||||||
|
}
|
||||||
|
const key = keyMap[Number(status)]
|
||||||
|
return key ? t(`pages-store.groupCatering.${key}`) : '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(item: GroupMealReservationVo) {
|
||||||
|
if (!item.id) return
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-store/pages/group-catering/detail?id=${item.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateChange(e: { detail: { value: string } }) {
|
||||||
|
expectedDate.value = e.detail.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTimeChange(e: { detail: { value: string } }) {
|
||||||
|
expectedTime.value = e.detail.value
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query?: Record<string, string | undefined>) => {
|
||||||
|
prefillContact()
|
||||||
|
if (query?.merchantId) {
|
||||||
|
initMerchantFromQuery(String(query.merchantId))
|
||||||
|
}
|
||||||
|
if (query?.tab === 'list') {
|
||||||
|
activeTab.value = 'list'
|
||||||
|
reloadList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (activeTab.value === 'list' && listQueried.value) {
|
||||||
|
reloadList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="group-catering-page">
|
||||||
|
<navbar :title="t('pages-store.groupCatering.title')" circle-back />
|
||||||
|
|
||||||
|
<view class="group-catering-tabs px-30rpx pt-16rpx pb-8rpx bg-#fff">
|
||||||
|
<view class="group-catering-tabs__inner">
|
||||||
|
<view
|
||||||
|
class="group-catering-tabs__item"
|
||||||
|
:class="{ 'group-catering-tabs__item--active': activeTab === 'submit' }"
|
||||||
|
@click="switchTab('submit')"
|
||||||
|
>
|
||||||
|
{{ t('pages-store.groupCatering.tabSubmit') }}
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="group-catering-tabs__item"
|
||||||
|
:class="{ 'group-catering-tabs__item--active': activeTab === 'list' }"
|
||||||
|
@click="switchTab('list')"
|
||||||
|
>
|
||||||
|
{{ t('pages-store.groupCatering.tabList') }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="activeTab === 'submit'" class="group-catering-form px-30rpx pt-24rpx">
|
||||||
|
<view class="group-form-card">
|
||||||
|
<text class="group-form-card__intro">{{ t('pages-store.groupCatering.intro') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-card mt-24rpx">
|
||||||
|
<view class="group-form-item" @click="openMerchantPicker">
|
||||||
|
<text class="group-form-item__label">{{ t('pages-store.groupCatering.fieldMerchant') }}</text>
|
||||||
|
<view class="group-form-item__value-wrap">
|
||||||
|
<text
|
||||||
|
class="group-form-item__value"
|
||||||
|
:class="{ 'group-form-item__value--placeholder': !selectedMerchant }"
|
||||||
|
>
|
||||||
|
{{ selectedMerchant?.merchantName || t('pages-store.groupCatering.selectMerchant') }}
|
||||||
|
</text>
|
||||||
|
<text class="group-form-item__arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-item">
|
||||||
|
<text class="group-form-item__label">{{ t('pages-store.groupCatering.fieldPeopleCount') }}</text>
|
||||||
|
<input
|
||||||
|
v-model="form.peopleCount"
|
||||||
|
class="group-form-item__input"
|
||||||
|
type="number"
|
||||||
|
:placeholder="t('pages-store.groupCatering.peopleCountPlaceholder')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-item">
|
||||||
|
<text class="group-form-item__label">{{ t('pages-store.groupCatering.fieldPerCapitaPrice') }}</text>
|
||||||
|
<input
|
||||||
|
v-model="form.perCapitaPrice"
|
||||||
|
class="group-form-item__input"
|
||||||
|
type="digit"
|
||||||
|
:placeholder="t('pages-store.groupCatering.perCapitaPricePlaceholder')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-item group-form-item--column">
|
||||||
|
<text class="group-form-item__label">{{ t('pages-store.groupCatering.fieldEstimatedTotal') }}</text>
|
||||||
|
<text class="group-form-item__estimate">${{ estimatedTotal }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-item group-form-item--column">
|
||||||
|
<text class="group-form-item__label">{{ t('pages-store.groupCatering.fieldScene') }}</text>
|
||||||
|
<view class="group-form-scenes">
|
||||||
|
<view
|
||||||
|
v-for="scene in sceneOptions"
|
||||||
|
:key="scene"
|
||||||
|
class="group-form-scene"
|
||||||
|
:class="{ 'group-form-scene--active': form.scene === scene }"
|
||||||
|
@click="form.scene = scene"
|
||||||
|
>
|
||||||
|
{{ scene }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<input
|
||||||
|
v-model="form.scene"
|
||||||
|
class="group-form-item__input mt-16rpx"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('pages-store.groupCatering.scenePlaceholder')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-item">
|
||||||
|
<text class="group-form-item__label">{{ t('pages-store.groupCatering.fieldExpectedTime') }}</text>
|
||||||
|
<view class="group-form-item__picker-row">
|
||||||
|
<picker mode="date" :value="expectedDate" @change="onDateChange">
|
||||||
|
<view class="group-form-picker">{{ expectedDate || t('pages-store.groupCatering.selectDate') }}</view>
|
||||||
|
</picker>
|
||||||
|
<picker mode="time" :value="expectedTime" @change="onTimeChange">
|
||||||
|
<view class="group-form-picker">{{ expectedTime || t('pages-store.groupCatering.selectTime') }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<text class="group-form-item__hint">{{ expectedTimeText }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-item">
|
||||||
|
<text class="group-form-item__label">{{ t('pages-store.groupCatering.fieldContactName') }}</text>
|
||||||
|
<input
|
||||||
|
v-model="form.contactName"
|
||||||
|
class="group-form-item__input"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('pages-store.groupCatering.contactNamePlaceholder')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-item">
|
||||||
|
<text class="group-form-item__label">{{ t('pages-store.groupCatering.fieldContactPhone') }}</text>
|
||||||
|
<input
|
||||||
|
v-model="form.contactPhone"
|
||||||
|
class="group-form-item__input"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('pages-store.groupCatering.contactPhonePlaceholder')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-item group-form-item--column">
|
||||||
|
<text class="group-form-item__label">{{ t('pages-store.groupCatering.fieldRemark') }}</text>
|
||||||
|
<textarea
|
||||||
|
v-model="form.remark"
|
||||||
|
class="group-form-textarea"
|
||||||
|
:placeholder="t('pages-store.groupCatering.remarkPlaceholder')"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-form-tips mt-24rpx">
|
||||||
|
<text>{{ t('pages-store.groupCatering.tips') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="h-180rpx" />
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]" />
|
||||||
|
|
||||||
|
<view class="group-catering-page__footer fixed bottom-0 left-0 right-0 px-30rpx pt-16rpx bg-#fff">
|
||||||
|
<wd-button
|
||||||
|
custom-class="!h-98rpx !text-30rpx !text-#fff !lh-98rpx !rounded-16rpx"
|
||||||
|
block
|
||||||
|
:loading="submitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ t('pages-store.groupCatering.submitAction') }}
|
||||||
|
</wd-button>
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="group-catering-list-panel">
|
||||||
|
<view v-if="showListLoading" class="group-catering-loading">
|
||||||
|
<text class="group-catering-loading__text">{{ t('common.loading') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="showListEmpty" class="group-catering-empty">
|
||||||
|
<image
|
||||||
|
class="group-catering-empty__img"
|
||||||
|
src="@img/chef/100.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text class="group-catering-empty__text">
|
||||||
|
{{ t('pages-store.groupCatering.listEmpty') }}
|
||||||
|
</text>
|
||||||
|
<view class="group-catering-empty__btn" @click="switchTab('submit')">
|
||||||
|
{{ t('pages-store.groupCatering.emptyAction') }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
v-else
|
||||||
|
scroll-y
|
||||||
|
class="group-catering-scroll"
|
||||||
|
:lower-threshold="80"
|
||||||
|
@scrolltolower="fetchReservationList(false)"
|
||||||
|
>
|
||||||
|
<view class="px-30rpx pt-24rpx">
|
||||||
|
<view
|
||||||
|
v-for="item in reservationList"
|
||||||
|
:key="String(item.id)"
|
||||||
|
class="group-list-card"
|
||||||
|
@click="openDetail(item)"
|
||||||
|
>
|
||||||
|
<view class="group-list-card__head">
|
||||||
|
<text class="group-list-card__merchant">{{ item.merchant?.merchantName || '--' }}</text>
|
||||||
|
<text class="group-list-card__status">{{ getStatusText(item.status) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-list-card__row">
|
||||||
|
<text>{{ t('pages-store.groupCatering.fieldPeopleCount') }}: {{ item.peopleCount ?? '--' }}</text>
|
||||||
|
<text>${{ formatGroupMealMoney(Number(item.perCapitaPrice) || 0) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-list-card__row">
|
||||||
|
<text>{{ t('pages-store.groupCatering.fieldEstimatedTotal') }}: ${{ formatGroupMealMoney(calcGroupMealEstimatedTotal(item)) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="group-list-card__row">
|
||||||
|
<text>{{ formatTimestampWithMonthName(Number(item.expectedTime)) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="canCancelGroupMeal(Number(item.status))" class="group-list-card__action">
|
||||||
|
{{ t('pages-store.groupCatering.viewAndCancel') }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="listLoadingMore" class="group-catering-loading-more">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</view>
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]" />
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<wd-popup
|
||||||
|
v-model="merchantPickerVisible"
|
||||||
|
position="bottom"
|
||||||
|
custom-style="border-radius: 24rpx 24rpx 0 0;"
|
||||||
|
>
|
||||||
|
<view class="merchant-picker">
|
||||||
|
<view class="merchant-picker__title">{{ t('pages-store.groupCatering.selectMerchant') }}</view>
|
||||||
|
<scroll-view scroll-y class="merchant-picker__list">
|
||||||
|
<view v-if="merchantLoading" class="center py-80rpx text-28rpx text-#999">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="item in merchantOptions"
|
||||||
|
:key="item.id"
|
||||||
|
class="merchant-picker__item"
|
||||||
|
@click="selectMerchant(item)"
|
||||||
|
>
|
||||||
|
{{ item.merchantName }}
|
||||||
|
</view>
|
||||||
|
<view v-if="!merchantLoading && merchantOptions.length === 0" class="center py-80rpx text-28rpx text-#999">
|
||||||
|
{{ t('pages-store.groupCatering.merchantEmpty') }}
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]" />
|
||||||
|
</view>
|
||||||
|
</wd-popup>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
page {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.group-catering-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-list-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: calc(100vh - 280rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-scroll {
|
||||||
|
height: calc(100vh - 280rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 280rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-loading__text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-loading-more {
|
||||||
|
padding: 24rpx 0 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-tabs__inner {
|
||||||
|
display: flex;
|
||||||
|
padding: 8rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-tabs__item {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-card {
|
||||||
|
padding: 28rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-card__intro {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-item {
|
||||||
|
padding: 24rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f3f3f3;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-item__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-item__value-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-item__value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
&--placeholder {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-item__arrow {
|
||||||
|
color: #999;
|
||||||
|
font-size: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-item__input {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f7f7f7;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-item__estimate {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e02e24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-item__picker-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-picker {
|
||||||
|
flex: 1;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f7f7f7;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-item__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-scenes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-scene {
|
||||||
|
padding: 12rpx 20rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #f7f7f7;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #fff7ef;
|
||||||
|
color: #ce7138;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f7f7f7;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-form-tips {
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fff7ef;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #a0672d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-list-card {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-list-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-list-card__merchant {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-list-card__status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #fff7ef;
|
||||||
|
color: #ce7138;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-list-card__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-list-card__action {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ce7138;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-page__footer {
|
||||||
|
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-picker {
|
||||||
|
max-height: 70vh;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-picker__title {
|
||||||
|
padding: 28rpx 30rpx 16rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-picker__list {
|
||||||
|
max-height: 56vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-picker__item {
|
||||||
|
padding: 28rpx 30rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 1rpx solid #f3f3f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 280rpx);
|
||||||
|
padding: 80rpx 40rpx 120rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-empty__img {
|
||||||
|
width: 280rpx;
|
||||||
|
height: 280rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-empty__text {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-catering-empty__btn {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
min-width: 280rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
background: #14181b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { GroupMealReservationVo } from '@/service/groupMealReservation'
|
||||||
|
|
||||||
|
/** 消费端团餐预定状态 */
|
||||||
|
export const GROUP_MEAL_STATUS = {
|
||||||
|
PENDING: 1,
|
||||||
|
CONTACTED: 2,
|
||||||
|
CONFIRMED: 3,
|
||||||
|
REJECTED: 4,
|
||||||
|
COMPLETED: 5,
|
||||||
|
USER_CANCELLED: 6,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function canCancelGroupMeal(status?: number) {
|
||||||
|
return status === GROUP_MEAL_STATUS.PENDING || status === GROUP_MEAL_STATUS.CONTACTED
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcGroupMealEstimatedTotal(item: Pick<
|
||||||
|
GroupMealReservationVo,
|
||||||
|
'peopleCount' | 'perCapitaPrice'
|
||||||
|
>) {
|
||||||
|
const people = Number(item.peopleCount)
|
||||||
|
const price = Number(item.perCapitaPrice)
|
||||||
|
if (!Number.isFinite(people) || !Number.isFinite(price) || people <= 0 || price < 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return people * price
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGroupMealMoney(value: number) {
|
||||||
|
return value.toFixed(2)
|
||||||
|
}
|
||||||
@@ -432,13 +432,13 @@ onShow(() => {
|
|||||||
</view>
|
</view>
|
||||||
<view v-else class="flex-1 min-w-0" />
|
<view v-else class="flex-1 min-w-0" />
|
||||||
<view
|
<view
|
||||||
class="cat-dish-add center shrink-0"
|
class="cat-dish-add shrink-0"
|
||||||
:class="{
|
:class="{
|
||||||
'cat-dish-add--busy': addingDishId === item.id,
|
'cat-dish-add--busy': addingDishId === item.id,
|
||||||
}"
|
}"
|
||||||
@click.stop="onAddCartClick(item)"
|
@click.stop="onAddCartClick(item)"
|
||||||
>
|
>
|
||||||
<image src="@img/chef/1285.png" class="w-28rpx h-28rpx" />
|
<image src="/static/app/images/add_cart.png" class="cat-dish-add__icon" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
@@ -698,11 +698,13 @@ onShow(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cat-dish-add {
|
.cat-dish-add {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-dish-add__icon {
|
||||||
width: 56rpx;
|
width: 56rpx;
|
||||||
height: 56rpx;
|
height: 56rpx;
|
||||||
border-radius: 50%;
|
display: block;
|
||||||
background: #14181b;
|
|
||||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-dish-add--busy {
|
.cat-dish-add--busy {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const emit = defineEmits(['confirm']);
|
|||||||
|
|
||||||
const show = ref(false);
|
const show = ref(false);
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = computed(() => [
|
||||||
{
|
{
|
||||||
label: t('pages-store.store.cancelOrder.informationError'),
|
label: t('pages-store.store.cancelOrder.informationError'),
|
||||||
value: 'time'
|
value: 'time'
|
||||||
@@ -23,26 +23,29 @@ const sortOptions = [
|
|||||||
label: t('pages-store.store.cancelOrder.dontWant'),
|
label: t('pages-store.store.cancelOrder.dontWant'),
|
||||||
value: 'comment'
|
value: 'comment'
|
||||||
}
|
}
|
||||||
];
|
]);
|
||||||
const currentSort = ref(0);
|
const currentSort = ref(0);
|
||||||
function handleClick(index: number) {
|
function handleClick(index: number) {
|
||||||
// show.value = false;
|
|
||||||
currentSort.value = index;
|
currentSort.value = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmCancel() {
|
function confirmCancel() {
|
||||||
console.log('取消订单', sortOptions[currentSort.value].label)
|
emit('confirm', sortOptions.value[currentSort.value].label);
|
||||||
emit('confirm', sortOptions[currentSort.value].label);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOpen() {
|
function onOpen() {
|
||||||
|
currentSort.value = 0;
|
||||||
show.value = true;
|
show.value = true;
|
||||||
}
|
}
|
||||||
function handleClose() {
|
function onClose() {
|
||||||
show.value = false;
|
show.value = false;
|
||||||
}
|
}
|
||||||
|
function handleClose() {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
defineExpose({
|
defineExpose({
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onClose,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -59,7 +62,7 @@ defineExpose({
|
|||||||
class="w-28rpx h-28rpx absolute top-30rpx right-30rpx"
|
class="w-28rpx h-28rpx absolute top-30rpx right-30rpx"
|
||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
/>
|
/>
|
||||||
<view class="text-36rpx lh-36rpx text-#333 font-bold text-center mb-68rpx">{{ t('common.cancel') }}</view>
|
<view class="text-36rpx lh-36rpx text-#333 font-bold text-center mb-68rpx">{{ t('pages.order.cancelOrder') }}</view>
|
||||||
<template v-for="(item, index) in sortOptions">
|
<template v-for="(item, index) in sortOptions">
|
||||||
<view @click="handleClick(index)" class="flex items-center mb-42rpx last:mb-0">
|
<view @click="handleClick(index)" class="flex items-center mb-42rpx last:mb-0">
|
||||||
<view class="w-48rpx h-48rpx shrink-0 mr-20rpx">
|
<view class="w-48rpx h-48rpx shrink-0 mr-20rpx">
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ import {
|
|||||||
appMerchantOrderPayOrderPost,
|
appMerchantOrderPayOrderPost,
|
||||||
appMerchantOrderCancelOrderPost,
|
appMerchantOrderCancelOrderPost,
|
||||||
appMerchantOrderZipPayVoucherPost,
|
appMerchantOrderZipPayVoucherPost,
|
||||||
|
appMerchantOrderZipPayVoucherBatchPost,
|
||||||
} from "@/service";
|
} from "@/service";
|
||||||
import ChooseImage from '@/components/choose-image/choose-image.vue'
|
import ChooseImage from '@/components/choose-image/choose-image.vue'
|
||||||
|
import {
|
||||||
|
resolveRequestErrorMessage,
|
||||||
|
isZipAmountChangedError,
|
||||||
|
isZipPendingReviewError,
|
||||||
|
stringifyOrderIds,
|
||||||
|
} from '@/pages-store/pages/order/utils/checkout-order'
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
import OrderProgress from './components/order-progress.vue'
|
import OrderProgress from './components/order-progress.vue'
|
||||||
import OrderDetailSkeleton from './components/order-detail-skeleton.vue'
|
import OrderDetailSkeleton from './components/order-detail-skeleton.vue'
|
||||||
@@ -20,16 +27,6 @@ import dayjs from 'dayjs'
|
|||||||
import useEventEmit from "@/hooks/useEventEmit";
|
import useEventEmit from "@/hooks/useEventEmit";
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
|
|
||||||
function fillI18nParams(template: string, params: Record<string, string | number>) {
|
|
||||||
let text = template
|
|
||||||
Object.keys(params).forEach((key) => {
|
|
||||||
const value = String(params[key] ?? '')
|
|
||||||
text = text.replace(new RegExp(`\\{${key}\\}`, 'g'), value)
|
|
||||||
text = text.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value)
|
|
||||||
})
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
// 价格明细
|
// 价格明细
|
||||||
const priceDetailRef = ref<InstanceType<typeof PriceDetail>>();
|
const priceDetailRef = ref<InstanceType<typeof PriceDetail>>();
|
||||||
// 打开价格明细
|
// 打开价格明细
|
||||||
@@ -48,15 +45,15 @@ const openCancelOrder = () => {
|
|||||||
cancelOrderRef.value?.onOpen();
|
cancelOrderRef.value?.onOpen();
|
||||||
};
|
};
|
||||||
function confirmCancel(reason: string) {
|
function confirmCancel(reason: string) {
|
||||||
console.log('取消订单', reason)
|
|
||||||
appMerchantOrderCancelOrderPost({
|
appMerchantOrderCancelOrderPost({
|
||||||
body: {
|
body: {
|
||||||
orderId: orderDetail.value.id,
|
orderId: orderDetail.value.id,
|
||||||
cancelReason: reason,
|
cancelReason: reason,
|
||||||
}
|
}
|
||||||
}).then(res=> {
|
}).then(res=> {
|
||||||
|
cancelOrderRef.value?.onClose()
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '取消订单成功',
|
title: t('pages-store.order.cancelSuccess'),
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -84,13 +81,23 @@ const orderStepsCancel = ref([
|
|||||||
// 页面加载状态
|
// 页面加载状态
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const orderId = ref('')
|
const orderId = ref('')
|
||||||
|
const zipVoucherOrderIds = ref<string[]>([])
|
||||||
|
const zipVoucherPaymentAmount = ref(0)
|
||||||
|
|
||||||
onLoad((options: any)=> {
|
onLoad((options: any)=> {
|
||||||
if(options.id) {
|
if(options.id) {
|
||||||
orderId.value = options.id;
|
orderId.value = options.id;
|
||||||
// appMerchantOrderDetail()
|
|
||||||
// 查询用户默认信用卡
|
|
||||||
appUserCardSelectDefault()
|
|
||||||
}
|
}
|
||||||
|
if (options?.orderIds) {
|
||||||
|
zipVoucherOrderIds.value = stringifyOrderIds(String(options.orderIds).split(','))
|
||||||
|
}
|
||||||
|
if (options?.paymentAmount != null && options?.paymentAmount !== '') {
|
||||||
|
const amount = Number(options.paymentAmount)
|
||||||
|
if (Number.isFinite(amount) && amount > 0) {
|
||||||
|
zipVoucherPaymentAmount.value = amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appUserCardSelectDefault()
|
||||||
})
|
})
|
||||||
|
|
||||||
onShow(()=> {
|
onShow(()=> {
|
||||||
@@ -102,13 +109,19 @@ onShow(()=> {
|
|||||||
const orderDetail = ref<MerchantOrderVo>()
|
const orderDetail = ref<MerchantOrderVo>()
|
||||||
function appMerchantOrderDetail() {
|
function appMerchantOrderDetail() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
appMerchantOrderDetailPost({
|
return appMerchantOrderDetailPost({
|
||||||
params: {
|
params: {
|
||||||
orderId: orderId.value,
|
orderId: orderId.value,
|
||||||
}
|
}
|
||||||
}).then((res: any)=> {
|
}).then((res: any)=> {
|
||||||
console.log('订单详情', res)
|
console.log('订单详情', res)
|
||||||
orderDetail.value = res.data
|
orderDetail.value = res.data
|
||||||
|
if (!zipVoucherOrderIds.value.length && orderDetail.value?.id) {
|
||||||
|
zipVoucherOrderIds.value = stringifyOrderIds([orderDetail.value.id])
|
||||||
|
}
|
||||||
|
if (zipVoucherPaymentAmount.value <= 0 && orderDetail.value?.paidAmount != null) {
|
||||||
|
zipVoucherPaymentAmount.value = Number(orderDetail.value.paidAmount) || 0
|
||||||
|
}
|
||||||
// 是自取订单还是配送订单 1-派送 2-自取
|
// 是自取订单还是配送订单 1-派送 2-自取
|
||||||
if(orderDetail.value) {
|
if(orderDetail.value) {
|
||||||
if(+orderDetail.value.receiveMethod === 2) {
|
if(+orderDetail.value.receiveMethod === 2) {
|
||||||
@@ -127,6 +140,23 @@ const orderStatus = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 税费及其他费用:按订单维度展示,非承载订单 deliveryFee/tip 为 0 */
|
||||||
|
const taxesAndOtherFeesTotal = computed(() => {
|
||||||
|
const d = orderDetail.value
|
||||||
|
return (
|
||||||
|
(Number(d?.tax) || 0) +
|
||||||
|
(Number(d?.tip) || 0) +
|
||||||
|
(Number(d?.deliveryFee) || 0)
|
||||||
|
).toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const orderDeliveryDateLabel = computed(() => {
|
||||||
|
const date = String((orderDetail.value as any)?.deliveryDate ?? '').trim()
|
||||||
|
if (!date) return ''
|
||||||
|
const d = dayjs(date)
|
||||||
|
return d.isValid() ? d.format('MM/DD/YYYY') : date
|
||||||
|
})
|
||||||
|
|
||||||
// ====== 为设计稿准备的数据结构(商品缩略卡/总件数/总价等)======
|
// ====== 为设计稿准备的数据结构(商品缩略卡/总件数/总价等)======
|
||||||
const orderDishList = computed(() => {
|
const orderDishList = computed(() => {
|
||||||
const list = orderDetail.value?.merchantOrderDishVoList as unknown as Array<any> | null | undefined
|
const list = orderDetail.value?.merchantOrderDishVoList as unknown as Array<any> | null | undefined
|
||||||
@@ -139,9 +169,7 @@ const orderTotalItemCount = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const orderTotalItemCountText = computed(() => {
|
const orderTotalItemCountText = computed(() => {
|
||||||
return fillI18nParams(t('pages.order.totalItemCount'), {
|
return `${t('pages.order.totalItemCountPrefix')}${orderTotalItemCount.value}${t('pages.order.totalItemCountSuffix')}`
|
||||||
count: orderTotalItemCount.value,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const orderTotalPrice = computed(() => {
|
const orderTotalPrice = computed(() => {
|
||||||
@@ -160,10 +188,11 @@ function dishTitle(dishItem: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payMethodText = computed(() => {
|
const payMethodText = computed(() => {
|
||||||
// 1-信用卡 2-余额(wallet)
|
const method = orderDetail.value?.payMethod
|
||||||
return orderDetail.value?.payMethod === 1
|
if (method === 1) return t('pages-user.choosePaymethod.creditCard')
|
||||||
? t('pages-user.choosePaymethod.creditCard')
|
if (method === 2) return t('pages-user.choosePaymethod.wallet')
|
||||||
: t('pages-user.choosePaymethod.wallet')
|
if (method === 3) return t('pages-store.order.payMethodZip')
|
||||||
|
return '--'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 复制订单号
|
// 复制订单号
|
||||||
@@ -188,6 +217,7 @@ const callPhoneFn = (phone: string) => {
|
|||||||
const payMethodOptions = ref({
|
const payMethodOptions = ref({
|
||||||
orderId: '',
|
orderId: '',
|
||||||
cardId: '',
|
cardId: '',
|
||||||
|
cardNumber: '',
|
||||||
payMethod: 1, // 支付方式 1信用卡 2余额
|
payMethod: 1, // 支付方式 1信用卡 2余额
|
||||||
payPassword: '',
|
payPassword: '',
|
||||||
})
|
})
|
||||||
@@ -216,9 +246,20 @@ function goPay() {
|
|||||||
// 如果是余额支付,弹出支付密码弹窗
|
// 如果是余额支付,弹出支付密码弹窗
|
||||||
if(payMethodOptions.value.payMethod === 2) {
|
if(payMethodOptions.value.payMethod === 2) {
|
||||||
passwordInputRef.value?.showPasswordInput()
|
passwordInputRef.value?.showPasswordInput()
|
||||||
} else {
|
return
|
||||||
appMerchantOrderPayOrder()
|
|
||||||
}
|
}
|
||||||
|
// 信用卡支付:必须已绑定卡(与 appMerchantOrderPayOrder 入参一致,避免 cardId/cardNumber 为空仍请求)
|
||||||
|
if (payMethodOptions.value.payMethod === 1) {
|
||||||
|
const cardId = String(payMethodOptions.value.cardId || '').trim()
|
||||||
|
if (!cardId) {
|
||||||
|
uni.showToast({
|
||||||
|
title: t('pages-store.order.pleaseBindCreditCard'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appMerchantOrderPayOrder()
|
||||||
}
|
}
|
||||||
function payPawSuccess(password: string) {
|
function payPawSuccess(password: string) {
|
||||||
payMethodOptions.value.payPassword = password
|
payMethodOptions.value.payPassword = password
|
||||||
@@ -320,6 +361,37 @@ function normalizeVoucherUrl(payload: unknown): string {
|
|||||||
return typeof payload === 'string' ? payload : ''
|
return typeof payload === 'string' ? payload : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitZipPayVoucher(zipPayVoucher: string) {
|
||||||
|
const orderIds = zipVoucherOrderIds.value.length
|
||||||
|
? zipVoucherOrderIds.value
|
||||||
|
: stringifyOrderIds([orderDetail.value?.id])
|
||||||
|
const paymentAmount = zipVoucherPaymentAmount.value > 0
|
||||||
|
? zipVoucherPaymentAmount.value
|
||||||
|
: Number(orderDetail.value?.paidAmount ?? 0)
|
||||||
|
if (!orderIds.length || paymentAmount <= 0) {
|
||||||
|
throw new Error(t('pages-store.order.zipPayAmountInvalid'))
|
||||||
|
}
|
||||||
|
if (orderIds.length > 1) {
|
||||||
|
await appMerchantOrderZipPayVoucherBatchPost({
|
||||||
|
body: {
|
||||||
|
orderIds,
|
||||||
|
zipPayVoucher,
|
||||||
|
paymentAmount,
|
||||||
|
},
|
||||||
|
options: { hideErrorToast: true },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await appMerchantOrderZipPayVoucherPost({
|
||||||
|
body: {
|
||||||
|
orderId: orderIds[0],
|
||||||
|
zipPayVoucher,
|
||||||
|
paymentAmount,
|
||||||
|
},
|
||||||
|
options: { hideErrorToast: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function onVoucherImageUploaded(urls: unknown) {
|
async function onVoucherImageUploaded(urls: unknown) {
|
||||||
const zipPayVoucher = normalizeVoucherUrl(urls)
|
const zipPayVoucher = normalizeVoucherUrl(urls)
|
||||||
if (!zipPayVoucher) {
|
if (!zipPayVoucher) {
|
||||||
@@ -334,22 +406,27 @@ async function onVoucherImageUploaded(urls: unknown) {
|
|||||||
if (voucherSubmitting.value) return
|
if (voucherSubmitting.value) return
|
||||||
voucherSubmitting.value = true
|
voucherSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await appMerchantOrderZipPayVoucherPost({
|
await submitZipPayVoucher(zipPayVoucher)
|
||||||
body: {
|
|
||||||
orderId: id,
|
|
||||||
zipPayVoucher,
|
|
||||||
},
|
|
||||||
options: { hideErrorToast: true },
|
|
||||||
})
|
|
||||||
uni.showToast({ title: t('pages-store.order.voucherSubmitSuccess'), icon: 'none' })
|
uni.showToast({ title: t('pages-store.order.voucherSubmitSuccess'), icon: 'none' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
appMerchantOrderDetail()
|
appMerchantOrderDetail()
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
} catch (err) {
|
||||||
catch {
|
const message = resolveRequestErrorMessage(err, t('pages-store.order.voucherSubmitFailed'))
|
||||||
uni.showToast({ title: t('pages-store.order.voucherSubmitFailed'), icon: 'none' })
|
if (isZipAmountChangedError(message)) {
|
||||||
}
|
await appMerchantOrderDetail()
|
||||||
finally {
|
if (zipVoucherPaymentAmount.value <= 0) {
|
||||||
|
zipVoucherPaymentAmount.value = Number(orderDetail.value?.paidAmount ?? 0)
|
||||||
|
}
|
||||||
|
uni.showToast({ title: t('pages-store.order.zipPayAmountChanged'), icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isZipPendingReviewError(message)) {
|
||||||
|
uni.showToast({ title: t('pages-store.order.zipPayPendingReview'), icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showToast({ title: message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
voucherSubmitting.value = false
|
voucherSubmitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,6 +648,12 @@ async function onVoucherImageUploaded(urls: unknown) {
|
|||||||
|
|
||||||
<view v-if="orderDetail?.receiveMethod === 1" class="pt-36rpx bg-white">
|
<view v-if="orderDetail?.receiveMethod === 1" class="pt-36rpx bg-white">
|
||||||
<view class="text-36rpx lh-36rpx text-#333 font-500 pl-30rpx mb-4rpx">{{ t('pages-store.order.deliveryAddress') }}</view>
|
<view class="text-36rpx lh-36rpx text-#333 font-500 pl-30rpx mb-4rpx">{{ t('pages-store.order.deliveryAddress') }}</view>
|
||||||
|
<view
|
||||||
|
v-if="orderDeliveryDateLabel"
|
||||||
|
class="text-28rpx lh-28rpx text-#6D6D6D pl-30rpx mb-16rpx"
|
||||||
|
>
|
||||||
|
{{ t('pages-store.order.deliveryDate') }}: {{ orderDeliveryDateLabel }}
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 收货地址 -->
|
<!-- 收货地址 -->
|
||||||
<view class="flex items-center border-bottom py-36rpx px-30rpx">
|
<view class="flex items-center border-bottom py-36rpx px-30rpx">
|
||||||
@@ -698,7 +781,7 @@ async function onVoucherImageUploaded(urls: unknown) {
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view>
|
<view>
|
||||||
<text>${{ (Number(orderDetail?.tax) + Number(orderDetail?.tip) + Number(orderDetail?.deliveryFee)).toFixed(2) }}</text>
|
<text>${{ taxesAndOtherFeesTotal }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex-center-sb">
|
<view class="flex-center-sb">
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
|
import type { MerchantAppointmentSlot } from '@/utils/deliverySchedule'
|
||||||
|
|
||||||
|
/** 小费选项:下次一定($0) */
|
||||||
|
export const TIP_NEXT_TIME = -1
|
||||||
|
|
||||||
|
export function formatDeliveryDateKey(startTime?: number | string | null): string {
|
||||||
|
if (startTime == null || startTime === '') return ''
|
||||||
|
const d = dayjs(Number(startTime))
|
||||||
|
return d.isValid() ? d.format('YYYY-MM-DD') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDeliveryDateLabel(dateKey: string): string {
|
||||||
|
if (!dateKey) return ''
|
||||||
|
const d = dayjs(dateKey)
|
||||||
|
return d.isValid() ? d.format('MM/DD') : dateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectDeliveryDatesFromMerchants(
|
||||||
|
merchants: Array<{ id?: string | number }>,
|
||||||
|
appointmentMap: Record<string, MerchantAppointmentSlot>,
|
||||||
|
): string[] {
|
||||||
|
const set = new Set<string>()
|
||||||
|
merchants.forEach((m) => {
|
||||||
|
const dateKey = formatDeliveryDateKey(appointmentMap[String(m.id)]?.startTime)
|
||||||
|
if (dateKey) set.add(dateKey)
|
||||||
|
})
|
||||||
|
return Array.from(set).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTipAmount(tipIndex: number, diyTip?: string): number {
|
||||||
|
const diy = String(diyTip ?? '').trim()
|
||||||
|
if (diy) return Number(diy) || 0
|
||||||
|
if (tipIndex === TIP_NEXT_TIME || tipIndex <= 0) return 0
|
||||||
|
return tipIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeliveryDateTipMap(
|
||||||
|
dates: string[],
|
||||||
|
indexMap: Record<string, number>,
|
||||||
|
diyMap: Record<string, string>,
|
||||||
|
): Record<string, number> {
|
||||||
|
const map: Record<string, number> = {}
|
||||||
|
dates.forEach((date) => {
|
||||||
|
map[date] = resolveTipAmount(indexMap[date] ?? TIP_NEXT_TIME, diyMap[date])
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReceiveMethod(deliveryMethodIndex: number): number {
|
||||||
|
return deliveryMethodIndex === 0 ? 1 : 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalAddressId(
|
||||||
|
receiveMethod: number,
|
||||||
|
addressId: string | number | undefined | null,
|
||||||
|
): string | undefined {
|
||||||
|
if (receiveMethod !== 1) return undefined
|
||||||
|
const id = String(addressId ?? '').trim()
|
||||||
|
return id || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifyId(id: string | number | undefined | null): string | undefined {
|
||||||
|
const s = String(id ?? '').trim()
|
||||||
|
return s || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifyCartIds(ids: Array<string | number>): string[] {
|
||||||
|
return ids.map((id) => String(id)).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SchedulePayload = {
|
||||||
|
startScheduledTime?: number
|
||||||
|
endScheduledTime?: number
|
||||||
|
startMap: Record<string, number>
|
||||||
|
endMap: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendDeliveryScheduleFields(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
deliveryTimeType: number,
|
||||||
|
options: {
|
||||||
|
isBatch: boolean
|
||||||
|
storeId?: string
|
||||||
|
showDeliveryTime?: string
|
||||||
|
diyTime?: { startTime?: number; endTime?: number }
|
||||||
|
merchants: Array<{ id?: string | number }>
|
||||||
|
appointmentMap: Record<string, MerchantAppointmentSlot>
|
||||||
|
buildScheduledTimePayload: (
|
||||||
|
merchants: Array<{ id?: string | number }>,
|
||||||
|
map: Record<string, MerchantAppointmentSlot>,
|
||||||
|
) => SchedulePayload
|
||||||
|
getTimeStamps?: (timeStr: string) => { startTimestamp: number; endTimestamp: number }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
body.deliveryType = deliveryTimeType
|
||||||
|
if (deliveryTimeType !== 2) return
|
||||||
|
|
||||||
|
if (options.isBatch) {
|
||||||
|
const scheduled = options.buildScheduledTimePayload(
|
||||||
|
options.merchants,
|
||||||
|
options.appointmentMap,
|
||||||
|
)
|
||||||
|
if (scheduled.startScheduledTime != null) {
|
||||||
|
body.startScheduledTime = scheduled.startScheduledTime
|
||||||
|
body.endScheduledTime = scheduled.endScheduledTime
|
||||||
|
}
|
||||||
|
if (Object.keys(scheduled.startMap).length > 0) {
|
||||||
|
body.merchantStartScheduledTimeMap = scheduled.startMap
|
||||||
|
body.merchantEndScheduledTimeMap = scheduled.endMap
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ap = options.appointmentMap[String(options.storeId ?? '')]
|
||||||
|
body.startScheduledTime = ap?.startTime ?? options.diyTime?.startTime
|
||||||
|
body.endScheduledTime = ap?.endTime ?? options.diyTime?.endTime
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumUniqueDeliveryDateFees(
|
||||||
|
list: unknown,
|
||||||
|
feeKey: 'groupDeliveryFee' | 'groupTip',
|
||||||
|
): number {
|
||||||
|
if (!Array.isArray(list)) return 0
|
||||||
|
const seen = new Set<string>()
|
||||||
|
let sum = 0
|
||||||
|
list.forEach((item: any) => {
|
||||||
|
const date = String(item?.deliveryDate ?? '').trim()
|
||||||
|
if (!date || seen.has(date)) return
|
||||||
|
seen.add(date)
|
||||||
|
sum += Number(item?.[feeKey] ?? 0) || 0
|
||||||
|
})
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCheckoutDeliveryFee(price: Record<string, any> | null | undefined, isBatch: boolean): number {
|
||||||
|
if (!price) return 0
|
||||||
|
if (isBatch) {
|
||||||
|
if (price.groupDeliveryFee != null && price.groupDeliveryFee !== '') {
|
||||||
|
return Number(price.groupDeliveryFee) || 0
|
||||||
|
}
|
||||||
|
const fromList = sumUniqueDeliveryDateFees(price.deliveryDateSummaryList, 'groupDeliveryFee')
|
||||||
|
if (fromList > 0) return fromList
|
||||||
|
return Number(price.totalDeliveryFee ?? price.deliveryFee ?? 0) || 0
|
||||||
|
}
|
||||||
|
return Number(price.deliveryFee ?? 0) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCheckoutTip(price: Record<string, any> | null | undefined, isBatch: boolean): number {
|
||||||
|
if (!price) return 0
|
||||||
|
if (isBatch) {
|
||||||
|
if (price.groupTip != null && price.groupTip !== '') {
|
||||||
|
return Number(price.groupTip) || 0
|
||||||
|
}
|
||||||
|
const fromList = sumUniqueDeliveryDateFees(price.deliveryDateSummaryList, 'groupTip')
|
||||||
|
if (fromList > 0) return fromList
|
||||||
|
return Number(price.totalTip ?? price.tip ?? 0) || 0
|
||||||
|
}
|
||||||
|
return Number(price.tip ?? 0) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCheckoutPaidAmount(price: Record<string, any> | null | undefined, isBatch: boolean): number {
|
||||||
|
if (!price) return 0
|
||||||
|
if (isBatch) {
|
||||||
|
return Number(price.totalPaidAmount ?? price.paidAmount ?? 0) || 0
|
||||||
|
}
|
||||||
|
return Number(price.paidAmount ?? 0) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCheckoutGoodsAmount(price: Record<string, any> | null | undefined, isBatch: boolean): number {
|
||||||
|
if (!price) return 0
|
||||||
|
if (isBatch) {
|
||||||
|
return Number(price.totalActualAmount ?? price.actualAmount ?? 0) || 0
|
||||||
|
}
|
||||||
|
return Number(price.actualAmount ?? 0) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCheckoutTax(price: Record<string, any> | null | undefined, isBatch: boolean): number {
|
||||||
|
if (!price) return 0
|
||||||
|
if (isBatch) {
|
||||||
|
return Number(price.totalTax ?? price.tax ?? 0) || 0
|
||||||
|
}
|
||||||
|
return Number(price.tax ?? 0) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifyOrderIds(ids: Array<string | number | null | undefined>): string[] {
|
||||||
|
return ids.map((id) => String(id ?? '').trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ZIP 凭证提交应付总额(支付组维度) */
|
||||||
|
export function pickZipPaymentAmount(
|
||||||
|
price: Record<string, any> | null | undefined,
|
||||||
|
isBatch: boolean,
|
||||||
|
): number {
|
||||||
|
return pickCheckoutPaidAmount(price, isBatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRequestErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
const e = err as { msg?: string; message?: string; data?: { msg?: string } }
|
||||||
|
return String(e?.msg || e?.data?.msg || e?.message || fallback).trim() || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isZipPendingReviewError(message: string): boolean {
|
||||||
|
return /ZIP.*待审核|待审核.*ZIP/i.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isZipAmountChangedError(message: string): boolean {
|
||||||
|
return /支付组金额已变化|金额已变化.*凭证/i.test(message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<view class="store-main-sk">
|
||||||
|
<view class="sk-banner skeleton-item"></view>
|
||||||
|
<view class="sk-notice skeleton-item"></view>
|
||||||
|
<view class="sk-info-block">
|
||||||
|
<view class="sk-store-name skeleton-item"></view>
|
||||||
|
<view class="flex items-center gap-16rpx mt-12rpx">
|
||||||
|
<view class="sk-rating skeleton-item"></view>
|
||||||
|
<view class="sk-sales skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
<view class="flex items-center gap-16rpx mt-16rpx">
|
||||||
|
<view class="sk-coupon skeleton-item"></view>
|
||||||
|
<view class="sk-coupon skeleton-item"></view>
|
||||||
|
<view class="sk-coupon skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="flex items-center px-24rpx pb-24rpx gap-16rpx">
|
||||||
|
<view v-for="i in 4" :key="i" class="sk-tab skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
<view class="sk-dish-grid px-24rpx">
|
||||||
|
<view v-for="i in 4" :key="i" class="sk-dish-card">
|
||||||
|
<view class="sk-dish-img skeleton-item"></view>
|
||||||
|
<view class="sk-dish-body">
|
||||||
|
<view class="flex items-center justify-between mb-10rpx">
|
||||||
|
<view class="sk-price skeleton-item"></view>
|
||||||
|
<view class="sk-dish-sales skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
<view class="sk-title skeleton-item mb-12rpx"></view>
|
||||||
|
<view class="sk-title sk-title--short skeleton-item mb-14rpx"></view>
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<view class="sk-member skeleton-item"></view>
|
||||||
|
<view class="sk-add skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.skeleton-item {
|
||||||
|
background: linear-gradient(90deg, #ebebeb 25%, #d6d6d6 50%, #ebebeb 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: sk-shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sk-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-main-sk {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-banner {
|
||||||
|
width: 100%;
|
||||||
|
height: 360rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-notice {
|
||||||
|
width: 100%;
|
||||||
|
height: 66rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-info-block {
|
||||||
|
background: #fff;
|
||||||
|
padding: 20rpx 24rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-store-name {
|
||||||
|
width: 60%;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-rating {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-sales {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-coupon {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-tab {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-dish-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-dish-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-dish-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 270rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-dish-body {
|
||||||
|
padding: 14rpx 14rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-price {
|
||||||
|
width: 90rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-dish-sales {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-title {
|
||||||
|
width: 90%;
|
||||||
|
height: 26rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-title--short {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-member {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-add {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,227 +1,127 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="store-skeleton">
|
<view class="store-skeleton">
|
||||||
<!-- 顶部导航栏 -->
|
<view class="store-sk-layout">
|
||||||
<view class="fixed top-0 left-0 z-9 w-full pt-6rpx">
|
<view class="store-sk-sidebar">
|
||||||
<status-bar />
|
<status-bar />
|
||||||
<view class="nav-bar">
|
<view class="sk-back skeleton-item"></view>
|
||||||
<view class="nav-btn skeleton-item"></view>
|
<view class="sk-head">
|
||||||
<view class="nav-btn skeleton-item"></view>
|
<view class="sk-head-icon skeleton-item"></view>
|
||||||
</view>
|
<view class="sk-head-text skeleton-item"></view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 占位 -->
|
|
||||||
<status-bar />
|
|
||||||
<view class="h-88rpx"></view>
|
|
||||||
|
|
||||||
<!-- 店铺 Logo -->
|
|
||||||
<view class="flex justify-center pt-20rpx">
|
|
||||||
<view class="logo-skeleton skeleton-item"></view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 店铺信息区域 -->
|
|
||||||
<view class="px-30rpx pt-24rpx pb-30rpx">
|
|
||||||
<view class="flex flex-col items-center">
|
|
||||||
<!-- 店铺名称 -->
|
|
||||||
<view class="name-skeleton skeleton-item mb-16rpx"></view>
|
|
||||||
<!-- 评分 + CHEFLINK -->
|
|
||||||
<view class="flex items-center mb-12rpx">
|
|
||||||
<view class="rating-skeleton skeleton-item mr-16rpx"></view>
|
|
||||||
<view class="cheflink-skeleton skeleton-item"></view>
|
|
||||||
</view>
|
</view>
|
||||||
<!-- 总销量 -->
|
|
||||||
<view class="sales-text-skeleton skeleton-item"></view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 配送信息卡片 -->
|
|
||||||
<view class="delivery-card-skeleton skeleton-item mt-30rpx"></view>
|
|
||||||
|
|
||||||
<!-- 优惠券标签行(占位) -->
|
|
||||||
<view class="flex items-center mt-24rpx">
|
|
||||||
<view class="coupon-tag-skeleton skeleton-item mr-16rpx"></view>
|
|
||||||
<view class="coupon-tag-skeleton skeleton-item mr-16rpx"></view>
|
|
||||||
<view class="coupon-tag-skeleton skeleton-item"></view>
|
|
||||||
<view class="flex-1"></view>
|
|
||||||
<view class="claim-skeleton skeleton-item"></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 分类胶囊标签 -->
|
|
||||||
<view class="flex items-center px-30rpx pb-24rpx">
|
|
||||||
<view
|
|
||||||
v-for="i in 4"
|
|
||||||
:key="i"
|
|
||||||
class="tab-chip-skeleton skeleton-item"
|
|
||||||
></view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 商品列表 -->
|
|
||||||
<view class="px-30rpx">
|
|
||||||
<view class="grid grid-cols-2 gap-24rpx">
|
|
||||||
<view
|
<view
|
||||||
v-for="i in 4"
|
v-for="i in 4"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="product-skeleton"
|
class="sk-merchant-item"
|
||||||
|
:class="{ 'sk-merchant-item--active': i === 1 }"
|
||||||
>
|
>
|
||||||
<!-- 商品图片 -->
|
<view class="sk-merchant-logo skeleton-item"></view>
|
||||||
<view class="product-img-skeleton skeleton-item"></view>
|
<view class="sk-merchant-name skeleton-item"></view>
|
||||||
<!-- 价格 + 销量 -->
|
|
||||||
<view class="flex items-center justify-between mt-16rpx">
|
|
||||||
<view class="price-skeleton skeleton-item"></view>
|
|
||||||
<view class="sales-skeleton skeleton-item"></view>
|
|
||||||
</view>
|
|
||||||
<!-- 商品名称 -->
|
|
||||||
<view class="product-name-skeleton skeleton-item mt-8rpx"></view>
|
|
||||||
<!-- 会员价 + 加购按钮 -->
|
|
||||||
<view class="flex items-center justify-between mt-12rpx">
|
|
||||||
<view class="member-skeleton skeleton-item"></view>
|
|
||||||
<view class="add-btn-skeleton skeleton-item"></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<store-main-skeleton />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import StoreMainSkeleton from './store-main-skeleton.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #ebebeb 25%, #d6d6d6 50%, #ebebeb 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s infinite;
|
animation: sk-shimmer 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes sk-shimmer {
|
||||||
0% { background-position: -200% 0; }
|
0% {
|
||||||
100% { background-position: 200% 0; }
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
|
100% {
|
||||||
.store-skeleton {
|
background-position: 200% 0;
|
||||||
background-color: #fff;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 顶部导航 */
|
|
||||||
.nav-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 30rpx;
|
|
||||||
height: 88rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn {
|
|
||||||
width: 48rpx;
|
|
||||||
height: 48rpx;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 店铺 Logo */
|
|
||||||
.logo-skeleton {
|
|
||||||
width: 128rpx;
|
|
||||||
height: 128rpx;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 店铺信息 */
|
|
||||||
.name-skeleton {
|
|
||||||
width: 360rpx;
|
|
||||||
height: 44rpx;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rating-skeleton {
|
|
||||||
width: 160rpx;
|
|
||||||
height: 24rpx;
|
|
||||||
border-radius: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cheflink-skeleton {
|
|
||||||
width: 120rpx;
|
|
||||||
height: 24rpx;
|
|
||||||
border-radius: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-text-skeleton {
|
|
||||||
width: 140rpx;
|
|
||||||
height: 24rpx;
|
|
||||||
border-radius: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 配送信息卡片 */
|
|
||||||
.delivery-card-skeleton {
|
|
||||||
width: 100%;
|
|
||||||
height: 140rpx;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 优惠券标签 */
|
|
||||||
.coupon-tag-skeleton {
|
|
||||||
width: 120rpx;
|
|
||||||
height: 52rpx;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.claim-skeleton {
|
|
||||||
width: 100rpx;
|
|
||||||
height: 28rpx;
|
|
||||||
border-radius: 6rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 胶囊标签 */
|
|
||||||
.tab-chip-skeleton {
|
|
||||||
width: 100rpx;
|
|
||||||
height: 60rpx;
|
|
||||||
border-radius: 30rpx;
|
|
||||||
margin-right: 20rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 商品卡片 */
|
.store-skeleton {
|
||||||
.product-skeleton {
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-img-skeleton {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 248rpx;
|
min-height: 100vh;
|
||||||
|
background: #f6f6f6;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-sk-layout {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-sk-sidebar {
|
||||||
|
width: 146rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #ececec;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-back {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
|
margin: 12rpx 0 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-skeleton {
|
.sk-head {
|
||||||
width: 120rpx;
|
display: flex;
|
||||||
height: 36rpx;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
padding: 8rpx 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-head-icon {
|
||||||
|
width: 26rpx;
|
||||||
|
height: 26rpx;
|
||||||
border-radius: 6rpx;
|
border-radius: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sales-skeleton {
|
.sk-head-text {
|
||||||
width: 80rpx;
|
width: 80rpx;
|
||||||
height: 22rpx;
|
height: 20rpx;
|
||||||
border-radius: 6rpx;
|
border-radius: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-name-skeleton {
|
.sk-merchant-item {
|
||||||
width: 85%;
|
width: 100%;
|
||||||
height: 34rpx;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 10rpx;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-merchant-item--active {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-merchant-logo {
|
||||||
|
width: 68rpx;
|
||||||
|
height: 68rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-merchant-name {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 20rpx;
|
||||||
border-radius: 6rpx;
|
border-radius: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-skeleton {
|
:deep(.store-main-sk) {
|
||||||
width: 160rpx;
|
flex: 1;
|
||||||
height: 42rpx;
|
min-width: 0;
|
||||||
border-radius: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn-skeleton {
|
|
||||||
width: 52rpx;
|
|
||||||
height: 52rpx;
|
|
||||||
border-radius: 26rpx;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import Config from "@/config";
|
|||||||
import {useConfigStore, useUserStore} from "@/store";
|
import {useConfigStore, useUserStore} from "@/store";
|
||||||
import DishesSkeleton from "./components/dishes-skeleton.vue";
|
import DishesSkeleton from "./components/dishes-skeleton.vue";
|
||||||
import {CollectionType} from "@/constant/enums";
|
import {CollectionType} from "@/constant/enums";
|
||||||
|
import { formatDeliveryScheduleDays } from "@/utils/deliverySchedule";
|
||||||
|
import { parseMerchantCartPayload } from "@/utils/utils";
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
@@ -110,11 +112,20 @@ function handleSubmit() {
|
|||||||
sideDishItemId: item.selectedIds![0],
|
sideDishItemId: item.selectedIds![0],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const count = Math.max(1, Math.floor(Number(addCartCount.value) || 1))
|
||||||
|
if (count > maxAddCartCount.value) {
|
||||||
|
uni.showToast({
|
||||||
|
title: t('common.prompt.stockInsufficient'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
appMerchantCartAddCartPost({
|
appMerchantCartAddCartPost({
|
||||||
body: {
|
body: {
|
||||||
merchantId: storeId.value,
|
merchantId: storeId.value,
|
||||||
dishId: dish.id,
|
dishId: dish.id,
|
||||||
count: 1,
|
count,
|
||||||
merchantCartSideDishBoList,
|
merchantCartSideDishBoList,
|
||||||
} as any,
|
} as any,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
@@ -145,11 +156,7 @@ function onAddCartClick() {
|
|||||||
}, 400);
|
}, 400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSideDishes.value) {
|
openSpecPopup();
|
||||||
openSpecPopup();
|
|
||||||
} else {
|
|
||||||
addCart();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onPageScroll((e) => {
|
onPageScroll((e) => {
|
||||||
@@ -306,6 +313,20 @@ function handleShare() {
|
|||||||
const isSoldOut = computed(() => isSoldOutStock(dishDetailData.value?.stock))
|
const isSoldOut = computed(() => isSoldOutStock(dishDetailData.value?.stock))
|
||||||
|
|
||||||
const specPopupVisible = ref(false)
|
const specPopupVisible = ref(false)
|
||||||
|
const addCartCount = ref(1)
|
||||||
|
|
||||||
|
const maxAddCartCount = computed(() => {
|
||||||
|
const stock = dishDetailData.value?.stock
|
||||||
|
if (stock == null || stock === '') return 99
|
||||||
|
if (isSoldOutStock(stock)) return 1
|
||||||
|
const n = Number(stock)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return 99
|
||||||
|
return Math.floor(n)
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetAddCartCount() {
|
||||||
|
addCartCount.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
const hasSideDishes = computed(() => {
|
const hasSideDishes = computed(() => {
|
||||||
const list = dishDetailData.value?.merchantSideDishVoList ?? [];
|
const list = dishDetailData.value?.merchantSideDishVoList ?? [];
|
||||||
@@ -317,20 +338,8 @@ const appDisplayName = computed(() => {
|
|||||||
return name || "CHEFLINK";
|
return name || "CHEFLINK";
|
||||||
});
|
});
|
||||||
|
|
||||||
function fillI18nParams(template: string, params: Record<string, string | number>) {
|
|
||||||
let text = template;
|
|
||||||
Object.keys(params).forEach((key) => {
|
|
||||||
const value = String(params[key] ?? "");
|
|
||||||
text = text.replace(new RegExp(`\\{${key}\\}`, "g"), value);
|
|
||||||
text = text.replace(new RegExp(`\\$\\{${key}\\}`, "g"), value);
|
|
||||||
});
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberPriceLabelText = computed(() =>
|
const memberPriceLabelText = computed(() =>
|
||||||
fillI18nParams(t("pages-store.store.dishDetail.memberPriceLabel"), {
|
`${appDisplayName.value}${t("pages-store.store.dishDetail.memberPriceLabelSuffix")}`
|
||||||
name: appDisplayName.value,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function formatPriceText(val: unknown) {
|
function formatPriceText(val: unknown) {
|
||||||
@@ -388,14 +397,15 @@ 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;
|
||||||
});
|
});
|
||||||
|
|
||||||
function openSpecPopup() {
|
function openSpecPopup() {
|
||||||
|
resetAddCartCount()
|
||||||
const sideList = dishDetailData.value?.merchantSideDishVoList ?? [];
|
const sideList = dishDetailData.value?.merchantSideDishVoList ?? [];
|
||||||
for (const item of sideList) {
|
for (const item of sideList) {
|
||||||
if (
|
if (
|
||||||
@@ -426,7 +436,7 @@ function getCartInfo() {
|
|||||||
}
|
}
|
||||||
}).then((res: any)=> {
|
}).then((res: any)=> {
|
||||||
console.log('购物车列表', res)
|
console.log('购物车列表', res)
|
||||||
cartDataList.value = res.data ?? []
|
cartDataList.value = parseMerchantCartPayload(res?.data).items as MerchantCartVo[]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,6 +668,13 @@ function navigateToCart() {
|
|||||||
|
|
||||||
// 获取商家详情信息
|
// 获取商家详情信息
|
||||||
const storeDetail = ref<MerchantVo>({})
|
const storeDetail = ref<MerchantVo>({})
|
||||||
|
const deliveryScheduleDaysText = computed(() => {
|
||||||
|
const days = formatDeliveryScheduleDays(
|
||||||
|
storeDetail.value?.deliveryScheduleTimes,
|
||||||
|
(key) => t(key)
|
||||||
|
);
|
||||||
|
return days ? `${t("pages-store.store.deliveryScheduleDaysPrefix")}${days}` : "";
|
||||||
|
});
|
||||||
function getStoreDetail() {
|
function getStoreDetail() {
|
||||||
appMerchantDetailMerchantIdGet({
|
appMerchantDetailMerchantIdGet({
|
||||||
params: {
|
params: {
|
||||||
@@ -771,21 +788,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
|
||||||
@@ -838,6 +853,10 @@ function getStoreDetail() {
|
|||||||
<text class="store-pill__name">{{ storeDetail.merchantName }}</text>
|
<text class="store-pill__name">{{ storeDetail.merchantName }}</text>
|
||||||
<text class="store-pill__arr">›</text>
|
<text class="store-pill__arr">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="deliveryScheduleDaysText"
|
||||||
|
class="delivery-schedule-hint"
|
||||||
|
>{{ deliveryScheduleDaysText }}</view>
|
||||||
|
|
||||||
<!-- 产品详情 -->
|
<!-- 产品详情 -->
|
||||||
<view class="section-title">{{
|
<view class="section-title">{{
|
||||||
@@ -1019,6 +1038,20 @@ function getStoreDetail() {
|
|||||||
</template>
|
</template>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="spec-popup__quantity">
|
||||||
|
<text class="spec-popup__quantity-label">{{ t('pages-store.store.quantity') }}</text>
|
||||||
|
<wd-input-number
|
||||||
|
v-model="addCartCount"
|
||||||
|
long-press
|
||||||
|
:min="1"
|
||||||
|
:max="maxAddCartCount"
|
||||||
|
:step="1"
|
||||||
|
:input-width="80"
|
||||||
|
button-size="56"
|
||||||
|
custom-class="!bg-transparent"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 底部确认按钮 -->
|
<!-- 底部确认按钮 -->
|
||||||
<view class="spec-popup__footer">
|
<view class="spec-popup__footer">
|
||||||
<view class="spec-popup__confirm-btn" @click="onSpecConfirm">
|
<view class="spec-popup__confirm-btn" @click="onSpecConfirm">
|
||||||
@@ -1200,6 +1233,7 @@ function getStoreDetail() {
|
|||||||
|
|
||||||
.dish-info {
|
.dish-info {
|
||||||
padding: 28rpx 30rpx 24rpx;
|
padding: 28rpx 30rpx 24rpx;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-block {
|
.price-block {
|
||||||
@@ -1281,6 +1315,9 @@ function getStoreDetail() {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12rpx;
|
gap: 12rpx;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
|
position: absolute;
|
||||||
|
top: 28rpx;
|
||||||
|
right: 30rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-tag {
|
.meta-tag {
|
||||||
@@ -1317,6 +1354,13 @@ function getStoreDetail() {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delivery-schedule-hint {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
color: #00a76d;
|
||||||
|
}
|
||||||
|
|
||||||
.store-pill__arr {
|
.store-pill__arr {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
color: #5a8fd8;
|
color: #5a8fd8;
|
||||||
@@ -1702,6 +1746,20 @@ function getStoreDetail() {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spec-popup__quantity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24rpx 30rpx 8rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__quantity-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
.spec-popup__footer {
|
.spec-popup__footer {
|
||||||
padding: 20rpx 30rpx;
|
padding: 20rpx 30rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ export const getFeaturedDishList = (data: Record<string, any>) =>
|
|||||||
|
|
||||||
//获取店铺详情
|
//获取店铺详情
|
||||||
export const getStoreDetailById = (storeId: string) =>
|
export const getStoreDetailById = (storeId: string) =>
|
||||||
http.post<any>('/app/merchant/detail/' + storeId);
|
http.post<any>('/app/merchant/detail/' + storeId);
|
||||||
|
|||||||
@@ -70,28 +70,34 @@ onShow(()=> {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="bg-#F6F6F6">
|
<view class="balance-page bg-#F6F6F6">
|
||||||
<image src="@img/chef/107.png" class="w-full h-472rpx"></image>
|
|
||||||
<z-paging ref="paging" v-model="dataList" @query="queryList">
|
<z-paging ref="paging" v-model="dataList" @query="queryList">
|
||||||
<template #top>
|
<template #top>
|
||||||
<wd-navbar
|
<view class="balance-hero">
|
||||||
safeAreaInsetTop
|
<image
|
||||||
:fixed="true"
|
src="@img/chef/107.png"
|
||||||
:placeholder="true"
|
class="balance-hero__bg"
|
||||||
:bordered="false"
|
mode="aspectFill"
|
||||||
custom-class="!bg-transparent"
|
/>
|
||||||
@click-left="handleClickLeft"
|
<view class="balance-hero__content">
|
||||||
>
|
<wd-navbar
|
||||||
<template #title>
|
safeAreaInsetTop
|
||||||
<text class="text-34rpx text-#fff !font-400">{{ t('pages-user.balance.title') }}</text>
|
:fixed="true"
|
||||||
</template>
|
:placeholder="true"
|
||||||
<template #left>
|
:bordered="false"
|
||||||
<view class="shrink-0">
|
custom-class="balance-navbar !bg-transparent"
|
||||||
<view class="i-carbon:chevron-left text-50rpx text-#fff ml-[-10rpx]"></view>
|
@click-left="handleClickLeft"
|
||||||
</view>
|
>
|
||||||
</template>
|
<template #title>
|
||||||
</wd-navbar>
|
<text class="balance-navbar__title">{{ t('pages-user.balance.title') }}</text>
|
||||||
<view class="h-254rpx box z-9 mx-18rpx mt-18rpx px-40rpx py-52rpx">
|
</template>
|
||||||
|
<template #left>
|
||||||
|
<view class="shrink-0">
|
||||||
|
<view class="i-carbon:chevron-left balance-navbar__back ml-[-10rpx]" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</wd-navbar>
|
||||||
|
<view class="h-254rpx box z-9 mx-18rpx mt-18rpx px-40rpx py-52rpx">
|
||||||
<view @click="navigateTo('/pages/agreement/index?code=' + Agreement.BALANCE_EXPLANATION)" class="flex items-center">
|
<view @click="navigateTo('/pages/agreement/index?code=' + Agreement.BALANCE_EXPLANATION)" class="flex items-center">
|
||||||
<text class="text-28rpx text-#333">{{ t('pages-user.balance.my-balance') }}:</text>
|
<text class="text-28rpx text-#333">{{ t('pages-user.balance.my-balance') }}:</text>
|
||||||
<image src="@img/chef/108.png" class="w-28rpx h-28rpx shrink-0"></image>
|
<image src="@img/chef/108.png" class="w-28rpx h-28rpx shrink-0"></image>
|
||||||
@@ -101,6 +107,8 @@ onShow(()=> {
|
|||||||
<wd-button @click="navigateTo('/pages-user/pages/recharge/index')" custom-class="!min-w-160rpx !h-64rpx !rounded-20rpx !bg-#00A76D">{{ t('common.recharge') }}</wd-button>
|
<wd-button @click="navigateTo('/pages-user/pages/recharge/index')" custom-class="!min-w-160rpx !h-64rpx !rounded-20rpx !bg-#00A76D">{{ t('common.recharge') }}</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<view class="px-18rpx">
|
<view class="px-18rpx">
|
||||||
<view class="bg-white rounded-36rpx overflow-hidden mt-18rpx py-30rpx">
|
<view class="bg-white rounded-36rpx overflow-hidden mt-18rpx py-30rpx">
|
||||||
@@ -142,6 +150,41 @@ onShow(()=> {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.balance-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-hero__bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 472rpx;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-hero__content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-navbar__title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-navbar__back {
|
||||||
|
font-size: 50rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.balance-navbar .wd-navbar__title) {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
background: linear-gradient(198deg, #D0F4E8 -8%, #FFFFFF 37%, #FFFFFF 56%, #FFFFFF 76%);
|
background: linear-gradient(198deg, #D0F4E8 -8%, #FFFFFF 37%, #FFFFFF 56%, #FFFFFF 76%);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n();
|
||||||
|
const emit = defineEmits(["confirm", "close", "update:modelValue"]);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
zIndex?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit("confirm");
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<wd-popup
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="(val) => emit('update:modelValue', val)"
|
||||||
|
position="bottom"
|
||||||
|
:z-index="zIndex || 2000"
|
||||||
|
custom-style="background: transparent;"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<view class="multi-store-notice">
|
||||||
|
<view class="multi-store-notice__title">
|
||||||
|
{{ t("pages-user.cart.orderNoticeTitle") }}
|
||||||
|
</view>
|
||||||
|
<view class="multi-store-notice__desc">
|
||||||
|
{{ t("pages-user.cart.multiStoreCheckoutNoticeDesc") }}
|
||||||
|
</view>
|
||||||
|
<view class="multi-store-notice__btn-fixed">
|
||||||
|
<wd-button
|
||||||
|
block
|
||||||
|
custom-class="multi-store-notice__btn"
|
||||||
|
custom-style="height:88rpx;line-height:88rpx;"
|
||||||
|
@click="handleConfirm"
|
||||||
|
@tap="handleConfirm"
|
||||||
|
>
|
||||||
|
{{ t("common.gotIt") }}
|
||||||
|
</wd-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</wd-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.multi-store-notice {
|
||||||
|
margin: 0 24rpx calc(24rpx + constant(safe-area-inset-bottom));
|
||||||
|
margin: 0 24rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 44rpx 40rpx 180rpx;
|
||||||
|
background: #fff;
|
||||||
|
min-height: 420rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-store-notice__title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 42rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-store-notice__desc {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
color: #333;
|
||||||
|
// text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-store-notice__btn-fixed {
|
||||||
|
position: fixed;
|
||||||
|
left: 64rpx;
|
||||||
|
right: 64rpx;
|
||||||
|
bottom: calc(48rpx + constant(safe-area-inset-bottom));
|
||||||
|
bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
z-index: 2200;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,18 +5,8 @@ const emit = defineEmits(['confirm','close'])
|
|||||||
const show = ref(false);
|
const show = ref(false);
|
||||||
const goodsName = ref('');
|
const goodsName = ref('');
|
||||||
|
|
||||||
function fillI18nParams(template: string, params: Record<string, string | number>) {
|
|
||||||
let text = template;
|
|
||||||
Object.keys(params).forEach((key) => {
|
|
||||||
const value = String(params[key] ?? "");
|
|
||||||
text = text.replace(new RegExp(`\\{${key}\\}`, "g"), value);
|
|
||||||
text = text.replace(new RegExp(`\\$\\{${key}\\}`, "g"), value);
|
|
||||||
});
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeProductDescText = computed(() =>
|
const removeProductDescText = computed(() =>
|
||||||
fillI18nParams(t("pages-user.cart.removeProductDesc"), { name: goodsName.value })
|
`${t("pages-user.cart.removeProductDescPrefix")}${goodsName.value}${t("pages-user.cart.removeProductDescSuffix")}`
|
||||||
);
|
);
|
||||||
function onOpen(title: string) {
|
function onOpen(title: string) {
|
||||||
if (title) {
|
if (title) {
|
||||||
|
|||||||
@@ -1,35 +1,52 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
appMerchantCartDeleteByMerchantIdPost,
|
appMerchantCartDeleteByMerchantIdPost,
|
||||||
appMerchantCartListMerchantPost,
|
appMerchantCartListMerchantPost,
|
||||||
appMerchantCartDeleteCartPost,
|
|
||||||
appMerchantCartCalculateSavingsPost,
|
|
||||||
appMerchantCartListByMerchantIdPost,
|
appMerchantCartListByMerchantIdPost,
|
||||||
|
appMerchantCartDeleteCartPost,
|
||||||
appMerchantCartAddCartByIdPost,
|
appMerchantCartAddCartByIdPost,
|
||||||
appMerchantCartAddCartPost,
|
appMerchantCartAddCartPost,
|
||||||
appMerchantDishDishIdGet,
|
appMerchantDishDishIdGet,
|
||||||
appMerchantDetailMerchantIdGet,
|
appMerchantDetailMerchantIdGet,
|
||||||
appAppointmentTimeUpdateAppointmentTimePost,
|
appMerchantOrderCalculatePriceCartBatchPost,
|
||||||
} from "@/service";
|
appUserAddressListPost,
|
||||||
|
} from '@/service';
|
||||||
|
import {
|
||||||
|
pickCheckoutDeliveryFee,
|
||||||
|
pickCheckoutGoodsAmount,
|
||||||
|
pickCheckoutPaidAmount,
|
||||||
|
stringifyCartIds,
|
||||||
|
} from '@/pages-store/pages/order/utils/checkout-order';
|
||||||
|
import {
|
||||||
|
buildReservationTimeUrl,
|
||||||
|
buildDayAppointmentSlot,
|
||||||
|
findNearestDeliveryScheduleDate,
|
||||||
|
saveCheckoutMerchantAppointments,
|
||||||
|
type MerchantAppointmentSlot,
|
||||||
|
} from '@/utils/deliverySchedule';
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import Config from "@/config";
|
import Config from '@/config';
|
||||||
import { useUserStore } from "@/store";
|
import { parseMerchantCartPayload } from '@/utils/utils';
|
||||||
import useEventEmit from "@/hooks/useEventEmit";
|
import { useUserStore } from '@/store';
|
||||||
import { EventEnum } from "@/constant/enums";
|
import useEventEmit from '@/hooks/useEventEmit';
|
||||||
import { onShow } from "@dcloudio/uni-app";
|
import { EventEnum } from '@/constant/enums';
|
||||||
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
|
|
||||||
dayjs.locale("zh-cn");
|
dayjs.locale("zh-cn");
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
import RemoveCart from "./components/remove-cart.vue";
|
import RemoveCart from './components/remove-cart.vue';
|
||||||
import RemoveStore from "./components/remove-store.vue";
|
import RemoveStore from './components/remove-store.vue';
|
||||||
import CartSkeleton from "./components/cart-skeleton.vue";
|
import MultiStoreNotice from "./components/multi-store-notice.vue";
|
||||||
|
import CartSkeleton from './components/cart-skeleton.vue';
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const removeCartRef = ref<InstanceType<typeof RemoveCart>>();
|
const removeCartRef = ref<InstanceType<typeof RemoveCart>>();
|
||||||
const removeItemRef = ref<InstanceType<typeof RemoveStore>>();
|
const removeItemRef = ref<InstanceType<typeof RemoveStore>>();
|
||||||
|
const isMultiStoreNoticeOpen = ref(false);
|
||||||
interface CartItem {
|
interface CartItem {
|
||||||
id: string;
|
id: string;
|
||||||
dishId: string;
|
dishId: string;
|
||||||
@@ -51,6 +68,8 @@ interface CartMerchant {
|
|||||||
cartTotalPrice: number;
|
cartTotalPrice: number;
|
||||||
merchantCartVoList: CartItem[];
|
merchantCartVoList: CartItem[];
|
||||||
allChecked: boolean;
|
allChecked: boolean;
|
||||||
|
businessHours?: string;
|
||||||
|
deliveryScheduleTimes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataList = ref<CartMerchant[]>([]);
|
const dataList = ref<CartMerchant[]>([]);
|
||||||
@@ -74,16 +93,6 @@ const appDisplayName = computed(() => {
|
|||||||
return name || "CHEFLINK";
|
return name || "CHEFLINK";
|
||||||
});
|
});
|
||||||
|
|
||||||
function fillI18nParams(template: string, params: Record<string, string | number>) {
|
|
||||||
let text = template;
|
|
||||||
Object.keys(params).forEach((key) => {
|
|
||||||
const value = String(params[key] ?? "");
|
|
||||||
text = text.replace(new RegExp(`\\{${key}\\}`, "g"), value);
|
|
||||||
text = text.replace(new RegExp(`\\$\\{${key}\\}`, "g"), value);
|
|
||||||
});
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePriceString(val: unknown) {
|
function normalizePriceString(val: unknown) {
|
||||||
const s = String(val ?? "").trim();
|
const s = String(val ?? "").trim();
|
||||||
if (!/^\d+(\.\d+)?$/.test(s)) return "0";
|
if (!/^\d+(\.\d+)?$/.test(s)) return "0";
|
||||||
@@ -107,8 +116,6 @@ function formatPriceForView(val: unknown) {
|
|||||||
function getCartLineDisplayPrice(item: CartItem) {
|
function getCartLineDisplayPrice(item: CartItem) {
|
||||||
// const memberCent = priceToCentString(item?.memberPrice);
|
// const memberCent = priceToCentString(item?.memberPrice);
|
||||||
// console.log('memberCent', memberCent);
|
// console.log('memberCent', memberCent);
|
||||||
console.log('item?.memberPrice', item?.memberPrice);
|
|
||||||
console.log(userStore.userInfo.userMembershipVo, 'userStore.isMember');
|
|
||||||
if (userStore.userInfo.userMembershipVo!==null) {
|
if (userStore.userInfo.userMembershipVo!==null) {
|
||||||
return item?.memberPrice;
|
return item?.memberPrice;
|
||||||
}
|
}
|
||||||
@@ -141,60 +148,144 @@ const selectedItemQty = computed(() =>
|
|||||||
selectedCartItems.value.reduce((n, it) => n + (Number(it.count) || 0), 0)
|
selectedCartItems.value.reduce((n, it) => n + (Number(it.count) || 0), 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 已选商品商品金额小计(不含运费等),用于与设计图「n件商品总共」对齐 */
|
function getCartLineUnitPrice(item: CartItem) {
|
||||||
|
return Number(getCartLineDisplayPrice(item)) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已选商品金额小计(不含运费/税费),与列表行价口径一致 */
|
||||||
const selectedGoodsSubtotal = computed(() => {
|
const selectedGoodsSubtotal = computed(() => {
|
||||||
let total = 0;
|
let total = 0
|
||||||
selectedCartItems.value.forEach((item) => {
|
selectedCartItems.value.forEach((item) => {
|
||||||
const base = Number(item.merchantDishVo?.discountPrice) || 0;
|
total += getCartLineUnitPrice(item) * (Number(item.count) || 0)
|
||||||
let side = 0;
|
})
|
||||||
item.sideDishList?.forEach((d: any) => {
|
return total.toFixed(2)
|
||||||
side += Number(d?.merchantSideDishItemVo?.price) || 0;
|
})
|
||||||
});
|
|
||||||
const line = base + side;
|
/** 优先使用批量算价返回的商品金额,与底部实付口径对齐 */
|
||||||
total += line * (Number(item.count) || 0);
|
const displayGoodsAmountStr = computed(() => {
|
||||||
});
|
const fromApi = pickCheckoutGoodsAmount(priceData.value as any, true)
|
||||||
return total.toFixed(2);
|
if (fromApi > 0) return fromApi.toFixed(2)
|
||||||
});
|
return selectedGoodsSubtotal.value
|
||||||
|
})
|
||||||
|
|
||||||
const deliveryUnifiedText = computed(() =>
|
const deliveryUnifiedText = computed(() =>
|
||||||
fillI18nParams(t("pages-user.cart.deliveryUnified"), { name: appDisplayName.value })
|
`${t("pages-user.cart.deliveryUnifiedPrefix")}${appDisplayName.value}${t("pages-user.cart.deliveryUnifiedSuffix")}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemsTotalWithPriceText = computed(() =>
|
const displayDeliveryFeeStr = computed(() => {
|
||||||
fillI18nParams(t("pages-user.cart.itemsTotalWithPrice"), {
|
const fee = pickCheckoutDeliveryFee(priceData.value as any, true)
|
||||||
count: priceData.value.totalQuantity ?? selectedItemQty.value,
|
return fee > 0 ? fee.toFixed(2) : ''
|
||||||
amount: selectedGoodsSubtotal.value,
|
})
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 与地址页、结算页一致:展示用户已保存预约日,无则展示今天(选时间会跳转 reservation-time 并写回接口) */
|
const itemsTotalWithPriceText = computed(() => {
|
||||||
const deliveryScheduleLabel = computed(() => {
|
const goodsPart = `${selectedItemQty.value}${t('pages-user.cart.itemsTotalWithPriceMiddle')}${displayGoodsAmountStr.value}`
|
||||||
const ap = userStore.appointmentTime as any;
|
const delivery = displayDeliveryFeeStr.value
|
||||||
|
if (!delivery) return goodsPart
|
||||||
|
return `${goodsPart},${t('pages-user.cart.deliveryFeeLine')} $${delivery}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const merchantAppointmentMap = ref<Record<string, MerchantAppointmentSlot>>({});
|
||||||
|
const merchantAppointmentDisplay = ref<Record<string, string>>({});
|
||||||
|
const selectingMerchantId = ref<string | null>(null);
|
||||||
|
const defaultAddressId = ref('');
|
||||||
|
|
||||||
|
async function loadDefaultAddress() {
|
||||||
|
if (!userStore.isLogin) return;
|
||||||
|
try {
|
||||||
|
const res: any = await appUserAddressListPost({
|
||||||
|
params: { pageNum: 1, pageSize: 100 },
|
||||||
|
});
|
||||||
|
const rows = res?.rows ?? [];
|
||||||
|
if (rows.length > 0) {
|
||||||
|
defaultAddressId.value = String(rows[0].id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCartBatchPriceBody(cartIds: string[]) {
|
||||||
|
const selectedMerchants = dataList.value.filter((m) =>
|
||||||
|
m.merchantCartVoList.some((item) => item.checked),
|
||||||
|
);
|
||||||
|
|
||||||
|
const startMap: Record<string, number> = {};
|
||||||
|
const endMap: Record<string, number> = {};
|
||||||
|
selectedMerchants.forEach((m) => {
|
||||||
|
const ap = merchantAppointmentMap.value[String(m.id)];
|
||||||
|
if (!ap?.startTime || !ap?.endTime) return;
|
||||||
|
startMap[String(m.id)] = Number(ap.startTime);
|
||||||
|
endMap[String(m.id)] = Number(ap.endTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
cartIds: stringifyCartIds(cartIds),
|
||||||
|
receiveMethod: 1,
|
||||||
|
deliveryType: Object.keys(startMap).length > 0 ? 2 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const addressId = String(defaultAddressId.value || '').trim();
|
||||||
|
if (addressId) body.addressId = addressId;
|
||||||
|
|
||||||
|
if (body.deliveryType === 2) {
|
||||||
|
body.merchantStartScheduledTimeMap = startMap;
|
||||||
|
body.merchantEndScheduledTimeMap = endMap;
|
||||||
|
const firstMerchantId = Object.keys(startMap)[0];
|
||||||
|
if (firstMerchantId) {
|
||||||
|
body.startScheduledTime = startMap[firstMerchantId];
|
||||||
|
body.endScheduledTime = endMap[firstMerchantId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDefaultMerchantAppointment(merchant: CartMerchant) {
|
||||||
|
const key = String(merchant.id);
|
||||||
|
if (merchantAppointmentMap.value[key]?.startTime) return;
|
||||||
|
|
||||||
|
const nearest = findNearestDeliveryScheduleDate(
|
||||||
|
merchant.deliveryScheduleTimes
|
||||||
|
);
|
||||||
|
if (!nearest) return;
|
||||||
|
|
||||||
|
const slot = buildDayAppointmentSlot(nearest);
|
||||||
|
merchantAppointmentMap.value[key] = slot;
|
||||||
|
merchantAppointmentDisplay.value[key] = dayjs(nearest).format("ddd, MM/DD");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMerchantScheduleLabel(merchantId: string | number) {
|
||||||
|
const key = String(merchantId);
|
||||||
|
if (merchantAppointmentDisplay.value[key]) {
|
||||||
|
return merchantAppointmentDisplay.value[key];
|
||||||
|
}
|
||||||
|
const ap = merchantAppointmentMap.value[key];
|
||||||
if (ap?.startTime) {
|
if (ap?.startTime) {
|
||||||
const start = dayjs(+ap.startTime);
|
const d = dayjs(Number(ap.startTime));
|
||||||
if (start.isValid() && start.isAfter(dayjs().subtract(1, "minute"))) {
|
if (d.isValid()) return d.format("ddd, MM/DD");
|
||||||
return start.format("ddd, MM/DD");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (ap?.endTime) {
|
return t("pages-store.checkout.chooseTime");
|
||||||
const end = dayjs(+ap.endTime);
|
}
|
||||||
if (end.isValid() && end.isAfter(dayjs().subtract(1, "minute"))) {
|
|
||||||
return end.format("ddd, MM/DD");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dayjs().format("ddd, MM/DD");
|
|
||||||
});
|
|
||||||
|
|
||||||
useEventEmit(EventEnum.CHOOSE_APPOINTMENT_TIME, (data: any) => {
|
useEventEmit(EventEnum.CHOOSE_APPOINTMENT_TIME, (data: any) => {
|
||||||
if (!data?.startTime || !data?.endTime) return;
|
if (!data?.startTime || !data?.endTime) return;
|
||||||
appAppointmentTimeUpdateAppointmentTimePost({
|
const merchantId = String(
|
||||||
body: {
|
data.merchantId || selectingMerchantId.value || ""
|
||||||
startTime: data.startTime,
|
);
|
||||||
endTime: data.endTime,
|
if (!merchantId) return;
|
||||||
} as any,
|
merchantAppointmentMap.value[merchantId] = {
|
||||||
}).then(() => {
|
date: data.date,
|
||||||
userStore.getAppointmentTime();
|
timeSlot: data.timeSlot,
|
||||||
});
|
startTime: data.startTime,
|
||||||
|
endTime: data.endTime,
|
||||||
|
};
|
||||||
|
merchantAppointmentDisplay.value[merchantId] = dayjs(data.date).format(
|
||||||
|
"ddd, MM/DD"
|
||||||
|
);
|
||||||
|
selectingMerchantId.value = null;
|
||||||
|
if (selectedCartItems.value.length > 0) {
|
||||||
|
void calculatePrice();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -213,21 +304,36 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const calculatePrice = () => {
|
const calculatePrice = async () => {
|
||||||
calculatingPrice.value = true;
|
calculatingPrice.value = true;
|
||||||
const cartIds = selectedCartItems.value.map((item) => item.id);
|
const cartIds = selectedCartItems.value.map((item) => String(item.id));
|
||||||
appMerchantCartCalculateSavingsPost({
|
if (!cartIds.length) {
|
||||||
body: cartIds as any,
|
priceData.value = { totalPayPrice: 0, savings: 0 };
|
||||||
})
|
calculatingPrice.value = false;
|
||||||
.then((res) => {
|
return;
|
||||||
priceData.value = (res as any).data ?? {};
|
}
|
||||||
})
|
|
||||||
.catch((err) => {
|
try {
|
||||||
console.error("价格统计失败", err);
|
if (!defaultAddressId.value) {
|
||||||
})
|
await loadDefaultAddress();
|
||||||
.finally(() => {
|
}
|
||||||
calculatingPrice.value = false;
|
|
||||||
|
const res: any = await appMerchantOrderCalculatePriceCartBatchPost({
|
||||||
|
body: buildCartBatchPriceBody(cartIds) as any,
|
||||||
});
|
});
|
||||||
|
const data = res?.data ?? {};
|
||||||
|
priceData.value = {
|
||||||
|
...data,
|
||||||
|
totalPayPrice: pickCheckoutPaidAmount(data, true),
|
||||||
|
goodsAmount: pickCheckoutGoodsAmount(data, true),
|
||||||
|
deliveryFee: pickCheckoutDeliveryFee(data, true),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("价格统计失败", err);
|
||||||
|
priceData.value = { totalPayPrice: 0, savings: 0 };
|
||||||
|
} finally {
|
||||||
|
calculatingPrice.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAllCheck = () => {
|
const toggleAllCheck = () => {
|
||||||
@@ -282,7 +388,7 @@ function confirmRemove() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchCheckout = () => {
|
const continueCheckoutWithValidation = () => {
|
||||||
if (selectedCount.value === 0) {
|
if (selectedCount.value === 0) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
icon: "none",
|
icon: "none",
|
||||||
@@ -299,12 +405,42 @@ const batchCheckout = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goCheckoutNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchCheckout = () => {
|
||||||
|
continueCheckoutWithValidation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goCheckoutNow = () => {
|
||||||
|
const selectedMerchants = dataList.value.filter((m) =>
|
||||||
|
m.merchantCartVoList.some((item) => item.checked)
|
||||||
|
);
|
||||||
|
for (const m of selectedMerchants) {
|
||||||
|
const ap = merchantAppointmentMap.value[String(m.id)];
|
||||||
|
if (!ap?.startTime || !ap?.endTime) {
|
||||||
|
uni.showToast({
|
||||||
|
title: `${t("pages-store.checkout.chooseTimeForStorePrefix")}${m.merchantName || ""}${t("pages-store.checkout.chooseTimeForStoreSuffix")}`,
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveCheckoutMerchantAppointments(merchantAppointmentMap.value);
|
||||||
const cartIds = selectedCartItems.value.map((item) => item.id);
|
const cartIds = selectedCartItems.value.map((item) => item.id);
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages-store/pages/order/checkout?cartIds=${cartIds.join(",")}&type=batch`,
|
url: `/pages-store/pages/order/checkout?cartIds=${cartIds.join(",")}&type=batch`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmMultiStoreNotice = () => {
|
||||||
|
continueCheckoutWithValidation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMultiStoreNoticeClose = () => {
|
||||||
|
isMultiStoreNoticeOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const goToHome = () => {
|
const goToHome = () => {
|
||||||
uni.switchTab({
|
uni.switchTab({
|
||||||
url: "/pages/home/index",
|
url: "/pages/home/index",
|
||||||
@@ -436,34 +572,35 @@ function handleRemoveItemPopupClose() {
|
|||||||
delItemData.value = null;
|
delItemData.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onScheduleTap() {
|
async function onMerchantScheduleTap(merchant: CartMerchant) {
|
||||||
if (!userStore.isLogin) {
|
if (!userStore.isLogin) {
|
||||||
uni.showToast({ title: t("common.pleaseLogin"), icon: "none" });
|
uni.showToast({ title: t("common.pleaseLogin"), icon: "none" });
|
||||||
setTimeout(() => uni.navigateTo({ url: Config.loginPath }), 400);
|
setTimeout(() => uni.navigateTo({ url: Config.loginPath }), 400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dataList.value.length) return;
|
selectingMerchantId.value = String(merchant.id);
|
||||||
|
let businessHours = merchant.businessHours;
|
||||||
const first = dataList.value[0];
|
let schedule = merchant.deliveryScheduleTimes;
|
||||||
let businessHours = (first as any).businessHours as string | undefined;
|
if (!businessHours || !schedule) {
|
||||||
if (!businessHours) {
|
|
||||||
try {
|
try {
|
||||||
const detail: any = await appMerchantDetailMerchantIdGet({
|
const detail: any = await appMerchantDetailMerchantIdGet({
|
||||||
params: { merchantId: first.id as any },
|
params: { merchantId: merchant.id as any },
|
||||||
});
|
});
|
||||||
businessHours = detail.data?.businessHours;
|
businessHours = detail.data?.businessHours ?? businessHours;
|
||||||
|
schedule = detail.data?.deliveryScheduleTimes ?? schedule;
|
||||||
|
merchant.businessHours = businessHours;
|
||||||
|
merchant.deliveryScheduleTimes = schedule;
|
||||||
} catch {
|
} catch {
|
||||||
businessHours = "";
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
uni.navigateTo({
|
||||||
const url = businessHours
|
url: buildReservationTimeUrl({
|
||||||
? `/pages/address/reservation-time?storeBusinessHours=${encodeURIComponent(
|
merchantId: merchant.id,
|
||||||
businessHours
|
deliveryScheduleTimes: schedule,
|
||||||
)}`
|
businessHours,
|
||||||
: "/pages/address/reservation-time";
|
}),
|
||||||
|
});
|
||||||
uni.navigateTo({ url });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addingRecommendId = ref<string | number | null>(null);
|
const addingRecommendId = ref<string | number | null>(null);
|
||||||
@@ -544,18 +681,6 @@ function openRecommendDish(dish: any) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 接口若返回运费/服务费(扩展字段)则展示 */
|
|
||||||
function feeLine(
|
|
||||||
origKey: string,
|
|
||||||
curKey: string
|
|
||||||
): { show: boolean; orig?: any; cur?: any } {
|
|
||||||
const d = priceData.value as any;
|
|
||||||
const orig = d[origKey];
|
|
||||||
const cur = d[curKey];
|
|
||||||
if (cur == null && orig == null) return { show: false };
|
|
||||||
return { show: true, orig, cur };
|
|
||||||
}
|
|
||||||
|
|
||||||
// onMounted(() => {
|
// onMounted(() => {
|
||||||
// loading.value = true;
|
// loading.value = true;
|
||||||
// getCartList();
|
// getCartList();
|
||||||
@@ -564,16 +689,17 @@ function feeLine(
|
|||||||
onShow(() => {
|
onShow(() => {
|
||||||
if (userStore.isLogin) {
|
if (userStore.isLogin) {
|
||||||
userStore.getAppointmentTime();
|
userStore.getAppointmentTime();
|
||||||
|
void loadDefaultAddress();
|
||||||
// 从结算页返回时强制刷新购物车,避免已结算商品残留
|
// 从结算页返回时强制刷新购物车,避免已结算商品残留
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
getCartList();
|
getCartList({ showMultiStoreNotice: true });
|
||||||
} else {
|
} else {
|
||||||
dataList.value = [];
|
dataList.value = [];
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getCartList() {
|
async function getCartList(options?: { showMultiStoreNotice?: boolean }) {
|
||||||
try {
|
try {
|
||||||
const res: any = await appMerchantCartListMerchantPost({});
|
const res: any = await appMerchantCartListMerchantPost({});
|
||||||
const merchants = res.data;
|
const merchants = res.data;
|
||||||
@@ -589,10 +715,13 @@ async function getCartList() {
|
|||||||
merchantId: merchant.id,
|
merchantId: merchant.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const payload = parseMerchantCartPayload(cartRes?.data);
|
||||||
return {
|
return {
|
||||||
...merchant,
|
...merchant,
|
||||||
|
deliveryScheduleTimes:
|
||||||
|
payload.deliveryScheduleTimes ?? merchant.deliveryScheduleTimes,
|
||||||
allChecked: false,
|
allChecked: false,
|
||||||
merchantCartVoList: (cartRes.data || []).map((item: any) => ({
|
merchantCartVoList: payload.items.map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
checked: false,
|
checked: false,
|
||||||
})),
|
})),
|
||||||
@@ -608,7 +737,13 @@ async function getCartList() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
dataList.value = await Promise.all(merchantPromises);
|
dataList.value = await Promise.all(merchantPromises);
|
||||||
|
dataList.value.forEach((m) => ensureDefaultMerchantAppointment(m));
|
||||||
syncItemCountCaches();
|
syncItemCountCaches();
|
||||||
|
if (options?.showMultiStoreNotice && dataList.value.length > 1) {
|
||||||
|
nextTick(() => {
|
||||||
|
isMultiStoreNoticeOpen.value = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取购物车列表失败", error);
|
console.error("获取购物车列表失败", error);
|
||||||
dataList.value = [];
|
dataList.value = [];
|
||||||
@@ -667,53 +802,10 @@ async function getCartList() {
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="cart-delivery-row mt-28rpx">
|
<view class="cart-delivery-row mt-28rpx">
|
||||||
<view class="cart-date-pill" @click="onScheduleTap">
|
<view class="cart-price-summary cart-price-summary--full">
|
||||||
<text>{{ deliveryScheduleLabel }}</text>
|
|
||||||
<text class="cart-date-pill-arr">›</text>
|
|
||||||
</view>
|
|
||||||
<view class="cart-price-summary">
|
|
||||||
<text class="cart-summary-line1">{{
|
<text class="cart-summary-line1">{{
|
||||||
itemsTotalWithPriceText
|
itemsTotalWithPriceText
|
||||||
}}</text>
|
}}</text>
|
||||||
<view
|
|
||||||
v-if="
|
|
||||||
feeLine('originalDeliveryFee', 'deliveryFee').show
|
|
||||||
"
|
|
||||||
class="cart-fee-line"
|
|
||||||
>
|
|
||||||
<text>{{ t("pages-user.cart.deliveryFeeLine") }}</text>
|
|
||||||
<text
|
|
||||||
v-if="priceData.originalDeliveryFee != null"
|
|
||||||
class="cart-fee-strike"
|
|
||||||
>${{ priceData.originalDeliveryFee }}</text
|
|
||||||
>
|
|
||||||
<text class="cart-fee-cur"
|
|
||||||
>${{ priceData.deliveryFee }}</text
|
|
||||||
>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-if="
|
|
||||||
feeLine('originalServiceFee', 'serviceFee').show
|
|
||||||
"
|
|
||||||
class="cart-fee-line"
|
|
||||||
>
|
|
||||||
<text>{{ t("pages-user.cart.serviceFeeLine") }}</text>
|
|
||||||
<text
|
|
||||||
v-if="priceData.originalServiceFee != null"
|
|
||||||
class="cart-fee-strike"
|
|
||||||
>${{ priceData.originalServiceFee }}</text
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
v-if="priceData.serviceFeeFree"
|
|
||||||
class="cart-fee-free"
|
|
||||||
>{{ t("pages-user.member.free") }}</text
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
v-else
|
|
||||||
class="cart-fee-cur"
|
|
||||||
>${{ priceData.serviceFee }}</text
|
|
||||||
>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -754,6 +846,16 @@ async function getCartList() {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="cart-merchant-schedule-row">
|
||||||
|
<view
|
||||||
|
class="cart-date-pill"
|
||||||
|
@click="onMerchantScheduleTap(merchant)"
|
||||||
|
>
|
||||||
|
<text>{{ getMerchantScheduleLabel(merchant.id) }}</text>
|
||||||
|
<text class="cart-date-pill-arr">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 商品行 -->
|
<!-- 商品行 -->
|
||||||
<view
|
<view
|
||||||
v-for="(item, itemIndex) in merchant.merchantCartVoList"
|
v-for="(item, itemIndex) in merchant.merchantCartVoList"
|
||||||
@@ -881,7 +983,7 @@ async function getCartList() {
|
|||||||
</z-paging>
|
</z-paging>
|
||||||
|
|
||||||
<view
|
<view
|
||||||
v-if="dataList.length > 0"
|
v-if="dataList.length > 0 && !isMultiStoreNoticeOpen"
|
||||||
class="cart-footer-wrap"
|
class="cart-footer-wrap"
|
||||||
>
|
>
|
||||||
<view class="cart-footer-float">
|
<view class="cart-footer-float">
|
||||||
@@ -928,6 +1030,11 @@ async function getCartList() {
|
|||||||
@confirm="handleRemoveItem"
|
@confirm="handleRemoveItem"
|
||||||
@close="handleRemoveItemPopupClose"
|
@close="handleRemoveItemPopupClose"
|
||||||
/>
|
/>
|
||||||
|
<multi-store-notice
|
||||||
|
v-model="isMultiStoreNoticeOpen"
|
||||||
|
@confirm="confirmMultiStoreNotice"
|
||||||
|
@close="handleMultiStoreNoticeClose"
|
||||||
|
></multi-store-notice>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -997,6 +1104,10 @@ async function getCartList() {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-merchant-schedule-row {
|
||||||
|
padding: 0 24rpx 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.cart-price-summary {
|
.cart-price-summary {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1004,6 +1115,10 @@ async function getCartList() {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-price-summary--full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.cart-summary-line1 {
|
.cart-summary-line1 {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
@@ -1205,7 +1320,7 @@ async function getCartList() {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 999;
|
z-index: 998;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1279,6 +1394,7 @@ async function getCartList() {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<style>
|
<style>
|
||||||
page {
|
page {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import RemoveStore from "./components/remove-store.vue";
|
|||||||
import StoreCartSkeleton from "./components/store-cart-skeleton.vue";
|
import StoreCartSkeleton from "./components/store-cart-skeleton.vue";
|
||||||
import { onBeforeUnmount, ref } from "vue";
|
import { onBeforeUnmount, ref } from "vue";
|
||||||
import {useConfigStore} from "@/store";
|
import {useConfigStore} from "@/store";
|
||||||
|
import { parseMerchantCartPayload } from "@/utils/utils";
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
// 骨架屏加载状态
|
// 骨架屏加载状态
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -219,8 +220,9 @@ function getCartInfo() {
|
|||||||
}
|
}
|
||||||
}).then((res: any)=> {
|
}).then((res: any)=> {
|
||||||
console.log('购物车列表', res)
|
console.log('购物车列表', res)
|
||||||
cartDataList.value = res.data
|
const items = parseMerchantCartPayload(res?.data).items as MerchantCartVo[]
|
||||||
syncItemCountCache(res.data || [])
|
cartDataList.value = items
|
||||||
|
syncItemCountCache(items)
|
||||||
|
|
||||||
// 购物车有菜品,查询菜品会员折扣价
|
// 购物车有菜品,查询菜品会员折扣价
|
||||||
if(cartDataList.value.length > 0) {
|
if(cartDataList.value.length > 0) {
|
||||||
@@ -344,7 +346,7 @@ function navigateTo(url: string) {
|
|||||||
<view @click="goBack" class="h-148rpx flex items-center justify-end pr-30rpx">
|
<view @click="goBack" class="h-148rpx flex items-center justify-end pr-30rpx">
|
||||||
<view class="w-212rpx h-64rpx bg-#F2F2F2 rounded-64rpx center">
|
<view class="w-212rpx h-64rpx bg-#F2F2F2 rounded-64rpx center">
|
||||||
<image
|
<image
|
||||||
src="@img/chef/1285.png"
|
src="/static/app/images/add_cart.png"
|
||||||
class="mr-16rpx w-24rpx h-24rpx shrink-0"
|
class="mr-16rpx w-24rpx h-24rpx shrink-0"
|
||||||
></image>
|
></image>
|
||||||
<text class="text-#333 text-28rpx lh-28rpx font-500">{{ t('pages-user.cart.addItems') }}</text>
|
<text class="text-#333 text-28rpx lh-28rpx font-500">{{ t('pages-user.cart.addItems') }}</text>
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function runBatchDelete() {
|
|||||||
}
|
}
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: t('common.prompt.system-prompt'),
|
title: t('common.prompt.system-prompt'),
|
||||||
content: t('pages.mine.collectionBatchDeleteConfirm', { count: selectedIds.value.length }),
|
content: `${t('pages.mine.collectionBatchDeleteConfirmPrefix')}${selectedIds.value.length}${t('pages.mine.collectionBatchDeleteConfirmSuffix')}`,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (!res.confirm) return
|
if (!res.confirm) return
|
||||||
const targetType = typeList[props.index] as number
|
const targetType = typeList[props.index] as number
|
||||||
|
|||||||
@@ -42,20 +42,10 @@ function formatCouponDetail(item: any) {
|
|||||||
return t('pages-store.store.discount')
|
return t('pages-store.store.discount')
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillI18nParams(template: string, params: Record<string, string | number>) {
|
|
||||||
let text = template
|
|
||||||
Object.keys(params).forEach((key) => {
|
|
||||||
const value = String(params[key] ?? '')
|
|
||||||
text = text.replace(new RegExp(`\\{${key}\\}`, 'g'), value)
|
|
||||||
text = text.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value)
|
|
||||||
})
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCouponMerchantText(item: any) {
|
function formatCouponMerchantText(item: any) {
|
||||||
const name = String(item?.merchantVo?.merchantName || '').trim()
|
const name = String(item?.merchantVo?.merchantName || '').trim()
|
||||||
if (name) {
|
if (name) {
|
||||||
return fillI18nParams(t('pages-user.coupon.merchant-only'), { name })
|
return `${t('pages-user.coupon.merchantOnlyPrefix')}${name}${t('pages-user.coupon.merchantOnlySuffix')}`
|
||||||
}
|
}
|
||||||
return item?.snapshotMerchantId
|
return item?.snapshotMerchantId
|
||||||
? t('pages-user.coupon.merchant-specific')
|
? t('pages-user.coupon.merchant-specific')
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const configStore = useConfigStore()
|
|||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
import {EventEnum} from "@/constant/enums";
|
import {EventEnum} from "@/constant/enums";
|
||||||
import {appMembershipRechargePost, appUserCardSelectDefaultPost} from "@/service";
|
import {appMembershipRechargePost, appUserCardSelectDefaultPost} from "@/service";
|
||||||
import { tWithParams } from '@/utils/utils';
|
|
||||||
|
|
||||||
// 订阅选项
|
// 订阅选项
|
||||||
const subscriptionPlans = ref([]);
|
const subscriptionPlans = ref([]);
|
||||||
@@ -213,7 +212,7 @@ function changeAutoSubscribe() {
|
|||||||
<text
|
<text
|
||||||
v-if="+item.membershipType === 2"
|
v-if="+item.membershipType === 2"
|
||||||
class=" text-#999 ml-16rpx"
|
class=" text-#999 ml-16rpx"
|
||||||
>({{ tWithParams('pages-user.member.billedAnnually', { amount: item.rechargeAmount }) }})</text
|
>({{ t('pages-user.member.billedAnnuallyPrefix') }}{{ item.rechargeAmount }}{{ t('pages-user.member.billedAnnuallySuffix') }})</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -240,7 +239,7 @@ function changeAutoSubscribe() {
|
|||||||
class="w-32rpx h-32rpx mr-16rpx shrink-0"
|
class="w-32rpx h-32rpx mr-16rpx shrink-0"
|
||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
/>
|
/>
|
||||||
<text class="text-24rpx text-#CE7138">{{ tWithParams('pages-user.member.savingsPerYear', { amount: `$${annualDiscount}` }) }}</text>
|
<text class="text-24rpx text-#CE7138">{{ t('pages-user.member.savingsPerYearPrefix') }}{{ annualDiscount }}{{ t('pages-user.member.savingsPerYearSuffix') }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { appMarketActivityListPost } from '@/service'
|
||||||
|
import { useConfigStore } from '@/store'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const activityId = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const activity = ref<Record<string, any>>({})
|
||||||
|
|
||||||
|
const bannerSrc = computed(() => {
|
||||||
|
return activity.value.activityImageUrl || activity.value.activityImage || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const activityTitle = computed(() => {
|
||||||
|
return activity.value.activityName || t('pages-user.recharge.activityDetailTitle')
|
||||||
|
})
|
||||||
|
|
||||||
|
const activityContent = computed(() => {
|
||||||
|
const content = activity.value.activityContent
|
||||||
|
if (typeof content === 'string' && content.trim()) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
return `<p>${t('pages-user.recharge.activityPlaceholderDesc')}</p>`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadActivityDetail() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await appMarketActivityListPost({})
|
||||||
|
const list = Array.isArray(res?.data) ? res.data : []
|
||||||
|
const matched = list.find((item) => String(item?.id) === activityId.value)
|
||||||
|
activity.value = matched || {}
|
||||||
|
} catch {
|
||||||
|
activity.value = {}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRecharge() {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages-user/pages/recharge/index',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query?: Record<string, string | undefined>) => {
|
||||||
|
activityId.value = String(query?.id ?? '')
|
||||||
|
loadActivityDetail()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="recharge-activity-page">
|
||||||
|
<navbar :title="t('pages-user.recharge.activityDetailTitle')" />
|
||||||
|
|
||||||
|
<view v-if="loading" class="recharge-activity-page__loading center py-120rpx text-28rpx text-#999">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view class="recharge-activity-page__banner">
|
||||||
|
<image
|
||||||
|
v-if="bannerSrc"
|
||||||
|
:src="bannerSrc"
|
||||||
|
class="recharge-activity-page__banner-img"
|
||||||
|
mode="scaleToFill"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="recharge-activity-page__banner-placeholder"
|
||||||
|
>
|
||||||
|
{{ t('pages-user.recharge.activityDetailTitle') }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="px-30rpx pt-32rpx">
|
||||||
|
<view class="mt-32rpx mb-24rpx text-40rpx lh-48rpx text-#333 font-bold tracking-[.04em]">
|
||||||
|
{{ activityTitle }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="recharge-activity-page__card rounded-16rpx bg-white px-28rpx py-32rpx">
|
||||||
|
<view class="text-30rpx lh-30rpx text-#333 font-500 mb-24rpx">
|
||||||
|
{{ t('pages-user.recharge.activityRulesTitle') }}
|
||||||
|
</view>
|
||||||
|
<mp-html
|
||||||
|
selectable
|
||||||
|
:preview-img="false"
|
||||||
|
:show-img-menu="false"
|
||||||
|
:tag-style="{
|
||||||
|
div: 'white-space: pre-wrap;',
|
||||||
|
p: 'white-space: pre-wrap;color:#666;font-size:14px;line-height:1.7;',
|
||||||
|
img: 'width:100%;max-width:100%;height:auto;',
|
||||||
|
}"
|
||||||
|
:content="activityContent"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="h-160rpx" />
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]" />
|
||||||
|
|
||||||
|
<view class="recharge-activity-page__footer fixed bottom-0 left-0 right-0 px-30rpx pt-16rpx bg-#fff">
|
||||||
|
<wd-button
|
||||||
|
custom-class="!h-98rpx !text-30rpx !text-#fff !lh-98rpx !rounded-16rpx"
|
||||||
|
block
|
||||||
|
@click="handleRecharge"
|
||||||
|
>
|
||||||
|
{{ t('pages-user.recharge.activityRechargeBtn') }}
|
||||||
|
</wd-button>
|
||||||
|
<view :style="[configStore.iosSafeBottomPlaceholder]" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
page {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.recharge-activity-page__banner {
|
||||||
|
width: 100%;
|
||||||
|
height: 420rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-activity-page__banner-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-activity-page__banner-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-activity-page__footer {
|
||||||
|
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,156 +1,465 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {appUserCardSelectDefaultPost} from "@/service";
|
import Config from '@/config/index'
|
||||||
import useEventEmit from "@/hooks/useEventEmit";
|
import ChooseImage from '@/components/choose-image/choose-image.vue'
|
||||||
import {EventEnum} from "@/constant/enums";
|
import { appMarketActivityRechargePromotionGet, appUserCardSelectDefaultPost } from '@/service'
|
||||||
import { rechargeBalanceApi } from "@/pages-user/service";
|
import useEventEmit from '@/hooks/useEventEmit'
|
||||||
const { t } = useI18n();
|
import { EventEnum } from '@/constant/enums'
|
||||||
|
import { rechargeBalanceApi } from '@/pages-user/service'
|
||||||
|
|
||||||
const amount = ref()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
const payMethodOptions = ref({
|
const PAY_METHOD_STRIPE = 1
|
||||||
payMethod: 1,
|
const PAY_METHOD_ZIP = 3
|
||||||
cardId: '',
|
|
||||||
cardNumber: '',
|
const amount = ref<string | number>('')
|
||||||
|
const payMethod = ref(PAY_METHOD_STRIPE)
|
||||||
|
const cardId = ref('')
|
||||||
|
const cardNumber = ref('')
|
||||||
|
const zipPayVoucher = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const rechargePromotion = ref<Record<string, any> | null>(null)
|
||||||
|
const voucherChooseRef = ref<InstanceType<typeof ChooseImage>>()
|
||||||
|
const promotionNoticeRef = ref<{ reset?: () => void }>()
|
||||||
|
const zellePayPath = Config.zellePayPath
|
||||||
|
|
||||||
|
const promotionTiers = computed(() => {
|
||||||
|
const list = rechargePromotion.value?.rechargeTierList
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return [...list].sort(
|
||||||
|
(a, b) => (Number(a?.sort) || 0) - (Number(b?.sort) || 0),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSubmit() {
|
function formatPromotionTier(tier: Record<string, any>) {
|
||||||
if(!amount.value) {
|
const recharge = String(tier.rechargeAmount ?? '0')
|
||||||
|
const gift = String(tier.giftAmount ?? '0')
|
||||||
|
if (locale.value === 'zh-Hans') {
|
||||||
|
return `充${recharge}送${gift}`
|
||||||
|
}
|
||||||
|
return `Deposit ${recharge}, get ${gift} bonus`
|
||||||
|
}
|
||||||
|
|
||||||
|
const promotionActivityName = computed(() => {
|
||||||
|
return (
|
||||||
|
rechargePromotion.value?.activityName ||
|
||||||
|
t('pages-user.recharge.activityDetailTitle')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const promotionNoticeTexts = computed(() => {
|
||||||
|
if (!promotionTiers.value.length) return []
|
||||||
|
return promotionTiers.value.map((tier) =>
|
||||||
|
`${promotionActivityName.value}:${formatPromotionTier(tier)}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 横向无缝滚动:多条拼接,重复一遍避免循环跳变 */
|
||||||
|
const promotionNoticeScrollText = computed(() => {
|
||||||
|
const items = promotionNoticeTexts.value.filter(Boolean)
|
||||||
|
if (!items.length) return ''
|
||||||
|
const once = items.join(' ')
|
||||||
|
if (items.length === 1) return once
|
||||||
|
return `${once} ${once}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const promotionNoticeShouldScroll = computed(
|
||||||
|
() => promotionNoticeTexts.value.length > 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
function selectPayMethod(method: number) {
|
||||||
|
if (payMethod.value === method) return
|
||||||
|
payMethod.value = method
|
||||||
|
if (method === PAY_METHOD_STRIPE) {
|
||||||
|
zipPayVoucher.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVoucherUrl(payload: unknown): string {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
const first = payload[0]
|
||||||
|
return typeof first === 'string' ? first : ''
|
||||||
|
}
|
||||||
|
return typeof payload === 'string' ? payload : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUploadVoucher() {
|
||||||
|
voucherChooseRef.value?.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVoucherUploaded(urls: unknown) {
|
||||||
|
const url = normalizeVoucherUrl(urls)
|
||||||
|
if (!url) {
|
||||||
|
uni.showToast({
|
||||||
|
title: t('pages-store.order.voucherUploadFailed'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zipPayVoucher.value = url
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAmount(): number | null {
|
||||||
|
const val = Number(amount.value)
|
||||||
|
if (!amount.value && amount.value !== 0) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: t('pages-user.recharge.description'),
|
title: t('pages-user.recharge.description'),
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
})
|
})
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
if(amount.value <= 0) {
|
if (!Number.isFinite(val) || val <= 0) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: t('pages-user.recharge.amount-invalid'),
|
title: t('pages-user.recharge.amount-invalid'),
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
})
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const payAmount = validateAmount()
|
||||||
|
if (payAmount == null || submitting.value) return
|
||||||
|
|
||||||
|
if (payMethod.value === PAY_METHOD_STRIPE) {
|
||||||
|
if (!cardId.value) {
|
||||||
|
uni.showToast({
|
||||||
|
title: t('pages-user.recharge.pay-method'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await rechargeBalanceApi({
|
||||||
|
payAmount,
|
||||||
|
payMethod: PAY_METHOD_STRIPE,
|
||||||
|
cardId: cardId.value,
|
||||||
|
cardType: 1,
|
||||||
|
})
|
||||||
|
uni.showToast({
|
||||||
|
title: t('pages-user.recharge.success'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
setTimeout(() => uni.navigateBack(), 1000)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if(!payMethodOptions.value.cardId) {
|
|
||||||
|
if (!zipPayVoucher.value) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: t('pages-user.recharge.pay-method'),
|
title: t('pages-user.recharge.voucherRequired'),
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rechargeBalanceApi({
|
|
||||||
payAmount: Number(amount.value),
|
submitting.value = true
|
||||||
cardId: payMethodOptions.value.cardId,
|
try {
|
||||||
cardType: 1
|
await rechargeBalanceApi({
|
||||||
}).then(res => {
|
payAmount,
|
||||||
console.log('充值余额', res)
|
payMethod: PAY_METHOD_ZIP,
|
||||||
|
zipPayVoucher: zipPayVoucher.value,
|
||||||
|
})
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: t('pages-user.recharge.success'),
|
title: t('pages-user.recharge.zipSubmitSuccess'),
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => uni.navigateBack(), 1200)
|
||||||
uni.navigateBack()
|
} finally {
|
||||||
}, 1000)
|
submitting.value = false
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateTo(url: string) {
|
function navigateTo(url: string) {
|
||||||
uni.navigateTo({ url })
|
uni.navigateTo({ url })
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad(()=> {
|
async function loadRechargePromotion() {
|
||||||
// 查询用户默认信用卡
|
try {
|
||||||
appUserCardSelectDefault()
|
const res: any = await appMarketActivityRechargePromotionGet({})
|
||||||
})
|
rechargePromotion.value = res?.data ?? null
|
||||||
|
} catch {
|
||||||
|
rechargePromotion.value = null
|
||||||
|
} finally {
|
||||||
|
await nextTick()
|
||||||
|
promotionNoticeRef.value?.reset?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function appUserCardSelectDefault() {
|
function appUserCardSelectDefault() {
|
||||||
appUserCardSelectDefaultPost({}).then((res: any)=> {
|
appUserCardSelectDefaultPost({}).then((res: any) => {
|
||||||
console.log('查询用户默认信用卡', res)
|
cardId.value = res.data?.cardId || ''
|
||||||
payMethodOptions.value.cardId = res.data?.cardId || ''
|
cardNumber.value = res.data?.cardNumber || ''
|
||||||
payMethodOptions.value.cardNumber = res.data?.cardNumber || ''
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
useEventEmit(EventEnum.CHOOSE_PAYMENT_METHOD, (data) => {
|
useEventEmit(EventEnum.CHOOSE_PAYMENT_METHOD, (data) => {
|
||||||
if(data) {
|
if (!data) return
|
||||||
payMethodOptions.value.cardId = data.cardId
|
cardId.value = data.cardId || ''
|
||||||
payMethodOptions.value.cardNumber = data.cardNumber
|
cardNumber.value = data.cardNumber || ''
|
||||||
payMethodOptions.value.payMethod = 1
|
payMethod.value = PAY_METHOD_STRIPE
|
||||||
}
|
zipPayVoucher.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
appUserCardSelectDefault()
|
||||||
|
void loadRechargePromotion()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="">
|
<view class="recharge-page">
|
||||||
<navbar :title="t('pages-user.recharge.title')"/>
|
<navbar :title="t('pages-user.recharge.title')" />
|
||||||
|
|
||||||
<view class="px-20rpx py-22rpx">
|
<view class="recharge-page__body">
|
||||||
<view class="bg-white rounded-12rpx p-28rpx">
|
<view v-if="promotionNoticeScrollText" class="recharge-promo-notice mx-30rpx mb-30rpx">
|
||||||
|
<wd-notice-bar
|
||||||
|
ref="promotionNoticeRef"
|
||||||
|
prefix="sound"
|
||||||
|
:text="promotionNoticeScrollText"
|
||||||
|
direction="horizontal"
|
||||||
|
:scrollable="promotionNoticeShouldScroll"
|
||||||
|
:delay="1"
|
||||||
|
:speed="60"
|
||||||
|
color="#00A76D"
|
||||||
|
background-color="#E8F8F1"
|
||||||
|
custom-class="recharge-promo-notice__bar"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bg-white rounded-12rpx p-28rpx mx-30rpx">
|
||||||
<view class="text-30rpx lh-30rpx text-#333 font-500 mb-30rpx">
|
<view class="text-30rpx lh-30rpx text-#333 font-500 mb-30rpx">
|
||||||
{{ t('pages-user.recharge.amount') }}
|
{{ t('pages-user.recharge.amount') }}
|
||||||
</view>
|
</view>
|
||||||
<view class="flex items-center bg-#F7F7F7 h-108rpx rounded-12rpx px-26rpx">
|
<view class="flex items-center bg-#F7F7F7 h-108rpx rounded-12rpx px-26rpx">
|
||||||
|
<text class="text-30rpx text-#333 mr-8rpx">$</text>
|
||||||
<wd-input
|
<wd-input
|
||||||
v-model="amount"
|
v-model="amount"
|
||||||
:focus-when-clear="false"
|
:focus-when-clear="false"
|
||||||
:placeholder="t('pages-user.recharge.description')"
|
:placeholder="t('pages-user.recharge.description')"
|
||||||
clearable
|
clearable
|
||||||
confirm-type="search"
|
custom-class="!text-30rpx !bg-transparent flex-1"
|
||||||
custom-class="!text-30rpx !bg-transparent flex-1"
|
no-border
|
||||||
no-border
|
placeholderStyle="font-size: 30rpx;color: #999;"
|
||||||
placeholderStyle="font-size: 30rpx;color: #999;"
|
type="digit"
|
||||||
type="number"
|
use-prefix-slot
|
||||||
use-prefix-slot
|
/>
|
||||||
>
|
|
||||||
</wd-input>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view
|
<view class="bg-white rounded-12rpx p-28rpx mt-30rpx mx-30rpx">
|
||||||
@click="navigateTo('/pages-user/pages/choose-paymethod/index?hideWallet=true')"
|
<view class="text-30rpx lh-30rpx text-#333 font-500 mb-24rpx">
|
||||||
class="bg-white rounded-12rpx p-28rpx mt-30rpx flex-center-sb text-32rpx lh-32rpx font-500 text-#333"
|
{{ t('pages-user.choosePaymethod.title') }}
|
||||||
>
|
|
||||||
<view class="flex items-center">
|
|
||||||
<image
|
|
||||||
src="@img/chef/138.png"
|
|
||||||
class="w-44rpx h-44rpx mr-28rpx shrink-0"
|
|
||||||
mode="aspectFit"
|
|
||||||
/>
|
|
||||||
<text class="">
|
|
||||||
{{ t('pages-user.choosePaymethod.creditCard') }}
|
|
||||||
</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="flex items-center">
|
|
||||||
<view class="mr-12px">
|
<view
|
||||||
<!--判断用户是否有信用卡-->
|
class="recharge-pay-option"
|
||||||
<text v-if="!payMethodOptions.cardId">{{ t("pages-user.member.creditCard") }}</text>
|
:class="{ 'recharge-pay-option--on': payMethod === PAY_METHOD_STRIPE }"
|
||||||
<text v-else>{{ payMethodOptions.cardNumber }}</text>
|
@click="selectPayMethod(PAY_METHOD_STRIPE)"
|
||||||
|
>
|
||||||
|
<view class="recharge-pay-option-dot">
|
||||||
|
<view v-if="payMethod === PAY_METHOD_STRIPE" class="recharge-pay-option-dot-inner" />
|
||||||
</view>
|
</view>
|
||||||
|
<image src="@img/chef/138.png" class="w-44rpx h-44rpx shrink-0" mode="aspectFit" />
|
||||||
|
<text class="flex-1 text-30rpx text-#333">{{ t('pages-user.recharge.payMethodStripe') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="payMethod === PAY_METHOD_STRIPE"
|
||||||
|
class="recharge-card-row"
|
||||||
|
@click="navigateTo('/pages-user/pages/choose-paymethod/index?hideWallet=true')"
|
||||||
|
>
|
||||||
|
<text class="text-28rpx text-#666">{{ t('pages-user.choosePaymethod.creditCard') }}</text>
|
||||||
|
<view class="flex items-center">
|
||||||
|
<text class="text-28rpx text-#333 mr-12rpx">
|
||||||
|
<template v-if="!cardId">{{ t('pages-user.member.creditCard') }}</template>
|
||||||
|
<template v-else>{{ cardNumber }}</template>
|
||||||
|
</text>
|
||||||
|
<image src="@img/chef/142.png" class="w-32rpx h-32rpx shrink-0" mode="aspectFit" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="recharge-pay-option mt-20rpx"
|
||||||
|
:class="{ 'recharge-pay-option--on': payMethod === PAY_METHOD_ZIP }"
|
||||||
|
@click="selectPayMethod(PAY_METHOD_ZIP)"
|
||||||
|
>
|
||||||
|
<view class="recharge-pay-option-dot">
|
||||||
|
<view v-if="payMethod === PAY_METHOD_ZIP" class="recharge-pay-option-dot-inner" />
|
||||||
|
</view>
|
||||||
|
<text class="flex-1 text-30rpx text-#333">{{ t('pages-user.recharge.payMethodZip') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="payMethod === PAY_METHOD_ZIP" class="recharge-zip-block">
|
||||||
|
<text class="recharge-zip-hint">{{ t('pages-user.recharge.zipPayHint') }}</text>
|
||||||
<image
|
<image
|
||||||
src="@img/chef/142.png"
|
v-if="zellePayPath"
|
||||||
class="w-32rpx h-32rpx shrink-0"
|
:src="zellePayPath"
|
||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
|
class="recharge-zip-qr"
|
||||||
/>
|
/>
|
||||||
|
<view v-else class="recharge-zip-qr recharge-zip-qr--empty center text-24rpx text-#999">
|
||||||
|
QR
|
||||||
|
</view>
|
||||||
|
<wd-button
|
||||||
|
custom-class="!h-88rpx !rounded-16rpx !text-28rpx !bg-#14181B !mt-24rpx"
|
||||||
|
block
|
||||||
|
@click="openUploadVoucher"
|
||||||
|
>
|
||||||
|
{{ t('pages-user.recharge.uploadVoucher') }}
|
||||||
|
</wd-button>
|
||||||
|
<view v-if="zipPayVoucher" class="recharge-voucher-preview mt-24rpx">
|
||||||
|
<text class="text-26rpx text-#00A76D mb-12rpx block">{{
|
||||||
|
t('pages-user.recharge.voucherUploaded')
|
||||||
|
}}</text>
|
||||||
|
<image :src="zipPayVoucher" mode="aspectFill" class="recharge-voucher-img" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<fixed-bottom-large-btn
|
<fixed-bottom-large-btn
|
||||||
:text="t('common.recharge')"
|
:text="submitting ? t('common.loading') : t('common.recharge')"
|
||||||
class="z-100"
|
class="z-100"
|
||||||
:fixed="true"
|
:fixed="true"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<choose-image ref="voucherChooseRef" :count="1" @change="onVoucherUploaded" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
page {
|
page {
|
||||||
background-color: #F5F5F5;
|
background-color: #f5f5f5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
:deep(.wd-input__clear) {
|
.recharge-page {
|
||||||
background-color: transparent !important;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recharge-page__body {
|
||||||
|
padding-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-promo-notice {
|
||||||
|
border-radius: 12rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.recharge-promo-notice__bar) {
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
padding: 16rpx 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.recharge-promo-notice__bar .wd-notice-bar__prefix) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.recharge-promo-notice__bar .wd-notice-bar__wrap) {
|
||||||
|
height: 36rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.recharge-promo-notice__bar .wd-notice-bar__content) {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-pay-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 24rpx 20rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 2rpx solid #ebebeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-pay-option--on {
|
||||||
|
border-color: #14181b;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-pay-option-dot {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2rpx solid #14181b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-pay-option-dot-inner {
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #14181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
margin-left: 56rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-zip-block {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-zip-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-zip-qr {
|
||||||
|
width: 100%;
|
||||||
|
height: 420rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-zip-qr--empty {
|
||||||
|
height: 220rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-voucher-preview {
|
||||||
|
padding-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-voucher-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 280rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.wd-input__clear),
|
||||||
:deep(.wd-input__icon) {
|
:deep(.wd-input__icon) {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,32 +21,33 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<view :change:prop="mapRenderjs.searchPlace" :prop="querySearch" class="bg-#f5f5f5">
|
<view class="bg-#f5f5f5">
|
||||||
<view class="px-22rpx py-20rpx">
|
<view class="px-22rpx py-20rpx">
|
||||||
<template v-if="placesLength === 0">
|
<view v-if="logicStore.searchLoading" class="center py-100rpx text-28rpx text-#9B9CA0">
|
||||||
<view class="center py-100rpx">
|
Loading...
|
||||||
<image class="w-250rpx h-250rpx" src="@img/chef/100.png"></image>
|
</view>
|
||||||
|
|
||||||
|
<template v-else-if="placesLength > 0">
|
||||||
|
<view class="rounded-36rpx bg-white">
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in logicStore.placesList"
|
||||||
|
:key="(item as any).id || index"
|
||||||
|
:class="[index === logicStore.placesList.length - 1 ? '' : 'border-bottom']"
|
||||||
|
class="px-22rpx pb-30rpx pt-34rpx"
|
||||||
|
@click="handleClickLocation(item as AddressPlaceItem)"
|
||||||
|
>
|
||||||
|
<view class="text-#000 text-26rpx font-bold">{{ (item as any).displayName }}</view>
|
||||||
|
<view class="text-#9B9CA0 text-24rpx flex-center-sb">
|
||||||
|
<view>{{ (item as any).formattedAddress }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<view v-else-if="hasSearched" class="center py-100rpx">
|
||||||
<view class="rounded-36rpx bg-white">
|
<image class="w-250rpx h-250rpx" src="@img/chef/100.png"></image>
|
||||||
<template v-for="(item,index) in logicStore.placesList" :key="index">
|
</view>
|
||||||
<view :class="[index === logicStore.placesList.length - 1 ? '' : 'border-bottom']"
|
|
||||||
class="px-22rpx pb-30rpx pt-34rpx"
|
|
||||||
@click="handleClickLocation(item)">
|
|
||||||
<!-- <view >{{ item }}</view> -->
|
|
||||||
<view class="text-#000 text-26rpx font-bold">{{ item.displayName }}</view>
|
|
||||||
<view class="text-#9B9CA0 text-24rpx flex-center-sb">
|
|
||||||
<view>{{ item.formattedAddress }}</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
</view>
|
</view>
|
||||||
<view id="map" :change:prop="mapRenderjs.initMap" :prop="mapDataComputed" :user-location="userLocation"
|
|
||||||
class="absolute left-0 bottom-0 w-0 h-0"></view>
|
|
||||||
</view>
|
</view>
|
||||||
</z-paging>
|
</z-paging>
|
||||||
</template>
|
</template>
|
||||||
@@ -54,84 +55,245 @@
|
|||||||
import Config from "@/config";
|
import Config from "@/config";
|
||||||
import {useLogicStore} from "@/pages-user/store/logic";
|
import {useLogicStore} from "@/pages-user/store/logic";
|
||||||
import {useUserStore} from "@/store";
|
import {useUserStore} from "@/store";
|
||||||
import { getCurrentInstance } from "vue";
|
import {debounce} from "throttle-debounce";
|
||||||
|
|
||||||
const {t, locale} = useI18n();
|
const {t, locale} = useI18n();
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const logicStore = useLogicStore()
|
const logicStore = useLogicStore()
|
||||||
|
|
||||||
|
export interface AddressPlaceItem {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
formattedAddress: string
|
||||||
|
location: { lat: number; lng: number } | null
|
||||||
|
}
|
||||||
|
|
||||||
let openerEventChannel: any = null
|
let openerEventChannel: any = null
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
// 统一通过 getOpenerEventChannel 获取导航传入的 eventChannel
|
|
||||||
const pages = getCurrentPages() as any[]
|
const pages = getCurrentPages() as any[]
|
||||||
const page = pages[pages.length - 1]
|
const page = pages[pages.length - 1]
|
||||||
openerEventChannel = page?.getOpenerEventChannel?.() || null
|
openerEventChannel = page?.getOpenerEventChannel?.() || null
|
||||||
})
|
})
|
||||||
|
|
||||||
// 生命周期:清空地址列表
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
logicStore.clearPlacesList()
|
logicStore.clearPlacesList()
|
||||||
})
|
})
|
||||||
|
|
||||||
const placesLength = computed(() => {
|
const placesLength = computed(() => logicStore.placesList.length)
|
||||||
return logicStore.placesList.length;
|
const mapSearchKeyword = ref<string>('')
|
||||||
});
|
const hasSearched = ref(false)
|
||||||
|
|
||||||
const userLocation = computed(() => ({
|
|
||||||
longitude: userStore.location.longitude,
|
|
||||||
latitude: userStore.location.latitude
|
|
||||||
}));
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => logicStore.searchLoading,
|
() => logicStore.searchLoading,
|
||||||
(newValue) => {
|
(loading) => {
|
||||||
if (newValue) {
|
if (loading) {
|
||||||
uni.showLoading({
|
uni.showLoading({ title: 'Loading...', mask: true })
|
||||||
title: 'Loading...',
|
} else {
|
||||||
mask: true,
|
uni.hideLoading()
|
||||||
});
|
}
|
||||||
} else {
|
},
|
||||||
uni.hideLoading();
|
{ immediate: true },
|
||||||
}
|
)
|
||||||
},
|
|
||||||
{immediate: true}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 地图搜索关键词
|
function resolveLanguageCode() {
|
||||||
const mapSearchKeyword = ref<string>('');
|
if (locale.value === 'zh-Hans') return 'zh-CN'
|
||||||
const querySearch = computed(() => {
|
if (locale.value === 'en') return 'en'
|
||||||
return {
|
return String(locale.value || 'en')
|
||||||
keyword: mapSearchKeyword.value,
|
}
|
||||||
|
|
||||||
|
function resolveRegionCode() {
|
||||||
|
return locale.value === 'zh-Hans' ? 'CN' : 'US'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBiasCenter() {
|
||||||
|
const lat = Number(userStore.location.latitude)
|
||||||
|
const lng = Number(userStore.location.longitude)
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lng) && (lat !== 0 || lng !== 0)) {
|
||||||
|
return { latitude: lat, longitude: lng }
|
||||||
}
|
}
|
||||||
|
if (locale.value === 'zh-Hans') {
|
||||||
|
return { latitude: 39.9042, longitude: 116.4074 }
|
||||||
|
}
|
||||||
|
return { latitude: 38.8977, longitude: -77.0365 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlacesApiPlace(place: any): AddressPlaceItem | null {
|
||||||
|
if (!place) return null
|
||||||
|
|
||||||
|
const lat = place.location?.latitude
|
||||||
|
const lng = place.location?.longitude
|
||||||
|
const formattedAddress = String(place.formattedAddress || '').trim()
|
||||||
|
const displayName = String(
|
||||||
|
typeof place.displayName === 'string'
|
||||||
|
? place.displayName
|
||||||
|
: place.displayName?.text || formattedAddress,
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
if (!displayName && !formattedAddress) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(place.id || ''),
|
||||||
|
displayName: displayName || formattedAddress,
|
||||||
|
formattedAddress,
|
||||||
|
location:
|
||||||
|
Number.isFinite(Number(lat)) && Number.isFinite(Number(lng))
|
||||||
|
? { lat: Number(lat), lng: Number(lng) }
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGeocodeResult(result: any): AddressPlaceItem | null {
|
||||||
|
if (!result) return null
|
||||||
|
|
||||||
|
const formattedAddress = String(result.formatted_address || '').trim()
|
||||||
|
const lat = result.geometry?.location?.lat
|
||||||
|
const lng = result.geometry?.location?.lng
|
||||||
|
const displayName =
|
||||||
|
formattedAddress.split(',')[0]?.trim() || formattedAddress
|
||||||
|
|
||||||
|
if (!formattedAddress) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(result.place_id || ''),
|
||||||
|
displayName,
|
||||||
|
formattedAddress,
|
||||||
|
location:
|
||||||
|
Number.isFinite(Number(lat)) && Number.isFinite(Number(lng))
|
||||||
|
? { lat: Number(lat), lng: Number(lng) }
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestJson<T = any>(options: UniApp.RequestOptions) {
|
||||||
|
return new Promise<{ statusCode: number; data: T }>((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
timeout: 10000,
|
||||||
|
...options,
|
||||||
|
success: (res) => resolve(res as any),
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchByPlacesTextSearch(keyword: string) {
|
||||||
|
const languageCode = resolveLanguageCode()
|
||||||
|
const regionCode = resolveRegionCode()
|
||||||
|
const center = resolveBiasCenter()
|
||||||
|
|
||||||
|
const res = await requestJson<{ places?: any[]; error?: any }>({
|
||||||
|
url: 'https://places.googleapis.com/v1/places:searchText',
|
||||||
|
method: 'POST',
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Goog-Api-Key': Config.googleMapKey,
|
||||||
|
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
textQuery: keyword,
|
||||||
|
languageCode,
|
||||||
|
regionCode,
|
||||||
|
pageSize: 20,
|
||||||
|
locationBias: {
|
||||||
|
circle: {
|
||||||
|
center,
|
||||||
|
radius: 50000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
const message = (res.data as any)?.error?.message || `HTTP ${res.statusCode}`
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const places = Array.isArray(res.data?.places) ? res.data.places : []
|
||||||
|
return places.map(parsePlacesApiPlace).filter(Boolean) as AddressPlaceItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchByGeocodeFallback(keyword: string) {
|
||||||
|
const languageCode = resolveLanguageCode()
|
||||||
|
const regionCode = resolveRegionCode()
|
||||||
|
const query = encodeURIComponent(keyword)
|
||||||
|
const url =
|
||||||
|
`https://maps.googleapis.com/maps/api/geocode/json?address=${query}` +
|
||||||
|
`&key=${Config.googleMapKey}&language=${languageCode}®ion=${regionCode}`
|
||||||
|
|
||||||
|
const res = await requestJson<{ results?: any[]; status?: string }>({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.statusCode !== 200 || res.data?.status !== 'OK') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = Array.isArray(res.data.results) ? res.data.results : []
|
||||||
|
return results.map(parseGeocodeResult).filter(Boolean) as AddressPlaceItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchPlaces(keyword: string) {
|
||||||
|
logicStore.searchLoading = true
|
||||||
|
hasSearched.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
let list = await searchByPlacesTextSearch(keyword)
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
list = await searchByGeocodeFallback(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
logicStore.setPlacesList(list)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('地址搜索失败', error)
|
||||||
|
try {
|
||||||
|
const fallback = await searchByGeocodeFallback(keyword)
|
||||||
|
logicStore.setPlacesList(fallback)
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Geocode 回退失败', fallbackError)
|
||||||
|
logicStore.setPlacesList([])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
logicStore.searchLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(300, (keyword: string) => {
|
||||||
|
void searchPlaces(keyword)
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleClickLocation(item: any) {
|
watch(mapSearchKeyword, (keyword) => {
|
||||||
console.log('item', item)
|
const text = String(keyword || '').trim()
|
||||||
|
if (!text) {
|
||||||
|
hasSearched.value = false
|
||||||
|
logicStore.clearPlacesList()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debouncedSearch(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleClickLocation(item: AddressPlaceItem) {
|
||||||
openerEventChannel?.emit?.('chooseAddress', item)
|
openerEventChannel?.emit?.('chooseAddress', item)
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用当前位置
|
|
||||||
function handleUseLocation() {
|
function handleUseLocation() {
|
||||||
// 检查是否获取到了当前定位
|
|
||||||
if (!userStore.location.longitude || !userStore.location.latitude) {
|
if (!userStore.location.longitude || !userStore.location.latitude) {
|
||||||
// 尝试再次获取定位
|
|
||||||
uni.getLocation({
|
uni.getLocation({
|
||||||
isHighAccuracy: true,
|
isHighAccuracy: true,
|
||||||
type: 'gcj02',
|
type: 'gcj02',
|
||||||
geocode: true,
|
geocode: true,
|
||||||
success: async (res) => {
|
success: (res) => {
|
||||||
getCityName(res.latitude, res.longitude)
|
getCityName(res.latitude, res.longitude)
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: () => {
|
||||||
const settings = uni.getAppAuthorizeSetting()
|
const settings = uni.getAppAuthorizeSetting()
|
||||||
console.log(settings)
|
|
||||||
if (settings.locationAuthorized === 'denied') {
|
if (settings.locationAuthorized === 'denied') {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: t('common.prompt.please-authorize-location-information'),
|
title: t('common.prompt.please-authorize-location-information'),
|
||||||
icon: 'none'
|
icon: 'none',
|
||||||
})
|
})
|
||||||
setTimeout(()=> {
|
setTimeout(() => {
|
||||||
uni.openAppAuthorizeSetting()
|
uni.openAppAuthorizeSetting()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -143,311 +305,75 @@ function handleUseLocation() {
|
|||||||
formattedAddress: userStore.location.formattedAddress || '',
|
formattedAddress: userStore.location.formattedAddress || '',
|
||||||
location: {
|
location: {
|
||||||
lng: userStore.location.longitude,
|
lng: userStore.location.longitude,
|
||||||
lat: userStore.location.latitude
|
lat: userStore.location.latitude,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 languageCode = resolveLanguageCode()
|
||||||
|
const url =
|
||||||
|
`https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}` +
|
||||||
|
`&key=${Config.googleMapKey}&language=${languageCode}`
|
||||||
|
|
||||||
uni.request({
|
uni.request({
|
||||||
url,
|
url,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
success: (res: any) => {
|
success: (res: any) => {
|
||||||
const results = res.data?.results || [];
|
const results = res.data?.results || []
|
||||||
console.log('geocode results:', results);
|
|
||||||
|
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
return handleFail();
|
return handleFail()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addr = results[0]; // 最高匹配度
|
const addr = results[0]
|
||||||
const components = addr.address_components || [];
|
const components = addr.address_components || []
|
||||||
|
|
||||||
// 提取城市名的工具函数
|
|
||||||
const pickAddress = (types: string[]) => {
|
const pickAddress = (types: string[]) => {
|
||||||
return components.find((item) => item.types.some((t) => types.includes(t)));
|
return components.find((item: any) => item.types.some((type: string) => types.includes(type)))
|
||||||
};
|
}
|
||||||
|
|
||||||
// 寻找城市名(多层兜底)
|
const cityObj =
|
||||||
let cityObj =
|
pickAddress(['locality']) ||
|
||||||
pickAddress(["locality"]) ||
|
pickAddress(['administrative_area_level_2']) ||
|
||||||
pickAddress(["administrative_area_level_2"]) ||
|
pickAddress(['administrative_area_level_1']) ||
|
||||||
pickAddress(["administrative_area_level_1"]) ||
|
pickAddress(['political'])
|
||||||
pickAddress(["political"]);
|
|
||||||
|
|
||||||
const cityName = cityObj?.long_name || null;
|
const cityName = cityObj?.long_name || null
|
||||||
|
const formattedAddress = addr.formatted_address || cityName || ''
|
||||||
// 从 Google 获取完整地址
|
|
||||||
const formattedAddress = addr.formatted_address || cityName || "";
|
|
||||||
|
|
||||||
console.log("city:", cityObj);
|
|
||||||
console.log("formattedAddress:", formattedAddress);
|
|
||||||
|
|
||||||
if (!cityName) {
|
if (!cityName) {
|
||||||
return handleFail();
|
return handleFail()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存入 store
|
|
||||||
userStore.location = {
|
userStore.location = {
|
||||||
location: cityName,
|
location: cityName,
|
||||||
formattedAddress,
|
formattedAddress,
|
||||||
longitude,
|
longitude,
|
||||||
latitude
|
latitude,
|
||||||
};
|
}
|
||||||
|
|
||||||
// 回传上一页(eventChannel)
|
|
||||||
openerEventChannel?.emit?.('chooseAddress', {
|
openerEventChannel?.emit?.('chooseAddress', {
|
||||||
displayName: cityName,
|
displayName: cityName,
|
||||||
formattedAddress,
|
formattedAddress,
|
||||||
location: { lng: longitude, lat: latitude }
|
location: { lng: longitude, lat: latitude },
|
||||||
});
|
})
|
||||||
|
|
||||||
uni.navigateBack();
|
uni.navigateBack()
|
||||||
},
|
},
|
||||||
|
fail: () => handleFail(),
|
||||||
fail: () => handleFail()
|
})
|
||||||
});
|
|
||||||
|
|
||||||
function handleFail() {
|
function handleFail() {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: t('common.prompt.getLocationFailed'),
|
title: t('common.prompt.getLocationFailed'),
|
||||||
icon: 'none'
|
icon: 'none',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDataComputed = computed(() => {
|
|
||||||
return {
|
|
||||||
language: locale.value,
|
|
||||||
lng: userStore.location.longitude || 174.7633,
|
|
||||||
lat: userStore.location.latitude || -36.8485,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent } from "vue";
|
|
||||||
import { useLogicStore } from "@/pages-user/store/logic";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
methods: {
|
|
||||||
onMapSearchResult(places: any) {
|
|
||||||
const logicStore = useLogicStore()
|
|
||||||
console.log(places, '接收到的搜索结果')
|
|
||||||
|
|
||||||
if (places && places.length > 0) {
|
|
||||||
// 保持与原先一致的解析逻辑
|
|
||||||
const parsedPlaces = places
|
|
||||||
.map((placeArray: any) => {
|
|
||||||
const placeData = placeArray[0]?.[0]
|
|
||||||
if (!placeData) return null
|
|
||||||
|
|
||||||
const placeId = placeData[1]
|
|
||||||
const formattedAddress = placeData[8] || ''
|
|
||||||
const locationArray = placeData[11]
|
|
||||||
const displayNameArray = placeData[27]
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: placeId,
|
|
||||||
displayName: displayNameArray?.[0] || formattedAddress,
|
|
||||||
formattedAddress: formattedAddress,
|
|
||||||
location: locationArray
|
|
||||||
? {
|
|
||||||
lat: locationArray[0],
|
|
||||||
lng: locationArray[1],
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
console.log('解析后的地址列表', parsedPlaces)
|
|
||||||
logicStore.setPlacesList(parsedPlaces)
|
|
||||||
} else {
|
|
||||||
logicStore.setPlacesList([])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMapNotLoaded() {
|
|
||||||
uni.showToast({
|
|
||||||
title: 'Map is not loaded yet, please wait',
|
|
||||||
icon: 'none',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onMapSearchTimeout() {
|
|
||||||
uni.showToast({
|
|
||||||
title: 'Search timeout, please try again',
|
|
||||||
icon: 'none',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onMapSearchLoading(_isLoading: boolean) {
|
|
||||||
// 如需联动 loading,可在此处驱动 store
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<script lang="renderjs" module="mapRenderjs">
|
|
||||||
import {Loader} from "@googlemaps/js-api-loader"
|
|
||||||
import * as R from 'ramda'
|
|
||||||
import Config from '@/config'
|
|
||||||
import {debounce} from "throttle-debounce";
|
|
||||||
|
|
||||||
let map = null
|
|
||||||
let mapLoaded = false; // 地图加载完成标志
|
|
||||||
// @ts-ignore
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
region: "US",
|
|
||||||
lan: 'en',
|
|
||||||
google: null,
|
|
||||||
canvas: null,
|
|
||||||
center: null,
|
|
||||||
dotLottie: null,
|
|
||||||
marker: null,
|
|
||||||
markerViewList: [],
|
|
||||||
AdvancedMarkerElement: null,
|
|
||||||
lng: -77.0365,
|
|
||||||
lat: 38.8977,
|
|
||||||
userLocation: {
|
|
||||||
longitude: 0,
|
|
||||||
latitude: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
userLocation: Object
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
console.log('mounted mapRenderjs')
|
|
||||||
mapLoaded = false; // 重置地图加载完成标志
|
|
||||||
// !map&&this.initMap()
|
|
||||||
this.searchPlace = debounce(200, this.searchPlace)
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
initMap({lng, lat, language}) {
|
|
||||||
console.log('initMap', lng, lat)
|
|
||||||
console.log('当前系统语言', language)
|
|
||||||
|
|
||||||
this.lan = language === 'zh-Hans' ? 'zh-CN' : 'en'
|
|
||||||
this.region = language === 'zh-Hans' ? 'CN' : 'US'
|
|
||||||
this.lng = language === 'zh-Hans' ? 116.4074 : -77.0365
|
|
||||||
this.lat = language === 'zh-Hans' ? 39.9042 : 38.8977
|
|
||||||
console.log('当前系统语言', this.lan)
|
|
||||||
console.log('当前系统区域', this.region)
|
|
||||||
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const loader = new Loader({
|
|
||||||
apiKey: Config.googleMapKey,
|
|
||||||
version: "weekly",
|
|
||||||
region: this.region, // 设置为美国
|
|
||||||
language: this.lan,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
loader.load().then(async (google) => {
|
|
||||||
const {Map} = await google.maps.importLibrary("maps")
|
|
||||||
this.google = google
|
|
||||||
console.log('google', google.maps)
|
|
||||||
// 地图相关配置
|
|
||||||
// 指定自定义地图样式的 ID。
|
|
||||||
// center: 地图初始中心点的经纬度(lat, lng)。
|
|
||||||
// zoom: 地图初始缩放级别。
|
|
||||||
// fullscreenControl: 是否显示全屏按钮(false 表示不显示)。
|
|
||||||
// cameraControl: 是否显示相机控制(false 表示不显示)。
|
|
||||||
// disableDefaultUI: 是否禁用所有默认 UI 控件(true 表示全部禁用)。
|
|
||||||
// gestureHandling: 设置手势操作方式("greedy" 表示允许所有手势操作)
|
|
||||||
const mapOptions = {
|
|
||||||
mapId: 'ff2268c265ce7a40',
|
|
||||||
center: {
|
|
||||||
lat: this.lat,
|
|
||||||
lng: this.lng,
|
|
||||||
},
|
|
||||||
zoom: 12,
|
|
||||||
fullscreenControl: false,
|
|
||||||
cameraControl: false,
|
|
||||||
disableDefaultUI: true,
|
|
||||||
gestureHandling: "greedy",
|
|
||||||
};
|
|
||||||
|
|
||||||
map = new Map(document.getElementById("map"), mapOptions);
|
|
||||||
mapLoaded = true; // 地图加载完成
|
|
||||||
});
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async searchPlace({keyword}) {
|
|
||||||
console.log('搜索关键词', keyword);
|
|
||||||
if (!keyword) {
|
|
||||||
this.$ownerInstance.callMethod('onMapSearchResult', [])
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!mapLoaded) {
|
|
||||||
console.log('地图未加载完成,无法搜索');
|
|
||||||
this.$ownerInstance.callMethod('onMapNotLoaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!map || !this.google) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$ownerInstance.callMethod('onMapSearchLoading', true);
|
|
||||||
let timeoutId;
|
|
||||||
try {
|
|
||||||
// 设置搜索超时
|
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
this.$ownerInstance.callMethod('onMapSearchLoading', false);
|
|
||||||
reject(new Error('Search timeout'));
|
|
||||||
}, 10000); // 10 秒超时
|
|
||||||
});
|
|
||||||
|
|
||||||
const {Place, SearchByTextRankPreference} = await this.google.maps.importLibrary("places");
|
|
||||||
const request = {
|
|
||||||
textQuery: keyword,
|
|
||||||
fields: ['id', 'displayName', 'location', 'formattedAddress', 'addressComponents'],
|
|
||||||
locationBias: {lat: this.lat, lng: this.lng},
|
|
||||||
language: this.lan,
|
|
||||||
maxResultCount: 20,
|
|
||||||
region: this.region,
|
|
||||||
rankPreference: SearchByTextRankPreference.RELEVANCE
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchPromise = Place.searchByText(request);
|
|
||||||
const {places} = await Promise.race([searchPromise, timeoutPromise]);
|
|
||||||
console.log('地图搜索结果原始数据', places);
|
|
||||||
|
|
||||||
// 将 Google Places API 返回的对象转换为可序列化的数组格式
|
|
||||||
const serializedPlaces = places.map(place => {
|
|
||||||
// 提取需要的字段并转换为普通对象
|
|
||||||
return [[{
|
|
||||||
1: place.id,
|
|
||||||
8: place.formattedAddress || '',
|
|
||||||
9: place.addressComponents || [],
|
|
||||||
11: place.location ? [place.location.lat(), place.location.lng()] : null,
|
|
||||||
27: place.displayName ? [
|
|
||||||
typeof place.displayName === 'string' ? place.displayName : place.displayName.text,
|
|
||||||
place.displayName.languageCode || this.lan
|
|
||||||
] : null
|
|
||||||
}]];
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('序列化后的搜索结果', serializedPlaces);
|
|
||||||
this.$ownerInstance.callMethod('onMapSearchResult', serializedPlaces);
|
|
||||||
} catch (e) {
|
|
||||||
console.log('搜索错误原因', e);
|
|
||||||
if (e.message === 'Search timeout') {
|
|
||||||
this.$ownerInstance.callMethod('onMapSearchTimeout');
|
|
||||||
}
|
|
||||||
this.$ownerInstance.callMethod('onMapSearchResult', []);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
this.$ownerInstance.callMethod('onMapSearchLoading', false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
page {
|
page {
|
||||||
@@ -456,4 +382,4 @@ page {
|
|||||||
</style>
|
</style>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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": [
|
||||||
@@ -213,6 +234,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/recharge/index"
|
"path": "pages/recharge/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/recharge/activity-detail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -255,8 +279,26 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/dishes/index"
|
"path": "pages/dishes/index"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/dishes/quick-topic",
|
||||||
|
"style": {
|
||||||
|
"onReachBottomDistance": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/home-store/index"
|
"path": "pages/home-store/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/energy-meal/index",
|
||||||
|
"style": {
|
||||||
|
"onReachBottomDistance": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/group-catering/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/group-catering/detail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {EventEnum} from "@/constant/enums";
|
import { EventEnum } from "@/constant/enums";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
// 店铺的营业时间(示例:周一到周五营业,周六周日不营业)
|
|
||||||
// 测试用的营业时间字符串
|
|
||||||
// MONDAY/TUESDAY/WEDNESDAY 09:00-18:00;THURSDAY/FRIDAY 08:00-09:00; // 周一到周五9点到18点,周四到周五8点到9点
|
|
||||||
// MONDAY/TUESDAY/WEDNESDAY 09:00-18:00;THURSDAY/FRIDAY 08:00-12:00;SATURDAY/SUNDAY 10:00-20:00 // 周六周日10点到20点
|
|
||||||
// MONDAY/TUESDAY/WEDNESDAY 09:00-16:00
|
|
||||||
// MONDAY 09:00-18:00;TUESDAY 09:00-10:00;WEDNESDAY 09:00-18:00;THURSDAY 09:00-18:00;FRIDAY 08:00-09:00
|
|
||||||
const storeBusinessHours = ref('');
|
const storeBusinessHours = ref('');
|
||||||
// 是否仅选择日期(当前需求:只选哪一天配送,不选具体时间段)
|
|
||||||
const onlySelectDay = ref(true);
|
const onlySelectDay = ref(true);
|
||||||
|
|
||||||
// 业务规则:只能预约周一 / 周四 / 周五
|
import { parseDeliveryScheduleTimes, isDeliveryScheduleDay } from '@/utils/deliverySchedule';
|
||||||
// JS 中:0-周日 1-周一 ... 4-周四 5-周五
|
|
||||||
const allowedWeekdays = [1, 4, 5];
|
const allowedWeekdays = ref<number[]>([]);
|
||||||
const isAllowedDay = (date: Date): boolean => {
|
const isAllowedDay = (date: Date): boolean => {
|
||||||
const dayIndex = date.getDay();
|
return isDeliveryScheduleDay(date, allowedWeekdays.value);
|
||||||
return allowedWeekdays.includes(dayIndex);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 解析商家营业时间的接口
|
|
||||||
interface BusinessHours {
|
interface BusinessHours {
|
||||||
days: string[]; // 营业的星期几
|
days: string[];
|
||||||
startTime: string; // 开始时间 HH:mm
|
startTime: string;
|
||||||
endTime: string; // 结束时间 HH:mm
|
endTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析商家营业时间字符串
|
|
||||||
* @param businessHoursStr 营业时间字符串,支持多种格式:
|
|
||||||
* - MONDAY/TUESDAY/WEDNESDAY 09:00-18:00;THURSDAY/FRIDAY/SATURDAY/SUNDAY 10:00-20:00
|
|
||||||
* - MONDAY/TUESDAY/WEDNESDAY 09:00-18:00
|
|
||||||
* - MONDAY 09:00-18:00;TUESDAY 09:00-10:00;WEDNESDAY 09:00-18:00
|
|
||||||
* @returns 解析后的营业时间数组
|
|
||||||
*/
|
|
||||||
const parseBusinessHours = (businessHoursStr: string): BusinessHours[] => {
|
const parseBusinessHours = (businessHoursStr: string): BusinessHours[] => {
|
||||||
if (!businessHoursStr) return [];
|
if (!businessHoursStr) return [];
|
||||||
|
|
||||||
const businessHours: BusinessHours[] = [];
|
const businessHours: BusinessHours[] = [];
|
||||||
// 使用分号分割不同的营业时间段
|
|
||||||
const segments = businessHoursStr.split(";");
|
const segments = businessHoursStr.split(";");
|
||||||
|
|
||||||
segments.forEach((segment) => {
|
segments.forEach((segment) => {
|
||||||
const trimmedSegment = segment.trim();
|
const trimmedSegment = segment.trim();
|
||||||
if (!trimmedSegment) return;
|
if (!trimmedSegment) return;
|
||||||
|
|
||||||
// 使用空格分割星期几和时间
|
|
||||||
const parts = trimmedSegment.split(" ");
|
const parts = trimmedSegment.split(" ");
|
||||||
if (parts.length !== 2) return;
|
if (parts.length !== 2) return;
|
||||||
|
|
||||||
const dayStr = parts[0].trim().toUpperCase();
|
const dayStr = parts[0].trim().toUpperCase();
|
||||||
const timeStr = parts[1];
|
const timeStr = parts[1];
|
||||||
|
|
||||||
// 解析时间范围
|
|
||||||
const timeRange = timeStr.split("-");
|
const timeRange = timeStr.split("-");
|
||||||
if (timeRange.length !== 2) return;
|
if (timeRange.length !== 2) return;
|
||||||
|
|
||||||
const startTime = timeRange[0].trim();
|
const startTime = timeRange[0].trim();
|
||||||
const endTime = timeRange[1].trim();
|
const endTime = timeRange[1].trim();
|
||||||
|
|
||||||
// 按斜杠分割星期几,支持 MONDAY/TUESDAY/WEDNESDAY 格式
|
|
||||||
const days = dayStr
|
const days = dayStr
|
||||||
.split("/")
|
.split("/")
|
||||||
.map((day) => day.trim())
|
.map((day) => day.trim())
|
||||||
.filter((day) => day);
|
.filter((day) => day);
|
||||||
|
|
||||||
businessHours.push({
|
businessHours.push({
|
||||||
days, // 支持多个星期几共享同一营业时间
|
days,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
});
|
});
|
||||||
@@ -78,17 +57,11 @@ const parseBusinessHours = (businessHoursStr: string): BusinessHours[] => {
|
|||||||
return businessHours;
|
return businessHours;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取指定日期的营业时间
|
|
||||||
* @param date 日期
|
|
||||||
* @returns 营业时间对象,如果不营业返回null
|
|
||||||
*/
|
|
||||||
const getBusinessHoursForDate = (date: Date): BusinessHours | null => {
|
const getBusinessHoursForDate = (date: Date): BusinessHours | null => {
|
||||||
if (!storeBusinessHours.value) return null;
|
if (!storeBusinessHours.value) return null;
|
||||||
|
|
||||||
const businessHours = parseBusinessHours(storeBusinessHours.value);
|
const businessHours = parseBusinessHours(storeBusinessHours.value);
|
||||||
|
|
||||||
// 获取星期几的英文名称(确保是大写)(不要国际化导致判断失效)
|
|
||||||
const dayNames = [
|
const dayNames = [
|
||||||
"SUNDAY",
|
"SUNDAY",
|
||||||
"MONDAY",
|
"MONDAY",
|
||||||
@@ -100,44 +73,29 @@ const getBusinessHoursForDate = (date: Date): BusinessHours | null => {
|
|||||||
];
|
];
|
||||||
const dayName = dayNames[date.getDay()];
|
const dayName = dayNames[date.getDay()];
|
||||||
|
|
||||||
// 查找包含当前星期几的营业时间
|
|
||||||
return businessHours.find((hours) => hours.days.includes(dayName)) || null;
|
return businessHours.find((hours) => hours.days.includes(dayName)) || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查日期是否营业
|
|
||||||
* @param date 日期
|
|
||||||
* @returns 是否营业
|
|
||||||
*/
|
|
||||||
const isDateOpen = (date: Date): boolean => {
|
const isDateOpen = (date: Date): boolean => {
|
||||||
if (!storeBusinessHours.value) return true; // 如果没有营业时间限制,默认营业
|
if (!storeBusinessHours.value) return true;
|
||||||
return getBusinessHoursForDate(date) !== null;
|
return getBusinessHoursForDate(date) !== null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查日期是否应该显示为可选择状态(营业且有可用时间段)
|
|
||||||
* @param date 日期
|
|
||||||
* @returns 是否可选择
|
|
||||||
*/
|
|
||||||
const isDateSelectable = (date: Date): boolean => {
|
const isDateSelectable = (date: Date): boolean => {
|
||||||
// 新增:限制只能预约周一 / 周四 / 周五
|
|
||||||
if (!isAllowedDay(date)) {
|
if (!isAllowedDay(date)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果只选日期模式,营业即可选择
|
|
||||||
if (onlySelectDay.value) {
|
if (onlySelectDay.value) {
|
||||||
if (!storeBusinessHours.value) return true;
|
if (!storeBusinessHours.value) return true;
|
||||||
return isDateOpen(date);
|
return isDateOpen(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原逻辑:需要营业且有可用时间段
|
if (!storeBusinessHours.value) return true;
|
||||||
if (!storeBusinessHours.value) return true; // 如果没有营业时间限制,默认可选择
|
|
||||||
if (!isDateOpen(date)) return false;
|
if (!isDateOpen(date)) return false;
|
||||||
return hasAvailableTimeSlots(date);
|
return hasAvailableTimeSlots(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成未来 7 天:设计稿为「本周」5 个圆 +「下周」2 个圆
|
|
||||||
const dateOptions = computed(() => {
|
const dateOptions = computed(() => {
|
||||||
const dates: Date[] = [];
|
const dates: Date[] = [];
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -154,10 +112,8 @@ const dateOptions = computed(() => {
|
|||||||
const thisWeekDates = computed(() => dateOptions.value.slice(0, 5));
|
const thisWeekDates = computed(() => dateOptions.value.slice(0, 5));
|
||||||
const nextWeekDates = computed(() => dateOptions.value.slice(5, 7));
|
const nextWeekDates = computed(() => dateOptions.value.slice(5, 7));
|
||||||
|
|
||||||
// 状态管理 - 初始化为第一个营业日期
|
|
||||||
const selectedDate = ref<Date>();
|
const selectedDate = ref<Date>();
|
||||||
|
|
||||||
// 检查指定时间是否在营业时间内
|
|
||||||
const isTimeInBusinessHours = (
|
const isTimeInBusinessHours = (
|
||||||
hour: number,
|
hour: number,
|
||||||
minute: number,
|
minute: number,
|
||||||
@@ -169,35 +125,28 @@ const isTimeInBusinessHours = (
|
|||||||
return timeStr >= businessHours.startTime && timeStr <= businessHours.endTime;
|
return timeStr >= businessHours.startTime && timeStr <= businessHours.endTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查指定日期是否有可用时间段
|
|
||||||
const hasAvailableTimeSlots = (date: Date): boolean => {
|
const hasAvailableTimeSlots = (date: Date): boolean => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentHour = now.getHours();
|
const currentHour = now.getHours();
|
||||||
const currentMinute = now.getMinutes();
|
const currentMinute = now.getMinutes();
|
||||||
|
|
||||||
// 获取指定日期的营业时间
|
|
||||||
const businessHours = getBusinessHoursForDate(date);
|
const businessHours = getBusinessHoursForDate(date);
|
||||||
|
|
||||||
// 如果没有营业时间,返回false
|
|
||||||
if (!businessHours) {
|
if (!businessHours) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析营业时间的开始和结束时间
|
|
||||||
const [startHour, startMinute] = businessHours.startTime
|
const [startHour, startMinute] = businessHours.startTime
|
||||||
.split(":")
|
.split(":")
|
||||||
.map(Number);
|
.map(Number);
|
||||||
const [endHour, endMinute] = businessHours.endTime.split(":").map(Number);
|
const [endHour, endMinute] = businessHours.endTime.split(":").map(Number);
|
||||||
|
|
||||||
// 检查是否有可用时间段
|
|
||||||
for (let hour = startHour; hour <= endHour; hour++) {
|
for (let hour = startHour; hour <= endHour; hour++) {
|
||||||
for (let minute = 0; minute < 60; minute += 30) {
|
for (let minute = 0; minute < 60; minute += 30) {
|
||||||
// 检查时间段开始时间是否在营业时间内
|
|
||||||
if (!isTimeInBusinessHours(hour, minute, businessHours)) {
|
if (!isTimeInBusinessHours(hour, minute, businessHours)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算时间段结束时间
|
|
||||||
let nextHour = hour;
|
let nextHour = hour;
|
||||||
let nextMinute = minute + 30;
|
let nextMinute = minute + 30;
|
||||||
if (nextMinute >= 60) {
|
if (nextMinute >= 60) {
|
||||||
@@ -205,7 +154,6 @@ const hasAvailableTimeSlots = (date: Date): boolean => {
|
|||||||
nextMinute = 0;
|
nextMinute = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查时间段结束时间是否超出营业时间
|
|
||||||
if (!isTimeInBusinessHours(nextHour, nextMinute, businessHours)) {
|
if (!isTimeInBusinessHours(nextHour, nextMinute, businessHours)) {
|
||||||
if (
|
if (
|
||||||
nextHour > endHour ||
|
nextHour > endHour ||
|
||||||
@@ -216,12 +164,10 @@ const hasAvailableTimeSlots = (date: Date): boolean => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 避免生成开始时间和结束时间相同的无效时间段
|
|
||||||
if (hour === nextHour && minute === nextMinute) {
|
if (hour === nextHour && minute === nextMinute) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是今天,过滤掉已经过去的时间
|
|
||||||
if (date.toDateString() === now.toDateString()) {
|
if (date.toDateString() === now.toDateString()) {
|
||||||
if (
|
if (
|
||||||
hour < currentHour ||
|
hour < currentHour ||
|
||||||
@@ -231,7 +177,6 @@ const hasAvailableTimeSlots = (date: Date): boolean => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果能到这里,说明有可用时间段
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,69 +184,55 @@ const hasAvailableTimeSlots = (date: Date): boolean => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化选中日期为第一个有可用时间段的营业日期
|
const userPickedDate = ref(false);
|
||||||
const initializeSelectedDate = () => {
|
|
||||||
if (onlySelectDay.value) {
|
/** 从今天起查找最近可配送日(配送日 + 营业时间) */
|
||||||
// 仅选日期模式:选择第一个“允许预约且营业”的日期(或第一个允许的日期)
|
const findNearestDeliverableDate = (maxDays = 30): Date | null => {
|
||||||
const firstOpen = dateOptions.value.find(
|
const today = new Date();
|
||||||
(d) => isAllowedDay(d) && isDateOpen(d)
|
today.setHours(0, 0, 0, 0);
|
||||||
);
|
for (let i = 0; i <= maxDays; i++) {
|
||||||
const firstAllowed = firstOpen || dateOptions.value.find((d) => isAllowedDay(d));
|
const d = new Date(today);
|
||||||
selectedDate.value = firstAllowed || dateOptions.value[0];
|
d.setDate(today.getDate() + i);
|
||||||
|
if (isDateSelectable(d)) {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeSelectedDate = (resetUserPick = false) => {
|
||||||
|
if (resetUserPick) {
|
||||||
|
userPickedDate.value = false;
|
||||||
|
}
|
||||||
|
if (userPickedDate.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非仅选日期模式:保留原逻辑
|
const nearest = findNearestDeliverableDate(30);
|
||||||
for (const date of dateOptions.value) {
|
if (nearest) {
|
||||||
if (isDateSelectable(date)) {
|
selectedDate.value = nearest;
|
||||||
selectedDate.value = date;
|
selectedTimeSlot.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date();
|
selectedDate.value = dateOptions.value[0] ?? new Date();
|
||||||
const nextBusinessDate = findNextBusinessDate(today);
|
selectedTimeSlot.value = "";
|
||||||
if (nextBusinessDate) {
|
|
||||||
selectedDate.value = nextBusinessDate;
|
|
||||||
nextTick(() => {
|
|
||||||
uni.showToast({
|
|
||||||
title: t('pages.address.reservationTime.currentTimeExpired'),
|
|
||||||
icon: "none",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
selectedDate.value = dateOptions.value[0];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听dateOptions变化,初始化选中日期
|
|
||||||
watch(
|
watch(
|
||||||
dateOptions,
|
() => [...allowedWeekdays.value],
|
||||||
() => {
|
() => {
|
||||||
if (!selectedDate.value) {
|
initializeSelectedDate();
|
||||||
initializeSelectedDate();
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 监听营业时间字符串变化,重新初始化选中日期
|
watch(storeBusinessHours, () => {
|
||||||
watch(
|
initializeSelectedDate();
|
||||||
storeBusinessHours,
|
});
|
||||||
() => {
|
|
||||||
if (storeBusinessHours.value) {
|
|
||||||
initializeSelectedDate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
const selectedTimeSlot = ref<string>("");
|
const selectedTimeSlot = ref<string>("");
|
||||||
|
|
||||||
/** 圆圈内上行:星期(与全局 dayjs 语言一致) */
|
|
||||||
const formatWeekdayCircle = (date: Date) => dayjs(date).format("dddd");
|
const formatWeekdayCircle = (date: Date) => dayjs(date).format("dddd");
|
||||||
|
|
||||||
/** 圆圈内下行:MM/DD;不可选时显示文案 */
|
|
||||||
const formatCircleSubLine = (date: Date) => {
|
const formatCircleSubLine = (date: Date) => {
|
||||||
if (!isDateSelectable(date)) {
|
if (!isDateSelectable(date)) {
|
||||||
return t("pages.address.reservationTime.notAvailable");
|
return t("pages.address.reservationTime.notAvailable");
|
||||||
@@ -309,34 +240,21 @@ const formatCircleSubLine = (date: Date) => {
|
|||||||
return dayjs(date).format("MM/DD");
|
return dayjs(date).format("MM/DD");
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查时间是否在营业时间内
|
|
||||||
* @param hour 小时
|
|
||||||
* @param minute 分钟
|
|
||||||
* @param businessHours 营业时间对象
|
|
||||||
* @returns 是否在营业时间内
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 生成时间段选项(根据商家营业时间过滤)
|
|
||||||
const timeSlots = computed(() => {
|
const timeSlots = computed(() => {
|
||||||
const slots: string[] = [];
|
const slots: string[] = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentHour = now.getHours();
|
const currentHour = now.getHours();
|
||||||
const currentMinute = now.getMinutes();
|
const currentMinute = now.getMinutes();
|
||||||
|
|
||||||
// 如果还没有选中日期,返回空数组
|
|
||||||
if (!selectedDate.value) {
|
if (!selectedDate.value) {
|
||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取选中日期的营业时间
|
|
||||||
const businessHours = getBusinessHoursForDate(selectedDate.value);
|
const businessHours = getBusinessHoursForDate(selectedDate.value);
|
||||||
|
|
||||||
// 如果没有营业时间限制,使用原有逻辑(0-24小时)
|
|
||||||
if (!businessHours) {
|
if (!businessHours) {
|
||||||
for (let hour = 0; hour < 24; hour++) {
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
for (let minute = 0; minute < 60; minute += 30) {
|
for (let minute = 0; minute < 60; minute += 30) {
|
||||||
// 如果是今天,过滤掉已经过去的时间
|
|
||||||
if (selectedDate.value.toDateString() === now.toDateString()) {
|
if (selectedDate.value.toDateString() === now.toDateString()) {
|
||||||
if (
|
if (
|
||||||
hour < currentHour ||
|
hour < currentHour ||
|
||||||
@@ -361,21 +279,17 @@ const timeSlots = computed(() => {
|
|||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析营业时间的开始和结束时间
|
|
||||||
const [startHour, startMinute] = businessHours.startTime
|
const [startHour, startMinute] = businessHours.startTime
|
||||||
.split(":")
|
.split(":")
|
||||||
.map(Number);
|
.map(Number);
|
||||||
const [endHour, endMinute] = businessHours.endTime.split(":").map(Number);
|
const [endHour, endMinute] = businessHours.endTime.split(":").map(Number);
|
||||||
|
|
||||||
// 生成营业时间内的时间段
|
|
||||||
for (let hour = startHour; hour <= endHour; hour++) {
|
for (let hour = startHour; hour <= endHour; hour++) {
|
||||||
for (let minute = 0; minute < 60; minute += 30) {
|
for (let minute = 0; minute < 60; minute += 30) {
|
||||||
// 检查时间段开始时间是否在营业时间内
|
|
||||||
if (!isTimeInBusinessHours(hour, minute, businessHours)) {
|
if (!isTimeInBusinessHours(hour, minute, businessHours)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算时间段结束时间
|
|
||||||
let nextHour = hour;
|
let nextHour = hour;
|
||||||
let nextMinute = minute + 30;
|
let nextMinute = minute + 30;
|
||||||
if (nextMinute >= 60) {
|
if (nextMinute >= 60) {
|
||||||
@@ -383,9 +297,7 @@ const timeSlots = computed(() => {
|
|||||||
nextMinute = 0;
|
nextMinute = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查时间段结束时间是否超出营业时间
|
|
||||||
if (!isTimeInBusinessHours(nextHour, nextMinute, businessHours)) {
|
if (!isTimeInBusinessHours(nextHour, nextMinute, businessHours)) {
|
||||||
// 如果结束时间超出营业时间,但开始时间在营业时间内,则调整结束时间为营业结束时间
|
|
||||||
if (
|
if (
|
||||||
nextHour > endHour ||
|
nextHour > endHour ||
|
||||||
(nextHour === endHour && nextMinute > endMinute)
|
(nextHour === endHour && nextMinute > endMinute)
|
||||||
@@ -395,12 +307,10 @@ const timeSlots = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 避免生成开始时间和结束时间相同的无效时间段(如 18:00 - 18:00)
|
|
||||||
if (hour === nextHour && minute === nextMinute) {
|
if (hour === nextHour && minute === nextMinute) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是今天,过滤掉已经过去的时间
|
|
||||||
if (selectedDate.value.toDateString() === now.toDateString()) {
|
if (selectedDate.value.toDateString() === now.toDateString()) {
|
||||||
if (
|
if (
|
||||||
hour < currentHour ||
|
hour < currentHour ||
|
||||||
@@ -423,11 +333,9 @@ const timeSlots = computed(() => {
|
|||||||
return slots;
|
return slots;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听时间段变化,如果当前选中日期没有可用时间段,自动选择下一个营业日期
|
|
||||||
watch(timeSlots, (newSlots) => {
|
watch(timeSlots, (newSlots) => {
|
||||||
if (onlySelectDay.value) return; // 仅选日期模式不需要处理时间段
|
if (onlySelectDay.value) return;
|
||||||
if (selectedDate.value && newSlots.length === 0) {
|
if (selectedDate.value && newSlots.length === 0) {
|
||||||
// 当前选中日期没有可用时间段,寻找下一个营业日期
|
|
||||||
const nextBusinessDate = findNextBusinessDate(selectedDate.value);
|
const nextBusinessDate = findNextBusinessDate(selectedDate.value);
|
||||||
if (nextBusinessDate) {
|
if (nextBusinessDate) {
|
||||||
selectedDate.value = nextBusinessDate;
|
selectedDate.value = nextBusinessDate;
|
||||||
@@ -441,14 +349,12 @@ watch(timeSlots, (newSlots) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 寻找下一个有可用时间段的营业日期
|
|
||||||
const findNextBusinessDate = (currentDate: Date): Date | null => {
|
const findNextBusinessDate = (currentDate: Date): Date | null => {
|
||||||
const maxDays = 30; // 最多向前查找30天
|
const maxDays = 30;
|
||||||
for (let i = 1; i <= maxDays; i++) {
|
for (let i = 1; i <= maxDays; i++) {
|
||||||
const nextDate = new Date(currentDate);
|
const nextDate = new Date(currentDate);
|
||||||
nextDate.setDate(currentDate.getDate() + i);
|
nextDate.setDate(currentDate.getDate() + i);
|
||||||
|
|
||||||
// 检查是否在dateOptions范围内
|
|
||||||
const isInRange = dateOptions.value.some((date) =>
|
const isInRange = dateOptions.value.some((date) =>
|
||||||
dayjs(date).isSame(dayjs(nextDate), "day")
|
dayjs(date).isSame(dayjs(nextDate), "day")
|
||||||
);
|
);
|
||||||
@@ -465,9 +371,7 @@ const findNextBusinessDate = (currentDate: Date): Date | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 选择日期
|
|
||||||
const selectDate = (date: Date) => {
|
const selectDate = (date: Date) => {
|
||||||
// 检查日期是否可选择,如果不可选择则不允许选择
|
|
||||||
if (!isDateSelectable(date)) {
|
if (!isDateSelectable(date)) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: t('pages.address.reservationTime.dateNotSelectable'),
|
title: t('pages.address.reservationTime.dateNotSelectable'),
|
||||||
@@ -476,12 +380,11 @@ const selectDate = (date: Date) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userPickedDate.value = true;
|
||||||
selectedDate.value = date;
|
selectedDate.value = date;
|
||||||
// 选择新日期后,清空已选择的时间段
|
|
||||||
selectedTimeSlot.value = "";
|
selectedTimeSlot.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 选择时间段
|
|
||||||
const selectTimeSlot = (timeSlot: string) => {
|
const selectTimeSlot = (timeSlot: string) => {
|
||||||
selectedTimeSlot.value = timeSlot;
|
selectedTimeSlot.value = timeSlot;
|
||||||
};
|
};
|
||||||
@@ -489,14 +392,12 @@ const selectTimeSlot = (timeSlot: string) => {
|
|||||||
const isDateSelected = (date: Date) =>
|
const isDateSelected = (date: Date) =>
|
||||||
!!selectedDate.value && dayjs(selectedDate.value).isSame(dayjs(date), "day");
|
!!selectedDate.value && dayjs(selectedDate.value).isSame(dayjs(date), "day");
|
||||||
|
|
||||||
// 提交预约
|
|
||||||
const submitReservation = () => {
|
const submitReservation = () => {
|
||||||
const dateVal = selectedDate.value;
|
const dateVal = selectedDate.value;
|
||||||
if (!dateVal) {
|
if (!dateVal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非仅选日期模式,需要选择时间段
|
|
||||||
if (!onlySelectDay.value) {
|
if (!onlySelectDay.value) {
|
||||||
if (!selectedTimeSlot.value) {
|
if (!selectedTimeSlot.value) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
@@ -507,13 +408,11 @@ const submitReservation = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算开始/结束时间
|
|
||||||
const selectedDateDayjs = dayjs(dateVal);
|
const selectedDateDayjs = dayjs(dateVal);
|
||||||
let startTime: dayjs.Dayjs;
|
let startTime: dayjs.Dayjs;
|
||||||
let endTime: dayjs.Dayjs;
|
let endTime: dayjs.Dayjs;
|
||||||
|
|
||||||
if (onlySelectDay.value) {
|
if (onlySelectDay.value) {
|
||||||
// 仅选日期:优先使用营业时间范围;若无营业时间限制,则使用当天起止
|
|
||||||
const bh = getBusinessHoursForDate(dateVal);
|
const bh = getBusinessHoursForDate(dateVal);
|
||||||
if (bh) {
|
if (bh) {
|
||||||
const [startHour, startMinute] = bh.startTime.split(':').map(Number);
|
const [startHour, startMinute] = bh.startTime.split(':').map(Number);
|
||||||
@@ -533,7 +432,6 @@ const submitReservation = () => {
|
|||||||
endTime = selectedDateDayjs.endOf('day');
|
endTime = selectedDateDayjs.endOf('day');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 选择了时间段:解析并生成起止时间
|
|
||||||
const [startTimeStr, endTimeStr] = selectedTimeSlot.value.split(' - ');
|
const [startTimeStr, endTimeStr] = selectedTimeSlot.value.split(' - ');
|
||||||
const [startHour, startMinute] = startTimeStr.split(':').map(Number);
|
const [startHour, startMinute] = startTimeStr.split(':').map(Number);
|
||||||
const [endHour, endMinute] = endTimeStr.split(':').map(Number);
|
const [endHour, endMinute] = endTimeStr.split(':').map(Number);
|
||||||
@@ -557,6 +455,7 @@ const submitReservation = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
uni.$emit(EventEnum.CHOOSE_APPOINTMENT_TIME, {
|
uni.$emit(EventEnum.CHOOSE_APPOINTMENT_TIME, {
|
||||||
|
merchantId: storeId.value,
|
||||||
date: dateVal,
|
date: dateVal,
|
||||||
timeSlot: onlySelectDay.value ? '' : selectedTimeSlot.value,
|
timeSlot: onlySelectDay.value ? '' : selectedTimeSlot.value,
|
||||||
startTime: startTime.valueOf(),
|
startTime: startTime.valueOf(),
|
||||||
@@ -573,19 +472,23 @@ const submitReservation = () => {
|
|||||||
|
|
||||||
const storeId = ref(null);
|
const storeId = ref(null);
|
||||||
|
|
||||||
// 页面加载时处理参数
|
|
||||||
onLoad((options: any) => {
|
onLoad((options: any) => {
|
||||||
if (options.storeId) {
|
if (options.merchantId) {
|
||||||
|
storeId.value = options.merchantId;
|
||||||
|
} else if (options.storeId) {
|
||||||
storeId.value = options.storeId;
|
storeId.value = options.storeId;
|
||||||
}
|
}
|
||||||
|
if (options.deliveryScheduleTimes) {
|
||||||
|
allowedWeekdays.value = parseDeliveryScheduleTimes(
|
||||||
|
decodeURIComponent(options.deliveryScheduleTimes)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (options.storeBusinessHours) {
|
if (options.storeBusinessHours) {
|
||||||
storeBusinessHours.value = options.storeBusinessHours;
|
storeBusinessHours.value = decodeURIComponent(options.storeBusinessHours);
|
||||||
// 如果传递了该参数,进入仅选日期模式
|
|
||||||
onlySelectDay.value = true;
|
onlySelectDay.value = true;
|
||||||
}
|
}
|
||||||
// 无论是否传参,统一初始化选中日期,避免首屏未选导致不显示时间段
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
initializeSelectedDate();
|
initializeSelectedDate(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -635,7 +538,6 @@ onLoad((options: any) => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 时间段选择区域:在仅选日期模式下隐藏 -->
|
|
||||||
<view v-if="!onlySelectDay" class="pb-138rpx mx-40rpx bg-white rounded-24rpx overflow-hidden mt-24rpx">
|
<view v-if="!onlySelectDay" class="pb-138rpx mx-40rpx bg-white rounded-24rpx overflow-hidden mt-24rpx">
|
||||||
<view
|
<view
|
||||||
v-for="(timeSlot, index) in timeSlots"
|
v-for="(timeSlot, index) in timeSlots"
|
||||||
@@ -648,7 +550,6 @@ onLoad((options: any) => {
|
|||||||
@click="selectTimeSlot(timeSlot)"
|
@click="selectTimeSlot(timeSlot)"
|
||||||
>
|
>
|
||||||
<text class="text-32rpx font-regular">{{ timeSlot }}</text>
|
<text class="text-32rpx font-regular">{{ timeSlot }}</text>
|
||||||
<!-- 单选按钮 -->
|
|
||||||
<image
|
<image
|
||||||
:src="
|
:src="
|
||||||
selectedTimeSlot === timeSlot
|
selectedTimeSlot === timeSlot
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
function handleSearch(){
|
function handleSearch(){
|
||||||
console.log("1111111111");
|
|
||||||
|
|
||||||
}
|
}
|
||||||
function handleClickItem(){
|
function handleClickItem(){
|
||||||
|
|||||||
@@ -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.toolStatusWithSizePrefix')}${tool}${t('pages.ai.chat.toolStatusWithSizeMiddle')}${size || '-'}${t('pages.ai.chat.toolStatusWithSizeSuffix')}`
|
||||||
|
: t('pages.ai.chat.searchingGoods')
|
||||||
|
thinkingStatus.value = t('pages.ai.chat.thinkingSearching')
|
||||||
|
},
|
||||||
|
onChunk(payload) {
|
||||||
|
if (payload?.conversationId) {
|
||||||
|
conversationId.value = String(payload.conversationId)
|
||||||
|
}
|
||||||
|
const chunkText = String(payload?.text || '')
|
||||||
|
if (!chunkText) return
|
||||||
|
|
||||||
|
let plainText = ''
|
||||||
|
chunkText.split('\n').forEach((line) => {
|
||||||
|
if (!line.trim()) return
|
||||||
|
if (isSummaryLine(line)) {
|
||||||
|
plainText += (plainText ? '\n' : '') + line
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lineProduct = tryParseProductLine(line)
|
||||||
|
if (lineProduct) {
|
||||||
|
pendingRecommendItems.value.push(lineProduct)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plainText += (plainText ? '\n' : '') + line
|
||||||
|
})
|
||||||
|
if (plainText) textQueue.value += plainText
|
||||||
|
flushTypingQueue(aiMessageIndex)
|
||||||
|
},
|
||||||
|
onEnd() {
|
||||||
|
toolStatus.value = ''
|
||||||
|
thinkingStatus.value = ''
|
||||||
|
const finalize = () => {
|
||||||
|
if (pendingRecommendItems.value.length > 0) {
|
||||||
|
recommendItems.value = [...pendingRecommendItems.value]
|
||||||
|
pendingRecommendItems.value = []
|
||||||
|
addedRecommendMap.value = {}
|
||||||
|
}
|
||||||
|
if (!messages.value[aiMessageIndex].text.trim() && recommendItems.value.length === 0) {
|
||||||
|
messages.value[aiMessageIndex].text = t('pages.ai.chat.emptyResult')
|
||||||
|
}
|
||||||
|
isStreaming.value = false
|
||||||
|
streamTask.value = null
|
||||||
|
}
|
||||||
|
if (textQueue.value) {
|
||||||
|
const waitDone = () => {
|
||||||
|
if (textQueue.value) {
|
||||||
|
scheduleFrame(waitDone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finalize()
|
||||||
|
}
|
||||||
|
scheduleFrame(waitDone)
|
||||||
|
} else {
|
||||||
|
finalize()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toolStatus.value = ''
|
||||||
|
thinkingStatus.value = ''
|
||||||
|
textQueue.value = ''
|
||||||
|
pendingRecommendItems.value = []
|
||||||
|
if (typingRafId) {
|
||||||
|
cancelFrame(typingRafId)
|
||||||
|
typingRafId = 0
|
||||||
|
}
|
||||||
|
if (!messages.value[aiMessageIndex].text.trim()) {
|
||||||
|
messages.value[aiMessageIndex].text = t('pages.ai.chat.requestFailed')
|
||||||
|
}
|
||||||
|
isStreaming.value = false
|
||||||
|
streamTask.value = null
|
||||||
|
},
|
||||||
|
// fallback: non-chunk response still rendered
|
||||||
|
onToken(token) {
|
||||||
|
textQueue.value += token
|
||||||
|
flushTypingQueue(aiMessageIndex)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (goCartPopupResolver) {
|
||||||
|
goCartPopupResolver(false)
|
||||||
|
goCartPopupResolver = null
|
||||||
|
}
|
||||||
|
if (streamTask.value && typeof streamTask.value.abort === 'function') {
|
||||||
|
streamTask.value.abort()
|
||||||
|
}
|
||||||
|
textQueue.value = ''
|
||||||
|
pendingRecommendItems.value = []
|
||||||
|
if (typingRafId) {
|
||||||
|
cancelFrame(typingRafId)
|
||||||
|
typingRafId = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<navbar :title="t('navbar-ai-chat')">
|
||||||
|
<template #right>
|
||||||
|
<!-- <view class="nav-action" @click="goPreference">
|
||||||
|
{{ t('common.go-to-settings') }}
|
||||||
|
</view> -->
|
||||||
|
<image src="/static/app/images/gengduo.png" class="w-44rpx h-44rpx ml-8rpx shrink-0" @click="goPreference"></image>
|
||||||
|
</template>
|
||||||
|
</navbar>
|
||||||
|
<!-- <view class="header">
|
||||||
|
<text class="title">{{ t('pages.ai.chat.title') }}</text>
|
||||||
|
<text v-if="preferenceSummary" class="pref">{{ preferenceSummary }}</text>
|
||||||
|
</view> -->
|
||||||
|
|
||||||
|
<view class="chat">
|
||||||
|
<view v-for="(m, idx) in messages" :key="idx" class="bubble-row" :class="m.from === 'me' ? 'row-me' : 'row-ai'">
|
||||||
|
<view class="bubble" :class="m.from === 'me' ? 'bubble-me' : 'bubble-ai'">
|
||||||
|
<text v-if="m.from === 'me'" class="bubble-text">{{ m.text }}</text>
|
||||||
|
<text v-else class="bubble-text">
|
||||||
|
<text
|
||||||
|
v-for="(seg, sIdx) in splitAiTextForRender(m.text)"
|
||||||
|
:key="`${idx}-${sIdx}`"
|
||||||
|
:class="seg.strong ? 'bubble-strong' : ''"
|
||||||
|
>{{ seg.text }}</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="thinkingStatus" class="thinking-status">
|
||||||
|
<text class="thinking-dot"></text>
|
||||||
|
<text class="thinking-text">{{ thinkingStatus }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="toolStatus" class="tool-status">{{ toolStatus }}</view>
|
||||||
|
|
||||||
|
<view v-if="recommendItems.length > 0" class="product-card">
|
||||||
|
<view class="product-card__top">
|
||||||
|
<text class="price">{{ recommendCountText }}</text>
|
||||||
|
<text class="label">{{ t('pages.ai.chat.recommendSource') }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="product-list">
|
||||||
|
<view class="product-item" v-for="(item, i) in recommendItems" :key="item.specId || item.id || i" @click="openDishDetail(item)">
|
||||||
|
<image class="thumb" mode="aspectFill" :src="item.img || ''" />
|
||||||
|
<view class="meta">
|
||||||
|
<text class="name">{{ item.name || t('pages.ai.chat.unnamed') }}</text>
|
||||||
|
<text class="money">$ {{ item.specActualSalePrice || item.price || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="add-one-btn"
|
||||||
|
:class="{ 'add-one-btn--added': isRecommendAdded(item) }"
|
||||||
|
:disabled="isRecommendAdded(item)"
|
||||||
|
@click.stop="addRecommendToCart(item)"
|
||||||
|
>
|
||||||
|
{{ isRecommendAdded(item) ? t('pages.ai.chat.added') : t('pages.ai.chat.quickAdd') }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button class="btn" :disabled="addingAll || allRecommendAdded" @click="addAllRecommendToCart">
|
||||||
|
{{ addingAll ? t('pages.ai.chat.addingAll') : (allRecommendAdded ? t('pages.ai.chat.allAdded') : t('pages.ai.chat.addAll')) }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="composer">
|
||||||
|
<input
|
||||||
|
v-model="inputText"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('pages.ai.chat.inputPlaceholder')"
|
||||||
|
/>
|
||||||
|
<button class="send" :disabled="isStreaming" @click="handleSend">
|
||||||
|
{{ isStreaming ? t('pages.ai.chat.generating') : t('common.send') }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<go-cart-popup
|
||||||
|
v-model="goCartPopupVisible"
|
||||||
|
:content="goCartPopupContent"
|
||||||
|
:title="t('pages.ai.chat.goCartTitle')"
|
||||||
|
:cancel-text="t('pages.ai.chat.goCartLater')"
|
||||||
|
:confirm-text="t('pages.ai.chat.goCartNow')"
|
||||||
|
@confirm="handleGoCartConfirm"
|
||||||
|
@cancel="handleGoCartCancel"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page {
|
||||||
|
background: #f2f2f2;
|
||||||
|
min-height: 100vh;
|
||||||
|
// padding: 24rpx;
|
||||||
|
padding-bottom: calc(180rpx + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #888;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
// background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding:24rpx;
|
||||||
|
// padding: 18rpx;
|
||||||
|
// box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-ai {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-me {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 78%;
|
||||||
|
padding: 14rpx 18rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-ai {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #14181b;
|
||||||
|
border-radius: 0 16rpx 16rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-me {
|
||||||
|
background: #14181b;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 16rpx 0 16rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b03d15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 1rpx solid #f1f1f1;
|
||||||
|
padding: 16rpx;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
color: #e23636;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #14181b;
|
||||||
|
font-size: 24rpx;
|
||||||
|
background: #e9eef6;
|
||||||
|
padding: 6rpx 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #111;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #e23636;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-one-btn {
|
||||||
|
width: 128rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
line-height: 56rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
border: 1rpx solid #14181b;
|
||||||
|
background: #fff;
|
||||||
|
color: #14181b;
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-one-btn--added {
|
||||||
|
border-color: #9aa5b1;
|
||||||
|
color: #9aa5b1;
|
||||||
|
background: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #14181b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
position: fixed;
|
||||||
|
// left: 24rpx;
|
||||||
|
// right: 24rpx;
|
||||||
|
bottom: 0;
|
||||||
|
background: #fff;
|
||||||
|
// border-radius: 20rpx;
|
||||||
|
width: 100%;
|
||||||
|
padding: 24rpx 24rpx calc(52rpx + env(safe-area-inset-bottom, 0px));
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
background: #f7f7f7;
|
||||||
|
padding: 12rpx 14rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send {
|
||||||
|
width: 160rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #14181b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-status {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #4f667e;
|
||||||
|
background: #eef3fb;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 8rpx 14rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-status {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
color: #5a6d85;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-dot {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #5a6d85;
|
||||||
|
opacity: 0.75;
|
||||||
|
animation: ai-thinking-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-text {
|
||||||
|
color: #5a6d85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #14181b;
|
||||||
|
padding: 8rpx 0 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-thinking-pulse {
|
||||||
|
0% { transform: scale(0.85); opacity: 0.45; }
|
||||||
|
50% { transform: scale(1.1); opacity: 1; }
|
||||||
|
100% { transform: scale(0.85); opacity: 0.45; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@ import Search from "../tabbar-home/components/search.vue";
|
|||||||
import { useConfigStore, useUserStore } from "@/store";
|
import { useConfigStore, useUserStore } from "@/store";
|
||||||
import BrowseSkeleton from "./components/browse-skeleton.vue";
|
import BrowseSkeleton from "./components/browse-skeleton.vue";
|
||||||
import {
|
import {
|
||||||
appMerchantDishNearbyListPost,
|
appMerchantNearbyListPost,
|
||||||
appSearchSearchRecipePost,
|
appSearchSearchRecipePost,
|
||||||
} from "@/service";
|
} from "@/service";
|
||||||
import {thumbnailImg} from "@/utils/utils";
|
import {thumbnailImg} from "@/utils/utils";
|
||||||
@@ -28,8 +28,7 @@ async function initData() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
}
|
}
|
||||||
getRecipeData()
|
getRecipeData()
|
||||||
// 获取菜品数据
|
getNearbyStoreList()
|
||||||
appMerchantDishNearbyList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取菜谱数据
|
// 获取菜谱数据
|
||||||
@@ -56,38 +55,44 @@ function navigateToRecipeList() {
|
|||||||
navigateTo('/pages-user/pages/recipe/list')
|
navigateTo('/pages-user/pages/recipe/list')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取附近的菜品
|
// 获取附近店铺
|
||||||
const dishData = ref<any[]>([]);
|
const nearbyStoreList = ref<any[]>([]);
|
||||||
function appMerchantDishNearbyList() {
|
const nearbyStoreLoaded = ref(false);
|
||||||
appMerchantDishNearbyListPost({
|
function getNearbyStoreList() {
|
||||||
params: {
|
nearbyStoreLoaded.value = false;
|
||||||
pageNum: 1,
|
appMerchantNearbyListPost({
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
body: {
|
body: {
|
||||||
lat: String(userStore.userLocation.latitude ?? ''),
|
lat: userStore.userLocation.latitude,
|
||||||
lng: String(userStore.userLocation.longitude ?? ''),
|
lng: userStore.userLocation.longitude,
|
||||||
}
|
},
|
||||||
}).then(res=> {
|
|
||||||
console.log('菜品数据', res)
|
|
||||||
dishData.value = res.rows;
|
|
||||||
})
|
})
|
||||||
}
|
.then((res) => {
|
||||||
function handleClickDish(item: any) {
|
console.log("附近店铺", res);
|
||||||
navigateTo(`/pages-store/pages/store/index?id=${item.merchantId}`)
|
nearbyStoreList.value = res.data || [];
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
nearbyStoreList.value = [];
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
nearbyStoreLoaded.value = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMerchantName(item: any) {
|
function handleClickStore(item: any) {
|
||||||
return item?.merchantVo?.merchantName || item?.merchantName||item?.dishName || '--'
|
navigateTo(`/pages-store/pages/store/index?id=${item.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMerchantLogo(item: any) {
|
function getStoreName(item: any) {
|
||||||
return item?.merchantVo?.logo || item?.logo || item?.dishImage?.split?.(',')?.[0] || item?.dishImage || ''
|
return item?.merchantName || "--";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMerchantRate(item: any) {
|
function getStoreLogo(item: any) {
|
||||||
const rating = Number(item?.merchantVo?.rating ?? item?.rating ?? 0)
|
return item?.logo || "";
|
||||||
return Number.isFinite(rating) && rating > 0 ? rating.toFixed(1) : '5.0'
|
}
|
||||||
|
|
||||||
|
function getStoreRate(item: any) {
|
||||||
|
const rating = Number(item?.rating ?? 0);
|
||||||
|
return Number.isFinite(rating) && rating > 0 ? rating.toFixed(1) : "5.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPreviewRecipeList() {
|
function getPreviewRecipeList() {
|
||||||
@@ -161,17 +166,28 @@ defineExpose({
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="section-title mt-54rpx">{{ t("pages.browse.titleCuisine") }}</view>
|
<view class="section-title mt-54rpx">{{ t("pages.browse.titleCuisine") }}</view>
|
||||||
<scroll-view scroll-x class="store-scroll mt-28rpx pb-40rpx" :show-scrollbar="false" :enable-flex="true">
|
<scroll-view
|
||||||
|
v-if="nearbyStoreList.length > 0"
|
||||||
|
scroll-x
|
||||||
|
class="store-scroll mt-28rpx pb-40rpx"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
:enable-flex="true"
|
||||||
|
>
|
||||||
<view class="store-track">
|
<view class="store-track">
|
||||||
<view v-for="item in dishData" :key="item.id" @click="handleClickDish(item)" class="store-card">
|
<view
|
||||||
|
v-for="item in nearbyStoreList"
|
||||||
|
:key="item.id"
|
||||||
|
@click="handleClickStore(item)"
|
||||||
|
class="store-card"
|
||||||
|
>
|
||||||
<image
|
<image
|
||||||
:src="thumbnailImg(getMerchantLogo(item))"
|
:src="thumbnailImg(getStoreLogo(item))"
|
||||||
class="store-card__cover"
|
class="store-card__cover"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
<view class="store-card__right">
|
<view class="store-card__right">
|
||||||
<view class="store-card__name line-clamp-2">{{ getMerchantName(item) }}</view>
|
<view class="store-card__name line-clamp-2">{{ getStoreName(item) }}</view>
|
||||||
<view class="store-card__rating">★★★★★ {{ getMerchantRate(item) }}</view>
|
<view class="store-card__rating">★★★★★ {{ getStoreRate(item) }}</view>
|
||||||
<view class="store-card__brand">{{ t("pages.browse.brandTag") }}</view>
|
<view class="store-card__brand">{{ t("pages.browse.brandTag") }}</view>
|
||||||
<view class="store-card__arrow center">
|
<view class="store-card__arrow center">
|
||||||
<i class="i-carbon:chevron-right text-30rpx text-white"></i>
|
<i class="i-carbon:chevron-right text-30rpx text-white"></i>
|
||||||
@@ -180,6 +196,15 @@ defineExpose({
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
<view
|
||||||
|
v-else-if="nearbyStoreLoaded && !loading"
|
||||||
|
class="store-empty mt-28rpx pb-40rpx"
|
||||||
|
>
|
||||||
|
<view class="store-empty__icon-wrap center">
|
||||||
|
<i class="i-carbon:location-off store-empty__icon"></i>
|
||||||
|
</view>
|
||||||
|
<text class="store-empty__text">{{ t("pages.browse.nearbyEmpty") }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -335,4 +360,35 @@ defineExpose({
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 220rpx;
|
||||||
|
padding: 48rpx 32rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-empty__icon-wrap {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ececec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-empty__icon {
|
||||||
|
font-size: 44rpx;
|
||||||
|
color: #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-empty__text {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
color: #8a8a8a;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,82 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="class-bullet-container">
|
<view class="class-bullet-container">
|
||||||
<!-- 第一行:自动缓慢滚动 + 可手动滑动 -->
|
<view class="category-grid">
|
||||||
<view class="scroll-row first-row">
|
<view
|
||||||
<scroll-view
|
v-for="item in topCategories"
|
||||||
class="ab-scroll mb-10rpx"
|
:key="item.id"
|
||||||
scroll-x
|
class="category-item"
|
||||||
show-scrollbar="false"
|
@click="handleItemClick(item)"
|
||||||
:scroll-left="scrollLeft1"
|
|
||||||
@touchstart="onTouchStart1"
|
|
||||||
@touchend="onTouchEnd1"
|
|
||||||
@touchcancel="onTouchEnd1"
|
|
||||||
@scroll="onScroll1"
|
|
||||||
>
|
>
|
||||||
<view class="sv-inner" id="sv1-inner">
|
<image
|
||||||
<!-- 一份内容 -->
|
v-if="item.categoryImage || item.logoUrl"
|
||||||
<view
|
:src="item.categoryImage || item.logoUrl"
|
||||||
v-for="(item, idx) in categories"
|
class="category-icon"
|
||||||
:key="item.id + '-row1-a-' + idx"
|
mode="aspectFit"
|
||||||
class="category-item"
|
/>
|
||||||
@click="handleItemClick(item)"
|
<text class="category-text">{{ item.categoryName || item.name }}</text>
|
||||||
>
|
</view>
|
||||||
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
|
<view class="category-item" @click="handleMoreClick">
|
||||||
<text class="category-text">{{ item.categoryName || item.name }}</text>
|
<image src="@/static/app/images/more.png" class="category-icon" mode="aspectFit" />
|
||||||
</view>
|
<text class="category-text">更多</text>
|
||||||
<!-- 第二份重复内容,用于无缝滚动 -->
|
</view>
|
||||||
<view
|
|
||||||
v-for="(item, idx) in categories"
|
|
||||||
:key="item.id + '-row1-b-' + idx"
|
|
||||||
class="category-item"
|
|
||||||
@click="handleItemClick(item)"
|
|
||||||
>
|
|
||||||
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
|
|
||||||
<text class="category-text">{{ item.categoryName || item.name }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</scroll-view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 第二行:反向数据,自动滚动速度略不同 -->
|
|
||||||
<view class="scroll-row">
|
|
||||||
<scroll-view
|
|
||||||
class="ab-scroll"
|
|
||||||
scroll-x
|
|
||||||
show-scrollbar="false"
|
|
||||||
:scroll-left="scrollLeft2"
|
|
||||||
@touchstart="onTouchStart2"
|
|
||||||
@touchend="onTouchEnd2"
|
|
||||||
@touchcancel="onTouchEnd2"
|
|
||||||
@scroll="onScroll2"
|
|
||||||
>
|
|
||||||
<view class="sv-inner" id="sv2-inner">
|
|
||||||
<view
|
|
||||||
v-for="(item, idx) in categoriesReversed"
|
|
||||||
:key="item.id + '-row2-a-' + idx"
|
|
||||||
class="category-item"
|
|
||||||
@click="handleItemClick(item)"
|
|
||||||
>
|
|
||||||
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
|
|
||||||
<text class="category-text">{{ item.categoryName || item.name }}</text>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-for="(item, idx) in categoriesReversed"
|
|
||||||
:key="item.id + '-row2-b-' + idx"
|
|
||||||
class="category-item"
|
|
||||||
@click="handleItemClick(item)"
|
|
||||||
>
|
|
||||||
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
|
|
||||||
<text class="category-text">{{ item.categoryName || item.name }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</scroll-view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useCategoryNavStore } from '@/store'
|
import { useCategoryNavStore } from '@/store'
|
||||||
|
|
||||||
// 定义分类项接口(与模板字段一致)
|
// 定义分类项接口(与模板字段一致)
|
||||||
@@ -121,128 +69,7 @@ const mockCategories: CategoryItem[] = [
|
|||||||
const categories = computed(() => {
|
const categories = computed(() => {
|
||||||
return props.categories.length > 0 ? props.categories : mockCategories
|
return props.categories.length > 0 ? props.categories : mockCategories
|
||||||
})
|
})
|
||||||
// 第二行使用反向顺序以形成与第一行相反的视觉效果
|
const topCategories = computed(() => categories.value.slice(0, 4))
|
||||||
const categoriesReversed = computed(() => {
|
|
||||||
const list = categories.value || []
|
|
||||||
return list.slice().reverse()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ===== 自动滚动相关(电商标签推荐风格) =====
|
|
||||||
// 视口宽度,用于计算最大可滚动距离(scrollWidth - viewportWidth)
|
|
||||||
const systemInfo = uni.getSystemInfoSync()
|
|
||||||
const viewportWidth = systemInfo.windowWidth || 0
|
|
||||||
|
|
||||||
const scrollLeft1 = ref(0)
|
|
||||||
const scrollLeft2 = ref(0)
|
|
||||||
|
|
||||||
// 最大可滚动距离(由 scroll 事件提供,避免自己算)
|
|
||||||
const maxScroll1 = ref(0)
|
|
||||||
const maxScroll2 = ref(0)
|
|
||||||
|
|
||||||
const isTouching1 = ref(false)
|
|
||||||
const isTouching2 = ref(false)
|
|
||||||
|
|
||||||
let autoTimer1: any = null
|
|
||||||
let autoTimer2: any = null
|
|
||||||
let resumeTimer1: any = null
|
|
||||||
let resumeTimer2: any = null
|
|
||||||
|
|
||||||
// 速度和间隔可以根据效果微调
|
|
||||||
const AUTO_INTERVAL = 30
|
|
||||||
const AUTO_STEP_1 = 0.6
|
|
||||||
const AUTO_STEP_2 = 0.4
|
|
||||||
const RESUME_DELAY = 800
|
|
||||||
const RESET_THRESHOLD = 2 // 在距离末尾一定范围内就重置
|
|
||||||
|
|
||||||
function startAuto1() {
|
|
||||||
if (autoTimer1) return
|
|
||||||
autoTimer1 = setInterval(() => {
|
|
||||||
if (isTouching1.value) return
|
|
||||||
let next = scrollLeft1.value + AUTO_STEP_1
|
|
||||||
const max = maxScroll1.value
|
|
||||||
if (max > 0 && next >= max - RESET_THRESHOLD) {
|
|
||||||
// 距离最右侧很近时,从头开始,实现循环
|
|
||||||
next = 0
|
|
||||||
}
|
|
||||||
scrollLeft1.value = next
|
|
||||||
}, AUTO_INTERVAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAuto2() {
|
|
||||||
if (autoTimer2) return
|
|
||||||
autoTimer2 = setInterval(() => {
|
|
||||||
if (isTouching2.value) return
|
|
||||||
let next = scrollLeft2.value + AUTO_STEP_2
|
|
||||||
const max = maxScroll2.value
|
|
||||||
if (max > 0 && next >= max - RESET_THRESHOLD) {
|
|
||||||
next = 0
|
|
||||||
}
|
|
||||||
scrollLeft2.value = next
|
|
||||||
}, AUTO_INTERVAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAuto1() {
|
|
||||||
if (autoTimer1) {
|
|
||||||
clearInterval(autoTimer1)
|
|
||||||
autoTimer1 = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAuto2() {
|
|
||||||
if (autoTimer2) {
|
|
||||||
clearInterval(autoTimer2)
|
|
||||||
autoTimer2 = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchStart1() {
|
|
||||||
isTouching1.value = true
|
|
||||||
if (resumeTimer1) clearTimeout(resumeTimer1)
|
|
||||||
stopAuto1()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchEnd1() {
|
|
||||||
resumeTimer1 = setTimeout(() => {
|
|
||||||
isTouching1.value = false
|
|
||||||
startAuto1()
|
|
||||||
}, RESUME_DELAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScroll1(e: any) {
|
|
||||||
const detail = e?.detail
|
|
||||||
if (detail && typeof detail.scrollLeft === 'number') {
|
|
||||||
scrollLeft1.value = detail.scrollLeft
|
|
||||||
// scrollWidth - viewportWidth 为最大可滚动距离
|
|
||||||
if (typeof detail.scrollWidth === 'number' && viewportWidth > 0) {
|
|
||||||
const max = detail.scrollWidth - viewportWidth
|
|
||||||
maxScroll1.value = max > 0 ? max : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchStart2() {
|
|
||||||
isTouching2.value = true
|
|
||||||
if (resumeTimer2) clearTimeout(resumeTimer2)
|
|
||||||
stopAuto2()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchEnd2() {
|
|
||||||
resumeTimer2 = setTimeout(() => {
|
|
||||||
isTouching2.value = false
|
|
||||||
startAuto2()
|
|
||||||
}, RESUME_DELAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScroll2(e: any) {
|
|
||||||
const detail = e?.detail
|
|
||||||
if (detail && typeof detail.scrollLeft === 'number') {
|
|
||||||
scrollLeft2.value = detail.scrollLeft
|
|
||||||
if (typeof detail.scrollWidth === 'number' && viewportWidth > 0) {
|
|
||||||
const max = detail.scrollWidth - viewportWidth
|
|
||||||
maxScroll2.value = max > 0 ? max : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击处理(列表页菜谱 id 来自 store,不放在 URL query)
|
// 点击处理(列表页菜谱 id 来自 store,不放在 URL query)
|
||||||
const handleItemClick = (item: CategoryItem) => {
|
const handleItemClick = (item: CategoryItem) => {
|
||||||
@@ -255,82 +82,46 @@ function navigateTo(url: string) {
|
|||||||
uni.navigateTo({ url })
|
uni.navigateTo({ url })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
function handleMoreClick() {
|
||||||
nextTick(() => {
|
navigateTo('/pages-store/pages/list/index')
|
||||||
// 启动自动滚动,maxScroll 值会在第一次 scroll 事件时更新
|
}
|
||||||
setTimeout(() => {
|
|
||||||
startAuto1()
|
|
||||||
startAuto2()
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopAuto1()
|
|
||||||
stopAuto2()
|
|
||||||
if (resumeTimer1) clearTimeout(resumeTimer1)
|
|
||||||
if (resumeTimer2) clearTimeout(resumeTimer2)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.class-bullet-container {
|
.class-bullet-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.scroll-row {
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ab-scroll {
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sv-inner {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 120rpx;
|
|
||||||
height: 60rpx;
|
|
||||||
padding: 0 20rpx;
|
|
||||||
background: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 30rpx;
|
|
||||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(0) scale(0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-icon {
|
|
||||||
width: 32rpx;
|
|
||||||
height: 32rpx;
|
|
||||||
margin-right: 8rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-text {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #333;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消 CSS 动画,由 JS 控制的 scroll-left 实现无缝滚动,避免与手势滚动冲突导致的抖动
|
.category-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-item {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 108rpx;
|
||||||
|
padding: 8rpx 4rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-icon {
|
||||||
|
width: 54rpx;
|
||||||
|
height: 54rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -79,14 +79,14 @@ function subtitleLine(item: any): string {
|
|||||||
<image
|
<image
|
||||||
:src="getCardImages(item)[0]"
|
:src="getCardImages(item)[0]"
|
||||||
class="featured-card__media-half featured-card__media-half--top"
|
class="featured-card__media-half featured-card__media-half--top"
|
||||||
mode="aspectFill"
|
mode="scaleToFill"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<image
|
<image
|
||||||
v-else-if="getCardImages(item).length >= 1"
|
v-else-if="getCardImages(item).length >= 1"
|
||||||
:src="getCardImages(item)[0]"
|
:src="getCardImages(item)[0]"
|
||||||
class="featured-card__media-single"
|
class="featured-card__media-single"
|
||||||
mode="aspectFill"
|
mode="scaleToFill"
|
||||||
/>
|
/>
|
||||||
<view v-else class="featured-card__media-single featured-card__media-placeholder" />
|
<view v-else class="featured-card__media-single featured-card__media-placeholder" />
|
||||||
</view>
|
</view>
|
||||||
@@ -148,9 +148,9 @@ function subtitleLine(item: any): string {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: 620rpx;
|
width: 560rpx;
|
||||||
height: 256rpx;
|
height: 306rpx;
|
||||||
min-height: 256rpx;
|
min-height: 306rpx;
|
||||||
margin-left: 16rpx;
|
margin-left: 16rpx;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
@@ -162,7 +162,7 @@ function subtitleLine(item: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.featured-card__media {
|
.featured-card__media {
|
||||||
width: 232rpx;
|
width: 383rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -204,23 +204,17 @@ function subtitleLine(item: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.featured-card__name {
|
.featured-card__name {
|
||||||
font-size: 28rpx;
|
display: block;
|
||||||
line-height: 34rpx;
|
width: 100%;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 30rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
}
|
white-space: normal;
|
||||||
.featured-card__name--primary {
|
word-break: break-word;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.featured-card__name--secondary {
|
.featured-card__name--secondary {
|
||||||
font-size: 28rpx;
|
font-weight: 500;
|
||||||
line-height: 34rpx;
|
|
||||||
color: #1a1a1a;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-card__media-placeholder {
|
.featured-card__media-placeholder {
|
||||||
@@ -230,8 +224,8 @@ function subtitleLine(item: any): string {
|
|||||||
|
|
||||||
.featured-card__fee {
|
.featured-card__fee {
|
||||||
margin-top: 10rpx;
|
margin-top: 10rpx;
|
||||||
font-size: 24rpx;
|
font-size: 18rpx;
|
||||||
line-height: 30rpx;
|
line-height: 24rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #d48806;
|
color: #d48806;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="app-title-skeleton skeleton-item"></view>
|
<view class="header-toolbar">
|
||||||
<view class="delivery-info-skeleton skeleton-item"></view>
|
<view class="brand-row">
|
||||||
|
<view class="logo-skeleton skeleton-item"></view>
|
||||||
|
<view class="app-title-skeleton skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
<view class="header-right">
|
||||||
|
<view class="location-pill-skeleton skeleton-item"></view>
|
||||||
|
<view class="cart-btn-skeleton skeleton-item"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="notice-row-skeleton skeleton-item"></view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 位置和通知区域 -->
|
<!-- 搜索栏 + 消息按钮 -->
|
||||||
<view class="location-notification">
|
|
||||||
<view class="location-skeleton skeleton-item"></view>
|
|
||||||
<view class="notification-skeleton skeleton-item"></view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 搜索栏 -->
|
|
||||||
<view class="search-section">
|
<view class="search-section">
|
||||||
<view class="search-bar-skeleton skeleton-item"></view>
|
<view class="search-row">
|
||||||
|
<view class="search-bar-skeleton skeleton-item"></view>
|
||||||
|
<view class="message-btn-skeleton skeleton-item"></view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 分类滚动区域 -->
|
<!-- 分类滚动区域 -->
|
||||||
@@ -107,59 +113,94 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 14rpx;
|
gap: 10rpx;
|
||||||
|
|
||||||
.app-title-skeleton {
|
.header-toolbar {
|
||||||
width: 241rpx;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-skeleton {
|
||||||
|
width: 52rpx;
|
||||||
height: 52rpx;
|
height: 52rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title-skeleton {
|
||||||
|
width: 210rpx;
|
||||||
|
height: 36rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delivery-info-skeleton {
|
.notice-row-skeleton {
|
||||||
width: 266rpx;
|
width: 100%;
|
||||||
height: 28rpx;
|
height: 32rpx;
|
||||||
border-radius: 6rpx;
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-pill-skeleton {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-btn-skeleton {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 位置和通知区域
|
// 搜索栏 + 消息按钮
|
||||||
.location-notification {
|
|
||||||
padding: 22rpx 30rpx 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.location-skeleton {
|
|
||||||
width: 329rpx;
|
|
||||||
height: 30rpx;
|
|
||||||
border-radius: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-skeleton {
|
|
||||||
width: 132rpx;
|
|
||||||
height: 60rpx;
|
|
||||||
border-radius: 30rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索栏
|
|
||||||
.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,159 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
appName: string
|
||||||
|
locationText: string
|
||||||
|
cartBadgeTotal?: number
|
||||||
|
isLogin?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'clickLocation'): void
|
||||||
|
(e: 'clickCart'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const noticeTexts = computed(() => [
|
||||||
|
t('pages.home.noticeBar.thursday'),
|
||||||
|
t('pages.home.noticeBar.sunday'),
|
||||||
|
t('pages.home.noticeBar.freshCatch'),
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 横向无缝滚动:多条拼接为一条,重复一遍避免循环时跳变 */
|
||||||
|
const noticeScrollText = computed(() => {
|
||||||
|
const items = noticeTexts.value.filter(Boolean)
|
||||||
|
if (!items.length) return ''
|
||||||
|
const once = items.join(' ')
|
||||||
|
return `${once} ${once}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="home-top-header px-24rpx pt-12rpx pb-8rpx">
|
||||||
|
<view class="home-top-header__toolbar flex items-center justify-between gap-12rpx">
|
||||||
|
<view class="flex items-center gap-14rpx min-w-0 flex-1">
|
||||||
|
<image
|
||||||
|
src="@img/logo.png"
|
||||||
|
class="w-52rpx h-52rpx shrink-0"
|
||||||
|
style="border-radius: 50%"
|
||||||
|
/>
|
||||||
|
<text class="text-32rpx lh-36rpx text-#1a1a1a font-bold tracking-tight line-clamp-1">
|
||||||
|
{{ appName }}
|
||||||
|
</text>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="home-cart-btn" @click="emit('clickCart')">
|
||||||
|
<view class="i-carbon:shopping-cart text-36rpx text-#14181b" />
|
||||||
|
<view
|
||||||
|
v-if="isLogin && (cartBadgeTotal || 0) > 0"
|
||||||
|
class="home-cart-badge"
|
||||||
|
>
|
||||||
|
{{ (cartBadgeTotal || 0) > 99 ? '99+' : cartBadgeTotal }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="home-notice-wrap mt-10rpx">
|
||||||
|
<wd-notice-bar
|
||||||
|
:text="noticeScrollText"
|
||||||
|
:delay="1"
|
||||||
|
:speed="60"
|
||||||
|
color="#00A76D"
|
||||||
|
background-color="transparent"
|
||||||
|
custom-class="home-notice-bar"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.home-top-header {
|
||||||
|
background: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-notice-wrap {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
:deep(.home-notice-bar) {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.wd-notice-bar__wrap) {
|
||||||
|
height: 32rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.wd-notice-bar__content) {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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();
|
||||||
|
|
||||||
function navigateTo(url: string) {
|
function navigateTo(url: string) {
|
||||||
if(userStore.checkLogin()) {
|
if(userStore.checkLogin()) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
@@ -16,31 +9,42 @@ function navigateTo(url: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function onFabClick() {
|
||||||
|
navigateTo('/pages/ai/recommend/index')
|
||||||
|
}
|
||||||
</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="relative shrink-0"
|
<view class="ai-fab" @click="onFabClick">
|
||||||
>
|
<image src="@/static/app/images/aidiancan.gif" class="ai-icon"></image>
|
||||||
<view
|
|
||||||
v-if="userStore.isLogin && userStore.unreadMessageCount > 0"
|
|
||||||
:class="compact ? 'h-26rpx top--10rpx right--10rpx text-20rpx px-8rpx' : 'w-32rpx h-32rpx top--16rpx right--16rpx text-24rpx line-height-32rpx'"
|
|
||||||
class="bg-#E23636 absolute z-2 rounded-50% text-#fff text-center font-500"
|
|
||||||
>{{ userStore.unreadMessageCount }}</view>
|
|
||||||
<image src="@img/chef/114.png" :class="compact ? 'w-34rpx h-34rpx' : 'w-40rpx h-40rpx'"></image>
|
|
||||||
</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-icon {
|
||||||
|
width: 260rpx;
|
||||||
|
height: 260rpx;
|
||||||
|
margin-right:-70rpx !important;
|
||||||
|
}
|
||||||
</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,17 +22,36 @@ function handleClickSearch() {
|
|||||||
emit('clickSearch')
|
emit('clickSearch')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goMessage() {
|
||||||
|
if (userStore.checkLogin()) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages-user/pages/message/index',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view
|
<view class="home-search-row flex items-center gap-14rpx">
|
||||||
@click="handleClickSearch"
|
<view
|
||||||
class="home-search-bar flex items-center h-88rpx rounded-44rpx pl-36rpx pr-28rpx bg-white"
|
@click="handleClickSearch"
|
||||||
>
|
class="home-search-bar flex-1 min-w-0 flex items-center h-88rpx rounded-44rpx pl-36rpx pr-28rpx bg-white"
|
||||||
<image src="@img/chef/100222.png" class="w-28rpx h-28rpx shrink-0"></image>
|
>
|
||||||
<text class="home-search-placeholder text-28rpx ml-16rpx tracking-[.04em] font-500 truncate">{{
|
<image src="@img/chef/100222.png" class="w-28rpx h-28rpx shrink-0"></image>
|
||||||
t('components.search.placeholder')
|
<text class="home-search-placeholder text-28rpx ml-16rpx tracking-[.04em] font-500 truncate">{{
|
||||||
}}</text>
|
t('components.search.placeholder')
|
||||||
|
}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="home-msg-btn" @click="goMessage">
|
||||||
|
<view
|
||||||
|
v-if="userStore.isLogin && userStore.unreadMessageCount > 0"
|
||||||
|
class="home-msg-badge"
|
||||||
|
>
|
||||||
|
{{ userStore.unreadMessageCount > 99 ? '99+' : userStore.unreadMessageCount }}
|
||||||
|
</view>
|
||||||
|
<image src="@img/chef/114.png" class="w-34rpx h-34rpx"></image>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
/** 外部列表(菜谱页等);首页不传则使用固定七项 */
|
||||||
list: {
|
list: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@@ -21,77 +25,109 @@ const props = defineProps({
|
|||||||
default: 'logoUrl',
|
default: 'logoUrl',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['changeType']);
|
|
||||||
watchEffect(() => {
|
|
||||||
if (props.currentId) {
|
|
||||||
selectedIndex.value = props.currentId;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedIndex = ref();
|
const emit = defineEmits(['changeType'])
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
function selectTab(item: any) {
|
const fixedTabs = [
|
||||||
selectedIndex.value = item[props.valueKey];
|
{
|
||||||
// 触发父组件事件
|
id: 'fresh-seafood-today',
|
||||||
console.log('selectTab', item[props.valueKey]);
|
nameKey: 'pages.home.quickTabs.freshSeafoodToday',
|
||||||
emit('changeType', item[props.valueKey]);
|
logoUrl: '/static/app/images/home/xiandahaixian.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new-calendar',
|
||||||
|
nameKey: 'pages.home.quickTabs.newCalendar',
|
||||||
|
logoUrl: '/static/app/images/home/shangxinrili.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-zone',
|
||||||
|
nameKey: 'pages.home.quickTabs.memberZone',
|
||||||
|
logoUrl: '/static/app/images/home/huiyuanzhuanqu.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'must-eat-list',
|
||||||
|
nameKey: 'pages.home.quickTabs.mustEatList',
|
||||||
|
logoUrl: '/static/app/images/home/bichibang.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'group-catering',
|
||||||
|
nameKey: 'pages.home.quickTabs.groupCatering',
|
||||||
|
logoUrl: '/static/app/images/home/tancanyuding.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'live-seafood-air',
|
||||||
|
nameKey: 'pages.home.quickTabs.liveSeafoodAir',
|
||||||
|
logoUrl: '/static/app/images/home/kongyunhaixian.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'energy-meal',
|
||||||
|
nameKey: 'pages.home.quickTabs.energyMeal',
|
||||||
|
logoUrl: '/static/app/images/home/nengliangcan.png',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const useFixedTabs = computed(() => !props.list || props.list.length === 0)
|
||||||
|
|
||||||
|
function selectTab(item: Record<string, unknown>) {
|
||||||
|
emit('changeType', item[props.valueKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTabName(item: Record<string, unknown>) {
|
||||||
|
if (useFixedTabs.value && item.nameKey) {
|
||||||
|
return t(String(item.nameKey))
|
||||||
|
}
|
||||||
|
return item[props.labelKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImage(item: Record<string, unknown>) {
|
||||||
|
return String(item[props.imgKey] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayList = computed(() =>
|
||||||
|
useFixedTabs.value ? fixedTabs : props.list,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<scroll-view :scroll-x="true">
|
<scroll-view :scroll-x="true">
|
||||||
<view class="flex items-center">
|
<view class="flex items-center">
|
||||||
<view class="shrink-0 w-30rpx"></view>
|
<view class="shrink-0 w-30rpx"></view>
|
||||||
<template v-for="(item, index) in list" :key="index">
|
<view
|
||||||
<view
|
v-for="(item, index) in displayList"
|
||||||
:class="[index === 0 ? '' : 'ml-40rpx']"
|
:key="String((item as Record<string, unknown>)[valueKey] ?? (item as Record<string, unknown>).id ?? index)"
|
||||||
class="w-112rpx flex flex-col items-center"
|
:class="[index === 0 ? '' : 'ml-40rpx']"
|
||||||
@click="selectTab(item)"
|
class="tab-item shrink-0 flex flex-col items-center"
|
||||||
>
|
@click="selectTab(item as Record<string, unknown>)"
|
||||||
<view
|
>
|
||||||
:class="['img-wrap', selectedIndex == item[props.valueKey] ? 'img-selected' : '']"
|
<image
|
||||||
>
|
class="tab-img"
|
||||||
<image
|
:src="getImage(item as Record<string, unknown>)"
|
||||||
class="tab-img rounded-50% overflow-hidden bg-common"
|
mode="heightFix"
|
||||||
:src="item[props.imgKey]"
|
/>
|
||||||
mode="aspectFill"
|
<text class="tab-label line-clamp-1">{{ getTabName(item as Record<string, unknown>) }}</text>
|
||||||
></image>
|
</view>
|
||||||
</view>
|
|
||||||
<text
|
|
||||||
:class="selectedIndex == item[props.valueKey] ? 'text-#CE7138' : 'text-#333'"
|
|
||||||
class="line-clamp-1 text-20rpx lh-20rpx mt-12rpx font-500"
|
|
||||||
>{{ item[props.labelKey] }}</text
|
|
||||||
>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
<view class="shrink-0 w-30rpx op-0">1</view>
|
<view class="shrink-0 w-30rpx op-0">1</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.img-wrap {
|
.tab-item {
|
||||||
display: flex;
|
min-width: 94rpx;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s;
|
|
||||||
width: 102rpx;
|
|
||||||
height: 102rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.07);
|
|
||||||
}
|
}
|
||||||
.img-selected {
|
|
||||||
border: 4rpx solid #ce7138;
|
.tab-label {
|
||||||
border-radius: 50%;
|
margin-top: 10rpx;
|
||||||
overflow: hidden;
|
font-size: 22rpx;
|
||||||
box-sizing: border-box;
|
line-height: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-img {
|
.tab-img {
|
||||||
width: 98rpx;
|
height: 144rpx;
|
||||||
height: 98rpx;
|
display: block;
|
||||||
border-radius: 50%;
|
margin-bottom: 10rpx;
|
||||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import useEventEmit from "@/hooks/useEventEmit";
|
import useEventEmit from "@/hooks/useEventEmit";
|
||||||
import {CollectionType, EventEnum} from "@/constant/enums";
|
import { CollectionType, EventEnum } from "@/constant/enums";
|
||||||
import { useConfigStore, useUserStore } from "@/store";
|
import { useConfigStore, useUserStore } from "@/store";
|
||||||
import Config from '@/config/index'
|
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";
|
||||||
@@ -16,7 +19,6 @@ import HomeSkeleton from "./components/home-skeleton.vue";
|
|||||||
import {
|
import {
|
||||||
appMarketActivityListPost,
|
appMarketActivityListPost,
|
||||||
appMerchantCartListMerchantPost,
|
appMerchantCartListMerchantPost,
|
||||||
appMerchantCategoryListGet,
|
|
||||||
appMerchantFeaturedListPost,
|
appMerchantFeaturedListPost,
|
||||||
appMerchantLabelListGet,
|
appMerchantLabelListGet,
|
||||||
appMerchantNearbyListPost, appMerchantRecommendListPost,
|
appMerchantNearbyListPost, appMerchantRecommendListPost,
|
||||||
@@ -24,7 +26,11 @@ import {
|
|||||||
appRecipeCategoryListGet
|
appRecipeCategoryListGet
|
||||||
} from "@/service";
|
} from "@/service";
|
||||||
import usePage from "@/hooks/usePage";
|
import usePage from "@/hooks/usePage";
|
||||||
import {getFeaturedDishList} from "@/pages-store/service";
|
import { getFeaturedDishList } from "@/pages-store/service";
|
||||||
|
import {
|
||||||
|
buildQuickTopicUrl,
|
||||||
|
isQuickTopicSlug,
|
||||||
|
} from '@/pages-store/pages/dishes/utils/quick-topic-route'
|
||||||
import { formatSalesCount } from "@/utils/utils";
|
import { formatSalesCount } from "@/utils/utils";
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
@@ -33,7 +39,35 @@ const props = defineProps<{
|
|||||||
price?: Array<string> | string;
|
price?: Array<string> | string;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits(["toggleScore", "togglePrice", "toggleNotOpen"]);
|
const emit = defineEmits(["toggleScore", "togglePrice", "toggleNotOpen"]);
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
/** 首页运营图:按语言切换(中文 / 英文) */
|
||||||
|
const HOME_PROMO_BANNERS = {
|
||||||
|
memberUpgrade: {
|
||||||
|
zh: 'https://www.howhowfresh.com/minio/ruoyi/2026/06/18/edb822ef721642b39e52f19b2e5df949.png',
|
||||||
|
en: 'https://www.howhowfresh.com/minio/ruoyi/2026/06/18/6a8a43575f70425eb6b8071633708531.png',
|
||||||
|
},
|
||||||
|
deliveryTime: {
|
||||||
|
zh: 'https://www.howhowfresh.com/minio/ruoyi/2026/06/18/8a0fcfab0a6e4ff29fdecdc41e01ebdb.png',
|
||||||
|
en: 'https://www.howhowfresh.com/minio/ruoyi/2026/06/18/99c6b38e7f1e4337baee0ec5e42c76ba.png',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const isEnglishLocale = computed(() =>
|
||||||
|
String(locale.value || uni.getLocale() || '').toLowerCase().startsWith('en'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const memberUpgradeBannerSrc = computed(() =>
|
||||||
|
isEnglishLocale.value
|
||||||
|
? HOME_PROMO_BANNERS.memberUpgrade.en
|
||||||
|
: HOME_PROMO_BANNERS.memberUpgrade.zh,
|
||||||
|
)
|
||||||
|
|
||||||
|
const deliveryTimeBannerSrc = computed(() =>
|
||||||
|
isEnglishLocale.value
|
||||||
|
? HOME_PROMO_BANNERS.deliveryTime.en
|
||||||
|
: HOME_PROMO_BANNERS.deliveryTime.zh,
|
||||||
|
)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
@@ -48,32 +82,24 @@ function getDishPromoLabel(item: Record<string, unknown>): string {
|
|||||||
return typeof raw === 'string' && raw.trim() ? raw.trim() : ''
|
return typeof raw === 'string' && raw.trim() ? raw.trim() : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFeaturedDishDisplayPrice(item: Record<string, any>) {
|
|
||||||
const firstSpecPrice = item?.merchantSideDishVoList?.[0]?.merchantSideDishItemVoList?.[0]?.actualSalePrice
|
|
||||||
if (firstSpecPrice != null && String(firstSpecPrice) !== '') return firstSpecPrice
|
|
||||||
if (item?.actualSalePrice != null && String(item.actualSalePrice) !== '') return item.actualSalePrice
|
|
||||||
return item?.discountPrice ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateTo(url: string) {
|
function navigateTo(url: string) {
|
||||||
if(userStore.checkLogin()) {
|
if (userStore.checkLogin()) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const swiperList = ref([]);
|
const swiperList = ref<any[]>([])
|
||||||
const currentSwiper = ref(0);
|
|
||||||
|
|
||||||
async function initData() {
|
async function initData() {
|
||||||
// 只在首次加载时显示骨架屏,避免切换时的白屏
|
// 只在首次加载时显示骨架屏,避免切换时的白屏
|
||||||
if(featuredList.value.length === 0) {
|
if (featuredList.value.length === 0) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
}
|
}
|
||||||
appMarketActivityList()
|
getAppMarketActivityList()
|
||||||
getAppMerchantLabelList()
|
getAppMerchantLabelList()
|
||||||
getAppMerchantCategoryList()
|
|
||||||
getAppFeaturedList()
|
getAppFeaturedList()
|
||||||
getAppNearbyListPost()
|
getAppNearbyListPost()
|
||||||
// 获取当前用户购物车信息
|
// 获取当前用户购物车信息
|
||||||
@@ -103,12 +129,21 @@ defineExpose({
|
|||||||
init: getIndexList,
|
init: getIndexList,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取轮播图列表
|
/** 首页轮播:POST /app/marketActivity/list */
|
||||||
function appMarketActivityList() {
|
function getAppMarketActivityList() {
|
||||||
appMarketActivityListPost({}).then(res=> {
|
appMarketActivityListPost({})
|
||||||
console.log('互动列表', res)
|
.then((res) => {
|
||||||
swiperList.value = res.data
|
const list = Array.isArray(res?.data) ? res.data : []
|
||||||
})
|
swiperList.value = list
|
||||||
|
.filter((item) => item?.activityImageUrl || item?.activityImage)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
activityImage: item.activityImageUrl || item.activityImage,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
swiperList.value = []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动信息获取 APP-商家标签分类控制器(用户端首页上面左右滚动的)
|
// 滚动信息获取 APP-商家标签分类控制器(用户端首页上面左右滚动的)
|
||||||
@@ -123,15 +158,7 @@ function getAppMerchantLabelList() {
|
|||||||
// appMerchantLabelList.value = res.data || []
|
// appMerchantLabelList.value = res.data || []
|
||||||
// })
|
// })
|
||||||
}
|
}
|
||||||
// 查询所有商家分类数据
|
|
||||||
const appMerchantCategoryList = ref([])
|
|
||||||
const currentCategory = ref('')
|
const currentCategory = ref('')
|
||||||
function getAppMerchantCategoryList() {
|
|
||||||
appMerchantCategoryListGet({}).then((res: any) => {
|
|
||||||
console.log('查询所有商家分类数据', res)
|
|
||||||
appMerchantCategoryList.value = res.data || []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询精选商家列表(首页)
|
// 查询精选商家列表(首页)
|
||||||
const featuredList = ref([])
|
const featuredList = ref([])
|
||||||
@@ -141,9 +168,9 @@ function getAppFeaturedList() {
|
|||||||
lat: userStore.userLocation.latitude,
|
lat: userStore.userLocation.latitude,
|
||||||
lng: userStore.userLocation.longitude,
|
lng: userStore.userLocation.longitude,
|
||||||
}
|
}
|
||||||
}).then(res=> {
|
}).then(res => {
|
||||||
featuredList.value = res.data || []
|
featuredList.value = res.data || []
|
||||||
}).finally(()=> {
|
}).finally(() => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -156,7 +183,7 @@ function getAppNearbyListPost() {
|
|||||||
lat: userStore.userLocation.latitude,
|
lat: userStore.userLocation.latitude,
|
||||||
lng: userStore.userLocation.longitude,
|
lng: userStore.userLocation.longitude,
|
||||||
}
|
}
|
||||||
}).then(res=> {
|
}).then(res => {
|
||||||
nearbyList.value = res.data || []
|
nearbyList.value = res.data || []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -173,7 +200,7 @@ function toggleDiscount(value: number) {
|
|||||||
discount.value = value;
|
discount.value = value;
|
||||||
// paging.value.refresh()
|
// paging.value.refresh()
|
||||||
}
|
}
|
||||||
const {paging, dataList, queryList} = usePage(getList)
|
const { paging, dataList, queryList } = usePage(getList)
|
||||||
function getList(pageNum: number, pageSize: number) {
|
function getList(pageNum: number, pageSize: number) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
getFeaturedDishList({
|
getFeaturedDishList({
|
||||||
@@ -181,7 +208,7 @@ function getList(pageNum: number, pageSize: number) {
|
|||||||
pageSize,
|
pageSize,
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
console.log('查询精选菜品列表', res)
|
console.log('查询精选菜品列表', res)
|
||||||
resolve({rows: res.rows})
|
resolve({ rows: res.rows })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -207,16 +234,37 @@ function handleItemClick(e) {
|
|||||||
merchantLabelId.value = e.id
|
merchantLabelId.value = e.id
|
||||||
// paging.value.refresh()
|
// paging.value.refresh()
|
||||||
}
|
}
|
||||||
function tabsTypeChange(id: string) {
|
function tabsTypeChange(id: string | number) {
|
||||||
currentCategory.value = id
|
const topic = String(id)
|
||||||
navigateTo('/pages-store/pages/home-store/index?merchantCategoryIds=' + id)
|
if (topic === 'energy-meal') {
|
||||||
// console.log('分类切换', id)
|
navigateTo('/pages-store/pages/energy-meal/index')
|
||||||
// paging.value.refresh()
|
return
|
||||||
|
}
|
||||||
|
if (topic === 'group-catering') {
|
||||||
|
navigateTo('/pages-store/pages/group-catering/index')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isQuickTopicSlug(topic)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentCategory.value = topic
|
||||||
|
const titleKeys: Record<string, string> = {
|
||||||
|
'member-zone': 'pages.home.quickTabs.memberZone',
|
||||||
|
'live-seafood-air': 'pages.home.quickTabs.liveSeafoodAir',
|
||||||
|
'must-eat-list': 'pages.home.quickTabs.mustEatList',
|
||||||
|
'new-calendar': 'pages.home.quickTabs.newCalendar',
|
||||||
|
'fresh-seafood-today': 'pages.home.quickTabs.freshSeafoodToday',
|
||||||
|
}
|
||||||
|
navigateTo(
|
||||||
|
buildQuickTopicUrl(topic, {
|
||||||
|
categoryName: t(titleKeys[topic]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 是否展示精选商家和附近商家 true 显示 false隐藏
|
// 是否展示精选商家和附近商家 true 显示 false隐藏
|
||||||
const isShowMerchant = computed(()=> {
|
const isShowMerchant = computed(() => {
|
||||||
if(!selfPickup.value && !discount.value && !props.scoreRange && !props.price && !currentCategory.value) {
|
if (!selfPickup.value && !discount.value && !props.scoreRange && !props.price && !currentCategory.value) {
|
||||||
return true // 没有筛选条件时显示
|
return true // 没有筛选条件时显示
|
||||||
} else {
|
} else {
|
||||||
return true // 有筛选条件时隐藏
|
return true // 有筛选条件时隐藏
|
||||||
@@ -252,6 +300,9 @@ function onRefresh() {
|
|||||||
console.log('手动触发下拉刷新了')
|
console.log('手动触发下拉刷新了')
|
||||||
merchantLabelId.value = ''
|
merchantLabelId.value = ''
|
||||||
currentCategory.value = ''
|
currentCategory.value = ''
|
||||||
|
getAppMarketActivityList()
|
||||||
|
getAppFeaturedList()
|
||||||
|
getAppNearbyListPost()
|
||||||
paging.value.refresh()
|
paging.value.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +318,12 @@ function handleClickSwiper(item: any) {
|
|||||||
case 3: // 会员
|
case 3: // 会员
|
||||||
navigateTo('/pages-user/pages/member/index')
|
navigateTo('/pages-user/pages/member/index')
|
||||||
break
|
break
|
||||||
|
case 5: // 充值活动
|
||||||
|
navigateTo('/pages-user/pages/recharge/activity-detail?id=' + item.id)
|
||||||
|
break
|
||||||
|
// case 4:
|
||||||
|
// navigateTo('/pages/ai/chat/index')
|
||||||
|
// break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,19 +334,19 @@ function navigateToDishes(item: any) {
|
|||||||
}
|
}
|
||||||
// 收藏菜品
|
// 收藏菜品
|
||||||
function handleDishCollectionClick(item: any) {
|
function handleDishCollectionClick(item: any) {
|
||||||
debouncedEmit(item.isCollect, item.id, CollectionType.DISH, ()=> {
|
debouncedEmit(item.isCollect, item.id, CollectionType.DISH, () => {
|
||||||
item.isCollect = !item.isCollect
|
item.isCollect = !item.isCollect
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 防抖处理函数
|
// 防抖处理函数
|
||||||
const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: CollectionType, callback: ()=> void) => {
|
const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: CollectionType, callback: () => void) => {
|
||||||
// 收藏接口
|
// 收藏接口
|
||||||
appCollectCollectPost({
|
appCollectCollectPost({
|
||||||
body: {
|
body: {
|
||||||
targetId: id,
|
targetId: id,
|
||||||
targetType: type
|
targetType: type
|
||||||
}
|
}
|
||||||
}).then(res=> {
|
}).then(res => {
|
||||||
callback()
|
callback()
|
||||||
})
|
})
|
||||||
}, {
|
}, {
|
||||||
@@ -298,204 +355,140 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view
|
<view class="home-page-root" :style="[
|
||||||
class="home-page-root"
|
{
|
||||||
:style="[
|
height: configStore.windowHeight + 'px',
|
||||||
{
|
},
|
||||||
height: configStore.windowHeight + 'px',
|
]">
|
||||||
},
|
<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>
|
||||||
<z-paging @onRefresh="onRefresh" ref="paging" v-model="dataList" :auto="false" @query="queryList" @scroll="onPageScroll" :refresher-enabled="true" :auto-show-back-to-top="false">
|
<status-bar />
|
||||||
<template #top>
|
<home-top-header :app-name="Config.appName" :location-text="userStore.userLocation.location"
|
||||||
<status-bar />
|
:cart-badge-total="cartBadgeTotal" :is-login="userStore.isLogin"
|
||||||
<!-- 设计稿:品牌行 + 右侧地址胶囊、消息/客服、购物车 -->
|
@click-location="navigateTo('/pages-user/pages/search-address/index')" @click-cart="goCart" />
|
||||||
<view class="home-top-header px-24rpx pt-12rpx pb-8rpx">
|
</template>
|
||||||
<view class="flex items-center justify-between gap-12rpx">
|
<view class="animate-in fade-in animate-ease-out animate-duration-300"
|
||||||
<view class="flex items-center gap-14rpx min-w-0 flex-1">
|
v-show="loading && featuredList.length === 0">
|
||||||
<image src="@img/logo.png" class="w-52rpx h-52rpx shrink-0" style="border-radius: 50%;"></image>
|
<home-skeleton />
|
||||||
<text class="text-32rpx lh-36rpx text-#1a1a1a font-bold tracking-tight shrink-0">{{ Config.appName }}</text>
|
</view>
|
||||||
</view>
|
<view class="px-24rpx pt-12rpx pb-8rpx">
|
||||||
<view class="flex items-center gap-10rpx shrink-0">
|
<search />
|
||||||
<view class="home-loc-pill" @click="navigateTo('/pages-user/pages/search-address/index')">
|
<msg-box />
|
||||||
<!-- <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">{{
|
<view class="mt-28rpx" v-if="appMerchantLabelList.length > 0">
|
||||||
userStore.userLocation.location || t('pages.home.default-location')
|
<class-bullet :categories="appMerchantLabelList" @itemClick="handleItemClick" />
|
||||||
}}</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>
|
|
||||||
<view
|
|
||||||
class="animate-in fade-in animate-ease-out animate-duration-300"
|
|
||||||
v-show="loading && featuredList.length === 0"
|
|
||||||
>
|
|
||||||
<home-skeleton />
|
|
||||||
</view>
|
</view>
|
||||||
<view class="px-24rpx pt-12rpx pb-8rpx">
|
</view>
|
||||||
<search />
|
<!-- 轮播图:/app/marketActivity/list -->
|
||||||
<!-- 分类标签(双行横滑) -->
|
<swiper v-if="swiperList.length > 0" class="card-swiper" :circular="swiperList.length > 1"
|
||||||
<view class="mt-28rpx" v-if="appMerchantLabelList.length > 0">
|
:autoplay="swiperList.length > 1">
|
||||||
<class-bullet :categories="appMerchantLabelList" @itemClick="handleItemClick" />
|
<swiper-item v-for="(item, sIdx) in swiperList" :key="item.id ?? sIdx" @click="handleClickSwiper(item)">
|
||||||
|
<image :src="item.activityImage" class="swiper-item-content w-full h-100%" mode="scaleToFill"></image>
|
||||||
|
</swiper-item>
|
||||||
|
</swiper>
|
||||||
|
|
||||||
|
<!-- 快捷入口(固定五项,跳转精选菜品专题页) -->
|
||||||
|
<tabs-type :current-id="currentCategory" class="mt-28rpx mb-24rpx home-tabs-quick"
|
||||||
|
@change-type="tabsTypeChange" />
|
||||||
|
|
||||||
|
<view class="home-promo-block px-24rpx">
|
||||||
|
<image :src="memberUpgradeBannerSrc" class="home-promo-banner-img" mode="widthFix" />
|
||||||
|
<view class="home-promo-actions">
|
||||||
|
<view class="home-promo-btn" @click="navigateTo('/pages-user/pages/member/index')">
|
||||||
|
<text class="home-promo-btn-text">{{ t('pages.home.open-member') }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="home-promo-btn" @click="navigateTo('/pages-user/pages/recharge/index')">
|
||||||
|
<text class="home-promo-btn-text">{{ t('pages.home.recharge-now') }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<swiper
|
<image :src="deliveryTimeBannerSrc" class="home-promo-banner-img" mode="widthFix" />
|
||||||
class="home-promo-swiper card-swiper"
|
</view>
|
||||||
:circular="true"
|
<!-- 精选商家和附近商家 -->
|
||||||
:autoplay="true"
|
<view class="animate-in fade-in animate-ease-in animate-duration-300" v-if="isShowMerchant">
|
||||||
previous-margin="48rpx"
|
<!-- Featured on ChefLink 精选商家(浅底 + 横向卡片,对齐设计稿) -->
|
||||||
next-margin="48rpx"
|
<view v-if="featuredList.length > 0" class="featured-merchants-section mt-36rpx px-24rpx pt-8rpx pb-16rpx">
|
||||||
>
|
<view class="mb-24rpx px-6rpx text-28rpx lh-36rpx text-#1a1a1a">
|
||||||
<swiper-item
|
{{ t('pages.home.featured-on') }}
|
||||||
v-for="(item, sIdx) in swiperList"
|
|
||||||
:key="item.id ?? sIdx"
|
|
||||||
@click="handleClickSwiper(item)"
|
|
||||||
>
|
|
||||||
<image
|
|
||||||
:src="item.activityImage"
|
|
||||||
class="swiper-item-content w-full h-100% rounded-32rpx bg-common"
|
|
||||||
></image>
|
|
||||||
</swiper-item>
|
|
||||||
</swiper>
|
|
||||||
|
|
||||||
<!-- 快捷入口(圆形分类) -->
|
|
||||||
<tabs-type @changeType="tabsTypeChange" v-if="appMerchantCategoryList.length > 0" :list="appMerchantCategoryList" :currentId="currentCategory" class="mt-28rpx home-tabs-quick" />
|
|
||||||
|
|
||||||
<!-- 筛选工具 -->
|
|
||||||
<!-- <filtrate-tool class="mt-32rpx" @togglePickup="togglePickup" @toggleDiscount="toggleDiscount" @toggleScore="toggleScore" @togglePrice="togglePrice" />-->
|
|
||||||
|
|
||||||
<view class="animate-in fade-in animate-ease-in animate-duration-300" v-if="isShowMerchant">
|
|
||||||
<!-- Featured on ChefLink 精选商家(浅底 + 横向卡片,对齐设计稿) -->
|
|
||||||
<view v-if="featuredList.length > 0" class="featured-merchants-section mt-36rpx px-24rpx pt-8rpx pb-16rpx">
|
|
||||||
<view class="mb-24rpx px-6rpx text-32rpx lh-44rpx text-#1a1a1a">
|
|
||||||
{{ t('pages.home.featured-on') }}
|
|
||||||
</view>
|
|
||||||
<featured-on :list="featuredList" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Nearby Merchants 附近商家 -->
|
|
||||||
<view v-if="nearbyList.length > 0" class="nearby-merchants-block mt-36rpx px-24rpx pb-16rpx">
|
|
||||||
<view class="mb-24rpx px-6rpx text-32rpx lh-44rpx text-#1a1a1a ">
|
|
||||||
{{ t('pages.home.nearby-merchants') }}
|
|
||||||
</view>
|
|
||||||
<nearby-merchants :list="nearbyList" />
|
|
||||||
</view>
|
</view>
|
||||||
|
<featured-on :list="featuredList" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- List 精选菜品瀑布流(浅底 + 白卡片 + 阴影,结构对齐设计稿) -->
|
<!-- Nearby Merchants 附近商家 -->
|
||||||
<view class="featured-dishes-section mt-36rpx px-24rpx pb-40rpx">
|
<view v-if="nearbyList.length > 0" class="nearby-merchants-block mt-36rpx px-24rpx pb-16rpx">
|
||||||
<view class="mb-24rpx px-6rpx text-32rpx lh-44rpx text-#1a1a1a "
|
<view class="mb-24rpx px-6rpx text-28rpx lh-36rpx text-#1a1a1a ">
|
||||||
>{{ t('pages.home.featured-dishes') }}</view>
|
{{ t('pages.home.nearby-merchants') }}
|
||||||
<view class="waterfall-row flex gap-16rpx items-start">
|
</view>
|
||||||
<view
|
<nearby-merchants :list="nearbyList" />
|
||||||
v-for="(col, colIndex) in featuredDishColumns"
|
</view>
|
||||||
:key="colIndex"
|
</view>
|
||||||
class="waterfall-col flex-1 min-w-0 flex flex-col gap-16rpx"
|
|
||||||
>
|
<!-- List 精选菜品瀑布流(浅底 + 白卡片 + 阴影,结构对齐设计稿) -->
|
||||||
<view
|
<view class="featured-dishes-section mt-36rpx px-24rpx pb-40rpx">
|
||||||
v-for="item in col"
|
<view class="mb-24rpx px-6rpx text-28rpx lh-36rpx text-#1a1a1a ">{{ t('pages.home.featured-dishes') }}</view>
|
||||||
:key="item.id || String(item.merchantId) + '-' + item.dishName"
|
<view class="waterfall-row flex gap-16rpx items-start">
|
||||||
@click="navigateToDishes(item)"
|
<view v-for="(col, colIndex) in featuredDishColumns" :key="colIndex"
|
||||||
class="featured-dish-card w-full"
|
class="waterfall-col flex-1 min-w-0 flex flex-col gap-16rpx">
|
||||||
>
|
<view v-for="item in col" :key="item.id || String(item.merchantId) + '-' + item.dishName"
|
||||||
<view class="featured-dish-image">
|
@click="navigateToDishes(item)" class="featured-dish-card w-full">
|
||||||
<view v-if="item.isNew == 1" class="dish-new-ribbon">
|
<view class="featured-dish-image">
|
||||||
<text class="dish-new-ribbon__text">NEW</text>
|
<view v-if="item.isNew == 1" class="dish-new-ribbon">
|
||||||
</view>
|
<text class="dish-new-ribbon__text">NEW</text>
|
||||||
<image
|
|
||||||
:src="item?.dishImage?.split(',')[0]"
|
|
||||||
mode="aspectFill"
|
|
||||||
class="featured-dish-img"
|
|
||||||
/>
|
|
||||||
<view
|
|
||||||
v-if="isSoldOutStock(item?.stock)"
|
|
||||||
class="featured-dish-sold-dim"
|
|
||||||
/>
|
|
||||||
<image
|
|
||||||
v-if="isSoldOutStock(item?.stock)"
|
|
||||||
src="/static/app/images/SoldOut.png"
|
|
||||||
mode="aspectFill"
|
|
||||||
class="featured-dish-sold-overlay"
|
|
||||||
/>
|
|
||||||
<view
|
|
||||||
@click.stop="handleDishCollectionClick(item)"
|
|
||||||
class="featured-dish-collect w-56rpx h-56rpx absolute z-4 top-12rpx right-12rpx center"
|
|
||||||
>
|
|
||||||
<image
|
|
||||||
v-if="!item.isCollect"
|
|
||||||
src="@img-store/1334.png"
|
|
||||||
mode="aspectFill"
|
|
||||||
class="w-44rpx h-44rpx featured-dish-collect-icon"
|
|
||||||
/>
|
|
||||||
<image
|
|
||||||
v-else
|
|
||||||
src="@img-store/1337.png"
|
|
||||||
mode="aspectFill"
|
|
||||||
class="w-44rpx h-44rpx featured-dish-collect-icon"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="featured-dish-body">
|
<image :src="item?.dishImage?.split(',')[0]" mode="aspectFill" class="featured-dish-img" />
|
||||||
<view class="featured-dish-meta flex items-start justify-between gap-12rpx mb-14rpx">
|
<view v-if="isSoldOutStock(item?.stock)" class="featured-dish-sold-dim" />
|
||||||
<view class="min-w-0 flex-1">
|
<image v-if="isSoldOutStock(item?.stock)" src="/static/app/images/SoldOut.png" mode="aspectFill"
|
||||||
<text class="featured-dish-price">US${{ getFeaturedDishDisplayPrice(item) }}</text>
|
class="featured-dish-sold-overlay" />
|
||||||
<!-- <text
|
<view @click.stop="handleDishCollectionClick(item)"
|
||||||
|
class="featured-dish-collect w-56rpx h-56rpx absolute z-4 top-12rpx right-12rpx center">
|
||||||
|
<image v-if="!item.isCollect" src="@img-store/1334.png" mode="aspectFill"
|
||||||
|
class="w-44rpx h-44rpx featured-dish-collect-icon" />
|
||||||
|
<image v-else src="@img-store/1337.png" mode="aspectFill"
|
||||||
|
class="w-44rpx h-44rpx featured-dish-collect-icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="featured-dish-body">
|
||||||
|
<view class="featured-dish-meta flex items-start justify-between gap-12rpx mb-14rpx">
|
||||||
|
<view class="min-w-0 flex-1">
|
||||||
|
<text class="featured-dish-price">US${{ item?.originalPrice }}</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"
|
||||||
>US${{ item?.originalPrice }}</text> -->
|
>US${{ item?.originalPrice }}</text> -->
|
||||||
</view>
|
|
||||||
<text class="featured-dish-sales shrink-0">{{ t('pages-store.store.sales') }}: {{ formatSalesCount(item?.salesCount) }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="featured-dish-title line-clamp-2 mb-16rpx">
|
<text class="featured-dish-sales shrink-0">{{ t('pages-store.store.sales') }}: {{
|
||||||
{{ item?.dishName }}
|
formatSalesCount(item?.salesCount) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="featured-dish-title line-clamp-2 mb-16rpx">
|
||||||
|
{{ item?.dishName }}
|
||||||
|
</view>
|
||||||
|
<view class="flex items-center justify-between gap-12rpx">
|
||||||
|
<view v-if="Number(item?.memberPrice) > 0" class="featured-dish-member shrink min-w-0">
|
||||||
|
<text class="featured-dish-member-inner">{{ t('pages-store.store.members') }}: US${{
|
||||||
|
item?.memberPrice
|
||||||
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex items-center justify-between gap-12rpx">
|
<view v-else class="flex-1 min-w-0"></view>
|
||||||
<view
|
<view class="featured-dish-add shrink-0">
|
||||||
v-if="Number(item?.memberPrice) > 0"
|
<image src="/static/app/images/add_cart.png" class="featured-dish-add__icon" />
|
||||||
class="featured-dish-member shrink min-w-0"
|
|
||||||
>
|
|
||||||
<text class="featured-dish-member-inner">{{ t('pages-store.store.members') }}: US${{ item?.memberPrice }}</text>
|
|
||||||
</view>
|
|
||||||
<view v-else class="flex-1 min-w-0"></view>
|
|
||||||
<view class="featured-dish-add center shrink-0">
|
|
||||||
<image
|
|
||||||
src="@img/chef/1285.png"
|
|
||||||
class="w-28rpx h-28rpx"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-if="getDishPromoLabel(item as Record<string, unknown>)"
|
|
||||||
class="featured-dish-promo mt-16rpx"
|
|
||||||
>
|
|
||||||
<text class="featured-dish-promo-text">{{ getDishPromoLabel(item as Record<string, unknown>) }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="getDishPromoLabel(item as Record<string, unknown>)" class="featured-dish-promo mt-16rpx">
|
||||||
|
<text class="featured-dish-promo-text">{{ getDishPromoLabel(item as Record<string, unknown>) }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<template #bottom>
|
</view>
|
||||||
<view class="h-50px"></view>
|
<template #bottom>
|
||||||
<view :style="[configStore.iosSafeBottomPlaceholder]"></view>
|
<view class="h-50px"></view>
|
||||||
</template>
|
<view :style="[configStore.iosSafeBottomPlaceholder]"></view>
|
||||||
</z-paging>
|
</template>
|
||||||
|
</z-paging>
|
||||||
<!-- 回到顶部(购物车已并入顶栏,不再使用底部浮条) -->
|
<!-- 回到顶部(购物车已并入顶栏,不再使用底部浮条) -->
|
||||||
<view v-if="showBackToTop" @click="scrollToTop" class="home-back-top fixed left-30rpx w-88rpx h-88rpx bg-#14181B rounded-50% center shadow-lg">
|
<view v-if="showBackToTop" @click="scrollToTop"
|
||||||
|
class="home-back-top fixed left-30rpx w-88rpx h-88rpx bg-#14181B rounded-50% center shadow-lg">
|
||||||
<image src="@img/chef/119.png" class="w-40rpx h-40rpx shrink-0 rotate-180"></image>
|
<image src="@img/chef/119.png" class="w-40rpx h-40rpx shrink-0 rotate-180"></image>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -506,72 +499,55 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-promo-swiper {
|
|
||||||
margin-top: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-tabs-quick {
|
.home-tabs-quick {
|
||||||
padding-left: 8rpx;
|
padding-left: 8rpx;
|
||||||
padding-right: 8rpx;
|
padding-right: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-promo-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-promo-banner-img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-promo-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24rpx;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-promo-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #21ae39;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-promo-btn-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.nearby-merchants-block {
|
.nearby-merchants-block {
|
||||||
background: #f2f2f2;
|
background: #f2f2f2;
|
||||||
}
|
}
|
||||||
@@ -582,19 +558,15 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-swiper {
|
.card-swiper {
|
||||||
height: 400rpx;
|
height: 420rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swiper-item-content {
|
.swiper-item-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transform: scale(0.94);
|
|
||||||
border-radius: 32rpx;
|
|
||||||
box-shadow: 0 8rpx 28rpx rgba(0, 0, 0, 0.08);
|
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
.swiper-item-active .swiper-item-content {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 精选商家 / 精选菜品 区块(与页面背景统一) */
|
/* 精选商家 / 精选菜品 区块(与页面背景统一) */
|
||||||
.featured-merchants-section,
|
.featured-merchants-section,
|
||||||
@@ -633,16 +605,16 @@ 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(20, 24, 27, 0.42);
|
background: rgba(201, 197, 197, 0.6);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-dish-sold-overlay {
|
.featured-dish-sold-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
right: 0;
|
||||||
top: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 140rpx;
|
||||||
height: 100%;
|
height: 140rpx;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -711,11 +683,13 @@ const debouncedEmit = debounce(1300, (isCollected: boolean, id: string, type: Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
.featured-dish-add {
|
.featured-dish-add {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-dish-add__icon {
|
||||||
width: 56rpx;
|
width: 56rpx;
|
||||||
height: 56rpx;
|
height: 56rpx;
|
||||||
border-radius: 50%;
|
display: block;
|
||||||
background: #14181b;
|
|
||||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 可选营销条(淡红底 + 文案) */
|
/* 可选营销条(淡红底 + 文案) */
|
||||||
|
|||||||
@@ -127,8 +127,10 @@ const isUserMember = computed(()=> {
|
|||||||
function handleTabClick(item: Tab) {
|
function handleTabClick(item: Tab) {
|
||||||
switch (item.code) {
|
switch (item.code) {
|
||||||
case "inviteFriends": {
|
case "inviteFriends": {
|
||||||
// R.both(checkNeedLogin, R.pipe(() => emits("inviteUser"), R.T))(item.isLogin)
|
R.both(
|
||||||
emits("inviteUser");
|
checkNeedLogin,
|
||||||
|
R.pipe(() => emits("inviteUser"), R.T)
|
||||||
|
)(item.isLogin);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "support": {
|
case "support": {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
appMerchantOrderOrderListPost,
|
appMerchantOrderOrderListPost,
|
||||||
|
appMerchantOrderCancelOrderPost,
|
||||||
type MerchantOrderVo
|
type MerchantOrderVo
|
||||||
} from "@/service";
|
} from "@/service";
|
||||||
|
import CancelOrder from '@/pages-store/pages/order/components/cancel-order.vue'
|
||||||
import {callPhone} from "@/utils/utils";
|
import {callPhone} from "@/utils/utils";
|
||||||
import {OrderCancelStatus, OrderStatus} from "@/constant/enums";
|
import {OrderCancelStatus, OrderStatus} from "@/constant/enums";
|
||||||
import {useUserStore} from "@/store";
|
import {useUserStore} from "@/store";
|
||||||
@@ -42,16 +44,6 @@ const {paging, dataList, loading, queryList, firstLoaded} = usePage<MerchantOrde
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function fillI18nParams(template: string, params: Record<string, string | number>) {
|
|
||||||
let text = template
|
|
||||||
Object.keys(params).forEach((key) => {
|
|
||||||
const value = String(params[key] ?? '')
|
|
||||||
text = text.replace(new RegExp(`\\{${key}\\}`, 'g'), value)
|
|
||||||
text = text.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value)
|
|
||||||
})
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTimestamp(input: unknown): number | null {
|
function normalizeTimestamp(input: unknown): number | null {
|
||||||
if (input == null || input === '') return null
|
if (input == null || input === '') return null
|
||||||
|
|
||||||
@@ -112,9 +104,7 @@ function getTotalDishCount(item: MerchantOrderVo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTotalDishCountText(item: MerchantOrderVo) {
|
function getTotalDishCountText(item: MerchantOrderVo) {
|
||||||
return fillI18nParams(t('pages.order.totalItemCount'), {
|
return `${t('pages.order.totalItemCountPrefix')}${getTotalDishCount(item)}${t('pages.order.totalItemCountSuffix')}`
|
||||||
count: getTotalDishCount(item),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameCode(value: unknown, code: unknown) {
|
function isSameCode(value: unknown, code: unknown) {
|
||||||
@@ -153,9 +143,32 @@ function handleClick(item: MerchantOrderVo) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelOrderRef = ref<InstanceType<typeof CancelOrder>>()
|
||||||
|
const pendingCancelOrder = ref<MerchantOrderVo | null>(null)
|
||||||
|
|
||||||
function handleCancelClick(item: MerchantOrderVo) {
|
function handleCancelClick(item: MerchantOrderVo) {
|
||||||
uni.navigateTo({
|
pendingCancelOrder.value = item
|
||||||
url: '/pages-store/pages/order/index?id=' + item.id
|
cancelOrderRef.value?.onOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCancel(reason: string) {
|
||||||
|
const order = pendingCancelOrder.value
|
||||||
|
if (!order?.id) return
|
||||||
|
appMerchantOrderCancelOrderPost({
|
||||||
|
body: {
|
||||||
|
orderId: order.id,
|
||||||
|
cancelReason: reason,
|
||||||
|
},
|
||||||
|
}).then(() => {
|
||||||
|
cancelOrderRef.value?.onClose()
|
||||||
|
pendingCancelOrder.value = null
|
||||||
|
uni.showToast({
|
||||||
|
title: t('pages-store.order.cancelSuccess'),
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
paging.value?.refresh()
|
||||||
|
}, 500)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +320,7 @@ defineExpose({
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</z-paging>
|
</z-paging>
|
||||||
|
<cancel-order ref="cancelOrderRef" @confirm="confirmCancel" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ const cover = computed(() => {
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="search-dish-item__add center" @click.stop="goDetail">
|
<view class="search-dish-item__add shrink-0" @click.stop="goDetail">
|
||||||
<image src="@img/chef/1285.png" class="search-dish-item__add-icon" mode="aspectFit" />
|
<image src="/static/app/images/add_cart.png" class="search-dish-item__add-icon" mode="aspectFit" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -180,17 +180,13 @@ const cover = computed(() => {
|
|||||||
|
|
||||||
.search-dish-item__add {
|
.search-dish-item__add {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
width: 64rpx;
|
|
||||||
height: 64rpx;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 50%;
|
|
||||||
background: #f2f2f2;
|
|
||||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-dish-item__add-icon {
|
.search-dish-item__add-icon {
|
||||||
width: 30rpx;
|
width: 64rpx;
|
||||||
height: 30rpx;
|
height: 64rpx;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-dish-item__img-wrap {
|
.search-dish-item__img-wrap {
|
||||||
|
|||||||
@@ -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 || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-ignore
|
||||||
|
import request from '@/http/vue-query';
|
||||||
|
import type { CustomRequestOptions } from '@/http/types';
|
||||||
|
|
||||||
|
export interface EnergyMealListQueryBo {
|
||||||
|
merchantId?: number | string;
|
||||||
|
mealName?: string;
|
||||||
|
merchantName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnergyMealItemVo {
|
||||||
|
id?: number | string;
|
||||||
|
energyMealId?: number | string;
|
||||||
|
dishId?: number | string;
|
||||||
|
quantity?: number;
|
||||||
|
sort?: number;
|
||||||
|
delFlag?: number;
|
||||||
|
merchantDishVo?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnergyMealVo {
|
||||||
|
id?: number | string;
|
||||||
|
merchantId?: number | string;
|
||||||
|
mealName?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
sort?: number;
|
||||||
|
delFlag?: number;
|
||||||
|
remark?: string;
|
||||||
|
merchant?: {
|
||||||
|
id?: number | string;
|
||||||
|
merchantName?: string;
|
||||||
|
logo?: string;
|
||||||
|
merchantAddress?: string;
|
||||||
|
};
|
||||||
|
itemList?: EnergyMealItemVo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnergyMealAddCartBo {
|
||||||
|
energyMealId: number | string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 能量餐分页列表 POST /app/energyMeal/list */
|
||||||
|
export async function appEnergyMealListPost({
|
||||||
|
params,
|
||||||
|
body,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
params: { pageNum: number; pageSize: number };
|
||||||
|
body?: EnergyMealListQueryBo;
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
return request<{ rows?: EnergyMealVo[]; total?: number }>(
|
||||||
|
'/app/energyMeal/list',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
data: body ?? {},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 能量餐一键加入购物车 POST /app/energyMeal/addCart */
|
||||||
|
export async function appEnergyMealAddCartPost({
|
||||||
|
body,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
body: EnergyMealAddCartBo;
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
return request<{ data?: number[] }>('/app/energyMeal/addCart', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-ignore
|
||||||
|
import request from '@/http/vue-query';
|
||||||
|
import type { CustomRequestOptions } from '@/http/types';
|
||||||
|
|
||||||
|
export interface GroupMealMerchantVo {
|
||||||
|
id?: number | string;
|
||||||
|
merchantName?: string;
|
||||||
|
logo?: string;
|
||||||
|
merchantAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMealReservationVo {
|
||||||
|
id?: number | string;
|
||||||
|
merchantId?: number | string;
|
||||||
|
userId?: number | string;
|
||||||
|
peopleCount?: number;
|
||||||
|
perCapitaPrice?: number;
|
||||||
|
scene?: string;
|
||||||
|
contactName?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
expectedTime?: number;
|
||||||
|
status?: number;
|
||||||
|
handleRemark?: string;
|
||||||
|
cancelReason?: string;
|
||||||
|
remark?: string;
|
||||||
|
merchant?: GroupMealMerchantVo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMealReservationSubmitBo {
|
||||||
|
merchantId: number | string;
|
||||||
|
peopleCount: number;
|
||||||
|
perCapitaPrice: number;
|
||||||
|
scene: string;
|
||||||
|
contactName: string;
|
||||||
|
contactPhone: string;
|
||||||
|
expectedTime: number;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMealReservationMyListQueryBo {
|
||||||
|
merchantId?: number | string;
|
||||||
|
status?: number;
|
||||||
|
expectedBeginTime?: number;
|
||||||
|
expectedEndTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMealReservationCancelBo {
|
||||||
|
id: number | string;
|
||||||
|
cancelReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交团餐预定 POST /app/groupMealReservation/submit */
|
||||||
|
export async function appGroupMealReservationSubmitPost({
|
||||||
|
body,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
body: GroupMealReservationSubmitBo;
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
return request<{ data?: number | string }>('/app/groupMealReservation/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我的团餐预定列表 POST /app/groupMealReservation/myList */
|
||||||
|
export async function appGroupMealReservationMyListPost({
|
||||||
|
params,
|
||||||
|
body,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
params: { pageNum: number; pageSize: number };
|
||||||
|
body?: GroupMealReservationMyListQueryBo;
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
return request<{ rows?: GroupMealReservationVo[]; total?: number }>(
|
||||||
|
'/app/groupMealReservation/myList',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
data: body ?? {},
|
||||||
|
...(options || {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我的团餐预定详情 GET /app/groupMealReservation/{id} */
|
||||||
|
export async function appGroupMealReservationIdGet({
|
||||||
|
id,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
id: number | string;
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
return request<{ data?: GroupMealReservationVo }>(
|
||||||
|
`/app/groupMealReservation/${id}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
...(options || {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消我的团餐预定 POST /app/groupMealReservation/cancel */
|
||||||
|
export async function appGroupMealReservationCancelPost({
|
||||||
|
body,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
body: GroupMealReservationCancelBo;
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
}) {
|
||||||
|
return request<Record<string, unknown>>('/app/groupMealReservation/cancel', {
|
||||||
|
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';
|
||||||
@@ -42,3 +44,5 @@ export * from './automaticCookingMachine';
|
|||||||
export * from './userCoupon';
|
export * from './userCoupon';
|
||||||
export * from './marketingActivity';
|
export * from './marketingActivity';
|
||||||
export * from './marketActivity';
|
export * from './marketActivity';
|
||||||
|
export * from './energyMeal';
|
||||||
|
export * from './groupMealReservation';
|
||||||
|
|||||||
@@ -16,3 +16,15 @@ export async function appMarketActivityListPost({
|
|||||||
...(options || {}),
|
...(options || {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 充值赠送活动 GET /app/marketActivity/rechargePromotion */
|
||||||
|
export async function appMarketActivityRechargePromotionGet({
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
options?: CustomRequestOptions;
|
||||||
|
} = {}) {
|
||||||
|
return request<API.R>('/app/marketActivity/rechargePromotion', {
|
||||||
|
method: 'GET',
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,17 +112,38 @@ export interface CalculatePriceCartBatchBo {
|
|||||||
*/
|
*/
|
||||||
receiveMethod?: number;
|
receiveMethod?: number;
|
||||||
/**
|
/**
|
||||||
* 小费比例
|
* 配送单小费(单商户算价/创单,固定金额)
|
||||||
*/
|
*/
|
||||||
tipDiscount?: number;
|
tip?: number;
|
||||||
|
/**
|
||||||
|
* 交付时间类型(1-立即交付 2-预约交付)
|
||||||
|
*/
|
||||||
|
deliveryType?: number;
|
||||||
|
/**
|
||||||
|
* 预约开始/结束时间(13 位毫秒时间戳,deliveryType=2 时)
|
||||||
|
*/
|
||||||
|
startScheduledTime?: number;
|
||||||
|
endScheduledTime?: number;
|
||||||
/**
|
/**
|
||||||
* 商户优惠券映射 { merchantId: couponId }
|
* 商户优惠券映射 { merchantId: couponId }
|
||||||
*/
|
*/
|
||||||
merchantCouponMap?: Record<string, number>;
|
merchantCouponMap?: Record<string, number>;
|
||||||
/**
|
/**
|
||||||
* 商户小费映射 { merchantId: tipAmount } (小费金额,单位:美元)
|
* 按配送日期的小费映射 { yyyy-MM-dd: tipAmount }
|
||||||
|
*/
|
||||||
|
deliveryDateTipMap?: Record<string, number>;
|
||||||
|
/**
|
||||||
|
* @deprecated 使用 deliveryDateTipMap
|
||||||
*/
|
*/
|
||||||
merchantTipMap?: Record<string, number>;
|
merchantTipMap?: Record<string, number>;
|
||||||
|
/**
|
||||||
|
* 商户预约开始时间映射 { merchantId: timestamp }
|
||||||
|
*/
|
||||||
|
merchantStartScheduledTimeMap?: Record<string, number | string>;
|
||||||
|
/**
|
||||||
|
* 商户预约结束时间映射 { merchantId: timestamp }
|
||||||
|
*/
|
||||||
|
merchantEndScheduledTimeMap?: Record<string, number | string>;
|
||||||
[property: string]: any;
|
[property: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,9 +292,13 @@ export interface CreateOrderCartBatchBo {
|
|||||||
*/
|
*/
|
||||||
endScheduledTime?: number | string;
|
endScheduledTime?: number | string;
|
||||||
/**
|
/**
|
||||||
* 小费比例
|
* 配送单小费(单商户算价/创单,固定金额)
|
||||||
*/
|
*/
|
||||||
tipDiscount?: number;
|
tip?: number;
|
||||||
|
/**
|
||||||
|
* 按配送日期的小费映射 { yyyy-MM-dd: tipAmount }
|
||||||
|
*/
|
||||||
|
deliveryDateTipMap?: Record<string, number>;
|
||||||
/**
|
/**
|
||||||
* 是否需要餐具(1-是 2-否)
|
* 是否需要餐具(1-是 2-否)
|
||||||
*/
|
*/
|
||||||
@@ -283,9 +308,17 @@ export interface CreateOrderCartBatchBo {
|
|||||||
*/
|
*/
|
||||||
merchantCouponMap?: Record<string, number>;
|
merchantCouponMap?: Record<string, number>;
|
||||||
/**
|
/**
|
||||||
* 商户小费映射 { merchantId: tipAmount } (小费金额,单位:美元)
|
* @deprecated 使用 deliveryDateTipMap
|
||||||
*/
|
*/
|
||||||
merchantTipMap?: Record<string, number>;
|
merchantTipMap?: Record<string, number>;
|
||||||
|
/**
|
||||||
|
* 商户预约开始时间映射 { merchantId: timestamp }
|
||||||
|
*/
|
||||||
|
merchantStartScheduledTimeMap?: Record<string, number | string>;
|
||||||
|
/**
|
||||||
|
* 商户预约结束时间映射 { merchantId: timestamp }
|
||||||
|
*/
|
||||||
|
merchantEndScheduledTimeMap?: Record<string, number | string>;
|
||||||
[property: string]: any;
|
[property: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,13 +500,19 @@ export async function appMerchantOrderPayOrderBatchPost({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 提交 Zip 支付凭证 POST /app/merchantOrder/zipPayVoucher */
|
/** 提交 Zip 支付凭证 POST /app/merchantOrder/zipPayVoucher(单订单兼容) */
|
||||||
|
export interface ZipPayVoucherBo {
|
||||||
|
orderId: string | number
|
||||||
|
zipPayVoucher: string
|
||||||
|
paymentAmount?: number
|
||||||
|
}
|
||||||
|
|
||||||
export async function appMerchantOrderZipPayVoucherPost({
|
export async function appMerchantOrderZipPayVoucherPost({
|
||||||
body,
|
body,
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
body: { orderId: number; zipPayVoucher: string };
|
body: ZipPayVoucherBo
|
||||||
options?: CustomRequestOptions;
|
options?: CustomRequestOptions
|
||||||
}) {
|
}) {
|
||||||
return request<API.R>('/app/merchantOrder/zipPayVoucher', {
|
return request<API.R>('/app/merchantOrder/zipPayVoucher', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -485,6 +524,30 @@ export async function appMerchantOrderZipPayVoucherPost({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量提交 Zip 支付凭证 POST /app/merchantOrder/zipPayVoucherBatch */
|
||||||
|
export interface ZipPayVoucherBatchBo {
|
||||||
|
orderIds: Array<string | number>
|
||||||
|
zipPayVoucher: string
|
||||||
|
paymentAmount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appMerchantOrderZipPayVoucherBatchPost({
|
||||||
|
body,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
body: ZipPayVoucherBatchBo
|
||||||
|
options?: CustomRequestOptions
|
||||||
|
}) {
|
||||||
|
return request<API.R>('/app/merchantOrder/zipPayVoucherBatch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PayOrderBatchBo - 批量订单支付参数
|
* PayOrderBatchBo - 批量订单支付参数
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3754,6 +3754,8 @@
|
|||||||
'deliveryService'?: number;
|
'deliveryService'?: number;
|
||||||
/** 配送时长(如"30分钟") */
|
/** 配送时长(如"30分钟") */
|
||||||
'deliveryTime'?: string;
|
'deliveryTime'?: string;
|
||||||
|
/** 可配送星期(1-7逗号分隔,1=周一 … 6=周六,7=周日) */
|
||||||
|
'deliveryScheduleTimes'?: string;
|
||||||
/** 配送费 */
|
/** 配送费 */
|
||||||
'deliveryFee'?: number;
|
'deliveryFee'?: number;
|
||||||
/** 店铺图片URL(多张逗号分隔) */
|
/** 店铺图片URL(多张逗号分隔) */
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 553 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 439 B |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 839 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 802 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',
|
||||||
@@ -34,7 +35,7 @@ export const useUserStore = defineStore(
|
|||||||
const address = ref(null)
|
const address = ref(null)
|
||||||
|
|
||||||
// 预约时间
|
// 预约时间
|
||||||
const appointmentTime = ref('')
|
const appointmentTime = ref<any>(null)
|
||||||
|
|
||||||
// 首页展示的预约时间
|
// 首页展示的预约时间
|
||||||
const appointmentTimeShow = computed(()=> {
|
const appointmentTimeShow = computed(()=> {
|
||||||
@@ -76,7 +77,6 @@ export const useUserStore = defineStore(
|
|||||||
function getAppointmentTime() {
|
function getAppointmentTime() {
|
||||||
if (!isLogin.value) return
|
if (!isLogin.value) return
|
||||||
appAppointmentTimeQueryAppointmentTimePost({}).then(res => {
|
appAppointmentTimeQueryAppointmentTimePost({}).then(res => {
|
||||||
console.log('查询用户的预约时间', res)
|
|
||||||
appointmentTime.value = res.data || ''
|
appointmentTime.value = res.data || ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -173,9 +173,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,
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* 店铺配送日 deliveryScheduleTimes(接口约定)
|
||||||
|
* 1=周一 … 6=周六,7=周日
|
||||||
|
* 示例:"1,4" 周一、周四;"1,4,7" 含周日
|
||||||
|
*/
|
||||||
|
const API_WEEKDAY_I18N_KEYS: Record<number, string> = {
|
||||||
|
1: "monday",
|
||||||
|
2: "tuesday",
|
||||||
|
3: "wednesday",
|
||||||
|
4: "thursday",
|
||||||
|
5: "friday",
|
||||||
|
6: "saturday",
|
||||||
|
7: "sunday",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 解析接口原始值为 API 星期数字 1–7 */
|
||||||
|
export function parseApiWeekdayNumbers(raw?: string | null): number[] {
|
||||||
|
if (!raw || typeof raw !== "string") return [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const result: number[] = [];
|
||||||
|
raw.split(",").forEach((part) => {
|
||||||
|
const n = Number(part.trim());
|
||||||
|
if (Number.isInteger(n) && n >= 1 && n <= 7 && !seen.has(n)) {
|
||||||
|
seen.add(n);
|
||||||
|
result.push(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 星期 → Date.getDay()(0=周日 … 6=周六) */
|
||||||
|
export function apiWeekdayToJsDay(apiDay: number): number | null {
|
||||||
|
if (apiDay >= 1 && apiDay <= 6) return apiDay;
|
||||||
|
if (apiDay === 7) return 0;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析为内部使用的 JS 星期(供日历可选判断) */
|
||||||
|
export function parseDeliveryScheduleTimes(
|
||||||
|
raw?: string | null
|
||||||
|
): number[] {
|
||||||
|
return parseApiWeekdayNumbers(raw)
|
||||||
|
.map(apiWeekdayToJsDay)
|
||||||
|
.filter((n): n is number => n !== null)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDeliveryScheduleDay(
|
||||||
|
date: Date,
|
||||||
|
schedule?: string | null | number[]
|
||||||
|
): boolean {
|
||||||
|
const allowed = Array.isArray(schedule)
|
||||||
|
? schedule
|
||||||
|
: parseDeliveryScheduleTimes(schedule);
|
||||||
|
if (!allowed.length) return true;
|
||||||
|
return allowed.includes(date.getDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MerchantAppointmentSlot = {
|
||||||
|
date?: Date | string;
|
||||||
|
timeSlot?: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 从指定日期起,找最近一天符合配送日规则的可配送日期 */
|
||||||
|
export function findNearestDeliveryScheduleDate(
|
||||||
|
deliveryScheduleTimes?: string | null,
|
||||||
|
fromDate: Date = new Date(),
|
||||||
|
maxDays = 30
|
||||||
|
): Date | null {
|
||||||
|
const base = new Date(fromDate);
|
||||||
|
base.setHours(0, 0, 0, 0);
|
||||||
|
for (let i = 0; i <= maxDays; i++) {
|
||||||
|
const d = new Date(base);
|
||||||
|
d.setDate(base.getDate() + i);
|
||||||
|
if (isDeliveryScheduleDay(d, deliveryScheduleTimes)) {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按自然日生成默认预约时段(全天) */
|
||||||
|
export function buildDayAppointmentSlot(date: Date): MerchantAppointmentSlot {
|
||||||
|
const start = new Date(date);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(date);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
timeSlot: "",
|
||||||
|
startTime: start.getTime(),
|
||||||
|
endTime: end.getTime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化配送日列表,用于店铺/商品详情展示 */
|
||||||
|
export function formatDeliveryScheduleDays(
|
||||||
|
raw: string | undefined | null,
|
||||||
|
weekdayLabel: (key: string) => string
|
||||||
|
): string {
|
||||||
|
const apiDays = parseApiWeekdayNumbers(raw);
|
||||||
|
if (!apiDays.length) return "";
|
||||||
|
return apiDays
|
||||||
|
.map((d) => {
|
||||||
|
const key = API_WEEKDAY_I18N_KEYS[d];
|
||||||
|
return key
|
||||||
|
? weekdayLabel(`pages-store.store.weekdays.${key}`)
|
||||||
|
: "";
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("、");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReservationTimeUrl(options: {
|
||||||
|
merchantId?: string | number;
|
||||||
|
deliveryScheduleTimes?: string;
|
||||||
|
businessHours?: string;
|
||||||
|
}): string {
|
||||||
|
const params: string[] = [];
|
||||||
|
if (options.merchantId != null && options.merchantId !== "") {
|
||||||
|
params.push(`merchantId=${encodeURIComponent(String(options.merchantId))}`);
|
||||||
|
}
|
||||||
|
if (options.deliveryScheduleTimes) {
|
||||||
|
params.push(
|
||||||
|
`deliveryScheduleTimes=${encodeURIComponent(options.deliveryScheduleTimes)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.businessHours) {
|
||||||
|
params.push(
|
||||||
|
`storeBusinessHours=${encodeURIComponent(options.businessHours)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const qs = params.length ? `?${params.join("&")}` : "";
|
||||||
|
return `/pages/address/reservation-time${qs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHECKOUT_MERCHANT_APPOINTMENTS_KEY =
|
||||||
|
"checkout_merchant_appointments";
|
||||||
|
|
||||||
|
export function saveCheckoutMerchantAppointments(
|
||||||
|
map: Record<string, MerchantAppointmentSlot>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(
|
||||||
|
CHECKOUT_MERCHANT_APPOINTMENTS_KEY,
|
||||||
|
JSON.stringify(map)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCheckoutMerchantAppointments(): Record<
|
||||||
|
string,
|
||||||
|
MerchantAppointmentSlot
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const raw = uni.getStorageSync(CHECKOUT_MERCHANT_APPOINTMENTS_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(String(raw));
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAppointmentPillLabel(
|
||||||
|
slot: MerchantAppointmentSlot | undefined,
|
||||||
|
fallback: string
|
||||||
|
): string {
|
||||||
|
if (!slot?.startTime) return fallback;
|
||||||
|
const start = new Date(Number(slot.startTime));
|
||||||
|
if (Number.isNaN(start.getTime())) return fallback;
|
||||||
|
const m = `${String(start.getMonth() + 1).padStart(2, "0")}-${String(start.getDate()).padStart(2, "0")}`;
|
||||||
|
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
return `${weekdays[start.getDay()]}, ${m.replace("-", "/")} >`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/** 精选菜品列表项展示工具(与首页瀑布流一致) */
|
||||||
|
|
||||||
|
export function getFeaturedDishImage(item: Record<string, unknown>): string {
|
||||||
|
const raw = item.dishImageUrl ?? item.dishImage
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return ''
|
||||||
|
return raw.split(',')[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFeaturedDishSoldOut(stockLike: unknown): boolean {
|
||||||
|
const n = Number(stockLike)
|
||||||
|
return !Number.isNaN(n) && n <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeaturedDishPromoLabel(item: Record<string, unknown>): string {
|
||||||
|
const raw = item.marketingLabel ?? item.hotSaleTag ?? item.rankTag ?? item.promotionLabel
|
||||||
|
return typeof raw === 'string' && raw.trim() ? raw.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 featuredDishList 分页响应(兼容 rows 在根或 data 下) */
|
||||||
|
export function parseFeaturedDishListRes(res: unknown): {
|
||||||
|
rows: Record<string, unknown>[]
|
||||||
|
total: number
|
||||||
|
} {
|
||||||
|
if (!res || typeof res !== 'object') {
|
||||||
|
return { rows: [], total: 0 }
|
||||||
|
}
|
||||||
|
const r = res as Record<string, unknown>
|
||||||
|
const data = r.data
|
||||||
|
let rows: unknown[] = []
|
||||||
|
if (Array.isArray(r.rows)) {
|
||||||
|
rows = r.rows
|
||||||
|
} else if (data && typeof data === 'object' && Array.isArray((data as Record<string, unknown>).rows)) {
|
||||||
|
rows = (data as Record<string, unknown>).rows as unknown[]
|
||||||
|
}
|
||||||
|
const totalRaw = r.total ?? (data && typeof data === 'object' ? (data as Record<string, unknown>).total : undefined)
|
||||||
|
const total = Number(totalRaw)
|
||||||
|
const rowList = rows as Record<string, unknown>[]
|
||||||
|
let resolvedTotal = Number.isFinite(total) ? total : rowList.length
|
||||||
|
// 部分接口有 rows 但 total 为 0,避免 z-paging 把 total 当成 success 后判失败
|
||||||
|
if (resolvedTotal <= 0 && rowList.length > 0) {
|
||||||
|
resolvedTotal = rowList.length
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rows: rowList,
|
||||||
|
total: resolvedTotal,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ type IRes<T> = T extends ArrayBuffer ? ArrayBuffer : IResData<T>
|
|||||||
|
|
||||||
|
|
||||||
export const http = <T>(options: CustomRequestOptions) => {
|
export const http = <T>(options: CustomRequestOptions) => {
|
||||||
console.log(options, 'options')
|
|
||||||
// 1. 返回 Promise 对象
|
// 1. 返回 Promise 对象
|
||||||
return new Promise<IRes<T>>((resolve, reject) => {
|
return new Promise<IRes<T>>((resolve, reject) => {
|
||||||
uni.request({
|
uni.request({
|
||||||
@@ -15,7 +14,6 @@ export const http = <T>(options: CustomRequestOptions) => {
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
// 响应成功
|
// 响应成功
|
||||||
success(res) {
|
success(res) {
|
||||||
console.log(res, '111111')
|
|
||||||
|
|
||||||
const ErrorMessage = {
|
const ErrorMessage = {
|
||||||
401: i18n.global.t('common.prompt.authentication-failed-please-log-in'),
|
401: i18n.global.t('common.prompt.authentication-failed-please-log-in'),
|
||||||
|
|||||||
@@ -455,4 +455,50 @@ export function tWithParams(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return str
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MerchantCartPayload = {
|
||||||
|
items: any[]
|
||||||
|
deliveryScheduleTimes?: string
|
||||||
|
merchantId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 listByMerchantId 等接口的 data:
|
||||||
|
* - 数组:直接为购物车项列表
|
||||||
|
* - 对象:{ merchantId, deliveryScheduleTimes, merchantCartVoList }
|
||||||
|
*/
|
||||||
|
export function parseMerchantCartPayload(data: unknown): MerchantCartPayload {
|
||||||
|
if (data == null) return { items: [] }
|
||||||
|
if (Array.isArray(data)) return { items: data }
|
||||||
|
if (typeof data !== 'object') return { items: [] }
|
||||||
|
|
||||||
|
const o = data as Record<string, unknown>
|
||||||
|
let items: any[] = []
|
||||||
|
|
||||||
|
if (Array.isArray(o.merchantCartVoList)) {
|
||||||
|
items = o.merchantCartVoList
|
||||||
|
} else if (Array.isArray(o.rows)) {
|
||||||
|
items = o.rows
|
||||||
|
} else if (Array.isArray(o.list)) {
|
||||||
|
items = o.list
|
||||||
|
} else if (Array.isArray(o.data)) {
|
||||||
|
items = o.data
|
||||||
|
} else if (o.id != null && (o.dishId != null || o.merchantDishVo != null)) {
|
||||||
|
items = [o]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
deliveryScheduleTimes:
|
||||||
|
typeof o.deliveryScheduleTimes === 'string'
|
||||||
|
? o.deliveryScheduleTimes
|
||||||
|
: undefined,
|
||||||
|
merchantId: o.merchantId != null ? String(o.merchantId) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated 请优先使用 parseMerchantCartPayload */
|
||||||
|
export function normalizeMerchantCartList(data: unknown): any[] {
|
||||||
|
return parseMerchantCartPayload(data).items
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,6 @@ import UniLayouts from '@uni-helper/vite-plugin-uni-layouts'
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default ({command, mode}) => {
|
export default ({command, mode}) => {
|
||||||
const {UNI_PLATFORM} = process.env
|
const {UNI_PLATFORM} = process.env
|
||||||
console.log('UNI_PLATFORM -> ', UNI_PLATFORM) // 得到 mp-weixin, h5, app 等
|
|
||||||
|
|
||||||
const env = loadEnv(mode, path.resolve(process.cwd(), 'env'))
|
const env = loadEnv(mode, path.resolve(process.cwd(), 'env'))
|
||||||
const {
|
const {
|
||||||
@@ -20,9 +19,6 @@ export default ({command, mode}) => {
|
|||||||
VITE_APP_PROXY,
|
VITE_APP_PROXY,
|
||||||
VITE_APP_PROXY_PREFIX
|
VITE_APP_PROXY_PREFIX
|
||||||
} = env
|
} = env
|
||||||
console.log('环境变量 env -> ', env)
|
|
||||||
|
|
||||||
|
|
||||||
return defineConfig({
|
return defineConfig({
|
||||||
envDir: './env', // 自定义env目录
|
envDir: './env', // 自定义env目录
|
||||||
css: {
|
css: {
|
||||||
|
|||||||