Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-08-24 00:22:25 +08:00
commit 8a8d101379
23 files changed
+1464 -96

No files matched your search

+14
View File
@@ -65,6 +65,20 @@ export function selectTimeFoodApi(query?: Object) {
})
}
/**
* 查询指定日期、餐段和食堂的每日菜谱展示数据,返回套餐组成明细。
*
* @param query 日期、餐段和食堂查询条件;餐段为空时查询该日全部餐段
* @returns 每日菜谱商品列表,套餐通过 comboItems 携带组成菜品
*/
export function getCanteenMenuList(query?: Object) {
return request({
url: "/canteen/canteenDailyMenu/ad/getCanteenList",
method: "get",
params: query,
})
}
// 根据每日日期和每日餐段类型获取可添加的不重复的菜品列表
export function getCanteenDailyMenuList(query?: Object) {
return request({
+17
View File
@@ -9,6 +9,23 @@ export function page(query?: Object) {
})
}
/** 查询已到期且仍可整单退款的每日菜谱预约订单候选。 */
export function expiredCandidates(query?: Object) {
return request({
url: "/hard/hard-auto-refund-task/expired-candidates",
method: "get",
params: query,
})
}
/** 管理端确认后逐单执行过期预约整单退款。 */
export function expiredRefund(taskId: string) {
return request({
url: `/hard/hard-auto-refund-task/${taskId}/expired-refund`,
method: "post",
})
}
/**
* 查询一次自动退款任务的只读尝试日志。
*
+38
View File
@@ -1,5 +1,43 @@
import request from "/@/utils/request"
/** 订单详情中的单张识别证据,字段来自 HardOrderEvidenceImageVo。 */
export interface HardOrderEvidenceImage {
/** 服务端证据主键。 */
id: string | number
/** 证据类型稳定编码。 */
evidenceType: "FACE_RECOGNITION" | "MENU_RECOGNITION" | string
/** 图片角色稳定编码。 */
evidenceRole: "FACE_REFERENCE" | "FACE_LIVE" | "MENU_LIVE" | string
/** 图片角色中文名称。 */
evidenceRoleName?: string
/** 同一角色下从零开始的图片序号。 */
sequenceNo: number
/** 终端采集时间。 */
capturedAt?: string
/** 人脸识别相似度快照。 */
similarity?: number | string
/** 产生证据的设备主键。 */
hardId?: string | number
/** 证据文件访问地址。 */
imageUrl?: string
/** 证据状态稳定编码。 */
status: "PENDING" | "AVAILABLE" | "UNAVAILABLE" | string
/** 永久缺失或复制失败原因。 */
failureReason?: string
}
/** 当前页面使用的订单详情扩展字段。 */
export interface HardOrderDetail {
/** 订单主键。 */
id: string | number
/** 设备名称。 */
hardName?: string
/** 订单详情才返回的长期识别证据。 */
evidenceImages?: HardOrderEvidenceImage[]
/** 兼容订单详情其余历史字段。 */
[key: string]: unknown
}
/**
* 离线订单手动支付
*/
+132
View File
@@ -1,5 +1,6 @@
import request from "/@/utils/request"
import type { AsyncTaskProgress } from "/@/api/asyncTask"
import type { UserIdentityStudentQuery, UserIdentityTeacherQuery } from "/@/components/UserIdentityQuery/types"
/** 用户每日预约查询条件,分页字段仅在列表接口中生效。 */
export interface CanteenUserReservationQuery {
@@ -11,6 +12,14 @@ export interface CanteenUserReservationQuery {
apptUseDateList: [string, string]
/** 姓名、登录账号或手机号关键字。 */
nameOrPhone?: string
/** 用户身份协议编码集合;为空时不限制身份类型。 */
identityTypes?: Array<string | number>
/** 学生身份扩展查询条件;字段语义与 UPMS 用户查询一致。 */
studentInfo?: UserIdentityStudentQuery
/** 老师身份扩展查询条件;老师学号为空时不限制老师资料。 */
teacherInfo?: UserIdentityTeacherQuery
/** 人脸录入状态:1 已录入,0 未录入,空值表示全部。 */
hasFace?: string
/** 部门树选中的部门主键。 */
deptIds?: Array<string | number>
/** 是否包含所选部门。 */
@@ -71,6 +80,80 @@ export interface CanteenUserReservationExportTaskResponse {
msg?: string
}
/** 用户预约统计查询参数,字段与用户预约表格筛选条件保持一致。 */
export type CanteenUserReservationStatisticsQuery = Omit<CanteenUserReservationQuery, "current" | "size" | "userIds"> & {
userIds?: Array<string | number>
}
/** 按餐段汇总的预约统计。金额单位为分。 */
export interface CanteenUserReservationMealStatistics {
/** 餐段字典编码。 */
hourType?: string
/** 该餐段去重后的预约人数。 */
reservedUserCount?: number
/** 该餐段预约订单数。 */
orderCount?: number
/** 该餐段菜品总份数。 */
dishCount?: number
/** 该餐段预约总价,单位为分。 */
totalPrice?: number
}
/** 按餐段和菜品快照汇总的预约统计。金额单位为分。 */
export interface CanteenUserReservationDishStatistics {
/** 餐段字典编码。 */
hourType?: string
/** 下单时保存的菜品名称。 */
productName?: string
/** 菜品总份数。 */
productCount?: number
/** 包含该菜品的去重订单数。 */
orderCount?: number
/** 菜品总价,单位为分。 */
totalPrice?: number
}
/** 预约统计的通用汇总结构。金额单位为分。 */
export interface CanteenUserReservationStatisticsSummary {
/** 用户-用餐日期记录数。 */
userCount?: number
/** 当前范围内去重用户数。 */
uniqueUserCount?: number
/** 已订餐用户-用餐日期记录数。 */
reservedUserCount?: number
/** 未订餐用户-用餐日期记录数。 */
unreservedUserCount?: number
/** 有效预约订单数。 */
orderCount?: number
/** 菜品总份数。 */
dishCount?: number
/** 有效预约总价,单位为分。 */
totalPrice?: number
/** 餐段统计列表。 */
meals?: CanteenUserReservationMealStatistics[]
/** 菜品统计列表。 */
dishes?: CanteenUserReservationDishStatistics[]
}
/** 预约每日统计结构。 */
export interface CanteenUserReservationDailyStatistics extends CanteenUserReservationStatisticsSummary {
/** 用餐日期,格式为 YYYY-MM-DD。 */
date?: string
}
/** 预约统计接口响应。 */
export interface CanteenUserReservationStatisticsResponse {
/** 统计响应数据。 */
data: {
/** 查询范围总计。 */
total?: CanteenUserReservationStatisticsSummary
/** 按日统计列表。 */
daily?: CanteenUserReservationDailyStatistics[]
}
/** 后端返回的提示信息。 */
msg?: string
}
/**
* 按预约使用日期查询食堂预约部门统计。
*/
@@ -126,6 +209,20 @@ export function getCanteenUserReservationDetail(query: CanteenUserReservationDet
})
}
/**
* 按当前用户预约表格筛选条件查询每日和查询范围总计。
*
* @param query 用户、日期、部门、食堂、餐段和状态筛选条件
* @returns 包含每日统计与总计的统一响应
*/
export function getCanteenUserReservationStatistics(query: CanteenUserReservationStatisticsQuery) {
return request<unknown, CanteenUserReservationStatisticsResponse>({
url: "/hard/hard-canteen-appt-total-log/user-reserv-statistics",
method: "post",
data: query,
})
}
/**
* 按当前筛选条件提交用户每日预约异步导出任务。
*
@@ -153,3 +250,38 @@ export function exportCanteenUserReservation(query: CanteenUserReservationQuery)
responseType: "blob",
})
}
/** 菜品预约统计导出查询条件,日期范围使用预约使用时间闭区间。 */
export interface CanteenProductReservationExportQuery {
/** 预约使用日期时间范围,格式为 YYYY-MM-DD HH:mm:ss。 */
apptUseDateTimeList?: [string, string]
/** 餐段字典编码。 */
hourType?: string | number
/** 食堂商户主键。 */
merchantId?: string | number
/** 用户部门主键集合。 */
userDeptIds?: Array<string | number>
/** 是否包含所选部门。 */
isInclude?: boolean
/** 商户部门主键集合。 */
merchantDeptIds?: Array<string | number>
/** 菜品预约统计允许的商户主键集合。 */
mchIds?: Array<string | number>
}
/** 菜品预约统计异步导出提交接口的统一响应。 */
export interface CanteenProductReservationExportTaskResponse {
/** 后端创建的异步任务进度。 */
data: AsyncTaskProgress
/** 后端返回的任务提交提示。 */
msg?: string
}
/** 按当前菜品预约统计筛选条件提交异步导出任务。 */
export function submitCanteenProductReservationExport(query: CanteenProductReservationExportQuery) {
return request<unknown, CanteenProductReservationExportTaskResponse>({
url: "/hard/hard-canteen-appt-total-log/product-reserv-export-task",
method: "post",
data: query,
})
}
+6
View File
@@ -14,6 +14,12 @@ export interface MchAutoRefundConfig {
ruleType: "PAY_TIME_LIMIT" | "APPT_CYCLE_CLOSE"
/** 支付后限时小时数,周期规则下为空。 */
payTimeLimitHours?: number | null
/** 过期预约自动退款开关,1 开启、0 关闭。 */
expiredReserveRefundEnabled: "0" | "1"
/** 餐段结束后的延迟小时数,范围 1-168。 */
expiredReserveRefundDelayHours?: number | null
/** 过期预约退款是否恢复库存,1 恢复、0 不恢复。 */
expiredReserveRefundRestoreInventory: "0" | "1"
}
/**
+152
View File
@@ -0,0 +1,152 @@
<template>
<el-form-item class="w-[250px] max-md:w-full" label="用户身份" prop="identityTypes">
<dict-select v-model="identityTypes" dict-code="user_identity_type" multiple placeholder="请选择用户身份" />
</el-form-item>
<el-form-item v-for="fieldConfig in activeFieldConfigs" :key="`${fieldConfig.modelName}.${fieldConfig.field}`"
class="w-[250px] max-md:w-full" :label="fieldConfig.label"
:prop="`${fieldConfig.modelName}.${fieldConfig.field}`">
<dict-select v-if="fieldConfig.control === 'dict-select'" :model-value="getQueryFieldValue(fieldConfig)"
:dict-code="fieldConfig.dictCode || ''" :placeholder="fieldConfig.placeholder"
@update:model-value="updateQueryFieldValue(fieldConfig, $event)" />
<el-input v-else :model-value="getQueryFieldValue(fieldConfig)" clearable :placeholder="fieldConfig.placeholder"
@update:model-value="updateQueryFieldValue(fieldConfig, $event)" />
</el-form-item>
<el-form-item class="w-[180px] max-md:w-full" label="人脸录入" prop="hasFace">
<el-select v-model="hasFace" clearable placeholder="全部">
<el-option label="已录入" value="1" />
<el-option label="未录入" value="0" />
</el-select>
</el-form-item>
</template>
<script setup lang="ts" name="UserIdentityQuery">
import { STUDENT_USER_IDENTITY, TEACHER_USER_IDENTITY } from "/@/constants/userIdentity"
import {
createEmptyUserIdentityStudentQuery,
createEmptyUserIdentityTeacherQuery,
type UserIdentityStudentQuery,
type UserIdentityTeacherQuery,
type UserIdentityQueryFieldConfig,
type UserIdentityQueryModelName,
} from "./types"
/** 用户身份扩展查询字段配置;新增身份字段时在此扩展对应协议编码、模型和控件。 */
const identityQueryFieldConfigs: UserIdentityQueryFieldConfig[] = [
// 学生学号使用文本输入,兼容学校已有的字母数字混合编号规则,后端按完整编号匹配。
{
identityType: STUDENT_USER_IDENTITY,
modelName: "studentInfo",
field: "studentNo",
label: "学号",
placeholder: "请输入学生学号",
control: "input",
},
// 学校名称沿用用户编辑表单的统一字典,避免自由输入产生无效学校值。
{
identityType: STUDENT_USER_IDENTITY,
modelName: "studentInfo",
field: "schoolName",
label: "学校",
placeholder: "请选择学校",
control: "dict-select",
dictCode: "user_student_school",
},
// 班级名称当前没有公共字典,继续按后端扩展资料原值查询。
{
identityType: STUDENT_USER_IDENTITY,
modelName: "studentInfo",
field: "className",
label: "班级",
placeholder: "请输入班级名称",
control: "input",
},
// 监护人资料存储为逗号分隔手机号集合,输入单个手机号即可包含匹配。
{
identityType: STUDENT_USER_IDENTITY,
modelName: "studentInfo",
field: "guardianPhones",
label: "监护人",
placeholder: "请输入监护人手机号",
control: "input",
},
// 老师身份使用独立扩展表和独立学号字段,不复用学生学号协议。
{
identityType: TEACHER_USER_IDENTITY,
modelName: "teacherInfo",
field: "teacherNo",
label: "老师学号",
placeholder: "请输入老师学号",
control: "input",
},
]
/** 用户身份类型协议编码集合,由字典值与后端身份表共同约束。 */
const identityTypes = defineModel<Array<string | number>>("identityTypes", { default: () => [] })
/** 学生身份扩展查询条件,由页面查询模型持有并提交给用户接口。 */
const studentInfo = defineModel<UserIdentityStudentQuery>("studentInfo", {
default: createEmptyUserIdentityStudentQuery,
})
/** 老师身份扩展查询条件,由页面查询模型持有并提交给用户接口。 */
const teacherInfo = defineModel<UserIdentityTeacherQuery>("teacherInfo", {
default: createEmptyUserIdentityTeacherQuery,
})
/** 人脸录入状态查询条件:1 已录入,0 未录入,空值表示全部。 */
const hasFace = defineModel<string>("hasFace", { default: "" })
/** 当前选中身份对应的扩展查询字段配置,驱动字段控件渲染。 */
const activeFieldConfigs = computed(() => {
const selected = new Set(identityTypes.value.map((value) => String(value)))
return identityQueryFieldConfigs.filter((fieldConfig) => selected.has(fieldConfig.identityType))
})
/**
* 获取指定身份扩展查询模型,供配置化字段统一读写。
*
* @param modelName 字段所属的学生或老师查询模型名称
* @returns 当前页面持有的对应身份查询模型
*/
const getQueryModel = (modelName: UserIdentityQueryModelName): Record<string, string | undefined> => {
return (modelName === "studentInfo" ? studentInfo.value : teacherInfo.value) as Record<string, string | undefined>
}
/**
* 读取配置字段的当前查询值。
*
* @param fieldConfig 当前身份扩展字段配置
* @returns 当前字段值,未填写时返回空字符串
*/
const getQueryFieldValue = (fieldConfig: UserIdentityQueryFieldConfig) => {
return getQueryModel(fieldConfig.modelName)[fieldConfig.field] || ""
}
/**
* 更新配置字段的查询值,普通输入会去除首尾空格,字典值保持协议原值。
*
* @param fieldConfig 当前身份扩展字段配置
* @param value Element Plus 控件返回的字段值
*/
const updateQueryFieldValue = (fieldConfig: UserIdentityQueryFieldConfig, value: unknown) => {
const nextValue = typeof value === "string" ? value.trim() : String(value || "")
if (fieldConfig.modelName === "studentInfo") {
studentInfo.value = { ...studentInfo.value, [fieldConfig.field]: nextValue }
return
}
teacherInfo.value = { ...teacherInfo.value, [fieldConfig.field]: nextValue }
}
/**
* 切换身份时同步清理未选身份的查询条件,避免用户看不到的条件继续提交。
* 默认身份的手机号继续使用用户主查询字段,不在此重复生成同义条件。
*/
watch(
identityTypes,
(values) => {
const selected = new Set(values.map((value) => String(value)))
if (!selected.has(STUDENT_USER_IDENTITY)) studentInfo.value = createEmptyUserIdentityStudentQuery()
if (!selected.has(TEACHER_USER_IDENTITY)) teacherInfo.value = createEmptyUserIdentityTeacherQuery()
},
{ deep: true },
)
</script>
+62
View File
@@ -0,0 +1,62 @@
/** 用户身份扩展查询字段所属的前端模型。 */
export type UserIdentityQueryModelName = "studentInfo" | "teacherInfo"
/** 用户身份扩展查询字段使用的表单控件。 */
export type UserIdentityQueryControl = "input" | "dict-select"
/** 单个用户身份扩展查询字段的渲染配置。 */
export interface UserIdentityQueryFieldConfig {
/** 显示该字段所需的身份协议编码。 */
identityType: string
/** 字段所属的查询模型名称。 */
modelName: UserIdentityQueryModelName
/** 前后端 DTO 共同使用的字段名称。 */
field: string
/** 查询表单展示的字段名称。 */
label: string
/** 字段未填写时展示的输入提示。 */
placeholder: string
/** 字段使用的表单控件类型。 */
control: UserIdentityQueryControl
/** 字典选择控件的数据字典编码;普通输入框不设置。 */
dictCode?: string
}
/** 学生身份扩展查询条件;学号和学校使用精确匹配,其余文本字段使用包含匹配。 */
export interface UserIdentityStudentQuery {
/** 学生学号,来源于学生身份扩展表,按完整学号精确匹配。 */
studentNo?: string
/** 学校名称,来源于学生身份扩展表,按学校字典值精确匹配。 */
schoolName?: string
/** 班级名称,来源于学生身份扩展表。 */
className?: string
/** 监护人手机号,来源于学生身份扩展表中逗号分隔的手机号集合。 */
guardianPhones?: string
}
/** 老师身份扩展查询条件。 */
export interface UserIdentityTeacherQuery {
/** 老师学号,来源于老师身份扩展表,按完整学号精确匹配。 */
teacherNo?: string
}
/**
* 创建用户身份通用查询组件的学生扩展字段初始值。
*
* @returns 所有学生身份查询字段均为空的独立对象
*/
export const createEmptyUserIdentityStudentQuery = (): UserIdentityStudentQuery => ({
studentNo: "",
schoolName: "",
className: "",
guardianPhones: "",
})
/**
* 创建用户身份通用查询组件的老师扩展字段初始值。
*
* @returns 老师学号为空的独立对象
*/
export const createEmptyUserIdentityTeacherQuery = (): UserIdentityTeacherQuery => ({
teacherNo: "",
})
+2
View File
@@ -19,6 +19,7 @@ import DraggableDepTree from "/@/components/DraggableDepTree/index.vue"
import SearchPanel from "/@/components/SearchPanel/index.vue"
import UserInfoCell from "/@/components/UserInfoCell/index.vue"
import UserIdentityInfo from "/@/components/UserIdentityInfo/index.vue"
import UserIdentityQuery from "/@/components/UserIdentityQuery/index.vue"
// 业务组件
import TreeMultipleSelect from "/@/components/TreeMultipleSelect/index.vue"
@@ -89,6 +90,7 @@ export default {
app.component("SearchPanel", SearchPanel)
app.component("UserInfoCell", UserInfoCell)
app.component("UserIdentityInfo", UserIdentityInfo)
app.component("UserIdentityQuery", UserIdentityQuery)
app.component("TreeMultipleSelect", TreeMultipleSelect)
app.component("SelectHard", SelectHard)
app.component("SelectHardSpuCategory", SelectHardSpuCategory)
+26
View File
@@ -15,6 +15,22 @@ export const AUTO_REFUND_RULE_OPTIONS: Array<{ label: string; value: AutoRefundR
{ label: "周期预约截止", value: AUTO_REFUND_RULE.APPT_CYCLE_CLOSE },
]
/** 自动退款任务场景编码,与 Hardware 任务表中的 task_type 保持一致。 */
export const AUTO_REFUND_TASK_TYPE = {
/** 规定时间内用户申请的免审核退款。 */
AFTER_SALE: "10",
/** 预约订单超过使用期限后的系统自动退款。 */
EXPIRED_RESERVE: "20",
} as const
/** 自动退款任务场景的管理端展示配置。 */
export const AUTO_REFUND_TASK_TYPE_META: Record<string, { label: string; type: "info" | "primary" | "warning" | "danger" | "success" }> = {
/** 管理端标识在商户规定时限内由用户发起且无需人工审核的退款。 */
[AUTO_REFUND_TASK_TYPE.AFTER_SALE]: { label: "规定时间内用户免审核退款", type: "success" },
/** 管理端标识订单超过预约使用期限后由系统创建的退款。 */
[AUTO_REFUND_TASK_TYPE.EXPIRED_RESERVE]: { label: "过期订单自动退款", type: "warning" },
}
/** 自动退款任务状态展示配置。 */
export const AUTO_REFUND_TASK_STATUS: Record<string, { label: string; type: "info" | "primary" | "warning" | "danger" | "success" }> = {
/** 售后事务已创建任务,等待首次同步执行。 */
@@ -39,6 +55,8 @@ export const AUTO_REFUND_TRIGGER_TYPE: Record<string, string> = {
"20": "定时重试",
/** 管理端技术人员对耗尽任务额外触发一次。 */
"30": "人工技术重试",
/** 管理端具备业务退款权限的逐单过期预约退款。 */
"40": "后台逐单过期退款",
}
/** 自动退款尝试结果展示配置。 */
@@ -61,6 +79,14 @@ export const AUTO_REFUND_ATTEMPT_RESULT: Record<string, { label: string; type: "
export const getAutoRefundTaskStatus = (status?: string) =>
AUTO_REFUND_TASK_STATUS[status || ""] || { label: status || "-", type: "info" as const }
/**
* 获取自动退款任务场景展示配置,未知编码保留原值便于排查协议兼容问题。
*
* @param taskType 自动退款任务场景编码。
*/
export const getAutoRefundTaskType = (taskType?: string) =>
AUTO_REFUND_TASK_TYPE_META[taskType || ""] || { label: taskType || "-", type: "info" as const }
/**
* 获取尝试结果展示配置,未知状态保留原编码便于技术排查。
*
+19 -11
View File
@@ -28,20 +28,14 @@
<el-form-item style="width: 250px" label="编制情况" prop="staffingType">
<dict-select v-model="state.queryForm.staffingType" dict-code="user_staffing_type" />
</el-form-item>
<el-form-item style="width: 250px" label="用户身份" prop="identityTypes">
<dict-select v-model="state.queryForm.identityTypes" dict-code="user_identity_type"
multiple placeholder="请选择用户身份" />
</el-form-item>
<user-identity-query v-model:identity-types="state.queryForm.identityTypes"
v-model:student-info="state.queryForm.studentInfo"
v-model:teacher-info="state.queryForm.teacherInfo"
v-model:has-face="state.queryForm.hasFace" />
<el-form-item style="width: 180px" label="启用状态" prop="lockFlag">
<dict-select v-model="state.queryForm.lockFlag" dict-code="lock_flag"
placeholder="启用/禁用" />
</el-form-item>
<el-form-item style="width: 180px" label="人脸录入" prop="hasFace">
<el-select v-model="state.queryForm.hasFace" clearable placeholder="全部">
<el-option label="已录入" value="1" />
<el-option label="未录入" value="0" />
</el-select>
</el-form-item>
</template>
</SearchPanel>
<section class="min-w-0" aria-labelledby="user-list-title">
@@ -257,6 +251,10 @@ import { useDict } from "/@/hooks/dict"
import UserRoleTags from "./components/user-role-tags.vue"
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
import { downBlobFile } from "/@/utils/other"
import {
createEmptyUserIdentityStudentQuery,
createEmptyUserIdentityTeacherQuery,
} from "/@/components/UserIdentityQuery/types"
const UserForm = defineAsyncComponent(() => import("./form.vue"))
const FaceManage = defineAsyncComponent(() => import("./face-manage.vue"))
@@ -308,6 +306,8 @@ const state: BasicTableProps = reactive<BasicTableProps>({
username: "",
phone: "",
identityTypes: [],
studentInfo: createEmptyUserIdentityStudentQuery(),
teacherInfo: createEmptyUserIdentityTeacherQuery(),
isDeepDept: false,
processStatus: "2",
lockFlag: "",
@@ -339,15 +339,23 @@ const advancedFilterCount = computed(() => {
state.queryForm.role,
state.queryForm.staffingType,
state.queryForm.identityTypes,
state.queryForm.studentInfo?.studentNo,
state.queryForm.studentInfo?.schoolName,
state.queryForm.studentInfo?.className,
state.queryForm.studentInfo?.guardianPhones,
state.queryForm.teacherInfo?.teacherNo,
state.queryForm.lockFlag,
state.queryForm.hasFace,
].filter((value) => (Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== "")).length
})
// 清空搜索条件
/** 清空表单、身份扩展条件和部门树选择后重新查询第一页。 */
const resetQuery = () => {
queryRef.value?.resetFields()
state.queryForm.deptId = ""
state.queryForm.deptIds = []
state.queryForm.identityTypes = []
Object.assign(state.queryForm.studentInfo, createEmptyUserIdentityStudentQuery())
Object.assign(state.queryForm.teacherInfo, createEmptyUserIdentityTeacherQuery())
draggableDepTreeRef.value.resetData()
getDataList()
}
@@ -18,7 +18,7 @@
<select-mch v-model="state.queryForm.merchantId" deptMch :apiParams="{ parentIsNull: false, types: '1' }" />
</el-form-item>
<el-form-item style="width: 450px" label="预约时间" prop="apptUseDateTimeList">
<el-date-picker v-model="state.queryForm.apptUseDateTimeList" value-format="YYYY-MM-DD HH:mm:ss" type="daterange" />
<el-date-picker v-model="state.queryForm.apptUseDateTimeList" value-format="YYYY-MM-DD HH:mm:ss" type="daterange" :default-time="appointmentDateDefaultTime" />
</el-form-item>
</SearchPanel>
</el-row>
@@ -128,6 +128,7 @@ import { displayPrice } from "/@/utils/formatMoney"
import { useDict } from "/@/hooks/dict"
import { getDicts } from "/@/api/admin/dict"
import { parseTime } from "/@/utils/formatTime"
import dayjs from "dayjs"
import * as hardTotalApptApi from "/@/api/hard/hard-total-appt"
@@ -150,8 +151,22 @@ const selectObjs = ref([]) as any
const multiple = ref(true)
const draggableDepTreeRef = ref() // 树组件ref引用
/** 获取包含今天在内的最近七个自然日预约时间闭区间。 */
const getDefaultAppointmentDateTimeRange = (): [string, string] => {
const endDate = dayjs()
return [
endDate.subtract(6, "day").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
endDate.endOf("day").format("YYYY-MM-DD HH:mm:ss"),
]
}
/** 预约日期范围默认覆盖开始日零点至结束日最后一秒。 */
const appointmentDateDefaultTime = [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
const state: BasicTableProps = reactive({
queryForm: {},
queryForm: {
apptUseDateTimeList: getDefaultAppointmentDateTimeRange(),
},
})
// table hook
@@ -185,6 +200,7 @@ const resetQuery = () => {
selectObjs.value = []
state.queryForm.deptId = ""
state.queryForm.userDeptIds = []
state.queryForm.apptUseDateTimeList = getDefaultAppointmentDateTimeRange()
getDataList()
}
@@ -17,12 +17,27 @@
:header-cell-style="tableStyle.headerCellStyle" :summary-method="getSummaries" :show-summary="true"
@selection-change="selectionChangHandle" @sort-change="sortChangeHandle">
<el-table-column label="商户" align="center" prop="merchantName" />
<el-table-column label="用餐时间" align="center" prop="createTime">
<el-table-column label="预约时间" align="center" prop="apptUseDate">
<template #default="{ row }">
<span>{{ parseTime(row.createTime, "{y}-{m}-{d}") }}</span>
<span>{{ parseTime(row.apptUseDate, "{y}-{m}-{d}") }}</span>
</template>
</el-table-column>
<el-table-column label="商品" align="center" prop="productName" min-width="220">
<template #default="{ row }">
<div class="flex flex-col items-center">
<div class="flex items-center justify-center">
<span>{{ row.productName }}</span>
<el-tag v-if="hasComboItems(row)" class="ml-1" type="warning" size="small">套餐</el-tag>
</div>
<div v-if="hasComboItems(row)" class="mt-1 flex flex-col items-start text-xs text-gray-500">
<div v-for="(item, index) in getComboItems(row)" :key="item.childSpuId || item.name || index" class="leading-5">
{{ item.name || item.childSpuName || "未命名菜品" }}
<span class="ml-1">x{{ item.quantity || 1 }}(共 {{ getComboItemTotalCount(row, item) }}份)</span>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="商品" align="center" prop="productName" />
<el-table-column label="商品图片" align="center">
<template #default="{ row }">
<preview-image v-if="row.productPic" :src="row.productPic" />
@@ -54,6 +69,37 @@ import { displayPrice } from "/@/utils/formatMoney"
import { parseTime } from "/@/utils/formatTime"
import * as hardTotalApi from "/@/api/hard/hard-total"
import { getCanteenMenuList } from "/@/api/canteen/canteenDailyMenu"
/** 每日菜谱套餐组成的只读展示字段。 */
interface ComboItem {
/** 组成菜品 SPU ID,用于稳定标识明细行。 */
childSpuId?: string | number
/** 组成菜品名称;兼容历史接口返回的 childSpuName。 */
name?: string
/** 历史接口返回的组成菜品名称。 */
childSpuName?: string
/** 套餐内该组成菜品的份数。 */
quantity?: number | string
}
/** 详情表格商品统计行,保留接口原始字段并附加套餐组成。 */
interface ProductTotalRow {
/** 当前详情所属的预约预计使用日期,来源于主表点击行。 */
apptUseDate?: string
/** 统计所属商户 ID。 */
merchantId?: string | number
/** 统计商品 SPU ID。 */
productId?: string | number
/** 统计商品名称。 */
productName?: string
/** 统计商品购买数量。 */
count?: number | string
/** 每日菜谱匹配到的套餐组成。 */
comboItems?: ComboItem[]
[key: string]: any
}
// 定义变量内容
const visible = ref(false)
const queryRef = ref<any>()
@@ -61,10 +107,82 @@ const queryRef = ref<any>()
const selectObjs = ref([]) as any
const multiple = ref(true)
/**
* 从预约统计接口补充每日菜谱套餐明细后返回表格分页结果。
* 统计接口只返回订单商品快照,套餐组成需按商户和商品 SPU 查询当日菜谱补齐。
*
* @param query 详情表格分页及筛选条件
* @returns 与原统计接口兼容的分页响应
*/
const queryProductTotals = async (query: any) => {
const response = await hardTotalApi.canteenProudctMchQuery(query)
const records = Array.isArray(response?.data?.records) ? (response.data.records as ProductTotalRow[]) : []
records.forEach((row) => {
// 详情接口的 createTime 是商品快照时间,预约时间必须使用当前查询限定的预计使用日期。
row.apptUseDate = query.orderApptUseDateStr
})
await attachDailyMenuComboItems(records, query.orderApptUseDateStr, query.hourType)
return response
}
/**
* 按当前分页中的商户批量查询每日菜谱,并将套餐组成挂到对应统计行。
* 查询失败时保持原统计行,避免菜谱服务异常导致详情弹框不可用。
*
* @param records 当前页商品统计行
* @param day 预约用餐日期,格式为 YYYY-MM-DD
* @param hourType 用餐时间段编码
*/
const attachDailyMenuComboItems = async (records: ProductTotalRow[], day: string, hourType: string | number) => {
records.forEach((row) => {
row.comboItems = []
})
if (!records.length || !day || hourType === undefined || hourType === null || hourType === "") return
const merchantIds = [...new Set(records.map((row) => row.merchantId).filter((id) => id !== undefined && id !== null))]
const menuGroups = await Promise.all(
merchantIds.map(async (merchantId) => {
try {
const response = await getCanteenMenuList({ day, hourType, merchantId })
return Array.isArray(response?.data) ? response.data : []
} catch (error) {
console.warn("查询预约日期每日菜谱失败", { merchantId, day, hourType, error })
return []
}
}),
)
const menuMap = new Map<string, any>()
menuGroups.flat().forEach((menu: any) => {
if (menu?.merchantId === undefined || menu?.merchantId === null || menu?.spuId === undefined || menu?.spuId === null) return
menuMap.set(`${menu.merchantId}:${menu.spuId}`, menu)
})
records.forEach((row) => {
const menu = menuMap.get(`${row.merchantId}:${row.productId}`)
if (Number(menu?.spuType) === 2 && Array.isArray(menu.comboItems)) {
row.comboItems = menu.comboItems
}
})
}
/** 获取统计行中的套餐组成,兼容历史接口未返回明细的情况。 */
const getComboItems = (row: ProductTotalRow): ComboItem[] => (Array.isArray(row.comboItems) ? row.comboItems : [])
/** 判断统计行是否匹配到可展示的套餐组成。 */
const hasComboItems = (row: ProductTotalRow) => getComboItems(row).length > 0
/** 计算当前统计数量对应的套餐组成总份数。 */
const getComboItemTotalCount = (row: ProductTotalRow, item: ComboItem) => {
const count = Number(row.count) || 0
const quantity = Number(item.quantity) || 1
return count * quantity
}
const state: BasicTableProps = reactive<BasicTableProps>({
createdIsNeed: false,
queryForm: {},
pageList: hardTotalApi.canteenProudctMchQuery,
pageList: queryProductTotals,
})
// table hook
@@ -115,6 +233,9 @@ const openDialog = (createTimeStr: any, hourType: any, queryParams: any) => {
visible.value = true
state.queryForm = {
...queryParams,
// 详情只展示当前日期和餐段对应的有效预约商品,避免混入普通购买记录。
orderType: "2",
apptIdOrApptRuleIdNotNull: true,
}
state.queryForm.orderApptUseDateStr = createTimeStr
state.queryForm.hourType = hourType
@@ -5,25 +5,26 @@
@getSelectNode="handleGetSelectNode" switch-inactive-text="包含" switch-active-text="不包含"
:defaultTreeWidth="20" :nodeClickable="false">
<template #default>
<SearchPanel
ref="queryRef"
:model="state.queryForm"
:loading="Boolean(state.loading)"
:search-text="$t('common.queryBtn')"
:reset-text="$t('common.resetBtn')"
@search="doSearch"
@reset="resetQuery"
>
<el-form-item style="width: 250px" label="用餐时间段" prop="hourType">
<dict-select v-model="state.queryForm.hourType" dict-code="daily_menu" />
</el-form-item>
<el-form-item style="width: 250px" label="食堂" prop="merchantId">
<select-mch v-model="state.queryForm.merchantId" deptMch :apiParams="{ parentIsNull: false, types: '1' }" />
</el-form-item>
<el-form-item style="width: 450px" label="预约时间" prop="apptUseDateTimeList">
<el-date-picker v-model="state.queryForm.apptUseDateTimeList" value-format="YYYY-MM-DD HH:mm:ss" type="daterange" />
</el-form-item>
</SearchPanel>
<SearchPanel ref="queryRef" :model="state.queryForm" :loading="Boolean(state.loading)"
:search-text="$t('common.queryBtn')" :reset-text="$t('common.resetBtn')" @search="doSearch"
@reset="resetQuery">
<el-form-item style="width: 250px" label="预约时间段" prop="hourType">
<dict-select v-model="state.queryForm.hourType" dict-code="daily_menu" />
</el-form-item>
<el-form-item style="width: 250px" label="食堂" prop="merchantId">
<select-mch v-model="state.queryForm.merchantId" deptMch
:apiParams="{ parentIsNull: false, types: '1' }" />
</el-form-item>
<el-form-item style="width: 450px" label="预约时间" prop="apptUseDateTimeList">
<el-date-picker v-model="state.queryForm.apptUseDateTimeList"
value-format="YYYY-MM-DD HH:mm:ss" type="daterange"
:default-time="appointmentDateDefaultTime" />
</el-form-item>
</SearchPanel>
<div class="mb-2 flex w-full items-center justify-end gap-2">
<right-toolbar :search="false" :export="'hard_canteenProductApptTotal_export'"
@exportExcel="exportData" @queryTable="doSearch" />
</div>
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle" :summary-method="getSummaries"
:show-summary="true" row-class-name="success-row" @selection-change="selectionChangHandle"
@@ -38,7 +39,7 @@
:header-cell-style="tableStyle.headerCellStyle" row-class-name="warning-row">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="userDeptName" align="center" label="部门" width="200" />
<el-table-column label="用餐时间段" width="100" align="center" prop="hourType">
<el-table-column label="预约时间段" width="100" align="center" prop="hourType">
<template #default="{ row }">
<dict-tag :options="daily_menu" :value="row.hourType" />
</template>
@@ -69,12 +70,12 @@
</el-table-column>
<el-table-column type="index" label="#" width="50" />
<el-table-column label="用餐时间" align="center" width="200" prop="createTime">
<el-table-column label="预约时间" align="center" width="200" prop="createTime">
<template #default="{ row }">
<span>{{ parseTime(row.createTime, "{y}-{m}-{d}") }}</span>
</template>
</el-table-column>
<el-table-column label="用餐时间段" width="100" align="center" prop="hourType">
<el-table-column label="预约时间段" width="100" align="center" prop="hourType">
<template #default="{ row }">
<dict-tag :options="daily_menu" :value="row.hourType" />
</template>
@@ -115,6 +116,8 @@
</DraggableDepTree>
</div>
<product-total-table ref="productTotalTableRef" />
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
@failure="handleExportFailure" />
</div>
</template>
@@ -124,8 +127,12 @@ import { displayPrice } from "/@/utils/formatMoney"
import { useDict } from "/@/hooks/dict"
import { parseTime } from "/@/utils/formatTime"
import { getDicts } from "/@/api/admin/dict"
import { useMessage } from "/@/hooks/message"
import type { AsyncTaskPollingConfig, AsyncTaskProgress } from "/@/api/asyncTask"
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
import { downBlobFile } from "/@/utils/other"
import dayjs from "dayjs"
import * as hardTotalApi from "/@/api/hard/hard-total"
import * as hardTotalApptApi from "/@/api/hard/hard-total-appt"
const ProductTotalTable = defineAsyncComponent(() => import("./components/product-total-table.vue"))
@@ -142,29 +149,60 @@ const draggableDepTreeRef = ref() // 树组件ref引用
const tableChildrenMap = ref(new Map<string, any>())
/** 获取包含今天在内的最近七个自然日预约时间闭区间。 */
const getDefaultAppointmentDateTimeRange = (): [string, string] => {
const endDate = dayjs()
return [
endDate.subtract(6, "day").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
endDate.endOf("day").format("YYYY-MM-DD HH:mm:ss"),
]
}
/** 预约日期范围默认覆盖开始日零点至结束日最后一秒,避免结束日数据被遗漏。 */
const appointmentDateDefaultTime = [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
// 定义查询字典
const { daily_menu } = useDict("daily_menu")
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
queryForm: {
apptUseDateTimeList: getDefaultAppointmentDateTimeRange(),
},
pageList: hardTotalApptApi.canteenProudctTotal,
})
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
/** 菜品预约导出进度弹窗引用,用于启动统一异步任务轮询。 */
const exportProgressRef = ref<InstanceType<typeof AsyncTaskProgressComponent>>()
/** 导出提交互斥状态,避免短时间内重复创建任务。 */
const exportSubmitting = ref(false)
/** 菜品预约导出的异步任务配置,与后端 Redis Key 保持一致。 */
const exportTaskConfig: AsyncTaskPollingConfig = {
progressUrl: "/hard/async-task/progress",
redisKey: "hard:canteen-product-appt:export",
showDialogOnFinished: false,
waitingMessage: "正在按预约日期分批生成菜品预约导出文件,请耐心等待进度达到 100%",
downloadButtonText: "下载 Excel",
}
/** 页面消息能力,用于导出提交和结果反馈。 */
const message = useMessage()
// 点击table展开行
const tableExpandChange = async (e: any) => {
try {
const { data } = await hardTotalApi.productTotalByDept({
const { data } = await hardTotalApptApi.listByDept({
hourType: e.hourType,
orderApptUseDateStr: parseTime(e.createTime, '{y}-{m}-{d}'),
merchantId: state.queryForm.merchantId,
userDeptIds: state.queryForm.userDeptIds,
isInclude: state.queryForm.isInclude,
})
if (data && data.length) {
tableChildrenMap.value.set(e.uniqueCode, data)
}
const departments = Array.isArray(data)
? data.flatMap((merchant: any) => Array.isArray(merchant.children) ? merchant.children : [])
: []
tableChildrenMap.value.set(e.uniqueCode, departments)
} catch (error: any) {
console.error(error);
}
@@ -203,6 +241,8 @@ const resetQuery = () => {
selectObjs.value = []
state.queryForm.deptId = ""
state.queryForm.userDeptIds = []
state.queryForm.apptUseDateTimeList = getDefaultAppointmentDateTimeRange()
tableChildrenMap.value.clear()
draggableDepTreeRef.value.resetData()
getDataList()
}
@@ -216,9 +256,69 @@ const selectionChangHandle = (objs: { id: string }[]) => {
// 搜索
const doSearch = () => {
productTotalTableRef.value.updateQueryParams(state.queryForm)
tableChildrenMap.value.clear()
getDataList()
}
/** 校验菜品导出所需的预约日期闭区间,避免提交异步任务后才由后台拒绝空日期。 */
const validateExportDateRange = () => {
const dateRange = state.queryForm.apptUseDateTimeList as string[] | undefined
if (!Array.isArray(dateRange) || dateRange.length !== 2 || !dateRange[0] || !dateRange[1]) {
message.warning("请选择开始和结束预约时间")
return false
}
const startDate = dayjs(dateRange[0])
const endDate = dayjs(dateRange[1])
if (!startDate.isValid() || !endDate.isValid()) {
message.warning("预约时间格式不正确")
return false
}
if (endDate.isBefore(startDate, "day")) {
message.warning("预约结束时间不能早于开始时间")
return false
}
return true
}
/** 按当前筛选条件提交菜品预约分批异步导出任务。 */
const exportData = async () => {
if (exportSubmitting.value) return
if (!validateExportDateRange()) return
if (!state.pagination?.total) {
message.warning("当前条件下没有可导出的数据")
return
}
exportSubmitting.value = true
try {
const response = await hardTotalApptApi.submitCanteenProductReservationExport({ ...state.queryForm })
if (!response.data?.taskId) {
message.error("导出任务提交成功,但响应中缺少任务ID")
return
}
message.success(response.msg || "菜品预约导出任务已提交")
exportProgressRef.value?.start(response.data)
} catch (error: any) {
message.error(error?.msg || "菜品预约导出任务提交失败")
} finally {
exportSubmitting.value = false
}
}
/** 菜品预约导出成功后下载文件中心返回的 Excel 文件。 */
const handleExportSuccess = async (progress: AsyncTaskProgress) => {
if (!progress.resultFileUrl) {
message.error("导出完成,但未返回文件下载地址")
return
}
message.success(progress.message || "菜品预约导出完成")
await downBlobFile(progress.resultFileUrl, {}, progress.resultFileName || "菜品预约统计.xlsx")
}
/** 菜品预约导出失败时展示后台任务错误信息。 */
const handleExportFailure = (progress: AsyncTaskProgress) => {
message.error(progress.message || "菜品预约导出失败")
}
// 树节点选择时
const handleGetSelectNode = (curClickedNode: any, checkedTreeNodes: any, isDeepTree: any) => {
state.queryForm.deptId = curClickedNode.id
@@ -242,4 +342,4 @@ onMounted(async () => {
.el-table .success-row {
--el-table-tr-bg-color: var(--el-color-success-light-9);
}
</style>
</style>
@@ -0,0 +1,36 @@
# 食堂用户预约汇总查询
## 功能边界
`index.vue` 展示“用户 × 用餐日期”的食堂预约汇总。查询的用户主数据来自 UPMS 当前操作者的数据权限范围,预约订单来自 Hardware;因此即使用户当天没有订餐,只要处于权限范围内,也可能返回“未订餐”行。
本页面的用户身份及人脸筛选复用全局 `UserIdentityQuery` 组件,查询条件放在搜索面板的“更多条件”区域。新增或调整身份字段时,必须同时检查前端组件、预约接口类型、Hardware 查询 DTO、预约服务转交逻辑和 UPMS 用户 SQL。
## 身份查询字段
| 页面字段 | 请求字段 | 匹配规则 | 生效条件 |
| --- | --- | --- | --- |
| 用户身份 | `identityTypes` | 命中任一身份编码 | 选择至少一个身份编码 |
| 学号 | `studentInfo.studentNo` | 完整匹配 | 选择学生身份 |
| 学校 | `studentInfo.schoolName` | 学校字典值完整匹配 | 选择学生身份 |
| 班级 | `studentInfo.className` | 包含匹配 | 选择学生身份 |
| 监护人 | `studentInfo.guardianPhones` | 在逗号分隔手机号集合中包含输入内容 | 选择学生身份 |
| 老师学号 | `teacherInfo.teacherNo` | 完整匹配 | 选择老师身份 |
| 人脸录入 | `hasFace` | `1` 已录入,`0` 未录入 | 选择对应状态 |
身份编码目前由 `user_identity_type` 字典和 UPMS 的 `UserIdentityTypeEnum` 共同约束,已有编码包括 `DEFAULT`、`STUDENT`、`TEACHER`。`UserIdentityQuery` 在切换身份时会清理未选身份的扩展条件,避免不可见条件继续提交。
## 数据链路
1. `src/views/hard/appt/canteen-user-appt-total/index.vue` 持有查询表单默认值,并通过 `user-identity-query` 写入身份及人脸条件。
2. `src/api/hard/hard-total-appt.ts` 将条件提交到列表、统计和导出接口;三者共用同一组身份字段。
3. `HardCanteenUserApptQueryDto` 实现 `BaseIsIncludeTreeInterface` 和 `BaseUserIdentityQueryInterface`,承载部门包含关系及身份条件。
4. Hardware 的预约列表服务和导出服务复制查询快照后,把身份条件转为 UPMS `UserDTO`,避免异步导出或分页预筛选丢字段。
5. UPMS `SysUserMapper.xml` 的用户查询 SQL 执行身份类型、学生资料、老师资料和人脸录入状态筛选,再返回带身份展示资料的用户 VO。人脸状态基于 `sys_user_face.del_flag = '0'` 的未删除记录判断。
## 后续修改检查
- 新增身份类型或扩展字段时,先修改 `src/components/UserIdentityQuery` 的配置和类型,再同步 Java 查询接口、DTO、UPMS SQL 以及本页文档。
- 修改查询字段名或匹配规则前,检查 PC 页面、统计抽屉、同步/异步导出、Feign `UserDTO` 和所有调用方;保持旧字段向后兼容。
- 不要在页面内新建 Axios 实例,也不要把身份查询改为直接访问数据库或新增数据库字段。
- 修改日期、部门或身份查询逻辑后,至少静态核对列表、统计、导出三条请求都携带同一查询快照;完整构建和测试按项目集中验证流程执行。
@@ -0,0 +1,141 @@
<template>
<el-drawer v-model="visible" title="预约统计" size="min(920px, 92vw)" destroy-on-close @closed="resetState">
<div v-loading="loading" class="flex h-full min-h-0 flex-col">
<el-empty v-if="!loading && !hasStatistics" description="当前筛选条件暂无预约统计数据" />
<el-tabs v-else v-model="activeTab" class="min-h-0 flex-1" stretch>
<el-tab-pane label="按日统计" name="daily">
<el-collapse v-model="expandedDates" accordion>
<el-collapse-item v-for="item in statistics.daily" :key="item.date" :name="item.date">
<template #title>
<div class="flex min-w-0 flex-1 items-center justify-between gap-3 pr-3">
<span class="font-medium text-[var(--el-text-color-primary)]">{{ item.date || "未记录日期" }}</span>
<el-tag type="info" effect="plain">{{ item.userCount ?? 0 }} 条记录</el-tag>
</div>
</template>
<reservation-statistics-summary :summary="item" :meal-options="mealOptions" />
</el-collapse-item>
</el-collapse>
<el-empty v-if="!statistics.daily.length" :image-size="72" description="当前日期范围没有统计记录" />
</el-tab-pane>
<el-tab-pane label="范围总计" name="total">
<reservation-statistics-summary :summary="statistics.total" :meal-options="mealOptions" />
</el-tab-pane>
</el-tabs>
<div class="mt-3 flex justify-end border-t border-[var(--el-border-color-lighter)] pt-3">
<el-button :icon="Refresh" :loading="loading" @click="reload">刷新</el-button>
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts" name="reservation-statistics-drawer">
import { Refresh } from "@element-plus/icons-vue"
import { useDict } from "/@/hooks/dict"
import { useMessage } from "/@/hooks/message"
import * as hardTotalApptApi from "/@/api/hard/hard-total-appt"
import ReservationStatisticsSummary from "./reservation-statistics-summary.vue"
/** 抽屉可见状态,由父页面通过公开的 open 方法控制。 */
const visible = ref(false)
/** 当前统计请求加载状态,用于遮罩内容和防止重复刷新。 */
const loading = ref(false)
/** 当前展示的统计维度标签页。 */
const activeTab = ref("daily")
/** 当前展开的用餐日期;手风琴模式下只保留一个日期。 */
const expandedDates = ref("")
/** 打开抽屉时保存的表格查询快照,刷新不会受页面后续输入影响。 */
const query = ref<hardTotalApptApi.CanteenUserReservationStatisticsQuery>()
/** 当前统计接口返回的范围总计和每日明细。 */
const statistics = reactive<{
total: hardTotalApptApi.CanteenUserReservationStatisticsSummary
daily: hardTotalApptApi.CanteenUserReservationDailyStatistics[]
}>({ total: {}, daily: [] })
/** 餐段字典,负责补齐零预约餐段并展示当前配置文案。 */
const { daily_menu } = useDict("daily_menu")
/** 页面消息能力,用于统计接口失败反馈。 */
const message = useMessage()
/** 统计请求序号,用于忽略关闭抽屉或重新打开后迟到的旧响应。 */
let requestSequence = 0
/** 当前餐段展示选项;选中餐段筛选时只展示该餐段。 */
const mealOptions = computed(() => {
const options = (daily_menu.value || [])
.filter((item: any) => item.value !== null && item.value !== undefined && item.value !== "")
.map((item: any) => ({ label: String(item.label), value: String(item.value) }))
const selectedHourType = query.value?.hourType
if (!selectedHourType) return options
const selectedCode = String(selectedHourType)
return options.some((item) => item.value === selectedCode)
? options.filter((item) => item.value === selectedCode)
: [{ label: selectedCode, value: selectedCode }]
})
/** 只要统计范围包含用户日期行或订单,即认为接口返回了可展示结果;无权限用户时不因日期占位行误判为有数据。 */
const hasStatistics = computed(() =>
Boolean(
statistics.daily.some((item) => item.userCount || item.orderCount) ||
statistics.total.orderCount ||
statistics.total.userCount,
),
)
/**
* 打开抽屉并使用当前表格筛选条件加载一次独立统计快照。
*
* @param params 当前用户预约表格的完整查询条件
*/
function open(params: hardTotalApptApi.CanteenUserReservationStatisticsQuery) {
requestSequence += 1
query.value = {
...params,
apptUseDateList: [...params.apptUseDateList],
deptIds: params.deptIds ? [...params.deptIds] : params.deptIds,
userIds: params.userIds ? [...params.userIds] : params.userIds,
identityTypes: params.identityTypes ? [...params.identityTypes] : params.identityTypes,
studentInfo: params.studentInfo ? { ...params.studentInfo } : params.studentInfo,
teacherInfo: params.teacherInfo ? { ...params.teacherInfo } : params.teacherInfo,
hasFace: params.hasFace,
}
statistics.total = {}
statistics.daily = []
expandedDates.value = ""
visible.value = true
activeTab.value = "daily"
reload()
}
/** 请求统计接口;失败时保留抽屉并提供刷新重试入口。 */
async function reload() {
if (!query.value) return
// 每次刷新都创建新的请求序号,允许新查询覆盖仍在途的旧请求。
const currentSequence = ++requestSequence
loading.value = true
try {
const response = await hardTotalApptApi.getCanteenUserReservationStatistics(query.value)
if (currentSequence !== requestSequence) return
statistics.total = response.data?.total || {}
statistics.daily = response.data?.daily || []
expandedDates.value = statistics.daily[0]?.date || ""
} catch (error: any) {
if (currentSequence !== requestSequence) return
statistics.total = {}
statistics.daily = []
expandedDates.value = ""
message.error(error?.msg || error?.message || "获取预约统计失败,请稍后重试")
} finally {
if (currentSequence === requestSequence) loading.value = false
}
}
/** 抽屉关闭后清理统计快照,避免下次打开短暂展示旧条件的数据。 */
function resetState() {
requestSequence += 1
query.value = undefined
statistics.total = {}
statistics.daily = []
expandedDates.value = ""
loading.value = false
}
defineExpose({ open })
</script>
@@ -0,0 +1,99 @@
<template>
<div class="space-y-3">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="订餐记录">{{ summary.reservedUserCount ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="未订餐记录">{{ summary.unreservedUserCount ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="去重用户">{{ summary.uniqueUserCount ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="预约订单">{{ summary.orderCount ?? 0 }} 单</el-descriptions-item>
<el-descriptions-item label="菜品份数">{{ summary.dishCount ?? 0 }} 份</el-descriptions-item>
<el-descriptions-item label="预约总价">{{ displayPrice(summary.totalPrice ?? 0) }}</el-descriptions-item>
</el-descriptions>
<div class="border-l-2 border-primary pl-2 text-sm font-medium text-[var(--el-text-color-primary)]">餐段统计</div>
<el-table :data="mealRows" border size="small" empty-text="暂无餐段统计">
<el-table-column prop="label" label="餐段" min-width="120" />
<el-table-column prop="reservedUserCount" label="订餐人次" width="110" align="center" />
<el-table-column prop="orderCount" label="订单数" width="100" align="center" />
<el-table-column prop="dishCount" label="菜品份数" width="110" align="center" />
<el-table-column label="总价" width="120" align="right">
<template #default="{ row }">{{ displayPrice(row.totalPrice ?? 0) }}</template>
</el-table-column>
</el-table>
<div class="border-l-2 border-primary pl-2 text-sm font-medium text-[var(--el-text-color-primary)]">菜品统计</div>
<el-table :data="summary.dishes || []" border size="small" empty-text="暂无菜品统计">
<el-table-column label="餐段" min-width="110">
<template #default="{ row }">{{ getMealLabel(row.hourType) }}</template>
</el-table-column>
<el-table-column prop="productName" label="菜品" min-width="180" show-overflow-tooltip />
<el-table-column prop="productCount" label="份数" width="100" align="center" />
<el-table-column prop="orderCount" label="订单数" width="100" align="center" />
<el-table-column label="总价" width="120" align="right">
<template #default="{ row }">{{ displayPrice(row.totalPrice ?? 0) }}</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts" name="reservation-statistics-summary">
import type { PropType } from "vue"
import { displayPrice } from "/@/utils/formatMoney"
import type {
CanteenUserReservationMealStatistics,
CanteenUserReservationStatisticsSummary,
} from "/@/api/hard/hard-total-appt"
/** 餐段字典选项,由抽屉按当前字典和餐段筛选条件提供。 */
interface MealOption {
/** 餐段展示文案。 */
label: string
/** 餐段协议编码。 */
value: string
}
/** 汇总展示输入,只负责渲染已加载的数据,不触发网络或业务副作用。 */
const props = defineProps({
summary: {
type: Object as PropType<CanteenUserReservationStatisticsSummary>,
required: true,
},
mealOptions: {
type: Array as PropType<MealOption[]>,
default: () => [],
},
})
/** 餐段表格行,在实际统计之外补齐当前字典餐段的零值,确保无预约时仍能看到午餐等餐段。 */
const mealRows = computed(() => {
const actualMap = new Map((props.summary.meals || []).map((item) => [String(item.hourType ?? ""), item]))
const rows = props.mealOptions.map((option) => ({
label: option.label,
hourType: option.value,
reservedUserCount: 0,
orderCount: 0,
dishCount: 0,
totalPrice: 0,
...actualMap.get(option.value),
}))
const configuredCodes = new Set(props.mealOptions.map((option) => option.value))
;(props.summary.meals || []).forEach((item: CanteenUserReservationMealStatistics) => {
const code = String(item.hourType ?? "")
if (configuredCodes.has(code)) return
rows.push({
label: getMealLabel(code),
hourType: code,
reservedUserCount: item.reservedUserCount ?? 0,
orderCount: item.orderCount ?? 0,
dishCount: item.dishCount ?? 0,
totalPrice: item.totalPrice ?? 0,
})
})
return rows
})
/** 将餐段协议编码转换为当前字典文案,未知历史编码保留原值。 */
function getMealLabel(value?: string): string {
if (!value) return "未标记餐段"
return props.mealOptions.find((item) => item.value === String(value))?.label || String(value)
}
</script>
@@ -19,6 +19,7 @@
:loading="Boolean(state.loading)"
:search-text="$t('common.queryBtn')"
:reset-text="$t('common.resetBtn')"
:hidden-active-count="advancedFilterCount"
@search="doSearch"
@reset="resetQuery"
>
@@ -59,10 +60,21 @@
<el-radio-button label="VERIFIED">已核销</el-radio-button>
</el-radio-group>
</el-form-item>
<template #more>
<user-identity-query v-model:identity-types="state.queryForm.identityTypes"
v-model:student-info="state.queryForm.studentInfo"
v-model:teacher-info="state.queryForm.teacherInfo"
v-model:has-face="state.queryForm.hasFace" />
</template>
</SearchPanel>
<div class="mb-2 flex w-full items-center justify-end gap-2">
<right-toolbar :search="false" :export="'hard_canteenUserApptTotal_export'" @exportExcel="exportData" @queryTable="doSearch" />
<right-toolbar :search="false" :export="'hard_canteenUserApptTotal_export'" @exportExcel="exportData" @queryTable="doSearch">
<el-tooltip class="item" effect="dark" content="统计" placement="top">
<el-button icon="DataLine" circle aria-label="打开预约统计" @click="openStatisticsDrawer" />
</el-tooltip>
</right-toolbar>
</div>
<el-table
@@ -256,6 +268,7 @@
<el-button @click="detailDialog.visible = false">关闭</el-button>
</template>
</el-dialog>
<reservation-statistics-drawer ref="statisticsDrawerRef" />
<async-task-progress-component
ref="exportProgressRef"
:config="exportTaskConfig"
@@ -277,6 +290,12 @@ import { displayPrice } from "/@/utils/formatMoney"
import * as hardTotalApptApi from "/@/api/hard/hard-total-appt"
import { Tickets } from "@element-plus/icons-vue"
import dayjs from "dayjs"
import {
createEmptyUserIdentityStudentQuery,
createEmptyUserIdentityTeacherQuery,
} from "/@/components/UserIdentityQuery/types"
const ReservationStatisticsDrawer = defineAsyncComponent(() => import("./components/reservation-statistics-drawer.vue"))
/** 已完成核销订单状态编码,属于现有前后端协议。 */
const VERIFIED_ORDER_STATUS = "20"
@@ -370,6 +389,8 @@ interface ReservationDetailDialogState {
const draggableDepTreeRef = ref()
/** 查询面板引用,用于重置表单字段和校验状态。 */
const queryRef = ref()
/** 预约统计抽屉引用,用于传入当前表格查询条件并触发统计请求。 */
const statisticsDrawerRef = ref()
/** 用户预约异步导出进度弹窗引用,用于提交成功后启动统一轮询。 */
const exportProgressRef = ref<InstanceType<typeof AsyncTaskProgressComponent>>()
/** 导出任务提交中的本地互斥状态,防止接口返回前连续创建重复任务。 */
@@ -412,6 +433,10 @@ const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {
apptUseDateList: getDefaultDateRange(),
nameOrPhone: "",
identityTypes: [],
studentInfo: createEmptyUserIdentityStudentQuery(),
teacherInfo: createEmptyUserIdentityTeacherQuery(),
hasFace: "",
reservationStatus: "",
verificationStatus: "",
deptIds: [],
@@ -423,6 +448,19 @@ const state: BasicTableProps = reactive<BasicTableProps>({
/** 表格分页、刷新和统一样式能力。 */
const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state)
/** 折叠时仍然生效的用户身份条件数量,帮助操作人员识别隐藏筛选。 */
const advancedFilterCount = computed(() => {
return [
state.queryForm.identityTypes,
state.queryForm.studentInfo?.studentNo,
state.queryForm.studentInfo?.schoolName,
state.queryForm.studentInfo?.className,
state.queryForm.studentInfo?.guardianPhones,
state.queryForm.teacherInfo?.teacherNo,
state.queryForm.hasFace,
].filter((value) => (Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== "")).length
})
/** 当前详情弹窗标题,明确展示用户和用餐日期。 */
const detailDialogTitle = computed(() => {
const context = [detailDialog.userName, detailDialog.apptUseDate].filter(Boolean).join(" · ")
@@ -588,6 +626,12 @@ function doSearch() {
getDataList()
}
/** 按当前表格查询条件打开统计抽屉,日期校验与列表查询保持一致。 */
function openStatisticsDrawer() {
if (!validateDateRange(true)) return
statisticsDrawerRef.value?.open({ ...state.queryForm })
}
/**
* 重置查询面板和部门树,恢复最近七天的默认用餐日期范围后重新查询。
*/
@@ -595,6 +639,10 @@ function resetQuery() {
queryRef.value?.resetFields()
state.queryForm.apptUseDateList = getDefaultDateRange()
state.queryForm.nameOrPhone = ""
state.queryForm.identityTypes = []
Object.assign(state.queryForm.studentInfo, createEmptyUserIdentityStudentQuery())
Object.assign(state.queryForm.teacherInfo, createEmptyUserIdentityTeacherQuery())
state.queryForm.hasFace = ""
state.queryForm.hourType = undefined
state.queryForm.merchantId = undefined
state.queryForm.reservationStatus = ""
@@ -1,15 +1,8 @@
<template>
<div class="flex h-full min-h-0 w-full flex-col">
<SearchPanel
v-show="showSearch"
ref="queryRef"
:model="state.queryForm"
:loading="Boolean(state.loading)"
:search-text="$t('common.queryBtn')"
:reset-text="$t('common.resetBtn')"
@search="getDataList"
@reset="resetQuery"
>
<SearchPanel v-show="showSearch" ref="queryRef" :model="state.queryForm" :loading="Boolean(state.loading)"
:search-text="$t('common.queryBtn')" :reset-text="$t('common.resetBtn')" @search="getDataList"
@reset="resetQuery">
<el-form-item label="订单号" prop="orderId">
<el-input v-model="state.queryForm.orderId" clearable placeholder="订单号" />
</el-form-item>
@@ -21,29 +14,19 @@
</el-form-item>
<el-form-item label="任务状态" prop="status">
<el-select v-model="state.queryForm.status" clearable placeholder="任务状态">
<el-option v-for="option in taskStatusOptions" :key="option.value" :label="option.label" :value="option.value" />
<el-option v-for="option in taskStatusOptions" :key="option.value" :label="option.label"
:value="option.value" />
</el-select>
</el-form-item>
<el-form-item class="!w-[380px]" label="创建时间" prop="createTimeList">
<el-date-picker
v-model="state.queryForm.createTimeList"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
<el-date-picker v-model="state.queryForm.createTimeList" type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" />
</el-form-item>
</SearchPanel>
<el-table
:data="state.dataList"
v-loading="state.loading"
border
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
:row-class-name="taskRowClassName"
@sort-change="sortChangeHandle"
>
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle" :row-class-name="taskRowClassName"
@sort-change="sortChangeHandle">
<el-table-column type="index" label="#" width="48" fixed="left" />
<el-table-column prop="mchName" label="商户" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.mchName || row.mchId }}</template>
@@ -68,13 +51,16 @@
</template>
</el-table-column>
<el-table-column label="尝试次数" width="110" align="center">
<template #default="{ row }">{{ row.autoAttemptCount || 0 }} / {{ row.totalAttemptCount || 0 }}</template>
<template #default="{ row }">{{ row.autoAttemptCount || 0 }} / {{ row.totalAttemptCount || 0
}}</template>
</el-table-column>
<el-table-column prop="nextRetryTime" label="下次执行" width="180">
<template #default="{ row }">{{ row.nextRetryTime || "-" }}</template>
</el-table-column>
<el-table-column prop="lastError" label="末次错误" min-width="220" show-overflow-tooltip>
<template #default="{ row }"><span :class="row.status === '40' ? 'text-[var(--el-color-danger)]' : ''">{{ row.lastError || "-" }}</span></template>
<template #default="{ row }"><span
:class="row.status === '40' ? 'text-[var(--el-color-danger)]' : ''">{{ row.lastError || "-"
}}</span></template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
@@ -82,25 +68,22 @@
<el-button icon="View" circle aria-label="查看尝试日志" @click="openAttempts(row)" />
</el-tooltip>
<el-tooltip v-if="row.status === '40'" content="人工重新触发" placement="top">
<el-button
icon="RefreshRight"
circle
type="danger"
aria-label="人工重新触发"
v-auth="'hard_autoRefundTask_retry'"
@click="handleRetry(row)"
/>
<el-button icon="RefreshRight" circle type="danger" aria-label="人工重新触发"
v-auth="'hard_autoRefundTask_retry'" @click="handleRetry(row)" />
</el-tooltip>
</template>
</el-table-column>
</el-table>
<pagination class="mt-auto" @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
<pagination class="mt-auto" @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
v-bind="state.pagination" />
<el-dialog v-model="attemptDialog.visible" title="自动退款尝试日志" width="min(1100px, 92vw)" :close-on-click-modal="false">
<el-dialog v-model="attemptDialog.visible" title="自动退款尝试日志" width="min(1100px, 92vw)"
:close-on-click-modal="false">
<el-table :data="attemptDialog.records" v-loading="attemptDialog.loading" border max-height="520">
<el-table-column prop="attemptNo" label="序号" width="70" align="center" />
<el-table-column label="触发来源" width="140">
<template #default="{ row }">{{ AUTO_REFUND_TRIGGER_TYPE[row.triggerType] || row.triggerType }}</template>
<template #default="{ row }">{{ AUTO_REFUND_TRIGGER_TYPE[row.triggerType] || row.triggerType
}}</template>
</el-table-column>
<el-table-column label="结果" width="140">
<template #default="{ row }">
+55 -2
View File
@@ -24,7 +24,7 @@
<el-date-picker v-model="state.queryForm.createTimeList" value-format="YYYY-MM-DD" type="daterange" />
</el-form-item>
</SearchPanel>
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
<el-table class="min-h-0 flex-1" height="100%" :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
@sort-change="sortChangeHandle">
<el-table-column type="selection" width="40" align="center" />
@@ -55,6 +55,23 @@
<dict-tag :options="hard_sale_status" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="退款类型" align="center" min-width="290">
<template #default="{ row }">
<template v-if="row.autoRefundTaskType">
<el-tag :type="getAutoRefundTaskType(row.autoRefundTaskType).type">
{{ getAutoRefundTaskType(row.autoRefundTaskType).label }}
</el-tag>
<div
v-if="row.autoRefundTaskType === AUTO_REFUND_TASK_TYPE.AFTER_SALE"
class="mt-1 whitespace-nowrap text-xs leading-4 text-[var(--el-text-color-secondary)]"
>
{{ getAutoRefundTimeText(row) }}
</div>
</template>
<el-tag v-else-if="row.autoRefundTaskStatus" type="info">自动退款(类型未知)</el-tag>
<span v-else>人工审核退款</span>
</template>
</el-table-column>
<el-table-column label="处理方式" align="center" min-width="150">
<template #default="{ row }">
<el-tag v-if="row.autoRefundTaskStatus" :type="getAutoRefundTaskStatus(row.autoRefundTaskStatus).type">
@@ -63,6 +80,9 @@
<span v-else>人工审核</span>
</template>
</el-table-column>
<el-table-column label="支付时间" align="center" width="180px" prop="orderPayTime">
<template #default="{ row }">{{ row.orderPayTime || "-" }}</template>
</el-table-column>
<el-table-column label="申请时间" align="center" width="180px" prop="createTime" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
@@ -105,7 +125,8 @@ import { pageJoin, delObjs } from "/@/api/hard/hard-after-sale"
import { useMessage, useMessageBox } from "/@/hooks/message"
import { useDict } from "/@/hooks/dict"
import { displayPrice } from "/@/utils/formatMoney"
import { getAutoRefundTaskStatus } from "/@/constants/autoRefund"
import dayjs from "dayjs"
import { AUTO_REFUND_RULE, AUTO_REFUND_TASK_TYPE, getAutoRefundTaskStatus, getAutoRefundTaskType } from "/@/constants/autoRefund"
// 引入组件
const FormDialog = defineAsyncComponent(() => import("./form.vue"))
@@ -137,6 +158,36 @@ const state: BasicTableProps = reactive<BasicTableProps>({
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
/**
* 将自动退款任务的资格截止快照转换为列表可读的免审时限。
* 支付时间和截止时间均来自服务端订单/任务快照,避免使用当前配置反推历史订单规则。
*
* @param row 当前售后列表行,包含订单支付时间和自动退款资格截止时间
* @returns 免审时限文案;历史数据缺失时返回截止时间或明确的缺失提示
*/
const getAutoRefundTimeText = (row: any) => {
const deadline = dayjs(row.autoRefundEligibilityDeadline)
if (!row.autoRefundEligibilityDeadline || !deadline.isValid()) {
return "免审时限未记录"
}
const payTime = dayjs(row.orderPayTime)
if (!row.orderPayTime || !payTime.isValid()) {
return `免审截止 ${deadline.format("YYYY-MM-DD HH:mm:ss")}`
}
if (row.autoRefundRuleType === AUTO_REFUND_RULE.APPT_CYCLE_CLOSE) {
return `预约周期截止前,截止 ${deadline.format("YYYY-MM-DD HH:mm:ss")}`
}
const minutes = deadline.diff(payTime, "minute")
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
const durationParts = [hours > 0 ? `${hours} 小时` : "", remainingMinutes > 0 ? `${remainingMinutes} 分钟` : ""].filter(Boolean)
const durationText = minutes > 0 ? `支付后 ${durationParts.join(" ")}内` : "免审截止已过"
return `${durationText},截止 ${deadline.format("YYYY-MM-DD HH:mm:ss")}`
}
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
@@ -202,8 +253,10 @@ const handleDelete = async (ids: string[]) => {
:deep(.el-tabs__content),
:deep(.el-tab-pane) {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
}
@@ -63,6 +63,43 @@
</el-table>
</el-descriptions-item>
</el-descriptions>
<el-descriptions v-if="hasFaceEvidence" :column="1" border title="识别凭证">
<el-descriptions-item label-class-name="no-colon">
<div class="grid w-full grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-4">
<div
v-for="evidence in faceEvidence"
:key="evidence.id"
class="flex min-h-[180px] gap-4 rounded-[6px] border border-br-light bg-[var(--el-fill-color-blank)] p-3"
>
<div class="flex h-[156px] w-40 flex-none items-center justify-center overflow-hidden rounded bg-fill-light">
<el-image
v-if="evidence.status === 'AVAILABLE' && evidence.imageUrl && !failedEvidenceImages.has(evidence.id)"
class="h-full w-full"
:src="evidenceImageUrl(evidence.imageUrl)"
:preview-src-list="[evidenceImageUrl(evidence.imageUrl)]"
fit="contain"
preview-teleported
loading="lazy"
@error="markEvidenceImageFailed(evidence.id)"
/>
<span v-else-if="evidence.status === 'PENDING'" class="p-3 text-center text-[var(--el-text-color-secondary)]">等待补传</span>
<span v-else-if="evidence.status === 'UNAVAILABLE'" class="p-3 text-center text-[var(--el-color-danger)]">图片永久缺失</span>
<span v-else-if="evidence.status === 'AVAILABLE' && failedEvidenceImages.has(evidence.id)" class="p-3 text-center text-[var(--el-color-danger)]">图片加载失败</span>
<span v-else-if="evidence.status === 'AVAILABLE'" class="p-3 text-center text-[var(--el-color-danger)]">图片地址缺失</span>
<span v-else class="p-3 text-center text-[var(--el-color-danger)]">证据状态异常</span>
</div>
<div class="flex min-w-0 flex-col gap-1.5 text-sm leading-normal text-[var(--el-text-color-secondary)]">
<div class="mb-0.5 font-semibold text-[var(--el-text-color-primary)]">{{ evidenceRoleLabel(evidence) }}</div>
<div>状态:{{ evidenceStatusLabel(evidence.status) }}</div>
<div>采集时间:{{ formatEvidenceTime(evidence.capturedAt) }}</div>
<div>相似度:{{ formatSimilarity(evidence.similarity) }}</div>
<div>设备:{{ globalData.detail.hardName || evidence.hardId || "--" }}</div>
<div v-if="evidence.failureReason" class="break-words text-[var(--el-color-danger)]">{{ evidence.failureReason }}</div>
</div>
</div>
</div>
</el-descriptions-item>
</el-descriptions>
<el-descriptions v-if="['7'].includes(globalData.detail.orderType)" :column="4" border title="订单信息">
<el-descriptions-item label="省: ">
@@ -158,6 +195,7 @@ import * as mapAddressApi from "../../../api/hard/hard-order-address.js"
import { displayPrice } from "/@/utils/formatMoney"
import { ElLoading } from "element-plus"
import { getAutoRefundTaskStatus } from "/@/constants/autoRefund"
import dayjs from "dayjs"
// 引入组件
const RefundRejectForm = defineAsyncComponent(() => import("./refund-reject.vue"))
@@ -181,6 +219,20 @@ const { query } = useRoute()
const refundRejectRef = ref()
const shipmentsRef = ref()
const loading = ref(false)
const failedEvidenceImages = ref(new Set<string | number>())
/** 人脸证据角色的前端兼容文案,服务端未返回名称时使用。 */
const evidenceRoleNames: Record<string, string> = {
FACE_REFERENCE: "后台参考人脸",
FACE_LIVE: "现场识别人脸",
}
/** 服务端三种证据状态的详情页文案。 */
const evidenceStatusNames: Record<string, string> = {
PENDING: "待补传",
AVAILABLE: "已保存",
UNAVAILABLE: "永久缺失",
}
const globalData = reactive<any>({
type: "",
detail: {},
@@ -201,6 +253,42 @@ const state: BasicTableProps = reactive<BasicTableProps>({
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
/** 订单详情中的人脸证据;菜谱识别预留数据不会误入本期展示。 */
const faceEvidence = computed<hardOrder.HardOrderEvidenceImage[]>(() =>
(globalData.detail?.evidenceImages ?? []).filter(
(item: hardOrder.HardOrderEvidenceImage) => item.evidenceType === "FACE_RECOGNITION",
),
)
/** 仅真实存在人脸证据位置时展示整块区域,普通订单保持原详情布局。 */
const hasFaceEvidence = computed(() => faceEvidence.value.length > 0)
/** 复用项目图片域名规则,绝对地址保持原值,相对地址补齐文件服务前缀。 */
const evidenceImageUrl = (url: string) => (url.includes("http") ? url : `${import.meta.env.VITE_IMAGE_BASE_PATH}${url}`)
/** 记录本页图片加载失败状态,避免再次渲染成貌似可用的空白图片。 */
const markEvidenceImageFailed = (id: string | number) => {
failedEvidenceImages.value = new Set(failedEvidenceImages.value).add(id)
}
/** 将服务端采集时间格式化为详情页统一的秒级时间,空值明确显示占位符。 */
const formatEvidenceTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm:ss") : "--")
/** 把 0 到 1 的相似度快照转为百分比,异常历史值不直接展示。 */
const formatSimilarity = (value?: number | string) => {
if (value === null || value === undefined || value === "") return "--"
const numeric = Number(value)
return Number.isFinite(numeric) ? `${(numeric * 100).toFixed(2)}%` : "--"
}
/** 优先使用服务端中文名称,未知历史角色回退稳定编码,避免误标。 */
const evidenceRoleLabel = (evidence: hardOrder.HardOrderEvidenceImage) =>
evidence.evidenceRoleName || evidenceRoleNames[evidence.evidenceRole] || evidence.evidenceRole || "识别图片"
/** 将三种服务端证据状态转换为详情页明确文案。 */
const evidenceStatusLabel = (status?: string) =>
evidenceStatusNames[status ?? ""] || status || "未知状态"
onMounted(async () => {
const type = query.type as unknown as any
if (type) {
@@ -232,6 +320,7 @@ const getDetail = async () => {
state.queryForm.orderId = id
const { data: orderData } = await hardOrder.getObj(id)
globalData.detail = orderData
failedEvidenceImages.value = new Set()
// 是否需要查询用户地址信息
if (['7'].includes(orderData.orderType)) {
getAddressDetail()
+21
View File
@@ -41,6 +41,16 @@
<dict-select v-model="state.queryForm.orderPayTypeCodes" dict-code="hard_pay_type"
multiple />
</el-form-item>
<el-form-item style="width: 450px" label="预约时间" prop="apptUseDateTimeList">
<el-date-picker v-model="state.queryForm.apptUseDateTimeList"
value-format="YYYY-MM-DD HH:mm:ss" type="daterange"
:default-time="dateRangeDefaultTime" />
</el-form-item>
<el-form-item style="width: 450px" label="核销时间" prop="verificationTimeList">
<el-date-picker v-model="state.queryForm.verificationTimeList"
value-format="YYYY-MM-DD HH:mm:ss" type="datetimerange"
:default-time="dateRangeDefaultTime" />
</el-form-item>
<el-form-item style="width: 450px" label="创建时间" prop="createTimeList">
<el-date-picker v-model="state.queryForm.createTimeList"
value-format="YYYY-MM-DD HH:mm:ss" type="datetimerange" />
@@ -133,6 +143,14 @@
<span>支付时间</span>
<span>{{ row.payTime }} </span>
</div>
<div v-if="row.apptUseDate" class="status">
<span>预约时间</span>
<span>{{ row.apptUseDate }}</span>
</div>
<div v-if="row.verificationTime" class="status">
<span>核销时间</span>
<span>{{ row.verificationTime }}</span>
</div>
</template>
</el-table-column>
<el-table-column width="200px" label="商品">
@@ -327,6 +345,9 @@ const tabList = ref<any>([{}])
const activeName = ref(null)
const draggableDepTreeRef = ref()
/** 日期范围默认覆盖开始日零点至结束日最后一秒,避免遗漏结束日期内的订单。 */
const dateRangeDefaultTime = [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
// 搜索变量
const queryRef = ref()
const showSearch = ref(true)
@@ -1,5 +1,5 @@
<template>
<div class="flex h-full min-h-0 w-full flex-col" v-loading="state.loading">
<div class="flex h-full min-h-0 w-full flex-col overflow-auto" v-loading="state.loading">
<div class="mb-4 flex items-center justify-between border-b border-br-light pb-4">
<h3 class="text-lg font-semibold text-[var(--el-text-color-primary)]">自动退款</h3>
<el-tooltip content="刷新自动退款配置" placement="top">
@@ -7,7 +7,7 @@
</el-tooltip>
</div>
<el-table :data="configRows" border :header-cell-style="tableHeaderStyle">
<el-table v-if="canConfigureAutoRefund" class="shrink-0" :data="configRows" border :header-cell-style="tableHeaderStyle">
<el-table-column label="订单类型" min-width="140">
<template #default>预约订单</template>
</el-table-column>
@@ -49,14 +49,97 @@
</template>
</el-table-column>
</el-table>
<div v-if="canConfigureAutoRefund" class="mb-3 mt-8 flex items-center justify-between border-b border-br-light pb-3">
<h4 class="text-base font-semibold text-[var(--el-text-color-primary)]">过期预约自动退款</h4>
<el-tooltip content="刷新过期预约退款配置和候选订单" placement="top">
<el-button icon="RefreshRight" circle aria-label="刷新过期预约退款" :disabled="!merchantId" @click="refreshExpiredSection" />
</el-tooltip>
</div>
<el-table v-if="canConfigureAutoRefund" class="shrink-0" :data="configRows" border :header-cell-style="tableHeaderStyle">
<el-table-column label="订单类型" min-width="140">
<template #default>预约订单</template>
</el-table-column>
<el-table-column label="自动退款" min-width="150">
<template #default="{ row }">
<el-switch
:model-value="row.expiredReserveRefundEnabled"
active-value="1"
inactive-value="0"
:loading="state.saving"
@change="handleExpiredToggle"
/>
</template>
</el-table-column>
<el-table-column label="结束后延迟" min-width="220">
<template #default="{ row }">
<el-input-number v-model="row.expiredReserveRefundDelayHours" :min="1" :max="168" :step="1" :precision="0" :disabled="state.saving" />
<span class="ml-2 text-[var(--el-text-color-secondary)]">小时</span>
</template>
</el-table-column>
<el-table-column label="退库存" min-width="150">
<template #default="{ row }">
<el-switch v-model="row.expiredReserveRefundRestoreInventory" active-value="1" inactive-value="0" :disabled="state.saving" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default>
<el-button type="primary" icon="Check" :loading="state.saving" @click="saveConfig">保存</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="canViewExpiredCandidates" class="mb-3 mt-8 flex items-center justify-between border-b border-br-light pb-3">
<h4 class="text-base font-semibold text-[var(--el-text-color-primary)]">待过期退款订单</h4>
<el-button type="primary" plain icon="RefreshRight" :loading="candidateState.loading" @click="loadCandidates">刷新列表</el-button>
</div>
<el-table v-if="canViewExpiredCandidates" class="shrink-0" :data="candidateState.records" border :header-cell-style="tableHeaderStyle" v-loading="candidateState.loading">
<el-table-column prop="orderId" label="订单号" min-width="170" show-overflow-tooltip />
<el-table-column label="用户" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.userName || row.userId || "-" }}</template>
</el-table-column>
<el-table-column prop="apptUseDate" label="预约日期" min-width="130" />
<el-table-column prop="hourType" label="餐段" min-width="100" />
<el-table-column prop="triggerTime" label="过期时间" min-width="180" />
<el-table-column label="退款金额" min-width="120" align="right">
<template #default="{ row }">{{ displayPrice(row.refundPrice || 0) }}</template>
</el-table-column>
<el-table-column label="任务状态" min-width="140">
<template #default="{ row }"><el-tag :type="getAutoRefundTaskStatus(row.status).type">{{ getAutoRefundTaskStatus(row.status).label }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button
type="danger"
link
:loading="candidateState.refundingId === String(row.id)"
:disabled="Boolean(candidateState.refundingId)"
v-auth="'hard_hardAfterSale_adminRefund'"
@click="handleExpiredRefund(row)"
>确认退款</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-if="canViewExpiredCandidates"
class="mt-3"
@size-change="handleCandidateSizeChange"
@current-change="handleCandidateCurrentChange"
v-bind="candidateState.pagination"
/>
</div>
</template>
<script setup lang="ts" name="MerchantAutoRefundConfig">
import { useMessage } from "/@/hooks/message"
import { useMessage, useMessageBox } from "/@/hooks/message"
import * as autoRefundConfigApi from "/@/api/wallet/mch-auto-refund-config"
import type { MchAutoRefundConfig } from "/@/api/wallet/mch-auto-refund-config"
import { AUTO_REFUND_RULE, AUTO_REFUND_RULE_OPTIONS } from "/@/constants/autoRefund"
import * as taskApi from "/@/api/hard/hard-auto-refund-task"
import { AUTO_REFUND_RULE, AUTO_REFUND_RULE_OPTIONS, getAutoRefundTaskStatus } from "/@/constants/autoRefund"
import { displayPrice } from "/@/utils/formatMoney"
import type { Pagination } from "/@/hooks/table"
import { useUserInfo } from "/@/stores/userInfo"
/** 商户自动退款配置组件输入。 */
interface Props {
@@ -72,16 +155,43 @@ interface AutoRefundConfigState {
saving: boolean
}
/** 过期预约候选订单查询、分页和逐单退款状态。 */
interface CandidateState {
/** 候选列表是否正在请求。 */
loading: boolean
/** 当前页候选订单。 */
records: any[]
/** 候选订单分页参数。 */
pagination: Pagination
/** 正在退款的任务 ID,防止重复点击。 */
refundingId: string
}
/** 商户详情页传入的当前商户。 */
const props = withDefaults(defineProps<Props>(), { mchId: null })
/** 规范化后的商户 ID,空值不发送请求。 */
const merchantId = computed(() => (props.mchId ? String(props.mchId) : ""))
/** 候选订单查询权限;配置权限和售后查看权限按需求独立控制。 */
const canViewExpiredCandidates = computed(() =>
useUserInfo().userInfos.authBtnList.includes("hard_hardAfterSale_view"),
)
/** 配置区域权限;无该权限时不调用 Wallet 配置接口,避免候选查询页收到 403。 */
const canConfigureAutoRefund = computed(() =>
useUserInfo().userInfos.authBtnList.includes("wallet_mchManage_setting"),
)
/** 当前唯一的预约订单配置行。 */
const config = reactive<MchAutoRefundConfig>(createDefaultConfig(""))
/** 表格需要的单行稳定数组。 */
const configRows = computed(() => [config])
/** 页面加载和保存状态。 */
const state = reactive<AutoRefundConfigState>({ loading: false, saving: false })
/** 过期预约候选订单独立状态,避免配置保存时误清空分页。 */
const candidateState = reactive<CandidateState>({
loading: false,
records: [],
pagination: { current: 1, size: 10, total: 0, pageSizes: [10, 20, 50], layout: "total, sizes, prev, pager, next, jumper" },
refundingId: "",
})
/** 沿用项目表格主题的表头样式。 */
const tableHeaderStyle = { background: "var(--el-fill-color-light)", color: "var(--el-text-color-primary)" }
@@ -97,6 +207,9 @@ function createDefaultConfig(mchId: string): MchAutoRefundConfig {
enabled: "0",
ruleType: AUTO_REFUND_RULE.PAY_TIME_LIMIT,
payTimeLimitHours: 24,
expiredReserveRefundEnabled: "0",
expiredReserveRefundDelayHours: 2,
expiredReserveRefundRestoreInventory: "1",
}
}
@@ -106,10 +219,19 @@ const loadConfig = async () => {
Object.assign(config, createDefaultConfig(""))
return
}
if (!canConfigureAutoRefund.value) {
if (canViewExpiredCandidates.value) {
await loadCandidates()
}
return
}
state.loading = true
try {
const { data } = await autoRefundConfigApi.getByMerchant(merchantId.value)
Object.assign(config, createDefaultConfig(merchantId.value), data || {})
if (canViewExpiredCandidates.value) {
await loadCandidates()
}
} catch (error: any) {
useMessage().error(error?.msg || error?.message || "读取自动退款配置失败")
} finally {
@@ -119,7 +241,7 @@ const loadConfig = async () => {
/** 保存当前完整配置行,周期规则提交时清除无意义的小时数。 */
const saveConfig = async () => {
if (!merchantId.value || state.saving) return
if (!canConfigureAutoRefund.value || !merchantId.value || state.saving) return
state.saving = true
try {
const payload: MchAutoRefundConfig = {
@@ -128,10 +250,16 @@ const saveConfig = async () => {
orderType: "2",
payTimeLimitHours:
config.ruleType === AUTO_REFUND_RULE.PAY_TIME_LIMIT ? config.payTimeLimitHours || 24 : null,
expiredReserveRefundEnabled: config.expiredReserveRefundEnabled === "1" ? "1" : "0",
expiredReserveRefundDelayHours: Math.min(168, Math.max(1, Number(config.expiredReserveRefundDelayHours) || 2)),
expiredReserveRefundRestoreInventory: config.expiredReserveRefundRestoreInventory === "0" ? "0" : "1",
}
const { data } = await autoRefundConfigApi.saveOrUpdate(payload)
Object.assign(config, payload, data || {})
useMessage().success("自动退款配置已保存")
if (canViewExpiredCandidates.value) {
await loadCandidates()
}
} catch (error: any) {
useMessage().error(error?.msg || error?.message || "保存自动退款配置失败")
await loadConfig()
@@ -150,11 +278,81 @@ const handleToggle = async (value: string | number | boolean) => {
await saveConfig()
}
/** 过期预约开关独立保存,保持既有用户申请免审退款开关语义不变。 */
const handleExpiredToggle = async (value: string | number | boolean) => {
config.expiredReserveRefundEnabled = String(value) === "1" ? "1" : "0"
await saveConfig()
}
/** 查询当前商户已到期且仍可整单退款的候选订单。 */
const loadCandidates = async () => {
if (!merchantId.value) {
candidateState.records = []
candidateState.pagination.total = 0
return
}
candidateState.loading = true
try {
const { data } = await taskApi.expiredCandidates({
current: candidateState.pagination.current,
size: candidateState.pagination.size,
mchId: merchantId.value,
})
candidateState.records = data?.records || []
candidateState.pagination.total = data?.total || 0
} catch (error: any) {
candidateState.records = []
candidateState.pagination.total = 0
useMessage().error(error?.msg || error?.message || "读取待过期退款订单失败")
} finally {
candidateState.loading = false
}
}
/** 刷新配置和候选列表,供顶部和分区刷新按钮共用。 */
const refreshExpiredSection = async () => {
await loadConfig()
}
/** 候选订单分页大小变化后回到第一页。 */
const handleCandidateSizeChange = (size: number) => {
candidateState.pagination.size = size
candidateState.pagination.current = 1
void loadCandidates()
}
/** 候选订单切页。 */
const handleCandidateCurrentChange = (current: number) => {
candidateState.pagination.current = current
void loadCandidates()
}
/** 二次确认后执行后台逐单业务退款,成功后仅刷新候选列表。 */
const handleExpiredRefund = async (row: any) => {
if (candidateState.refundingId) return
try {
await useMessageBox().confirm("确认对该预约订单执行整单退款?退款后不可撤销。")
} catch {
return
}
candidateState.refundingId = String(row.id)
try {
await taskApi.expiredRefund(candidateState.refundingId)
useMessage().success("订单退款已提交")
await loadCandidates()
} catch (error: any) {
useMessage().error(error?.msg || error?.message || "订单退款失败")
} finally {
candidateState.refundingId = ""
}
}
/** 商户切换时清理上一商户数据并读取其独立配置。 */
watch(
merchantId,
(value) => {
Object.assign(config, createDefaultConfig(value))
candidateState.pagination.current = 1
void loadConfig()
},
{ immediate: true },
+7 -1
View File
@@ -95,7 +95,7 @@
<el-tabs v-model="activeTab" class="demo-tabs">
<el-tab-pane label="支付相关" name="PAY_SETTING" />
<el-tab-pane label="商户功能" name="TAKE_OUT_SETTING" />
<el-tab-pane v-if="canConfigureAutoRefund" label="自动退款" name="AUTO_REFUND_SETTING" />
<el-tab-pane v-if="canAccessAutoRefund" label="自动退款" name="AUTO_REFUND_SETTING" />
<el-tab-pane label="App推送" name="APP_PUSH_SETTING" />
<el-tab-pane label="分店" name="FD_SETTING" />
</el-tabs>
@@ -128,6 +128,12 @@ const activeTab = ref("PAY_SETTING")
const canConfigureAutoRefund = computed(() =>
useUserInfo().userInfos.authBtnList.includes("wallet_mchManage_setting"),
)
/** 过期预约候选列表复用售后查看权限,允许无配置权限的业务人员进入列表。 */
const canViewExpiredRefundCandidates = computed(() =>
useUserInfo().userInfos.authBtnList.includes("hard_hardAfterSale_view"),
)
/** 自动退款页入口权限由配置权限和候选查询权限共同决定。 */
const canAccessAutoRefund = computed(() => canConfigureAutoRefund.value || canViewExpiredRefundCandidates.value)
const takeOutSettingRef = ref()
const paySettingRef = ref()