feat:订单列表右侧drawer统计
This commit is contained in:
1 parent
1e78cc6d12
commit
ef0055cb51
6 files changed
+466
-72
No files matched your search
@@ -0,0 +1,64 @@
|
||||
# `biz-pc-cloud-zax` 开发规则
|
||||
|
||||
> 适用于后台管理端项目。执行任务前必须先完整阅读 [HQ Platform 通用规则](../agents/common.md),并继续检查目标目录内更具体的说明和现有同类实现。
|
||||
|
||||
## 技术栈基线
|
||||
|
||||
以下版本是扫描当前项目得到的基线,实际开发始终以该项目的 `package.json`、锁文件和配置文件为准,不得为了匹配本文数字擅自升级或降级依赖。
|
||||
|
||||
- 运行框架:Vue `3.4.15`、TypeScript `4.9.5`、Vite `4.3.3`。
|
||||
- UI 框架:Element Plus `2.5.5` 与 `@element-plus/icons-vue`。项目历史文案中的 Element UI,在本项目内应理解为 Element Plus,不得引入 Vue 2 的 Element UI。
|
||||
- 样式体系:Tailwind CSS `3.4.6`、PostCSS、Autoprefixer、Sass;Tailwind 同时加载 DaisyUI `4.11.1`,但新页面仍以当前页面的 Element Plus 风格为主,只有相邻代码已经使用 DaisyUI 时才继续使用。
|
||||
- 状态与路由:Pinia `2.0.32`、`pinia-plugin-persist`、Vue Router `4.1.6`。路由采用 Hash 模式,并支持后端返回动态菜单、权限路由、标签页和 KeepAlive。
|
||||
- 网络层:Axios `1.3.3`,统一入口为 `src/utils/request.ts`,包含 Base URL、Bearer Token、`TENANT-ID`、请求/响应加解密、微服务/单体 URL 适配和令牌过期处理。
|
||||
- 国际化:Vue I18n `9.2.2`,全局入口为 `src/i18n`,已有公共语言包和页面级语言包。
|
||||
- 表格体系:Element Plus Table 与 VXE Table / VXE PC UI 并存,VXE 通过 Element 渲染插件接入。选用哪一种必须跟随同业务模块已有页面,不得在同一功能中无理由混用。
|
||||
- 图形与树:`@vue-flow/core`、Background、Controls、MiniMap 已用于用户关系图;组织架构还使用 `vue3-tree-org`,并有 `DraggableDepTree`、`TreeSelect`、`TreeMultipleSelect` 等本地组件。
|
||||
- 可视化与编辑:ECharts / Vue ECharts、WangEditor、CodeMirror、JSON Editor、表单设计器 `form-designer-plus`、拖拽库 SortableJS / VueDraggable。
|
||||
- 其他已有能力:腾讯地图组件、二维码、打印、日历、视频播放、图片/文件上传、WebSocket、SSE、富文本、签名、区域选择、字典组件等。开发前先在 `src/components`、`src/hooks`、`src/utils` 中搜索复用。
|
||||
- 工程能力:Vite 自动导入 Vue/Router/Pinia API、SVG 图标构建、VXE 样式按需加载、Top-level Await、生产压缩和 Gzip 产物。
|
||||
|
||||
## 关键目录与入口
|
||||
|
||||
- `src/main.ts`:应用启动入口,注册 Pinia、Router、全局组件、指令、国际化、VXE、腾讯地图及全局主题。
|
||||
- `src/views`:业务页面,按 `admin`、`canteen`、`flow`、`hard`、`integrated`、`supermarket`、`wallet` 等领域拆分。
|
||||
- `src/api`:前端接口层,目录与后端服务领域基本对应。页面不得绕过该层零散创建 Axios 实例。
|
||||
- `src/components`:全局与业务公共组件;其中分页、右侧工具栏、字典、上传、选择器、树和地图组件已在全局注册。
|
||||
- `src/router`:静态路由、后端动态路由、权限守卫与 KeepAlive 处理。
|
||||
- `src/stores`:Pinia 状态,包括用户、路由、标签页、主题和缓存页面等。
|
||||
- `src/theme` 与 `src/assets/styles`:主题、响应式样式和全局样式;`src/theme/tailwind.css` 是 Tailwind 注入入口。
|
||||
- `tailwind.config.js`:Tailwind 内容扫描、间距、字号、阴影和颜色映射。颜色大量映射到 Element Plus CSS 变量,深色模式采用 `class`。
|
||||
- `vite.config.ts`:`/@` 指向 `src`;配置开发/预览代理、WebSocket 代理、环境变量、压缩和构建分包。
|
||||
|
||||
## 页面与样式约定
|
||||
|
||||
- 修改页面前先查看同业务目录及相邻页面,复用已有的页面容器、查询表单、表格、分页、弹窗、权限指令、API 层和反馈方式。
|
||||
- 常规布局和视觉样式优先使用 Tailwind 原子类,并遵循 `tailwind.config.js` 中已有的主题映射;颜色优先使用 `primary`、边框/填充映射或 `var(--el-*)`,避免硬编码导致主题和暗黑模式失效。
|
||||
- Element Plus 组件应遵循项目现有尺寸、表单标签宽度、按钮顺序、弹窗结构和消息提示方式,不得为单个页面另造一套视觉语言。
|
||||
- 表格页面先判断相邻实现使用 `el-table` 还是 `vxe-table`;查询、分页、选择、导出、权限按钮等行为保持一致。
|
||||
- `@vue-flow` 的当前参考实现为 `src/views/admin/system/user/relation-tree.vue`,已有自定义节点、边、缩放适配、背景、控件和缩略图。流程图、关系图和节点连线树优先复用该实现及现有类型,不重复引入图形库。
|
||||
- 普通组织树、部门树或下拉树应先评估现有 `vue3-tree-org` 和本地 Tree 组件;不要因为项目安装了 `@vue-flow` 就把所有树强行改成流程画布。
|
||||
- 图标优先使用 `@element-plus/icons-vue` 或现有 `SvgIcon` 图标体系,不手写重复 SVG。
|
||||
- 新增或修改页面时检查常见分辨率、文本溢出、表格横向滚动、弹窗高度、加载/空状态、主题切换和深色模式。
|
||||
|
||||
## 数据与权限约定
|
||||
|
||||
- 统一使用 `src/utils/request.ts`;不得重复实现 Token、租户头、加解密和错误状态处理。
|
||||
- API 文件按现有领域目录放置,并维持现有的请求函数风格、参数序列化和 TypeScript 类型习惯。
|
||||
- 菜单通常由后端动态返回。新增页面时必须核对路由名称、组件路径、菜单配置、权限标识和 KeepAlive 名称之间的对应关系。
|
||||
- 权限按钮、字典展示、租户信息和用户选择等优先复用已有指令、Store 与全局组件。
|
||||
|
||||
## 常用命令与验证
|
||||
|
||||
- 本地开发:`npm run dev` 或项目当前包管理器对应的 `dev` 脚本。
|
||||
- 本地环境:`npm run dev:local`。
|
||||
- 代码检查:`npm run lint:eslint`。该脚本带自动修复,只能在确认影响范围后运行,避免改动无关文件。
|
||||
- 构建:按目标环境使用 `npm run build`、`npm run build:dev` 或 `npm run build:prod`。
|
||||
- 项目同时存在不同锁文件痕迹;修改依赖前必须确认当前团队实际使用的包管理器,不得一次操作同时重写多份锁文件。
|
||||
|
||||
## 开发规则
|
||||
|
||||
- 开发前必须依次检查目标业务页面、对应 API、可复用组件、主题配置和权限路由。
|
||||
- 不得在页面内创建独立的 Axios 客户端、全局样式覆盖或重复的基础组件。
|
||||
- 修改公共请求、路由、Store、主题或全局组件时,必须扩大验证范围,因为这些改动会影响多个业务模块。
|
||||
- 默认只做必要的静态检查或针对性核对;收到用户明确的集中验证指令后,再执行 lint 和构建并确认受影响模块能够通过现有流程。
|
||||
@@ -47,6 +47,58 @@ export interface HardOrderDetail {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 订单列表统计查询条件;字段与订单分页筛选保持一致。 */
|
||||
export type HardOrderStatisticsQuery = Record<string, any>
|
||||
|
||||
/** 订单按餐段汇总的统计,金额单位均为分。 */
|
||||
export interface HardOrderMealStatistics {
|
||||
/** 餐段字典编码;历史订单未记录时为空。 */
|
||||
hourType?: string | null
|
||||
/** 订单数。 */
|
||||
orderCount?: number
|
||||
/** 购物车商品份数。 */
|
||||
productCount?: number
|
||||
/** 商品总价。 */
|
||||
totalPrice?: number
|
||||
/** 额外支付。 */
|
||||
extraPrice?: number
|
||||
/** 配送费。 */
|
||||
deliveryPrice?: number
|
||||
/** 实际支付。 */
|
||||
payPrice?: number
|
||||
/** 已退款金额。 */
|
||||
refundPrice?: number
|
||||
/** 订单实际金额,即实际支付减已退款。 */
|
||||
actualPrice?: number
|
||||
}
|
||||
|
||||
/** 订单统计通用汇总,金额单位均为分。 */
|
||||
export interface HardOrderStatisticsSummary {
|
||||
orderCount?: number
|
||||
productCount?: number
|
||||
totalPrice?: number
|
||||
extraPrice?: number
|
||||
deliveryPrice?: number
|
||||
payPrice?: number
|
||||
refundPrice?: number
|
||||
actualPrice?: number
|
||||
meals?: HardOrderMealStatistics[]
|
||||
}
|
||||
|
||||
/** 订单按预计使用日期拆分的统计。 */
|
||||
export interface HardOrderDailyStatistics extends HardOrderStatisticsSummary {
|
||||
date?: string
|
||||
}
|
||||
|
||||
/** 订单统计接口统一响应。 */
|
||||
export interface HardOrderStatisticsResponse {
|
||||
data: {
|
||||
total?: HardOrderStatisticsSummary
|
||||
daily?: HardOrderDailyStatistics[]
|
||||
}
|
||||
msg?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 离线订单手动支付
|
||||
*/
|
||||
@@ -89,6 +141,20 @@ export function pageJoin(query: any) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单页面当前完整筛选条件统计全部命中订单,不受分页大小影响。
|
||||
*
|
||||
* @param query 订单列表筛选条件
|
||||
* @returns 范围总计及按日期、餐段拆分的统计
|
||||
*/
|
||||
export function getStatistics(query: HardOrderStatisticsQuery) {
|
||||
return request<unknown, HardOrderStatisticsResponse>({
|
||||
url: "/hard/hard-order/statistics",
|
||||
method: "post",
|
||||
data: query,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单页面当前筛选条件提交异步分批导出任务。
|
||||
*
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
class="batch-refund-drawer"
|
||||
title="批量退款确认"
|
||||
size="min(1080px, 94vw)"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!submitting"
|
||||
:before-close="handleBeforeClose"
|
||||
destroy-on-close
|
||||
@closed="resetState"
|
||||
>
|
||||
<el-drawer v-model="visible" class="batch-refund-drawer" title="批量退款确认" size="min(1080px, 94vw)"
|
||||
:close-on-click-modal="false" :close-on-press-escape="!submitting" :before-close="handleBeforeClose"
|
||||
destroy-on-close @closed="resetState">
|
||||
<div v-loading="loading" class="text-sm">
|
||||
<el-alert
|
||||
class="mb-2"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="将按每笔订单当前剩余可退商品执行整单退款,实际结果以支付渠道处理为准。"
|
||||
/>
|
||||
<el-alert class="mb-3 shrink-0" type="warning" :closable="false" show-icon
|
||||
title="将按每笔订单当前剩余可退商品执行整单退款,实际结果以支付渠道处理为准。" />
|
||||
|
||||
<el-result v-if="!loading && errorMessage" icon="error" title="退款预览加载失败" :sub-title="errorMessage">
|
||||
<template #extra>
|
||||
@@ -27,8 +14,7 @@
|
||||
|
||||
<template v-else-if="preview">
|
||||
<div
|
||||
class="mb-2 grid grid-cols-4 gap-px overflow-hidden rounded border border-[var(--el-border-color-lighter)] bg-[var(--el-border-color-lighter)]"
|
||||
>
|
||||
class="mb-2 grid grid-cols-4 gap-px overflow-hidden rounded border border-[var(--el-border-color-lighter)] bg-[var(--el-border-color-lighter)]">
|
||||
<div class="min-w-0 bg-[var(--el-bg-color)] px-3 py-2">
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">可退款订单</div>
|
||||
<div class="mt-0.5 font-semibold tabular-nums text-[var(--el-color-primary)]">
|
||||
@@ -43,7 +29,8 @@
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">菜品统计</div>
|
||||
<div class="mt-0.5 font-semibold tabular-nums">
|
||||
{{ productSummaryRows.length }} 种 / {{ productSummaryProductCount }} 份
|
||||
<span v-if="productSummaryProductWeight > 0"> / {{ displayWeight(productSummaryProductWeight) }} kg </span>
|
||||
<span v-if="productSummaryProductWeight > 0"> / {{
|
||||
displayWeight(productSummaryProductWeight) }} kg </span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 bg-[var(--el-bg-color)] px-3 py-2">
|
||||
@@ -54,14 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="preview.summary.unavailableOrderCount"
|
||||
class="mb-2"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="`${preview.summary.unavailableOrderCount} 笔订单当前不可退款,任务会自动跳过并写入结果文件。`"
|
||||
/>
|
||||
<el-alert v-if="preview.summary.unavailableOrderCount" class="mb-2" type="error" :closable="false"
|
||||
show-icon :title="`${preview.summary.unavailableOrderCount} 笔订单当前不可退款,任务会自动跳过并写入结果文件。`" />
|
||||
|
||||
<section class="mb-3">
|
||||
<div class="mb-1.5 flex min-h-6 items-center justify-between gap-3">
|
||||
@@ -73,15 +54,9 @@
|
||||
{{ productSummaryRows.length }} 种菜品
|
||||
</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="productSummaryRows"
|
||||
border
|
||||
size="small"
|
||||
:height="176"
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
empty-text="暂无可退款菜品"
|
||||
>
|
||||
<el-table :data="productSummaryRows" border size="small" :height="176"
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
empty-text="暂无可退款菜品">
|
||||
<el-table-column prop="productName" label="菜品名称" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="productCount" label="可退数量" width="160" align="right">
|
||||
<template #default="{ row }">
|
||||
@@ -95,14 +70,15 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div
|
||||
class="grid min-h-8 grid-cols-[minmax(0,1fr)_160px_160px] items-center border border-t-0 border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
|
||||
>
|
||||
class="grid min-h-8 grid-cols-[minmax(0,1fr)_160px_160px] items-center border border-t-0 border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]">
|
||||
<span class="min-w-0 px-3 font-medium text-[var(--el-text-color-primary)]">
|
||||
合计 <span class="font-normal text-[var(--el-text-color-secondary)]">({{ productSummaryRows.length }} 种)</span>
|
||||
合计 <span class="font-normal text-[var(--el-text-color-secondary)]">({{
|
||||
productSummaryRows.length }} 种)</span>
|
||||
</span>
|
||||
<span class="px-3 text-right font-semibold tabular-nums text-[var(--el-text-color-primary)]">
|
||||
{{ productSummaryProductCount }} 份
|
||||
<span v-if="productSummaryProductWeight > 0"> / {{ displayWeight(productSummaryProductWeight) }} kg </span>
|
||||
<span v-if="productSummaryProductWeight > 0"> / {{
|
||||
displayWeight(productSummaryProductWeight) }} kg </span>
|
||||
</span>
|
||||
<span class="px-3 text-right font-semibold tabular-nums text-[var(--el-text-color-primary)]">
|
||||
{{ displayPrice(productSummaryRefundPrice) }}
|
||||
@@ -117,15 +93,9 @@
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-table
|
||||
:data="sortedPreviewOrders"
|
||||
border
|
||||
size="small"
|
||||
:height="280"
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
row-key="orderId"
|
||||
>
|
||||
<el-table :data="sortedPreviewOrders" border size="small" :height="280"
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
row-key="orderId">
|
||||
<el-table-column label="日期" prop="businessDate" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.businessDate || "-" }}</template>
|
||||
</el-table-column>
|
||||
@@ -161,12 +131,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余可退菜品" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.products?.length" class="grid grid-cols-2 gap-x-3 gap-y-1 py-0.5 text-left">
|
||||
<div
|
||||
v-for="(product, productIndex) in row.products"
|
||||
<div v-if="row.products?.length"
|
||||
class="grid grid-cols-2 gap-x-3 gap-y-1 py-0.5 text-left">
|
||||
<div v-for="(product, productIndex) in row.products"
|
||||
:key="`${row.orderId}-${product.productId || product.productName}-${productIndex}`"
|
||||
class="flex min-w-0 items-center justify-between gap-2"
|
||||
>
|
||||
class="flex min-w-0 items-center justify-between gap-2">
|
||||
<el-tooltip :content="product.productName || '未命名商品'" placement="top-start">
|
||||
<span class="min-w-0 truncate">{{ product.productName || "未命名商品" }}</span>
|
||||
</el-tooltip>
|
||||
@@ -196,7 +165,8 @@
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="76px" size="small" class="mt-3">
|
||||
<el-form-item label="退款备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="200" show-word-limit placeholder="请输入本批次退款备注" />
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="200" show-word-limit
|
||||
placeholder="请输入本批次退款备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
@@ -205,14 +175,8 @@
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<el-button :disabled="submitting" @click="close">取消</el-button>
|
||||
<el-button
|
||||
v-auth="'hard_hardAfterSale_adminRefund'"
|
||||
type="danger"
|
||||
:icon="Money"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
>
|
||||
<el-button v-auth="'hard_hardAfterSale_adminRefund'" type="danger" :icon="Money" :loading="submitting"
|
||||
:disabled="!canSubmit" @click="submit">
|
||||
确认退款 {{ refundableOrderCount }} 笔
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" class="order-statistics-drawer" title="订单统计" size="min(1200px, 96vw)" destroy-on-close
|
||||
@closed="resetState">
|
||||
<div v-loading="loading" class="flex h-full min-h-0 flex-col text-base">
|
||||
<el-alert class="mb-3 shrink-0" type="info" :closable="false" show-icon
|
||||
title="统计范围与订单列表当前筛选条件完全一致,不受当前页和每页条数影响;日期优先取预计使用日期,历史空日期回退到创建日期;商品份数取原订单购物车份数,订单实际为实际支付减已退款。" />
|
||||
<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="expandedDate" accordion>
|
||||
<el-collapse-item v-for="item in statistics.daily" :key="item.date || 'unknown'"
|
||||
:name="item.date || 'unknown'">
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-tag type="info" effect="plain">{{ item.orderCount ?? 0 }} 单</el-tag>
|
||||
<el-tag type="success" effect="plain">{{ displayPrice(item.actualPrice ?? 0)
|
||||
}}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<order-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">
|
||||
<order-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="order-statistics-drawer">
|
||||
import { Refresh } from "@element-plus/icons-vue"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import * as hardOrderApi from "/@/api/hard/hard-order"
|
||||
import OrderStatisticsSummary from "./order-statistics-summary.vue"
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const activeTab = ref("daily")
|
||||
const expandedDate = ref("")
|
||||
/** 打开抽屉时冻结的列表查询快照,刷新不会混入页面后续尚未查询的输入。 */
|
||||
const query = ref<hardOrderApi.HardOrderStatisticsQuery>()
|
||||
const statistics = reactive<{
|
||||
total: hardOrderApi.HardOrderStatisticsSummary
|
||||
daily: hardOrderApi.HardOrderDailyStatistics[]
|
||||
}>({ total: {}, daily: [] })
|
||||
const { daily_menu } = useDict("daily_menu")
|
||||
const message = useMessage()
|
||||
/** 请求序号用于忽略关闭或重新打开抽屉后迟到的旧响应。 */
|
||||
let requestSequence = 0
|
||||
|
||||
/** 有餐段多选时只展示选中项,否则按完整字典补齐零值行。 */
|
||||
const mealOptions = computed(() => {
|
||||
const configured = (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 selectedCodes = (query.value?.hourTypeCodes || []).map(String)
|
||||
if (!selectedCodes.length) return configured
|
||||
return selectedCodes.map(
|
||||
(code: string) => configured.find((item) => item.value === code) || { label: code, value: code },
|
||||
)
|
||||
})
|
||||
|
||||
const hasStatistics = computed(() => Boolean(statistics.total.orderCount || statistics.daily.length))
|
||||
|
||||
/** 打开抽屉并按当前列表筛选条件加载一次独立统计快照。 */
|
||||
function open(params: hardOrderApi.HardOrderStatisticsQuery) {
|
||||
requestSequence += 1
|
||||
query.value = cloneQuery(params)
|
||||
statistics.total = {}
|
||||
statistics.daily = []
|
||||
expandedDate.value = ""
|
||||
activeTab.value = "daily"
|
||||
visible.value = true
|
||||
reload()
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (!query.value) return
|
||||
const currentSequence = ++requestSequence
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await hardOrderApi.getStatistics(query.value)
|
||||
if (currentSequence !== requestSequence) return
|
||||
statistics.total = response.data?.total || {}
|
||||
statistics.daily = response.data?.daily || []
|
||||
expandedDate.value = statistics.daily[0]?.date || ""
|
||||
} catch (error: any) {
|
||||
if (currentSequence !== requestSequence) return
|
||||
statistics.total = {}
|
||||
statistics.daily = []
|
||||
expandedDate.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 = []
|
||||
expandedDate.value = ""
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
/** 当前筛选对象只包含可序列化表单值,JSON 快照可完整隔离数组和身份子对象。 */
|
||||
function cloneQuery(params: hardOrderApi.HardOrderStatisticsQuery): hardOrderApi.HardOrderStatisticsQuery {
|
||||
return JSON.parse(JSON.stringify(params || {}))
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(.order-statistics-drawer) {
|
||||
--el-font-size-base: var(--el-font-size-medium);
|
||||
--el-font-size-small: var(--el-font-size-base);
|
||||
}
|
||||
|
||||
:global(.order-statistics-drawer .el-drawer__title) {
|
||||
font-size: var(--el-font-size-large);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.order-statistics-drawer .el-alert) {
|
||||
--el-alert-title-font-size: var(--el-font-size-base);
|
||||
}
|
||||
|
||||
:global(.order-statistics-drawer .el-tabs__item),
|
||||
:global(.order-statistics-drawer .el-collapse-item__header) {
|
||||
font-size: var(--el-font-size-base);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<el-descriptions :column="4" border>
|
||||
<el-descriptions-item label="订单数">{{ summary.orderCount ?? 0 }} 单</el-descriptions-item>
|
||||
<el-descriptions-item label="商品份数">{{ summary.productCount ?? 0 }} 份</el-descriptions-item>
|
||||
<el-descriptions-item label="商品总价">
|
||||
<span class="statistics-number">{{ displayPrice(summary.totalPrice ?? 0) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="额外支付">
|
||||
<span class="statistics-number">{{ displayPrice(summary.extraPrice ?? 0) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="配送费">
|
||||
<span class="statistics-number">{{ displayPrice(summary.deliveryPrice ?? 0) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="实际支付">
|
||||
<span class="statistics-number text-primary">{{ displayPrice(summary.payPrice ?? 0) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已退款">
|
||||
<span class="statistics-number text-[var(--el-color-danger)]">{{ displayPrice(summary.refundPrice ?? 0) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单实际">
|
||||
<span class="statistics-number font-semibold text-[var(--el-color-success)]">
|
||||
{{ displayPrice(summary.actualPrice ?? 0) }}
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="border-l-2 border-primary pl-2 text-base font-medium text-[var(--el-text-color-primary)]">餐段统计</div>
|
||||
<el-table :data="mealRows" border empty-text="暂无餐段统计">
|
||||
<el-table-column prop="label" label="餐段" min-width="120" fixed="left" />
|
||||
<el-table-column prop="orderCount" label="订单数" width="90" align="center" />
|
||||
<el-table-column prop="productCount" label="商品份数" width="100" align="center" />
|
||||
<el-table-column label="商品总价" width="120" align="right">
|
||||
<template #default="{ row }"><span class="statistics-number">{{ displayPrice(row.totalPrice ?? 0) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="额外支付" width="120" align="right">
|
||||
<template #default="{ row }"><span class="statistics-number">{{ displayPrice(row.extraPrice ?? 0) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配送费" width="110" align="right">
|
||||
<template #default="{ row }"><span class="statistics-number">{{ displayPrice(row.deliveryPrice ?? 0) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实际支付" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="statistics-number text-primary">{{ displayPrice(row.payPrice ?? 0) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已退款" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="statistics-number text-[var(--el-color-danger)]">{{ displayPrice(row.refundPrice ?? 0) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单实际" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="statistics-number font-medium text-[var(--el-color-success)]">
|
||||
{{ displayPrice(row.actualPrice ?? 0) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="order-statistics-summary">
|
||||
import type { PropType } from "vue"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import type { HardOrderMealStatistics, HardOrderStatisticsSummary } from "/@/api/hard/hard-order"
|
||||
|
||||
/** 餐段字典选项,由统计抽屉按当前字典和页面筛选条件提供。 */
|
||||
interface MealOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
summary: {
|
||||
type: Object as PropType<HardOrderStatisticsSummary>,
|
||||
required: true,
|
||||
},
|
||||
mealOptions: {
|
||||
type: Array as PropType<MealOption[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 按当前餐段字典顺序展示,并补充无订单的零值餐段;历史未知餐段排在字典餐段之后。
|
||||
*/
|
||||
const mealRows = computed(() => {
|
||||
const actualMap = new Map((props.summary.meals || []).map((item) => [normalizeMealCode(item.hourType), item]))
|
||||
const rows = props.mealOptions.map((option) => ({
|
||||
label: option.label,
|
||||
hourType: option.value,
|
||||
orderCount: 0,
|
||||
productCount: 0,
|
||||
totalPrice: 0,
|
||||
extraPrice: 0,
|
||||
deliveryPrice: 0,
|
||||
payPrice: 0,
|
||||
refundPrice: 0,
|
||||
actualPrice: 0,
|
||||
...actualMap.get(option.value),
|
||||
}))
|
||||
const configuredCodes = new Set(props.mealOptions.map((item) => item.value))
|
||||
;(props.summary.meals || []).forEach((item: HardOrderMealStatistics) => {
|
||||
const code = normalizeMealCode(item.hourType)
|
||||
if (configuredCodes.has(code)) return
|
||||
rows.push({
|
||||
label: code || "未记录餐段",
|
||||
hourType: code,
|
||||
orderCount: item.orderCount ?? 0,
|
||||
productCount: item.productCount ?? 0,
|
||||
totalPrice: item.totalPrice ?? 0,
|
||||
extraPrice: item.extraPrice ?? 0,
|
||||
deliveryPrice: item.deliveryPrice ?? 0,
|
||||
payPrice: item.payPrice ?? 0,
|
||||
refundPrice: item.refundPrice ?? 0,
|
||||
actualPrice: item.actualPrice ?? 0,
|
||||
})
|
||||
})
|
||||
return rows
|
||||
})
|
||||
|
||||
function normalizeMealCode(value?: string | null): string {
|
||||
return value === null || value === undefined ? "" : String(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistics-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -78,7 +78,11 @@
|
||||
</el-tabs>
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="'hard_hardwareOrder_export'"
|
||||
@exportExcel="exportExcel" class="ml10" style="float: right; margin-right: 20px"
|
||||
@queryTable="getDataList" />
|
||||
@queryTable="getDataList">
|
||||
<el-tooltip effect="dark" content="统计" placement="top">
|
||||
<el-button icon="DataLine" circle aria-label="打开订单统计" @click="openStatisticsDrawer" />
|
||||
</el-tooltip>
|
||||
</right-toolbar>
|
||||
</div>
|
||||
<div v-auth="'hard_hardAfterSale_adminRefund'"
|
||||
class="mb-3 flex min-h-[40px] flex-wrap items-center justify-between gap-3 border border-[var(--el-border-color-light)] bg-[var(--el-fill-color-light)] px-3 py-2">
|
||||
@@ -90,20 +94,21 @@
|
||||
<span class="text-[var(--el-text-color-secondary)]">仅可勾选符合现有退款入口基础条件的订单</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-button :disabled="selectedOrderCount === 0 || batchRefundRunning" @click="clearBatchSelection">
|
||||
<el-button :disabled="selectedOrderCount === 0 || batchRefundRunning"
|
||||
@click="clearBatchSelection">
|
||||
清空选择
|
||||
</el-button>
|
||||
<el-button type="danger" icon="Money" :disabled="selectedOrderCount === 0 || batchRefundRunning"
|
||||
<el-button type="danger" icon="Money"
|
||||
:disabled="selectedOrderCount === 0 || batchRefundRunning"
|
||||
@click="openBatchRefundDrawer">
|
||||
{{ batchRefundRunning ? "退款处理中" : `批量退款(${selectedOrderCount})` }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table ref="orderTableRef" :data="state.dataList" v-loading="state.loading" border
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle" :show-summary="false" :summary-method="getSummaries"
|
||||
row-key="id">
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="selectionChangHandle" @sort-change="sortChangeHandle" :show-summary="false"
|
||||
:summary-method="getSummaries" row-key="id">
|
||||
<el-table-column type="selection" reserve-selection :selectable="isBatchRefundRowSelectable"
|
||||
width="44" fixed="left" align="center" />
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
@@ -348,6 +353,7 @@
|
||||
<admin-refund-dialog ref="adminRefundDailogRef" @refresh="handleSingleRefundRefresh" />
|
||||
<shop-car ref="shopCarDialogRef" />
|
||||
<batch-refund-drawer ref="batchRefundDrawerRef" @submitted="handleBatchRefundSubmitted" />
|
||||
<order-statistics-drawer ref="statisticsDrawerRef" />
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
|
||||
@failure="handleExportFailure" />
|
||||
<async-task-progress-component ref="batchRefundProgressRef" :config="batchRefundTaskConfig"
|
||||
@@ -379,6 +385,8 @@ const ShopCar = defineAsyncComponent(() => import("../components/shop-car.vue"))
|
||||
const TransactionList = defineAsyncComponent(() => import("/@/views/wallet/components/transaction-list.vue"))
|
||||
/** 批量退款确认抽屉异步组件,只有订单页实际渲染时才加载。 */
|
||||
const BatchRefundDrawer = defineAsyncComponent(() => import("./components/batch-refund-drawer.vue"))
|
||||
/** 订单统计抽屉异步组件,沿用预约统计页的按日和范围总计布局。 */
|
||||
const OrderStatisticsDrawer = defineAsyncComponent(() => import("./components/order-statistics-drawer.vue"))
|
||||
// 定义查询字典
|
||||
const { hard_pay_type, hard_order_type, hard_order_status, hard_type, hard_refund_status, daily_menu, wallet_type } = useDict(
|
||||
"hard_pay_type",
|
||||
@@ -427,10 +435,18 @@ interface BatchRefundDrawerExpose {
|
||||
open(orderIds: Array<string | number>): void
|
||||
}
|
||||
|
||||
/** 订单统计抽屉向父页面公开的最小操作契约。 */
|
||||
interface OrderStatisticsDrawerExpose {
|
||||
/** 按订单列表当前完整筛选条件打开统计抽屉。 */
|
||||
open(query: hardOrder.HardOrderStatisticsQuery): void
|
||||
}
|
||||
|
||||
/** Element Plus 订单表格引用,用于主动同步超限行和清空跨页保留选择。 */
|
||||
const orderTableRef = ref()
|
||||
/** 批量退款确认抽屉引用,用于冻结当前跨页订单 ID 并加载服务端预览。 */
|
||||
const batchRefundDrawerRef = ref<BatchRefundDrawerExpose>()
|
||||
/** 订单统计抽屉引用,用于冻结当前筛选条件并请求全部匹配结果。 */
|
||||
const statisticsDrawerRef = ref<OrderStatisticsDrawerExpose>()
|
||||
/** 批量退款进度弹窗引用,用于跟踪后台逐笔原路退款任务。 */
|
||||
const batchRefundProgressRef = ref<InstanceType<typeof AsyncTaskProgressComponent>>()
|
||||
/** 跨页订单选择快照;键统一转成字符串,值保留原始订单 ID 和列表展示数据。 */
|
||||
@@ -556,6 +572,11 @@ const handleGetSelectNode = (curClickedNode: any, checkedTreeNodes: any, isDeepT
|
||||
getDataList()
|
||||
}
|
||||
|
||||
/** 按当前订单列表完整筛选条件打开统计抽屉,统计范围不受当前分页和勾选订单影响。 */
|
||||
const openStatisticsDrawer = () => {
|
||||
statisticsDrawerRef.value?.open({ ...state.queryForm })
|
||||
}
|
||||
|
||||
/** 按当前筛选条件或勾选订单提交异步分批导出任务。 */
|
||||
const exportExcel = async () => {
|
||||
if (exportSubmitting.value) {
|
||||
|
||||
Reference in new issue
Block a user