Переглянути джерело

订单监测新功能_前端

Gogs 2 тижнів тому
батько
коміт
2c1d6679d1

+ 8 - 0
src/api/order.js

@@ -167,3 +167,11 @@ export function getShopChannelDiversity(params) {
     params
   })
 }
+
+export function getShopOperationReport(params) {
+  return request({
+    url: '/api/shop/report/list',
+    method: 'get',
+    params
+  })
+}

+ 27 - 0
src/api/supply.js

@@ -1,5 +1,32 @@
 import request from '@/utils/request'
 
+export function getSupplyMonitorSuppliers(params) {
+  return request({
+    url: '/api/supply/monitor/suppliers',
+    method: 'get',
+    params,
+    timeout: 60000
+  })
+}
+
+export function getSupplyMonitorCompare(params) {
+  return request({
+    url: '/api/supply/monitor/compare',
+    method: 'get',
+    params,
+    timeout: 60000
+  })
+}
+
+export function getSupplyPaymentPlan(params) {
+  return request({
+    url: '/api/supply/monitor/payment-plan',
+    method: 'get',
+    params,
+    timeout: 60000
+  })
+}
+
 // 获取产品供应商详情(成本/交付/账期)
 export function getProductDetails(code) {
   return request({

+ 30 - 0
src/router/index.js

@@ -100,6 +100,36 @@ export const constantRoutes = [
         meta: { title: '功能菜单' }
       }
     ]
+  },
+  {
+    path: '/order',
+    component: Layout,
+    hidden: true,
+    redirect: '/order/shopreport',
+    alwaysShow: true,
+    meta: { title: '订单与财务', icon: 'money' },
+    children: [
+      {
+        path: 'shopreport',
+        component: () => import('@/views/order/shopreport/index.vue'),
+        name: 'ShopOperationReport',
+        meta: { title: '店铺运营报表', icon: 'excel' }
+      }
+    ]
+  },
+  {
+    path: '/supply',
+    component: Layout,
+    hidden: true,
+    redirect: '/supply/monitor',
+    children: [
+      {
+        path: 'monitor',
+        component: () => import('@/views/supply/monitor/index.vue'),
+        name: 'SupplyMonitorEnhancement',
+        meta: { title: '供应商查询对比与付款计划', icon: 'chart' }
+      }
+    ]
   }
 ]
 

+ 36 - 2
src/views/module-submenu.vue

@@ -59,9 +59,12 @@ export default {
       // 从系统菜单查找
       if (this.sidebarRouters && this.sidebarRouters.length > 0) {
         for (const route of this.sidebarRouters) {
+          if (route.hidden) {
+            continue
+          }
           if (route.path === modulePath && route.children) {
             // 返回系统模块的子菜单,确保路径完整
-            return route.children
+            const items = route.children
               .filter(child => !child.hidden)
               .map(child => {
                 // 如果路径是相对路径,需要拼接父路径
@@ -71,11 +74,42 @@ export default {
                   path: fullPath
                 }
               })
+            return this.appendLocalMenuItems(modulePath, items)
           }
         }
       }
 
-      return []
+      return this.appendLocalMenuItems(modulePath, [])
+    },
+    appendLocalMenuItems(modulePath, items) {
+      const result = [...items]
+      if (modulePath === '/order' && !result.some(item => item.path === '/order/shopreport')) {
+        result.push({
+          path: '/order/shopreport',
+          name: 'ShopOperationReport',
+          meta: {
+            title: '店铺运营报表',
+            icon: 'excel'
+          }
+        })
+      }
+      if (modulePath === '/supply' && !result.some(item => item.path === '/supply/monitor')) {
+        const monitorItem = {
+          path: '/supply/monitor',
+          name: 'SupplyMonitorEnhancement',
+          meta: {
+            title: '供应商查询对比与付款计划',
+            icon: 'chart'
+          }
+        }
+        const weightsIndex = result.findIndex(item => item.path === '/supply/weights' || (item.meta && item.meta.title === '权重设置'))
+        if (weightsIndex >= 0) {
+          result.splice(weightsIndex, 0, monitorItem)
+        } else {
+          result.push(monitorItem)
+        }
+      }
+      return result
     },
     handleItemClick(item) {
       this.$router.push(item.path)

+ 29 - 3
src/views/order/channel/index.vue

@@ -5,6 +5,13 @@
       <p class="subtitle">分析商品在不同销售渠道的覆盖广度</p>
     </header>
 
+    <OrderDateRangeFilter
+      :max-date="maxDate"
+      default-period="day"
+      storage-key="dtm-shop-date-filter"
+      @change="handleFilterChange"
+    />
+
     <div class="chart-card">
       <h3 class="chart-title">商品渠道覆盖 Top 20 趋势</h3>
       <OrderLoadingPanel
@@ -69,12 +76,14 @@
 <script>
 import * as echarts from 'echarts'
 import OrderLoadingPanel from '../components/OrderLoadingPanel.vue'
-import { getShopCrossSellingProducts } from '@/api/order'
+import OrderDateRangeFilter from '../components/OrderDateRangeFilter.vue'
+import { getShopCrossSellingProducts, getShopMaxDate } from '@/api/order'
 
 export default {
   name: 'OrderChannel',
   components: {
-    OrderLoadingPanel
+    OrderLoadingPanel,
+    OrderDateRangeFilter
   },
   data() {
     return {
@@ -82,6 +91,8 @@ export default {
       currentPage: 1,
       itemsPerPage: 10,
       skuKeyword: '',
+      maxDate: '',
+      currentQuery: {},
       loading: true,
       chartInstance: null
     }
@@ -96,7 +107,7 @@ export default {
     }
   },
   mounted() {
-    this.fetchData()
+    this.initFilter()
     window.addEventListener('resize', this.handleResize)
   },
   beforeDestroy() {
@@ -111,6 +122,19 @@ export default {
       this.currentPage = 1
       this.fetchData()
     },
+    async initFilter() {
+      try {
+        this.maxDate = await getShopMaxDate()
+      } catch (error) {
+        const today = new Date()
+        this.maxDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
+      }
+    },
+    handleFilterChange(params) {
+      this.currentQuery = params || {}
+      this.currentPage = 1
+      this.fetchData()
+    },
     initLineChart() {
       const chartEl = this.$refs.channelChartRef
       if (!chartEl) return
@@ -151,6 +175,8 @@ export default {
       this.loading = true
       try {
         const response = await getShopCrossSellingProducts({
+          startDate: this.currentQuery.startDate,
+          endDate: this.currentQuery.endDate,
           skuKeyword: this.skuKeyword || undefined
         })
         if (response?.success) {

+ 191 - 0
src/views/order/components/OrderDateRangeFilter.vue

@@ -0,0 +1,191 @@
+<template>
+  <el-form :inline="true" size="small" class="order-date-filter">
+    <el-form-item label="统计周期">
+      <el-radio-group v-model="periodType" size="small" @change="handlePeriodChange">
+        <el-radio-button label="day">日报</el-radio-button>
+        <el-radio-button label="month">月报</el-radio-button>
+        <el-radio-button label="year">年报</el-radio-button>
+        <el-radio-button label="all">全量</el-radio-button>
+      </el-radio-group>
+    </el-form-item>
+    <el-form-item label="日期范围">
+      <el-date-picker
+        v-model="dateRange"
+        type="daterange"
+        value-format="yyyy-MM-dd"
+        range-separator="至"
+        start-placeholder="开始日期"
+        end-placeholder="结束日期"
+        :clearable="periodType !== 'all'"
+        :disabled="periodType === 'all'"
+        :editable="false"
+        :picker-options="pickerOptions"
+      />
+    </el-form-item>
+    <el-form-item>
+      <el-button type="primary" icon="el-icon-search" size="mini" @click="emitChange">查询</el-button>
+      <el-button icon="el-icon-refresh" size="mini" @click="reset">重置</el-button>
+      <slot name="actions" :params="queryParams" />
+    </el-form-item>
+  </el-form>
+</template>
+
+<script>
+export default {
+  name: 'OrderDateRangeFilter',
+  props: {
+    maxDate: {
+      type: String,
+      default: ''
+    },
+    defaultPeriod: {
+      type: String,
+      default: 'day'
+    },
+    storageKey: {
+      type: String,
+      default: ''
+    },
+    fullRangeStart: {
+      type: String,
+      default: '1900-01-01'
+    }
+  },
+  data() {
+    return {
+      periodType: this.defaultPeriod,
+      dateRange: []
+    }
+  },
+  computed: {
+    queryParams() {
+      const params = {
+        periodType: this.periodType
+      }
+      if (this.dateRange && this.dateRange.length === 2) {
+        params.startDate = this.dateRange[0]
+        params.endDate = this.dateRange[1]
+      }
+      return params
+    },
+    pickerOptions() {
+      return {
+        disabledDate: time => {
+          if (!this.maxDate) return false
+          return time.getTime() > new Date(`${this.maxDate}T23:59:59`).getTime()
+        }
+      }
+    }
+  },
+  watch: {
+    maxDate: {
+      immediate: true,
+      handler(value) {
+        if (value && (!this.dateRange || this.dateRange.length === 0)) {
+          const stored = this.loadStoredFilter()
+          if (stored) {
+            this.periodType = stored.periodType || this.defaultPeriod
+            this.dateRange = this.periodType === 'all' ? this.buildRange('all') : stored.dateRange
+          } else {
+            this.dateRange = this.buildRange(this.periodType)
+          }
+          this.emitChange()
+        }
+      }
+    }
+  },
+  methods: {
+    pad2(value) {
+      return String(value).padStart(2, '0')
+    },
+    toDate(value) {
+      if (value instanceof Date) return new Date(value.getTime())
+      if (!value) return new Date()
+      return new Date(`${value}T00:00:00`)
+    },
+    formatYmd(value) {
+      const date = this.toDate(value)
+      return `${date.getFullYear()}-${this.pad2(date.getMonth() + 1)}-${this.pad2(date.getDate())}`
+    },
+    startOfMonth(value) {
+      const date = this.toDate(value)
+      return new Date(date.getFullYear(), date.getMonth(), 1)
+    },
+    endOfMonth(value) {
+      const date = this.toDate(value)
+      return new Date(date.getFullYear(), date.getMonth() + 1, 0)
+    },
+    startOfYear(value) {
+      const date = this.toDate(value)
+      return new Date(date.getFullYear(), 0, 1)
+    },
+    endOfYear(value) {
+      const date = this.toDate(value)
+      return new Date(date.getFullYear(), 11, 31)
+    },
+    buildRange(type) {
+      const baseDate = this.toDate(this.maxDate || new Date())
+      if (type === 'all') {
+        return [this.fullRangeStart, this.formatYmd(baseDate)]
+      }
+      if (type === 'year') {
+        return [this.formatYmd(this.startOfYear(baseDate)), this.formatYmd(this.endOfYear(baseDate))]
+      }
+      if (type === 'month') {
+        return [this.formatYmd(this.startOfMonth(baseDate)), this.formatYmd(this.endOfMonth(baseDate))]
+      }
+      const day = this.formatYmd(baseDate)
+      return [day, day]
+    },
+    handlePeriodChange() {
+      this.dateRange = this.buildRange(this.periodType)
+      this.emitChange()
+    },
+    reset() {
+      this.periodType = this.defaultPeriod
+      this.dateRange = this.maxDate ? this.buildRange(this.periodType) : []
+      this.emitChange()
+    },
+    loadStoredFilter() {
+      if (!this.storageKey) {
+        return null
+      }
+      try {
+        const raw = window.sessionStorage.getItem(this.storageKey)
+        if (!raw) {
+          return null
+        }
+        const parsed = JSON.parse(raw)
+        if (!parsed || !Array.isArray(parsed.dateRange) || parsed.dateRange.length !== 2) {
+          return null
+        }
+        return parsed
+      } catch (error) {
+        return null
+      }
+    },
+    saveStoredFilter() {
+      if (!this.storageKey) {
+        return
+      }
+      window.sessionStorage.setItem(this.storageKey, JSON.stringify({
+        periodType: this.periodType,
+        dateRange: this.dateRange
+      }))
+    },
+    emitChange() {
+      this.saveStoredFilter()
+      this.$emit('change', this.queryParams)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.order-date-filter {
+  padding: 16px 16px 0;
+  background: #fff;
+  border: 1px solid #e5e6eb;
+  border-radius: 4px;
+}
+</style>

+ 34 - 5
src/views/order/efficiency/index.vue

@@ -5,6 +5,13 @@
       <p class="page-description">分析各部门的平均销售额与渠道商品多样性。</p>
     </header>
 
+    <OrderDateRangeFilter
+      :max-date="maxDate"
+      default-period="day"
+      storage-key="dtm-shop-date-filter"
+      @change="handleFilterChange"
+    />
+
     <section class="chart-area">
       <OrderLoadingPanel
         v-if="barChart.loading"
@@ -36,7 +43,8 @@
 <script>
 import * as echarts from 'echarts'
 import OrderLoadingPanel from '../components/OrderLoadingPanel.vue'
-import { getShopChannelDiversity, getShopDepartmentEfficiency } from '@/api/order'
+import OrderDateRangeFilter from '../components/OrderDateRangeFilter.vue'
+import { getShopChannelDiversity, getShopDepartmentEfficiency, getShopMaxDate } from '@/api/order'
 
 const EMPTY_DATA_HINTS = ['未上传', '请先上传', '暂无数据', '无数据', 'not found', 'no data']
 
@@ -52,18 +60,21 @@ function createChartState(defaultTitle) {
 export default {
   name: 'OrderEfficiency',
   components: {
-    OrderLoadingPanel
+    OrderLoadingPanel,
+    OrderDateRangeFilter
   },
   data() {
     return {
       barChartInstance: null,
       pieChartInstance: null,
+      maxDate: '',
+      currentQuery: {},
       barChart: createChartState('部门效率数据暂不可用'),
       pieChart: createChartState('渠道多样性数据暂不可用')
     }
   },
   mounted() {
-    this.loadCharts()
+    this.initFilter()
     window.addEventListener('resize', this.handleResize)
   },
   beforeDestroy() {
@@ -74,6 +85,18 @@ export default {
     async loadCharts() {
       await Promise.all([this.initBarChart(), this.initPieChart()])
     },
+    async initFilter() {
+      try {
+        this.maxDate = await getShopMaxDate()
+      } catch (error) {
+        const today = new Date()
+        this.maxDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
+      }
+    },
+    handleFilterChange(params) {
+      this.currentQuery = params || {}
+      this.loadCharts()
+    },
     disposeCharts() {
       if (this.barChartInstance) {
         this.barChartInstance.dispose()
@@ -158,7 +181,10 @@ export default {
     async initBarChart() {
       this.resetChartState(this.barChart, '部门效率数据暂不可用')
       try {
-        const response = await getShopDepartmentEfficiency()
+        const response = await getShopDepartmentEfficiency({
+          startDate: this.currentQuery.startDate,
+          endDate: this.currentQuery.endDate
+        })
         const chartData = this.normalizeBarData(response)
 
         await this.$nextTick()
@@ -211,7 +237,10 @@ export default {
     async initPieChart() {
       this.resetChartState(this.pieChart, '渠道多样性数据暂不可用')
       try {
-        const response = await getShopChannelDiversity()
+        const response = await getShopChannelDiversity({
+          startDate: this.currentQuery.startDate,
+          endDate: this.currentQuery.endDate
+        })
         const chartData = this.normalizePieData(response)
 
         await this.$nextTick()

+ 20 - 26
src/views/order/ordervalue/index.vue

@@ -1,30 +1,16 @@
-<template>
+<template>
   <div class="order-value-view">
     <header class="page-header">
       <h1 class="page-title">订单价值</h1>
-      <div class="header-controls">
-        <span class="control-label">时光回溯控制器</span>
-        <el-date-picker
-          v-model="selectedDate"
-          class="date-picker"
-          type="date"
-          value-format="yyyy-MM-dd"
-          :clearable="false"
-          :editable="false"
-          :picker-options="pickerOptions"
-          placeholder="选择上传数据日期"
-          @change="handleDateChange"
-        />
-        <div class="control-item-group">
-          <button :class="['time-tab', { active: activeTab === 'day' }]" @click="selectDateRange('day')">当天</button>
-          <button :class="['time-tab', { active: activeTab === '7d' }]" @click="selectDateRange('7d')">最近7天</button>
-          <button :class="['time-tab', { active: activeTab === 'tm' }]" @click="selectDateRange('tm')">本月</button>
-          <button :class="['time-tab', { active: activeTab === 'lm' }]" @click="selectDateRange('lm')">上月</button>
-          <button :class="['time-tab', { active: activeTab === 'all' }]" @click="selectDateRange('all')">全量数据</button>
-        </div>
-      </div>
     </header>
 
+    <OrderDateRangeFilter
+      :max-date="maxDate"
+      default-period="day"
+      storage-key="dtm-order-date-filter"
+      @change="handleFilterChange"
+    />
+
     <section class="kpi-cards-grid">
       <OrderLoadingPanel
         v-if="dashboardLoading"
@@ -61,6 +47,7 @@ import FunnelChart from './FunnelChart/index.vue'
 import Top5PieChart from './Top5PieChart/index.vue'
 import LeakageCard from './LeakageCard/index.vue'
 import OrderLoadingPanel from '../components/OrderLoadingPanel.vue'
+import OrderDateRangeFilter from '../components/OrderDateRangeFilter.vue'
 import {
   getOrderAveragePaymentTime,
   getOrderGmv,
@@ -76,7 +63,8 @@ export default {
     FunnelChart,
     Top5PieChart,
     LeakageCard,
-    OrderLoadingPanel
+    OrderLoadingPanel,
+    OrderDateRangeFilter
   },
   data() {
     return {
@@ -107,6 +95,15 @@ export default {
     this.initDashboard()
   },
   methods: {
+    handleFilterChange(params) {
+      const range = {
+        start: params.startDate || this.maxDate,
+        end: params.endDate || this.maxDate
+      }
+      this.activeTab = params.periodType || 'day'
+      this.currentDateRange = range
+      this.fetchAllApiData(range).finally(() => this.finishDashboardLoading())
+    },
     handleDateChange() {
       if (!this.selectedDate) {
         this.selectedDate = this.maxDate
@@ -250,14 +247,11 @@ export default {
       try {
         const res = await getOrderMaxDate()
         this.maxDate = res
-        this.selectedDate = res
       } catch (error) {
         console.error('获取订单最大日期失败:', error)
         const today = this.formatYmd(new Date())
         this.maxDate = today
-        this.selectedDate = today
       }
-      await this.selectDateRange(this.activeTab)
     },
     async selectDateRange(type) {
       this.activeTab = type

+ 29 - 3
src/views/order/related/index.vue

@@ -5,6 +5,13 @@
       <p class="page-subtitle">探索商品之间的共同购买关系,发现潜在高价值组合。</p>
     </header>
 
+    <OrderDateRangeFilter
+      :max-date="maxDate"
+      default-period="day"
+      storage-key="dtm-order-date-filter"
+      @change="handleFilterChange"
+    />
+
     <div class="table-container">
       <OrderLoadingPanel
         v-if="loading"
@@ -69,12 +76,14 @@
 <script>
 import * as echarts from 'echarts'
 import OrderLoadingPanel from '../components/OrderLoadingPanel.vue'
-import { getOrderCoPurchase } from '@/api/order'
+import OrderDateRangeFilter from '../components/OrderDateRangeFilter.vue'
+import { getOrderCoPurchase, getOrderMaxDate } from '@/api/order'
 
 export default {
   name: 'OrderRelated',
   components: {
-    OrderLoadingPanel
+    OrderLoadingPanel,
+    OrderDateRangeFilter
   },
   data() {
     return {
@@ -83,6 +92,8 @@ export default {
       currentPage: 1,
       itemsPerPage: 10,
       skuKeyword: '',
+      maxDate: '',
+      currentQuery: {},
       chartInstance: null
     }
   },
@@ -96,7 +107,7 @@ export default {
     }
   },
   mounted() {
-    this.fetchData()
+    this.initFilter()
     window.addEventListener('resize', this.handleResize)
   },
   beforeDestroy() {
@@ -114,10 +125,25 @@ export default {
       this.currentPage = 1
       this.fetchData()
     },
+    async initFilter() {
+      try {
+        this.maxDate = await getOrderMaxDate()
+      } catch (error) {
+        const today = new Date()
+        this.maxDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
+      }
+    },
+    handleFilterChange(params) {
+      this.currentQuery = params || {}
+      this.currentPage = 1
+      this.fetchData()
+    },
     async fetchData() {
       this.loading = true
       try {
         const response = await getOrderCoPurchase({
+          startDate: this.currentQuery.startDate,
+          endDate: this.currentQuery.endDate,
           skuKeyword: this.skuKeyword || undefined
         })
         this.coPurchaseData = response || []

+ 192 - 0
src/views/order/shopreport/index.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="app-container shop-report-page">
+    <OrderDateRangeFilter
+      :max-date="maxDate"
+      default-period="day"
+      storage-key="dtm-shop-date-filter"
+      class="report-toolbar"
+      @change="handleFilterChange"
+    >
+      <template slot="actions">
+        <el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport">导出 Excel</el-button>
+      </template>
+    </OrderDateRangeFilter>
+
+    <el-alert
+      class="report-alert"
+      title="当前没有成本/费用数据源,毛利额和毛利率字段已预留,暂不参与计算。"
+      type="warning"
+      :closable="false"
+      show-icon
+    />
+
+    <el-row :gutter="16" class="summary-row">
+      <el-col :xs="24" :sm="12" :md="6">
+        <div class="summary-item">
+          <span class="summary-label">销售额</span>
+          <strong>¥{{ formatMoney(summary.salesAmount) }}</strong>
+        </div>
+      </el-col>
+      <el-col :xs="24" :sm="12" :md="6">
+        <div class="summary-item">
+          <span class="summary-label">订单量</span>
+          <strong>{{ formatNumber(summary.orderCount) }}</strong>
+        </div>
+      </el-col>
+      <el-col :xs="24" :sm="12" :md="6">
+        <div class="summary-item">
+          <span class="summary-label">销量</span>
+          <strong>{{ formatNumber(summary.totalQuantity) }}</strong>
+        </div>
+      </el-col>
+      <el-col :xs="24" :sm="12" :md="6">
+        <div class="summary-item">
+          <span class="summary-label">平均客单价</span>
+          <strong>¥{{ formatMoney(summary.avgOrderValue) }}</strong>
+        </div>
+      </el-col>
+    </el-row>
+
+    <el-table v-loading="loading" :data="reportList" border stripe class="report-table">
+      <el-table-column label="统计周期" prop="period" min-width="110" fixed="left" />
+      <el-table-column label="平台" prop="platformName" min-width="140" show-overflow-tooltip />
+      <el-table-column label="渠道/店铺" prop="channelName" min-width="160" show-overflow-tooltip />
+      <el-table-column label="订单量" prop="orderCount" min-width="100" align="right">
+        <template slot-scope="scope">{{ formatNumber(scope.row.orderCount) }}</template>
+      </el-table-column>
+      <el-table-column label="销量" prop="totalQuantity" min-width="100" align="right">
+        <template slot-scope="scope">{{ formatNumber(scope.row.totalQuantity) }}</template>
+      </el-table-column>
+      <el-table-column label="销售额" prop="salesAmount" min-width="130" align="right">
+        <template slot-scope="scope">¥{{ formatMoney(scope.row.salesAmount) }}</template>
+      </el-table-column>
+      <el-table-column label="客单价" prop="avgOrderValue" min-width="120" align="right">
+        <template slot-scope="scope">¥{{ formatMoney(scope.row.avgOrderValue) }}</template>
+      </el-table-column>
+      <el-table-column label="毛利额" prop="grossProfit" min-width="100" align="center">
+        <template slot-scope="scope">{{ scope.row.grossProfit || '--' }}</template>
+      </el-table-column>
+      <el-table-column label="毛利率" prop="grossMargin" min-width="100" align="center">
+        <template slot-scope="scope">{{ scope.row.grossMargin || '--' }}</template>
+      </el-table-column>
+      <el-table-column label="备注" prop="remark" min-width="260" show-overflow-tooltip />
+    </el-table>
+  </div>
+</template>
+
+<script>
+import { getShopMaxDate, getShopOperationReport } from '@/api/order'
+import OrderDateRangeFilter from '@/views/order/components/OrderDateRangeFilter'
+
+export default {
+  name: 'ShopOperationReport',
+  components: {
+    OrderDateRangeFilter
+  },
+  data() {
+    return {
+      loading: false,
+      maxDate: '',
+      currentQuery: {},
+      reportList: []
+    }
+  },
+  computed: {
+    summary() {
+      const total = this.reportList.reduce((result, row) => {
+        result.orderCount += Number(row.orderCount || 0)
+        result.totalQuantity += Number(row.totalQuantity || 0)
+        result.salesAmount += Number(row.salesAmount || 0)
+        return result
+      }, { orderCount: 0, totalQuantity: 0, salesAmount: 0 })
+      total.avgOrderValue = total.orderCount > 0 ? total.salesAmount / total.orderCount : 0
+      return total
+    }
+  },
+  created() {
+    this.initFilter()
+  },
+  methods: {
+    async initFilter() {
+      try {
+        const maxDate = await getShopMaxDate()
+        this.maxDate = maxDate || ''
+      } catch (error) {
+        this.maxDate = ''
+      }
+    },
+    buildParams() {
+      return {
+        periodType: this.currentQuery.periodType || 'day',
+        startDate: this.currentQuery.startDate,
+        endDate: this.currentQuery.endDate
+      }
+    },
+    handleFilterChange(params) {
+      this.currentQuery = params || {}
+      this.handleQuery()
+    },
+    async handleQuery() {
+      this.loading = true
+      try {
+        const res = await getShopOperationReport(this.buildParams())
+        this.reportList = Array.isArray(res.data) ? res.data : []
+      } finally {
+        this.loading = false
+      }
+    },
+    handleExport() {
+      this.download('/api/shop/report/export', this.buildParams(), `店铺运营报表_${new Date().getTime()}.xlsx`)
+    },
+    formatNumber(value) {
+      const number = Number(value || 0)
+      return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(number)
+    },
+    formatMoney(value) {
+      const number = Number(value || 0)
+      return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.shop-report-page {
+  background: #f5f7fa;
+  min-height: calc(100vh - 84px);
+}
+
+.report-toolbar,
+.report-alert,
+.summary-row,
+.report-table {
+  margin-bottom: 16px;
+}
+
+.summary-item {
+  min-height: 88px;
+  padding: 18px 20px;
+  background: #fff;
+  border: 1px solid #e5e6eb;
+  border-radius: 4px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.summary-label {
+  margin-bottom: 10px;
+  color: #606266;
+  font-size: 13px;
+}
+
+.summary-item strong {
+  color: #1f2d3d;
+  font-size: 24px;
+  line-height: 1.2;
+}
+
+.report-table {
+  width: 100%;
+}
+</style>

+ 19 - 26
src/views/order/shopvalue/index.vue

@@ -1,33 +1,19 @@
-<template>
+<template>
   <div class="shop-value-analysis-page">
     <header class="page-header">
       <div>
         <h1 class="main-title">店铺价值分析</h1>
         <p class="subtitle">实时监控关键指标与趋势</p>
       </div>
-      <div class="header-actions">
-        <span class="control-label">时间筛选</span>
-        <el-date-picker
-          v-model="selectedDate"
-          class="date-picker"
-          type="date"
-          value-format="yyyy-MM-dd"
-          :clearable="false"
-          :editable="false"
-          :picker-options="pickerOptions"
-          placeholder="选择上传数据日期"
-          @change="handleDateChange"
-        />
-        <div class="tab-group">
-          <button :class="['time-tab', { active: activeTab === 'day' }]" @click="applyDateFilter('day')">当天</button>
-          <button :class="['time-tab', { active: activeTab === '7d' }]" @click="applyDateFilter('7d')">最近7天</button>
-          <button :class="['time-tab', { active: activeTab === 'tm' }]" @click="applyDateFilter('tm')">本月</button>
-          <button :class="['time-tab', { active: activeTab === 'lm' }]" @click="applyDateFilter('lm')">上月</button>
-          <button :class="['time-tab', { active: activeTab === 'all' }]" @click="applyDateFilter('all')">全量数据</button>
-        </div>
-      </div>
     </header>
 
+    <OrderDateRangeFilter
+      :max-date="maxDate"
+      default-period="day"
+      storage-key="dtm-shop-date-filter"
+      @change="handleFilterChange"
+    />
+
     <div class="chart-card">
       <OrderLoadingPanel
         v-if="topProductLoading"
@@ -106,6 +92,7 @@
 <script>
 import * as echarts from 'echarts'
 import OrderLoadingPanel from '../components/OrderLoadingPanel.vue'
+import OrderDateRangeFilter from '../components/OrderDateRangeFilter.vue'
 import {
   getShopChannelContribution,
   getShopChannelRoiValue,
@@ -118,7 +105,8 @@ import {
 export default {
   name: 'ShopValue',
   components: {
-    OrderLoadingPanel
+    OrderLoadingPanel,
+    OrderDateRangeFilter
   },
   data() {
     return {
@@ -150,6 +138,14 @@ export default {
     window.removeEventListener('resize', this.handleResize)
   },
   methods: {
+    handleFilterChange(params) {
+      this.activeTab = params.periodType || 'day'
+      this.currentDateRange = {
+        start: params.startDate || this.maxDate,
+        end: params.endDate || this.maxDate
+      }
+      this.fetchData()
+    },
     getQueryParams() {
       return {
         startDate: this.currentDateRange.start,
@@ -226,14 +222,11 @@ export default {
       try {
         const maxDate = await getShopMaxDate()
         this.maxDate = maxDate
-        this.selectedDate = maxDate
       } catch (error) {
         console.error('获取店铺最大日期失败:', error)
         const today = this.formatYmd(new Date())
         this.maxDate = today
-        this.selectedDate = today
       }
-      await this.applyDateFilter(this.activeTab)
     },
     async applyDateFilter(type) {
       this.activeTab = type

+ 429 - 0
src/views/supply/monitor/index.vue

@@ -0,0 +1,429 @@
+<template>
+  <div class="app-container">
+    <div class="page-header">
+      <h2><i class="el-icon-s-data"></i> 供应商查询、对比与付款计划</h2>
+      <p class="page-desc">基于供应商账期、采购订单、采购入库和订单入库合并数据,补充供应商实时查询、对比分析和预计付款计划。</p>
+    </div>
+
+    <el-card class="box-card">
+      <div slot="header" class="clearfix">
+        <span><i class="el-icon-search"></i> 查询条件</span>
+      </div>
+      <el-form :inline="true" size="small" class="form-inline">
+        <el-form-item label="供应商名称">
+          <el-input v-model="query.supplierName" clearable placeholder="输入供应商名称" style="width: 260px" @keyup.enter.native="handleQuery" />
+        </el-form-item>
+        <el-form-item label="日期范围">
+          <el-date-picker
+            v-model="dateRange"
+            type="daterange"
+            value-format="yyyy-MM-dd"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
+          <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-alert
+      class="box-card"
+      title="付款计划当前按 实际验收日期 + 供应商账期 推算;如果后续接入发票日期和真实付款日期,可以替换为真实付款计划。"
+      type="warning"
+      show-icon
+      :closable="false"
+    />
+
+    <el-tabs v-model="activeTab" type="border-card" class="box-card" @tab-click="handleTabClick">
+      <el-tab-pane label="供应商列表" name="list">
+        <el-table
+          v-loading="loading"
+          :data="pagedSupplierList"
+          row-key="supplierName"
+          border
+          highlight-current-row
+          style="width: 100%"
+          @selection-change="handleSelectionChange"
+        >
+          <el-table-column type="selection" width="48" reserve-selection />
+          <el-table-column prop="supplierName" label="供应商名称" min-width="220" fixed="left" show-overflow-tooltip />
+          <el-table-column prop="supplierCode" label="供应商代码" width="110" />
+          <el-table-column label="账期" width="90" align="right">
+            <template slot-scope="scope">{{ scope.row.termDays || 0 }}天</template>
+          </el-table-column>
+          <el-table-column label="订单量" prop="orderCount" width="90" align="right" />
+          <el-table-column label="订单数量" width="110" align="right">
+            <template slot-scope="scope">{{ formatNumber(scope.row.orderQty) }}</template>
+          </el-table-column>
+          <el-table-column label="入库数量" width="110" align="right">
+            <template slot-scope="scope">{{ formatNumber(scope.row.receiptQty) }}</template>
+          </el-table-column>
+          <el-table-column label="入库金额" width="130" align="right">
+            <template slot-scope="scope">¥{{ formatMoney(scope.row.receiptAmount) }}</template>
+          </el-table-column>
+          <el-table-column label="交付及时率" width="120" align="right">
+            <template slot-scope="scope">{{ formatPercent(scope.row.deliveryRate) }}</template>
+          </el-table-column>
+          <el-table-column label="完成率" width="110" align="right">
+            <template slot-scope="scope">{{ formatPercent(scope.row.completionRate) }}</template>
+          </el-table-column>
+        </el-table>
+        <div class="pagination-wrap">
+          <el-pagination
+            :current-page.sync="pagination.list.page"
+            :page-size="pagination.list.size"
+            :total="supplierList.length"
+            layout="total, prev, pager, next, jumper"
+          />
+        </div>
+      </el-tab-pane>
+
+      <el-tab-pane label="供应商对比" name="compare">
+        <div class="toolbar">
+          <span>已选择 {{ selectedSuppliers.length }} 个供应商,支持勾选 2-5 个供应商进行对比。</span>
+          <el-button type="primary" size="mini" :disabled="selectedSuppliers.length < 2" @click="loadCompare">生成对比</el-button>
+        </div>
+        <div class="chart-card">
+          <div class="chart-title">供应商核心指标柱状对比</div>
+          <div v-if="compareList.length === 0" class="chart-empty">请选择 2-5 个供应商并生成对比</div>
+          <div ref="compareChart" class="compare-chart" />
+        </div>
+        <el-table v-loading="compareLoading" :data="pagedCompareList" border highlight-current-row style="width: 100%">
+          <el-table-column prop="supplierName" label="供应商名称" min-width="220" fixed="left" show-overflow-tooltip />
+          <el-table-column label="账期" width="90" align="right">
+            <template slot-scope="scope">{{ scope.row.termDays || 0 }}天</template>
+          </el-table-column>
+          <el-table-column label="订单量" prop="orderCount" width="90" align="right" />
+          <el-table-column label="订单金额" width="130" align="right">
+            <template slot-scope="scope">¥{{ formatMoney(scope.row.orderAmount) }}</template>
+          </el-table-column>
+          <el-table-column label="入库金额" width="130" align="right">
+            <template slot-scope="scope">¥{{ formatMoney(scope.row.receiptAmount) }}</template>
+          </el-table-column>
+          <el-table-column label="交付及时率" width="120" align="right">
+            <template slot-scope="scope">{{ formatPercent(scope.row.deliveryRate) }}</template>
+          </el-table-column>
+          <el-table-column label="完成率" width="110" align="right">
+            <template slot-scope="scope">{{ formatPercent(scope.row.completionRate) }}</template>
+          </el-table-column>
+          <el-table-column label="未完工数" width="120" align="right">
+            <template slot-scope="scope">{{ formatNumber(scope.row.uncompletedQty) }}</template>
+          </el-table-column>
+        </el-table>
+        <div class="pagination-wrap">
+          <el-pagination
+            :current-page.sync="pagination.compare.page"
+            :page-size="pagination.compare.size"
+            :total="compareList.length"
+            layout="total, prev, pager, next, jumper"
+          />
+        </div>
+      </el-tab-pane>
+
+      <el-tab-pane label="付款计划" name="payment">
+        <el-table v-loading="paymentLoading" :data="pagedPaymentPlan" border highlight-current-row style="width: 100%">
+          <el-table-column prop="dueDate" label="预计付款日" width="120" fixed="left" />
+          <el-table-column prop="supplierName" label="供应商名称" min-width="220" show-overflow-tooltip />
+          <el-table-column label="账期" width="90" align="right">
+            <template slot-scope="scope">{{ scope.row.termDays || 0 }}天</template>
+          </el-table-column>
+          <el-table-column label="预计付款金额" width="150" align="right">
+            <template slot-scope="scope">¥{{ formatMoney(scope.row.estimatedPayAmount) }}</template>
+          </el-table-column>
+          <el-table-column label="入库数量" width="120" align="right">
+            <template slot-scope="scope">{{ formatNumber(scope.row.receiptQty) }}</template>
+          </el-table-column>
+          <el-table-column prop="receiptLines" label="入库行数" width="100" align="right" />
+          <el-table-column prop="status" label="状态" width="110" align="center">
+            <template slot-scope="scope">
+              <el-tag :type="paymentTag(scope.row.status)" size="mini">{{ scope.row.status }}</el-tag>
+            </template>
+          </el-table-column>
+        </el-table>
+        <div class="pagination-wrap">
+          <el-pagination
+            :current-page.sync="pagination.payment.page"
+            :page-size="pagination.payment.size"
+            :total="paymentPlan.length"
+            layout="total, prev, pager, next, jumper"
+          />
+        </div>
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { getSupplyMonitorCompare, getSupplyMonitorSuppliers, getSupplyPaymentPlan } from '@/api/supply'
+
+export default {
+  name: 'SupplyMonitorEnhancement',
+  data() {
+    return {
+      activeTab: 'list',
+      loading: false,
+      compareLoading: false,
+      paymentLoading: false,
+      compareChart: null,
+      query: {
+        supplierName: ''
+      },
+      dateRange: [],
+      supplierList: [],
+      selectedSuppliers: [],
+      compareList: [],
+      paymentPlan: [],
+      pagination: {
+        list: { page: 1, size: 20 },
+        compare: { page: 1, size: 20 },
+        payment: { page: 1, size: 20 }
+      }
+    }
+  },
+  created() {
+    this.handleQuery()
+  },
+  mounted() {
+    window.addEventListener('resize', this.handleResize)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.handleResize)
+    if (this.compareChart) {
+      this.compareChart.dispose()
+      this.compareChart = null
+    }
+  },
+  computed: {
+    pagedSupplierList() {
+      return this.slicePage(this.supplierList, this.pagination.list)
+    },
+    pagedCompareList() {
+      return this.slicePage(this.compareList, this.pagination.compare)
+    },
+    pagedPaymentPlan() {
+      return this.slicePage(this.paymentPlan, this.pagination.payment)
+    }
+  },
+  methods: {
+    slicePage(list, pager) {
+      const start = (pager.page - 1) * pager.size
+      return list.slice(start, start + pager.size)
+    },
+    buildParams() {
+      const params = {
+        supplierName: this.query.supplierName
+      }
+      if (this.dateRange && this.dateRange.length === 2) {
+        params.startDate = this.dateRange[0]
+        params.endDate = this.dateRange[1]
+      }
+      return params
+    },
+    handleQuery() {
+      this.loadSuppliers()
+      if (this.activeTab === 'payment') {
+        this.loadPaymentPlan()
+      } else {
+        this.paymentPlan = []
+      }
+      this.compareList = []
+      this.$nextTick(this.renderCompareChart)
+    },
+    resetQuery() {
+      this.query.supplierName = ''
+      this.dateRange = []
+      this.handleQuery()
+    },
+    async loadSuppliers() {
+      this.loading = true
+      try {
+        const res = await getSupplyMonitorSuppliers(this.buildParams())
+        this.supplierList = Array.isArray(res.data) ? res.data : []
+        this.pagination.list.page = 1
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadPaymentPlan() {
+      this.paymentLoading = true
+      try {
+        const res = await getSupplyPaymentPlan(this.buildParams())
+        this.paymentPlan = Array.isArray(res.data) ? res.data : []
+        this.pagination.payment.page = 1
+      } finally {
+        this.paymentLoading = false
+      }
+    },
+    handleTabClick(tab) {
+      if (tab.name === 'payment' && this.paymentPlan.length === 0) {
+        this.loadPaymentPlan()
+      }
+      if (tab.name === 'compare') {
+        this.$nextTick(this.renderCompareChart)
+      }
+    },
+    async loadCompare() {
+      const supplierNames = this.selectedSuppliers.slice(0, 5).map(item => item.supplierName).join(',')
+      this.compareLoading = true
+      try {
+        const res = await getSupplyMonitorCompare({
+          ...this.buildParams(),
+          supplierNames
+        })
+        this.compareList = Array.isArray(res.data) ? res.data : []
+        this.pagination.compare.page = 1
+        this.activeTab = 'compare'
+        this.$nextTick(this.renderCompareChart)
+      } finally {
+        this.compareLoading = false
+      }
+    },
+    handleSelectionChange(selection) {
+      this.selectedSuppliers = selection.slice(0, 5)
+    },
+    renderCompareChart() {
+      const chartEl = this.$refs.compareChart
+      if (!chartEl) return
+      this.compareChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
+      const names = this.compareList.map(item => item.supplierName)
+      this.compareChart.setOption({
+        color: ['#409eff', '#67c23a', '#e6a23c', '#f56c6c'],
+        tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+        legend: { top: 0, data: ['订单金额', '入库金额', '交付及时率', '完成率'] },
+        grid: { left: 56, right: 24, top: 48, bottom: 64 },
+        xAxis: {
+          type: 'category',
+          data: names,
+          axisLabel: { interval: 0, rotate: names.length > 3 ? 20 : 0 }
+        },
+        yAxis: [
+          { type: 'value', name: '金额', axisLabel: { formatter: value => this.compactMoney(value) } },
+          { type: 'value', name: '比例', min: 0, max: 100, axisLabel: { formatter: '{value}%' } }
+        ],
+        series: [
+          { name: '订单金额', type: 'bar', data: this.compareList.map(item => Number(item.orderAmount || 0)) },
+          { name: '入库金额', type: 'bar', data: this.compareList.map(item => Number(item.receiptAmount || 0)) },
+          { name: '交付及时率', type: 'bar', yAxisIndex: 1, data: this.compareList.map(item => Number(item.deliveryRate || 0) * 100) },
+          { name: '完成率', type: 'bar', yAxisIndex: 1, data: this.compareList.map(item => Number(item.completionRate || 0) * 100) }
+        ]
+      }, true)
+      this.compareChart.resize()
+    },
+    handleResize() {
+      if (this.compareChart) this.compareChart.resize()
+    },
+    paymentTag(status) {
+      if (status === '已到期') return 'danger'
+      if (status === '7天内到期') return 'warning'
+      return 'success'
+    },
+    compactMoney(value) {
+      const number = Number(value || 0)
+      if (Math.abs(number) >= 10000) {
+        return `${this.formatNumber(number / 10000)}万`
+      }
+      return this.formatNumber(number)
+    },
+    formatNumber(value) {
+      return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(Number(value || 0))
+    },
+    formatMoney(value) {
+      return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value || 0))
+    },
+    formatPercent(value) {
+      return `${this.formatNumber(Number(value || 0) * 100)}%`
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-container {
+  padding: 20px;
+}
+
+.page-header {
+  margin-bottom: 20px;
+
+  h2 {
+    font-size: 24px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 8px;
+
+    i {
+      margin-right: 8px;
+      color: #409eff;
+    }
+  }
+
+  .page-desc {
+    color: #909399;
+    font-size: 14px;
+    margin: 0;
+  }
+}
+
+.box-card {
+  margin-bottom: 20px;
+}
+
+.form-inline {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  color: #606266;
+}
+
+.chart-card {
+  position: relative;
+  margin-bottom: 16px;
+  padding: 16px;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  background: #fff;
+}
+
+.chart-title {
+  margin-bottom: 8px;
+  color: #303133;
+  font-weight: 600;
+}
+
+.compare-chart {
+  width: 100%;
+  height: 320px;
+}
+
+.chart-empty {
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: 154px;
+  z-index: 2;
+  text-align: center;
+  color: #909399;
+}
+
+.pagination-wrap {
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 16px;
+}
+
+::v-deep .el-card__header {
+  font-weight: bold;
+}
+</style>