diff --git a/api/appointment.js b/api/appointment.js new file mode 100644 index 0000000..24c7984 --- /dev/null +++ b/api/appointment.js @@ -0,0 +1,52 @@ +/** + * 预约服务买家端 API + */ +import { http, Method } from '@/utils/request.js' + +/** 查询可预约时段 */ +export function listSlots(skuId, slotDate) { + return http.request({ + url: '/buyer/appointment/slots', + method: Method.GET, + needToken: true, + params: { skuId, slotDate }, + }) +} + +/** 绑定预约结算上下文(addToCart 后、createTrade 前) */ +export function bindCheckout(data) { + return http.request({ + url: '/buyer/appointment/checkout/bind', + method: Method.POST, + needToken: true, + data, + }) +} + +/** 预约商品扩展配置 */ +export function getGoodsExt(goodsId) { + return http.request({ + url: `/buyer/appointment/goods/${goodsId}/ext`, + method: Method.GET, + needToken: false, + }) +} + +/** 买家预约订单详情(时段、核销码) */ +export function getAppointmentOrderDetail(orderSn) { + return http.request({ + url: `/buyer/appointment/order/${orderSn}`, + method: Method.GET, + needToken: true, + }) +} + +/** 改期申请 */ +export function requestReschedule(orderSn, payload) { + return http.request({ + url: `/buyer/appointment/orders/${orderSn}/reschedule`, + method: Method.POST, + needToken: true, + data: payload || {}, + }) +} diff --git a/api/promotions.js b/api/promotions.js index 69b4455..2724ea0 100644 --- a/api/promotions.js +++ b/api/promotions.js @@ -202,4 +202,40 @@ export function getMineBargainLog(params) { }); } +/** + * 分页查询当前客户礼品卡 + */ +export function getGiftCardCashMemberCardPage(params) { + return http.request({ + url: '/promotion/giftCardCash/memberCard', + method: Method.GET, + needToken: true, + params, + }) +} + +/** + * 绑定礼品卡(卡号 + 兑换码/卡密) + */ +export function bindGiftCardCashMemberCard(data) { + return http.request({ + url: '/promotion/giftCardCash/memberCard/bind', + method: Method.POST, + needToken: true, + header: { 'content-type': 'application/json' }, + data, + }) +} + +/** + * 激活礼品卡 + */ +export function activateGiftCardCashMemberCard(memberCardId) { + return http.request({ + url: `/promotion/giftCardCash/memberCard/${memberCardId}/activate`, + method: Method.POST, + needToken: true, + }) +} + diff --git a/components/m-buy/goods.vue b/components/m-buy/goods.vue index 55ae097..7c5b8e4 100644 --- a/components/m-buy/goods.vue +++ b/components/m-buy/goods.vue @@ -121,6 +121,7 @@ import { goodsFormatPrice } from '@/utils/filters.js' import { isECoupon, isVirtualGoods, + isAppointmentGoods, getECouponStock, getECouponMaxBuyNum, } from '@/utils/goodsType.js' @@ -173,12 +174,13 @@ const maxBuyNum = computed(() => { return 999 }) -// 电子卡券禁止加购(后端 CARD_KEY_E_COUPON_CART_FORBIDDEN) +// 电子卡券/预约商品禁止加购 const showAddToCart = computed(() => { return ( buyType.value != 'PINTUAN' && !isVirtualGoods(props.goodsDetail?.goodsType) && - !isECoupon(props.goodsDetail?.goodsType) + !isECoupon(props.goodsDetail?.goodsType) && + !isAppointmentGoods(props.goodsDetail?.goodsType) ) }) @@ -341,6 +343,14 @@ const addToCartOrBuy = (val: string) => { data.cartType = resolveCartType() API_trade.addToCart(data).then((res) => { if (res.data.success) { + if (isAppointmentGoods(props.goodsDetail?.goodsType)) { + const goodsId = props.goodsDetail.goodsId || '' + uni.navigateTo({ + url: `/pages/order/appointment/appointmentCheckout?skuId=${props.goodsDetail.id}&goodsId=${goodsId}&num=${num.value}`, + }) + closeMask() + return + } uni.navigateTo({ url: `/pages/order/fillorder?way=${data.cartType}&addr=${props.addr?.id || ''}&parentOrder=${encodeURIComponent(JSON.stringify(parentOrder.value || ''))}`, }) diff --git a/pages.json b/pages.json index 78db80d..05abb10 100644 --- a/pages.json +++ b/pages.json @@ -771,6 +771,12 @@ } }, + { + "path": "gift-card/myGiftCards", + "style": { + "navigationBarTitleText": "我的礼品卡" + } + }, { "path": "payment/payOrder", "style": { @@ -867,6 +873,18 @@ "enablePullDownRefresh": true } }, + { + "path": "appointment/appointmentCheckout", + "style": { + "navigationBarTitleText": "预约信息" + } + }, + { + "path": "appointment/appointmentReschedule", + "style": { + "navigationBarTitleText": "申请改期" + } + }, { "path": "deliverDetail", "style": { diff --git a/pages/cart/gift-card/myGiftCards.vue b/pages/cart/gift-card/myGiftCards.vue new file mode 100644 index 0000000..9612fa1 --- /dev/null +++ b/pages/cart/gift-card/myGiftCards.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/pages/order/afterSales/afterSalesDetail.vue b/pages/order/afterSales/afterSalesDetail.vue index 0786a8f..fe01e0f 100644 --- a/pages/order/afterSales/afterSalesDetail.vue +++ b/pages/order/afterSales/afterSalesDetail.vue @@ -31,7 +31,7 @@ {{ getGoodsName(item) }} - ¥{{ unitPrice(applyInfo.applyRefundPrice) }} + ¥{{ unitPrice(displayRefundPrice) }} 购买数量: {{ item.num }} @@ -164,11 +164,13 @@ + + diff --git a/pages/order/appointment/appointmentReschedule.vue b/pages/order/appointment/appointmentReschedule.vue new file mode 100644 index 0000000..65b8399 --- /dev/null +++ b/pages/order/appointment/appointmentReschedule.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/pages/order/fillorder.vue b/pages/order/fillorder.vue index 215f74b..219eb69 100644 --- a/pages/order/fillorder.vue +++ b/pages/order/fillorder.vue @@ -68,6 +68,11 @@ + + 预约信息 + {{ appointmentCheckoutSummary }} + + @@ -336,19 +341,23 @@ -
+
- 礼品卡 + + + 使用礼品卡 + 使用说明 + + -¥{{ unitPrice(giftCardDeductAmount) }} - {{ giftCardList.length }} 张可用已抵扣 -¥{{ unitPrice(giftCardDeductAmount) }} + {{ giftCardList.length }} 张可用 - + 暂无可用礼品卡 + - {{ item.giftCardName || '礼品卡' }} - - 余额 ¥{{ unitPrice(item.balance) }} - {{ item.cardNo }} + + + + + {{ item.giftCardName || '礼品卡' }} + 面值{{ item.faceText }}元 + + + 现金卡 + {{ item.expireText }} + + + + + ¥{{ unitPrice(item.balance) }} + 余额 + + {{ item.cardNo }} + + 已选
@@ -428,7 +453,10 @@ import { secrecyMobile, isLogin, } from '@/utils/filters.js' -import { hasECouponInCheckout } from '@/utils/goodsType.js' +import { hasECouponInCheckout, hasAppointmentInCheckout } from '@/utils/goodsType.js' +import { SERVICE_MODE_LABEL, formatDate } from '@/utils/appointmentGoods.js' +import { bindCheckout } from '@/api/appointment.js' +import { decorateGiftCard, GIFT_CARD_PAY_NOTICE } from '@/utils/giftCard.js' const store = useStore() const { proxy } = getCurrentInstance()! @@ -474,12 +502,32 @@ const originOrderData = ref('') // E_COUPON 结算:免地址/免配送(hidePhysicalCheckout);优惠券/活动/礼品卡可用(M-02) const isECouponCheckout = computed(() => hasECouponInCheckout(orderMessage.value)) +const isAppointmentCheckout = computed(() => hasAppointmentInCheckout(orderMessage.value)) const isVirtualCheckout = computed(() => orderMessage.value?.cartTypeEnum === 'VIRTUAL') const hidePhysicalCheckout = computed( - () => isVirtualCheckout.value || isECouponCheckout.value + () => isVirtualCheckout.value || isECouponCheckout.value || isAppointmentCheckout.value ) +const appointmentCheckoutContext = ref(null) -const giftCardList = computed(() => orderMessage.value?.canUseGiftCards || []) +const appointmentCheckoutSummary = computed(() => { + const ctx = + appointmentCheckoutContext.value || + orderMessage.value?.appointmentCheckout || + orderMessage.value?.appointmentContext + if (!ctx) return '' + const date = formatDate(ctx.slotDate) + const mode = SERVICE_MODE_LABEL[ctx.serviceMode] || ctx.serviceMode || '' + const slot = ctx.slotStartTime && ctx.slotEndTime ? `${ctx.slotStartTime}-${ctx.slotEndTime}` : '' + const contact = ctx.contactName ? `${ctx.contactName} ${ctx.contactPhone || ''}` : '' + return [date, slot, mode, contact].filter(Boolean).join(' · ') +}) + +const selectedGiftCardIds = computed(() => orderMessage.value?.selectedGiftCardIds || []) +const giftCardList = computed(() => + (orderMessage.value?.canUseGiftCards || []).map((item: any) => + decorateGiftCard(item, selectedGiftCardIds.value) + ) +) const giftCardDeductAmount = computed(() => { const dto = orderMessage.value?.priceDetailDTO if (!dto) return 0 @@ -488,8 +536,6 @@ const giftCardDeductAmount = computed(() => { } return Number(dto.giftCardPrice) || 0 }) -const selectedGiftCardIds = computed(() => orderMessage.value?.selectedGiftCardIds || []) -const giftCardExpanded = ref(false) watch( remarkVal, @@ -527,9 +573,10 @@ onShow(async () => { mask: true, }) try { + appointmentCheckoutContext.value = uni.getStorageSync('appointment_checkout_context') || null await getOrderList() - // E_COUPON 不需要配送方式 - if (!isECouponCheckout.value) { + // E_COUPON / 预约商品不需要配送方式 + if (!isECouponCheckout.value && !isAppointmentCheckout.value) { await getDistribution() } if (routerVal.value.way == 'PINTUAN') { @@ -605,6 +652,15 @@ function invoice() { invoiceFlag.value = true } +function showGiftCardNotice() { + uni.showModal({ + title: '礼品卡使用说明', + content: GIFT_CARD_PAY_NOTICE, + showCancel: false, + confirmText: '我知道了', + }) +} + function isGiftCardSelected(item: any) { if (!item?.id) return false return (selectedGiftCardIds.value || []).map(String).includes(String(item.id)) @@ -717,36 +773,62 @@ function createTradeFun() { delete submit.parentOrderSn } - API_Trade.createTrade(submit).then((res) => { - if (res.data.success) { - uni.showToast({ - title: '创建订单成功!', - duration: 2000, - icon: 'none', - }) - if (orderMessage.value.priceDetailDTO.billPrice == 0) { - uni.navigateTo({ - url: '/pages/order/myOrder?status=0', + const runCreate = () => + API_Trade.createTrade(submit).then((res) => { + if (res.data.success) { + uni.removeStorageSync('appointment_checkout_context') + uni.showToast({ + title: '创建订单成功!', + duration: 2000, + icon: 'none', }) - } else { - // #ifdef MP-WEIXIN - pay(res.data.result.sn) - // #endif + if (orderMessage.value.priceDetailDTO.billPrice == 0) { + uni.navigateTo({ + url: '/pages/order/myOrder?status=0', + }) + } else { + // #ifdef MP-WEIXIN + pay(res.data.result.sn) + // #endif - // #ifndef MP-WEIXIN - navigateTo( - `/pages/cart/payment/payOrder?trade_sn=${res.data.result.sn}` - ) - // #endif + // #ifndef MP-WEIXIN + navigateTo( + `/pages/cart/payment/payOrder?trade_sn=${res.data.result.sn}` + ) + // #endif + } + } else { + uni.showToast({ + title: res.data.message, + duration: 2000, + icon: 'none', + }) } - } else { + }) + + if (isAppointmentCheckout.value) { + const ctx = + appointmentCheckoutContext.value || + uni.getStorageSync('appointment_checkout_context') + if (!ctx) { uni.showToast({ - title: res.data.message, - duration: 2000, + title: '预约信息不完整,请返回重选时段', icon: 'none', }) + return } - }) + bindCheckout(ctx) + .then(runCreate) + .catch((e: any) => { + uni.showToast({ + title: e?.data?.message || e?.message || '预约信息提交失败', + icon: 'none', + }) + }) + return + } + + runCreate() }, 3000) } @@ -861,8 +943,8 @@ function getOrderList() { ;(store.state as any).canUseCoupons = res.data.result.canUseCoupons ;(store.state as any).cantUseCoupons = res.data.result.cantUseCoupons - // E_COUPON 免地址,不自动回填 memberAddress - if (!hasECouponInCheckout(res.data.result)) { + // E_COUPON / 预约商品免地址,不自动回填 memberAddress + if (!hasECouponInCheckout(res.data.result) && !hasAppointmentInCheckout(res.data.result)) { if (!res.data.result.memberAddress) { getUserAddress() } else { @@ -894,6 +976,22 @@ page { } diff --git a/pages/order/myOrder.vue b/pages/order/myOrder.vue index 2a29f92..3c8d7fc 100644 --- a/pages/order/myOrder.vue +++ b/pages/order/myOrder.vue @@ -62,7 +62,7 @@ v-if="order.allowOperationVO.pay" @click="waitPay(order)">立即付款 + v-if="showOrderCancel(order)" @click="onCancel(order.sn)"> 取消订单 @@ -71,7 +71,7 @@ 确认收货 退款/售后 @@ -123,7 +123,8 @@ import { orderStatusList, } from '@/utils/filters.js' // E_COUPON 订单不展示确认收货、退款/售后(FR-B-04) -import { isECouponOrder } from '@/utils/goodsType.js' +import { isECouponOrder, isAppointmentOrder } from '@/utils/goodsType.js' +import { getAppointmentAllowOperation } from '@/utils/appointmentGoods.js' const store = useStore() const { proxy } = getCurrentInstance()! @@ -371,6 +372,23 @@ function orderStateExp(state: number) { return { stateTip, stateTipColor } } +function showOrderCancel(order: any) { + if (isAppointmentOrder(order?.orderType)) { + return !!getAppointmentAllowOperation(order)?.cancel + } + return !!order?.allowOperationVO?.cancel +} + +function showOrderAfterSale(order: any) { + if (isECouponOrder(order?.orderType)) return false + if (isAppointmentOrder(order?.orderType)) { + const allow = getAppointmentAllowOperation(order) + if (!allow?.afterSale) return false + } + const status = order?.groupAfterSaleStatus + return status && (status.includes('NOT_APPLIED') || status.includes('PART_AFTER_SALE')) +} + function navigateToOrderDetail(sn: string) { uni.navigateTo({ url: './orderDetail?sn=' + sn, diff --git a/pages/order/orderDetail.vue b/pages/order/orderDetail.vue index b5020da..d3e9b19 100644 --- a/pages/order/orderDetail.vue +++ b/pages/order/orderDetail.vue @@ -11,7 +11,55 @@ - + + + 预约时间 + {{ appointmentSlotText }} + + + 服务方式 + {{ serviceModeLabel }} + + + 联系人 + {{ appointmentDetail.contactName }} {{ appointmentDetail.contactPhone }} + + + 服务地址 + {{ appointmentDetail.serviceAddressSnapshot }} + + + {{ appointmentFormGroups.length > 1 ? `补充信息${gi + 1}` : '补充信息' }} + + + {{ row.fieldLabel }}:{{ row.value }} + + + + + 核销码({{ appointmentCodes.length }}) + + {{ code.seqNo ? '#' + code.seqNo + ' ' : '' }}{{ code.code }} + ({{ verificationStatusText(code.status) }}) + + + + 申请改期 + + + 查看卡密 @@ -92,7 +140,7 @@ ¥{{unitPrice(sku.goodsPrice) }} - + {{refundPriceList(sku.isRefund)}} ({{unitPrice(sku.refundPrice, "¥") }}) @@ -112,7 +160,7 @@ 商品总价: ¥{{unitPrice(order.goodsPrice) }} - + 运费: ¥{{unitPrice(order.freightPrice) }} @@ -142,7 +190,7 @@ 取消订单 查看物流 store.getters.lightColor) @@ -319,7 +376,30 @@ const orderPackage = ref([]) const legacyTraces = ref(null) const isECouponOrder = computed(() => checkECouponOrder(order.value?.orderType)) +const isAppointmentOrderFlag = computed(() => checkAppointmentOrder(order.value?.orderType)) const isNonPhysicalOrder = computed(() => checkNonPhysicalOrder(order.value?.orderType)) +const appointmentDetail = ref>({}) +const appointmentAllow = computed(() => + getAppointmentAllowOperation(order.value) || { cancel: false, afterSale: false, reschedule: false } +) +const showCancelBtn = computed(() => { + if (isAppointmentOrderFlag.value) return appointmentAllow.value.cancel + return orderDetail.value.allowOperationVO?.cancel === true +}) +const appointmentCodes = computed(() => appointmentDetail.value?.codes || []) +const serviceModeLabel = computed( + () => SERVICE_MODE_LABEL[appointmentDetail.value?.serviceMode] || appointmentDetail.value?.serviceMode || '' +) +const appointmentSlotText = computed(() => { + const d = appointmentDetail.value + if (!d?.slotDate) return '—' + const date = formatDate(d.slotDate) + const slot = d.slotStartTime && d.slotEndTime ? `${d.slotStartTime}-${d.slotEndTime}` : '' + return [date, slot].filter(Boolean).join(' ') +}) +const appointmentFormGroups = computed(() => + parseFormAnswerGroups(appointmentDetail.value?.formAnswerJson) +) const ecouponCardKeyDelivered = computed(() => orderHasCardKeyContent(orderGoodsList.value) ) @@ -335,6 +415,7 @@ const allowOperation = computed(() => ) const showDeliveryInfoBlock = computed(() => { + if (isAppointmentOrderFlag.value) return true if (isECouponOrder.value) return true if (order.value.orderType === 'VIRTUAL') return false if (order.value.deliveryMethod === 'SELF_PICK_UP') return false @@ -444,6 +525,29 @@ function ByUserMessage(orderItem: any) { }) } +function verificationStatusText(status: string) { + return VERIFICATION_CODE_STATUS_TEXT[status] || status || '—' +} + +async function loadAppointmentDetail(orderSnParam: string) { + if (!checkAppointmentOrder(order.value?.orderType)) { + appointmentDetail.value = {} + return + } + try { + const res = await getAppointmentOrderDetail(orderSnParam) + appointmentDetail.value = res.data?.result || {} + } catch { + appointmentDetail.value = {} + } +} + +function onRescheduleStub() { + uni.navigateTo({ + url: `/pages/order/appointment/appointmentReschedule?orderSn=${order.value.sn}`, + }) +} + function loadData(orderSnParam: string) { uni.showLoading({ title: '加载中' }) getOrderDetail(orderSnParam).then(async (res) => { @@ -454,6 +558,7 @@ function loadData(orderSnParam: string) { if (!order.value.allowOperationVO && result.allowOperationVO) { order.value.allowOperationVO = result.allowOperationVO } + await loadAppointmentDetail(orderSnParam) await loadDelivery(order.value) hideLoadingIfNeeded() }) @@ -774,6 +879,39 @@ page, } } +.appointment-entry__row { + display: flex; + font-size: 26rpx; + margin-bottom: 12rpx; + line-height: 1.5; +} +.appointment-entry__row .label { + color: #999; + width: 140rpx; + flex-shrink: 0; +} +.form-answers { + flex: 1; +} +.form-answer-row { + margin-bottom: 4rpx; +} +.appointment-codes__title { + font-size: 26rpx; + font-weight: 600; + margin: 16rpx 0 8rpx; +} +.appointment-code-item { + margin-bottom: 8rpx; +} +.code-status { + color: #999; + font-size: 22rpx; +} +.reschedule-stub { + margin-top: 16rpx; +} + .verificationCode { font-weight: bold; letter-spacing: 2rpx; diff --git a/pages/tabbar/user/utils/tool.vue b/pages/tabbar/user/utils/tool.vue index 716b764..241a195 100644 --- a/pages/tabbar/user/utils/tool.vue +++ b/pages/tabbar/user/utils/tool.vue @@ -53,6 +53,10 @@ 优惠券 + + + 礼品卡 + diff --git a/utils/appointmentGoods.js b/utils/appointmentGoods.js new file mode 100644 index 0000000..e25bf39 --- /dev/null +++ b/utils/appointmentGoods.js @@ -0,0 +1,297 @@ +/** + * 预约商品(APPOINTMENT_GOODS)买家端工具 + * 规则:禁加购、仅 BUY_NOW、免物流地址、支持买家售后(含部分数量退款) + */ +export const APPOINTMENT_GOODS_TYPE = 'APPOINTMENT_GOODS' +export const APPOINTMENT_ORDER_TYPE = 'APPOINTMENT' + +export const SERVICE_MODE = { + IN_STORE: 'IN_STORE', + ON_SITE: 'ON_SITE', +} + +export const SERVICE_MODE_LABEL = { + IN_STORE: '到店服务', + ON_SITE: '上门服务', +} + +export const VERIFICATION_CODE_STATUS = { + UNUSED: 'UNUSED', + USED: 'USED', + INVALID: 'INVALID', +} + +export const VERIFICATION_CODE_STATUS_TEXT = { + UNUSED: '待核销', + USED: '已核销', + INVALID: '已失效', +} + +export function isAppointmentGoods(goodsType) { + return goodsType === APPOINTMENT_GOODS_TYPE +} + +export function isAppointmentOrder(orderType) { + return orderType === APPOINTMENT_ORDER_TYPE +} + +/** + * 结算页识别当前交易是否含预约 SKU(免地址/免配送) + */ +export function hasAppointmentInCheckout(orderMessage) { + if (!orderMessage) return false + const checked = orderMessage.checkedSkuList || [] + if (checked.some((item) => isAppointmentGoods(item?.goodsSku?.goodsType))) { + return true + } + const cartList = orderMessage.cartList || [] + return cartList.some((cart) => + (cart.checkedSkuList || []).some((item) => + isAppointmentGoods(item?.goodsSku?.goodsType) + ) + ) +} + +export function parseServiceModes(ext) { + if (!ext) return [SERVICE_MODE.IN_STORE] + const raw = ext.serviceModes + if (Array.isArray(raw) && raw.length) return raw + if (typeof raw === 'string' && raw.trim()) { + return raw.split(',').map((s) => s.trim()).filter(Boolean) + } + return [SERVICE_MODE.IN_STORE] +} + +export function formatSlotLabel(slot) { + if (!slot) return '' + const date = formatDate(slot.slotDate) + const start = slot.slotStartTime || '' + const end = slot.slotEndTime || '' + const qty = + slot.showRemainingQty && slot.availableQty != null + ? `(余${slot.availableQty})` + : '' + return `${date} ${start}-${end}${qty}` +} + +export function formatDate(value) { + if (!value) return '' + if (typeof value === 'string') return value.slice(0, 10) + const d = new Date(value) + if (Number.isNaN(d.getTime())) return '' + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +export function buildBookableDates(ext, days = 30) { + const rangeDays = Number(ext?.bookingDateRangeDays) || days + const list = [] + const today = new Date() + today.setHours(0, 0, 0, 0) + for (let i = 0; i < rangeDays; i++) { + const d = new Date(today) + d.setDate(today.getDate() + i) + list.push(formatDate(d)) + } + return list +} + +export function buildCancelNotice(ext) { + if (!ext) return '请按时到店/等候服务;具体取消规则以商家设置为准。' + const mode = ext.cancelMode + const hours = ext.cancelHours + if (mode === 'NOT_ALLOWED') return '预约成功后不可取消,请谨慎下单。' + if (mode === 'ALLOW_BEFORE_HOURS' && hours) { + return `服务开始前 ${hours} 小时可免费取消,逾期可能无法退款。` + } + return '请按时到店/等候服务;具体取消规则以商家设置为准。' +} + +export function parseFormFields(schemaJson) { + if (!schemaJson) return [] + let data = schemaJson + if (typeof data === 'string') { + try { + data = JSON.parse(data) + } catch { + return [] + } + } + const list = Array.isArray(data) ? data : data?.fields || [] + return list.map((field, index) => { + const fieldType = String(field.field_type || field.fieldType || field.type || 'TEXT') + .toUpperCase() + .replace(/-/g, '_') + const options = Array.isArray(field.options) + ? field.options.map((item, oi) => + typeof item === 'string' + ? { label: item, value: item } + : { + label: item?.label || item?.name || `选项${oi + 1}`, + value: item?.value || item?.label || `opt_${oi}`, + } + ) + : [] + return { + fieldKey: field.field_key || field.fieldKey || field.id || field.key || `field_${index}`, + fieldLabel: field.field_label || field.fieldLabel || field.label || field.title || `字段${index + 1}`, + fieldType, + required: !!field.required, + hint: field.hint_text || field.hintText || field.hint || field.placeholder || '请输入', + richText: fieldType === 'RICH_TEXT', + richTextContent: field.rich_text_content || field.richTextContent || field.content || '', + options, + optionLabels: options.map((item) => item.label), + contentType: field.content_type || field.contentType || 'TEXT', + defaultValue: field.default_value || field.defaultValue || '', + cityLevel: field.city_level || field.cityLevel || '', + defaultVisibility: field.default_visibility || field.defaultVisibility || '', + defaultDateMode: field.default_date_mode || field.defaultDateMode || '', + defaultDate: field.default_date || field.defaultDate || '', + defaultDateStart: field.default_date_start || field.defaultDateStart || '', + defaultDateEnd: field.default_date_end || field.defaultDateEnd || '', + defaultTimeMode: field.default_time_mode || field.defaultTimeMode || '', + defaultTime: field.default_time || field.defaultTime || '', + defaultTimeStart: field.default_time_start || field.defaultTimeStart || '', + defaultTimeEnd: field.default_time_end || field.defaultTimeEnd || '', + maxUploadCount: Number(field.max_upload_count || field.maxUploadCount) || 1, + } + }) +} + +function pad2(n) { + return String(n).padStart(2, '0') +} + +function currentTime() { + const d = new Date() + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}` +} + +export function defaultFormValue(field) { + const type = field?.fieldType + const opts = field?.options || [] + if (type === 'RADIO' || type === 'SELECT') { + return opts[0]?.value || '' + } + if (type === 'CHECKBOX' || type === 'IMAGE') return [] + if (type === 'TEXT') return field.defaultValue || '' + if (type === 'DATE' && field.defaultVisibility === 'SHOW') { + return field.defaultDateMode === 'SPECIFIED_DATE' ? field.defaultDate || '' : formatDate(new Date()) + } + if (type === 'DATE_RANGE' && field.defaultVisibility === 'SHOW') { + if (field.defaultDateMode === 'SPECIFIED_DATE') { + return [field.defaultDateStart, field.defaultDateEnd].filter(Boolean).join('~') + } + const today = formatDate(new Date()) + return `${today}~${today}` + } + if (type === 'TIME' && field.defaultVisibility === 'SHOW') { + return field.defaultTimeMode === 'SPECIFIED_TIME' ? field.defaultTime || '' : currentTime() + } + if (type === 'TIME_RANGE' && field.defaultVisibility === 'SHOW') { + if (field.defaultTimeMode === 'SPECIFIED_TIME') { + return [field.defaultTimeStart, field.defaultTimeEnd].filter(Boolean).join('~') + } + const now = currentTime() + return `${now}~${now}` + } + return '' +} + +export function isBlankFormValue(value) { + if (value == null) return true + if (typeof value === 'string') { + const text = value.trim() + if (!text || text === '~') return true + if (text.startsWith('~') || text.endsWith('~')) return true + return false + } + if (Array.isArray(value)) return !value.length + return false +} + +export function validateTextContent(field, value) { + if (isBlankFormValue(value)) return true + const type = field?.contentType || 'TEXT' + const text = String(value).trim() + if (type === 'MOBILE') return /^1\d{10}$/.test(text) + if (type === 'ID_CARD') return /(^\d{15}$)|(^\d{17}[\dXx]$)/.test(text) + if (type === 'EMAIL') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) + if (type === 'NUMBER') return /^-?\d+(\.\d+)?$/.test(text) + return true +} + +export function buildFormGroups(ext, quantity) { + const fields = parseFormFields(ext?.systemFormSnapshot) + const fillable = fields.filter((item) => !item.richText) + if (!ext?.systemFormId && !fillable.length) return [] + const mode = ext?.formSubmitMode || 'PER_APPOINTMENT' + const count = mode === 'PER_ORDER' ? 1 : Math.max(Number(quantity) || 1, 1) + return Array.from({ length: count }, () => { + const group = {} + fillable.forEach((field) => { + group[field.fieldKey] = defaultFormValue(field) + }) + return group + }) +} + +export function parseFormAnswerGroups(formAnswerJson) { + if (!formAnswerJson) return [] + let data = formAnswerJson + if (typeof data === 'string') { + try { + data = JSON.parse(data) + } catch { + return [] + } + } + const fields = Array.isArray(data?.fields) ? data.fields : [] + const answers = Array.isArray(data?.answers) ? data.answers : [] + return answers + .map((group) => { + const map = group && typeof group === 'object' && !Array.isArray(group) ? group : {} + const source = fields.length + ? fields + : Object.keys(map).map((key) => ({ fieldKey: key, fieldLabel: key })) + return source + .map((field) => ({ + fieldKey: field.fieldKey, + fieldLabel: field.fieldLabel || field.fieldKey, + value: map[field.fieldKey] ?? '', + })) + .filter((row) => row.value !== '' && row.value != null) + }) + .filter((rows) => rows.length) +} + +/** 预约单:未付款仅可取消;已付款仅可售后 */ +export function getAppointmentAllowOperation(order) { + if (!order || !isAppointmentOrder(order.orderType)) { + return null + } + const unpaid = + order.orderStatus === 'UNPAID' && order.payStatus === 'UNPAID' + const paid = order.payStatus === 'PAID' + return { + cancel: unpaid, + pay: unpaid, + rog: false, + showLogistics: false, + afterSale: paid && order.orderStatus !== 'CANCELLED', + reschedule: paid && ['PAID', 'TAKE'].includes(order.orderStatus), + } +} + +export function computePartialRefundPrice(applyInfo, num) { + const totalNum = Number(applyInfo?.totalNum || applyInfo?.orderItemNum) || 0 + const totalPrice = Number(applyInfo?.applyRefundPrice) || 0 + const qty = Number(num) || 1 + if (!totalNum || totalNum <= 0) return totalPrice + if (qty >= totalNum) return totalPrice + const unit = totalPrice / totalNum + return Math.round(unit * qty * 100) / 100 +} diff --git a/utils/giftCard.js b/utils/giftCard.js new file mode 100644 index 0000000..a630a19 --- /dev/null +++ b/utils/giftCard.js @@ -0,0 +1,77 @@ +/** + * 礼品卡(现金卡)展示与状态工具,与 PC 买家端对齐。 + */ + +export const GIFT_CARD_STATUS = { + AVAILABLE: 'AVAILABLE', + UNAVAILABLE: 'UNAVAILABLE', + PENDING_ACTIVATION: 'PENDING_ACTIVATION', +} + +export const GIFT_CARD_PAY_NOTICE = + '请在礼品卡有效期内使用;结算时勾选礼品卡即可按规则抵扣订单金额,可与优惠券等活动叠加规则以平台说明为准。放弃勾选或取消抵扣将恢复对应余额。' + +export const GIFT_CARD_USE_NOTICE = + '请在有效期内使用本礼品卡;消费时将优先使用卡内余额。具体使用范围以活动规则为准。如有疑问请联系客服。' + +export function formatGiftFaceValue(val) { + if (val == null || val === '') { + return '—' + } + const n = Number(val) + if (Number.isNaN(n)) { + return String(val) + } + return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, '') +} + +export function formatGiftExpire(row) { + const t = row && row.expireTime + if (!t) { + return '长期有效' + } + const d = new Date(t) + if (Number.isNaN(d.getTime())) { + return '长期有效' + } + if (d.getFullYear() >= 2099) { + return '长期有效' + } + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day} 到期` +} + +export function resolveMemberCardId(row) { + if (!row) { + return '' + } + if (row.memberCardId != null && row.memberCardId !== '') { + return String(row.memberCardId) + } + if (row.id != null && row.id !== '') { + return String(row.id) + } + return '' +} + +export function decorateGiftCard(item, selectedIds = []) { + const ids = (selectedIds || []).map(String) + return { + ...item, + selected: ids.indexOf(String(item && item.id)) !== -1, + faceText: formatGiftFaceValue(item && item.faceValue), + expireText: formatGiftExpire(item), + } +} + +export function giftCardActionLabel(status) { + if (status === GIFT_CARD_STATUS.PENDING_ACTIVATION) { + return '激活自用' + } + if (status === GIFT_CARD_STATUS.AVAILABLE) { + return '去使用' + } + return '不可用' +} diff --git a/utils/goodsType.js b/utils/goodsType.js index 649363d..9eebcc5 100644 --- a/utils/goodsType.js +++ b/utils/goodsType.js @@ -13,6 +13,22 @@ export const E_COUPON_ORDER_TYPE = 'E_COUPON' export const VIRTUAL_GOODS_TYPE = 'VIRTUAL_GOODS' export const VIRTUAL_ORDER_TYPE = 'VIRTUAL' +import { + APPOINTMENT_GOODS_TYPE, + APPOINTMENT_ORDER_TYPE, + isAppointmentGoods, + isAppointmentOrder, + hasAppointmentInCheckout, +} from './appointmentGoods.js' + +export { + APPOINTMENT_GOODS_TYPE, + APPOINTMENT_ORDER_TYPE, + isAppointmentGoods, + isAppointmentOrder, + hasAppointmentInCheckout, +} + /** 单次购买数量上限(O-09 默认:min(quantity, 999)) */ export const E_COUPON_MAX_BUY_NUM = 999 @@ -32,13 +48,13 @@ export function isVirtualOrder(orderType) { return orderType === VIRTUAL_ORDER_TYPE } -/** 非实物:含 E_COUPON 与核销型 VIRTUAL_GOODS(详情页隐藏「送至」等) */ +/** 非实物:含 E_COUPON、预约商品与核销型 VIRTUAL_GOODS(详情页隐藏「送至」等) */ export function isNonPhysicalGoods(goodsType) { - return isECoupon(goodsType) || isVirtualGoods(goodsType) + return isECoupon(goodsType) || isVirtualGoods(goodsType) || isAppointmentGoods(goodsType) } export function isNonPhysicalOrder(orderType) { - return isECouponOrder(orderType) || isVirtualOrder(orderType) + return isECouponOrder(orderType) || isVirtualOrder(orderType) || isAppointmentOrder(orderType) } /** 可售库存:E_COUPON 读 SKU quantity(由卡池 syncSkuStock 同步) */