index.vue 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318
  1. <template>
  2. <div class="app-container">
  3. <div class="page-header">
  4. <div>
  5. <h2><i class="el-icon-data-analysis"></i> 生命周期分析</h2>
  6. <p class="page-desc">支持在 SKU 和 SPU 两个维度查看生命周期分析结果</p>
  7. </div>
  8. <div class="analysis-switch">
  9. <button
  10. v-for="item in analysisTabs"
  11. :key="item.value"
  12. type="button"
  13. class="switch-btn"
  14. :class="{ active: activeView === item.value }"
  15. @click="switchAnalysis(item.value)"
  16. >
  17. {{ item.label }}
  18. </button>
  19. </div>
  20. </div>
  21. <div class="upload-toolbar">
  22. <div class="toolbar-left">
  23. <el-upload
  24. ref="toolbarUpload"
  25. class="toolbar-upload"
  26. :limit="1"
  27. accept=".xlsx,.xls,.csv"
  28. :http-request="customUpload"
  29. :disabled="upload.isUploading"
  30. :on-change="handleFileChange"
  31. :before-upload="beforeUpload"
  32. :auto-upload="false"
  33. :show-file-list="false"
  34. >
  35. <el-button plain>上传文件</el-button>
  36. </el-upload>
  37. <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">开始分析</el-button>
  38. <el-button type="success" :disabled="!hasResults" @click="exportResults">导出分析</el-button>
  39. </div>
  40. <div class="toolbar-status" v-if="upload.fileName">已上传:{{ upload.fileName }}</div>
  41. <div class="toolbar-status" v-else-if="upload.pendingFileName">已选择:{{ upload.pendingFileName }}</div>
  42. <div class="toolbar-status muted" v-else>未上传</div>
  43. </div>
  44. <template v-if="hasResults && detail">
  45. <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
  46. <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
  47. <div class="flex items-center gap-3">
  48. <label class="text-sm font-medium text-gray-700">选择{{ currentConfig.entityName }}:</label>
  49. <select
  50. v-model="selectedValue"
  51. class="border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm entity-select"
  52. >
  53. <option v-for="item in entityOptions" :key="item" :value="item">{{ item }}</option>
  54. </select>
  55. </div>
  56. <div class="flex items-center gap-2">
  57. <span class="text-sm font-medium text-gray-700">当前阶段:</span>
  58. <span class="px-3 py-1 bg-gradient-to-r from-yellow-100 to-amber-100 text-yellow-800 rounded-full text-sm font-medium flex items-center gap-2">
  59. {{ currentStage }}
  60. </span>
  61. </div>
  62. </div>
  63. <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  64. <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
  65. <p class="text-xs text-gray-500 uppercase tracking-wide">{{ currentConfig.entityDisplayLabel }}</p>
  66. <p class="text-lg font-medium text-gray-800 truncate">{{ selectedValue }}</p>
  67. </div>
  68. <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
  69. <p class="text-xs text-gray-500 uppercase tracking-wide">生命周期完整性</p>
  70. <p class="text-lg font-medium" :class="detail && detail.is_complete ? 'text-green-700' : 'text-orange-600'">
  71. {{ detail && detail.is_complete ? '完整' : '不完整' }}
  72. <span v-if="detail && detail.completeness_score != null" class="ml-2 text-sm text-gray-500">
  73. (得分: {{ detail.completeness_score }}/100)
  74. </span>
  75. </p>
  76. </div>
  77. <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
  78. <p class="text-xs text-gray-500 uppercase tracking-wide">总销售额</p>
  79. <p class="text-lg font-medium text-gray-800">{{ formatCurrency(totalRevenue) }}</p>
  80. </div>
  81. <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
  82. <p class="text-xs text-gray-500 uppercase tracking-wide">总销量</p>
  83. <p class="text-lg font-medium text-gray-800">{{ totalQty }}</p>
  84. </div>
  85. </div>
  86. </div>
  87. <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
  88. <div class="flex justify-between items-center mb-6">
  89. <h3 class="text-lg font-semibold text-gray-800">{{ currentConfig.entityName }}生命周期趋势图</h3>
  90. <div class="flex items-center gap-3">
  91. <div class="flex items-center gap-2">
  92. <span class="w-3 h-3 rounded-full bg-blue-500"></span>
  93. <span class="text-sm text-gray-600">销售额</span>
  94. </div>
  95. <div class="flex items-center gap-2">
  96. <span class="w-3 h-3 rounded-full bg-gray-500"></span>
  97. <span class="text-sm text-gray-600">销量</span>
  98. </div>
  99. </div>
  100. </div>
  101. <div class="h-96">
  102. <canvas ref="lifeCycleTrendRef"></canvas>
  103. </div>
  104. <div class="mt-4 space-y-2">
  105. <div v-if="detail && detail.is_complete" class="text-sm">
  106. <span :class="hasFourStages ? 'text-green-700 font-medium' : 'text-red-600 font-medium'">
  107. <i :class="hasFourStages ? 'fa fa-check-circle' : 'fa fa-exclamation-triangle'" class="mr-1"></i>
  108. {{ hasFourStages ? '阶段划分完整:已包含引入/成长/成熟/衰退四个阶段' : '阶段划分不完整:未包含所有四个标准阶段' }}
  109. </span>
  110. </div>
  111. <div v-if="detail && !detail.is_complete && detail.completeness_score != null" class="text-sm text-orange-600">
  112. <i class="fa fa-info-circle mr-1"></i>
  113. 不完整生命周期{{ currentConfig.entityName }} - 完整性评估得分:{{ detail.completeness_score }}/100(需要达到 80 分以上才能被评为完整周期)
  114. </div>
  115. </div>
  116. </div>
  117. <div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-20">
  118. <div class="bg-white rounded-xl p-6 shadow-sm space-y-6">
  119. <h3 class="text-lg font-semibold text-gray-800">基本指标</h3>
  120. <div>
  121. <div class="flex justify-between mb-2">
  122. <span class="text-sm text-gray-500">总销售额</span>
  123. <span class="text-sm font-medium text-gray-800">{{ formatCurrency(totalRevenue) }}</span>
  124. </div>
  125. <div class="progress-bar"><div class="progress-value bg-blue-500" :style="{ width: revenuePct + '%' }"></div></div>
  126. </div>
  127. <div>
  128. <div class="flex justify-between mb-2">
  129. <span class="text-sm text-gray-500">总销量</span>
  130. <span class="text-sm font-medium text-gray-800">{{ totalQty }}</span>
  131. </div>
  132. <div class="progress-bar"><div class="progress-value bg-gray-500" :style="{ width: qtyPct + '%' }"></div></div>
  133. </div>
  134. <div class="grid grid-cols-2 gap-4 pt-2">
  135. <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
  136. <p class="text-xs text-gray-500 uppercase tracking-wide">峰值销售额</p>
  137. <p class="text-lg font-medium text-gray-800">{{ formatCurrency(detail && detail.peak_revenue) }}</p>
  138. <p class="text-xs text-gray-400 mt-1">{{ formatDate(detail && detail.peak_revenue_date) }}</p>
  139. </div>
  140. <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
  141. <p class="text-xs text-gray-500 uppercase tracking-wide">峰值销量</p>
  142. <p class="text-lg font-medium text-gray-800">{{ detail && detail.peak_quantity != null ? detail.peak_quantity : '-' }}</p>
  143. <p class="text-xs text-gray-400 mt-1">{{ formatDate(detail && detail.peak_quantity_date) }}</p>
  144. </div>
  145. </div>
  146. <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
  147. <p class="text-xs text-gray-500 uppercase tracking-wide">当前阶段说明</p>
  148. <p class="text-sm text-gray-700">
  149. 处于 <span class="font-medium">{{ currentStage }}</span>,已持续
  150. <span class="font-medium">{{ currentStageDurationDays }}</span> 天。
  151. </p>
  152. <p class="text-xs text-gray-600 mt-1">{{ detail && detail.details ? detail.details : '-' }}</p>
  153. <p v-if="detail && !detail.is_complete && detail.next_stage_prediction" class="text-xs text-blue-600 mt-1">
  154. 下一阶段预测:{{ detail.next_stage_prediction }}
  155. </p>
  156. </div>
  157. </div>
  158. <div class="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm">
  159. <h3 class="text-lg font-semibold text-gray-800 mb-6">阶段分析</h3>
  160. <div class="overflow-x-auto">
  161. <table class="min-w-full divide-y divide-gray-200">
  162. <thead>
  163. <tr>
  164. <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">阶段</th>
  165. <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">总销售额</th>
  166. <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">销售额占比</th>
  167. <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">总销量</th>
  168. <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">销量占比</th>
  169. <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日均销售额</th>
  170. <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日均销量</th>
  171. <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">持续天数</th>
  172. <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">起止日期</th>
  173. </tr>
  174. </thead>
  175. <tbody class="bg-white divide-y divide-gray-200">
  176. <tr v-for="(stats, stage) in displayStageStats" :key="stage">
  177. <td class="px-4 py-3 text-sm"><span class="lifecycle-stage" :class="stageClass(stage)">{{ stage }}</span></td>
  178. <td class="px-4 py-3 text-sm font-medium text-gray-800">{{ formatCurrency(stats.totalRevenue) }}</td>
  179. <td class="px-4 py-3 text-sm text-gray-700">{{ formatPercent(stats.revenuePercentage) }}</td>
  180. <td class="px-4 py-3 text-sm font-medium text-gray-800">{{ stats.totalQuantity != null ? stats.totalQuantity : '-' }}</td>
  181. <td class="px-4 py-3 text-sm text-gray-700">{{ formatPercent(stats.quantityPercentage) }}</td>
  182. <td class="px-4 py-3 text-sm text-gray-700">{{ formatCurrency(stats.avgDailyRevenue) }}</td>
  183. <td class="px-4 py-3 text-sm text-gray-700">{{ stats.avgDailyQuantity != null ? stats.avgDailyQuantity : '-' }}</td>
  184. <td class="px-4 py-3 text-sm text-gray-700">{{ stats.durationDays }}</td>
  185. <td class="px-4 py-3 text-xs text-gray-600">{{ formatDate(stats.startDate) }} ~ {{ formatDate(stats.endDate) }}</td>
  186. </tr>
  187. </tbody>
  188. </table>
  189. </div>
  190. <div class="mt-8">
  191. <div class="flex justify-between items-center mb-4">
  192. <h4 class="text-base font-semibold text-gray-800">阶段对比条形图</h4>
  193. <div class="text-xs text-gray-500">销售额/销量/持续天数</div>
  194. </div>
  195. <div class="h-64">
  196. <canvas ref="stageCompareRef"></canvas>
  197. </div>
  198. </div>
  199. </div>
  200. </div>
  201. <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
  202. <div class="flex justify-between items-center mb-6">
  203. <h3 class="text-lg font-semibold text-gray-800">完整性评估细项</h3>
  204. <div class="text-sm" :class="detail && detail.is_complete ? 'text-green-600' : 'text-orange-600'">
  205. 总分 {{ detail && detail.completeness_score != null ? detail.completeness_score : 0 }}/100
  206. <span class="ml-2">{{ detail && detail.is_complete ? '(已达完整标准 80 分)' : '(未达完整标准)' }}</span>
  207. </div>
  208. </div>
  209. <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  210. <div
  211. v-for="item in breakdownItems"
  212. :key="item.key"
  213. class="flex items-center justify-between border border-gray-200 rounded-lg px-4 py-3 bg-gray-50"
  214. >
  215. <div class="flex items-center gap-3">
  216. <span :class="item.hit ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'" class="px-3 py-1 rounded-full text-xs font-medium">
  217. {{ item.hit ? '命中' : '未命中' }}
  218. </span>
  219. <span class="text-sm text-gray-800">{{ item.label }}</span>
  220. </div>
  221. <span class="text-xs text-gray-500 font-medium">权重 {{ item.weight }}</span>
  222. </div>
  223. </div>
  224. </div>
  225. </template>
  226. <div v-else class="empty-state shadow-sm">
  227. <i class="el-icon-upload2"></i>
  228. <p>暂无{{ currentConfig.entityName }}生命周期分析结果,请先上传文件并开始分析。</p>
  229. </div>
  230. </div>
  231. </template>
  232. <script>
  233. import { analyzeFile, analyzeSpuFile, getResults, getSpuResults } from '@/api/lifecycle'
  234. import { Chart } from 'chart.js'
  235. import { formatCurrency, formatDate } from '../../../utils/format'
  236. const STORAGE_KEY = 'lifecycle_analysis_active_view'
  237. const CACHE_CONFIG = {
  238. sku: {
  239. resultsKey: 'analysis_results',
  240. selectedKey: 'analysis_selectedSku'
  241. },
  242. spu: {
  243. resultsKey: 'spu_analysis_results',
  244. selectedKey: 'spu_analysis_selectedSpu'
  245. }
  246. }
  247. const stageOrder = ['引入期', '成长期', '成熟期', '衰退期']
  248. const breakdownMapping = [
  249. { key: 'sufficient_time', label: '时间长度≥120天' },
  250. { key: 'reasonable_peak_position', label: '峰值位置25%-75%' },
  251. { key: 'significant_growth', label: '显著增长(额≥100% 或 量≥80%)' },
  252. { key: 'noticeable_decline', label: '明显衰退(额≤-35% 或 量≤-30%)' },
  253. { key: 'has_lifecycle_shape', label: '生命周期形状(额CV≥0.3 且 量CV≥0.25)' },
  254. { key: 'peak_significance', label: '峰值显著性(≥均值1.8x)' },
  255. { key: 'data_quality_check', label: '数据质量(长度≥120,峰值>0,增/退均出现)' },
  256. { key: 'cycle_completeness', label: '周期完整性(时间/峰值/增长/衰退/形状)' },
  257. { key: 'trend_consistency', label: '趋势一致性(增差≤0.8 或 退差≤0.4 或同向)' }
  258. ]
  259. const ANALYSIS_CONFIG = {
  260. sku: {
  261. label: 'SKU分析',
  262. entityName: 'SKU',
  263. entityDisplayLabel: 'SKU编码',
  264. maxSizeMB: 300,
  265. exportPrefix: 'sku_lifecycle_results',
  266. fetchResults: getResults,
  267. analyze: analyzeFile
  268. },
  269. spu: {
  270. label: 'SPU分析',
  271. entityName: 'SPU',
  272. entityDisplayLabel: 'SPU名称',
  273. maxSizeMB: 500,
  274. exportPrefix: 'spu_lifecycle_results',
  275. fetchResults: getSpuResults,
  276. analyze: analyzeSpuFile
  277. }
  278. }
  279. function loadCachedAnalysis(view) {
  280. const config = CACHE_CONFIG[view]
  281. try {
  282. const rawResults = localStorage.getItem(config.resultsKey)
  283. return {
  284. results: rawResults ? JSON.parse(rawResults) : {},
  285. selected: localStorage.getItem(config.selectedKey) || ''
  286. }
  287. } catch (e) {
  288. return { results: {}, selected: '' }
  289. }
  290. }
  291. function createUploadState() {
  292. return {
  293. isUploading: false,
  294. fileName: '',
  295. pendingFileName: '',
  296. ignoreFileChange: false
  297. }
  298. }
  299. export default {
  300. name: 'LifecycleAnalysis',
  301. data() {
  302. return {
  303. activeView: localStorage.getItem(STORAGE_KEY) === 'spu' ? 'spu' : 'sku',
  304. trendChart: null,
  305. stageCompareChart: null,
  306. cache: {
  307. sku: loadCachedAnalysis('sku'),
  308. spu: loadCachedAnalysis('spu')
  309. },
  310. uploads: {
  311. sku: createUploadState(),
  312. spu: createUploadState()
  313. }
  314. }
  315. },
  316. computed: {
  317. analysisTabs() {
  318. return [
  319. { label: 'SKU分析', value: 'sku' },
  320. { label: 'SPU分析', value: 'spu' }
  321. ]
  322. },
  323. currentConfig() {
  324. return ANALYSIS_CONFIG[this.activeView]
  325. },
  326. upload() {
  327. return this.uploads[this.activeView]
  328. },
  329. currentCache() {
  330. return this.cache[this.activeView] || { results: {}, selected: '' }
  331. },
  332. results() {
  333. return this.currentCache.results || {}
  334. },
  335. hasResults() {
  336. return Object.keys(this.results || {}).length > 0
  337. },
  338. selectedValue: {
  339. get() {
  340. return this.currentCache.selected || ''
  341. },
  342. set(value) {
  343. this.setSelectedValue(value)
  344. }
  345. },
  346. entityOptions() {
  347. return Object.keys(this.results || {}).filter(key => key !== '_analysis_summary_')
  348. },
  349. detail() {
  350. return (this.results && this.selectedValue && this.results[this.selectedValue]) || null
  351. },
  352. currentStage() {
  353. return (this.detail && this.detail.current_stage) || '-'
  354. },
  355. currentStageDurationDays() {
  356. const stats = (this.detail && this.detail.stage_statistics) || {}
  357. const currentStage = this.detail && this.detail.current_stage
  358. if (currentStage && stats[currentStage] && stats[currentStage].durationDays != null) {
  359. return stats[currentStage].durationDays
  360. }
  361. return '-'
  362. },
  363. stageStats() {
  364. return (this.detail && this.detail.stage_statistics) || {}
  365. },
  366. displayStageStats() {
  367. const detail = this.detail || {}
  368. const stats = this.stageStats || {}
  369. const revenue = detail.revenue_series || detail.smoothed_revenue || []
  370. const qty = detail.quantity_series || detail.smoothed_quantity || []
  371. const rawDates = detail.date_series || []
  372. const labels = rawDates.map(date => formatDate(date))
  373. if (detail.is_complete) {
  374. const boundaries = (detail.stage_boundaries || []).map(item => formatDate(item.date))
  375. const indices = boundaries
  376. .map(date => labels.indexOf(date))
  377. .filter(index => index >= 0)
  378. .sort((a, b) => a - b)
  379. if (indices.length >= 3) {
  380. const ranges = [
  381. { name: stageOrder[0], start: 0, end: indices[0] },
  382. { name: stageOrder[1], start: indices[0], end: indices[1] },
  383. { name: stageOrder[2], start: indices[1], end: indices[2] },
  384. { name: stageOrder[3], start: indices[2], end: labels.length - 1 }
  385. ]
  386. const totalRevenue = revenue.reduce((sum, value) => sum + (Number(value) || 0), 0)
  387. const totalQty = qty.reduce((sum, value) => sum + (Number(value) || 0), 0)
  388. const built = {}
  389. ranges.forEach(range => {
  390. const revenueSum = revenue.slice(range.start, range.end + 1).reduce((sum, value) => sum + (Number(value) || 0), 0)
  391. const qtySum = qty.slice(range.start, range.end + 1).reduce((sum, value) => sum + (Number(value) || 0), 0)
  392. const days = Math.max(0, range.end - range.start + 1)
  393. built[range.name] = {
  394. totalRevenue: revenueSum,
  395. revenuePercentage: totalRevenue ? revenueSum / totalRevenue * 100 : 0,
  396. totalQuantity: qtySum,
  397. quantityPercentage: totalQty ? qtySum / totalQty * 100 : 0,
  398. avgDailyRevenue: days ? revenueSum / days : 0,
  399. avgDailyQuantity: days ? qtySum / days : 0,
  400. durationDays: days,
  401. startDate: rawDates[range.start],
  402. endDate: rawDates[range.end]
  403. }
  404. })
  405. return built
  406. }
  407. if (stageOrder.every(stage => stats[stage])) {
  408. return stats
  409. }
  410. }
  411. const actual = {}
  412. const keys = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
  413. keys.forEach(key => {
  414. const value = stats[key] || {}
  415. let duration = value.durationDays
  416. const startIndex = labels.indexOf(formatDate(value.startDate))
  417. const endIndex = labels.indexOf(formatDate(value.endDate))
  418. if (startIndex >= 0 && endIndex >= 0 && endIndex >= startIndex) {
  419. duration = Math.max(0, endIndex - startIndex + 1)
  420. }
  421. actual[key] = Object.assign({}, value, { durationDays: duration })
  422. })
  423. return actual
  424. },
  425. breakdownItems() {
  426. const detail = this.detail || {}
  427. const completionDetails = detail.completion_details || {}
  428. return breakdownMapping.map(item => {
  429. const current = completionDetails[item.key] || {}
  430. return {
  431. key: item.key,
  432. label: item.label,
  433. hit: !!current.hit,
  434. weight: current.weight != null ? current.weight : '-'
  435. }
  436. })
  437. },
  438. hasFourStages() {
  439. return stageOrder.every(stage => !!this.stageStats[stage])
  440. },
  441. totalRevenue() {
  442. if (this.detail && this.detail.total_revenue != null) return this.detail.total_revenue
  443. return Object.values(this.stageStats || {}).reduce((sum, value) => sum + (value.totalRevenue || 0), 0)
  444. },
  445. totalQty() {
  446. if (this.detail && this.detail.total_quantity != null) return this.detail.total_quantity
  447. return Object.values(this.stageStats || {}).reduce((sum, value) => sum + (value.totalQuantity || 0), 0)
  448. },
  449. revenuePct() {
  450. return 100
  451. },
  452. qtyPct() {
  453. return 100
  454. }
  455. },
  456. mounted() {
  457. this.initCurrentView()
  458. },
  459. beforeDestroy() {
  460. this.destroyCharts()
  461. },
  462. watch: {
  463. activeView() {
  464. localStorage.setItem(STORAGE_KEY, this.activeView)
  465. this.resetUploaderSelection()
  466. this.initCurrentView()
  467. },
  468. detail() {
  469. this.$nextTick(() => {
  470. this.renderTrend()
  471. this.renderStageCompare()
  472. })
  473. },
  474. results() {
  475. this.ensureCurrentSelection()
  476. }
  477. },
  478. methods: {
  479. formatCurrency,
  480. formatDate,
  481. switchAnalysis(view) {
  482. if (view === this.activeView) return
  483. this.activeView = view
  484. },
  485. initCurrentView() {
  486. if (!this.hasResults) {
  487. this.getList()
  488. return
  489. }
  490. this.ensureCurrentSelection()
  491. this.$nextTick(() => {
  492. this.renderTrend()
  493. this.renderStageCompare()
  494. })
  495. },
  496. getCurrentUploadState() {
  497. return this.uploads[this.activeView]
  498. },
  499. persistCurrentCache() {
  500. const config = CACHE_CONFIG[this.activeView]
  501. const cache = this.currentCache
  502. try {
  503. localStorage.setItem(config.resultsKey, JSON.stringify(cache.results || {}))
  504. localStorage.setItem(config.selectedKey, cache.selected || '')
  505. } catch (e) {}
  506. },
  507. setCurrentResults(results) {
  508. this.$set(this.cache, this.activeView, Object.assign({}, this.currentCache, {
  509. results: results || {}
  510. }))
  511. this.persistCurrentCache()
  512. },
  513. setSelectedValue(value) {
  514. this.$set(this.cache, this.activeView, Object.assign({}, this.currentCache, {
  515. selected: value || ''
  516. }))
  517. this.persistCurrentCache()
  518. },
  519. extractResultsPayload(response) {
  520. if (!response) return null
  521. if (response.success && response.data) return response.data
  522. if (response.code === 200 && response.data) return response.data
  523. if (response.data && typeof response.data === 'object') return response.data
  524. if (!response.message && !response.msg && typeof response === 'object') return response
  525. return null
  526. },
  527. ensureCurrentSelection() {
  528. if (!this.hasResults) {
  529. this.selectedValue = ''
  530. this.destroyCharts()
  531. return
  532. }
  533. if (!this.selectedValue || !this.results[this.selectedValue]) {
  534. const first = this.pickFirstValue(this.results)
  535. if (first) {
  536. this.selectedValue = first
  537. }
  538. }
  539. },
  540. getList() {
  541. this.currentConfig.fetchResults().then(response => {
  542. const results = this.extractResultsPayload(response)
  543. if (results) {
  544. this.setCurrentResults(results)
  545. const first = this.pickFirstValue(results)
  546. this.setSelectedValue(first)
  547. this.$nextTick(() => {
  548. this.renderTrend()
  549. this.renderStageCompare()
  550. })
  551. } else {
  552. this.setCurrentResults({})
  553. }
  554. }).catch(() => {
  555. this.setCurrentResults({})
  556. this.destroyCharts()
  557. })
  558. },
  559. handleFileChange(file, fileList) {
  560. const upload = this.getCurrentUploadState()
  561. if (upload.ignoreFileChange) return
  562. if (!fileList || fileList.length === 0 || !file || !file.raw) return
  563. upload.pendingFileName = file.name
  564. upload.fileName = ''
  565. },
  566. beforeUpload(file) {
  567. const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
  568. file.type === 'application/vnd.ms-excel' ||
  569. file.type === 'text/csv' ||
  570. file.name.endsWith('.xlsx') ||
  571. file.name.endsWith('.xls') ||
  572. file.name.endsWith('.csv')
  573. const isLtLimit = file.size / 1024 / 1024 < this.currentConfig.maxSizeMB
  574. if (!isExcel) {
  575. this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式')
  576. return false
  577. }
  578. if (!isLtLimit) {
  579. this.$modal.msgError(`上传文件大小不能超过 ${this.currentConfig.maxSizeMB}MB`)
  580. return false
  581. }
  582. return true
  583. },
  584. customUpload(options) {
  585. const file = options.file
  586. const upload = this.getCurrentUploadState()
  587. upload.isUploading = true
  588. this.currentConfig.analyze(file).then(response => {
  589. upload.isUploading = false
  590. if (response && response.success) {
  591. const results = response.data || {}
  592. this.setCurrentResults(results)
  593. const first = this.pickFirstValue(results)
  594. this.setSelectedValue(first)
  595. upload.fileName = upload.pendingFileName || file.name
  596. upload.pendingFileName = ''
  597. this.$modal.msgSuccess(`${this.currentConfig.entityName}生命周期分析完成`)
  598. this.$nextTick(() => {
  599. this.renderTrend()
  600. this.renderStageCompare()
  601. })
  602. options.onSuccess(response)
  603. } else {
  604. const message = response && (response.msg || response.message) ? response.msg || response.message : '分析失败'
  605. this.$modal.msgError(message)
  606. options.onError(new Error(message))
  607. }
  608. }).catch(error => {
  609. upload.isUploading = false
  610. const message = (error && error.message) || '文件上传失败,请重试'
  611. this.$modal.msgError(message)
  612. options.onError(error)
  613. }).finally(() => {
  614. this.resetUploaderSelection()
  615. })
  616. },
  617. submitUpload() {
  618. const target = this.$refs.toolbarUpload
  619. const fileList = target && target.uploadFiles ? target.uploadFiles : []
  620. if (!fileList || fileList.length === 0) {
  621. this.$modal.msgError('请选择要上传的文件')
  622. return
  623. }
  624. target.submit()
  625. },
  626. resetUploaderSelection() {
  627. if (!this.$refs.toolbarUpload) return
  628. const upload = this.getCurrentUploadState()
  629. upload.ignoreFileChange = true
  630. this.$refs.toolbarUpload.clearFiles()
  631. this.$nextTick(() => {
  632. upload.ignoreFileChange = false
  633. })
  634. },
  635. formatPercent(value) {
  636. if (value == null || Number.isNaN(value)) return '-'
  637. return `${Number(value).toFixed(1)}%`
  638. },
  639. stageClass(stage) {
  640. if (!stage) return ''
  641. if (stage.indexOf('引入') >= 0 || stage.indexOf('导入') >= 0) return 'intro'
  642. if (stage.indexOf('成长') >= 0) return 'growth'
  643. if (stage.indexOf('成熟') >= 0) return 'maturity'
  644. if (stage.indexOf('衰退') >= 0) return 'decline'
  645. return ''
  646. },
  647. normalizeDate(dateStr) {
  648. if (!dateStr) return ''
  649. const match = String(dateStr).match(/(\d{4}-\d{2}-\d{2})/)
  650. if (match) return match[1]
  651. const parts = String(dateStr).split('T')
  652. return parts[0].split(' ')[0]
  653. },
  654. destroyCharts() {
  655. if (this.trendChart) {
  656. this.trendChart.destroy()
  657. this.trendChart = null
  658. }
  659. if (this.stageCompareChart) {
  660. this.stageCompareChart.destroy()
  661. this.stageCompareChart = null
  662. }
  663. },
  664. renderTrend() {
  665. const canvas = this.$refs.lifeCycleTrendRef
  666. if (!canvas || !this.detail) {
  667. if (this.trendChart) {
  668. this.trendChart.destroy()
  669. this.trendChart = null
  670. }
  671. return
  672. }
  673. const detail = this.detail || {}
  674. const revenue = detail.smoothed_revenue || detail.revenue_series || []
  675. const qty = detail.smoothed_quantity || detail.quantity_series || []
  676. const rawDates = detail.date_series || []
  677. const labels = rawDates.map(date => formatDate(date))
  678. const boundaries = (detail.stage_boundaries || []).map(boundary => ({
  679. type: boundary.type,
  680. date: boundary.date ? formatDate(boundary.date) : null,
  681. index: boundary.index
  682. }))
  683. const stageColors = {
  684. [stageOrder[0]]: 'rgba(59,130,246,0.08)',
  685. [stageOrder[1]]: 'rgba(16,185,129,0.10)',
  686. [stageOrder[2]]: 'rgba(245,158,11,0.10)',
  687. [stageOrder[3]]: 'rgba(239,68,68,0.08)'
  688. }
  689. const segments = []
  690. const stagesMap = detail.stages_map || []
  691. if (stagesMap.length > 0 && stagesMap.length === labels.length) {
  692. let currentStage = stagesMap[0]
  693. let segmentStart = 0
  694. for (let i = 1; i < stagesMap.length; i++) {
  695. if (stagesMap[i] !== currentStage) {
  696. if (currentStage && stageColors[currentStage]) {
  697. segments.push({
  698. start: segmentStart,
  699. end: i - 1,
  700. stage: currentStage
  701. })
  702. }
  703. currentStage = stagesMap[i]
  704. segmentStart = i
  705. }
  706. }
  707. if (currentStage && stageColors[currentStage] && segmentStart < stagesMap.length) {
  708. segments.push({
  709. start: segmentStart,
  710. end: stagesMap.length - 1,
  711. stage: currentStage
  712. })
  713. }
  714. } else {
  715. const stats = this.displayStageStats || {}
  716. const orderedStages = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
  717. orderedStages.forEach(stage => {
  718. const startIndex = labels.indexOf(formatDate(stats[stage] && stats[stage].startDate))
  719. const endIndex = labels.indexOf(formatDate(stats[stage] && stats[stage].endDate))
  720. if (startIndex >= 0 && endIndex >= 0 && endIndex >= startIndex) {
  721. segments.push({ start: startIndex, end: endIndex, stage })
  722. }
  723. })
  724. }
  725. const peakRevenueDate = detail.peak_revenue_date
  726. const peakQtyDate = detail.peak_quantity_date
  727. let peakRevenueIndex = -1
  728. let peakQtyIndex = -1
  729. if (peakRevenueDate && rawDates.length > 0) {
  730. const targetDate = this.normalizeDate(peakRevenueDate)
  731. peakRevenueIndex = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
  732. if (peakRevenueIndex < 0 && detail.revenue_peak_idx != null && detail.revenue_peak_idx < rawDates.length) {
  733. peakRevenueIndex = detail.revenue_peak_idx
  734. }
  735. }
  736. if (peakQtyDate && rawDates.length > 0) {
  737. const targetDate = this.normalizeDate(peakQtyDate)
  738. peakQtyIndex = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
  739. if (peakQtyIndex < 0 && detail.quantity_peak_idx != null && detail.quantity_peak_idx < rawDates.length) {
  740. peakQtyIndex = detail.quantity_peak_idx
  741. }
  742. }
  743. const boundaryIndices = []
  744. boundaries.forEach(boundary => {
  745. let index = -1
  746. if (boundary.index != null && boundary.index >= 0 && boundary.index < rawDates.length) {
  747. index = boundary.index
  748. } else if (boundary.date) {
  749. const targetDate = this.normalizeDate(boundary.date)
  750. index = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
  751. }
  752. if (index >= 0 && index < rawDates.length) {
  753. boundaryIndices.push({ index, type: boundary.type })
  754. }
  755. })
  756. if (this.trendChart) this.trendChart.destroy()
  757. this.trendChart = new Chart(canvas, {
  758. type: 'line',
  759. data: {
  760. labels,
  761. datasets: [
  762. {
  763. label: '销售额',
  764. data: revenue,
  765. borderColor: '#3b82f6',
  766. backgroundColor: 'rgba(59,130,246,0.15)',
  767. lineTension: 0.25,
  768. pointRadius: 0,
  769. pointHoverRadius: 6,
  770. pointHoverBackgroundColor: '#3b82f6',
  771. pointHoverBorderColor: '#ffffff',
  772. pointHoverBorderWidth: 2
  773. },
  774. {
  775. label: '销量',
  776. data: qty,
  777. borderColor: '#64748b',
  778. backgroundColor: 'rgba(100,116,139,0.15)',
  779. lineTension: 0.25,
  780. pointRadius: 0,
  781. pointHoverRadius: 6,
  782. pointHoverBackgroundColor: '#64748b',
  783. pointHoverBorderColor: '#ffffff',
  784. pointHoverBorderWidth: 2
  785. }
  786. ]
  787. },
  788. options: {
  789. responsive: true,
  790. maintainAspectRatio: false,
  791. tooltips: {
  792. enabled: true,
  793. backgroundColor: 'rgba(0,0,0,0.8)',
  794. titleFontColor: '#ffffff',
  795. bodyFontColor: '#ffffff',
  796. borderColor: '#374151',
  797. borderWidth: 1,
  798. cornerRadius: 6,
  799. displayColors: true,
  800. callbacks: {
  801. title(context) {
  802. return `日期: ${context[0].label}`
  803. },
  804. label(context, data) {
  805. const datasetLabel = data.datasets[context.datasetIndex].label
  806. return `${datasetLabel}: ${Number(context.value).toLocaleString()}`
  807. }
  808. }
  809. },
  810. hover: {
  811. mode: 'index',
  812. intersect: false
  813. },
  814. scales: {
  815. xAxes: [{
  816. ticks: {
  817. maxRotation: 0,
  818. autoSkip: true,
  819. maxTicksLimit: 12,
  820. callback(value, index) {
  821. return labels[index]
  822. }
  823. }
  824. }]
  825. }
  826. },
  827. plugins: [{
  828. id: 'stage-backgrounds',
  829. beforeDatasetsDraw(chart) {
  830. const ctx = chart.ctx
  831. const chartArea = chart.chartArea
  832. const xScale = chart.scales['x-axis-0']
  833. if (!segments.length) return
  834. ctx.save()
  835. segments.forEach(segment => {
  836. const startX = xScale.getPixelForValue(segment.start)
  837. const endX = xScale.getPixelForValue(segment.end)
  838. const color = stageColors[segment.stage] || 'rgba(0,0,0,0.04)'
  839. ctx.fillStyle = color
  840. ctx.fillRect(startX, chartArea.top, endX - startX, chartArea.bottom - chartArea.top)
  841. ctx.fillStyle = '#374151'
  842. ctx.font = 'bold 12px sans-serif'
  843. ctx.textAlign = 'left'
  844. ctx.fillText(segment.stage, startX + 4, chartArea.top + 14)
  845. })
  846. ctx.restore()
  847. }
  848. }, {
  849. id: 'stage-markers',
  850. afterDatasetsDraw(chart) {
  851. const ctx = chart.ctx
  852. const chartArea = chart.chartArea
  853. const xScale = chart.scales['x-axis-0']
  854. const yScale = chart.scales['y-axis-0']
  855. ctx.save()
  856. boundaryIndices.forEach(boundary => {
  857. const x = xScale.getPixelForValue(boundary.index)
  858. ctx.strokeStyle = '#ef4444'
  859. ctx.setLineDash([8, 4])
  860. ctx.lineWidth = 2
  861. ctx.beginPath()
  862. ctx.moveTo(x, chartArea.top)
  863. ctx.lineTo(x, chartArea.bottom)
  864. ctx.stroke()
  865. ctx.setLineDash([])
  866. const labelText = boundary.type
  867. const labelWidth = ctx.measureText(labelText).width + 8
  868. const labelHeight = 16
  869. const labelX = x - labelWidth / 2
  870. const labelY = chartArea.top + 8
  871. ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
  872. ctx.fillRect(labelX, labelY, labelWidth, labelHeight)
  873. ctx.strokeStyle = '#ef4444'
  874. ctx.lineWidth = 1
  875. ctx.strokeRect(labelX, labelY, labelWidth, labelHeight)
  876. ctx.fillStyle = '#ef4444'
  877. ctx.font = 'bold 11px sans-serif'
  878. ctx.textAlign = 'center'
  879. ctx.fillText(labelText, x, labelY + 12)
  880. })
  881. if (peakRevenueIndex >= 0 && peakRevenueIndex < revenue.length && revenue[peakRevenueIndex] != null) {
  882. const x = xScale.getPixelForValue(peakRevenueIndex)
  883. const y = yScale.getPixelForValue(revenue[peakRevenueIndex])
  884. ctx.fillStyle = '#dc2626'
  885. ctx.beginPath()
  886. ctx.arc(x, y, 5, 0, Math.PI * 2)
  887. ctx.fill()
  888. ctx.strokeStyle = '#ffffff'
  889. ctx.lineWidth = 2
  890. ctx.stroke()
  891. ctx.fillStyle = '#dc2626'
  892. ctx.font = 'bold 12px sans-serif'
  893. ctx.textAlign = 'left'
  894. ctx.fillText('销售额峰值', x + 10, y - 10)
  895. ctx.font = '11px sans-serif'
  896. ctx.fillText(`¥${Number(revenue[peakRevenueIndex]).toLocaleString()}`, x + 10, y + 2)
  897. }
  898. if (peakQtyIndex >= 0 && peakQtyIndex < qty.length && qty[peakQtyIndex] != null) {
  899. const x = xScale.getPixelForValue(peakQtyIndex)
  900. const y = yScale.getPixelForValue(qty[peakQtyIndex])
  901. ctx.fillStyle = '#2563eb'
  902. ctx.beginPath()
  903. ctx.arc(x, y, 5, 0, Math.PI * 2)
  904. ctx.fill()
  905. ctx.strokeStyle = '#ffffff'
  906. ctx.lineWidth = 2
  907. ctx.stroke()
  908. ctx.fillStyle = '#2563eb'
  909. ctx.font = 'bold 12px sans-serif'
  910. ctx.textAlign = 'left'
  911. ctx.fillText('销量峰值', x + 10, y - 10)
  912. ctx.font = '11px sans-serif'
  913. ctx.fillText(`${String(qty[peakQtyIndex]).toLocaleString()}件`, x + 10, y + 2)
  914. }
  915. ctx.restore()
  916. }
  917. }]
  918. })
  919. },
  920. renderStageCompare() {
  921. const canvas = this.$refs.stageCompareRef
  922. if (!canvas || !this.detail) {
  923. if (this.stageCompareChart) {
  924. this.stageCompareChart.destroy()
  925. this.stageCompareChart = null
  926. }
  927. return
  928. }
  929. const stats = this.displayStageStats || {}
  930. const labels = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
  931. const revenueData = labels.map(stage => (stats[stage] && stats[stage].totalRevenue) || 0)
  932. const qtyData = labels.map(stage => (stats[stage] && stats[stage].totalQuantity) || 0)
  933. const durationData = labels.map(stage => (stats[stage] && stats[stage].durationDays) || 0)
  934. if (this.stageCompareChart) this.stageCompareChart.destroy()
  935. this.stageCompareChart = new Chart(canvas, {
  936. type: 'bar',
  937. data: {
  938. labels,
  939. datasets: [
  940. { label: '销售额', data: revenueData, backgroundColor: 'rgba(59,130,246,0.7)' },
  941. { label: '销量', data: qtyData, backgroundColor: 'rgba(16,185,129,0.7)' },
  942. { label: '持续天数', data: durationData, backgroundColor: 'rgba(245,158,11,0.7)' }
  943. ]
  944. },
  945. options: {
  946. responsive: true,
  947. maintainAspectRatio: false,
  948. scales: {
  949. xAxes: [{ stacked: false }],
  950. yAxes: [{ ticks: { beginAtZero: true } }]
  951. }
  952. }
  953. })
  954. },
  955. formatUploadDate(date) {
  956. const target = date instanceof Date ? date : new Date(date)
  957. if (Number.isNaN(target.getTime())) return ''
  958. const year = target.getFullYear()
  959. const month = String(target.getMonth() + 1).padStart(2, '0')
  960. const day = String(target.getDate()).padStart(2, '0')
  961. return `${year}-${month}-${day}`
  962. },
  963. exportResults() {
  964. if (!this.hasResults) {
  965. this.$modal.msgError('暂无可导出的分析结果')
  966. return
  967. }
  968. const payload = JSON.stringify(this.results || {}, null, 2)
  969. const blob = new Blob([payload], { type: 'application/json;charset=utf-8' })
  970. const url = URL.createObjectURL(blob)
  971. const link = document.createElement('a')
  972. link.href = url
  973. link.download = `${this.currentConfig.exportPrefix}_${this.formatUploadDate(new Date())}.json`
  974. document.body.appendChild(link)
  975. link.click()
  976. document.body.removeChild(link)
  977. URL.revokeObjectURL(url)
  978. },
  979. pickFirstValue(results) {
  980. const keys = Object.keys(results || {})
  981. return keys.find(key => key !== '_analysis_summary_') || ''
  982. }
  983. }
  984. }
  985. </script>
  986. <style scoped lang="scss">
  987. .app-container {
  988. padding: 20px;
  989. }
  990. .page-header {
  991. display: flex;
  992. align-items: flex-start;
  993. justify-content: space-between;
  994. gap: 16px;
  995. margin-bottom: 20px;
  996. h2 {
  997. font-size: 24px;
  998. font-weight: 600;
  999. color: #303133;
  1000. margin-bottom: 8px;
  1001. i {
  1002. margin-right: 8px;
  1003. color: #409eff;
  1004. }
  1005. }
  1006. .page-desc {
  1007. color: #909399;
  1008. font-size: 14px;
  1009. margin: 0;
  1010. }
  1011. }
  1012. .analysis-switch {
  1013. display: inline-flex;
  1014. padding: 4px;
  1015. border-radius: 10px;
  1016. background: #f3f6fb;
  1017. border: 1px solid #dce6f2;
  1018. }
  1019. .switch-btn {
  1020. border: none;
  1021. background: transparent;
  1022. color: #606266;
  1023. padding: 8px 16px;
  1024. border-radius: 8px;
  1025. font-size: 14px;
  1026. font-weight: 500;
  1027. cursor: pointer;
  1028. transition: all 0.2s ease;
  1029. }
  1030. .switch-btn.active {
  1031. background: #ffffff;
  1032. color: #2563eb;
  1033. box-shadow: 0 2px 8px rgba(37, 99, 235, 0.12);
  1034. }
  1035. .mb-20 { margin-bottom: 20px; }
  1036. .upload-toolbar {
  1037. display: flex;
  1038. align-items: center;
  1039. justify-content: space-between;
  1040. background: #ffffff;
  1041. border: 1px solid #e6eaf2;
  1042. border-radius: 8px;
  1043. padding: 12px 16px;
  1044. margin-bottom: 16px;
  1045. box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
  1046. }
  1047. .toolbar-left {
  1048. display: flex;
  1049. align-items: center;
  1050. gap: 12px;
  1051. }
  1052. .toolbar-upload ::v-deep .el-upload {
  1053. display: inline-flex;
  1054. }
  1055. .toolbar-status {
  1056. font-size: 13px;
  1057. color: #16a34a;
  1058. background: #f0fdf4;
  1059. border: 1px solid #dcfce7;
  1060. border-radius: 6px;
  1061. padding: 6px 10px;
  1062. }
  1063. .toolbar-status.muted {
  1064. color: #6b7280;
  1065. background: #f8fafc;
  1066. border-color: #e2e8f0;
  1067. }
  1068. .empty-state {
  1069. display: flex;
  1070. flex-direction: column;
  1071. align-items: center;
  1072. justify-content: center;
  1073. gap: 12px;
  1074. min-height: 280px;
  1075. background: #ffffff;
  1076. border-radius: 8px;
  1077. border: 1px solid #e6eaf2;
  1078. color: #909399;
  1079. font-size: 14px;
  1080. i {
  1081. font-size: 40px;
  1082. color: #c0c4cc;
  1083. }
  1084. p {
  1085. margin: 0;
  1086. }
  1087. }
  1088. .p-6 { padding: 24px; }
  1089. .p-4 { padding: 16px; }
  1090. .px-3 { padding-left: 12px; padding-right: 12px; }
  1091. .px-4 { padding-left: 16px; padding-right: 16px; }
  1092. .py-1 { padding-top: 4px; padding-bottom: 4px; }
  1093. .py-2 { padding-top: 8px; padding-bottom: 8px; }
  1094. .py-3 { padding-top: 12px; padding-bottom: 12px; }
  1095. .pt-2 { padding-top: 8px; }
  1096. .mb-2 { margin-bottom: 8px; }
  1097. .mb-4 { margin-bottom: 16px; }
  1098. .mb-6 { margin-bottom: 24px; }
  1099. .mb-8 { margin-bottom: 32px; }
  1100. .mt-1 { margin-top: 4px; }
  1101. .mt-4 { margin-top: 16px; }
  1102. .mt-8 { margin-top: 32px; }
  1103. .ml-2 { margin-left: 8px; }
  1104. .mr-1 { margin-right: 4px; }
  1105. .flex { display: flex; }
  1106. .flex-wrap { flex-wrap: wrap; }
  1107. .items-center { align-items: center; }
  1108. .justify-between { justify-content: space-between; }
  1109. .gap-2 { gap: 8px; }
  1110. .gap-3 { gap: 12px; }
  1111. .gap-4 { gap: 16px; }
  1112. .gap-8 { gap: 20px; }
  1113. .grid { display: grid; }
  1114. .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
  1115. .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  1116. .lg\:col-span-2 { grid-column: span 2 / span 2; }
  1117. .overflow-x-auto { overflow-x: auto; }
  1118. .min-w-full { min-width: 100%; }
  1119. .text-base { font-size: 16px; line-height: 1.5; }
  1120. .text-lg { font-size: 18px; line-height: 1.5; }
  1121. .text-sm { font-size: 14px; line-height: 1.5; }
  1122. .text-xs { font-size: 12px; line-height: 1.4; }
  1123. .font-semibold { font-weight: 600; }
  1124. .font-medium { font-weight: 500; }
  1125. .text-left { text-align: left; }
  1126. .uppercase { text-transform: uppercase; }
  1127. .tracking-wide { letter-spacing: 0.04em; }
  1128. .tracking-wider { letter-spacing: 0.06em; }
  1129. .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  1130. .text-gray-800 { color: #303133; }
  1131. .text-gray-700 { color: #606266; }
  1132. .text-gray-600 { color: #909399; }
  1133. .text-gray-500 { color: #909399; }
  1134. .text-gray-400 { color: #9ca3af; }
  1135. .text-blue-600 { color: #2563eb; }
  1136. .text-green-700 { color: #15803d; }
  1137. .text-green-600 { color: #16a34a; }
  1138. .text-red-600 { color: #dc2626; }
  1139. .text-orange-600 { color: #ea580c; }
  1140. .text-yellow-800 { color: #92400e; }
  1141. .bg-white { background: #ffffff; }
  1142. .bg-gray-50 { background: #f9fafb; }
  1143. .bg-gray-500 { background: #6b7280; }
  1144. .bg-blue-500 { background: #3b82f6; }
  1145. .bg-green-100 { background: #dcfce7; }
  1146. .bg-red-100 { background: #fee2e2; }
  1147. .bg-gradient-to-r { background-image: linear-gradient(to right, var(--from-color), var(--to-color)); }
  1148. .from-yellow-100 { --from-color: #fef9c3; }
  1149. .to-amber-100 { --to-color: #fef3c7; }
  1150. .border { border-width: 1px; border-style: solid; }
  1151. .border-gray-200 { border-color: #e5e7eb; }
  1152. .border-gray-300 { border-color: #d1d5db; }
  1153. .rounded-xl { border-radius: 8px; }
  1154. .rounded-lg { border-radius: 10px; }
  1155. .rounded-full { border-radius: 9999px; }
  1156. .shadow-sm { border: 1px solid #e6eaf2; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); }
  1157. .h-96 { height: 400px; }
  1158. .h-64 { height: 256px; }
  1159. .w-3 { width: 12px; }
  1160. .h-3 { height: 12px; }
  1161. .space-y-2 > * + * { margin-top: 8px; }
  1162. .space-y-6 > * + * { margin-top: 24px; }
  1163. .divide-y > * + * { border-top: 1px solid #e5e7eb; }
  1164. .divide-gray-200 > * + * { border-top-color: #e5e7eb; }
  1165. .focus\:outline-none:focus { outline: none; }
  1166. .focus\:border-transparent:focus { border-color: transparent; }
  1167. .focus\:ring-2:focus { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35); }
  1168. .focus\:ring-blue-500:focus { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.45); }
  1169. .entity-select {
  1170. min-width: 220px;
  1171. background: #ffffff;
  1172. }
  1173. table {
  1174. border-collapse: separate;
  1175. border-spacing: 0;
  1176. }
  1177. thead tr {
  1178. background: #f8fafc;
  1179. }
  1180. thead th:first-child {
  1181. border-top-left-radius: 8px;
  1182. }
  1183. thead th:last-child {
  1184. border-top-right-radius: 8px;
  1185. }
  1186. @media (max-width: 767px) {
  1187. .page-header,
  1188. .upload-toolbar {
  1189. flex-direction: column;
  1190. align-items: stretch;
  1191. }
  1192. .analysis-switch,
  1193. .toolbar-left {
  1194. width: 100%;
  1195. }
  1196. .switch-btn {
  1197. flex: 1;
  1198. }
  1199. }
  1200. @media (min-width: 768px) {
  1201. .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  1202. }
  1203. @media (min-width: 1024px) {
  1204. .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  1205. .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  1206. }
  1207. .progress-bar { height: 8px; background: #f3f4f6; border-radius: 999px; overflow: hidden; }
  1208. .progress-value { height: 100%; border-radius: 999px; }
  1209. .lifecycle-stage { padding: 2px 8px; border-radius: 999px; font-size: 12px; }
  1210. .lifecycle-stage.intro { background: #dbeafe; color: #1e40af; }
  1211. .lifecycle-stage.growth { background: #dcfce7; color: #166534; }
  1212. .lifecycle-stage.maturity { background: #fef3c7; color: #92400e; }
  1213. .lifecycle-stage.decline { background: #fee2e2; color: #991b1b; }
  1214. </style>