This commit is contained in:
zjf committed 2026-11-02 19:45:41 +08:00
1 parent 3f7a980683
commit 3143da3f16
6 files changed
+1930

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
import request from '@/config/axios'
export interface HotelZaxRoomVO {
id?: number
hotelId?: number
hotelCode?: string
roomTypeCode?: string
roomTypeName?: string
bedType?: string
capacity?: number
roomNumber?: number
}
export interface HotelZaxProductVO {
id?: number
hotelId?: number
hotelCode?: string
roomTypeCode?: string
productCode?: string
productName?: string
priceCode?: string
cancelCode?: string
mealType?: number
mealCount?: number
sellPrice?: number
stock?: number
}
export interface HotelZaxVO {
id?: number
hotelCode?: string
hotelName?: string
groupCode?: string
coverImage?: string
carouselImages?: string
address?: string
district?: string
districtAdcode?: number
bizArea?: string
starLevel?: number
score?: number
facilityTags?: string
longitude?: number
latitude?: number
phone?: string
minSellPrice?: number
status?: string
supplierUpdateTime?: string
syncedAt?: string
intro?: string
roomCount?: number
productCount?: number
rooms?: HotelZaxRoomVO[]
products?: HotelZaxProductVO[]
}
export interface HotelZaxPageReq {
pageNo: number
pageSize: number
hotelName?: string
hotelCode?: string
status?: string
district?: string
districtAdcode?: number
}
export const getHotelZaxPage = (params: HotelZaxPageReq) => {
return request.get({ url: '/product/hotel-zax/page', params })
}
export const getHotelZax = (id: number) => {
return request.get({ url: '/product/hotel-zax/get', params: { id } })
}
export const updateHotelZax = (data: HotelZaxVO) => {
return request.put({ url: '/product/hotel-zax/update', data })
}
export const updateHotelZaxStatus = (id: number, status: string) => {
return request.put({ url: '/product/hotel-zax/update-status', data: { id, status } })
}
export const saveHotelZaxRoom = (data: HotelZaxRoomVO) => {
return request.post({ url: '/product/hotel-zax/room/save', data })
}
export const deleteHotelZaxRoom = (hotelId: number, roomId: number) => {
return request.delete({ url: '/product/hotel-zax/room/delete', params: { hotelId, roomId } })
}
export const saveHotelZaxProduct = (data: HotelZaxProductVO) => {
return request.post({ url: '/product/hotel-zax/product/save', data })
}
export const deleteHotelZaxProduct = (hotelId: number, productId: number) => {
return request.delete({ url: '/product/hotel-zax/product/delete', params: { hotelId, productId } })
}
export interface HotelZaxDailyVO {
id?: number
hotelId?: number
hotelCode?: string
productId?: number
productCode?: string
roomTypeCode?: string
stayDate?: string
costPrice?: number
sellPrice?: number
stock?: number
sold?: number
remainStock?: number
dailyStatus?: number
}
export interface HotelZaxDailyPageReq {
pageNo: number
pageSize: number
hotelId: number
productCode?: string
stayFrom?: string
stayTo?: string
}
export const getHotelZaxDailyPage = (params: HotelZaxDailyPageReq) => {
return request.get({ url: '/product/hotel-zax/daily/page', params })
}
export const saveHotelZaxDaily = (data: HotelZaxDailyVO) => {
return request.post({ url: '/product/hotel-zax/daily/save', data })
}
export const batchHotelZaxDaily = (data: {
hotelId: number
mode: 'FILL' | 'ADJUST'
startDate: string
endDate: string
productCodes?: string[]
costPrice?: number
sellPrice?: number
stock?: number
dailyStatus?: number
fillMissing?: boolean
}) => {
return request.post({ url: '/product/hotel-zax/daily/batch', data })
}
export const deleteHotelZaxDaily = (hotelId: number, dailyId: number) => {
return request.delete({ url: '/product/hotel-zax/daily/delete', params: { hotelId, dailyId } })
}
export const syncHotelZaxDaily = (hotelId: number) => {
return request.post({ url: '/product/hotel-zax/daily/sync', params: { hotelId } })
}
export const getHotelZaxSyncCursor = () => {
return request.get({ url: '/product/hotel-zax/sync-cursor' })
}
/** full=true:按当前资安心授权范围全量拉(改授权后必须全量,否则增量水位会显示 0) */
export const syncHotelZax = (full = true) => {
return request.post({ url: '/product/hotel-zax/sync', params: { full } })
}
export const resetHotelZaxCursor = () => {
return request.post({ url: '/product/hotel-zax/reset-cursor' })
}
export interface HotelZaxOrderVO {
id?: number
userId?: number
channelNo?: string
orderNo?: string
platformNo?: string
orderStatus?: string
bookStatus?: string
hotelCode?: string
hotelName?: string
productCode?: string
productName?: string
roomTypeCode?: string
checkInDate?: string
checkOutDate?: string
nights?: number
roomCount?: number
contactName?: string
contactPhone?: string
guestJson?: string
payOrderId?: number
payStatus?: boolean
payTime?: string
sellAmount?: number
failMsg?: string
createTime?: string
}
export interface HotelZaxOrderPageReq {
pageNo: number
pageSize: number
hotelName?: string
hotelCode?: string
channelNo?: string
orderNo?: string
orderStatus?: string
payStatus?: boolean
contactPhone?: string
userId?: number
}
export const getHotelZaxOrderPage = (params: HotelZaxOrderPageReq) => {
return request.get({ url: '/product/hotel-zax/order/page', params })
}
export const getHotelZaxOrder = (id: number) => {
return request.get({ url: '/product/hotel-zax/order/get', params: { id } })
}
export const cancelHotelZaxOrder = (id: number, reason?: string) => {
return request.post({ url: '/product/hotel-zax/order/cancel', params: { id, reason } })
}
@@ -0,0 +1,238 @@
<template>
<el-drawer v-model="visible" :title="title" size="72%" destroy-on-close @closed="reset">
<div v-loading="loading">
<div v-if="hotel" class="hotel-overview mb-16px">
<div class="hotel-overview__hero">
<div class="hotel-overview__cover">
<el-image
v-if="hotel.coverImage"
:src="hotel.coverImage"
fit="cover"
class="hotel-overview__cover-img"
:preview-src-list="[hotel.coverImage]"
/>
<div v-else class="hotel-overview__cover-empty">暂无主图</div>
</div>
<div class="hotel-overview__main">
<div class="hotel-overview__name">{{ hotel.hotelName || '-' }}</div>
<div class="hotel-overview__code">编码 {{ hotel.hotelCode || '-' }}</div>
<div class="hotel-overview__tags">
<el-tag
:type="hotel.status === 'ACTIVE' ? 'success' : hotel.status === 'OUT_OF_SCOPE' ? 'warning' : 'info'"
effect="dark"
size="small"
>
{{
hotel.status === 'ACTIVE'
? '上架'
: hotel.status === 'OUT_OF_SCOPE'
? '未授权'
: hotel.status === 'LOCAL_OFFLINE'
? '本地下架'
: '下架'
}}
</el-tag>
<el-tag v-if="starLabel(hotel.starLevel) !== '-'" type="warning" effect="plain" size="small">
{{ starLabel(hotel.starLevel) }}
</el-tag>
<el-tag v-if="hotel.score != null" type="danger" effect="plain" size="small">
评分 {{ hotel.score }}
</el-tag>
<el-tag v-if="hotel.minSellPrice != null" effect="plain" size="small">
起价 ¥{{ hotel.minSellPrice }}
</el-tag>
</div>
<div class="hotel-overview__addr">
<span class="hotel-overview__addr-label">地址</span>
<span>{{ hotel.address || '-' }}</span>
</div>
</div>
</div>
<div class="hotel-overview__grid">
<div class="hotel-overview__cell">
<span class="hotel-overview__label">联系电话</span>
<span class="hotel-overview__value">{{ hotel.phone || '-' }}</span>
</div>
<div class="hotel-overview__cell">
<span class="hotel-overview__label">位置区域</span>
<span class="hotel-overview__value">
{{ hotel.district || '-' }}
<span v-if="hotel.districtAdcode" class="text-gray-400">({{ hotel.districtAdcode }})</span>
</span>
</div>
<div class="hotel-overview__cell">
<span class="hotel-overview__label">商圈地标</span>
<span class="hotel-overview__value">{{ hotel.bizArea || '-' }}</span>
</div>
<div class="hotel-overview__cell">
<span class="hotel-overview__label">坐标</span>
<span class="hotel-overview__value">
<template v-if="hotel.longitude != null && hotel.latitude != null">
{{ hotel.longitude }}, {{ hotel.latitude }}
</template>
<template v-else>-</template>
</span>
</div>
<div class="hotel-overview__cell hotel-overview__cell--full">
<span class="hotel-overview__label">设施标签</span>
<span class="hotel-overview__value">
<template v-if="facilityTagList.length">
<el-tag
v-for="(tag, idx) in facilityTagList"
:key="`${tag}-${idx}`"
size="small"
effect="plain"
class="mr-4px mb-4px"
>
{{ tag }}
</el-tag>
</template>
<template v-else>-</template>
</span>
</div>
<div v-if="hotel.intro" class="hotel-overview__cell hotel-overview__cell--full">
<span class="hotel-overview__label">简介</span>
<div class="hotel-overview__value intro-html" v-html="hotel.intro"></div>
</div>
</div>
</div>
<HotelZaxInventory v-if="hotel?.id" :hotel-id="hotel.id" @changed="emit('changed')" />
</div>
</el-drawer>
</template>
<script lang="ts" setup>
import * as HotelZaxApi from '@/api/mall/product/hotelZax'
import HotelZaxInventory from './HotelZaxInventory.vue'
defineOptions({ name: 'HotelZaxDetail' })
const emit = defineEmits<{ (e: 'changed'): void }>()
const visible = ref(false)
const loading = ref(false)
const hotel = ref<HotelZaxApi.HotelZaxVO | null>(null)
const title = computed(() => (hotel.value?.hotelName ? `酒店详情 - ${hotel.value.hotelName}` : '酒店详情'))
const facilityTagList = computed(() =>
(hotel.value?.facilityTags || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
)
const starLabel = (v?: number) => {
const map: Record<number, string> = { 5: '五星', 4: '高档', 3: '舒适', 2: '经济', 1: '民宿' }
return v ? map[v] || String(v) : '-'
}
const reset = () => {
hotel.value = null
}
const load = async (id: number) => {
loading.value = true
try {
hotel.value = await HotelZaxApi.getHotelZax(id)
} finally {
loading.value = false
}
}
const open = async (id: number) => {
visible.value = true
await load(id)
}
defineExpose({ open })
</script>
<style scoped>
.hotel-overview__hero {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.hotel-overview__cover {
width: 140px;
height: 105px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
background: var(--el-fill-color-light);
}
.hotel-overview__cover-img {
width: 100%;
height: 100%;
}
.hotel-overview__cover-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder);
font-size: 13px;
}
.hotel-overview__name {
font-size: 20px;
font-weight: 600;
line-height: 1.3;
}
.hotel-overview__code {
margin-top: 4px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.hotel-overview__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.hotel-overview__addr {
margin-top: 10px;
font-size: 13px;
color: var(--el-text-color-regular);
}
.hotel-overview__addr-label {
color: var(--el-text-color-secondary);
margin-right: 8px;
}
.hotel-overview__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 20px;
padding: 14px 16px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.hotel-overview__cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.hotel-overview__cell--full {
grid-column: 1 / -1;
}
.hotel-overview__label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.hotel-overview__value {
font-size: 14px;
color: var(--el-text-color-primary);
word-break: break-all;
}
.intro-html {
max-height: 220px;
overflow: auto;
line-height: 1.6;
}
.intro-html :deep(img) {
max-width: 100%;
}
</style>
@@ -0,0 +1,259 @@
<template>
<el-drawer
v-model="visible"
:title="'编辑酒店'"
size="80%"
:close-on-click-modal="false"
destroy-on-close
@closed="reset"
>
<div v-loading="loading" class="pr-12px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="酒店编码">
<el-input v-model="form.hotelCode" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="酒店名称" prop="hotelName">
<el-input v-model="form.hotelName" maxlength="256" placeholder="酒店名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上架状态">
<el-switch
v-model="form.status"
active-value="ACTIVE"
inactive-value="LOCAL_OFFLINE"
inline-prompt
active-text="上架"
inactive-text="下架"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系电话">
<el-input v-model="form.phone" maxlength="32" placeholder="联系电话" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="酒店主图">
<UploadImg v-model="form.coverImage" :limit="1" height="80px" width="80px" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="轮播图">
<UploadImgs v-model="carouselList" :limit="9" />
<div class="text-12px text-gray-400 mt-4px">多图上传,保存为逗号分隔 URL</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="位置区域">
<el-input v-model="form.district" placeholder="如:南湖区" maxlength="128" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="区域编码">
<el-input-number
v-model="form.districtAdcode"
class="!w-full"
:controls="false"
placeholder="adcode"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商圈地标">
<el-input v-model="form.bizArea" maxlength="128" placeholder="如:嘉兴南站附近" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="起价(元)">
<el-input-number
v-model="form.minSellPrice"
class="!w-full"
:min="0"
:precision="2"
controls-position="right"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="经度">
<el-input-number v-model="form.longitude" class="!w-full" :precision="6" :controls="false" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="纬度">
<el-input-number v-model="form.latitude" class="!w-full" :precision="6" :controls="false" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="详细地址">
<el-input v-model="form.address" type="textarea" :rows="2" maxlength="512" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="星级档次">
<el-select v-model="form.starLevel" clearable placeholder="请选择" class="!w-full">
<el-option :value="5" label="五星" />
<el-option :value="4" label="高档" />
<el-option :value="3" label="舒适" />
<el-option :value="2" label="经济" />
<el-option :value="1" label="民宿" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="评分">
<el-input-number
v-model="form.score"
class="!w-full"
:min="0"
:max="5"
:precision="1"
:step="0.1"
controls-position="right"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="设施标签">
<el-select
v-model="facilityTagList"
class="!w-full"
multiple
filterable
allow-create
default-first-option
placeholder="含早餐 / 免费取消 / 免费停车 等"
>
<el-option v-for="t in facilityOptions" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="酒店简介">
<Editor v-if="visible" v-model="form.intro" height="280px" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider v-if="form.id" content-position="left">房型 / 产品 / 日历</el-divider>
<HotelZaxInventory v-if="form.id" :hotel-id="form.id" @changed="emit('success')" />
</div>
<template #footer>
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保 存</el-button>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { Editor } from '@/components/Editor'
import { UploadImg, UploadImgs } from '@/components/UploadFile'
import * as HotelZaxApi from '@/api/mall/product/hotelZax'
import HotelZaxInventory from './HotelZaxInventory.vue'
defineOptions({ name: 'HotelZaxForm' })
const message = useMessage()
const emit = defineEmits<{ (e: 'success'): void }>()
const visible = ref(false)
const loading = ref(false)
const saving = ref(false)
const formRef = ref()
const carouselList = ref<string[]>([])
const facilityTagList = ref<string[]>([])
const facilityOptions = ['含早餐', '免费取消', '免费停车', '近地铁', '接送机', '健身房', '游泳池']
const form = reactive<HotelZaxApi.HotelZaxVO>({
id: undefined,
hotelCode: '',
hotelName: '',
status: 'ACTIVE',
phone: '',
coverImage: '',
carouselImages: '',
district: '',
districtAdcode: undefined,
bizArea: '',
address: '',
starLevel: undefined,
score: undefined,
facilityTags: '',
longitude: undefined,
latitude: undefined,
minSellPrice: undefined,
intro: ''
})
const rules = {
hotelName: [{ required: true, message: '请输入酒店名称', trigger: 'blur' }]
}
const reset = () => {
formRef.value?.resetFields?.()
carouselList.value = []
facilityTagList.value = []
}
const open = async (id: number) => {
visible.value = true
loading.value = true
try {
const data = await HotelZaxApi.getHotelZax(id)
Object.assign(form, {
id: data.id,
hotelCode: data.hotelCode,
hotelName: data.hotelName,
status: data.status || 'ACTIVE',
phone: data.phone || '',
coverImage: data.coverImage || '',
carouselImages: data.carouselImages || '',
district: data.district || '',
districtAdcode: data.districtAdcode,
bizArea: data.bizArea || '',
address: data.address || '',
starLevel: data.starLevel,
score: data.score,
facilityTags: data.facilityTags || '',
longitude: data.longitude,
latitude: data.latitude,
minSellPrice: data.minSellPrice,
intro: data.intro || ''
})
carouselList.value = (data.carouselImages || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
facilityTagList.value = (data.facilityTags || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
} finally {
loading.value = false
}
}
const submit = async () => {
await formRef.value?.validate?.()
saving.value = true
try {
await HotelZaxApi.updateHotelZax({
...form,
carouselImages: carouselList.value.filter(Boolean).join(','),
facilityTags: facilityTagList.value.filter(Boolean).join(',')
})
message.success('保存成功')
visible.value = false
emit('success')
} finally {
saving.value = false
}
}
defineExpose({ open })
</script>
@@ -0,0 +1,675 @@
<template>
<div>
<el-alert
type="info"
:closable="false"
show-icon
class="mb-12px"
title="房型、产品、日历在本页维护。同步资安心会拉房型/产品和近 90 天日历;也可在日历 Tab 单独「从资安心拉取」。本地改过的日历再同步时会更新价态/配额,已售保留。"
/>
<el-tabs v-model="activeTab">
<el-tab-pane :label="`房型(${rooms.length})`" name="room">
<div class="mb-8px">
<el-button
type="primary"
size="small"
v-hasPermi="['product:hotel-zax:update']"
@click="openRoomDialog()"
>
新增房型
</el-button>
</div>
<el-table :data="rooms" border size="small" max-height="420">
<el-table-column prop="roomTypeCode" label="房型编码" min-width="120" show-overflow-tooltip />
<el-table-column prop="roomTypeName" label="房型名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="bedType" label="床型" min-width="100" show-overflow-tooltip />
<el-table-column prop="capacity" label="可住" width="70" />
<el-table-column prop="roomNumber" label="房间数" width="80" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" v-hasPermi="['product:hotel-zax:update']" @click="openRoomDialog(row)">
编辑
</el-button>
<el-button link type="danger" v-hasPermi="['product:hotel-zax:update']" @click="handleRemoveRoom(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`产品(${products.length})`" name="product">
<div class="mb-8px">
<el-button
type="primary"
size="small"
v-hasPermi="['product:hotel-zax:update']"
@click="openProductDialog()"
>
新增产品
</el-button>
</div>
<el-table :data="products" border size="small" max-height="420">
<el-table-column prop="productCode" label="产品编码" min-width="120" show-overflow-tooltip />
<el-table-column prop="productName" label="产品名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="roomTypeCode" label="所属房型" min-width="110" show-overflow-tooltip />
<el-table-column prop="mealCount" label="早餐份数" width="90" />
<el-table-column prop="sellPrice" label="卖价(元)" width="100" />
<el-table-column prop="stock" label="库存" width="80" />
<el-table-column prop="priceCode" label="价格码" min-width="100" show-overflow-tooltip />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
v-hasPermi="['product:hotel-zax:update']"
@click="openBatchDailyDialog('FILL', row.productCode)"
>
生成日历
</el-button>
<el-button link type="primary" v-hasPermi="['product:hotel-zax:update']" @click="openProductDialog(row)">
编辑
</el-button>
<el-button
link
type="danger"
v-hasPermi="['product:hotel-zax:update']"
@click="handleRemoveProduct(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="日历" name="daily">
<div class="mb-8px flex flex-wrap gap-8px items-center">
<el-select
v-model="dailyProductCode"
clearable
placeholder="按产品筛选"
class="!w-220px"
@change="loadDaily"
>
<el-option
v-for="p in products"
:key="p.productCode"
:label="p.productName || p.productCode"
:value="p.productCode!"
/>
</el-select>
<el-button
type="primary"
size="small"
:loading="dailySyncing"
v-hasPermi="['product:hotel-zax:sync']"
@click="handleSyncDaily"
>
从资安心拉取
</el-button>
<el-button
type="primary"
size="small"
v-hasPermi="['product:hotel-zax:update']"
@click="openBatchDailyDialog('FILL')"
>
批量生成
</el-button>
<el-button
type="warning"
plain
size="small"
v-hasPermi="['product:hotel-zax:update']"
@click="openBatchDailyDialog('ADJUST')"
>
批量调价
</el-button>
<el-button size="small" v-hasPermi="['product:hotel-zax:update']" @click="openDailyDialog()">
单日编辑
</el-button>
</div>
<el-table v-loading="dailyLoading" :data="dailyList" border size="small" max-height="420">
<el-table-column label="日期" width="120">
<template #default="{ row }">{{ formatStayDate(row.stayDate) }}</template>
</el-table-column>
<el-table-column prop="productCode" label="产品编码" min-width="120" />
<el-table-column prop="roomTypeCode" label="房型" min-width="100" />
<el-table-column prop="costPrice" label="成本价(元)" width="100" />
<el-table-column prop="sellPrice" label="卖价(元)" width="100" />
<el-table-column prop="stock" label="配额" width="70" />
<el-table-column prop="sold" label="已售" width="70" />
<el-table-column label="可售" width="70">
<template #default="{ row }">
{{ row.remainStock != null ? row.remainStock : Math.max(0, (row.stock || 0) - (row.sold || 0)) }}
</template>
</el-table-column>
<el-table-column prop="dailyStatus" label="房态" width="80">
<template #default="{ row }">
<el-tag :type="row.dailyStatus === 1 ? 'success' : 'info'" size="small">
{{ row.dailyStatus === 1 ? '开房' : '关房' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" v-hasPermi="['product:hotel-zax:update']" @click="openDailyDialog(row)">
编辑
</el-button>
<el-button link type="danger" v-hasPermi="['product:hotel-zax:update']" @click="handleRemoveDaily(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-12px flex justify-end">
<el-pagination
v-model:current-page="dailyPage.pageNo"
v-model:page-size="dailyPage.pageSize"
:total="dailyPage.total"
layout="total, prev, pager, next"
@current-change="loadDaily"
/>
</div>
</el-tab-pane>
</el-tabs>
</div>
<el-dialog v-model="roomVisible" :title="roomForm.id ? '编辑房型' : '新增房型'" width="520px" destroy-on-close>
<el-form :model="roomForm" label-width="90px">
<el-form-item label="房型编码" required>
<el-input v-model="roomForm.roomTypeCode" :disabled="!!roomForm.id" maxlength="64" />
</el-form-item>
<el-form-item label="房型名称" required>
<el-input v-model="roomForm.roomTypeName" maxlength="128" />
</el-form-item>
<el-form-item label="床型">
<el-input v-model="roomForm.bedType" maxlength="64" />
</el-form-item>
<el-form-item label="可住人数">
<el-input-number v-model="roomForm.capacity" :min="1" :max="20" />
</el-form-item>
<el-form-item label="房间数">
<el-input-number v-model="roomForm.roomNumber" :min="0" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="roomVisible = false">取消</el-button>
<el-button type="primary" :loading="subSaving" @click="submitRoom">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="productVisible" :title="productForm.id ? '编辑产品' : '新增产品'" width="560px" destroy-on-close>
<el-form :model="productForm" label-width="90px">
<el-form-item label="产品编码" required>
<el-input v-model="productForm.productCode" :disabled="!!productForm.id" maxlength="64" />
</el-form-item>
<el-form-item label="产品名称" required>
<el-input v-model="productForm.productName" maxlength="128" />
</el-form-item>
<el-form-item label="所属房型" required>
<el-select v-model="productForm.roomTypeCode" class="!w-full" filterable>
<el-option
v-for="r in rooms"
:key="r.roomTypeCode"
:label="`${r.roomTypeName || r.roomTypeCode}(${r.roomTypeCode})`"
:value="r.roomTypeCode!"
/>
</el-select>
</el-form-item>
<el-form-item label="早餐份数">
<el-input-number v-model="productForm.mealCount" :min="0" :max="20" />
</el-form-item>
<el-form-item label="卖价(元)">
<el-input-number v-model="productForm.sellPrice" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="库存">
<el-input-number v-model="productForm.stock" :min="0" :max="99999" />
</el-form-item>
<el-form-item label="价格码">
<el-input v-model="productForm.priceCode" maxlength="64" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="productVisible = false">取消</el-button>
<el-button type="primary" :loading="subSaving" @click="submitProduct">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="dailyVisible" :title="dailyForm.id ? '编辑日历' : '新增日历'" width="520px" destroy-on-close>
<el-form :model="dailyForm" label-width="100px">
<el-form-item label="产品" required>
<el-select v-model="dailyForm.productCode" class="!w-full" @change="onDailyProductChange">
<el-option
v-for="p in products"
:key="p.productCode"
:label="p.productName || p.productCode"
:value="p.productCode!"
/>
</el-select>
</el-form-item>
<el-form-item label="日期" required>
<el-date-picker v-model="dailyForm.stayDate" type="date" value-format="YYYY-MM-DD" class="!w-full" />
</el-form-item>
<el-form-item label="成本价">
<el-input-number v-model="dailyForm.costPrice" :min="0" :precision="2" class="!w-full" />
</el-form-item>
<el-form-item label="卖价" required>
<el-input-number v-model="dailyForm.sellPrice" :min="0" :precision="2" class="!w-full" />
</el-form-item>
<el-form-item label="当日配额" required>
<el-input-number v-model="dailyForm.stock" :min="0" class="!w-full" />
<div class="text-12px text-gray-400 mt-4px">可售 = 配额 − 已售;已售由下单累加,不可手工改</div>
</el-form-item>
<el-form-item v-if="dailyForm.id" label="已售">
<span>{{ dailyForm.sold ?? 0 }}</span>
</el-form-item>
<el-form-item label="房态" required>
<el-select v-model="dailyForm.dailyStatus" class="!w-full">
<el-option label="开房" :value="1" />
<el-option label="关房" :value="0" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dailyVisible = false">取消</el-button>
<el-button type="primary" :loading="subSaving" @click="submitDaily">保存</el-button>
</template>
</el-dialog>
<el-dialog
v-model="batchVisible"
:title="batchForm.mode === 'ADJUST' ? '批量调价' : '批量生成日历'"
width="560px"
destroy-on-close
>
<el-alert
:closable="false"
show-icon
class="mb-12px"
:type="batchForm.mode === 'ADJUST' ? 'warning' : 'info'"
:title="
batchForm.mode === 'ADJUST'
? '对区间内已有日历改价/配额/房态,已售不动;未填的字段不改。'
: '按产品默认卖价/配额补齐缺失日期,已有日期自动跳过。单次最长 366 天。'
"
/>
<el-form :model="batchForm" label-width="110px">
<el-form-item label="日期区间" required>
<el-date-picker
v-model="batchRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始"
end-placeholder="结束"
class="!w-full"
/>
</el-form-item>
<el-form-item label="产品">
<el-select
v-model="batchForm.productCodes"
multiple
clearable
collapse-tags
collapse-tags-tooltip
placeholder="空=全部产品"
class="!w-full"
>
<el-option
v-for="p in products"
:key="p.productCode"
:label="p.productName || p.productCode"
:value="p.productCode!"
/>
</el-select>
</el-form-item>
<el-form-item :label="batchForm.mode === 'ADJUST' ? '卖价(可选)' : '卖价覆盖'">
<el-input-number v-model="batchForm.sellPrice" :min="0" :precision="2" class="!w-full" controls-position="right" />
</el-form-item>
<el-form-item :label="batchForm.mode === 'ADJUST' ? '成本价(可选)' : '成本覆盖'">
<el-input-number v-model="batchForm.costPrice" :min="0" :precision="2" class="!w-full" controls-position="right" />
</el-form-item>
<el-form-item :label="batchForm.mode === 'ADJUST' ? '配额(可选)' : '配额覆盖'">
<el-input-number v-model="batchForm.stock" :min="0" class="!w-full" controls-position="right" />
</el-form-item>
<el-form-item :label="batchForm.mode === 'ADJUST' ? '房态(可选)' : '房态'">
<el-select v-model="batchForm.dailyStatus" clearable placeholder="不改 / 默认开房" class="!w-full">
<el-option label="开房" :value="1" />
<el-option label="关房" :value="0" />
</el-select>
</el-form-item>
<el-form-item v-if="batchForm.mode === 'ADJUST'" label="同时补齐">
<el-switch v-model="batchForm.fillMissing" />
<span class="ml-8px text-12px text-gray-500">勾选后,区间内没有的日期也会按产品默认生成</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchVisible = false">取消</el-button>
<el-button type="primary" :loading="batchSaving" @click="submitBatchDaily">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import * as HotelZaxApi from '@/api/mall/product/hotelZax'
defineOptions({ name: 'HotelZaxInventory' })
const props = defineProps<{ hotelId: number }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
const message = useMessage()
const activeTab = ref('room')
const rooms = ref<HotelZaxApi.HotelZaxRoomVO[]>([])
const products = ref<HotelZaxApi.HotelZaxProductVO[]>([])
const dailyList = ref<HotelZaxApi.HotelZaxDailyVO[]>([])
const dailyLoading = ref(false)
const dailySyncing = ref(false)
const dailyProductCode = ref('')
const dailyPage = reactive({ pageNo: 1, pageSize: 10, total: 0 })
const subSaving = ref(false)
const batchSaving = ref(false)
/** LocalDate 被序列成 [y,m,d] 时表格会显示成 2026,11,2 */
const formatStayDate = (raw: unknown) => {
if (raw == null || raw === '') return '-'
if (Array.isArray(raw) && raw.length >= 3) {
const y = Number(raw[0])
const m = Number(raw[1])
const d = Number(raw[2])
if (y > 0 && m > 0 && d > 0) {
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}
}
const s = String(raw).trim()
const comma = s.match(/^(\d{4}),(\d{1,2}),(\d{1,2})/)
if (comma) {
return `${comma[1]}-${comma[2].padStart(2, '0')}-${comma[3].padStart(2, '0')}`
}
return s.length >= 10 ? s.slice(0, 10) : s
}
const load = async () => {
if (!props.hotelId) return
const data = await HotelZaxApi.getHotelZax(props.hotelId)
rooms.value = data.rooms || []
products.value = data.products || []
await loadDaily()
}
const handleSyncDaily = async () => {
dailySyncing.value = true
try {
const res = await HotelZaxApi.syncHotelZaxDaily(props.hotelId)
message.success(`已拉取日历 ${res?.upsert ?? 0} 条`)
dailyPage.pageNo = 1
await loadDaily()
emit('changed')
} finally {
dailySyncing.value = false
}
}
const loadDaily = async () => {
if (!props.hotelId) return
dailyLoading.value = true
try {
const data = await HotelZaxApi.getHotelZaxDailyPage({
pageNo: dailyPage.pageNo,
pageSize: dailyPage.pageSize,
hotelId: props.hotelId,
productCode: dailyProductCode.value || undefined
})
dailyList.value = data.list || []
dailyPage.total = data.total || 0
} finally {
dailyLoading.value = false
}
}
watch(
() => props.hotelId,
() => {
activeTab.value = 'room'
dailyProductCode.value = ''
dailyPage.pageNo = 1
load()
},
{ immediate: true }
)
const roomVisible = ref(false)
const roomForm = reactive<HotelZaxApi.HotelZaxRoomVO>({
id: undefined,
hotelId: undefined,
roomTypeCode: '',
roomTypeName: '',
bedType: '',
capacity: 2,
roomNumber: 1
})
const openRoomDialog = (row?: HotelZaxApi.HotelZaxRoomVO) => {
Object.assign(roomForm, {
id: row?.id,
hotelId: props.hotelId,
roomTypeCode: row?.roomTypeCode || '',
roomTypeName: row?.roomTypeName || '',
bedType: row?.bedType || '',
capacity: row?.capacity ?? 2,
roomNumber: row?.roomNumber ?? 1
})
roomVisible.value = true
}
const submitRoom = async () => {
if (!roomForm.roomTypeCode || !roomForm.roomTypeName) {
message.warning('请填写房型编码和名称')
return
}
subSaving.value = true
try {
await HotelZaxApi.saveHotelZaxRoom(roomForm)
message.success('保存成功')
roomVisible.value = false
await load()
emit('changed')
} finally {
subSaving.value = false
}
}
const handleRemoveRoom = async (row: HotelZaxApi.HotelZaxRoomVO) => {
if (!row.id) return
try {
await message.confirm(`确认删除房型「${row.roomTypeName || row.roomTypeCode}」?`)
} catch {
return
}
await HotelZaxApi.deleteHotelZaxRoom(props.hotelId, row.id)
message.success('已删除')
await load()
emit('changed')
}
const productVisible = ref(false)
const productForm = reactive<HotelZaxApi.HotelZaxProductVO>({
id: undefined,
hotelId: undefined,
productCode: '',
productName: '',
roomTypeCode: '',
mealCount: 0,
sellPrice: undefined,
stock: 0,
priceCode: ''
})
const openProductDialog = (row?: HotelZaxApi.HotelZaxProductVO) => {
Object.assign(productForm, {
id: row?.id,
hotelId: props.hotelId,
productCode: row?.productCode || '',
productName: row?.productName || '',
roomTypeCode: row?.roomTypeCode || rooms.value[0]?.roomTypeCode || '',
mealCount: row?.mealCount ?? 0,
sellPrice: row?.sellPrice,
stock: row?.stock ?? 0,
priceCode: row?.priceCode || ''
})
productVisible.value = true
}
const submitProduct = async () => {
if (!productForm.productCode || !productForm.productName || !productForm.roomTypeCode) {
message.warning('请填写产品编码、名称和所属房型')
return
}
subSaving.value = true
try {
await HotelZaxApi.saveHotelZaxProduct(productForm)
message.success('保存成功')
productVisible.value = false
await load()
emit('changed')
} finally {
subSaving.value = false
}
}
const handleRemoveProduct = async (row: HotelZaxApi.HotelZaxProductVO) => {
if (!row.id) return
try {
await message.confirm(`确认删除产品「${row.productName || row.productCode}」?其日历也会一并删除。`)
} catch {
return
}
await HotelZaxApi.deleteHotelZaxProduct(props.hotelId, row.id)
message.success('已删除')
await load()
emit('changed')
}
const dailyVisible = ref(false)
const dailyForm = reactive<HotelZaxApi.HotelZaxDailyVO>({
id: undefined,
hotelId: undefined,
productCode: '',
stayDate: '',
costPrice: 0,
sellPrice: undefined,
stock: 0,
sold: 0,
dailyStatus: 1
})
const onDailyProductChange = (code?: string) => {
const p = products.value.find((x) => x.productCode === code)
if (!p) return
if (dailyForm.sellPrice == null) dailyForm.sellPrice = p.sellPrice
if (dailyForm.stock == null || dailyForm.stock === 0) dailyForm.stock = p.stock ?? 0
}
const openDailyDialog = (row?: HotelZaxApi.HotelZaxDailyVO) => {
const first = products.value[0]
Object.assign(dailyForm, {
id: row?.id,
hotelId: props.hotelId,
productCode: row?.productCode || first?.productCode || '',
stayDate: row?.stayDate || '',
costPrice: row?.costPrice ?? 0,
sellPrice: row?.sellPrice ?? first?.sellPrice,
stock: row?.stock ?? first?.stock ?? 0,
sold: row?.sold ?? 0,
dailyStatus: row?.dailyStatus ?? 1
})
dailyVisible.value = true
}
const submitDaily = async () => {
if (!dailyForm.productCode || !dailyForm.stayDate || dailyForm.sellPrice == null || dailyForm.stock == null
|| dailyForm.dailyStatus == null) {
message.warning('请填写产品、日期、卖价、配额和房态')
return
}
subSaving.value = true
try {
await HotelZaxApi.saveHotelZaxDaily(dailyForm)
message.success('保存成功')
dailyVisible.value = false
await loadDaily()
emit('changed')
} finally {
subSaving.value = false
}
}
const handleRemoveDaily = async (row: HotelZaxApi.HotelZaxDailyVO) => {
if (!row.id) return
try {
await message.confirm(`确认删除 ${row.stayDate} / ${row.productCode} 的日历?`)
} catch {
return
}
await HotelZaxApi.deleteHotelZaxDaily(props.hotelId, row.id)
message.success('已删除')
await loadDaily()
emit('changed')
}
const batchVisible = ref(false)
const batchRange = ref<string[]>([])
const batchForm = reactive({
mode: 'FILL' as 'FILL' | 'ADJUST',
productCodes: [] as string[],
sellPrice: undefined as number | undefined,
costPrice: undefined as number | undefined,
stock: undefined as number | undefined,
dailyStatus: undefined as number | undefined,
fillMissing: false
})
const openBatchDailyDialog = (mode: 'FILL' | 'ADJUST', productCode?: string) => {
batchForm.mode = mode
batchForm.productCodes = productCode ? [productCode] : []
batchForm.sellPrice = undefined
batchForm.costPrice = undefined
batchForm.stock = undefined
batchForm.dailyStatus = mode === 'FILL' ? 1 : undefined
batchForm.fillMissing = false
const today = new Date()
const end = new Date()
end.setDate(today.getDate() + 29)
const pad = (n: number) => String(n).padStart(2, '0')
const fmt = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
batchRange.value = [fmt(today), fmt(end)]
batchVisible.value = true
}
const submitBatchDaily = async () => {
if (!batchRange.value || batchRange.value.length !== 2) {
message.warning('请选择日期区间')
return
}
batchSaving.value = true
try {
const res = await HotelZaxApi.batchHotelZaxDaily({
hotelId: props.hotelId,
mode: batchForm.mode,
startDate: batchRange.value[0],
endDate: batchRange.value[1],
productCodes: batchForm.productCodes.length ? batchForm.productCodes : undefined,
sellPrice: batchForm.sellPrice,
costPrice: batchForm.costPrice,
stock: batchForm.stock,
dailyStatus: batchForm.dailyStatus,
fillMissing: batchForm.fillMissing
})
message.success(`完成:新增 ${res?.inserted ?? 0},更新 ${res?.updated ?? 0}`)
batchVisible.value = false
activeTab.value = 'daily'
await loadDaily()
emit('changed')
} finally {
batchSaving.value = false
}
}
</script>
+295
View File
@@ -0,0 +1,295 @@
<template>
<ContentWrap>
<el-alert
class="mb-15px"
type="info"
:closable="false"
title="资安心酒店本地库:列表/详情读本地;可编辑展示信息、房型、产品与日历。同步可能覆盖房型/产品;已生成的本地日历优先于供应方价态。"
/>
<el-descriptions v-if="cursor" :column="4" border class="mb-15px" size="small">
<el-descriptions-item label="本地总数">{{ cursor.total ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="上架数">{{ cursor.active ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="同步水位">{{ cursor.updatedSince || '(全量起点)' }}</el-descriptions-item>
<el-descriptions-item label="上次同步">{{
cursor.lastSyncAt ? formatDate(new Date(cursor.lastSyncAt)) : '-'
}}</el-descriptions-item>
<el-descriptions-item label="上次结果" :span="4">{{ cursor.lastMsg || '-' }}</el-descriptions-item>
</el-descriptions>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="80px"
>
<el-form-item label="酒店名称" prop="hotelName">
<el-input
v-model="queryParams.hotelName"
placeholder="酒店名称"
clearable
class="!w-200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="酒店编码" prop="hotelCode">
<el-input
v-model="queryParams.hotelCode"
placeholder="hotelCode"
clearable
class="!w-180px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="上架状态" prop="status">
<el-select v-model="queryParams.status" clearable placeholder="全部" class="!w-140px">
<el-option label="上架" value="ACTIVE" />
<el-option label="本地下架" value="LOCAL_OFFLINE" />
<el-option label="供应方下架" value="OFFLINE" />
<el-option label="未授权" value="OUT_OF_SCOPE" />
</el-select>
</el-form-item>
<el-form-item label="区域" prop="district">
<el-input
v-model="queryParams.district"
placeholder="区域名"
clearable
class="!w-180px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button
type="primary"
:loading="syncing"
v-hasPermi="['product:hotel-zax:sync']"
@click="handleSync"
>
<Icon icon="ep:refresh" class="mr-5px" />同步资安心
</el-button>
<el-button plain v-hasPermi="['product:hotel-zax:sync']" @click="handleResetCursor">
重置水位
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table v-loading="loading" :data="list" border :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="封面" width="72" align="center">
<template #default="{ row }">
<el-image
v-if="row.coverImage"
:src="row.coverImage"
class="h-40px w-40px rounded-4px"
fit="cover"
:preview-src-list="[row.coverImage]"
preview-teleported
/>
<span v-else class="text-gray-400">无</span>
</template>
</el-table-column>
<el-table-column label="酒店编码" prop="hotelCode" min-width="120" />
<el-table-column label="酒店名称" prop="hotelName" min-width="180" />
<el-table-column label="位置区域" prop="district" min-width="140">
<template #default="{ row }">{{ row.district || '-' }}</template>
</el-table-column>
<el-table-column label="商圈" prop="bizArea" width="120">
<template #default="{ row }">{{ cleanText(row.bizArea) }}</template>
</el-table-column>
<el-table-column label="上架状态" prop="status" width="120" align="center">
<template #default="{ row }">
<el-switch
v-if="checkPermi(['product:hotel-zax:update'])"
:model-value="row.status === 'ACTIVE'"
inline-prompt
active-text="上架"
inactive-text="下架"
:disabled="listingId === row.id"
@change="(on: boolean) => handleToggleListing(row, on)"
/>
<el-tag v-else-if="row.status === 'ACTIVE'" type="success" size="small">上架</el-tag>
<el-tag v-else-if="row.status === 'OUT_OF_SCOPE'" type="warning" size="small">未授权</el-tag>
<el-tag v-else type="info" size="small">下架</el-tag>
</template>
</el-table-column>
<el-table-column label="房型数" prop="roomCount" width="80" align="center">
<template #default="{ row }">{{ row.roomCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="产品数" prop="productCount" width="80" align="center">
<template #default="{ row }">{{ row.productCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="起价" prop="minSellPrice" width="90" align="right">
<template #default="{ row }">
{{ row.minSellPrice != null ? `¥${row.minSellPrice}` : '-' }}
</template>
</el-table-column>
<el-table-column
label="同步时间"
prop="syncedAt"
width="170"
:formatter="dateFormatter"
/>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" v-hasPermi="['product:hotel-zax:query']" @click="openDetail(row)">
详情
</el-button>
<el-button link type="primary" v-hasPermi="['product:hotel-zax:update']" @click="openEdit(row)">
编辑
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<HotelZaxForm ref="formRef" @success="onChanged" />
<HotelZaxDetail ref="detailRef" @changed="onChanged" />
</template>
<script lang="ts" setup>
import { dateFormatter, formatDate } from '@/utils/formatTime'
import { checkPermi } from '@/utils/permission'
import * as HotelZaxApi from '@/api/mall/product/hotelZax'
import HotelZaxForm from './HotelZaxForm.vue'
import HotelZaxDetail from './HotelZaxDetail.vue'
defineOptions({ name: 'ProductHotelZax' })
const message = useMessage()
const loading = ref(false)
const syncing = ref(false)
const listingId = ref<number>()
const total = ref(0)
const list = ref<HotelZaxApi.HotelZaxVO[]>([])
const cursor = ref<any>(null)
const queryFormRef = ref()
const formRef = ref<InstanceType<typeof HotelZaxForm>>()
const detailRef = ref<InstanceType<typeof HotelZaxDetail>>()
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
hotelName: undefined as string | undefined,
hotelCode: undefined as string | undefined,
status: undefined as string | undefined,
district: undefined as string | undefined
})
const cleanText = (v?: string) => {
if (!v) return '-'
const s = v.trim()
if (s === '[]' || s === '{}' || s === '()') return '-'
return s
}
const loadCursor = async () => {
try {
cursor.value = await HotelZaxApi.getHotelZaxSyncCursor()
} catch {
cursor.value = null
}
}
const getList = async () => {
loading.value = true
try {
const data = await HotelZaxApi.getHotelZaxPage(queryParams)
list.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
}
const onChanged = async () => {
await loadCursor()
await getList()
}
const handleSync = async () => {
try {
await message.confirm(
'将按资安心当前授权范围全量同步到本地(改授权后也请用此按钮)。不在授权内的本地酒店会标为下架。确认继续?'
)
} catch {
return
}
syncing.value = true
try {
const res = await HotelZaxApi.syncHotelZax(true)
const offline = res?.offlineOutOfScope ?? 0
const dailyFail = res?.dailyFail ?? 0
message.success(
`同步完成:写入 ${res?.upsert ?? 0},详情 ${res?.detailOk ?? 0},日历 ${res?.dailyRows ?? 0} 条` +
(dailyFail > 0 ? `,日历失败 ${dailyFail} 家` : '') +
(offline > 0 ? `,下架授权外 ${offline}` : '')
)
await onChanged()
} catch (e: any) {
message.error(e?.msg || e?.message || '同步失败')
} finally {
syncing.value = false
}
}
const handleResetCursor = async () => {
try {
await message.confirm('重置水位后,下次同步会从最早变更重新拉(仍按授权范围)。确认?')
} catch {
return
}
await HotelZaxApi.resetHotelZaxCursor()
message.success('已重置')
await loadCursor()
}
const handleToggleListing = async (row: HotelZaxApi.HotelZaxVO, on: boolean) => {
if (!row.id) return
listingId.value = row.id
try {
// 后台下架用 LOCAL_OFFLINE,避免与供应方下架/授权外混淆;同步不会冲掉
await HotelZaxApi.updateHotelZaxStatus(row.id, on ? 'ACTIVE' : 'LOCAL_OFFLINE')
row.status = on ? 'ACTIVE' : 'LOCAL_OFFLINE'
message.success(on ? '已上架' : '已下架')
await loadCursor()
} catch (e: any) {
message.error(e?.msg || e?.message || '操作失败')
} finally {
listingId.value = undefined
}
}
const openDetail = (row: HotelZaxApi.HotelZaxVO) => {
if (!row.id) return
detailRef.value?.open(row.id)
}
const openEdit = (row: HotelZaxApi.HotelZaxVO) => {
if (!row.id) return
formRef.value?.open(row.id)
}
onMounted(async () => {
await loadCursor()
await getList()
})
</script>
@@ -0,0 +1,244 @@
<template>
<ContentWrap>
<el-alert
class="mb-15px"
type="info"
:closable="false"
title="小如酒店订单:用户先微信支付,支付成功后再向资安心预定。待支付不会出现在资安心后台。"
/>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="88px"
>
<el-form-item label="酒店名称" prop="hotelName">
<el-input
v-model="queryParams.hotelName"
placeholder="酒店名称"
clearable
class="!w-180px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="渠道单号" prop="channelNo">
<el-input
v-model="queryParams.channelNo"
placeholder="channelNo"
clearable
class="!w-180px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="供应单号" prop="orderNo">
<el-input
v-model="queryParams.orderNo"
placeholder="资安心单号"
clearable
class="!w-180px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="orderStatus">
<el-select v-model="queryParams.orderStatus" clearable placeholder="全部" class="!w-150px">
<el-option label="待支付" value="UNPAID" />
<el-option label="已支付待预定" value="PAID" />
<el-option label="已提交供应方" value="CREATED" />
<el-option label="预定失败" value="BOOK_FAIL" />
<el-option label="已取消" value="CANCELLED" />
</el-select>
</el-form-item>
<el-form-item label="支付" prop="payStatus">
<el-select v-model="queryParams.payStatus" clearable placeholder="全部" class="!w-120px">
<el-option label="已支付" :value="true" />
<el-option label="未支付" :value="false" />
</el-select>
</el-form-item>
<el-form-item label="手机号" prop="contactPhone">
<el-input
v-model="queryParams.contactPhone"
placeholder="联系人手机"
clearable
class="!w-150px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table v-loading="loading" :data="list" border :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="酒店" min-width="160">
<template #default="{ row }">
<div>{{ row.hotelName || '-' }}</div>
<div class="text-12px text-gray-400">{{ row.hotelCode }}</div>
</template>
</el-table-column>
<el-table-column label="产品" min-width="140">
<template #default="{ row }">{{ row.productName || row.productCode || '-' }}</template>
</el-table-column>
<el-table-column label="入住" width="200">
<template #default="{ row }">
{{ row.checkInDate || '-' }} → {{ row.checkOutDate || '-' }}
<span v-if="row.nights">({{ row.nights }}晚×{{ row.roomCount || 1 }}间)</span>
</template>
</el-table-column>
<el-table-column label="金额" prop="sellAmount" width="90" align="right">
<template #default="{ row }">
{{ row.sellAmount != null ? `¥${row.sellAmount}` : '-' }}
</template>
</el-table-column>
<el-table-column label="支付" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.payStatus ? 'success' : 'warning'" size="small">
{{ row.payStatus ? '已支付' : '未支付' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.orderStatus)" size="small">{{ statusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="渠道单号" prop="channelNo" min-width="160" />
<el-table-column label="供应单号" prop="orderNo" min-width="140">
<template #default="{ row }">{{ row.orderNo || '-' }}</template>
</el-table-column>
<el-table-column label="联系人" width="140">
<template #default="{ row }">{{ row.contactName || '-' }} {{ row.contactPhone || '' }}</template>
</el-table-column>
<el-table-column label="失败原因" prop="failMsg" min-width="160">
<template #default="{ row }">{{ row.failMsg || '-' }}</template>
</el-table-column>
<el-table-column
label="下单时间"
prop="createTime"
width="170"
:formatter="dateFormatter"
/>
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="canCancel(row)"
link
type="danger"
:loading="cancellingId === row.id"
@click="handleCancel(row)"
>
取消
</el-button>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>
<script lang="ts" setup>
import { dateFormatter } from '@/utils/formatTime'
import * as HotelZaxApi from '@/api/mall/product/hotelZax'
defineOptions({ name: 'ProductHotelZaxOrder' })
const message = useMessage()
const loading = ref(false)
const cancellingId = ref<number>()
const total = ref(0)
const list = ref<HotelZaxApi.HotelZaxOrderVO[]>([])
const queryFormRef = ref()
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
hotelName: undefined as string | undefined,
channelNo: undefined as string | undefined,
orderNo: undefined as string | undefined,
orderStatus: undefined as string | undefined,
payStatus: undefined as boolean | undefined,
contactPhone: undefined as string | undefined
})
const statusText = (row: HotelZaxApi.HotelZaxOrderVO) => {
const s = row.orderStatus || ''
if (s === 'UNPAID') return '待支付'
if (s === 'PAID') return '已支付待预定'
if (s === 'CREATED' || s === 'UPSTREAM_OK') return '已提交供应方'
if (s === 'BOOK_FAIL' || s === 'UPSTREAM_FAIL') return '预定失败'
if (s === 'CANCELLED' || s === 'CANCELED') return '已取消'
return s || '-'
}
const statusType = (s?: string) => {
if (s === 'UNPAID') return 'warning'
if (s === 'PAID') return 'info'
if (s === 'CREATED' || s === 'UPSTREAM_OK') return 'success'
if (s === 'BOOK_FAIL' || s === 'UPSTREAM_FAIL') return 'danger'
if (s === 'CANCELLED' || s === 'CANCELED') return 'info'
return ''
}
const canCancel = (row: HotelZaxApi.HotelZaxOrderVO) => {
const s = row.orderStatus || ''
return s !== 'CANCELLED' && s !== 'CANCELED' && s !== 'COMPLETED'
}
const handleCancel = async (row: HotelZaxApi.HotelZaxOrderVO) => {
if (!row.id) return
const paid = !!row.payStatus
try {
await message.confirm(
paid
? `确认取消订单 ${row.channelNo || row.id}?将原路退款给用户,并通知资安心取消预订。`
: `确认取消未支付订单 ${row.channelNo || row.id}?`
)
} catch {
return
}
cancellingId.value = row.id
try {
await HotelZaxApi.cancelHotelZaxOrder(row.id, paid ? '后台取消退款' : '后台取消')
message.success(paid ? '已取消并退款' : '已取消')
await getList()
} catch (e: any) {
message.error(e?.msg || e?.message || '取消失败')
} finally {
cancellingId.value = undefined
}
}
const getList = async () => {
loading.value = true
try {
const data = await HotelZaxApi.getHotelZaxOrderPage(queryParams)
list.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
}
onMounted(() => {
getList()
})
</script>