钱新宇 4 روز پیش
والد
کامیت
f7eeb433b9

+ 9 - 9
traceCodePackages/traceabilityReport/pages/blacklist/detail/index.vue

@@ -30,14 +30,14 @@
                   <text class="customer-name highlight">{{ c.upstreamCustomer }}</text>
                 </view>
                 <view class="info-sub-grid">
-                  <view class="sub-item">
-                    <text class="sub-label">客户级别:</text>
-                    <text class="sub-value">{{ c.customerLevel || '--' }}</text>
-                  </view>
                   <view class="sub-item">
                     <text class="sub-label">客户类型:</text>
                     <text class="sub-value">{{ c.customerNature || '--' }}</text>
                   </view>
+                  <view class="sub-item">
+                    <text class="sub-label">客户级别:</text>
+                    <text class="sub-value">{{ c.customerLevel || '--' }}</text>
+                  </view>
                   <view class="sub-item">
                     <text class="sub-label">责任经理:</text>
                     <text class="sub-value">{{ c.responsibleManager || '--' }}</text>
@@ -54,10 +54,10 @@
                 <view class="blk-table">
                   <view class="blk-header">
                     <view class="th col-region">货源片区</view>
-                    <view class="th col-qty">数量</view>
+                    <view class="th col-qty">数量(盒)</view>
                     <view class="th col-batch">批号</view>
                     <view class="th col-sample">追溯码(抽样)</view>
-                    <view class="th col-terminal">终端到达数量</view>
+                    <view class="th col-terminal">终端到达数量(盒)</view>
                   </view>
                   <view class="blk-body">
                     <view class="blk-row" v-for="(row, idx) in c.detailLineDTOList" :key="'row-' + idx">
@@ -350,7 +350,7 @@ export default {
 }
 
 .blk-table {
-  min-width: 1085rpx;
+  min-width: 1150rpx;
   border-top: 1rpx solid #eee;
   border-left: 1rpx solid #eee;
 }
@@ -405,7 +405,7 @@ export default {
 }
 
 .col-qty {
-  width: 140rpx;
+  width: 160rpx;
 }
 
 .col-batch {
@@ -421,6 +421,6 @@ export default {
 }
 
 .col-terminal {
-  width: 220rpx;
+  width: 265rpx;
 }
 </style>

+ 325 - 56
traceCodePackages/traceabilityReport/pages/blacklist/index.vue

@@ -13,24 +13,21 @@
         </view>
       </view>
       <view class="header-toolbar">
-        <view class="update-tip">
-          <!-- <text class="tip-icon">🕒</text> -->
-          <text>统计日期:{{
-            formatDate(
-              new Date().setDate(new Date().getDate() - 1),
-              "YYYY-MM-DD",
-            ) || "--"
-          }}</text>
+        <view class="filter-tabs">
+          <view class="tab-item" v-for="(tab, index) in timeFilters" :key="index"
+            :class="{ active: currentFilter === tab.value }" @click="switchFilter(tab.value)">
+            {{ tab.label }}
+          </view>
         </view>
-
         <view class="filter-wrap">
           <view class="selector" @click.stop="toggleProvinceDropdown">
             <text class="selector-text">{{
               selectedProvince || "全部省份"
-              }}</text>
+            }}</text>
             <text class="selector-arrow" :class="{ open: dropdownProvinceOpen }"></text>
           </view>
         </view>
+
       </view>
       <!-- 背景装饰 -->
       <view class="header-circle circle-1"></view>
@@ -40,26 +37,45 @@
     <scroll-view class="list-scroll" scroll-y="true" refresher-enabled :refresher-triggered="isRefreshing"
       @refresherrefresh="onRefresh">
       <view class="list-container">
-        <view class="card-item" v-for="(item, index) in rows" :key="index" @click="toDetail(item)">
-          <view class="card-header">
-            <view class="header-left">
-              <text class="index-num">{{ index + 1 }}</text>
-              <text class="company-name">{{ item.queryCustomer }}</text>
-            </view>
-            <view class="header-right">
-              <text class="status-tag">黑名单</text>
+        <view class="date-group" v-for="groupItem in groupedRows" :key="groupItem.date">
+          <view class="date-group-card">
+            <view class="group-left">
+              <view class="group-select-btn" :class="{
+                selected: isGroupFullySelected(groupItem),
+                partial: isGroupPartiallySelected(groupItem),
+              }" @click.stop="toggleGroupSelection(groupItem)">
+                <text v-if="isGroupFullySelected(groupItem)">✓</text>
+                <text v-else-if="isGroupPartiallySelected(groupItem)">-</text>
+              </view>
+              <text class="date-label">{{ groupItem.date }}</text>
             </view>
           </view>
 
-          <view class="card-body">
-            <view class="code-row">
-              <text class="label">信用代码:</text>
-              <text class="value code-text">{{ item.socialCreditCode }}</text>
+          <view class="card-item" v-for="(item, index) in groupItem.list" :key="item.__selectId || index"
+            @click="toDetail(item)">
+            <view class="card-header">
+              <view class="header-left">
+                <view class="company-select-btn" :class="{ selected: isCompanySelected(item) }"
+                  @click.stop="toggleCompanySelection(item)">
+                  <text v-if="isCompanySelected(item)">✓</text>
+                </view>
+                <text class="company-name">{{ item.queryCustomer }}</text>
+              </view>
+              <view class="header-right">
+                <text class="status-tag">黑名单</text>
+              </view>
             </view>
-            <view class="info-grid">
-              <view class="info-item">
-                <text class="label">省份</text>
-                <text class="value">{{ item.province }}</text>
+
+            <view class="card-body">
+              <view class="code-row">
+                <text class="label">信用代码:</text>
+                <text class="value code-text">{{ item.socialCreditCode }}</text>
+              </view>
+              <view class="info-grid">
+                <view class="info-item">
+                  <text class="label">省份</text>
+                  <text class="value">{{ item.province }}</text>
+                </view>
               </view>
             </view>
           </view>
@@ -72,9 +88,6 @@
     </scroll-view>
 
     <view class="footer-btn-area">
-      <button class="action-btn history-btn" @click="handleHistory">
-        历史记录
-      </button>
       <button class="action-btn export-btn" @click="handleExport">
         导出至邮箱
       </button>
@@ -132,17 +145,20 @@
       </view>
     </view>
   </view>
+  <BottomScrollTip :text="'下滑动查看更多内容'" :bottom="'140rpx'" />
 </template>
 
 <script>
 import EmptyView from "../../../wigets/empty.vue";
+import BottomScrollTip from "../../../wigets/BottomScrollTip.vue";
 import request from "../../../request/index.js";
-import { formatDate } from "../../../utils/utils.js";
+import { formatDate, guid } from "../../../utils/utils.js";
 
 
 export default {
   components: {
     EmptyView,
+    BottomScrollTip
   },
   data() {
     return {
@@ -154,11 +170,24 @@ export default {
       provinceSearchText: "",
       provinceList: [""],
       selectedProvince: "",
+      currentFilter: "all",
+      timeFilters: [
+        { label: "全部", value: "all" },
+        { label: "近7天", value: "7" },
+        { label: "近15天", value: "15" },
+        { label: "近30天", value: "30" },
+      ],
       emailModalOpen: false,
       emailForm: {
         email: "",
       },
       emailError: false,
+      // 仅用于展示/校验(不影响请求逻辑)
+      currentFromDate: "",
+      currentToDate: "",
+
+      // 多选导出选择状态:存 socialCreditCode(或其他唯一字段)的集合
+      selectedCompanyKeys: [],
     };
   },
   created() {
@@ -169,6 +198,65 @@ export default {
   },
   methods: {
     formatDate,
+    switchFilter(value) {
+      if (this.currentFilter === value) return;
+      this.currentFilter = value;
+      this.resetFetch();
+    },
+
+    // 用于选择的唯一 key(优先 socialCreditCode)
+    getCompanyKey(item = {}) {
+      return String(item.__selectId || item.socialCreditCode || item.id || item.queryCustomer || "");
+    },
+
+    isCompanySelected(item) {
+      const key = this.getCompanyKey(item);
+      if (!key) return false;
+      return this.selectedCompanyKeys.includes(key);
+    },
+
+    toggleCompanySelection(item) {
+      const key = this.getCompanyKey(item);
+      if (!key) return;
+      const has = this.selectedCompanyKeys.includes(key);
+      if (has) {
+        this.selectedCompanyKeys = this.selectedCompanyKeys.filter((k) => k !== key);
+      } else {
+        this.selectedCompanyKeys = [...this.selectedCompanyKeys, key];
+      }
+    },
+
+    isGroupFullySelected(groupItem = {}) {
+      const list = Array.isArray(groupItem.list) ? groupItem.list : [];
+      if (!list.length) return false;
+      return list.every((it) => this.isCompanySelected(it));
+    },
+
+    isGroupPartiallySelected(groupItem = {}) {
+      const list = Array.isArray(groupItem.list) ? groupItem.list : [];
+      if (!list.length) return false;
+      const selectedCount = list.filter((it) => this.isCompanySelected(it)).length;
+      return selectedCount > 0 && selectedCount < list.length;
+    },
+
+    toggleGroupSelection(groupItem = {}) {
+      const list = Array.isArray(groupItem.list) ? groupItem.list : [];
+      if (!list.length) return;
+      const groupKeys = list.map((it) => this.getCompanyKey(it)).filter(Boolean);
+      if (!groupKeys.length) return;
+
+      const allSelected = groupKeys.every((k) =>
+        this.selectedCompanyKeys.includes(k),
+      );
+      if (allSelected) {
+        this.selectedCompanyKeys = this.selectedCompanyKeys.filter(
+          (k) => !groupKeys.includes(k),
+        );
+      } else {
+        const merged = new Set([...this.selectedCompanyKeys, ...groupKeys]);
+        this.selectedCompanyKeys = Array.from(merged);
+      }
+    },
 
     getProviceList() {
       request("/common/getProviceList", {
@@ -185,18 +273,31 @@ export default {
       if (this.loading) return;
       this.loading = true;
 
-      const yesterday = new Date();
-      yesterday.setDate(yesterday.getDate() - 1);
-      const dateStr = this.formatDate(yesterday, "YYYY-MM-DD");
+      const today = new Date();
+      const toDateStr = this.formatDate(today, "YYYY-MM-DD");
 
+      let fromDateStr = "";
+      if (this.currentFilter === "all") {
+        const twoMonthsAgo = new Date();
+        twoMonthsAgo.setMonth(today.getMonth() - 2);
+        fromDateStr = this.formatDate(twoMonthsAgo, "YYYY-MM-DD");
+      } else {
+        const days = parseInt(this.currentFilter);
+        const pastDate = new Date();
+        pastDate.setDate(today.getDate() - days);
+        fromDateStr = this.formatDate(pastDate, "YYYY-MM-DD");
+      }
 
       request("/blacklist-report/get-export-company-data", {
-        fromDate: dateStr,
-        toDate: dateStr,
+        fromDate: fromDateStr,
+        toDate: toDateStr,
         path: "traceabilityReport/pages/blacklist/index.vue",
       }).then((res) => {
         if (res.code == 200) {
-          this.allRows = res.data || [];
+          this.allRows = (res.data || []).map((it) => ({
+            ...it,
+            __selectId: it?.__selectId || guid("blk_"),
+          }));
           this.applyFilter();
         }
         this.loading = false;
@@ -211,6 +312,7 @@ export default {
 
     resetFetch() {
       this.rows = [];
+      this.selectedCompanyKeys = [];
       this.fetchList();
     },
 
@@ -227,11 +329,11 @@ export default {
     },
 
     handleExport() {
-      if (this.rows?.length == 0) {
+      if (this.selectedCompanyKeys?.length === 0) {
         uni.showToast({
-          title: '暂无数据...',
+          title: "请选择要导出的企业",
           icon: 'none'
-        })
+        });
         return
       }
       this.emailModalOpen = true;
@@ -248,27 +350,68 @@ export default {
         this.emailError = true;
         return;
       }
+      const selectedTaskIds = this.getSelectedTaskIds();
+      if (!selectedTaskIds.length) {
+        uni.showToast({ title: "未找到可导出的任务", icon: "none" });
+        return;
+      }
       uni.showLoading({ title: "发送中..." });
 
-      request("/report/sendemail", {
-        taskId: this.rows[0]?.taskId,
-        emailAddress: this.emailForm.email + "@999.com.cn",
-        path: "traceabilityReport/pages/blacklist/index.vue",
-      }).then((res) => {
-        uni.hideLoading();
-        if (res.code == 200) {
-          uni.showToast({
-            title: "发送成功",
-            icon: "success",
-          });
-          this.closeEmailModal();
-        } else {
-          uni.showToast({
-            title: res.msg || "发送失败",
-            icon: "none",
+      const doSingleSend = async (taskId) => {
+        return request("/report/sendemail", {
+          taskId,
+          emailAddress: this.emailForm.email + "@999.com.cn",
+          path: "traceabilityReport/pages/blacklist/index.vue",
+        });
+      };
+
+      const run = async () => {
+        try {
+          // 先尝试一次性传多个 taskId(后端若支持)
+          const res = await request("/report/sendemail", {
+            taskId: selectedTaskIds,
+            emailAddress: this.emailForm.email + "@999.com.cn",
+            path: "traceabilityReport/pages/blacklist/index.vue",
           });
+          if (res?.code == 200) {
+            uni.hideLoading();
+            uni.showToast({ title: "发送成功", icon: "success" });
+            this.closeEmailModal();
+            return;
+          }
+        } catch (e) {
+          // ignore, fallback below
         }
-      });
+
+        // 不支持 combinedTask:逐个发送
+        for (let i = 0; i < selectedTaskIds.length; i++) {
+          const taskId = selectedTaskIds[i];
+          const r = await doSingleSend(taskId);
+          if (r?.code != 200) {
+            uni.hideLoading();
+            uni.showToast({
+              title: r?.msg || "发送失败",
+              icon: "none",
+            });
+            return;
+          }
+        }
+
+        uni.hideLoading();
+        uni.showToast({ title: "发送成功", icon: "success" });
+        this.closeEmailModal();
+      };
+
+      run();
+    },
+
+    getSelectedTaskIds() {
+      const keys = new Set(this.selectedCompanyKeys || []);
+      const selectedRows = (this.rows || []).filter((r) =>
+        keys.has(this.getCompanyKey(r)),
+      );
+      const ids = selectedRows.map((r) => r?.taskId).filter(Boolean);
+      return Array.from(new Set(ids));
     },
 
     applyFilter() {
@@ -278,6 +421,8 @@ export default {
         this.rows = this.allRows.filter(item => item.customerProvinceName === this.selectedProvince);
       }
       this.totalCount = this.rows.length;
+      // 切换省份/刷新数据后,清空已选企业,避免“跨筛选脏选中”
+      this.selectedCompanyKeys = [];
     },
 
     toggleProvinceDropdown() {
@@ -303,6 +448,25 @@ export default {
         p?.toLowerCase()?.includes(this.provinceSearchText.toLowerCase()),
       );
     },
+    groupedRows() {
+      const groups = {};
+      (this.rows || []).forEach((item) => {
+        const date = item?.billTime || "未知日期";
+        if (!groups[date]) groups[date] = [];
+        groups[date].push(item);
+      });
+
+      return Object.keys(groups)
+        .sort(
+          (a, b) =>
+            new Date(String(b).replace(/-/g, "/")).getTime() -
+            new Date(String(a).replace(/-/g, "/")).getTime(),
+        )
+        .map((date) => ({
+          date,
+          list: groups[date],
+        }));
+    },
   },
 };
 </script>
@@ -379,6 +543,7 @@ export default {
   align-items: center;
   position: relative;
   z-index: 3;
+  gap: 20rpx;
 }
 
 .update-tip {
@@ -443,6 +608,110 @@ export default {
   z-index: 999;
 }
 
+/* Header date filter tabs */
+.filter-tabs {
+  display: flex;
+  background: rgba(255, 255, 255, 0.2);
+  border-radius: 16rpx;
+  padding: 6rpx 6rpx;
+  z-index: 30;
+  position: relative;
+  flex: 1;
+  max-width: 420rpx;
+}
+
+.tab-item {
+  flex: 1;
+  text-align: center;
+  font-size: 24rpx;
+  padding: 10rpx 0;
+  border-radius: 12rpx;
+  color: rgba(255, 255, 255, 0.8);
+  transition: all 0.3s;
+}
+
+.tab-item.active {
+  background: #fff;
+  color: #2b32b2;
+  font-weight: 600;
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
+}
+
+/* Date-group (history style) */
+.date-group {
+  margin-bottom: 40rpx;
+}
+
+.date-group-card {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  background: #fff;
+  padding: 20rpx 30rpx;
+  border-radius: 16rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.03);
+  border-left: 8rpx solid #1488cc;
+}
+
+.group-left {
+  display: flex;
+  align-items: center;
+}
+
+.date-label {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.group-right {
+  flex-shrink: 0;
+}
+
+.group-select-btn {
+  width: 44rpx;
+  height: 44rpx;
+  border-radius: 50%;
+  border: 2rpx solid #1488cc;
+  color: #1488cc;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 26rpx;
+  background: rgba(20, 136, 204, 0.06);
+  margin-right: 18rpx;
+}
+
+.group-select-btn.selected {
+  background: #1488cc;
+  color: #fff;
+}
+
+.group-select-btn.partial {
+  background: rgba(20, 136, 204, 0.15);
+}
+
+.company-select-btn {
+  width: 44rpx;
+  height: 44rpx;
+  border-radius: 50%;
+  border: 2rpx solid #d0d7e2;
+  color: #d0d7e2;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 14rpx;
+  font-size: 26rpx;
+  background: #fff;
+}
+
+.company-select-btn.selected {
+  border-color: #1488cc;
+  color: #1488cc;
+  background: rgba(20, 136, 204, 0.08);
+}
+
 .dropdown {
   width: 360rpx;
   background: #fff;

+ 4 - 1
traceCodePackages/traceabilityReport/pages/customerScanningRate/index.vue

@@ -38,18 +38,21 @@
       </view> -->
     </view>
   </view>
+  <BottomScrollTip :text="'下滑查看更多内容'" />
 </template>
 
 <script>
 import ScanRateTable from "./wigets/ScanRateTable.vue";
 import request from "../../../request/index";
+import BottomScrollTip from "../../../wigets/BottomScrollTip.vue";
 export default {
   components: {
     ScanRateTable,
+    BottomScrollTip,
   },
   data() {
     return {
-      activeRange: 2,
+      activeRange: 3,
       varietyColumns: [
         {
           key: "physicName",

+ 3 - 3
traceCodePackages/traceabilityReport/pages/ganmaoling/detail/index.vue

@@ -42,7 +42,7 @@
             <view class="th col-product">产品名称</view>
             <view class="th col-batch">批号</view>
             <view class="th col-source">货源地</view>
-            <view class="th col-qty">数量</view>
+            <view class="th col-qty">数量(盒)</view>
             <view class="th col-code">追溯码(抽样)</view>
           </view>
           <view class="flow-body">
@@ -254,7 +254,7 @@ export default {
 }
 
 .flow-table {
-  min-width: 1720rpx;
+  min-width: 1785rpx;
   border-top: 1rpx solid #ccc;
   border-left: 1rpx solid #ccc;
 }
@@ -323,7 +323,7 @@ export default {
 }
 
 .col-qty {
-  width: 120rpx;
+  width: 160rpx;
 }
 
 .col-code {

+ 314 - 79
traceCodePackages/traceabilityReport/pages/ganmaoling/index.vue

@@ -18,21 +18,19 @@
       </view>
 
       <view class="header-toolbar">
-        <view class="update-tip">
-          <!-- <text class="tip-icon">🕒</text> -->
-          <text>统计日期:{{
-            formatDate(
-              new Date().setDate(new Date().getDate() - 1),
-              "YYYY-MM-DD",
-            ) || "--"
-          }}</text>
-        </view>
 
+
+        <view class="filter-tabs">
+          <view class="tab-item" v-for="(tab, index) in timeFilters" :key="index"
+            :class="{ active: currentFilter === tab.value }" @click="switchFilter(tab.value)">
+            {{ tab.label }}
+          </view>
+        </view>
         <view class="filter-wrap">
           <view class="selector" @click.stop="toggleProvinceDropdown">
             <text class="selector-text">{{
               selectedProvince || "全部省份"
-            }}</text>
+              }}</text>
             <text class="selector-arrow" :class="{ open: dropdownProvinceOpen }"></text>
           </view>
         </view>
@@ -43,33 +41,53 @@
     <scroll-view class="list-scroll" scroll-y="true" refresher-enabled :refresher-triggered="isRefreshing"
       @refresherrefresh="onRefresh">
       <view class="list-container">
-        <view class="card-item" v-for="(item, index) in rows" :key="index" @click="toDetail(item)">
-          <view class="card-header">
-            <view class="header-left">
-              <text class="index-num">{{ index + 1 }}</text>
-              <text class="company-name">{{ item.customerName }}</text>
-              <text class="level-tag" :class="getLevelClass(item.customerLevel)">
-                {{ item.customerLevel }}
-              </text>
-            </view>
-            <view class="header-right">
-              <text class="alert-count">{{ item.totalWarningAmount }}次预警</text>
+        <view class="date-group" v-for="groupItem in groupedRows" :key="groupItem.date">
+          <view class="date-group-card">
+            <view class="group-left">
+              <view class="group-select-btn" :class="{
+                selected: isGroupFullySelected(groupItem),
+                partial: isGroupPartiallySelected(groupItem),
+              }" @click.stop="toggleGroupSelection(groupItem)">
+                <text v-if="isGroupFullySelected(groupItem)">✓</text>
+                <text v-else-if="isGroupPartiallySelected(groupItem)">-</text>
+              </view>
+              <text class="date-label">{{ groupItem.date }}</text>
             </view>
           </view>
 
-          <view class="card-body">
-            <view class="info-grid">
-              <view class="info-item">
-                <text class="label">省份</text>
-                <text class="value">{{ item.customerProvinceName ?? '--' }}</text>
+          <view class="card-item" v-for="(item, index) in groupItem.list" :key="item.__selectId || index"
+            @click="toDetail(item)">
+            <view class="card-header">
+              <view class="header-left">
+                <view class="company-select-btn" :class="{ selected: isCompanySelected(item) }"
+                  @click.stop="toggleCompanySelection(item)">
+                  <text v-if="isCompanySelected(item)">✓</text>
+                </view>
+                <text class="index-num">{{ index + 1 }}</text>
+                <text class="company-name">{{ item.customerName }}</text>
+                <text class="level-tag" :class="getLevelClass(item.customerLevel)">
+                  {{ item.customerLevel }}
+                </text>
               </view>
-              <view class="info-item">
-                <text class="label">责任人</text>
-                <text class="value">{{ item.responsibleManager }}</text>
+              <view class="header-right">
+                <text class="alert-count">{{ item.totalWarningAmount }}次预警</text>
               </view>
-              <view class="info-item">
-                <text class="label">性质</text>
-                <text class="value">{{ item.customerCategory }}</text>
+            </view>
+
+            <view class="card-body">
+              <view class="info-grid">
+                <view class="info-item">
+                  <text class="label">省份</text>
+                  <text class="value">{{ item.customerProvinceName ?? '--' }}</text>
+                </view>
+                <view class="info-item">
+                  <text class="label">责任人</text>
+                  <text class="value">{{ item.responsibleManager }}</text>
+                </view>
+                <view class="info-item">
+                  <text class="label">性质</text>
+                  <text class="value">{{ item.customerCategory }}</text>
+                </view>
               </view>
             </view>
           </view>
@@ -83,9 +101,6 @@
 
     <!-- 底部按钮区 -->
     <view class="footer-btn-area">
-      <button class="action-btn history-btn" @click="handleHistory">
-        历史记录
-      </button>
       <button class="action-btn export-btn" @click="handleExport">
         导出至邮箱
       </button>
@@ -139,16 +154,19 @@
       </view>
     </view>
   </view>
+  <BottomScrollTip :text="'下滑动查看更多内容'" :bottom="'140rpx'" />
 </template>
 
 <script>
 import EmptyView from "../../../wigets/empty.vue";
 import request from "../../../request/index.js";
-import { formatDate } from "../../../utils/utils.js";
+import BottomScrollTip from "../../../wigets/BottomScrollTip.vue";
+import { formatDate, guid } from "../../../utils/utils.js";
 
 export default {
   components: {
     EmptyView,
+    BottomScrollTip
   },
   data() {
     return {
@@ -160,12 +178,21 @@ export default {
       provinceSearchText: "",
       provinceList: [""],
       selectedProvince: "",
+      currentFilter: "all",
+      timeFilters: [
+        { label: "全部", value: "all" },
+        { label: "近7天", value: "7" },
+        { label: "近15天", value: "15" },
+        { label: "近30天", value: "30" },
+      ],
       totalCount: 0,
       emailModalOpen: false,
       emailForm: {
         email: "",
       },
       emailError: false,
+      // 多选导出选择状态:存每条记录的唯一 __selectId
+      selectedCompanyKeys: [],
     };
   },
   created() {
@@ -177,6 +204,78 @@ export default {
   methods: {
     formatDate,
 
+    switchFilter(value) {
+      if (this.currentFilter === value) return;
+      this.currentFilter = value;
+      this.resetFetch();
+    },
+
+    // 选择的唯一 key(由列表接口注入 __selectId)
+    getCompanyKey(item = {}) {
+      return String(item.__selectId || "");
+    },
+
+    isCompanySelected(item) {
+      const key = this.getCompanyKey(item);
+      if (!key) return false;
+      return (this.selectedCompanyKeys || []).includes(key);
+    },
+
+    toggleCompanySelection(item) {
+      const key = this.getCompanyKey(item);
+      if (!key) return;
+      const has = this.isCompanySelected(item);
+      if (has) {
+        this.selectedCompanyKeys = (this.selectedCompanyKeys || []).filter(
+          (k) => k !== key,
+        );
+      } else {
+        this.selectedCompanyKeys = [
+          ...(this.selectedCompanyKeys || []),
+          key,
+        ];
+      }
+    },
+
+    isGroupFullySelected(groupItem = {}) {
+      const list = Array.isArray(groupItem.list) ? groupItem.list : [];
+      if (!list.length) return false;
+      return list.every((it) => this.isCompanySelected(it));
+    },
+
+    isGroupPartiallySelected(groupItem = {}) {
+      const list = Array.isArray(groupItem.list) ? groupItem.list : [];
+      if (!list.length) return false;
+      const selectedCount = list.filter((it) => this.isCompanySelected(it)).length;
+      return selectedCount > 0 && selectedCount < list.length;
+    },
+
+    toggleGroupSelection(groupItem = {}) {
+      const list = Array.isArray(groupItem.list) ? groupItem.list : [];
+      if (!list.length) return;
+      const groupKeys = list.map((it) => this.getCompanyKey(it)).filter(Boolean);
+      if (!groupKeys.length) return;
+
+      const allSelected = groupKeys.every((k) =>
+        (this.selectedCompanyKeys || []).includes(k),
+      );
+
+      if (allSelected) {
+        this.selectedCompanyKeys = (this.selectedCompanyKeys || []).filter(
+          (k) => !groupKeys.includes(k),
+        );
+      } else {
+        const merged = new Set([...(this.selectedCompanyKeys || []), ...groupKeys]);
+        this.selectedCompanyKeys = Array.from(merged);
+      }
+    },
+
+    getUpdatedDate(fullTime) {
+      const t = String(fullTime || "");
+      if (!t) return "未知日期";
+      return t.split(" ")[0] || t.split("T")[0] || "未知日期";
+    },
+
     getProviceList() {
       request("/common/getProviceList", {
         path: "traceabilityReport/pages/ganmaoling/index.vue",
@@ -199,15 +298,19 @@ export default {
       if (this.loading) return;
       this.loading = true;
 
+      const days = this.currentFilter === "all" ? -1 : this.currentFilter;
       request(
-        `/report/ganmaoling/list?days=1`,
+        `/report/ganmaoling/list?days=${days}`,
         {
           path: "traceabilityReport/pages/ganmaoling/index.vue",
         },
         "get",
       ).then((res) => {
         if (res.code == 200) {
-          this.allRows = res.data || [];
+          this.allRows = (res.data || []).map((it) => ({
+            ...it,
+            __selectId: it?.__selectId || guid("glm_"),
+          }));
           this.applyFilter();
         }
         this.loading = false;
@@ -222,6 +325,7 @@ export default {
 
     resetFetch() {
       this.rows = [];
+      this.selectedCompanyKeys = [];
       this.fetchList();
     },
 
@@ -232,6 +336,8 @@ export default {
         this.rows = this.allRows.filter(item => item.customerProvinceName === this.selectedProvince);
       }
       this.totalCount = this.rows.length;
+      // 切换筛选条件后清空已选企业,避免脏选中
+      this.selectedCompanyKeys = [];
     },
 
     toggleProvinceDropdown() {
@@ -255,9 +361,9 @@ export default {
     },
 
     handleExport() {
-      if (this.rows.length == 0) {
+      if ((this.selectedCompanyKeys || []).length === 0) {
         uni.showToast({
-          title: '暂无数据...',
+          title: '请选择要导出的企业',
           icon: 'none'
         })
         return
@@ -270,49 +376,73 @@ export default {
       this.emailModalOpen = false;
     },
 
-    handleSendEmail() {
+    async handleSendEmail() {
       if (!this.emailForm.email) {
         this.emailError = true;
         return;
       }
-      uni.showLoading({ title: "发送中..." });
+      const selectedRows = (this.rows || []).filter((r) =>
+        (this.selectedCompanyKeys || []).includes(this.getCompanyKey(r)),
+      );
+      const selectedDates = Array.from(
+        new Set(selectedRows.map((r) => this.getUpdatedDate(r.updatedTime))),
+      ).filter(Boolean);
 
-      const yesterday = new Date();
-      yesterday.setDate(yesterday.getDate() - 1);
-      const dateStr = this.formatDate(yesterday, "YYYY-MM-DD");
+      if (!selectedDates.length) {
+        uni.showToast({ title: "未找到可导出的日期", icon: "none" });
+        return;
+      }
 
-      request(
-        `/report/ganmaoling/sendemail`,
-        {
-          updatedTime: dateStr,
-          emailAddress: this.emailForm.email + "@999.com.cn",
-          path: "traceabilityReport/pages/ganmaoling/index.vue",
-        },
-        "post",
-      ).then((res) => {
-        uni.hideLoading();
-        if (res.code == 200) {
-          uni.showToast({
-            title: "发送成功",
-            icon: "success",
-          });
-          this.closeEmailModal();
-        } else {
-          uni.showToast({
-            title: res.msg || "发送失败",
-            icon: "none",
-          });
+      uni.showLoading({ title: "发送中..." });
+      try {
+        for (let i = 0; i < selectedDates.length; i++) {
+          const dateStr = selectedDates[i];
+          const res = await request(
+            `/report/ganmaoling/sendemail`,
+            {
+              updatedTime: dateStr,
+              emailAddress: this.emailForm.email + "@999.com.cn",
+              path: "traceabilityReport/pages/ganmaoling/index.vue",
+            },
+            "post",
+          );
+          if (res?.code != 200) {
+            uni.hideLoading();
+            uni.showToast({
+              title: res?.msg || "发送失败",
+              icon: "none",
+            });
+            return;
+          }
         }
-      });
-    },
-
-    handleHistory() {
-      uni.navigateTo({
-        url: "/traceCodePackages/traceabilityReport/pages/ganmaoling/history/index",
-      });
+        uni.hideLoading();
+        uni.showToast({ title: "发送成功", icon: "success" });
+        this.closeEmailModal();
+      } catch (e) {
+        uni.hideLoading();
+        uni.showToast({ title: "发送失败", icon: "none" });
+      }
     },
   },
   computed: {
+    groupedRows() {
+      const groups = {};
+      (this.rows || []).forEach((item) => {
+        const date = this.getUpdatedDate(item.updatedTime);
+        if (!groups[date]) groups[date] = [];
+        groups[date].push(item);
+      });
+      return Object.keys(groups)
+        .sort(
+          (a, b) =>
+            new Date(String(b).replace(/-/g, "/")).getTime() -
+            new Date(String(a).replace(/-/g, "/")).getTime(),
+        )
+        .map((date) => ({
+          date,
+          list: groups[date],
+        }));
+    },
     filteredProvinceList() {
       if (!this.provinceSearchText) {
         return this.provinceList;
@@ -415,6 +545,7 @@ export default {
   align-items: center;
   position: relative;
   z-index: 3;
+  gap: 20rpx;
 }
 
 .update-tip {
@@ -470,6 +601,107 @@ export default {
   transform: rotate(180deg);
 }
 
+/* Header date filter tabs */
+.filter-tabs {
+  display: flex;
+  background: rgba(255, 255, 255, 0.2);
+  border-radius: 16rpx;
+  padding: 6rpx 6rpx;
+  z-index: 30;
+  position: relative;
+  flex: 1;
+  max-width: 420rpx;
+}
+
+.tab-item {
+  flex: 1;
+  text-align: center;
+  font-size: 24rpx;
+  padding: 10rpx 0;
+  border-radius: 12rpx;
+  color: rgba(255, 255, 255, 0.8);
+  transition: all 0.3s;
+}
+
+.tab-item.active {
+  background: #fff;
+  color: #2b32b2;
+  font-weight: 600;
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
+}
+
+/* Date-group (history style) */
+.date-group {
+  margin-bottom: 40rpx;
+}
+
+.date-group-card {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  background: #fff;
+  padding: 16rpx 22rpx;
+  border-radius: 16rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.03);
+  border-left: 6rpx solid #1488cc;
+}
+
+.group-left {
+  display: flex;
+  align-items: center;
+}
+
+.date-label {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.group-select-btn {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 50%;
+  border: 2rpx solid #1488cc;
+  color: #1488cc;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 22rpx;
+  background: rgba(20, 136, 204, 0.06);
+  margin-right: 14rpx;
+}
+
+.group-select-btn.selected {
+  background: #1488cc;
+  color: #fff;
+}
+
+.group-select-btn.partial {
+  background: rgba(20, 136, 204, 0.15);
+}
+
+.company-select-btn {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 50%;
+  border: 2rpx solid #d0d7e2;
+  color: #d0d7e2;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 12rpx;
+  font-size: 22rpx;
+  background: #fff;
+  flex-shrink: 0;
+}
+
+.company-select-btn.selected {
+  border-color: #1488cc;
+  color: #1488cc;
+  background: rgba(20, 136, 204, 0.08);
+}
+
 /* Dropdown Layer (Absolute on top of everything) */
 .dropdown-layer {
   position: absolute;
@@ -556,8 +788,8 @@ export default {
 .card-item {
   background: #fff;
   border-radius: 20rpx;
-  padding: 30rpx;
-  margin-bottom: 24rpx;
+  padding: 26rpx 22rpx;
+  margin-bottom: 20rpx;
   box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.05);
   transition: transform 0.2s;
 }
@@ -569,23 +801,25 @@ export default {
 .card-header {
   display: flex;
   justify-content: space-between;
-  align-items: flex-start;
-  margin-bottom: 24rpx;
+  align-items: center;
+  margin-bottom: 16rpx;
 }
 
 .header-left {
   flex: 1;
   display: flex;
   align-items: center;
-  flex-wrap: wrap;
+  flex-wrap: nowrap;
   margin-right: 20rpx;
-  gap: 12rpx;
+  gap: 0;
+  min-width: 0;
 }
 
 .index-num {
   font-size: 24rpx;
   color: #999;
   font-family: monospace;
+  margin-right: 12rpx;
 }
 
 .company-name {
@@ -594,6 +828,7 @@ export default {
   color: #096dd9;
   line-height: 1.4;
   text-decoration: underline;
+  margin-right: 8rpx;
 }
 
 .level-tag {

+ 6 - 6
traceCodePackages/traceabilityReport/pages/reportExport/detail/index.vue

@@ -14,7 +14,7 @@
             <view class="company-card" v-for="(company, cIdx) in rows" :key="cIdx">
               <view class="company-header" @click="toggleExpand(company)">
                 <view class="company-title">
-                  <text class="company-name">{{ company.name }}</text>
+                  <text class="company-name">入库方:{{ company.name }}</text>
                   <view class="company-products" v-if="company.products && company.products.length">
                     <text class="company-product-item" v-for="(prod, pIdx) in company.products" :key="pIdx">
                       {{ prod.name }} {{ prod.spec }}
@@ -41,8 +41,8 @@
                         <view class="batch-header-row">
                           <text class="th-cell col-batch">批号</text>
                           <text class="th-cell col-region">货源地区</text>
-                          <text class="th-cell col-qty">货源数量</text>
-                          <text class="th-cell col-qty">终端到达数量</text>
+                          <text class="th-cell col-qty">货源数量(盒)</text>
+                          <text class="th-cell col-qty">终端到达数量(盒)</text>
                           <text class="th-cell col-time">出库时间</text>
                           <text class="th-cell col-code">追溯码抽样</text>
                         </view>
@@ -77,7 +77,7 @@
             <view class="company-card" v-for="(company, cIdx) in _rows" :key="cIdx">
               <view class="company-header" @click="toggleExpand(company)">
                 <view class="company-title">
-                  <text class="company-name">{{ company.name }}</text>
+                  <text class="company-name">出库方:{{ company.name }}</text>
                   <view class="company-products" v-if="company.products && company.products.length">
                     <text class="company-product-item" v-for="(prod, pIdx) in company.products" :key="pIdx">
                       {{ prod.name }} {{ prod.spec }}
@@ -527,7 +527,7 @@ export default {
 }
 
 .batch-table {
-  min-width: 1410rpx;
+  min-width: 1550rpx;
 }
 
 .batch-header-row {
@@ -578,7 +578,7 @@ export default {
 }
 
 .col-qty {
-  width: 140rpx;
+  width: 210rpx;
 }
 
 .col-time {

+ 19 - 1
traceCodePackages/utils/utils.js

@@ -61,6 +61,24 @@ export function getUid(len = 16) {
   return (ts + rand).slice(0, len);
 }
 
+
+/**
+ * 生成唯一的ID
+ * @method guid
+ * @grammar Base.guid() => String
+ * @grammar Base.guid( prefix ) => String
+ */
+export const guid = (() => {
+  let counter = 0;
+  return (prefix) => {
+    let id = (+new Date()).toString(32);
+    for (let i = 0; i < 5; i++) {
+      id += Math.floor(Math.random() * 65535).toString(32);
+    }
+    return (prefix || 'cw_') + id + (counter++).toString(32);
+  };
+})();
+
 //判断是否有某个功能权限
 export function hasFunction(key) {
   return new Promise((resolve, reject) => {
@@ -72,7 +90,7 @@ export function hasFunction(key) {
     ).then((res) => {
       if (res?.data?.permissions) {
         const permissions = res.data.permissions || []
-        if(Array.isArray(key)) {
+        if (Array.isArray(key)) {
           resolve(key.map(item => permissions.includes(item)))
         } else {
           resolve(permissions.includes(key))