This commit is contained in:
dzc committed 2026-10-16 11:35:56 +08:00
commit 1393c6a38f
9 files changed
+596 -94

No files matched your search

+1 -1
View File
@@ -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",
+1 -1
View File
@@ -161,7 +161,7 @@ export default {
dailyMenuBadge: "每日菜谱",
unnamed: "未命名菜品",
perUnit: "/份",
sold: "已售 {count}",
sold: "已订 {count}",
mealSupply: "{meal}供应",
productInfo: "商品信息",
supplyDate: "供应日期",
+1 -1
View File
@@ -161,7 +161,7 @@ export default {
dailyMenuBadge: "每日菜譜",
unnamed: "未命名菜品",
perUnit: "/份",
sold: "已售 {count}",
sold: "已訂 {count}",
mealSupply: "{meal}供應",
productInfo: "商品信息",
supplyDate: "供應日期",
+397 -62
View File
@@ -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
View File
@@ -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;
+18 -1
View File
@@ -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),
+32 -4
View File
@@ -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
+47 -4
View File
@@ -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",
})
},
})
}