feat: 更新预约功能与礼品卡支持

- 新增礼品卡页面,支持礼品卡的选择与使用。
- 调整预约下单流程,增强预约信息展示与用户交互。
- 更新支付成功页面的导航逻辑,确保用户体验流畅。
- 优化搜索页面的历史关键词加载逻辑,提升性能。
This commit is contained in:
pikachu1995@126.com
2026-09-28 14:37:26 +08:00
parent 2107e3f03a
commit 0bffe81c6b
25 changed files with 2272 additions and 1038 deletions

View File

@@ -31,6 +31,66 @@ export function isAppointmentGoods(goodsType) {
return goodsType === APPOINTMENT_GOODS_TYPE
}
/** 预约商品 SKU quantity 固定为 0,可售性在结算页按时段校验 */
export const APPOINTMENT_BUY_MAX_NUM = 999
export function getAppointmentStock(_sku) {
return APPOINTMENT_BUY_MAX_NUM
}
export function getAppointmentMaxBuyNum(_sku) {
return APPOINTMENT_BUY_MAX_NUM
}
/** 将买家端 specs 转为规格维度列表(与 m-buy 一致) */
export function buildSpecFormatList(specs) {
if (!Array.isArray(specs) || !specs.length) return []
const arr = []
specs.forEach((item) => {
;(item.specValues || []).forEach((spec) => {
if (spec.specName === 'images') return
const values = {
value: spec.specValue,
skuId: item.skuId,
}
let found = arr.find((row) => row.name === spec.specName)
if (!found) {
found = { name: spec.specName, values: [] }
arr.push(found)
}
if (!found.values.find((v) => v.value === values.value)) {
found.values.push(values)
}
})
})
return arr
}
/** 按当前选中规格值匹配 skuId */
export function findSkuIdBySpecSelection(specs, selectedValues) {
if (!Array.isArray(specs) || !specs.length) return ''
const matched = specs.find((item) => {
const specValues = (item.specValues || []).filter((j) => j.specName !== 'images')
if (specValues.length !== selectedValues.length) return false
return specValues.every((sv, idx) => sv.specValue === selectedValues[idx])
})
return matched?.skuId || ''
}
/** 从 specs 解析某 sku 的规格选中值 */
export function getSpecSelectionForSku(specs, skuId) {
const selected = []
if (!Array.isArray(specs) || !skuId) return selected
const item = specs.find((row) => row.skuId === skuId)
if (!item) return selected
;(item.specValues || [])
.filter((v) => v.specName !== 'images')
.forEach((v, idx) => {
selected[idx] = v.specValue
})
return selected
}
export function isAppointmentOrder(orderType) {
return orderType === APPOINTMENT_ORDER_TYPE
}
@@ -55,9 +115,22 @@ export function hasAppointmentInCheckout(orderMessage) {
export function parseServiceModes(ext) {
if (!ext) return [SERVICE_MODE.IN_STORE]
const raw = ext.serviceModes
if (Array.isArray(raw) && raw.length) return raw
if (Array.isArray(raw) && raw.length) {
return raw.map(String).filter(Boolean)
}
if (typeof raw === 'string' && raw.trim()) {
return raw.split(',').map((s) => s.trim()).filter(Boolean)
const trimmed = raw.trim()
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed) && parsed.length) {
return parsed.map(String).filter(Boolean)
}
} catch {
/* fall through */
}
}
return trimmed.split(',').map((s) => s.trim()).filter(Boolean)
}
return [SERVICE_MODE.IN_STORE]
}
@@ -85,28 +158,296 @@ export function formatDate(value) {
return `${y}-${m}-${day}`
}
function parseWeekdays(raw) {
if (raw == null || raw === '') return null
const list = Array.isArray(raw) ? raw : String(raw).split(',')
const set = new Set(
list
.map((item) => Number(String(item).trim()))
.filter((num) => num >= 1 && num <= 7)
)
return set.size ? set : null
}
function parseDay(value) {
if (value == null || value === '') return null
if (typeof value === 'number') {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
date.setHours(0, 0, 0, 0)
return date
}
const text = String(value).trim()
const dateOnly = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:$|[ T]\d{2}:\d{2})/)
const hasZone = /[zZ]|[+-]\d{2}:?\d{2}$/.test(text)
if (dateOnly && !hasZone) {
return new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]))
}
const date = new Date(text)
if (Number.isNaN(date.getTime())) return null
date.setHours(0, 0, 0, 0)
return date
}
/** 商家星期:周一=1 … 周日=7 */
function sellerWeekday(date) {
const day = date.getDay()
return day === 0 ? 7 : day
}
export function buildBookableDates(ext, days = 30) {
const rangeDays = Number(ext?.bookingDateRangeDays) || days
const list = []
const rangeDays = Math.max(Number(ext?.bookingDateRangeDays) || days, 1)
const today = new Date()
today.setHours(0, 0, 0, 0)
const mode = String(ext?.bookableDateMode || '').toUpperCase()
const custom = mode === 'CUSTOM'
const weekdays = custom ? parseWeekdays(ext?.customWeekdays) : null
const startLimit = custom ? parseDay(ext?.customDateStart) : null
const endLimit = custom ? parseDay(ext?.customDateEnd) : null
const list = []
for (let i = 0; i < rangeDays; i++) {
const d = new Date(today)
d.setDate(today.getDate() + i)
list.push(formatDate(d))
const date = new Date(today)
date.setDate(today.getDate() + i)
if (startLimit && date < startLimit) continue
if (endLimit && date > endLimit) continue
if (weekdays && !weekdays.has(sellerWeekday(date))) continue
list.push(formatDate(date))
}
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} 小时可免费取消,逾期可能无法退款。`
export const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六']
export function parseDateParts(dateStr) {
if (!dateStr) return null
const parts = String(dateStr).slice(0, 10).split('-').map(Number)
if (parts.length < 3) return null
const [y, m, d] = parts
return { y, m, d, date: new Date(y, m - 1, d) }
}
export function formatMonthTitle(dateStr) {
const parts = parseDateParts(dateStr)
if (!parts) return ''
return `${parts.y}年${String(parts.m).padStart(2, '0')}月`
}
function buildCalendarDayCell(date, todayStr, bookableSet, inMonth) {
const value = formatDate(date)
const bookable = bookableSet instanceof Set ? bookableSet.has(value) : true
return {
value,
day: date.getDate(),
isToday: value === todayStr,
inMonth,
bookable,
}
return '请按时到店/等候服务;具体取消规则以商家设置为准。'
}
/** 以 anchor 所在自然周(周日始)生成 7 天条 */
export function buildWeekStrip(anchorDateStr, bookableSet) {
const parts = parseDateParts(anchorDateStr)
if (!parts) return []
const anchor = parts.date
anchor.setHours(0, 0, 0, 0)
const todayStr = formatDate(new Date())
const start = new Date(anchor)
start.setDate(anchor.getDate() - anchor.getDay())
const days = []
for (let i = 0; i < 7; i++) {
const d = new Date(start)
d.setDate(start.getDate() + i)
days.push(buildCalendarDayCell(d, todayStr, bookableSet, true))
}
return days
}
/** 月初 YYYY-MM-01 */
export function monthStartDate(dateStr) {
const parts = parseDateParts(dateStr)
if (!parts) return formatDate(new Date())
return `${parts.y}-${String(parts.m).padStart(2, '0')}-01`
}
/** 整月日历(周日始,含前后月补位日期) */
export function buildMonthCalendar(anchorDateStr, bookableSet) {
const parts = parseDateParts(monthStartDate(anchorDateStr))
if (!parts) return []
const year = parts.y
const month = parts.m - 1
const todayStr = formatDate(new Date())
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const startPad = firstDay.getDay()
const totalDays = lastDay.getDate()
const weeks = []
let currentWeek = []
const prevMonthLast = new Date(year, month, 0).getDate()
for (let i = startPad - 1; i >= 0; i--) {
const d = new Date(year, month - 1, prevMonthLast - i)
currentWeek.push(buildCalendarDayCell(d, todayStr, bookableSet, false))
}
for (let day = 1; day <= totalDays; day++) {
const d = new Date(year, month, day)
currentWeek.push(buildCalendarDayCell(d, todayStr, bookableSet, true))
if (currentWeek.length === 7) {
weeks.push(currentWeek)
currentWeek = []
}
}
if (currentWeek.length) {
let nextDay = 1
while (currentWeek.length < 7) {
const d = new Date(year, month + 1, nextDay++)
currentWeek.push(buildCalendarDayCell(d, todayStr, bookableSet, false))
}
weeks.push(currentWeek)
}
return weeks
}
export function shiftWeekDate(dateStr, deltaWeeks) {
const parts = parseDateParts(dateStr)
if (!parts) return formatDate(new Date())
const d = parts.date
d.setDate(d.getDate() + deltaWeeks * 7)
return formatDate(d)
}
export function shiftMonthDate(dateStr, deltaMonths) {
const parts = parseDateParts(monthStartDate(dateStr))
if (!parts) return monthStartDate(formatDate(new Date()))
const d = new Date(parts.y, parts.m - 1 + deltaMonths, 1)
return formatDate(d)
}
export function clampBookableDate(dateStr, bookableList) {
if (!dateStr) return dateStr
if (!Array.isArray(bookableList) || !bookableList.length) return dateStr
if (bookableList.includes(dateStr)) return dateStr
const target = new Date(dateStr).getTime()
let nearest = bookableList[0]
let minDiff = Math.abs(new Date(nearest).getTime() - target)
bookableList.forEach((item) => {
const diff = Math.abs(new Date(item).getTime() - target)
if (diff < minDiff) {
minDiff = diff
nearest = item
}
})
return nearest
}
export function isSlotFull(slot) {
if (!slot) return true
return slot.availableQty != null && slot.availableQty <= 0
}
/** 时段剩余可约数量。capacity 为 0 表示不限,返回 null */
export function getSlotAvailableQty(slot) {
if (!slot) return null
const capacity = Number(slot.capacity)
if (!capacity) return null
const qty = Number(slot.availableQty)
if (!Number.isFinite(qty) || qty >= 1000000) return null
return Math.max(Math.floor(qty), 0)
}
/** 时段开始时间晚于当前时间才可预约;未来日期全部可约 */
export function isSlotStartAfterNow(slot, dateStr) {
if (!slot?.slotStartTime) return false
const date = formatDate(dateStr || slot.slotDate)
if (!date) return false
const today = formatDate(new Date())
if (date > today) return true
if (date < today) return false
const parts = String(slot.slotStartTime).split(':').map(Number)
const start = new Date()
start.setHours(parts[0] || 0, parts[1] || 0, 0, 0)
return start.getTime() > Date.now()
}
export function slotStatusText(slot) {
if (isSlotFull(slot)) return '约满'
if (slot?.showRemainingQty && slot?.availableQty != null) {
return `剩余 ${slot.availableQty}`
}
return ''
}
const SELLER_WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
function formatWeekdayRule(raw) {
const set = parseWeekdays(raw)
if (!set) return ''
const days = [...set].sort((a, b) => a - b)
if (days.length >= 7) return '每天'
const consecutive = days.every((day, index) => index === 0 || day === days[index - 1] + 1)
if (consecutive && days.length > 1) {
return `${SELLER_WEEKDAY_LABELS[days[0]]}至${SELLER_WEEKDAY_LABELS[days[days.length - 1]]}`
}
return days.map((day) => SELLER_WEEKDAY_LABELS[day]).join('、')
}
/** 预约须知中的可预约日期说明 */
export function buildBookableDateNotice(ext) {
if (!ext) return ''
const mode = String(ext.bookableDateMode || '').toUpperCase()
if (mode !== 'CUSTOM') return '可预约日期:每天。'
const start = formatDate(parseDay(ext.customDateStart))
const end = formatDate(parseDay(ext.customDateEnd))
const weekdays = formatWeekdayRule(ext.customWeekdays)
let rangeText = ''
if (start && end) rangeText = `${start} 至 ${end}`
else if (start) rangeText = `${start} 起`
else if (end) rangeText = `至 ${end}`
let rule = rangeText
if (weekdays && weekdays !== '每天') {
rule = rangeText ? `${rangeText} 的${weekdays}` : weekdays
}
if (!rule) return '可预约日期:每天。'
return `可预约日期:${rule}。`
}
/** 预约须知中的预约日期范围(今天起连续 N 天,含今天) */
export function buildBookingRangeNotice(ext) {
if (!ext) return ''
const rangeDays = Math.max(Number(ext.bookingDateRangeDays) || 30, 1)
if (rangeDays === 1) return '预约日期范围:只能预约今天。'
if (rangeDays === 2) return '预约日期范围:可以预约今天和明天。'
return `预约日期范围:从今天起,连续 ${rangeDays} 天都可以预约。`
}
function formatLeadTime(hours) {
const n = Math.max(Number(hours) || 1, 1)
if (n % 24 === 0) return `${n / 24} 天`
return `${n} 小时`
}
/** 预约须知中的提前预约说明 */
export function buildAdvanceNotice(ext) {
if (!ext) return ''
const mode = String(ext.advanceBookingMode || '').toUpperCase()
const hours = Number(ext.advanceBookingHours)
if (mode === 'REQUIRE_HOURS' && hours > 0) {
return `提前预约:请提前 ${formatLeadTime(hours)}预约。`
}
return '提前预约:随时可以约,选现在之后的时段就行。'
}
export function buildCancelNotice(ext) {
if (!ext) return '请按时到店或等候服务,取消规则以商家设置为准。'
const mode = String(ext.cancelMode || '').toUpperCase()
const hours = Number(ext.cancelHours)
if (mode === 'NOT_ALLOWED') return '取消订单:约好后就不能取消了,请确认时间再下单。'
if (mode === 'ALLOW_BEFORE_HOURS' && hours > 0) {
return `取消订单:服务开始前 ${formatLeadTime(hours)}可以取消。`
}
return '取消订单:请按时到店或等候服务,取消规则以商家设置为准。'
}
export function parseFormFields(schemaJson) {
@@ -224,6 +565,64 @@ export function validateTextContent(field, value) {
return true
}
export function formatFormAnswerValue(value) {
if (value == null || value === '') return ''
if (Array.isArray(value)) {
const list = value.map((item) => (item == null ? '' : String(item).trim())).filter(Boolean)
if (!list.length) return ''
const looksLikeFile = list.every((item) => /^(https?:|wxfile:|data:|blob:|\/)/.test(item))
return looksLikeFile ? `${list.length}张图片` : list.join('、')
}
const text = String(value).trim()
return text === '~' ? '' : text
}
/** 结算页展示用:字段名 + 填写内容。多份时用分组标题,不把序号拼进字段名 */
export function buildFormDisplayRows(fields, groups) {
const rows = []
const list = groups || []
list.forEach((group, index) => {
const fieldRows = []
;(fields || []).forEach((field) => {
if (!field || field.richText) return
const text = formatFormAnswerValue(group?.[field.fieldKey])
if (!text) return
fieldRows.push({
label: field.fieldLabel || '补充信息',
value: text,
})
})
if (!fieldRows.length) return
if (list.length > 1) {
rows.push({ type: 'group', label: `第${index + 1}位` })
}
rows.push(...fieldRows)
})
return rows
}
/** 从表单答案提取联系人快照:姓名、手机号字段 */
export function pickContactFromForm(fields, groups) {
const group = (groups && groups[0]) || {}
let contactName = ''
let contactPhone = ''
;(fields || []).forEach((field) => {
if (!field || field.richText || field.fieldType !== 'TEXT') return
const text = String(group[field.fieldKey] ?? '').trim()
if (!text) return
const label = field.fieldLabel || ''
const contentType = field.contentType || ''
if (!contactPhone && (contentType === 'MOBILE' || /电话|手机/.test(label))) {
contactPhone = text
return
}
if (!contactName && /姓名|联系人|名字/.test(label)) {
contactName = text
}
})
return { contactName, contactPhone }
}
export function buildFormGroups(ext, quantity) {
const fields = parseFormFields(ext?.systemFormSnapshot)
const fillable = fields.filter((item) => !item.richText)
@@ -268,6 +667,14 @@ export function parseFormAnswerGroups(formAnswerJson) {
.filter((rows) => rows.length)
}
/** 预约订单列表、详情共用的状态标题 */
export function appointmentOrderStatusText(orderStatus) {
if (orderStatus === 'UNPAID') return '待支付'
if (orderStatus === 'CANCELLED') return '已取消'
if (orderStatus === 'COMPLETED' || orderStatus === 'COMPLETE') return '已完成'
return '待使用'
}
/** 预约单:未付款仅可取消;已付款仅可售后 */
export function getAppointmentAllowOperation(order) {
if (!order || !isAppointmentOrder(order.orderType)) {
@@ -282,7 +689,6 @@ export function getAppointmentAllowOperation(order) {
rog: false,
showLogistics: false,
afterSale: paid && order.orderStatus !== 'CANCELLED',
reschedule: paid && ['PAID', 'TAKE'].includes(order.orderStatus),
}
}

View File

@@ -73,15 +73,23 @@ export function setClipboard (val) {
// #endif
// #ifndef H5
uni.setClipboardData({
data: val,
const text = val == null ? "" : String(val);
const clipboard = typeof wx !== "undefined" && wx.setClipboardData ? wx : uni;
clipboard.setClipboardData({
data: text,
success: function () {
uni.showToast({
title: "复制成功!",
title: "复制成功",
duration: 2000,
icon: "none",
});
},
fail: function () {
uni.showToast({
title: "复制失败,请重试",
icon: "none",
});
},
});
// #endif
}

View File

@@ -19,6 +19,8 @@ import {
isAppointmentGoods,
isAppointmentOrder,
hasAppointmentInCheckout,
getAppointmentStock,
getAppointmentMaxBuyNum,
} from './appointmentGoods.js'
export {
@@ -27,6 +29,8 @@ export {
isAppointmentGoods,
isAppointmentOrder,
hasAppointmentInCheckout,
getAppointmentStock,
getAppointmentMaxBuyNum,
}
/** 单次购买数量上限(O-09 默认:min(quantity, 999)) */

View File

@@ -67,15 +67,20 @@ export function getThemeStyle(theme = DEFAULT_THEME) {
const safeTheme = theme || DEFAULT_THEME;
const primary = safeTheme.mainColor || DEFAULT_THEME.mainColor;
const light = safeTheme.lightColor || DEFAULT_THEME.lightColor;
const primarySoft = hexToRgba(primary, 0.1);
return {
"--theme-primary": primary,
"--theme-light": light,
"--theme-aider": safeTheme.aiderLightColor || DEFAULT_THEME.aiderLightColor,
"--theme-primary-10": hexToRgba(primary, 0.1),
"--theme-primary-10": primarySoft,
"--theme-primary-20": hexToRgba(primary, 0.2),
"--theme-primary-30": hexToRgba(primary, 0.3),
"--theme-primary-70": hexToRgba(primary, 0.7),
"--theme-light-10": hexToRgba(light, 0.1),
"--theme-light-80": hexToRgba(light, 0.8),
"--u-primary": primary,
"--up-primary": primary,
"--u-primary-light": primarySoft,
"--up-primary-light": primarySoft,
};
}