mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-11-07 01:18:53 +08:00
- 新增礼品卡页面,支持礼品卡的选择与使用。 - 调整预约下单流程,增强预约信息展示与用户交互。 - 更新支付成功页面的导航逻辑,确保用户体验流畅。 - 优化搜索页面的历史关键词加载逻辑,提升性能。
704 lines
23 KiB
JavaScript
704 lines
23 KiB
JavaScript
/**
|
||
* 预约商品(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
|
||
}
|
||
|
||
/** 预约商品 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
|
||
}
|
||
|
||
/**
|
||
* 结算页识别当前交易是否含预约 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.map(String).filter(Boolean)
|
||
}
|
||
if (typeof raw === 'string' && raw.trim()) {
|
||
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]
|
||
}
|
||
|
||
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}`
|
||
}
|
||
|
||
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 = 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 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 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,
|
||
}
|
||
}
|
||
|
||
/** 以 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) {
|
||
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 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)
|
||
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 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)) {
|
||
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',
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|