feat:bug修改

This commit is contained in:
dzc committed 2026-08-10 10:20:07 +08:00
1 parent c11fe90da3
commit f2dbfc13db
12 files changed
+508 -293

No files matched your search

+4 -4
View File
@@ -33,12 +33,12 @@ VITE_OAUTH2_SOCIAL_CLIENT='social:social'
# 是否开启websocket 消息接受,
VITE_WEBSOCKET_ENABLE = true
# --------------------------- start 开发环境 start ----------------------------------
# VITE_APP_DEV_SERVICE = https://www.zianxin.com/api
VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc
VITE_APP_DEV_SERVICE = https://www.zianxin.com/api
# VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc
# VITE_APP_DEV_SERVICE = http://127.0.0.1:9999
# VITE_IMAGE_DEV_PATH = https://www.zianxin.com/api
VITE_IMAGE_DEV_PATH = https://dzc-test-1.fy.takin.cc
VITE_IMAGE_DEV_PATH = https://www.zianxin.com/api
# VITE_IMAGE_DEV_PATH = https://dzc-test-1.fy.takin.cc
# VITE_IMAGE_DEV_PATH = http://127.0.0.1:9999
# ----------------------------- end 开发环境 end ------------------------------------
+2 -2
View File
@@ -2,8 +2,8 @@
"name" : "资安信",
"appid" : "__UNI__1BFE88F",
"description" : "资安信后勤管理系统",
"versionName" : "1.5.08",
"versionCode" : 1508,
"versionName" : "1.5.12",
"versionCode" : 1512,
"transformPx" : false,
/* 5+App特有相关 */
"app-plus" : {
+39 -20
View File
@@ -5,14 +5,14 @@ import { onLoad } from "@dcloudio/uni-app"
import { foodApi } from "@@/api"
import type { DailyMenuComboItem, DailyMenuDetail } from "@@/components/order/daily-menu-types"
import { hooks } from "@@/index"
import { hooks, router } from "@@/index"
import { filePath } from "@@/utils/file"
/** 页面请求和展示状态,由详情接口统一驱动。 */
/** 页面请求和展示状态,由每日菜谱商品详情接口统一驱动。 */
const state = reactive({
/** 路由传入的每日菜谱记录 ID。 */
detailId: "",
/** 当前每日菜谱详情;请求失败或记录不存在时为空。 */
/** 当前商品详情;请求失败或记录不存在时为空。 */
detail: null as DailyMenuDetail | null,
/** 首次加载和重试期间的骨架屏状态。 */
loading: false,
@@ -25,6 +25,9 @@ const state = reactive({
/** 当前菜品是否为套餐,兼容服务端将协议值返回为字符串的情况。 */
const isCombo = computed(() => Number(state.detail?.spuType) === 2)
/** 商品类型展示名称;除协议值 2 外均按单品兜底,与服务端默认类型规则保持一致。 */
const productTypeLabel = computed(() => (isCombo.value ? "套餐" : "单品"))
/** 套餐组成按服务端排序值展示,复制数组以避免修改接口响应对象。 */
const comboItems = computed(() => {
const items = Array.isArray(state.detail?.comboItems) ? state.detail.comboItems : []
@@ -90,13 +93,13 @@ const menuDateParts = computed(() => {
/** 是否存在有效市场价,只有高于销售价时才展示划线价。 */
const hasMarketPrice = computed(() => Number(state.detail?.marketPrice || 0) > Number(state.detail?.price || 0))
/** 菜谱补充信息按移动端扫描顺序组织,详情页只保留分类。 */
/** 商品补充信息按移动端扫描顺序组织,详情页只保留分类。 */
const menuInfoItems = computed(() => [
{
key: "category",
icon: "apps-line",
iconColor: "#475467",
label: "菜品分类",
label: "商品分类",
value: state.detail?.categoryName || "未分类",
},
])
@@ -113,6 +116,16 @@ const comboItemQuantity = (quantity?: number) => {
/** 套餐总份数由有效数量汇总,异常数量按一份处理。 */
const comboTotalQuantity = computed(() => comboItems.value.reduce((total, item) => total + comboItemQuantity(item.quantity), 0))
/** 套餐子项使用 SPU ID 进入通用商品详情;每日菜谱记录 ID 只用于当前套餐详情。 */
const openComboItemDetail = (comboItem: DailyMenuComboItem) => {
const spuId = String(comboItem.childSpuId || "").trim()
if (!spuId) {
uni.showToast({ title: "套餐商品信息缺失,暂时无法查看详情", icon: "none" })
return
}
router.jump("/pages/canteen/product-detail", { id: spuId, source: "daily" })
}
/** 将富文本图片转换为跨 H5、iOS、Android 可访问的完整地址。 */
const normalizeDescription = (description?: string) => {
if (!description) return ""
@@ -125,10 +138,10 @@ const normalizeDescription = (description?: string) => {
})
}
/** 查询每日菜谱详情;失败时保留当前路由并提供可重试状态。 */
/** 查询每日菜谱中的商品详情;单品与套餐使用相同接口,失败时保留当前路由并提供可重试状态。 */
const loadDetail = async () => {
if (!state.detailId) {
state.errorMessage = "菜谱信息缺失,暂时无法查看详情"
state.errorMessage = "商品信息缺失,暂时无法查看详情"
return
}
state.loading = true
@@ -137,12 +150,12 @@ const loadDetail = async () => {
const response: any = await foodApi.detail(state.detailId)
if (response?.code != null && Number(response.code) !== 0) {
state.detail = null
state.errorMessage = response?.msg || "菜谱详情加载失败,请稍后重试"
state.errorMessage = response?.msg || "商品详情加载失败,请稍后重试"
return
}
if (!response?.data?.id) {
state.detail = null
state.errorMessage = "该菜谱已下架或不存在"
state.errorMessage = "该商品已下架或不存在"
return
}
state.detail = {
@@ -171,9 +184,9 @@ onLoad((options: any) => {
</script>
<template>
<crud-layout title="菜谱详情" tabBarType="just-flot-back" bg="/static/page/blue-bg.png" mainIsScroll>
<crud-layout title="商品详情" tabBarType="just-flot-back" bg="/static/page/blue-bg.png" mainIsScroll>
<view class="daily-menu-detail-page min-h-full">
<view v-if="state.loading" class="min-h-full bg-#f5f7fb" aria-busy="true" aria-label="菜谱详情加载中">
<view v-if="state.loading" class="min-h-full bg-#f5f7fb" aria-busy="true" aria-label="商品详情加载中">
<base-skeleton width="100%" height="420" round="0" color="#dcecff" dark-color="#323232" />
<view class="relative z-1 mx-20rpx mt-[-28rpx]">
<view
@@ -208,7 +221,7 @@ onLoad((options: any) => {
<view class="flex h-96rpx w-96rpx items-center justify-center rounded-10rpx bg-#eaecf0">
<tm-icon name="error-warning-line" :size="48" color="#667085" />
</view>
<view class="mt-18rpx text-30rpx font-700 leading-[1.4] color-#101828">暂时无法查看菜谱</view>
<view class="mt-18rpx text-30rpx font-700 leading-[1.4] color-#101828">暂时无法查看商品</view>
<view class="mt-10rpx text-24rpx leading-[1.6] color-#667085">{{ state.errorMessage }}</view>
<view
v-if="state.detailId"
@@ -216,7 +229,7 @@ onLoad((options: any) => {
hover-class="tap-feedback"
role="button"
tabindex="0"
aria-label="重新加载菜谱详情"
aria-label="重新加载商品详情"
@tap="loadDetail"
@keydown.enter.stop.prevent="loadDetail"
@keydown.space.stop.prevent="loadDetail"
@@ -267,9 +280,9 @@ onLoad((options: any) => {
<tm-icon name="calendar-event-line" :size="25" color="#0094ff" />
<text>每日菜谱</text>
</view>
<view v-if="isCombo" class="h-22rpx w-1px bg-#d0d5dd" />
<view v-if="isCombo" class="flex items-center gap-6rpx color-#b45309">
<text>套餐</text>
<view class="h-22rpx w-1px bg-#d0d5dd" />
<view class="flex items-center gap-6rpx" :class="isCombo ? 'color-#b45309' : 'color-#027a48'">
<text>{{ productTypeLabel }}</text>
</view>
<view class="ml-4rpx h-1px min-w-32rpx flex-1 bg-#e4edf7" />
</view>
@@ -294,7 +307,7 @@ onLoad((options: any) => {
</view>
<view
class="mx-[-18rpx] mb-[-22rpx] mt-20rpx flex items-center border-t-1px border-#e4edf7 border-t-solid bg-#f7fbff p-18rpx"
aria-label="菜谱供应安排"
aria-label="商品供应安排"
>
<view class="flex min-w-0 flex-1 items-center gap-8rpx text-22rpx font-600 leading-[1.45] color-#344054">
<tm-icon name="calendar-event-line" :size="27" color="#0094ff" />
@@ -314,7 +327,7 @@ onLoad((options: any) => {
>
<view class="flex min-h-42rpx items-center text-28rpx font-700 color-#101828">
<view class="mr-10rpx h-24rpx w-6rpx rounded-4rpx bg-#0094ff" />
<text>菜谱信息</text>
<text>商品信息</text>
<text v-if="menuDateParts.year" class="ml-auto text-22rpx font-500 color-#667085">{{ menuDateParts.year }}</text>
</view>
<view
@@ -376,7 +389,12 @@ onLoad((options: any) => {
<view
v-for="(item, index) in comboItems"
:key="item.childSpuId || index"
class="flex min-h-108rpx items-center border-b-1px border-#eaecf0 border-b-solid py-10rpx last:border-b-0"
class="flex min-h-108rpx cursor-pointer items-center border-b-1px border-#eaecf0 border-b-solid py-10rpx last:border-b-0 [touch-action:manipulation]"
hover-class="tap-feedback" role="link" tabindex="0"
:aria-label="`查看${item.name || '套餐商品'}商品详情`"
@tap.stop="openComboItemDetail(item)"
@keydown.enter.stop.prevent="openComboItemDetail(item)"
@keydown.space.stop.prevent="openComboItemDetail(item)"
>
<image
class="h-84rpx w-84rpx shrink-0 rounded-10rpx bg-#f2f4f7"
@@ -396,6 +414,7 @@ onLoad((options: any) => {
>
×{{ comboItemQuantity(item.quantity) }}
</view>
<tm-icon class="ml-8rpx shrink-0" name="arrow-right-s-line" :size="25" color="#98a2b3" />
</view>
</view>
<view v-else class="px-0 pb-10rpx pt-24rpx text-center text-24rpx color-#667085">暂无套餐组成明细</view>
@@ -407,7 +426,7 @@ onLoad((options: any) => {
>
<view class="flex min-h-42rpx items-center text-28rpx font-700 color-#101828">
<view class="mr-10rpx h-24rpx w-6rpx rounded-4rpx bg-#0094ff" />
<text>菜品详情</text>
<text>商品详情</text>
</view>
<view class="description mt-12rpx break-words text-24rpx leading-[1.65] color-#475467">
<tm-html :value="state.detail.description" />
+95 -45
View File
@@ -6,25 +6,52 @@ import { productApi } from "@@/api"
import { filePath } from "@@/utils/file"
import { hooks } from "@@/index"
/** 每日菜品详情的路由来源标识,决定使用 canteen_spu 数据源。 */
const DAILY_PRODUCT_SOURCE = "daily"
/** 商品详情页面状态,在线商品与每日菜品共用展示结构。 */
const state = reactive<any>({
detailId: null,
/** 路由传入的商品 SPU ID。 */
detailId: "",
/** 商品数据来源;daily 表示每日菜品,空字符串表示在线点餐商品。 */
detailSource: "",
/** 当前商品详情;接口失败或商品不存在时为空对象。 */
detail: null,
/** 已解析的轮播图片地址。 */
sliderPics: [] as string[],
/** 当前轮播图索引。 */
heroIndex: 0,
})
/** 读取商品 ID 和数据来源,保证每日菜品不会误查在线商品表。 */
onLoad((options: any) => {
if (!isEmpty(options.id)) {
state.detailId = options.id
state.detailId = String(options.id)
}
state.detailSource = options?.source === DAILY_PRODUCT_SOURCE ? DAILY_PRODUCT_SOURCE : ""
})
/** 页面返回前台时刷新商品详情,保留原有库存和上下架状态刷新行为。 */
onShow(() => {
getDetail()
})
/** 当前详情是否来自每日菜品表。 */
const isDailyProduct = computed(() => state.detailSource === DAILY_PRODUCT_SOURCE)
/** 当前商品是否为套餐。 */
const isCombo = computed(() => Number(state.detail?.spuType) === 2)
const isDown = computed(() => Number(state.detail?.status) === 1)
/** 在线商品是否已下架;每日菜品接口不使用在线商品状态协议。 */
const isDown = computed(() => !isDailyProduct.value && Number(state.detail?.status) === 1)
/** 商品是否售罄,兼容接口直接返回 soldOut 或仅返回库存数量的情况。 */
const isSoldOut = computed(() => state.detail?.soldOut === true || Number(state.detail?.stock ?? 0) <= 0)
/** 套餐组成项数量。 */
const comboCount = computed(() => (state.detail?.comboItems || []).length)
/** 详情轮播图片;接口无轮播图时使用封面图兜底。 */
const heroList = computed(() => {
if (state.sliderPics.length) return state.sliderPics
if (state.detail?.picUrl) return [state.detail.picUrl]
@@ -44,55 +71,78 @@ const hasDescription = computed(() => {
return text.length > 0
})
const getDetail = async () => {
uni.showLoading({ title: "加载中..." })
const { data } = await productApi.get({ id: state.detailId })
state.detail = data || {}
/** 按路由来源查询对应商品表,并统一处理轮播图、缓存兜底和富文本图片。 */
const getDetail = async () => {
if (!state.detailId) {
state.detail = {}
return
}
uni.showLoading({ title: "加载中..." })
try {
const cacheRaw = uni.getStorageSync("canteen_spu_detail_cache")
const cache = cacheRaw ? JSON.parse(cacheRaw) : null
if (cache && String(cache.id) === String(state.detailId)) {
if (state.detail.spuType == null && cache.spuType != null) {
state.detail.spuType = cache.spuType
}
if (!(state.detail.comboItems || []).length && (cache.comboItems || []).length) {
state.detail.comboItems = cache.comboItems
const response: any = isDailyProduct.value
? await productApi.getDaily({ id: state.detailId })
: await productApi.get({ id: state.detailId })
if (response?.code != null && Number(response.code) !== 0) {
state.detail = {}
return
}
const data = response?.data
state.detail = data || {}
if (!isDailyProduct.value) {
try {
const cacheRaw = uni.getStorageSync("canteen_spu_detail_cache")
const cache = cacheRaw ? JSON.parse(cacheRaw) : null
if (cache && String(cache.id) === String(state.detailId)) {
if (state.detail.spuType == null && cache.spuType != null) {
state.detail.spuType = cache.spuType
}
if (!(state.detail.comboItems || []).length && (cache.comboItems || []).length) {
state.detail.comboItems = cache.comboItems
}
}
} catch (_error) {
// 缓存只用于在线商品详情兜底,解析失败不影响接口数据展示。
}
}
} catch (e) {
/* ignore */
}
const raw =
(data?.sliderPicUrls ?? data?.albumPics ?? data?.sliderPics ?? data?.pics ?? data?.bannerUrls) as any
let arr: string[] = []
if (Array.isArray(raw)) {
arr = raw.map((x: any) => String(x).trim()).filter(Boolean)
} else if (typeof raw === "string") {
arr = raw
.split(",")
.map((x) => x.trim())
.filter(Boolean)
}
state.sliderPics = arr
state.heroIndex = 0
const raw =
(data?.sliderPicUrls ?? data?.albumPics ?? data?.sliderPics ?? data?.pics ?? data?.bannerUrls) as any
let arr: string[] = []
if (Array.isArray(raw)) {
arr = raw.map((x: any) => String(x).trim()).filter(Boolean)
} else if (typeof raw === "string") {
arr = raw
.split(",")
.map((x) => x.trim())
.filter(Boolean)
}
state.sliderPics = arr
state.heroIndex = 0
if (state.detail.description) {
state.detail.description = state.detail.description.replace(
/<img\s+([^>]*?)\/?>/gi,
(_match: string, attrs: string) => {
const srcMatch = attrs.match(/src=["']([^"']+)["']/i)
const altMatch = attrs.match(/alt=["']([^"']*)["']/i)
const src = srcMatch ? filePath(srcMatch[1]) : ""
const alt = altMatch ? altMatch[1] : ""
return `<img src="${src}"${alt ? ` alt="${alt}"` : ""} style="display:block;width:100%;height:180px;object-fit:cover;border-radius:8px;margin:0 auto 12px auto;background:#f5f6f8;" />`
},
)
if (state.detail.description) {
state.detail.description = state.detail.description.replace(
/<img\s+([^>]*?)\/?>/gi,
(_match: string, attrs: string) => {
const srcMatch = attrs.match(/src=["']([^"']+)["']/i)
const altMatch = attrs.match(/alt=["']([^"']*)["']/i)
const src = srcMatch ? filePath(srcMatch[1]) : ""
const alt = altMatch ? altMatch[1] : ""
return `<img src="${src}"${alt ? ` alt="${alt}"` : ""} style="display:block;width:100%;height:180px;object-fit:cover;border-radius:8px;margin:0 auto 12px auto;background:#f5f6f8;" />`
},
)
}
} catch (error) {
state.detail = {}
console.error("查询商品详情失败", error)
uni.showToast({ title: "商品详情加载失败", icon: "none" })
} finally {
uni.hideLoading()
}
uni.hideLoading()
}
/** 同步轮播图索引,异常事件统一回退到第一张。 */
const onHeroChange = (e: any) => {
state.heroIndex = Number(e?.detail?.current || 0)
}
@@ -148,10 +198,10 @@ const onHeroChange = (e: any) => {
</view>
<view class="meta">
<view class="meta__item" :class="isDown ? 'is-down' : 'is-up'">
<view v-if="!isDailyProduct" class="meta__item" :class="isDown ? 'is-down' : 'is-up'">
{{ isDown ? "已下架" : "上架中" }}
</view>
<view v-if="state.detail.soldOut" class="meta__item is-sold">售罄</view>
<view v-if="isSoldOut" class="meta__item is-sold">售罄</view>
<view class="meta__item is-stock">库存 {{ state.detail.stock ?? 0 }}</view>
</view>
</view>
+141 -134
View File
@@ -1,148 +1,154 @@
<template>
<crud-layout title="订餐" mainIsScroll bg="/static/page/blue-bg.png">
<confirm-tabs v-model="tabState.activeValue" :list="tabState.items" @change="onConfirmTabChange" />
<view class="rounded-b-24rpx overflow-hidden">
<table-reservation-time-select class="rounded-b-16rpx" :mch-detail="state.mchDetail"
@select="onSelectAppt" />
<!-- App 端 tm-drawer 不会 Teleport:由选择器包住页面布局,使抽屉成为 crud-layout 的外部兄弟节点。 -->
<select-reserve-diners v-model="dinerState.userIds" :guardian-ward-enabled="guardianWardReserveEnabled"
@change="onDinersChange" v-slot="{ openDrawer, loading, selectedOptions, guardianWardEnabled }">
<crud-layout title="订餐" mainIsScroll bg="/static/page/blue-bg.png">
<confirm-tabs v-model="tabState.activeValue" :list="tabState.items" @change="onConfirmTabChange" />
<view class="rounded-b-24rpx overflow-hidden">
<table-reservation-time-select class="rounded-b-16rpx" :mch-detail="state.mchDetail"
@select="onSelectAppt" />
<view class="h-12rpx" />
<reserve-diners-trigger :loading="loading" :selected-options="selectedOptions"
:guardian-ward-enabled="guardianWardEnabled" @open="openDrawer" />
</view>
<view class="h-12rpx" />
<select-reserve-diners v-model="dinerState.userIds" :guardian-ward-enabled="guardianWardReserveEnabled"
@change="onDinersChange" />
</view>
<view class="h-12rpx" />
<view class="menu-panel bg-white px-24rpx" :aria-busy="requestState.food.loading">
<view class="merchant-entry" hover-class="tap-feedback" role="link" tabindex="0"
:aria-label="`查看${state.mchDetail.name || '当前商户'}详情`" @tap="openMerchantDetail"
@keydown.enter.stop.prevent="openMerchantDetail" @keydown.space.stop.prevent="openMerchantDetail">
<view class="merchant-entry__icon">
<tm-icon name="store-2-line" :size="30" color="#0094ff" />
</view>
<view class="ml-12rpx min-w-0 flex-1">
<view class="text-19rpx color-#667085">当前商户</view>
<view class="show-line sub-text-24 color-#101828!">{{ state.mchDetail.name || "商户信息" }}</view>
</view>
<view class="ml-16rpx flex flex-shrink-0 items-center text-21rpx font-600 color-#475467">
<text>查看详情</text>
<tm-icon name="arrow-right-s-line" :size="27" color="#667085" />
</view>
</view>
<view class="mt-14rpx flex items-start justify-between gap-20rpx">
<view class="min-w-0 flex-1">
<view class="sub-text-28">可选菜谱</view>
<view class="mt-3rpx text-20rpx color-#667085">
{{ currentMealCaption }}
<view class="menu-panel bg-white px-24rpx" :aria-busy="requestState.food.loading">
<view class="merchant-entry" hover-class="tap-feedback" role="link" tabindex="0"
:aria-label="`查看${state.mchDetail.name || '当前商户'}详情`" @tap="openMerchantDetail"
@keydown.enter.stop.prevent="openMerchantDetail" @keydown.space.stop.prevent="openMerchantDetail">
<view class="merchant-entry__icon">
<tm-icon name="store-2-line" :size="30" color="#0094ff" />
</view>
<view class="ml-12rpx min-w-0 flex-1">
<view class="text-19rpx color-#667085">当前商户</view>
<view class="show-line sub-text-24 color-#101828!">{{ state.mchDetail.name || "商户信息" }}</view>
</view>
<view class="ml-16rpx flex flex-shrink-0 items-center text-21rpx font-600 color-#475467">
<text>查看详情</text>
<tm-icon name="arrow-right-s-line" :size="27" color="#667085" />
</view>
</view>
<view v-if="currentDraftCount > 0"
class="flex-shrink-0 rounded-full bg-#eef8ff px-12rpx py-6rpx text-20rpx font-700 color-#0094ff">
已选 {{ currentDraftCount }} 份
</view>
</view>
<view v-if="!hasMealSelection" class="menu-state">
<view class="menu-state__icon bg-#eef8ff">
<tm-icon name="calendar-schedule-line" :size="42" color="#0094ff" />
</view>
<view class="sub-text-26">请先选择日期和餐段</view>
<view class="mt-8rpx text-22rpx color-#667085">选择后将展示对应时段的可订菜谱</view>
</view>
<daily-menu-food-skeleton v-else-if="requestState.food.loading" />
<view v-else-if="requestState.food.failed" class="menu-state">
<view class="menu-state__icon bg-#fff4e8">
<tm-icon name="error-warning-line" :size="42" color="#d97706" />
</view>
<view class="sub-text-26">菜谱加载失败</view>
<view class="mt-8rpx text-22rpx color-#667085">请检查网络后重新加载</view>
<view class="retry-button mt-22rpx" hover-class="tap-feedback" @tap="loadFoodList"> 重新加载 </view>
</view>
<view v-else-if="state.foodList.length === 0" class="menu-state">
<view class="menu-state__icon bg-#f2f4f7">
<tm-icon name="bowl-line" :size="42" color="#667085" />
</view>
<view class="sub-text-26">该餐段暂无可订菜谱</view>
<view class="mt-8rpx text-22rpx color-#667085">可切换其他日期或餐段查看</view>
</view>
<view v-else class="mt-10rpx">
<daily-menu-food-card v-for="item in state.foodList" :key="item.spuId || item.id" :item="item"
:ignore-stock="pageConfig.ignoreStock" :quantity="foodQuantity(item)" :can-add="canAddFood(item)"
:add-disabled="currentDraftSingleProductLimitReached" @change="adjustFoodQuantity(item, $event)" />
</view>
</view>
<view class="h-12rpx" />
<view class="booked-panel bg-white px-24rpx">
<view class="booked-summary" :class="{ 'booked-summary--clickable': bookedMenus.length > 0 }"
hover-class="tap-feedback"
@tap="bookedMenus.length > 0 && (viewState.bookedMenusExpanded = !viewState.bookedMenusExpanded)">
<view class="booked-summary__icon">
<tm-icon name="calendar-check-line" :size="28" color="#027a48" />
</view>
<view class="ml-12rpx min-w-0 flex-1">
<view class="flex items-center gap-10rpx">
<text class="sub-text-25">已预订菜谱</text>
<text v-if="bookedMenus.length > 0" class="text-20rpx font-700 color-#027a48">{{
bookedMenus.length }} 笔</text>
</view>
<view v-if="requestState.booked.loading" class="mt-2rpx text-20rpx color-#667085">正在加载预约</view>
<view v-else-if="requestState.booked.failed" class="mt-2rpx text-20rpx color-#d97706">加载失败,点击重试
</view>
<view v-else-if="bookedMenus.length === 0" class="mt-2rpx text-20rpx color-#667085">当前商户暂无未核销预约
</view>
<view v-else class="mt-2rpx text-20rpx color-#667085 show-line">
{{ formatReservationDate(bookedMenus[0]?.apptUseDate) }} · {{ bookedMenus[0]?.apptTitle ||
"预约餐段" }}
·
{{ bookedFoodSummary(bookedMenus[0]) }}
</view>
</view>
<view v-if="requestState.booked.failed" class="booked-retry" @tap.stop="loadBookedMenus">重试</view>
<view v-else-if="bookedMenus.length > 0" class="booked-chevron">
<tm-icon :name="viewState.bookedMenusExpanded ? 'arrow-up-s-line' : 'arrow-down-s-line'" :size="28"
color="#667085" />
</view>
</view>
<view v-if="viewState.bookedMenusExpanded && bookedMenus.length > 0" class="booked-list">
<view v-for="reservation in bookedMenus" :key="reservation.id" class="booked-row">
<view class="mt-14rpx flex items-start justify-between gap-20rpx">
<view class="min-w-0 flex-1">
<view class="flex items-center gap-8rpx">
<text class="sub-text-23">{{ formatReservationDate(reservation.apptUseDate) }}</text>
<text class="meal-tag show-line">{{ reservation.apptTitle || "预约餐段" }}</text>
</view>
<view class="mt-4rpx text-20rpx color-#667085 show-line">
{{ reservation.username || "用餐人" }} · {{ bookedFoodSummary(reservation) }}
<view class="sub-text-28">可选菜谱</view>
<view class="mt-3rpx text-20rpx color-#667085">
{{ currentMealCaption }}
</view>
</view>
<text class="ml-12rpx flex-shrink-0 text-19rpx font-700 color-#027a48">已预订</text>
<view v-if="currentDraftCount > 0"
class="flex-shrink-0 rounded-full bg-#eef8ff px-12rpx py-6rpx text-20rpx font-700 color-#0094ff">
已选 {{ currentDraftCount }} 份
</view>
</view>
<view v-if="!hasMealSelection" class="menu-state">
<view class="menu-state__icon bg-#eef8ff">
<tm-icon name="calendar-schedule-line" :size="42" color="#0094ff" />
</view>
<view class="sub-text-26">请先选择日期和餐段</view>
<view class="mt-8rpx text-22rpx color-#667085">选择后将展示对应时段的可订菜谱</view>
</view>
<daily-menu-food-skeleton v-else-if="requestState.food.loading" />
<view v-else-if="requestState.food.failed" class="menu-state">
<view class="menu-state__icon bg-#fff4e8">
<tm-icon name="error-warning-line" :size="42" color="#d97706" />
</view>
<view class="sub-text-26">菜谱加载失败</view>
<view class="mt-8rpx text-22rpx color-#667085">请检查网络后重新加载</view>
<view class="retry-button mt-22rpx" hover-class="tap-feedback" @tap="loadFoodList"> 重新加载 </view>
</view>
<view v-else-if="state.foodList.length === 0" class="menu-state">
<view class="menu-state__icon bg-#f2f4f7">
<tm-icon name="bowl-line" :size="42" color="#667085" />
</view>
<view class="sub-text-26">该餐段暂无可订菜谱</view>
<view class="mt-8rpx text-22rpx color-#667085">可切换其他日期或餐段查看</view>
</view>
<view v-else class="mt-10rpx">
<daily-menu-food-card v-for="item in state.foodList" :key="item.spuId || item.id" :item="item"
:ignore-stock="pageConfig.ignoreStock" :quantity="foodQuantity(item)"
:can-add="canAddFood(item)" :add-disabled="currentDraftSingleProductLimitReached"
@change="adjustFoodQuantity(item, $event)" />
</view>
</view>
</view>
<view class="h-12rpx" />
<template #main-bottom>
<view class="cart-bar">
<view class="cart-summary" hover-class="tap-feedback" @tap="openCartDrawer">
<view class="cart-icon-wrap">
<tm-icon name="shopping-cart-line" :size="34" color="#ffffff" />
<view v-if="totalFoodCount > 0" class="cart-badge">{{ totalFoodCount }}</view>
</view>
<view class="ml-14rpx min-w-0 flex-1">
<view v-if="totalFoodCount > 0" class="flex items-baseline gap-6rpx">
<text class="text-30rpx font-800 color-#e5484d"> ¥{{ hooks.useMoney(totalPayableAmount) }}
</text>
<text class="text-19rpx color-#667085">{{ selectedDinerCount }} 人</text>
</view>
<view v-else class="sub-text-24 color-#344054">尚未选择菜品</view>
<view class="mt-1rpx text-19rpx color-#667085 show-line">
{{ cartSummaryText }}
</view>
</view>
</view>
<view class="submit-button"
:class="{ 'submit-button--disabled': totalFoodCount === 0 || requestState.cart.loading }"
<view class="h-12rpx" />
<view class="booked-panel bg-white px-24rpx">
<view class="booked-summary" :class="{ 'booked-summary--clickable': bookedMenus.length > 0 }"
hover-class="tap-feedback"
@tap="totalFoodCount > 0 && !requestState.cart.loading && submitDrafts()">
{{ requestState.cart.loading ? "准备中..." : "去支付" }}
@tap="bookedMenus.length > 0 && (viewState.bookedMenusExpanded = !viewState.bookedMenusExpanded)">
<view class="booked-summary__icon">
<tm-icon name="calendar-check-line" :size="28" color="#027a48" />
</view>
<view class="ml-12rpx min-w-0 flex-1">
<view class="flex items-center gap-10rpx">
<text class="sub-text-25">已预订菜谱</text>
<text v-if="bookedMenus.length > 0" class="text-20rpx font-700 color-#027a48">{{
bookedMenus.length }} 笔</text>
</view>
<view v-if="requestState.booked.loading" class="mt-2rpx text-20rpx color-#667085">正在加载预约</view>
<view v-else-if="requestState.booked.failed" class="mt-2rpx text-20rpx color-#d97706">加载失败,点击重试
</view>
<view v-else-if="bookedMenus.length === 0" class="mt-2rpx text-20rpx color-#667085">当前商户暂无未核销预约
</view>
<view v-else class="mt-2rpx text-20rpx color-#667085 show-line">
{{ formatReservationDate(bookedMenus[0]?.apptUseDate) }} · {{ bookedMenus[0]?.apptTitle ||
"预约餐段" }}
·
{{ bookedFoodSummary(bookedMenus[0]) }}
</view>
</view>
<view v-if="requestState.booked.failed" class="booked-retry" @tap.stop="loadBookedMenus">重试</view>
<view v-else-if="bookedMenus.length > 0" class="booked-chevron">
<tm-icon :name="viewState.bookedMenusExpanded ? 'arrow-up-s-line' : 'arrow-down-s-line'"
:size="28" color="#667085" />
</view>
</view>
<view v-if="viewState.bookedMenusExpanded && bookedMenus.length > 0" class="booked-list">
<view v-for="reservation in bookedMenus" :key="reservation.id" class="booked-row">
<view class="min-w-0 flex-1">
<view class="flex items-center gap-8rpx">
<text class="sub-text-23">{{ formatReservationDate(reservation.apptUseDate) }}</text>
<text class="meal-tag show-line">{{ reservation.apptTitle || "预约餐段" }}</text>
</view>
<view class="mt-4rpx text-20rpx color-#667085 show-line">
{{ reservation.username || "用餐人" }} · {{ bookedFoodSummary(reservation) }}
</view>
</view>
<text class="ml-12rpx flex-shrink-0 text-19rpx font-700 color-#027a48">已预订</text>
</view>
</view>
</view>
</template>
</crud-layout>
<view class="h-12rpx" />
<template #main-bottom>
<view class="cart-bar">
<view class="cart-summary" hover-class="tap-feedback" @tap="openCartDrawer">
<view class="cart-icon-wrap">
<tm-icon name="shopping-cart-line" :size="34" color="#ffffff" />
<view v-if="totalFoodCount > 0" class="cart-badge">{{ totalFoodCount }}</view>
</view>
<view class="ml-14rpx min-w-0 flex-1">
<view v-if="totalFoodCount > 0" class="flex items-baseline gap-6rpx">
<text class="text-30rpx font-800 color-#e5484d"> ¥{{ hooks.useMoney(totalPayableAmount)
}}
</text>
<text class="text-19rpx color-#667085">{{ selectedDinerCount }} 人</text>
</view>
<view v-else class="sub-text-24 color-#344054">尚未选择菜品</view>
<view class="mt-1rpx text-19rpx color-#667085 show-line">
{{ cartSummaryText }}
</view>
</view>
</view>
<view class="submit-button"
:class="{ 'submit-button--disabled': totalFoodCount === 0 || requestState.cart.loading }"
hover-class="tap-feedback"
@tap="totalFoodCount > 0 && !requestState.cart.loading && submitDrafts()">
{{ requestState.cart.loading ? "准备中..." : "去支付" }}
</view>
</view>
</template>
</crud-layout>
</select-reserve-diners>
<daily-menu-cart-drawer v-model:show="viewState.cartDrawerShow" :draft-groups="draftGroups"
:total-food-count="totalFoodCount" :selected-diner-count="selectedDinerCount"
:total-payable-amount="totalPayableAmount" :ignore-stock="pageConfig.ignoreStock"
@@ -166,6 +172,7 @@ import DailyMenuFoodCard from "@@/components/order/daily-menu-food-card.vue"
import DailyMenuFoodSkeleton from "@@/components/order/daily-menu-food-skeleton.vue"
import type { PaymentCheckItem, PaymentSelection, PaymentSummaryItem } from "@@/components/base/base-payment-drawer/base-payment-drawer.vue"
import type { DailyMenuItem, ReservationDraft } from "@@/components/order/daily-menu-types"
import ReserveDinersTrigger from "@@/components/user/reserve-diners-trigger.vue"
import SelectReserveDiners from "@@/components/user/select-reserve-diners.vue"
import ConfirmTabs from "./components/confirm-tabs.vue"
@@ -218,7 +225,7 @@ const state = reactive<PageState>({
/** 页面配置 */
const pageConfig = reactive({
ignoreStock: true,
ignoreStock: false,
})
/** 当前选中的预约日期与餐段。 */
+26 -19
View File
@@ -164,7 +164,7 @@ onUnload(() => {
<template>
<crud-layout mainIsScroll title="充值结果" bg="/static/page/blue-bg.png">
<view class="pt-24rpx pb-250rpx">
<view class="pb-24rpx pt-24rpx">
<view class="rounded-24rpx bg-white px-30rpx pb-24rpx pt-10rpx shadow-main">
<view class="h-500rpx flex-col-center pb-20rpx pt-10rpx">
<view class="h-280rpx w-280rpx flex-row-center">
@@ -172,7 +172,8 @@ onUnload(() => {
:height="currentAnimationSize.height" :loop="currentLoop" />
</view>
<view class="text-38rpx color-#222 font-700">{{ titleText }}</view>
<view class="mt-10rpx max-w-590rpx text-center text-24rpx color-#657187 leading-38rpx">{{ descText }}</view>
<view class="mt-10rpx max-w-590rpx text-center text-24rpx color-#657187 leading-38rpx">{{ descText
}}</view>
<view class="mt-10rpx text-58rpx color-#222 font-700">¥{{ hooks.useMoney(payOrderAmount) }}</view>
</view>
@@ -183,38 +184,44 @@ onUnload(() => {
</view>
<view v-if="isGuardianMode" class="flex items-center justify-between py-16rpx">
<text class="text-26rpx color-#657187">充值对象</text>
<text class="max-w-380rpx text-right text-28rpx color-#333 font-500">{{ state.guardianResult.wardName || "被监护人" }}</text>
<text class="max-w-380rpx text-right text-28rpx color-#333 font-500">{{
state.guardianResult.wardName || "被监护人" }}</text>
</view>
<view class="flex items-center justify-between py-16rpx">
<text class="text-26rpx color-#657187">充值方式</text>
<text class="max-w-380rpx text-right text-28rpx color-#333 font-500">{{ payTypeText }}</text>
</view>
<view v-if="isGuardianMode && state.guardianResult.paid" class="flex items-center justify-between py-16rpx">
<view v-if="isGuardianMode && state.guardianResult.paid"
class="flex items-center justify-between py-16rpx">
<text class="text-26rpx color-#657187">最新余额</text>
<text class="max-w-380rpx text-right text-28rpx color-#333 font-700">¥{{ hooks.useMoney(state.guardianResult.balance || 0) }}</text>
<text class="max-w-380rpx text-right text-28rpx color-#333 font-700">¥{{
hooks.useMoney(state.guardianResult.balance || 0) }}</text>
</view>
<view class="flex items-center justify-between py-16rpx">
<text class="text-26rpx color-#657187">订单状态</text>
<text class="max-w-380rpx text-right text-28rpx font-700" :class="resultStatusClass">{{ resultStatusText }}</text>
<text class="max-w-380rpx text-right text-28rpx font-700" :class="resultStatusClass">{{
resultStatusText }}</text>
</view>
</view>
</view>
<information-stream-ad v-if="state.phase !== 'loading'" class="mt-16rpx w-full" />
</view>
<view v-if="state.phase !== 'loading'"
class="result-actions fixed bottom-0 left-0 right-0 z-20 flex flex-col items-center gap-16rpx bg-white px-30rpx pt-18rpx">
<information-stream-ad class="w-full" />
<view v-if="state.phase === 'success'"
class="h-88rpx w-full flex-row-center rounded-full bg-blue text-30rpx color-white font-700 shadow-main active:opacity-80"
@tap="goTargetPage">
{{ isGuardianMode ? "返回图谱列表" : "查看钱包" }}
<!-- 底栏只承载操作按钮,避免 iOS 将其余内容高度一并绘制成白色操作区域。 -->
<template #main-bottom>
<view v-if="state.phase !== 'loading'" class="result-actions bg-white px-30rpx pt-18rpx">
<view v-if="state.phase === 'success'"
class="h-88rpx w-full flex-row-center rounded-full bg-blue text-30rpx color-white font-700 shadow-main active:opacity-80"
@tap="goTargetPage">
{{ isGuardianMode ? "返回图谱列表" : "查看钱包" }}
</view>
<view v-else
class="h-88rpx w-full flex-row-center rounded-full bg-blue text-30rpx color-white font-700 shadow-main active:opacity-80"
@tap="retryResult">
{{ state.phase === "confirming" ? "重新确认到账" : "重新查询" }}
</view>
</view>
<view v-else
class="h-88rpx w-full flex-row-center rounded-full bg-blue text-30rpx color-white font-700 shadow-main active:opacity-80"
@tap="retryResult">
{{ state.phase === "confirming" ? "重新确认到账" : "重新查询" }}
</view>
</view>
</template>
</crud-layout>
</template>
+9
View File
@@ -1,6 +1,7 @@
import request from "@/sheep/utils/request"
export default {
/** 查询在线点餐商品详情,ID 来源于 canteen_online_spu。 */
get: (params) => {
return request({
url: "/canteen/canteenOnlineSpu/app/getCanteenSpuDetails",
@@ -8,6 +9,14 @@ export default {
params,
})
},
/** 查询每日菜品详情,ID 来源于 canteen_spu 或套餐组成的 childSpuId。 */
getDaily: (params: { id: string | number }) => {
return request({
url: "/canteen/canteenSpu/app/getCanteenSpuDetails",
method: "GET",
params,
})
},
/**
* 通过部门ID查询商户简洁信息
* @param deptId 部门ID
@@ -1,13 +1,6 @@
<template>
<tm-drawer
v-model:show="drawerShow"
position="bottom"
size="64%"
title="订餐购物车"
:show-close="true"
:show-cancel="false"
:show-footer="true"
>
<tm-drawer v-model:show="drawerShow" position="bottom" size="64%" title="订餐购物车" :show-close="true"
:show-cancel="false" :show-footer="true">
<view v-if="props.draftGroups.length === 0" class="cart-empty">
<view class="cart-empty__icon">
<tm-icon name="shopping-cart-line" :size="36" color="#0094ff" />
@@ -18,7 +11,8 @@
<view v-else class="pb-12rpx">
<view class="cart-toolbar">
<view class="text-21rpx color-#667085"> {{ props.draftGroups.length }} 个餐段 · {{ props.totalFoodCount }} 份 </view>
<view class="text-21rpx color-#667085"> {{ props.draftGroups.length }} 个餐段 · {{ props.totalFoodCount }}
份 </view>
<view class="clear-button" hover-class="tap-feedback" @tap="emitClear">清空</view>
</view>
@@ -28,12 +22,8 @@
<text class="cart-group__date">{{ formatReservationDate(draft.apptDate) }}</text>
<text class="meal-tag show-line">{{ draft.mealTitle }}</text>
</view>
<view
class="remove-hit-area"
hover-class="tap-feedback"
:aria-label="`移除${draft.mealTitle}选择`"
@tap="emitRemoveGroup(draft.key)"
>
<view class="remove-hit-area" hover-class="tap-feedback" :aria-label="`移除${draft.mealTitle}选择`"
@tap="emitRemoveGroup(draft.key)">
<view class="remove-button">
<tm-icon name="delete-bin-line" :size="23" color="#d92d20" />
</view>
@@ -44,21 +34,21 @@
<image class="cart-food-row__image" :src="foodImage(item)" mode="aspectFill" />
<view class="ml-12rpx min-w-0 flex-1">
<view class="show-line sub-text-24">{{ item.name || "未命名菜品" }}</view>
<view class="mt-3rpx text-23rpx font-700 color-#e5484d">¥{{ hooks.useMoney(item.price || 0) }} </view>
<view class="mt-3rpx text-23rpx font-700 color-#e5484d">¥{{ hooks.useMoney(item.price || 0) }}
</view>
</view>
<view class="quantity-control">
<view class="quantity-hit-area" hover-class="tap-feedback" @tap="emitQuantityChange(draft.key, item, -1)">
<view class="quantity-hit-area" hover-class="tap-feedback"
@tap="emitQuantityChange(draft.key, item, -1)">
<view class="quantity-button quantity-button--secondary">
<tm-icon name="subtract-line" :size="23" color="#0094ff" />
</view>
</view>
<view class="quantity-value">{{ item.quantity }}</view>
<view
class="quantity-hit-area"
<view class="quantity-hit-area"
:class="{ 'quantity-hit-area--disabled': !canIncreaseFood(draft, item) }"
hover-class="tap-feedback"
@tap="canIncreaseFood(draft, item) && emitQuantityChange(draft.key, item, 1)"
>
@tap="canIncreaseFood(draft, item) && emitQuantityChange(draft.key, item, 1)">
<view class="quantity-button quantity-button--primary">
<tm-icon name="add-line" :size="25" color="#ffffff" />
</view>
@@ -72,14 +62,11 @@
<view class="drawer-footer">
<view class="min-w-0 flex-1">
<view class="text-20rpx color-#667085">{{ props.selectedDinerCount }} 位用餐人合计</view>
<view class="mt-1rpx text-32rpx font-800 color-#e5484d">¥{{ hooks.useMoney(props.totalPayableAmount) }}</view>
<view class="mt-1rpx text-32rpx font-800 color-#e5484d">¥{{ hooks.useMoney(props.totalPayableAmount)
}}</view>
</view>
<view
class="drawer-submit"
:class="{ 'drawer-submit--disabled': props.totalFoodCount === 0 }"
hover-class="tap-feedback"
@tap="props.totalFoodCount > 0 && emitSubmit()"
>
<view class="drawer-submit" :class="{ 'drawer-submit--disabled': props.totalFoodCount === 0 }"
hover-class="tap-feedback" @tap="props.totalFoodCount > 0 && emitSubmit()">
确认订餐
</view>
</view>
@@ -2,7 +2,7 @@
<view class="daily-food-card" :class="{ 'daily-food-card--sold-out': soldOut }">
<view class="daily-food-card__main">
<view class="daily-food-card__detail" hover-class="card-tap-feedback" role="link" tabindex="0"
:aria-label="`查看${displayName}每日菜谱详情`" @tap="openDailyMenuDetail"
:aria-label="`查看${displayName}商品详情`" @tap="openDailyMenuDetail"
@keydown.enter.stop.prevent="openDailyMenuDetail" @keydown.space.stop.prevent="openDailyMenuDetail">
<image class="daily-food-card__image" :src="imageUrl" mode="aspectFill"
:aria-label="`${displayName}菜品图片`" />
@@ -65,7 +65,11 @@
<view v-if="comboExpanded && comboItems.length > 0" class="combo-list">
<view v-for="(comboItem, comboIndex) in comboItems"
:key="comboItem.childSpuId || `${comboItem.name || 'combo'}-${comboIndex}`"
class="combo-list__item">
class="combo-list__item" hover-class="tap-feedback" role="link" tabindex="0"
:aria-label="`查看${comboItem.name || '套餐商品'}商品详情`"
@tap.stop="openComboItemDetail(comboItem)"
@keydown.enter.stop.prevent="openComboItemDetail(comboItem)"
@keydown.space.stop.prevent="openComboItemDetail(comboItem)">
<image class="combo-list__image" :src="comboItemImageUrl(comboItem.picUrl)" mode="aspectFill"
:aria-label="`${comboItem.name || '未命名菜品'}图片`" />
<view class="ml-12rpx min-w-0 flex-1">
@@ -74,6 +78,7 @@
</view>
</view>
<view class="combo-list__price ml-12rpx">¥{{ hooks.useMoney(comboItem.price || 0) }}</view>
<tm-icon class="ml-8rpx shrink-0" name="arrow-right-s-line" :size="25" color="#98a2b3" />
</view>
</view>
</view>
@@ -86,7 +91,7 @@ import { computed, ref } from "vue"
import { hooks, router } from "@@/index"
import { filePath } from "@@/utils/file"
import type { DailyMenuItem } from "./daily-menu-types"
import type { DailyMenuComboItem, DailyMenuItem } from "./daily-menu-types"
/** 紧凑菜品卡片的输入数据。 */
type Props = {
@@ -162,11 +167,21 @@ const toggleCombo = () => {
comboExpanded.value = !comboExpanded.value
}
/** 携带每日菜谱记录 ID 进入菜谱详情页,避免与在线商品详情混用。 */
/** 套餐子项使用 SPU ID 进入通用商品详情;该 ID 不属于每日菜谱记录,不能传给每日菜谱详情接口。 */
const openComboItemDetail = (comboItem: DailyMenuComboItem) => {
const spuId = String(comboItem.childSpuId || "").trim()
if (!spuId) {
uni.showToast({ title: "套餐商品信息缺失,暂时无法查看详情", icon: "none" })
return
}
router.jump("/pages/canteen/product-detail", { id: spuId, source: "daily" })
}
/** 携带每日菜谱记录 ID 进入商品详情页;单品与套餐共用入口,由详情页按 spuType 区分展示。 */
const openDailyMenuDetail = () => {
const dailyMenuId = String(props.item.id || "").trim()
if (!dailyMenuId) {
uni.showToast({ title: "菜谱信息缺失,暂时无法查看详情", icon: "none" })
uni.showToast({ title: "商品信息缺失,暂时无法查看详情", icon: "none" })
return
}
router.jump("/pages/canteen/daily-menu-detail", {
@@ -0,0 +1,70 @@
<template>
<view class="card bg-white p-24rpx" :aria-busy="props.loading" @tap="emit('open')">
<template v-if="props.loading">
<view class="flex items-center justify-between gap-20rpx" aria-label="用餐人加载中">
<view class="min-w-0 flex-1">
<base-skeleton width="112" height="30" round="6" color="#e8edf3" dark-color="#323232" />
<base-skeleton class="mt-10rpx" width="72%" height="22" round="6" color="#e8edf3"
dark-color="#323232" />
</view>
<base-skeleton class="shrink-0" width="128" height="26" round="6" color="#dcecff"
dark-color="#323232" />
</view>
<base-skeleton class="mt-18rpx" width="126" height="44" round="22" color="#dcecff" dark-color="#323232" />
</template>
<template v-else>
<view class="flex items-center justify-between">
<view>
<view class="sub-text-30">用餐人</view>
<view class="second-text-22 mt-6rpx color-#999">
{{ props.guardianWardEnabled ? "每位用餐人将生成一笔独立订单" : "当前商户仅支持为本人订餐" }}
</view>
</view>
<view v-if="props.guardianWardEnabled" class="flex items-center color-#0094ff text-24rpx font-700">
<text>已选 {{ props.selectedOptions.length }} 人</text>
<tm-icon name="arrow-right-s-line" :size="26" color="#0094ff" />
</view>
<view v-else class="second-text-24 color-#999">仅本人</view>
</view>
<view class="mt-18rpx flex flex-wrap gap-12rpx">
<view v-for="item in props.selectedOptions" :key="item.userId"
class="flex items-center rounded-full bg-#eef8ff px-14rpx py-8rpx color-#0094ff text-22rpx">
<text class="max-w-180rpx show-line">{{ item.name }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
/** 用餐人触发卡片展示的最小人员信息。 */
type SelectedDinerOption = {
/** 用户 ID,仅用于列表稳定渲染。 */
userId: string
/** 用户展示名称。 */
name: string
}
/** 触发卡片展示参数,由用餐人选择组件的查询与选择状态提供。 */
const props = withDefaults(
defineProps<{
/** 是否正在加载用餐人列表,加载期间显示骨架并保留点击区域尺寸。 */
loading?: boolean
/** 当前商户是否允许选择被监护人代订。 */
guardianWardEnabled?: boolean
/** 已选用餐人,用于展示人数和姓名标签。 */
selectedOptions?: SelectedDinerOption[]
}>(),
{
loading: false,
guardianWardEnabled: false,
selectedOptions: () => [],
},
)
/** 触发卡片点击事件,由持有完整选择状态的父组件决定是否打开抽屉。 */
const emit = defineEmits<{
/** 请求打开用餐人选择抽屉。 */
open: []
}>()
</script>
@@ -1,39 +1,9 @@
<template>
<view class="card bg-white p-24rpx" :aria-busy="loading" @tap="openDrawer">
<template v-if="loading">
<view class="flex items-center justify-between gap-20rpx" aria-label="用餐人加载中">
<view class="min-w-0 flex-1">
<base-skeleton width="112" height="30" round="6" color="#e8edf3" dark-color="#323232" />
<base-skeleton class="mt-10rpx" width="72%" height="22" round="6" color="#e8edf3"
dark-color="#323232" />
</view>
<base-skeleton class="shrink-0" width="128" height="26" round="6" color="#dcecff"
dark-color="#323232" />
</view>
<base-skeleton class="mt-18rpx" width="126" height="44" round="22" color="#dcecff" dark-color="#323232" />
</template>
<template v-else>
<view class="flex items-center justify-between">
<view>
<view class="sub-text-30">用餐人</view>
<view class="second-text-22 mt-6rpx color-#999">
{{ props.guardianWardEnabled ? "每位用餐人将生成一笔独立订单" : "当前商户仅支持为本人订餐" }}
</view>
</view>
<view v-if="props.guardianWardEnabled" class="flex items-center color-#0094ff text-24rpx font-700">
<text>已选 {{ selectedOptions.length }} 人</text>
<tm-icon name="arrow-right-s-line" :size="26" color="#0094ff" />
</view>
<view v-else class="second-text-24 color-#999">仅本人</view>
</view>
<view class="mt-18rpx flex flex-wrap gap-12rpx">
<view v-for="item in selectedOptions" :key="item.userId"
class="flex items-center rounded-full bg-#eef8ff px-14rpx py-8rpx color-#0094ff text-22rpx">
<text class="max-w-180rpx show-line">{{ item.name }}</text>
</view>
</view>
</template>
</view>
<slot :open-drawer="openDrawer" :loading="loading" :selected-options="selectedOptions"
:guardian-ward-enabled="props.guardianWardEnabled">
<reserve-diners-trigger :loading="loading" :selected-options="selectedOptions"
:guardian-ward-enabled="props.guardianWardEnabled" @open="openDrawer" />
</slot>
<tm-drawer v-model:show="show" position="bottom" size="65%" title="选择用餐人" :show-close="true" :show-cancel="false"
:show-footer="!loading" confirm-text="确认选择" @confirm="confirmSelection">
<view v-if="loading" aria-label="用餐人列表加载中">
@@ -111,22 +81,51 @@ import STORAGE_KEY from "@@/constant/storage-constant"
import { router } from "@@/index"
import { useUserStore } from "@@/stores/user"
import { filePath } from "@@/utils/file"
import ReserveDinersTrigger from "./reserve-diners-trigger.vue"
/** 订餐可选人员,来源于当前用户及有效被监护人关系。 */
type DinerOption = {
/** 用户 ID,长整型按字符串保存并提交。 */
userId: string
/** 用户展示名称。 */
name: string
/** 用户头像文件路径,空值时由界面展示姓名首字。 */
avatar?: string
/** 是否为当前登录用户本人。 */
isSelf?: boolean
}
/** 默认插槽参数;App 页面可用它分离页面内触发卡片与页面外抽屉。 */
type ReserveDinersSlotProps = {
/** 请求打开用餐人抽屉的方法,商户未开启代订时调用无副作用。 */
openDrawer: () => void
/** 用餐人关系是否仍在加载。 */
loading: boolean
/** 当前已选且仍有效的用餐人。 */
selectedOptions: DinerOption[]
/** 当前商户是否允许选择被监护人代订。 */
guardianWardEnabled: boolean
}
defineSlots<{
/** 自定义触发内容;也可包裹页面布局,让组件内部抽屉渲染到 crud-layout 外。 */
default?: (slotProps: ReserveDinersSlotProps) => any
}>()
/** 本地记忆的用餐人缓存结构,缓存只保存已校验过的用户 ID。 */
type RememberedDiners = {
/** 上次确认选择的用餐人 ID,关系失效后会在重新查询时移除。 */
dinerUserIds?: string[]
}
/** 用餐人选择缓存键前缀,实际键会追加当前登录用户 ID,避免账号间串用。 */
const REMEMBERED_DINERS_STORAGE_PREFIX = "reserve-diners"
/** 选择器输入参数,人员 ID 由父页面持有,商户配置决定是否查询被监护人。 */
const props = withDefaults(
defineProps<{
/** 已确认的用餐人 ID 列表,长整型统一按字符串处理。 */
modelValue?: string[]
/** 当前商户是否开启监护关系代订能力。 */
guardianWardEnabled?: boolean
}>(),
{
@@ -134,44 +133,71 @@ const props = withDefaults(
guardianWardEnabled: false,
},
)
/** 选择确认后同步 ID 列表及完整展示对象。 */
const emit = defineEmits<{
/** 更新父页面持有的用餐人 ID 列表。 */
"update:modelValue": [string[]]
/** 通知父页面当前已选人员对象,用于后续订单展示与校验。 */
change: [DinerOption[]]
}>()
/** 当前登录用户状态,用于构造“本人”选项。 */
const userStore = useUserStore()
/** 用餐人抽屉是否显示。 */
const show = ref(false)
/** 被监护人关系是否正在加载。 */
const loading = ref(false)
/** 最近一次被监护人查询是否失败;失败时仍允许为本人订餐。 */
const loadFailed = ref(false)
/** 服务端返回且当前有效的被监护人选项。 */
const wards = ref<DinerOption[]>([])
/** 抽屉内尚未确认的临时选择,取消关闭时不得影响父页面。 */
const tempSelectedIds = ref<string[]>([])
/** 当前已经持久化的“记住人员”开关状态。 */
const rememberSelection = ref(false)
/** 抽屉内尚未确认的“记住人员”开关状态。 */
const tempRememberSelection = ref(false)
/** 组件初始化时父页面是否已给出选择,用于避免缓存覆盖明确入参。 */
const initialSelectionProvided = props.modelValue.length > 0
/** 被监护人查询序号,用于丢弃商户配置快速变化产生的过期响应。 */
let loadRequestSeq = 0
/** 本地持久化的登录信息,作为 Pinia 尚未恢复时构造本人选项的降级来源。 */
const storedUserInfo: any = uni.getStorageSync(STORAGE_KEY.USERINFO) || {}
/** 兼容历史登录信息的两种用户对象结构。 */
const storedUser = storedUserInfo?.sysUser || storedUserInfo?.userInfo || {}
/** 当前登录用户对应的本人选项,优先读取已恢复的 Pinia 用户状态。 */
const selfOption = computed<DinerOption>(() => ({
userId: String(userStore.userInfo?.userId || userStore.userId || storedUser?.userId || storedUserInfo?.userId || ""),
name: userStore.userInfo?.name || storedUser?.name || "本人",
avatar: userStore.userInfo?.avatar || storedUser?.avatar,
isSelf: true,
}))
/** 当前可选择人员,本人始终排在有效被监护人之前。 */
const options = computed(() => [selfOption.value, ...wards.value].filter(item => item.userId))
/** 已确认且仍存在于有效选项中的人员,用于触发卡片与父页面回调。 */
const selectedOptions = computed(() => options.value.filter(item => props.modelValue.map(String).includes(item.userId)))
/** 将后端头像路径转换为当前平台可访问地址。 */
const avatarUrl = (avatar?: string) => filePath(avatar)
/** 判断指定人员是否在抽屉的临时选择中。 */
const isTempSelected = (userId: string) => tempSelectedIds.value.includes(String(userId))
/** 生成当前登录用户隔离的用餐人缓存键;用户 ID 未就绪时不允许读写缓存。 */
const rememberedDinersStorageKey = () => selfOption.value.userId
? `${REMEMBERED_DINERS_STORAGE_PREFIX}:${selfOption.value.userId}`
: ""
/**
* 规范化并同步已确认人员,确保父页面只接收非空且不重复的字符串 ID。
*/
const emitSelection = (ids: string[]) => {
const normalizedIds = [...new Set(ids.map(String).filter(Boolean))]
emit("update:modelValue", normalizedIds)
emit("change", options.value.filter(item => normalizedIds.includes(item.userId)))
}
/**
* 读取当前账号记住的用餐人,并同步已持久化的开关状态;缓存格式异常时按未记忆处理。
*/
const readRememberedDinerIds = () => {
const storageKey = rememberedDinersStorageKey()
if (!storageKey) return []
@@ -182,6 +208,9 @@ const readRememberedDinerIds = () => {
return normalizedIds
}
/**
* 首次未显式传入选择时恢复缓存,并用最新有效关系过滤越权或失效人员。
*/
const restoreRememberedDiners = () => {
const rememberedIds = readRememberedDinerIds()
if (initialSelectionProvided || !rememberedIds.length) return false
@@ -196,6 +225,9 @@ const restoreRememberedDiners = () => {
return true
}
/**
* 按抽屉确认时的开关保存或清除当前账号缓存,存储失败不影响本次订餐选择。
*/
const persistRememberedDiners = (ids: string[]) => {
const storageKey = rememberedDinersStorageKey()
if (!storageKey) return
@@ -212,6 +244,9 @@ const persistRememberedDiners = (ids: string[]) => {
}
}
/**
* 父页面尚未提供选择时补入本人,保证订餐计价和提交始终至少有一名用餐人。
*/
const ensureSelfSelected = () => {
const selfId = selfOption.value.userId
if (!selfId) return
@@ -236,6 +271,9 @@ const resetToSelf = () => {
}
}
/**
* 查询当前用户有效的被监护人,并在响应返回后校验缓存与父页面现有选择。
*/
const loadWards = async () => {
if (!props.guardianWardEnabled) {
resetToSelf()
@@ -275,6 +313,9 @@ const loadWards = async () => {
}
}
/**
* 打开用餐人抽屉;通过默认插槽暴露后,App 页面可让抽屉挂在 crud-layout 外部,避开 scroll-view 裁切。
*/
const openDrawer = () => {
if (!props.guardianWardEnabled) return
tempSelectedIds.value = props.modelValue.map(String)
@@ -283,6 +324,9 @@ const openDrawer = () => {
show.value = true
}
/**
* 切换抽屉内临时选择;业务要求至少保留一名实际用餐人。
*/
const toggleOption = (userId: string) => {
const id = String(userId)
if (isTempSelected(id)) {
@@ -308,6 +352,9 @@ const openFaceReviewRecords = (item: DinerOption) => {
})
}
/**
* 确认临时选择并按开关更新缓存,关闭抽屉前同步父页面订单状态。
*/
const confirmSelection = () => {
if (!tempSelectedIds.value.length) return uni.showToast({ title: "至少选择一名用餐人", icon: "none" })
const selectedIds = [...tempSelectedIds.value]
@@ -316,7 +363,9 @@ const confirmSelection = () => {
show.value = false
}
/** 登录用户 ID 恢复后补齐默认本人选择。 */
watch(() => selfOption.value.userId, ensureSelfSelected, { immediate: true })
/** 商户代订能力变化时重新校验关系;关闭能力时立即收敛为本人。 */
watch(
() => props.guardianWardEnabled,
(enabled) => (enabled ? loadWards() : resetToSelf()),
@@ -517,8 +517,10 @@ export default {
_customStyle,
]"
>
<!-- 内容区需截断触摸冒泡,避免遮罩层的 preventDefault 同时禁用内部 scroll-view。 -->
<view
@click.stop=""
@touchmove.stop=""
ref="tmDrawerWrapContent"
class="tmDrawerWrapContent"
:class="[