index.vue 42 KB

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