Explorar el Código

生命周期模块前端阶段建议卡片添加、数据上传模块完成8种数据文件的上传功能

Zhu Jiaqi hace 2 semanas
padre
commit
f0b28e2497
Se han modificado 3 ficheros con 741 adiciones y 273 borrados
  1. 40 0
      src/api/uploadData.js
  2. 251 7
      src/views/lifecycle/lifecycleAnalysis/index.vue
  3. 450 266
      src/views/upload/index.vue

+ 40 - 0
src/api/uploadData.js

@@ -0,0 +1,40 @@
+import request from '@/utils/request'
+
+export function uploadPreparedData(key, file) {
+  const data = new FormData()
+  data.append('file', file)
+
+  return request({
+    url: `/api/upload-data/${key}`,
+    method: 'post',
+    data,
+    timeout: 120000,
+    headers: {
+      'Content-Type': 'multipart/form-data',
+      repeatSubmit: false
+    }
+  })
+}
+
+export function previewPreparedData(key, file) {
+  const data = new FormData()
+  data.append('file', file)
+
+  return request({
+    url: `/api/upload-data/${key}/preview`,
+    method: 'post',
+    data,
+    timeout: 120000,
+    headers: {
+      'Content-Type': 'multipart/form-data',
+      repeatSubmit: false
+    }
+  })
+}
+
+export function getSupportedUploadItems() {
+  return request({
+    url: '/api/upload-data/supported',
+    method: 'get'
+  })
+}

+ 251 - 7
src/views/lifecycle/lifecycleAnalysis/index.vue

@@ -32,7 +32,7 @@
           clearable
         />
         <el-button type="primary" size="small" :loading="loading" @click="getList">刷新分析</el-button>
-        <el-button type="success" size="small" :disabled="!hasResults" @click="exportResults">导出分析</el-button>
+        <el-button type="success" size="small" :disabled="!hasRawResults" @click="exportResults">导出分析</el-button>
       </div>
       <div class="toolbar-status" v-if="generatedAt">数据更新时间:{{ generatedAt }}</div>
       <div class="toolbar-status muted" v-else>数据库实时统计</div>
@@ -48,6 +48,11 @@
             <el-select v-model="selectedValue" filterable size="small" class="entity-select">
               <el-option v-for="item in entityOptions" :key="item" :label="item" :value="item" />
             </el-select>
+            <el-select v-model="lifecycleStatusFilter" size="small" class="status-select">
+              <el-option label="全部生命周期" value="all" />
+              <el-option label="完整生命周期" value="complete" />
+              <el-option label="不完整生命周期" value="incomplete" />
+            </el-select>
           </div>
           <el-tag :type="stageTagType(currentStage)" effect="plain">{{ currentStage }}</el-tag>
         </div>
@@ -157,6 +162,39 @@
         </div>
       </div>
 
+      <div class="panel mb-20">
+        <div class="panel-header">
+          <div>
+            <h3>阶段建议</h3>
+            <p class="panel-subtitle">按当前{{ currentConfig.entityName }}的生命周期阶段生成运营动作,后续可接入 AI 输出个性化建议。</p>
+          </div>
+          <el-tag type="info" effect="plain">当前阶段:{{ currentStage }}</el-tag>
+        </div>
+        <div class="advice-grid">
+          <div
+            v-for="card in stageAdviceCards"
+            :key="card.stage"
+            class="advice-card"
+            :class="[card.className, { active: card.isCurrent }]"
+          >
+            <div class="advice-card-header">
+              <span class="advice-icon"><i :class="card.icon"></i></span>
+              <div>
+                <h4>{{ card.stage }}</h4>
+                <p>{{ card.summary }}</p>
+              </div>
+            </div>
+            <div class="advice-metrics">
+              <span>销售额占比 {{ card.revenuePercentage }}</span>
+              <span>持续 {{ card.durationDays }} 天</span>
+            </div>
+            <ul>
+              <li v-for="item in card.suggestions" :key="item">{{ item }}</li>
+            </ul>
+          </div>
+        </div>
+      </div>
+
       <div class="panel">
         <div class="panel-header">
           <h3>完整性评估细项</h3>
@@ -176,7 +214,7 @@
 
     <div v-else class="empty-state">
       <i class="el-icon-data-analysis"></i>
-      <p>{{ loading ? '正在加载数据库生命周期分析结果...' : `暂无${currentConfig.entityName}生命周期分析结果` }}</p>
+      <p>{{ emptyMessage }}</p>
     </div>
   </div>
 </template>
@@ -202,6 +240,11 @@ const CACHE_CONFIG = {
 }
 const DATE_RANGE_KEY = 'lifecycle_database_analysis_date_range'
 const stageOrder = ['引入期', '成长期', '成熟期', '衰退期']
+const lifecycleStatusOptions = {
+  all: '全部生命周期',
+  complete: '完整生命周期',
+  incomplete: '不完整生命周期'
+}
 const breakdownMapping = [
   { key: 'sufficient_time', label: '时间长度≥120天' },
   { key: 'reasonable_peak_position', label: '峰值位置25%-75%' },
@@ -213,6 +256,32 @@ const breakdownMapping = [
   { key: 'cycle_completeness', label: '周期完整性' },
   { key: 'trend_consistency', label: '趋势一致性' }
 ]
+const stageAdviceMapping = {
+  引入期: {
+    icon: 'el-icon-s-promotion',
+    className: 'intro',
+    summary: '验证市场接受度和基础转化效率。',
+    suggestions: ['聚焦首批核心渠道,避免过早铺量。', '跟踪点击、加购和退款反馈,快速修正标题、价格与主图。', '准备小批量补货阈值,防止试销转好后断货。']
+  },
+  成长期: {
+    icon: 'el-icon-top-right',
+    className: 'growth',
+    summary: '放大有效流量并保障供给稳定。',
+    suggestions: ['提升投放和活动资源,优先放大高转化渠道。', '按销量增速校准安全库存和补货周期。', '监控毛利、评价和履约稳定性,避免增长质量下滑。']
+  },
+  成熟期: {
+    icon: 'el-icon-medal',
+    className: 'maturity',
+    summary: '稳定利润表现并延长高质量销售窗口。',
+    suggestions: ['保持主推资源,控制折扣频率,守住毛利。', '通过组合、赠品或会员权益提升复购和客单价。', '观察竞品价格和搜索热度,提前准备迭代款。']
+  },
+  衰退期: {
+    icon: 'el-icon-bottom-right',
+    className: 'decline',
+    summary: '控制投入,降低库存和资源占用。',
+    suggestions: ['减少低效投放,将预算迁移到成长或潜力商品。', '结合库存水位设置清仓、套装或尾货策略。', '复盘衰退原因,沉淀到下一代商品选品和定价。']
+  }
+}
 
 const ANALYSIS_CONFIG = {
   sku: {
@@ -261,6 +330,7 @@ export default {
       dateRange: loadDateRange(),
       loading: false,
       error: '',
+      lifecycleStatusFilter: 'all',
       trendChart: null,
       stageCompareChart: null,
       cache: {
@@ -285,6 +355,9 @@ export default {
     results() {
       return this.currentCache.results || {}
     },
+    hasRawResults() {
+      return this.allEntityOptions.length > 0
+    },
     hasResults() {
       return this.entityOptions.length > 0
     },
@@ -296,9 +369,17 @@ export default {
         this.setSelectedValue(value)
       }
     },
-    entityOptions() {
+    allEntityOptions() {
       return Object.keys(this.results || {}).filter(key => key !== '_analysis_summary_')
     },
+    entityOptions() {
+      return this.allEntityOptions.filter(key => {
+        if (this.lifecycleStatusFilter === 'all') return true
+        const item = this.results[key]
+        const isComplete = !!(item && item.is_complete)
+        return this.lifecycleStatusFilter === 'complete' ? isComplete : !isComplete
+      })
+    },
     detail() {
       return (this.results && this.selectedValue && this.results[this.selectedValue]) || null
     },
@@ -339,6 +420,29 @@ export default {
         }
       })
     },
+    stageAdviceCards() {
+      return stageOrder.map(stage => {
+        const config = stageAdviceMapping[stage]
+        const stats = (this.displayStageStats && this.displayStageStats[stage]) || {}
+        return {
+          stage,
+          icon: config.icon,
+          className: config.className,
+          summary: config.summary,
+          suggestions: config.suggestions,
+          isCurrent: this.currentStage === stage,
+          revenuePercentage: this.formatPercent(stats.revenuePercentage),
+          durationDays: stats.durationDays != null ? stats.durationDays : 0
+        }
+      })
+    },
+    emptyMessage() {
+      if (this.loading) return '正在加载数据库生命周期分析结果...'
+      if (this.hasRawResults && !this.hasResults) {
+        return `暂无${lifecycleStatusOptions[this.lifecycleStatusFilter]}的${this.currentConfig.entityName}分析结果`
+      }
+      return `暂无${this.currentConfig.entityName}生命周期分析结果`
+    },
     totalRevenue() {
       if (this.detail && this.detail.total_revenue != null) return this.detail.total_revenue
       return 0
@@ -358,6 +462,9 @@ export default {
         localStorage.setItem(DATE_RANGE_KEY, JSON.stringify(this.dateRange || []))
       } catch (e) {}
     },
+    lifecycleStatusFilter() {
+      this.ensureCurrentSelection()
+    },
     detail() {
       this.$nextTick(() => {
         this.renderTrend()
@@ -382,7 +489,7 @@ export default {
       this.activeView = view
     },
     initCurrentView() {
-      if (!this.hasResults) {
+      if (!this.hasRawResults) {
         this.getList()
         return
       }
@@ -421,12 +528,17 @@ export default {
       this.persistCurrentCache()
     },
     ensureCurrentSelection() {
+      if (!this.hasRawResults) {
+        this.selectedValue = ''
+        this.destroyCharts()
+        return
+      }
       if (!this.hasResults) {
         this.selectedValue = ''
         this.destroyCharts()
         return
       }
-      if (!this.selectedValue || !this.results[this.selectedValue]) {
+      if (!this.selectedValue || this.entityOptions.indexOf(this.selectedValue) === -1) {
         this.selectedValue = this.pickFirstValue(this.results)
       }
     },
@@ -653,7 +765,7 @@ export default {
       return `${year}-${month}-${day}`
     },
     exportResults() {
-      if (!this.hasResults) {
+      if (!this.hasRawResults) {
         this.$modal.msgError('暂无可导出的分析结果')
         return
       }
@@ -670,7 +782,13 @@ export default {
     },
     pickFirstValue(results) {
       const keys = Object.keys(results || {})
-      return keys.find(key => key !== '_analysis_summary_') || ''
+      return keys.find(key => {
+        if (key === '_analysis_summary_') return false
+        if (this.lifecycleStatusFilter === 'all') return true
+        const item = results[key]
+        const isComplete = !!(item && item.is_complete)
+        return this.lifecycleStatusFilter === 'complete' ? isComplete : !isComplete
+      }) || ''
     }
   }
 }
@@ -788,16 +906,28 @@ export default {
   }
 }
 
+.panel-subtitle {
+  margin: -10px 0 0;
+  color: #909399;
+  font-size: 13px;
+  line-height: 1.6;
+}
+
 .selector-row {
   display: flex;
   align-items: center;
   gap: 10px;
+  flex-wrap: wrap;
 }
 
 .entity-select {
   width: 260px;
 }
 
+.status-select {
+  width: 150px;
+}
+
 .stats-grid {
   display: grid;
   grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -954,6 +1084,116 @@ export default {
   color: #b91c1c;
 }
 
+.advice-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+  gap: 16px;
+  margin-top: 18px;
+}
+
+.advice-card {
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  background: #ffffff;
+  padding: 16px;
+  min-height: 260px;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  position: relative;
+}
+
+.advice-card.active {
+  border-color: #409eff;
+  box-shadow: 0 6px 16px rgba(64, 158, 255, 0.12);
+}
+
+.advice-card::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 0;
+  bottom: 0;
+  width: 4px;
+  border-radius: 8px 0 0 8px;
+  background: #94a3b8;
+}
+
+.advice-card.intro::before {
+  background: #3b82f6;
+}
+
+.advice-card.growth::before {
+  background: #10b981;
+}
+
+.advice-card.maturity::before {
+  background: #f59e0b;
+}
+
+.advice-card.decline::before {
+  background: #ef4444;
+}
+
+.advice-card-header {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+
+  h4 {
+    margin: 0 0 6px;
+    font-size: 16px;
+    color: #303133;
+  }
+
+  p {
+    margin: 0;
+    color: #606266;
+    font-size: 13px;
+    line-height: 1.5;
+  }
+}
+
+.advice-icon {
+  width: 34px;
+  height: 34px;
+  border-radius: 8px;
+  background: #f1f5f9;
+  color: #2563eb;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex: 0 0 auto;
+  font-size: 18px;
+}
+
+.advice-metrics {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+
+  span {
+    font-size: 12px;
+    color: #475569;
+    background: #f8fafc;
+    border: 1px solid #e2e8f0;
+    border-radius: 6px;
+    padding: 5px 8px;
+  }
+}
+
+.advice-card ul {
+  margin: 0;
+  padding-left: 18px;
+  color: #475569;
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.advice-card li + li {
+  margin-top: 6px;
+}
+
 .breakdown-grid {
   display: grid;
   grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
@@ -1008,5 +1248,9 @@ export default {
   .entity-select {
     width: 100%;
   }
+
+  .status-select {
+    width: 100%;
+  }
 }
 </style>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 450 - 266
src/views/upload/index.vue


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio