酒店 食堂

This commit is contained in:
zjf committed 2026-10-20 11:41:42 +08:00
1 parent 2447995bb8
commit 685c8ce589
9 files changed
+706 -30

No files matched your search

@@ -0,0 +1,47 @@
import request from "/@/utils/request"
export function fetchList(query?: Object) {
return request({
url: "/supermarket/smHotelDownstreamApp/page",
method: "get",
params: query,
})
}
export function getObj(id: string | number) {
return request({
url: `/supermarket/smHotelDownstreamApp/${id}`,
method: "get",
})
}
export function addObj(obj?: Object) {
return request({
url: "/supermarket/smHotelDownstreamApp",
method: "post",
data: obj,
})
}
export function putObj(obj?: Object) {
return request({
url: "/supermarket/smHotelDownstreamApp",
method: "put",
data: obj,
})
}
export function delObjs(ids?: Object) {
return request({
url: "/supermarket/smHotelDownstreamApp",
method: "delete",
data: ids,
})
}
export function rotateSecret(id: string | number) {
return request({
url: `/supermarket/smHotelDownstreamApp/rotateSecret/${id}`,
method: "post",
})
}
+8
View File
@@ -49,3 +49,11 @@ export function compensateOrder(id: string | number) {
method: "post",
})
}
/** 订单来源筛选项(渠道下游 + 开放应用,各自显示名) */
export function listOrderSourceOptions() {
return request({
url: "/supermarket/smHotelOrder/orderSourceOptions",
method: "get",
})
}
+59 -5
View File
@@ -174,6 +174,8 @@ const emit = defineEmits(["refresh"])
const LS_EXPAND = "canteen_daily_menu_expand_combo"
const LS_AUTO_SUM = "canteen_daily_menu_auto_sum_combo"
/** 选择菜品弹窗:商品分类按商户记忆;无记忆取 sort 最大(排序最靠前) */
const LS_SELECT_CATEGORY = "canteen_daily_menu_select_category"
const imageBaseUrl = import.meta.env.VITE_IMAGE_BASE_PATH
const { spu_status } = useDict("spu_status")
@@ -187,6 +189,8 @@ const loading = ref(false)
const categoryOptions = ref<any>([])
const summingId = ref<string | number | null>(null)
const summingAll = ref(false)
/** 打开选择弹窗时跳过一次分类记忆写入,避免 reset 误清本地偏好 */
const skipRememberSelectCategory = ref(false)
/** 勾选偏好本地记住 */
const expandComboItems = ref(localStorage.getItem(LS_EXPAND) !== "0")
@@ -194,6 +198,27 @@ const autoSumComboPrice = ref(localStorage.getItem(LS_AUTO_SUM) === "1")
watch(expandComboItems, (v) => localStorage.setItem(LS_EXPAND, v ? "1" : "0"))
watch(autoSumComboPrice, (v) => localStorage.setItem(LS_AUTO_SUM, v ? "1" : "0"))
const selectCategoryKey = (merchantId: string | number | "") =>
`${LS_SELECT_CATEGORY}:${merchantId || "_"}`
const pickPreferredCategoryId = (list: any[], saved?: string | number | null) => {
if (!list?.length) return ""
if (saved != null && saved !== "" && list.some((c) => String(c.id) === String(saved))) {
return list.find((c) => String(c.id) === String(saved))?.id ?? ""
}
return list.reduce((best, cur) => {
const bs = Number(best?.sort) || 0
const cs = Number(cur?.sort) || 0
return cs > bs ? cur : best
}).id
}
const resolveSelectCategory = (merchantId: string | number | "", list: any[]) => {
if (!merchantId || !list?.length) return ""
const saved = localStorage.getItem(selectCategoryKey(merchantId))
return pickPreferredCategoryId(list, saved)
}
/** comboSpuId -> 组成项(含单价) */
const comboChildrenMap = ref<Record<string, any[]>>({})
@@ -215,11 +240,23 @@ const state: BasicTableProps = reactive<BasicTableProps>({
merchantId: "",
startTime: "",
hourType: "",
categoryId: "" as string | number | "",
name: "",
},
pageList: getCanteenDailyMenuList,
dataList: [],
})
watch(
() => state.queryForm.categoryId,
(cid) => {
if (skipRememberSelectCategory.value) return
const mid = state.queryForm.merchantId || form.merchantId
if (!mid || cid == null || cid === "") return
localStorage.setItem(selectCategoryKey(mid), String(cid))
},
)
const openDialog = (merchantId: string, time?: string, hourType?: number) => {
visible.value = true
selectedFood.value = []
@@ -252,6 +289,7 @@ const handleClickSelect = async () => {
useMessage().warning("请先选择餐段")
return
}
skipRememberSelectCategory.value = true
queryRef.value?.resetFields()
foodMultipleTableRef.value?.clearSelection()
selectDialogVisible.value = true
@@ -259,22 +297,38 @@ const handleClickSelect = async () => {
state.queryForm.merchantId = form.merchantId
state.queryForm.startTime = form.startTime
state.queryForm.hourType = form.hourTypes.length === 1 ? form.hourTypes[0] : ""
state.queryForm.name = ""
getDataList()
try {
const res = await getCanteenCategory()
const res = await getCanteenCategory(
form.merchantId ? { merchantId: form.merchantId } : undefined,
)
if (res.code === 0) {
categoryOptions.value = res.data
categoryOptions.value = res.data || []
} else {
categoryOptions.value = []
}
} catch {
categoryOptions.value = []
}
state.queryForm.categoryId = resolveSelectCategory(form.merchantId, categoryOptions.value)
await nextTick()
skipRememberSelectCategory.value = false
getDataList()
}
const { getDataList, currentChangeHandle, sizeChangeHandle } = useTable(state)
const resetQuery = () => {
queryRef.value?.resetFields()
const resetQuery = async () => {
skipRememberSelectCategory.value = true
state.queryForm.name = ""
state.queryForm.categoryId = resolveSelectCategory(
form.merchantId || state.queryForm.merchantId,
categoryOptions.value,
)
await nextTick()
skipRememberSelectCategory.value = false
getDataList()
}
+14 -1
View File
@@ -305,7 +305,20 @@
{{ row.offlineSold != null ? row.offlineSold : 0 }}
</template>
</el-table-column>
<el-table-column label="剩余总库存" prop="remainStock" width="100" align="center" sortable />
<el-table-column label="剩余总库存" prop="remainStock" width="128" align="center" sortable>
<template #default="{ row }">
<div class="inline-flex items-center justify-center gap-1">
<span>{{ row.remainStock != null ? row.remainStock : "-" }}</span>
<el-tooltip
v-if="row.remainLimitedByCombo"
:content="row.remainStockHint || '受组成菜可拼份数限制'"
placement="top"
>
<el-tag type="warning" size="small" effect="plain" class="cursor-help">组成菜</el-tag>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column label="价格" width="96" align="right">
<template #default="{ row }">
<span class="text-red-600">{{ displayPrice(row.price) }}</span>
@@ -59,7 +59,25 @@
</div>
<div class="meta-item">
<span class="meta-label">剩余总库存</span>
<span class="meta-value">{{ detail.remainStock ?? "-" }}</span>
<span class="meta-value">
{{ detail.remainStock ?? "-" }}
<el-tag
v-if="detail.remainLimitedByCombo"
type="warning"
size="small"
effect="plain"
class="ml-1"
>组成菜限制</el-tag>
</span>
</div>
<div v-if="detail.remainLimitedByCombo" class="meta-item meta-item--full">
<span class="meta-label">剩余说明</span>
<span class="meta-value meta-value--hint">
{{ detail.remainStockHint || "受同日同餐段组成菜可拼份数限制" }}
<template v-if="detail.selfRemainStock != null || detail.comboFormableRemain != null">
(本行 {{ detail.selfRemainStock ?? "-" }} / 组成可拼 {{ detail.comboFormableRemain ?? "-" }})
</template>
</span>
</div>
</div>
@@ -369,6 +387,10 @@ defineExpose({ openDialog })
min-width: 0;
}
.meta-item--full {
grid-column: 1 / -1;
}
.meta-label {
flex-shrink: 0;
width: 4.5em;
@@ -383,6 +405,11 @@ defineExpose({ openDialog })
word-break: break-word;
}
.meta-value--hint {
color: var(--el-color-warning-dark-2);
line-height: 1.5;
}
.dish-detail__section {
display: flex;
flex-direction: column;
+118 -12
View File
@@ -53,7 +53,20 @@
<el-col :span="14" class="mb20">
<el-form-item label="菜品价格" prop="price">
<el-input-number v-model="form.price" :min="0" :precision="2" placeholder="请输入菜品价格" />
<div class="flex items-center gap-2 flex-wrap">
<el-input-number v-model="form.price" :min="0" :precision="2" placeholder="请输入菜品价格" />
<el-button
v-if="form.spuType === 2 && form.comboItems.length"
link
type="primary"
@click="applyComboPriceSum"
>
按组成合计
</el-button>
</div>
<div v-if="form.spuType === 2 && form.comboItems.length" class="field-hint" style="margin-left: 0; margin-top: 4px">
组成合计 {{ formatYuan(comboSumFen) }},加入/改份数时自动填入,可再改
</div>
</el-form-item>
</el-col>
@@ -98,7 +111,13 @@
</el-table-column>
<el-table-column label="份数" width="140">
<template #default="{ row }">
<el-input-number v-model="row.quantity" :min="1" :max="99" controls-position="right" />
<el-input-number
v-model="row.quantity"
:min="1"
:max="99"
controls-position="right"
@change="applyComboPriceSum"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
@@ -208,6 +227,11 @@ import Pagination from "/@/components/Pagination/index.vue"
const emit = defineEmits(["refresh"])
/** 组成菜筛选分类:按商户记忆;无记忆时取 sort 最大 */
const LS_CHILD_CATEGORY = "canteen_spu_combo_child_category"
/** 新增菜品/套餐时的菜品分类记忆 */
const LS_FORM_CATEGORY = "canteen_spu_form_category"
const formatYuan = (price: any) => {
if (price === null || price === undefined || price === "") return "—"
const n = Number(price)
@@ -215,6 +239,40 @@ const formatYuan = (price: any) => {
return "¥" + (n / 100).toFixed(2)
}
const categoryStorageKey = (prefix: string, merchantId: string | number | "") =>
`${prefix}:${merchantId || "_"}`
const pickPreferredCategoryId = (list: any[], saved?: string | number | null) => {
if (!list?.length) return ""
if (saved != null && saved !== "" && list.some((c) => String(c.id) === String(saved))) {
return list.find((c) => String(c.id) === String(saved))?.id ?? ""
}
return list.reduce((best, cur) => {
const bs = Number(best?.sort) || 0
const cs = Number(cur?.sort) || 0
return cs > bs ? cur : best
}).id
}
const loadCategoryList = async (merchantId?: string | number | "") => {
const res: any = await getAllSimple(
merchantId != null && merchantId !== "" ? { merchantId } : undefined,
)
return (res?.data || []) as any[]
}
const resolveRememberedCategory = async (prefix: string, merchantId: string | number | "") => {
if (!merchantId) return ""
const saved = localStorage.getItem(categoryStorageKey(prefix, merchantId))
const list = await loadCategoryList(merchantId)
return pickPreferredCategoryId(list, saved)
}
const rememberCategory = (prefix: string, merchantId: string | number | "", categoryId: string | number | "") => {
if (!merchantId || categoryId == null || categoryId === "") return
localStorage.setItem(categoryStorageKey(prefix, merchantId), String(categoryId))
}
type ComboLine = {
childSpuId: string | number
childSpuName?: string
@@ -294,43 +352,78 @@ const dataRules = ref({
],
})
/** 组成单价×份数合计(分) */
const comboSumFen = computed(() =>
form.comboItems.reduce((sum, item) => {
const price = Number(item.childSpuPrice) || 0
const qty = Number(item.quantity) > 0 ? Number(item.quantity) : 1
return sum + price * qty
}, 0),
)
/** 套餐:按组成自动填入菜品价格(元),仍可手改 */
const applyComboPriceSum = () => {
if (form.spuType !== 2 || !form.comboItems.length) return
form.price = Number((comboSumFen.value / 100).toFixed(2))
}
const onSpuTypeChange = () => {
if (form.spuType !== 2) {
form.comboItems = []
} else if (form.comboItems.length) {
applyComboPriceSum()
}
}
watch(
() => form.merchantId,
(mid, oldMid) => {
async (mid, oldMid) => {
if (mid === oldMid) return
childQuery.merchantId = mid || ""
childQuery.categoryId = ""
// 仅用户手动换商户时清空分类;编辑回填时保留 categoryId
// 仅用户手动换商户时重填分类;编辑回填时保留 categoryId
if (!isHydrating.value) {
form.categoryId = ""
form.categoryId = mid ? await resolveRememberedCategory(LS_FORM_CATEGORY, mid) : ""
childQuery.categoryId = mid ? await resolveRememberedCategory(LS_CHILD_CATEGORY, mid) : ""
}
},
)
watch(
() => form.categoryId,
(cid) => {
if (isHydrating.value || !form.merchantId || cid == null || cid === "") return
rememberCategory(LS_FORM_CATEGORY, form.merchantId, cid)
},
)
watch(
() => childQuery.merchantId,
(mid, oldMid) => {
async (mid, oldMid) => {
if (!childPickerVisible.value) return
if (mid === oldMid) return
childQuery.categoryId = ""
childQuery.categoryId = mid ? await resolveRememberedCategory(LS_CHILD_CATEGORY, mid) : ""
childQuery.current = 1
searchChildren()
},
)
const openChildPicker = () => {
watch(
() => childQuery.categoryId,
(cid) => {
if (!childPickerVisible.value) return
const mid = childQuery.merchantId
if (!mid || cid == null || cid === "") return
rememberCategory(LS_CHILD_CATEGORY, mid, cid)
},
)
const openChildPicker = async () => {
if (!form.merchantId) {
useMessage().warning("请先选择商户")
return
}
childQuery.merchantId = form.merchantId
childQuery.categoryId = ""
childQuery.categoryId = await resolveRememberedCategory(LS_CHILD_CATEGORY, form.merchantId)
childQuery.current = 1
childPickerVisible.value = true
searchChildren()
@@ -394,10 +487,13 @@ onBeforeUnmount(() => {
unbindChildPickerEnter()
})
const resetChildQuery = () => {
const resetChildQuery = async () => {
childQuery.name = ""
childQuery.categoryId = ""
childQuery.merchantId = form.merchantId || ""
// 重置名称,分类仍回到记忆/默认(不要求反复选)
childQuery.categoryId = childQuery.merchantId
? await resolveRememberedCategory(LS_CHILD_CATEGORY, childQuery.merchantId)
: ""
childQuery.current = 1
searchChildren()
}
@@ -443,11 +539,15 @@ const confirmChildren = () => {
})
childPickerVisible.value = false
dataFormRef.value?.validateField("comboItems")
applyComboPriceSum()
}
const removeComboItem = (index: number) => {
form.comboItems.splice(index, 1)
dataFormRef.value?.validateField("comboItems")
if (form.comboItems.length) {
applyComboPriceSum()
}
}
const openDialog = async (id: string) => {
@@ -462,6 +562,12 @@ const openDialog = async (id: string) => {
if (id) {
form.id = id
getcanteenSpuData(id)
return
}
// 新增:商户就绪后填入记忆/默认分类(sort 最大)
await nextTick()
if (form.merchantId) {
form.categoryId = await resolveRememberedCategory(LS_FORM_CATEGORY, form.merchantId)
}
}
+52
View File
@@ -36,6 +36,9 @@
@change="onQueryDistrictChange"
/>
</el-form-item>
<el-form-item v-if="isRoleAdmin" label="归属部门" prop="deptId">
<select-dept v-model="state.queryForm.deptId" style="width: 220px" placeholder="按部门筛选" />
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
@@ -154,6 +157,11 @@
<dict-tag :options="sm_hotel_data_source" :value="scope.row.sheetName" />
</template>
</el-table-column>
<el-table-column prop="deptId" label="归属部门" min-width="140" show-overflow-tooltip>
<template #default="scope">
{{ deptNameOf(scope.row.deptId) }}
</template>
</el-table-column>
<el-table-column label="操作" width="620" fixed="right">
<template #default="scope">
<el-button
@@ -483,8 +491,10 @@ import {
} from "/@/api/supermarket/smHotelMid"
import { listEnabledChannelDept } from "/@/api/supermarket/smHotelChannelDept"
import { getObj as getAreaObj } from "/@/api/admin/sysArea"
import { deptTree } from "/@/api/admin/dept"
import { useDict } from "/@/hooks/dict"
import { useMessage, useMessageBox } from "/@/hooks/message"
import { useUserInfo } from "/@/stores/userInfo"
import { useRouter } from "vue-router"
const router = useRouter()
@@ -494,6 +504,32 @@ const DetailDrawer = defineAsyncComponent(() => import("./detail.vue"))
const ChinaArea = defineAsyncComponent(() => import("/@/components/ChinaArea/index.vue"))
const { sm_hotel_data_source } = useDict("sm_hotel_data_source")
/** 归属部门筛选仅平台管理员可用(渠道映射核查) */
const isRoleAdmin = computed(() => !!useUserInfo().userInfos?.roleCodes?.includes?.("ROLE_ADMIN"))
/** 部门 id → 名称(列表「归属部门」展示) */
const deptNameMap = ref<Record<string, string>>({})
const flattenDeptTree = (nodes: any[], map: Record<string, string>) => {
for (const n of nodes || []) {
if (n?.id != null) map[String(n.id)] = n.name || String(n.id)
if (n?.children?.length) flattenDeptTree(n.children, map)
}
}
const loadDeptNameMap = async () => {
try {
const res: any = await deptTree()
const map: Record<string, string> = {}
flattenDeptTree(res?.data || [], map)
deptNameMap.value = map
} catch {
deptNameMap.value = {}
}
}
const deptNameOf = (deptId?: string | number | null) => {
if (deptId == null || deptId === "") return "-"
return deptNameMap.value[String(deptId)] || String(deptId)
}
/** 查询区:位置区域级联 */
const districtPath = ref("")
const districtCascaderKey = ref(0)
@@ -1077,6 +1113,7 @@ const defaultQuery = () => ({
sheetName: "",
districtAdcode: undefined as number | undefined,
district: "",
deptId: undefined as string | number | undefined,
})
const state: BasicTableProps = reactive<BasicTableProps>({
@@ -1086,6 +1123,17 @@ const state: BasicTableProps = reactive<BasicTableProps>({
const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state)
/** 非管理员不带部门条件,避免手工拼参绕过筛选项 */
watch(
isRoleAdmin,
(admin) => {
if (!admin && state.queryForm) {
state.queryForm.deptId = undefined
}
},
{ immediate: true },
)
const resetQuery = () => {
queryRef.value?.resetFields()
state.queryForm = defaultQuery()
@@ -1416,6 +1464,10 @@ const submitImport = async () => {
}
}
onMounted(() => {
loadDeptNameMap()
})
onBeforeUnmount(() => {
stopPoll()
stopSyncPoll()
@@ -0,0 +1,309 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-alert
type="info"
:closable="false"
show-icon
class="mb-3"
title="统一开放平台:新建应用后把 appKey/secret 发给下游;对方按我方开放 API 对接。环启/慧订仍走「渠道对接配置」特例协议。限流 QPS 按应用独立生效。"
/>
<el-row v-show="showSearch">
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="名称" prop="appName">
<el-input v-model="state.queryForm.appName" clearable style="width: 140px" />
</el-form-item>
<el-form-item label="appKey" prop="appKey">
<el-input v-model="state.queryForm.appKey" clearable style="width: 160px" />
</el-form-item>
<el-form-item label="伙伴编码" prop="partnerCode">
<el-input v-model="state.queryForm.partnerCode" clearable style="width: 140px" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="state.queryForm.status" clearable placeholder="全部" style="width: 110px">
<el-option label="启用" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row class="mb8">
<div style="width: 100%">
<el-button type="primary" icon="Plus" v-auth="'supermarket_smHotelDownstreamApp_edit'" @click="openForm()">
新建应用
</el-button>
<el-button
type="danger"
plain
icon="Delete"
:disabled="!selectedIds.length"
v-auth="'supermarket_smHotelDownstreamApp_edit'"
@click="handleDelete()"
>
删除
</el-button>
<right-toolbar v-model:showSearch="showSearch" class="ml10 mr20" style="float: right" @queryTable="getDataList" />
</div>
</el-row>
<el-table
:data="state.dataList"
v-loading="state.loading"
border
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="40" />
<el-table-column prop="appName" label="应用名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="partnerCode" label="订单来源编码" min-width="120" show-overflow-tooltip />
<el-table-column prop="appKey" label="appKey" min-width="180" show-overflow-tooltip />
<el-table-column prop="appSecretMasked" label="secret" width="120" />
<el-table-column prop="callbackEnabled" label="回调" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.callbackEnabled === '1' ? 'success' : 'info'" size="small">
{{ row.callbackEnabled === "1" ? "开" : "关" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="callbackBaseUrl" label="回调根地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '1' ? 'success' : 'info'" size="small">
{{ row.status === "1" ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" min-width="160" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button text type="primary" v-auth="'supermarket_smHotelDownstreamApp_edit'" @click="openForm(row)">
编辑
</el-button>
<el-button text type="warning" v-auth="'supermarket_smHotelDownstreamApp_edit'" @click="handleRotate(row)">
轮换密钥
</el-button>
<el-button text type="danger" v-auth="'supermarket_smHotelDownstreamApp_edit'" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<el-dialog v-model="formVisible" :title="form.id ? '编辑开放应用' : '新建开放应用'" width="720px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="应用名称" prop="appName">
<el-input v-model="form.appName" maxlength="64" show-word-limit />
</el-form-item>
<el-form-item label="订单来源编码" prop="partnerCode">
<el-input v-model="form.partnerCode" placeholder="空则用 appKey;订单列表按此筛选" maxlength="64" />
</el-form-item>
<el-form-item v-if="!form.id" label="appKey" prop="appKey">
<el-input v-model="form.appKey" placeholder="可空,系统自动生成" maxlength="64" />
</el-form-item>
<el-form-item v-else label="appKey">
<el-input :model-value="form.appKey" disabled />
</el-form-item>
<el-form-item label="启用" prop="status">
<el-switch v-model="form.status" active-value="1" inactive-value="0" />
</el-form-item>
<el-divider content-position="left">回调(对方提供)</el-divider>
<el-form-item label="启用回调">
<el-switch v-model="form.callbackEnabled" active-value="1" inactive-value="0" />
</el-form-item>
<el-form-item label="回调根地址">
<el-input v-model="form.callbackBaseUrl" placeholder="https://partner.example.com" />
</el-form-item>
<el-form-item label="订单状态路径">
<el-input v-model="form.callbackOrderPath" placeholder="/order/status/notify" />
</el-form-item>
<el-form-item label="资源变更路径">
<el-input v-model="form.callbackResourcePath" placeholder="可选" />
</el-form-item>
<el-divider content-position="left">限流 QPS(JSON,按接口独立)</el-divider>
<el-form-item label="rateLimitJson">
<el-input
v-model="form.rateLimitJson"
type="textarea"
:rows="5"
placeholder='{"default":20,"hotel.list":10,"order.create":5}'
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="secretVisible" title="请立即复制密钥" width="520px">
<el-alert type="warning" :closable="false" show-icon class="mb-3" title="密钥仅展示一次,关闭后列表只显示脱敏值。" />
<el-descriptions :column="1" border>
<el-descriptions-item label="appKey">{{ secretInfo.appKey }}</el-descriptions-item>
<el-descriptions-item label="secret">{{ secretInfo.appSecret }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="copySecret">复制</el-button>
<el-button @click="secretVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup name="smHotelDownstreamApp">
import { BasicTableProps, useTable } from "/@/hooks/table"
import { useMessage, useMessageBox } from "/@/hooks/message"
import { addObj, delObjs, fetchList, putObj, rotateSecret } from "/@/api/supermarket/smHotelDownstreamApp"
const showSearch = ref(true)
const queryRef = ref()
const formRef = ref()
const selectedIds = ref<any[]>([])
const formVisible = ref(false)
const saving = ref(false)
const secretVisible = ref(false)
const secretInfo = reactive({ appKey: "", appSecret: "" })
const DEFAULT_RATE =
'{"default":20,"hotel.list":10,"hotel.detail":20,"hotel.daily":20,"order.check":10,"order.create":5,"order.cancel":5,"order.query":20}'
const form = reactive<any>({
id: undefined,
appName: "",
partnerCode: "",
appKey: "",
status: "1",
callbackEnabled: "0",
callbackBaseUrl: "",
callbackOrderPath: "/order/status/notify",
callbackResourcePath: "",
rateLimitJson: DEFAULT_RATE,
remark: "",
})
const rules = {
appName: [{ required: true, message: "请输入应用名称", trigger: "blur" }],
}
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
pageList: fetchList,
})
const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state)
const onSelectionChange = (rows: any[]) => {
selectedIds.value = (rows || []).map((r) => r.id)
}
const resetQuery = () => {
queryRef.value?.resetFields()
state.queryForm = {}
getDataList()
}
const openForm = (row?: any) => {
Object.assign(form, {
id: undefined,
appName: "",
partnerCode: "",
appKey: "",
status: "1",
callbackEnabled: "0",
callbackBaseUrl: "",
callbackOrderPath: "/order/status/notify",
callbackResourcePath: "",
rateLimitJson: DEFAULT_RATE,
remark: "",
})
if (row?.id) {
Object.assign(form, {
id: row.id,
appName: row.appName,
partnerCode: row.partnerCode,
appKey: row.appKey,
status: row.status || "1",
callbackEnabled: row.callbackEnabled || "0",
callbackBaseUrl: row.callbackBaseUrl || "",
callbackOrderPath: row.callbackOrderPath || "/order/status/notify",
callbackResourcePath: row.callbackResourcePath || "",
rateLimitJson: row.rateLimitJson || DEFAULT_RATE,
remark: row.remark || "",
})
}
formVisible.value = true
}
const submitForm = async () => {
await formRef.value?.validate()
saving.value = true
try {
if (form.id) {
await putObj({ ...form })
useMessage().success("已保存")
} else {
const res: any = await addObj({ ...form })
const data = res?.data || {}
useMessage().success("已创建")
if (data.appSecret) {
secretInfo.appKey = data.appKey
secretInfo.appSecret = data.appSecret
secretVisible.value = true
}
}
formVisible.value = false
getDataList(false)
} catch (err: any) {
useMessage().error(err?.msg || err?.message || "保存失败")
} finally {
saving.value = false
}
}
const handleDelete = async (row?: any) => {
const ids = row?.id ? [row.id] : selectedIds.value
if (!ids.length) return
try {
await useMessageBox().confirm(`确认删除 ${ids.length} 个开放应用?`)
await delObjs(ids)
useMessage().success("已删除")
getDataList(false)
} catch (err: any) {
if (err === "cancel") return
useMessage().error(err?.msg || err?.message || "删除失败")
}
}
const handleRotate = async (row: any) => {
try {
await useMessageBox().confirm(`确认轮换 ${row.appName} 的密钥?旧密钥将立即失效。`)
const res: any = await rotateSecret(row.id)
const data = res?.data || {}
secretInfo.appKey = data.appKey || row.appKey
secretInfo.appSecret = data.appSecret
secretVisible.value = true
getDataList(false)
} catch (err: any) {
if (err === "cancel") return
useMessage().error(err?.msg || err?.message || "轮换失败")
}
}
const copySecret = async () => {
const text = `appKey=${secretInfo.appKey}\nsecret=${secretInfo.appSecret}`
try {
await navigator.clipboard.writeText(text)
useMessage().success("已复制")
} catch {
useMessage().warning("复制失败,请手动选择")
}
}
</script>
+71 -11
View File
@@ -13,6 +13,22 @@
<el-button size="small" @click="quickFilter('')">全部</el-button>
</el-button-group>
</el-form-item>
<el-form-item label="订单来源" prop="partnerCode">
<el-select
v-model="state.queryForm.partnerCode"
clearable
filterable
placeholder="全部来源"
style="width: 180px"
>
<el-option
v-for="item in orderSourceOptions"
:key="item.partnerCode"
:label="item.label || item.partnerCode"
:value="item.partnerCode"
/>
</el-select>
</el-form-item>
<el-form-item label="酒店名称/编码" prop="hotelCode">
<el-input v-model="state.queryForm.hotelCode" clearable placeholder="酒店编码" />
</el-form-item>
@@ -63,17 +79,22 @@
:header-cell-style="tableStyle.headerCellStyle"
>
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="orderSource" label="来源" width="80">
<el-table-column prop="orderSource" label="订单来源" min-width="140" show-overflow-tooltip>
<template #default="scope">
<el-tag v-if="resolveSource(scope.row) === 'APP'" type="success" size="small">App</el-tag>
<el-tag v-else-if="resolveSource(scope.row) === 'ADMIN'" type="warning" size="small">后台</el-tag>
<el-tag v-else-if="resolveSource(scope.row) === 'DOWNSTREAM'" type="info" size="small">下游</el-tag>
<span v-else>{{ resolveSource(scope.row) || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="partnerCode" label="分销伙伴" width="110" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.partnerCode || "-" }}
<el-tag v-else-if="resolveSource(scope.row) === 'LOCAL'" type="warning" size="small" effect="plain">本地确认</el-tag>
<el-tag v-else-if="resolveSource(scope.row) === 'DOWNSTREAM'" type="info" size="small">
{{
scope.row.orderSourceLabel
|| (scope.row.partnerCode
? `${protocolText(scope.row.partnerProtocol)}${scope.row.partnerCode}`
: "")
|| scope.row.downstreamAppName
|| "下游"
}}
</el-tag>
<span v-else>{{ scope.row.orderSourceLabel || resolveSource(scope.row) || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="channelNo" label="订单号" min-width="160" show-overflow-tooltip />
@@ -177,8 +198,17 @@
<el-descriptions :column="2" border>
<el-descriptions-item label="订单号" :span="2">{{ detailOrder.channelNo || "-" }}</el-descriptions-item>
<el-descriptions-item label="确认单号" :span="2">{{ detailOrder.platformNo || "-" }}</el-descriptions-item>
<el-descriptions-item label="来源">{{ resolveSource(detailOrder) }}</el-descriptions-item>
<el-descriptions-item label="分销伙伴">{{ detailOrder.partnerCode || "-" }}</el-descriptions-item>
<el-descriptions-item label="订单来源" :span="2">
{{
detailOrder.orderSourceLabel
|| (detailOrder.partnerCode
? `${protocolText(detailOrder.partnerProtocol)}${detailOrder.partnerCode}`
: "")
|| detailOrder.downstreamAppName
|| resolveSource(detailOrder)
|| "-"
}}
</el-descriptions-item>
<el-descriptions-item label="下单用户">{{ detailOrder.createBy || "-" }}</el-descriptions-item>
<el-descriptions-item label="支付状态">{{ payStatusText(detailOrder.payStatus) }}</el-descriptions-item>
<el-descriptions-item label="订单状态">{{ orderStatusText(detailOrder.orderStatus) }}</el-descriptions-item>
@@ -413,7 +443,14 @@
<script setup lang="ts" name="systemSmHotelOrder">
import { BasicTableProps, useTable } from "/@/hooks/table"
import { fetchList, getObj, cancelOrder, compensateOrder, refundOrder } from "/@/api/supermarket/smHotelOrder"
import {
fetchList,
getObj,
cancelOrder,
compensateOrder,
refundOrder,
listOrderSourceOptions,
} from "/@/api/supermarket/smHotelOrder"
import {
listEnabledDownstreamPartners,
pullHuanqiOrders,
@@ -429,6 +466,9 @@ const detailActing = ref(false)
const pullVisible = ref(false)
const pullLoading = ref(false)
const pullPartnerCode = ref("")
/** 订单来源筛选项(环启/慧订/开放应用各自名称) */
const orderSourceOptions = ref<Array<{ partnerCode: string; label?: string; protocol?: string }>>([])
/** 拉取环启订单用的伙伴列表 */
const downstreamPartnerOptions = ref<Array<{ partnerCode: string; label?: string }>>([])
/** 详情接口完整 VO:order / hotel / product / dailyList ... */
const detailVo = ref<any>(null)
@@ -524,6 +564,7 @@ const mealText = (type: any) => {
const resolveSource = (row: any) => {
if (row?.orderSource && row.orderSource !== "-") return row.orderSource
if (row?.partnerCode || row?.downstreamAppId || row?.downstreamAppName) return "DOWNSTREAM"
const ch = String(row?.channelNo || "")
if (/^APP/i.test(ch)) return "APP"
if (/^ADMIN/i.test(ch)) return "ADMIN"
@@ -531,6 +572,14 @@ const resolveSource = (row: any) => {
return ch ? "DOWNSTREAM" : "-"
}
const protocolText = (protocol?: string) => {
const p = String(protocol || "").toLowerCase()
if (p === "huanqi") return "环启·"
if (p === "huiding") return "慧订·"
if (p === "open") return "开放·"
return ""
}
const dataSourceText = (ds?: string) => {
const v = String(ds || "").toLowerCase()
if (!v || v === "-") return "-"
@@ -612,6 +661,15 @@ const channelOrderStatusText = (s: any) => {
return map[String(s)] || String(s)
}
const loadOrderSourceOptions = async () => {
try {
const res: any = await listOrderSourceOptions()
orderSourceOptions.value = res?.data || []
} catch {
orderSourceOptions.value = []
}
}
const loadDownstreamPartners = async () => {
try {
const res: any = await listEnabledDownstreamPartners()
@@ -621,6 +679,8 @@ const loadDownstreamPartners = async () => {
}
}
loadOrderSourceOptions()
const openPullDialog = async () => {
await loadDownstreamPartners()
if (!downstreamPartnerOptions.value.length) {