This commit is contained in:
zjf committed 2026-11-05 17:34:45 +08:00
1 parent 2a1d0b1d11
commit 14f4b160e6
7 files changed
+376 -47

No files matched your search

+1 -1
View File
@@ -588,7 +588,7 @@ const submitSingle = async () => {
saving.value = true
try {
const payload: any = {
id: single.id || undefined,
id: single.id != null && single.id !== "" ? String(single.id) : undefined,
stayDate: single.stayDate,
productCode: single.productCode,
sellPrice: single.sellPrice !== "" ? Number(single.sellPrice) : undefined,
+1 -1
View File
@@ -604,7 +604,7 @@ const submitEdit = async () => {
const payload: any = {
...(hotel.value || {}),
...buildPayload(),
id: hotel.value?.id || hotelId.value,
id: String(hotel.value?.id || hotelId.value),
}
const res: any = await smHotelAdminApi.update(payload)
if (res?.code !== 0) {
+1
View File
@@ -354,6 +354,7 @@ const batchSetStatus = async (status: "ACTIVE" | "OFFLINE") => {
}
const res: any = await smHotelAdminApi.update({
...hotel,
id: hotel?.id != null ? String(hotel.id) : id,
upstreamStatus: status,
})
if (res?.code === 0) ok++
+349 -41
View File
@@ -30,7 +30,12 @@
</view>
<view v-else-if="!list.length" class="ord__empty">暂无订单</view>
<view v-else class="ord__list">
<view v-for="row in list" :key="row.id" class="card" :class="{ 'card--warn': isPriceAnomaly(row) || row.orderStatus === 'PENDING_CANCEL' }">
<view
v-for="row in list"
:key="row.id"
class="card"
:class="{ 'card--warn': isPriceAnomaly(row) || row.orderStatus === 'PENDING_CANCEL' }"
@tap="openOrderDetail(row)">
<view class="card__head">
<text class="card__hotel">{{ row.hotelName || row.hotelCode || "酒店" }}</text>
<text class="card__status" :class="statusClass(row.orderStatus)">
@@ -39,34 +44,54 @@
</view>
<view v-if="isPriceAnomaly(row)" class="card__badge">结算价异常 · 请人工判断</view>
<view v-else-if="row.orderStatus === 'PENDING_CANCEL'" class="card__badge">下游申请取消 · 请确认</view>
<view class="card__kv">
<text class="k">单号</text>
<text class="v mono copyable" @tap.stop="copyText(orderNo(row))">
{{ orderNo(row) }}
<text v-if="orderNo(row) !== '-'" class="copy-tip">复制</text>
</text>
</view>
<view class="card__kv">
<text class="k">房型/产品</text>
<text class="v">{{ row.productName || row.roomTypeName || row.productCode || "-" }}</text>
</view>
<view class="card__kv">
<text class="k">入住</text>
<text class="v">{{ row.checkInDate || "-" }} ~ {{ row.checkOutDate || "-" }} · {{ row.count || 1 }}间</text>
</view>
<view class="card__kv">
<text class="k">联系人</text>
<text class="v">{{ row.contactName || "-" }} {{ row.contactPhone || "" }}</text>
</view>
<view class="card__kv">
<text class="k">支付时间</text>
<text class="v">{{ fmtTime(row.createTime) }}</text>
</view>
<view v-if="row.orderStatus === 'PENDING_CANCEL' && row.cancelRuleHint" class="card__line tip">
取消规则:{{ row.cancelRuleHint }}
</view>
<view class="card__line">{{ row.productName || row.roomTypeName || row.productCode || "-" }}</view>
<view class="card__line muted">
{{ row.checkInDate || "-" }} ~ {{ row.checkOutDate || "-" }} · {{ row.count || 1 }}间
</view>
<view class="card__line muted">
{{ row.contactName || "-" }} {{ row.contactPhone || "" }}
</view>
<view v-if="row.failMsg" class="card__line tip">{{ row.failMsg }}</view>
<view class="card__foot">
<view class="card__foot" @tap.stop>
<text class="card__amt">¥{{ formatAmt(row.sellAmount) }}</text>
<view v-if="canAct(row)" class="card__acts">
<button class="btn btn--ghost" :disabled="actingId === row.id" @tap="onReject(row)">
拒绝
</button>
<button class="btn btn--primary" :disabled="actingId === row.id" @tap="onConfirm(row)">
确认接单
</button>
</view>
<view v-else-if="canActCancel(row)" class="card__acts">
<button class="btn btn--ghost" :disabled="actingId === row.id" @tap="onRejectCancel(row)">
拒绝取消
</button>
<button class="btn btn--danger" :disabled="actingId === row.id" @tap="onConfirmCancel(row)">
同意取消
</button>
<view class="card__acts">
<button class="btn btn--link" @tap.stop="goHotel(row)">酒店</button>
<button class="btn btn--link" @tap.stop="openOrderDetail(row)">详情</button>
<template v-if="canAct(row)">
<button class="btn btn--ghost" :disabled="actingId === row.id" @tap.stop="onReject(row)">
拒绝
</button>
<button class="btn btn--primary" :disabled="actingId === row.id" @tap.stop="onConfirm(row)">
确认接单
</button>
</template>
<template v-else-if="canActCancel(row)">
<button class="btn btn--ghost" :disabled="actingId === row.id" @tap.stop="onRejectCancel(row)">
拒绝取消
</button>
<button class="btn btn--danger" :disabled="actingId === row.id" @tap.stop="onConfirmCancel(row)">
同意取消
</button>
</template>
</view>
</view>
</view>
@@ -78,20 +103,88 @@
<view v-else-if="list.length" class="ord__more muted">没有更多了</view>
</view>
<!-- 订单详情 -->
<view v-if="detailShow" class="sheet-mask" @tap="closeDetail">
<view class="sheet" @tap.stop>
<view class="sheet__hd">
<text class="sheet__title">订单详情</text>
<text class="sheet__close" @tap="closeDetail">关闭</text>
</view>
<scroll-view scroll-y class="sheet__bd" v-if="detailLoading">
<view class="ord__loading"><view class="ord__spin" /><text>加载中…</text></view>
</scroll-view>
<scroll-view v-else scroll-y class="sheet__bd">
<template v-if="detail?.order">
<view class="dsec">
<view class="dsec__t">订单</view>
<view class="drow"><text class="dk">状态</text><text class="dv">{{ statusText(detail.order.orderStatus) }}</text></view>
<view class="drow">
<text class="dk">渠道单号</text>
<text class="dv mono copyable" @tap="copyText(detail.order.channelNo)">
{{ detail.order.channelNo || "-" }}
<text v-if="detail.order.channelNo" class="copy-tip">复制</text>
</text>
</view>
<view class="drow">
<text class="dk">平台单号</text>
<text class="dv mono copyable" @tap="copyText(detail.order.platformNo)">
{{ detail.order.platformNo || "-" }}
<text v-if="detail.order.platformNo" class="copy-tip">复制</text>
</text>
</view>
<view class="drow"><text class="dk">入住离店</text><text class="dv">{{ detail.order.checkInDate }} ~ {{ detail.order.checkOutDate }}</text></view>
<view class="drow"><text class="dk">间数</text><text class="dv">{{ detail.order.count || 1 }}</text></view>
<view class="drow"><text class="dk">应收</text><text class="dv strong">¥{{ formatAmt(detail.order.sellAmount) }}</text></view>
<view class="drow"><text class="dk">成本</text><text class="dv">¥{{ formatAmt(detail.order.costAmount) }}</text></view>
<view class="drow"><text class="dk">支付时间</text><text class="dv">{{ fmtTime(detail.order.createTime) }}</text></view>
<view class="drow"><text class="dk">联系人</text><text class="dv">{{ detail.order.contactName || "-" }} {{ detail.order.contactPhone || "" }}</text></view>
<view v-if="detail.order.cancelRuleHint" class="drow"><text class="dk">取消规则</text><text class="dv tip">{{ detail.order.cancelRuleHint }}</text></view>
<view v-if="detail.order.failMsg" class="drow"><text class="dk">失败说明</text><text class="dv tip">{{ detail.order.failMsg }}</text></view>
</view>
<view v-if="detail.hotel" class="dsec">
<view class="dsec__t">酒店</view>
<view class="drow"><text class="dk">名称</text><text class="dv">{{ detail.hotel.hotelName || "-" }}</text></view>
<view class="drow"><text class="dk">编码</text><text class="dv mono">{{ detail.hotel.hotelCode || "-" }}</text></view>
<view class="drow"><text class="dk">地址</text><text class="dv">{{ detail.hotel.address || detail.hotel.district || "-" }}</text></view>
<view class="drow"><text class="dk">电话</text><text class="dv">{{ detail.hotel.phone || "-" }}</text></view>
<view class="drow"><text class="dk">来源</text><text class="dv">{{ detail.hotel.dataSource || "-" }}</text></view>
<button class="btn btn--primary sheet__btn" @tap="goHotelFromDetail">查看酒店详情</button>
</view>
<view v-if="detail.product" class="dsec">
<view class="dsec__t">产品</view>
<view class="drow"><text class="dk">名称</text><text class="dv">{{ detail.product.productName || detail.roomTypeName || "-" }}</text></view>
<view class="drow"><text class="dk">编码</text><text class="dv mono">{{ detail.product.productCode || "-" }}</text></view>
<view class="drow"><text class="dk">房型</text><text class="dv">{{ detail.product.roomTypeCode || detail.order.roomTypeCode || "-" }}</text></view>
</view>
<view v-if="guestLines.length" class="dsec">
<view class="dsec__t">入住人</view>
<view v-for="(g, i) in guestLines" :key="i" class="drow">
<text class="dk">{{ i + 1 }}</text>
<text class="dv">{{ g }}</text>
</view>
</view>
</template>
<view v-else class="ord__empty">暂无详情</view>
</scroll-view>
</view>
</view>
<view class="tab-spacer" />
<hm-tabbar active="orders" />
</crud-layout>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { computed, ref } from "vue"
import { onLoad, onShow } from "@dcloudio/uni-app"
import { smHotelAdminApi } from "@@/api"
import { router } from "@@/index"
import { canAccessHotelManage, refreshUserPermissions } from "@@/utils/user-role"
import HmTabbar from "./components/hm-tabbar.vue"
import HmNavBar from "./components/hm-nav-bar.vue"
import { consumeHmBridgeAuth, markHmFromAppWebView, goHotelManageMine, notifyHotelManageH5Ready } from "./hm-h5-config"
import { useHmEmbed } from "./use-hm-embed"
import { setHmHotel } from "./hm-context"
const { embedInApp } = useHmEmbed()
const loading = ref(false)
@@ -100,6 +193,12 @@ const list = ref<any[]>([])
const page = ref(1)
const hasMore = ref(true)
const filter = ref<"pending" | "cancel" | "all">("pending")
/** 防止 onShow / 切 Tab 并发请求互相覆盖导致「全部」空白 */
let fetchSeq = 0
const detailShow = ref(false)
const detailLoading = ref(false)
const detail = ref<any>(null)
const tabs = [
{ key: "pending" as const, label: "待确认" },
@@ -142,12 +241,33 @@ const statusClass = (s?: string) => {
return ""
}
const orderNo = (row: any) => String(row?.channelNo || row?.platformNo || row?.id || "-")
const copyText = (text?: string | number | null) => {
const v = String(text || "").trim()
if (!v || v === "-") {
uni.showToast({ title: "无可复制内容", icon: "none" })
return
}
uni.setClipboardData({
data: v,
success: () => uni.showToast({ title: "已复制", icon: "none" }),
fail: () => uni.showToast({ title: "复制失败", icon: "none" }),
})
}
const formatAmt = (v: any) => {
const n = Number(v)
if (Number.isNaN(n)) return "0"
if (Number.isNaN(n)) return "0.00"
return n.toFixed(2)
}
const fmtTime = (v: any) => {
if (!v) return "-"
const s = String(v).replace("T", " ")
return s.length > 16 ? s.slice(0, 16) : s
}
const isLocalRow = (row: any) => {
const ds = String(row?.hotelDataSource || "")
.trim()
@@ -170,6 +290,21 @@ const canActCancel = (row: any) => {
return !!row && row.orderStatus === "PENDING_CANCEL"
}
const guestLines = computed(() => {
const g = detail.value?.guests
if (!g) return [] as string[]
const arr = Array.isArray(g) ? g : [g]
return arr
.map((item: any) => {
if (typeof item === "string") return item
const name = item?.name || item?.guestName || item?.contactName || ""
const phone = item?.phone || item?.mobile || item?.contactPhone || ""
const idNo = item?.idNo || item?.idCard || ""
return [name, phone, idNo].filter(Boolean).join(" · ") || JSON.stringify(item)
})
.filter(Boolean)
})
const buildParams = () => {
const params: Record<string, any> = {
current: page.value,
@@ -181,22 +316,23 @@ const buildParams = () => {
params.hotelDataSource = "local"
} else if (filter.value === "cancel") {
params.orderStatus = "PENDING_CANCEL"
// 待取消含本地与上游酒店,人工核对取消规则
}
return params
}
const fetchPage = async (reset: boolean) => {
if (loading.value) return
const seq = ++fetchSeq
if (reset) {
page.value = 1
hasMore.value = true
}
loading.value = true
try {
if (reset) {
page.value = 1
hasMore.value = true
}
const res: any = await smHotelAdminApi.orderPage(buildParams())
if (seq !== fetchSeq) return
if (res?.code !== 0) {
uni.showToast({ title: res?.msg || "加载失败", icon: "none" })
if (reset) list.value = []
return
}
const records = res?.data?.records || res?.data?.list || []
@@ -204,16 +340,21 @@ const fetchPage = async (reset: boolean) => {
list.value = reset ? records : list.value.concat(records)
hasMore.value = list.value.length < total
} catch (e: any) {
if (seq !== fetchSeq) return
uni.showToast({ title: e?.msg || "加载失败", icon: "none" })
if (reset) list.value = []
} finally {
loading.value = false
notifyHotelManageH5Ready()
if (seq === fetchSeq) {
loading.value = false
notifyHotelManageH5Ready()
}
}
}
const switchFilter = (key: "pending" | "cancel" | "all") => {
if (filter.value === key) return
filter.value = key
list.value = []
fetchPage(true)
}
@@ -223,6 +364,54 @@ const loadMore = () => {
fetchPage(false)
}
const goHotel = (row: any) => {
const id = row?.hotelId
if (!id) {
uni.showToast({ title: "无关联酒店", icon: "none" })
return
}
setHmHotel({ id, hotelName: row.hotelName, hotelCode: row.hotelCode })
router.jump("/pages/hotel-manage/detail", { id: String(id) })
}
const goHotelFromDetail = () => {
const h = detail.value?.hotel
const id = h?.id || detail.value?.order?.hotelId
if (!id) {
uni.showToast({ title: "无关联酒店", icon: "none" })
return
}
detailShow.value = false
setHmHotel({ id, hotelName: h?.hotelName, hotelCode: h?.hotelCode })
router.jump("/pages/hotel-manage/detail", { id: String(id) })
}
const openOrderDetail = async (row: any) => {
if (!row?.id) return
detailShow.value = true
detailLoading.value = true
detail.value = null
try {
const res: any = await smHotelAdminApi.orderDetail(String(row.id))
if (res?.code !== 0) {
uni.showToast({ title: res?.msg || "详情加载失败", icon: "none" })
detailShow.value = false
return
}
detail.value = res.data || null
} catch (e: any) {
uni.showToast({ title: e?.msg || "详情加载失败", icon: "none" })
detailShow.value = false
} finally {
detailLoading.value = false
}
}
const closeDetail = () => {
detailShow.value = false
detail.value = null
}
const onConfirm = (row: any) => {
uni.showModal({
title: "确认接单",
@@ -425,7 +614,7 @@ onShow(async () => {
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 10rpx;
margin-bottom: 12rpx;
}
.card__hotel {
flex: 1;
@@ -455,14 +644,41 @@ onShow(async () => {
background: #fef3f2;
color: #d92d20;
}
.card__kv {
display: flex;
gap: 16rpx;
font-size: 24rpx;
line-height: 1.55;
margin-top: 4rpx;
}
.card__kv .k {
width: 120rpx;
flex-shrink: 0;
color: #98a2b3;
}
.card__kv .v {
flex: 1;
color: #344054;
word-break: break-all;
}
.card__kv .mono {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 22rpx;
}
.copyable {
display: inline;
}
.copy-tip {
margin-left: 12rpx;
font-size: 20rpx;
color: #2e6bf0;
font-family: inherit;
}
.card__line {
font-size: 24rpx;
color: #344054;
line-height: 1.5;
}
.card__line.muted {
color: #98a2b3;
}
.card__line.tip {
margin-top: 8rpx;
font-size: 22rpx;
@@ -476,7 +692,8 @@ onShow(async () => {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
gap: 12rpx;
flex-wrap: wrap;
}
.card__amt {
font-size: 32rpx;
@@ -485,11 +702,13 @@ onShow(async () => {
}
.card__acts {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
gap: 10rpx;
justify-content: flex-end;
}
.btn {
margin: 0;
padding: 0 22rpx;
padding: 0 20rpx;
height: 56rpx;
line-height: 56rpx;
font-size: 24rpx;
@@ -512,6 +731,10 @@ onShow(async () => {
background: #d92d20;
color: #fff;
}
.btn--link {
background: rgba(46, 107, 240, 0.08);
color: #2e6bf0;
}
.ord__more {
text-align: center;
padding: 28rpx 0 8rpx;
@@ -524,4 +747,89 @@ onShow(async () => {
.tab-spacer {
height: 140rpx;
}
.sheet-mask {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(16, 24, 40, 0.45);
display: flex;
align-items: flex-end;
}
.sheet {
width: 100%;
max-height: 82vh;
background: #fff;
border-radius: 28rpx 28rpx 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sheet__hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 16rpx;
border-bottom: 1rpx solid #edf1f7;
}
.sheet__title {
font-size: 32rpx;
font-weight: 700;
color: #1d2939;
}
.sheet__close {
font-size: 26rpx;
color: #667085;
padding: 8rpx 12rpx;
}
.sheet__bd {
max-height: 68vh;
padding: 8rpx 32rpx 40rpx;
box-sizing: border-box;
}
.dsec {
padding: 20rpx 0;
border-bottom: 1rpx solid #f2f4f7;
}
.dsec:last-child {
border-bottom: none;
}
.dsec__t {
font-size: 28rpx;
font-weight: 600;
color: #1d2939;
margin-bottom: 12rpx;
}
.drow {
display: flex;
gap: 16rpx;
font-size: 24rpx;
line-height: 1.55;
margin-top: 6rpx;
}
.dk {
width: 140rpx;
flex-shrink: 0;
color: #98a2b3;
}
.dv {
flex: 1;
color: #344054;
word-break: break-all;
}
.dv.strong {
font-weight: 700;
color: #1d2939;
}
.dv.tip {
color: #b42318;
}
.dv.mono {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 22rpx;
}
.sheet__btn {
margin-top: 20rpx;
width: 100%;
}
</style>
+1 -1
View File
@@ -258,7 +258,7 @@ const submitProduct = async () => {
form.productCode.trim() ||
`${form.roomTypeCode}-P${String(Date.now()).slice(-4)}`
const payload: any = {
id: editingId.value || undefined,
id: editingId.value ? String(editingId.value) : undefined,
productName: form.productName.trim(),
productCode: autoCode,
roomTypeCode: form.roomTypeCode,
+13 -3
View File
@@ -270,17 +270,23 @@ const submitRoom = async () => {
uni.showToast({ title: "请填写床型", icon: "none" })
return
}
if (!form.roomNumber || Number(form.roomNumber) <= 0) {
// 物理房间数允许 0(上游常空/为 0);空才拦截。库存另在产品管理维护
if (form.roomNumber === "" || form.roomNumber == null || Number.isNaN(Number(form.roomNumber))) {
uni.showToast({ title: "请填写房间数", icon: "none" })
return
}
if (Number(form.roomNumber) < 0) {
uni.showToast({ title: "房间数不能为负数", icon: "none" })
return
}
saving.value = true
try {
const autoCode =
form.roomTypeCode.trim() ||
`${hotelCode.value || "R"}-F${String(Date.now()).slice(-6)}`
const roomPayload: any = {
id: editingRoomId.value || undefined,
// 雪花 id 必须字符串,避免 Number 精度丢失
id: editingRoomId.value ? String(editingRoomId.value) : undefined,
roomTypeName: form.roomTypeName.trim(),
roomTypeCode: autoCode,
bedType: form.bedType.trim(),
@@ -304,7 +310,11 @@ const submitRoom = async () => {
const wantProduct = sell != null || cost != null || !!editingProductId.value
if (wantProduct) {
const productPayload: any = {
id: editingProductId.value || existing?.id || undefined,
id: editingProductId.value
? String(editingProductId.value)
: existing?.id != null
? String(existing.id)
: undefined,
roomTypeCode,
productCode: existing?.productCode || `${roomTypeCode}-P01`,
productName: form.roomTypeName.trim(),
+10
View File
@@ -86,6 +86,16 @@ export default {
method: "GET",
params: { scopeByHotel: true, ...(params || {}) },
header: tocOff,
timeout: 60000,
}),
/** 订单详情(含酒店/产品摘要) */
orderDetail: (id: string | number): Promise<any> =>
request({
url: `/supermarket/smHotelOrder/${id}`,
method: "GET",
header: tocOff,
timeout: 30000,
}),
/** 本地待确认订单:确认接单 */