Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
8a8d101379
23 files changed
+1464
-96
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询一次自动退款任务的只读尝试日志。
|
||||
*
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 离线订单手动支付
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
@@ -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: "",
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
/**
|
||||
* 获取尝试结果展示配置,未知状态保留原编码便于技术排查。
|
||||
*
|
||||
|
||||
@@ -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 实例,也不要把身份查询改为直接访问数据库或新增数据库字段。
|
||||
- 修改日期、部门或身份查询逻辑后,至少静态核对列表、统计、导出三条请求都携带同一查询快照;完整构建和测试按项目集中验证流程执行。
|
||||
+141
@@ -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>
|
||||
+99
@@ -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 }">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user