Merge branch 'master' of https://gitea.zianxin.com/zianxin/biz-mobile-cloud-zax
This commit is contained in:
commit
1393c6a38f
9 files changed
+596
-94
No files matched your search
@@ -161,7 +161,7 @@ export default {
|
||||
dailyMenuBadge: "Daily menu",
|
||||
unnamed: "Unnamed dish",
|
||||
perUnit: "/ea",
|
||||
sold: "Sold {count}",
|
||||
sold: "Ordered {count}",
|
||||
mealSupply: "{meal} service",
|
||||
productInfo: "Product info",
|
||||
supplyDate: "Supply date",
|
||||
|
||||
@@ -161,7 +161,7 @@ export default {
|
||||
dailyMenuBadge: "每日菜谱",
|
||||
unnamed: "未命名菜品",
|
||||
perUnit: "/份",
|
||||
sold: "已售 {count}",
|
||||
sold: "已订 {count}",
|
||||
mealSupply: "{meal}供应",
|
||||
productInfo: "商品信息",
|
||||
supplyDate: "供应日期",
|
||||
|
||||
@@ -161,7 +161,7 @@ export default {
|
||||
dailyMenuBadge: "每日菜譜",
|
||||
unnamed: "未命名菜品",
|
||||
perUnit: "/份",
|
||||
sold: "已售 {count}",
|
||||
sold: "已訂 {count}",
|
||||
mealSupply: "{meal}供應",
|
||||
productInfo: "商品信息",
|
||||
supplyDate: "供應日期",
|
||||
|
||||
+397
-62
@@ -89,7 +89,28 @@
|
||||
</view>
|
||||
<view class="kv">
|
||||
<text class="kv__k">共住</text>
|
||||
<text class="kv__v">{{ nightCount }}晚 · {{ form.rooms }}间房</text>
|
||||
<text class="kv__v">{{ nightCount }}晚</text>
|
||||
</view>
|
||||
<view class="kv">
|
||||
<text class="kv__k">间数</text>
|
||||
<view class="room-stepper">
|
||||
<view
|
||||
class="room-stepper__btn"
|
||||
:class="{ disabled: form.rooms <= 1 }"
|
||||
@tap="changeRooms(-1)"
|
||||
>
|
||||
−
|
||||
</view>
|
||||
<text class="room-stepper__num">{{ form.rooms }}</text>
|
||||
<view
|
||||
class="room-stepper__btn"
|
||||
:class="{ disabled: form.rooms >= maxRooms }"
|
||||
@tap="changeRooms(1)"
|
||||
>
|
||||
+
|
||||
</view>
|
||||
<text class="room-stepper__hint">最多{{ maxRooms }}间</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="kv kv--last">
|
||||
<text class="kv__k">房型</text>
|
||||
@@ -151,22 +172,65 @@
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 入住人:标签在上、输入在下 -->
|
||||
<!-- 入住人:表单行样式(灰底输入),避免像日期展示卡 -->
|
||||
<view class="sec guest-sec">
|
||||
<view class="sec-title">入住人信息</view>
|
||||
<view class="guest-field">
|
||||
<text class="guest-field__label">姓名</text>
|
||||
<input class="guest-field__input" v-model="form.contactName" placeholder="请输入入住人姓名" />
|
||||
<view class="guest-sec__hd">
|
||||
<text class="guest-sec__title">入住人信息</text>
|
||||
<text class="guest-sec__sub">必填主要联系人;订多间时可继续添加</text>
|
||||
</view>
|
||||
<view class="guest-field guest-field--last">
|
||||
<text class="guest-field__label">手机号</text>
|
||||
<input
|
||||
class="guest-field__input"
|
||||
v-model="form.contactPhone"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
|
||||
<view
|
||||
v-for="(g, idx) in guests"
|
||||
:key="idx"
|
||||
class="guest-block"
|
||||
>
|
||||
<view class="guest-block__bar">
|
||||
<view class="guest-block__who">
|
||||
<view class="guest-block__dot" :class="{ 'is-main': idx === 0 }" />
|
||||
<text class="guest-block__name">
|
||||
{{ idx === 0 ? "主要联系人" : `入住人 ${idx + 1}` }}
|
||||
</text>
|
||||
<text v-if="idx === 0" class="guest-block__badge">必填</text>
|
||||
</view>
|
||||
<view v-if="idx > 0" class="guest-block__del" @tap="removeGuest(idx)">
|
||||
<tm-icon name="delete-bin-line" :size="28" color="#94a3b8" />
|
||||
<text>删除</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="guest-row">
|
||||
<text class="guest-row__lab">姓名</text>
|
||||
<input
|
||||
class="guest-row__input"
|
||||
v-model="g.name"
|
||||
placeholder="点击输入姓名"
|
||||
:maxlength="32"
|
||||
placeholder-class="guest-row__ph"
|
||||
/>
|
||||
</view>
|
||||
<view class="guest-row guest-row--last">
|
||||
<text class="guest-row__lab">手机</text>
|
||||
<input
|
||||
class="guest-row__input"
|
||||
v-model="g.mobile"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
placeholder="点击输入手机号"
|
||||
placeholder-class="guest-row__ph"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="guests.length < form.rooms"
|
||||
class="guest-add"
|
||||
hover-class="guest-add--active"
|
||||
:hover-stay-time="80"
|
||||
@tap="addGuest"
|
||||
>
|
||||
<tm-icon name="user-add-line" :size="32" color="#1677ff" />
|
||||
<text class="guest-add__txt">添加入住人</text>
|
||||
<text class="guest-add__cap">还可加 {{ form.rooms - guests.length }} 人</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -174,7 +238,7 @@
|
||||
<view class="sec fee-sec">
|
||||
<view class="sec-title">费用明细</view>
|
||||
<view class="fee-row">
|
||||
<text class="fee-row__k">房费({{ nightCount }}晚)</text>
|
||||
<text class="fee-row__k">房费({{ nightCount }}晚 · {{ form.rooms }}间)</text>
|
||||
<text class="fee-row__v">¥{{ roomFee }}</text>
|
||||
</view>
|
||||
<view class="fee-row">
|
||||
@@ -246,11 +310,11 @@
|
||||
</view>
|
||||
<view class="ht-confirm__row">
|
||||
<text class="ht-confirm__k">入住</text>
|
||||
<text class="ht-confirm__v">{{ stayRangeText }}</text>
|
||||
<text class="ht-confirm__v">{{ stayRangeText }} · {{ form.rooms }}间</text>
|
||||
</view>
|
||||
<view class="ht-confirm__row">
|
||||
<text class="ht-confirm__k">入住人</text>
|
||||
<text class="ht-confirm__v">{{ form.contactName.trim() }}</text>
|
||||
<text class="ht-confirm__v">{{ guestSummaryText }}</text>
|
||||
</view>
|
||||
<view class="ht-confirm__line" />
|
||||
<view class="ht-confirm__row">
|
||||
@@ -291,6 +355,8 @@ const DATE_PICKER_MIN = dayjs().format("YYYY-MM-DD")
|
||||
const DATE_PICKER_MAX = dayjs().add(365, "day").format("YYYY-MM-DD")
|
||||
/** 连住上限:最多 30 晚(约一个月) */
|
||||
const MAX_STAY_NIGHTS = 30
|
||||
/** 与后端 App 下单一致:单次最多 10 间 */
|
||||
const MAX_ROOMS = 10
|
||||
|
||||
const userStore = useUserStore()
|
||||
const submitting = ref(false)
|
||||
@@ -303,6 +369,8 @@ const hotelMetaLoaded = ref(false)
|
||||
const calendarLoaded = ref(false)
|
||||
/** 产品未来日历行(含可售) */
|
||||
const calendarRows = ref<any[]>([])
|
||||
/** 入住人列表:至少 1 位,最多 = 间数;可只填主要联系人 */
|
||||
const guests = ref<Array<{ name: string; mobile: string }>>([{ name: "", mobile: "" }])
|
||||
/** 支付拉起后用户取消等:保留待支付单,重试只走支付不再创单 */
|
||||
const pendingPay = ref<{
|
||||
orderId: string | number
|
||||
@@ -313,14 +381,6 @@ const pendingPay = ref<{
|
||||
productName: string
|
||||
} | null>(null)
|
||||
|
||||
// 房型/日期/间数变更后丢弃缓存,避免付到旧单
|
||||
watch(
|
||||
() => [form.hotelId, form.productCode, form.checkIn, form.checkOut, form.rooms],
|
||||
() => {
|
||||
pendingPay.value = null
|
||||
},
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
hotelId: "",
|
||||
/** 酒店所属部门:pay-card / 收款商户均按此解析 */
|
||||
@@ -337,15 +397,118 @@ const form = reactive({
|
||||
checkIn: dayjs().format("YYYY-MM-DD"),
|
||||
checkOut: dayjs().add(1, "day").format("YYYY-MM-DD"),
|
||||
rooms: 1,
|
||||
contactName: "",
|
||||
contactPhone: "",
|
||||
})
|
||||
|
||||
// 房型/日期/间数变更后丢弃缓存,避免付到旧单
|
||||
watch(
|
||||
() => [form.hotelId, form.productCode, form.checkIn, form.checkOut, form.rooms],
|
||||
() => {
|
||||
pendingPay.value = null
|
||||
},
|
||||
)
|
||||
|
||||
const nightCount = computed(() => {
|
||||
const n = dayjs(form.checkOut).diff(dayjs(form.checkIn), "day")
|
||||
return n > 0 ? n : 1
|
||||
})
|
||||
|
||||
/** 所选区间每晚可售的最小值,作为间数上限(未加载日历时按 10) */
|
||||
const maxRooms = computed(() => {
|
||||
if (!calendarLoaded.value || !stayNightList.value.length) return MAX_ROOMS
|
||||
let minRemain = MAX_ROOMS
|
||||
for (const ymd of stayNightList.value) {
|
||||
const row = calendarRows.value.find((r) => stayYmd(r?.stayDate) === ymd)
|
||||
if (!row || Number(row.dailyStatus) !== 1) {
|
||||
minRemain = 0
|
||||
break
|
||||
}
|
||||
const remain =
|
||||
row.remainStock != null
|
||||
? Number(row.remainStock)
|
||||
: Math.max(0, Number(row.stock || 0) - Number(row.sold || 0))
|
||||
minRemain = Math.min(minRemain, remain)
|
||||
}
|
||||
return Math.max(1, Math.min(MAX_ROOMS, Math.floor(minRemain) || 1))
|
||||
})
|
||||
|
||||
const changeRooms = (delta: number) => {
|
||||
const next = Math.max(1, Math.min(maxRooms.value, (Number(form.rooms) || 1) + delta))
|
||||
if (next === form.rooms) {
|
||||
if (delta > 0 && form.rooms >= maxRooms.value) {
|
||||
uni.showToast({ title: `当前日期最多可订${maxRooms.value}间`, icon: "none" })
|
||||
}
|
||||
return
|
||||
}
|
||||
form.rooms = next
|
||||
// 间数减少时裁掉多余入住人;增加时不自动加行(用户点「添加入住人」)
|
||||
if (guests.value.length > next) {
|
||||
guests.value = guests.value.slice(0, next)
|
||||
}
|
||||
}
|
||||
|
||||
const addGuest = () => {
|
||||
if (guests.value.length >= form.rooms) {
|
||||
uni.showToast({ title: `最多可填${form.rooms}位入住人`, icon: "none" })
|
||||
return
|
||||
}
|
||||
guests.value.push({ name: "", mobile: "" })
|
||||
}
|
||||
|
||||
const removeGuest = (idx: number) => {
|
||||
if (idx <= 0 || guests.value.length <= 1) return
|
||||
guests.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
const guestSummaryText = computed(() => {
|
||||
const filled = guests.value.filter((g) => g.name?.trim())
|
||||
if (!filled.length) return "-"
|
||||
if (filled.length === 1) return filled[0].name.trim()
|
||||
return `${filled[0].name.trim()} 等${filled.length}人`
|
||||
})
|
||||
|
||||
/** 提交用:联系人取首位;guestList 含所有已填完整的入住人 */
|
||||
const buildGuestPayload = () => {
|
||||
const primary = guests.value[0] || { name: "", mobile: "" }
|
||||
const list = guests.value
|
||||
.filter((g) => g.name?.trim() && g.mobile?.trim())
|
||||
.map((g) => ({ name: g.name.trim(), mobile: g.mobile.trim() }))
|
||||
return {
|
||||
contactName: primary.name.trim(),
|
||||
contactPhone: primary.mobile.trim(),
|
||||
guestList: list.length ? list : [{ name: primary.name.trim(), mobile: primary.mobile.trim() }],
|
||||
}
|
||||
}
|
||||
|
||||
const validateGuests = () => {
|
||||
const primary = guests.value[0]
|
||||
if (!primary?.name?.trim()) {
|
||||
uni.showToast({ title: "请填写主要入住人姓名", icon: "none" })
|
||||
return false
|
||||
}
|
||||
if (!/^1\d{10}$/.test(primary.mobile || "")) {
|
||||
uni.showToast({ title: "请填写正确的主要入住人手机号", icon: "none" })
|
||||
return false
|
||||
}
|
||||
for (let i = 1; i < guests.value.length; i++) {
|
||||
const g = guests.value[i]
|
||||
const name = g.name?.trim() || ""
|
||||
const mobile = g.mobile?.trim() || ""
|
||||
if (!name && !mobile) {
|
||||
uni.showToast({ title: `请完善入住人${i + 1},或删除该行`, icon: "none" })
|
||||
return false
|
||||
}
|
||||
if (!name) {
|
||||
uni.showToast({ title: `请填写入住人${i + 1}姓名`, icon: "none" })
|
||||
return false
|
||||
}
|
||||
if (!/^1\d{10}$/.test(mobile)) {
|
||||
uni.showToast({ title: `请填写入住人${i + 1}正确手机号`, icon: "none" })
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const checkOutPickerMin = computed(() => dayjs(form.checkIn).add(1, "day").format("YYYY-MM-DD"))
|
||||
const checkOutPickerMax = computed(() => dayjs(form.checkIn).add(MAX_STAY_NIGHTS, "day").format("YYYY-MM-DD"))
|
||||
|
||||
@@ -363,8 +526,24 @@ const toPickerValue = (ymd: string) => `${ymd} 00:00:00`
|
||||
const normalizeDate = (value: string) => dayjs(String(value || "").replace(/\//g, "-")).format("YYYY-MM-DD")
|
||||
|
||||
const roomFeeNum = computed(() => {
|
||||
const unit = Number(form.sellPrice) || 0
|
||||
return unit * nightCount.value * (form.rooms || 1)
|
||||
const rooms = Math.max(1, Number(form.rooms) || 1)
|
||||
// 优先按所选每晚日历卖价累加,再 × 间数
|
||||
if (calendarLoaded.value && stayNightList.value.length) {
|
||||
let nightSum = 0
|
||||
let priced = 0
|
||||
for (const ymd of stayNightList.value) {
|
||||
const hit = bookableNightSet.value.get(ymd)
|
||||
const p = hit ? Number(hit.sellPrice) : 0
|
||||
if (p > 0) {
|
||||
nightSum += p
|
||||
priced++
|
||||
} else {
|
||||
nightSum += Number(form.sellPrice) || 0
|
||||
}
|
||||
}
|
||||
if (priced > 0 || nightSum > 0) return nightSum * rooms
|
||||
}
|
||||
return (Number(form.sellPrice) || 0) * nightCount.value * rooms
|
||||
})
|
||||
|
||||
const roomFee = computed(() => roomFeeNum.value.toFixed(2))
|
||||
@@ -598,7 +777,19 @@ const loadCalendar = async () => {
|
||||
}
|
||||
|
||||
/** 按已加载日历刷新单价:仅用日历行自身卖价/成本取均价;没有有效价时保留产品标价 */
|
||||
const clampRoomsByStock = () => {
|
||||
if (!calendarLoaded.value) return
|
||||
const cap = maxRooms.value
|
||||
if (form.rooms > cap) {
|
||||
form.rooms = cap
|
||||
if (guests.value.length > cap) {
|
||||
guests.value = guests.value.slice(0, cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStayPriceFromCalendar = () => {
|
||||
clampRoomsByStock()
|
||||
if (!form.checkIn || !form.checkOut) return
|
||||
const prices: number[] = []
|
||||
stayNightList.value.forEach((ymd) => {
|
||||
@@ -667,14 +858,7 @@ const openConfirmDialog = async () => {
|
||||
}
|
||||
const ok = await hooks.useCheckLogin()
|
||||
if (!ok) return
|
||||
if (!form.contactName?.trim()) {
|
||||
uni.showToast({ title: "请填写入住人姓名", icon: "none" })
|
||||
return
|
||||
}
|
||||
if (!/^1\d{10}$/.test(form.contactPhone || "")) {
|
||||
uni.showToast({ title: "请填写正确手机号", icon: "none" })
|
||||
return
|
||||
}
|
||||
if (!validateGuests()) return
|
||||
const today = dayjs().format("YYYY-MM-DD")
|
||||
if (form.checkIn < today) {
|
||||
uni.showToast({ title: "入住日期不能早于今天", icon: "none" })
|
||||
@@ -712,6 +896,7 @@ const submit = async () => {
|
||||
try {
|
||||
// 1) 无缓存待支付单时才创单;支付失败重试只走支付,避免连点刷单
|
||||
if (!orderId) {
|
||||
const guestPayload = buildGuestPayload()
|
||||
const res: any = await hotelApi.createOrder({
|
||||
hotelId: form.hotelId,
|
||||
productCode: form.productCode,
|
||||
@@ -719,8 +904,9 @@ const submit = async () => {
|
||||
checkInDate: form.checkIn,
|
||||
checkOutDate: form.checkOut,
|
||||
count: form.rooms,
|
||||
contactName: form.contactName.trim(),
|
||||
contactPhone: form.contactPhone.trim(),
|
||||
contactName: guestPayload.contactName,
|
||||
contactPhone: guestPayload.contactPhone,
|
||||
guestList: guestPayload.guestList,
|
||||
totalAmount: totalAmount.value,
|
||||
})
|
||||
if (res?.code !== 0) {
|
||||
@@ -841,11 +1027,15 @@ onLoad((opt: any) => {
|
||||
form.productCode = opt?.productCode || ""
|
||||
form.checkIn = opt?.checkIn || form.checkIn
|
||||
form.checkOut = opt?.checkOut || form.checkOut
|
||||
form.rooms = Number(opt?.rooms || 1) || 1
|
||||
form.rooms = Math.max(1, Math.min(MAX_ROOMS, Number(opt?.rooms || 1) || 1))
|
||||
clampStayRange(false)
|
||||
const u: any = userStore.userInfo || {}
|
||||
form.contactName = u?.sysUser?.name || u?.name || ""
|
||||
form.contactPhone = u?.sysUser?.phone || u?.phone || ""
|
||||
guests.value = [
|
||||
{
|
||||
name: u?.sysUser?.name || u?.name || "",
|
||||
mobile: u?.sysUser?.phone || u?.phone || "",
|
||||
},
|
||||
]
|
||||
layout()
|
||||
fillHotelMeta().then(() => loadCalendar())
|
||||
})
|
||||
@@ -1227,9 +1417,172 @@ onLoad((opt: any) => {
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.guest-sec,
|
||||
.room-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.room-stepper__btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #d0d5dd;
|
||||
background: #fff;
|
||||
color: #344054;
|
||||
font-size: 18px;
|
||||
line-height: 26px;
|
||||
text-align: center;
|
||||
}
|
||||
.room-stepper__btn:active {
|
||||
background: #f2f4f7;
|
||||
border-color: #98a2b3;
|
||||
}
|
||||
.room-stepper__btn.disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
.room-stepper__num {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #101828;
|
||||
}
|
||||
.room-stepper__hint {
|
||||
margin-left: 2px;
|
||||
font-size: 11px;
|
||||
color: #98a2b3;
|
||||
}
|
||||
.guest-sec {
|
||||
padding: 16px;
|
||||
}
|
||||
.pay-sec {
|
||||
margin: 0 12px 8px;
|
||||
padding: 14px 12px 12px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.guest-sec__hd {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.guest-sec__title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #101828;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.guest-sec__sub {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #98a2b3;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.guest-block {
|
||||
margin-bottom: 12px;
|
||||
padding: 4px 0 2px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
}
|
||||
.guest-block__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
.guest-block__who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.guest-block__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.guest-block__dot.is-main {
|
||||
background: #1677ff;
|
||||
}
|
||||
.guest-block__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #344054;
|
||||
}
|
||||
.guest-block__badge {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
background: #eff4ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.guest-block__del {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: #98a2b3;
|
||||
padding: 4px 0 4px 8px;
|
||||
}
|
||||
.guest-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 8px 8px;
|
||||
padding: 0 12px;
|
||||
min-height: 44px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ec;
|
||||
}
|
||||
.guest-row--last {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.guest-row__lab {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
font-size: 14px;
|
||||
color: #667085;
|
||||
}
|
||||
.guest-row__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
color: #101828;
|
||||
background: transparent;
|
||||
}
|
||||
.guest-row__ph {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.guest-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
min-height: 44px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed #b2d0ff;
|
||||
background: #f7fbff;
|
||||
}
|
||||
.guest-add--active {
|
||||
background: #eef5ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
.guest-add__txt {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1677ff;
|
||||
}
|
||||
.guest-add__cap {
|
||||
font-size: 12px;
|
||||
color: #8fb3e8;
|
||||
}
|
||||
.pay-sec__tip {
|
||||
padding: 24rpx 16rpx;
|
||||
@@ -1248,24 +1601,6 @@ onLoad((opt: any) => {
|
||||
color: #222;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.guest-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.guest-field--last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.guest-field__label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.guest-field__input {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.fee-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
+82
-19
@@ -42,28 +42,27 @@
|
||||
<view v-if="hotel.score != null" class="ht-score-row">
|
||||
<text class="ht-score">{{ hotel.score }}</text>
|
||||
<text class="ht-score__unit">分</text>
|
||||
<text v-if="hotel.phone" class="ht-phone">电话 {{ hotel.phone }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 酒店介绍 -->
|
||||
<view v-if="introText" class="ht-card">
|
||||
<view class="ht-sec">酒店介绍</view>
|
||||
<rich-text class="ht-intro" :nodes="hotel.intro" />
|
||||
</view>
|
||||
|
||||
<!-- 设施服务 -->
|
||||
<view v-if="facilityList.length" class="ht-card">
|
||||
<view class="ht-sec">设施服务</view>
|
||||
<view class="ht-facilities">
|
||||
<view v-for="(f, i) in facilityList" :key="i" class="ht-fac">
|
||||
<tm-icon :name="facilityIcon(f)" :size="32" color="#1570ef" />
|
||||
<text class="ht-fac__text">{{ f }}</text>
|
||||
<!-- 独立一行,加大点击区域;用 @click 与客服电话组件一致 -->
|
||||
<view
|
||||
v-if="hotel.phone"
|
||||
class="ht-call-row"
|
||||
hover-class="ht-call-row--active"
|
||||
:hover-stay-time="100"
|
||||
@click.stop="callHotel"
|
||||
>
|
||||
<view class="ht-call-row__left">
|
||||
<text class="ht-call-row__lab">联系电话</text>
|
||||
<text class="ht-call-row__num">{{ hotel.phone }}</text>
|
||||
</view>
|
||||
<view class="ht-call-row__btn">
|
||||
<text class="ht-call-row__btn-txt">拨打</text>
|
||||
<tm-icon name="phone-fill" :size="28" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 可选房型 -->
|
||||
<!-- 可选房型:紧贴基本信息,首屏可见,避免拖到介绍下面 -->
|
||||
<view class="ht-card">
|
||||
<view class="ht-sec">
|
||||
可选房型
|
||||
@@ -84,6 +83,23 @@
|
||||
<view class="ht-room__btn" @tap="book(p)">预订</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 酒店介绍 -->
|
||||
<view v-if="introText" class="ht-card">
|
||||
<view class="ht-sec">酒店介绍</view>
|
||||
<rich-text class="ht-intro" :nodes="hotel.intro" />
|
||||
</view>
|
||||
|
||||
<!-- 设施服务 -->
|
||||
<view v-if="facilityList.length" class="ht-card">
|
||||
<view class="ht-sec">设施服务</view>
|
||||
<view class="ht-facilities">
|
||||
<view v-for="(f, i) in facilityList" :key="i" class="ht-fac">
|
||||
<tm-icon :name="facilityIcon(f)" :size="32" color="#1570ef" />
|
||||
<text class="ht-fac__text">{{ f }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
@@ -106,6 +122,7 @@ import { onLoad } from "@dcloudio/uni-app"
|
||||
import dayjs from "dayjs"
|
||||
import { hotelApi } from "@@/api"
|
||||
import { router } from "@@/index"
|
||||
import { useMakePhoneCall } from "@@/hooks"
|
||||
import { filePath } from "@@/utils/file"
|
||||
|
||||
const STAR_MAP: Record<number, string> = { 1: "民宿", 2: "经济", 3: "舒适", 4: "高档", 5: "五星" }
|
||||
@@ -272,6 +289,10 @@ const layout = () => {
|
||||
scrollHeight.value = `${sys.windowHeight || 667}px`
|
||||
}
|
||||
|
||||
const callHotel = () => {
|
||||
useMakePhoneCall(hotel.value?.phone)
|
||||
}
|
||||
|
||||
const onBack = () => {
|
||||
uni.navigateBack({ fail: () => router.jump("/pages/hotel/list", {}, 2) })
|
||||
}
|
||||
@@ -432,11 +453,53 @@ onLoad((opt: any) => {
|
||||
font-size: 22rpx;
|
||||
color: #1570ef;
|
||||
}
|
||||
.ht-phone {
|
||||
margin-left: 20rpx;
|
||||
.ht-call-row {
|
||||
margin-top: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 4rpx 4rpx;
|
||||
border-top: 1rpx solid #f1f5f9;
|
||||
}
|
||||
.ht-call-row--active .ht-call-row__num {
|
||||
color: #1570ef;
|
||||
}
|
||||
.ht-call-row__left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
.ht-call-row__lab {
|
||||
font-size: 22rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.ht-call-row__num {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1570ef;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
.ht-call-row__btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 14rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #1570ef;
|
||||
box-shadow: 0 6rpx 16rpx rgba(21, 112, 239, 0.28);
|
||||
}
|
||||
.ht-call-row__btn-txt {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
.ht-sec {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -269,10 +269,14 @@
|
||||
scroll-y
|
||||
class="ht-scroll"
|
||||
:style="{ height: scrollHeight }"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
refresher-default-style="black"
|
||||
@refresherrefresh="onRefresh"
|
||||
@scrolltolower="loadMore"
|
||||
>
|
||||
<view class="ht-list">
|
||||
<view v-if="state.loading && !state.list.length" class="ht-empty">加载中…</view>
|
||||
<view v-if="state.loading && !state.list.length && !refreshing" class="ht-empty">加载中…</view>
|
||||
<view v-else-if="!state.list.length" class="ht-empty">暂无符合条件的酒店</view>
|
||||
|
||||
<view v-for="(item, idx) in state.list" :key="item.id" class="ht-card" @tap="goDetail(item)">
|
||||
@@ -332,6 +336,7 @@ import { filePath } from "@@/utils/file"
|
||||
|
||||
const statusBarHeight = ref(20)
|
||||
const scrollHeight = ref("100vh")
|
||||
const refreshing = ref(false)
|
||||
const panel = ref<"" | "area" | "price" | "score" | "more">("")
|
||||
|
||||
const filterTabs = [
|
||||
@@ -907,6 +912,18 @@ const fetchPage = async (reset = false) => {
|
||||
const reload = () => fetchPage(true)
|
||||
const loadMore = () => fetchPage(false)
|
||||
|
||||
const onRefresh = async () => {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
// 下拉刷新允许打断「加载更多」等待态
|
||||
state.loading = false
|
||||
await fetchPage(true)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (item: any) => {
|
||||
router.jump("/pages/hotel/detail", {
|
||||
id: String(item.id),
|
||||
|
||||
@@ -107,7 +107,15 @@
|
||||
</view>
|
||||
<view v-if="order.phone" class="kv">
|
||||
<text class="kv__k">酒店电话</text>
|
||||
<text class="kv__v kv__v--link" @tap="callHotel">{{ order.phone }}</text>
|
||||
<view
|
||||
class="ht-call"
|
||||
hover-class="ht-call--active"
|
||||
:hover-stay-time="100"
|
||||
@click.stop="callHotel"
|
||||
>
|
||||
<text class="ht-call__text">{{ order.phone }}</text>
|
||||
<tm-icon name="phone-fill" :size="28" color="#1570ef" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="kv" :class="{ 'kv--last': !order.failMsg }">
|
||||
<text class="kv__k">订单金额</text>
|
||||
@@ -145,7 +153,8 @@ import { reactive, ref, computed } from "vue"
|
||||
import { onLoad, onShow, onHide, onUnload } from "@dcloudio/uni-app"
|
||||
import dayjs from "dayjs"
|
||||
import { hotelApi } from "@@/api"
|
||||
import { hooks, router } from "@@/index"
|
||||
import { router } from "@@/index"
|
||||
import { useMakePhoneCall } from "@@/hooks"
|
||||
import { filePath } from "@@/utils/file"
|
||||
import { AggregationPayTool } from "@@/utils/pay-tool"
|
||||
import { waitHotelOrderAfterPay } from "@@/utils/hotel-order"
|
||||
@@ -399,8 +408,7 @@ const copyNo = () => {
|
||||
}
|
||||
|
||||
const callHotel = () => {
|
||||
if (!order.phone) return
|
||||
uni.makePhoneCall({ phoneNumber: String(order.phone).replace(/\s/g, "") })
|
||||
useMakePhoneCall(order.phone)
|
||||
}
|
||||
|
||||
const goHotel = () => {
|
||||
@@ -721,6 +729,26 @@ onUnload(() => stopCountdown())
|
||||
.kv__v--link {
|
||||
color: #1677ff;
|
||||
}
|
||||
.ht-call {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #eff6ff;
|
||||
border: 1rpx solid #93c5fd;
|
||||
max-width: 70%;
|
||||
}
|
||||
.ht-call--active {
|
||||
opacity: 0.72;
|
||||
background: #dbeafe;
|
||||
}
|
||||
.ht-call__text {
|
||||
font-size: 26rpx;
|
||||
color: #1570ef;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
.kv__v--fail {
|
||||
color: #cf1322;
|
||||
text-align: right;
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
scroll-y
|
||||
class="ht-scroll"
|
||||
:style="{ height: scrollHeight }"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
refresher-default-style="black"
|
||||
@refresherrefresh="onRefresh"
|
||||
@scrolltolower="loadMore"
|
||||
>
|
||||
<view class="ht-orders">
|
||||
<view v-if="state.loading && !state.list.length" class="ht-empty">
|
||||
<view v-if="state.loading && !state.list.length && !refreshing" class="ht-empty">
|
||||
<tm-icon name="loader-4-line" :size="48" color="#94a3b8" />
|
||||
<text class="ht-empty__text">加载中…</text>
|
||||
</view>
|
||||
@@ -138,6 +142,7 @@ const tabs = [
|
||||
]
|
||||
|
||||
const scrollHeight = ref("70vh")
|
||||
const refreshing = ref(false)
|
||||
const remainMap = reactive<Record<string, number>>({})
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -341,6 +346,17 @@ const fetchPage = async (reset = false) => {
|
||||
const reload = () => fetchPage(true)
|
||||
const loadMore = () => fetchPage(false)
|
||||
|
||||
const onRefresh = async () => {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
state.loading = false
|
||||
await fetchPage(true)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchTab = (v: string) => {
|
||||
if (state.status === v) return
|
||||
state.status = v
|
||||
|
||||
@@ -1,4 +1,47 @@
|
||||
export const useMakePhoneCall = (str: string | undefined) => {
|
||||
if (str == undefined) return;
|
||||
uni.makePhoneCall({ phoneNumber: str });
|
||||
};
|
||||
/**
|
||||
* 拨打电话:打开系统拨号盘填入号码(不直接呼叫,避免无 CALL_PHONE 权限时静默失败)。
|
||||
* 与 service-text 客服电话同一套入口。
|
||||
*/
|
||||
export const useMakePhoneCall = (raw: string | undefined | null) => {
|
||||
const phoneNumber = String(raw || "")
|
||||
.replace(/[^\d+]/g, "")
|
||||
.trim()
|
||||
if (!phoneNumber) {
|
||||
uni.showToast({ title: "暂无联系电话", icon: "none" })
|
||||
return
|
||||
}
|
||||
|
||||
const plusObj = (typeof globalThis !== "undefined" && (globalThis as any).plus) || null
|
||||
|
||||
// App:优先 tel: 打开拨号界面(不直接播出)
|
||||
if (plusObj?.runtime?.openURL) {
|
||||
try {
|
||||
plusObj.runtime.openURL(`tel:${phoneNumber}`)
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn("plus.runtime.openURL tel failed", e)
|
||||
}
|
||||
}
|
||||
// 带确认框的 dial(true=弹确认,不强制直接呼叫权限)
|
||||
if (plusObj?.device?.dial) {
|
||||
try {
|
||||
plusObj.device.dial(phoneNumber, true)
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn("plus.device.dial failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
uni.makePhoneCall({
|
||||
phoneNumber,
|
||||
fail: (err) => {
|
||||
const msg = String((err as any)?.errMsg || "")
|
||||
if (/cancel|取消/i.test(msg)) return
|
||||
console.warn("makePhoneCall fail", err)
|
||||
uni.showToast({
|
||||
title: msg.replace(/^makePhoneCall:fail\s*/i, "") || "无法打开拨号盘",
|
||||
icon: "none",
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user