| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318 |
- <template>
- <div class="app-container">
- <div class="page-header">
- <div>
- <h2><i class="el-icon-data-analysis"></i> 生命周期分析</h2>
- <p class="page-desc">支持在 SKU 和 SPU 两个维度查看生命周期分析结果</p>
- </div>
- <div class="analysis-switch">
- <button
- v-for="item in analysisTabs"
- :key="item.value"
- type="button"
- class="switch-btn"
- :class="{ active: activeView === item.value }"
- @click="switchAnalysis(item.value)"
- >
- {{ item.label }}
- </button>
- </div>
- </div>
- <div class="upload-toolbar">
- <div class="toolbar-left">
- <el-upload
- ref="toolbarUpload"
- class="toolbar-upload"
- :limit="1"
- accept=".xlsx,.xls,.csv"
- :http-request="customUpload"
- :disabled="upload.isUploading"
- :on-change="handleFileChange"
- :before-upload="beforeUpload"
- :auto-upload="false"
- :show-file-list="false"
- >
- <el-button plain>上传文件</el-button>
- </el-upload>
- <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">开始分析</el-button>
- <el-button type="success" :disabled="!hasResults" @click="exportResults">导出分析</el-button>
- </div>
- <div class="toolbar-status" v-if="upload.fileName">已上传:{{ upload.fileName }}</div>
- <div class="toolbar-status" v-else-if="upload.pendingFileName">已选择:{{ upload.pendingFileName }}</div>
- <div class="toolbar-status muted" v-else>未上传</div>
- </div>
- <template v-if="hasResults && detail">
- <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
- <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
- <div class="flex items-center gap-3">
- <label class="text-sm font-medium text-gray-700">选择{{ currentConfig.entityName }}:</label>
- <select
- v-model="selectedValue"
- 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"
- >
- <option v-for="item in entityOptions" :key="item" :value="item">{{ item }}</option>
- </select>
- </div>
- <div class="flex items-center gap-2">
- <span class="text-sm font-medium text-gray-700">当前阶段:</span>
- <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">
- {{ currentStage }}
- </span>
- </div>
- </div>
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
- <p class="text-xs text-gray-500 uppercase tracking-wide">{{ currentConfig.entityDisplayLabel }}</p>
- <p class="text-lg font-medium text-gray-800 truncate">{{ selectedValue }}</p>
- </div>
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
- <p class="text-xs text-gray-500 uppercase tracking-wide">生命周期完整性</p>
- <p class="text-lg font-medium" :class="detail && detail.is_complete ? 'text-green-700' : 'text-orange-600'">
- {{ detail && detail.is_complete ? '完整' : '不完整' }}
- <span v-if="detail && detail.completeness_score != null" class="ml-2 text-sm text-gray-500">
- (得分: {{ detail.completeness_score }}/100)
- </span>
- </p>
- </div>
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
- <p class="text-xs text-gray-500 uppercase tracking-wide">总销售额</p>
- <p class="text-lg font-medium text-gray-800">{{ formatCurrency(totalRevenue) }}</p>
- </div>
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
- <p class="text-xs text-gray-500 uppercase tracking-wide">总销量</p>
- <p class="text-lg font-medium text-gray-800">{{ totalQty }}</p>
- </div>
- </div>
- </div>
- <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
- <div class="flex justify-between items-center mb-6">
- <h3 class="text-lg font-semibold text-gray-800">{{ currentConfig.entityName }}生命周期趋势图</h3>
- <div class="flex items-center gap-3">
- <div class="flex items-center gap-2">
- <span class="w-3 h-3 rounded-full bg-blue-500"></span>
- <span class="text-sm text-gray-600">销售额</span>
- </div>
- <div class="flex items-center gap-2">
- <span class="w-3 h-3 rounded-full bg-gray-500"></span>
- <span class="text-sm text-gray-600">销量</span>
- </div>
- </div>
- </div>
- <div class="h-96">
- <canvas ref="lifeCycleTrendRef"></canvas>
- </div>
- <div class="mt-4 space-y-2">
- <div v-if="detail && detail.is_complete" class="text-sm">
- <span :class="hasFourStages ? 'text-green-700 font-medium' : 'text-red-600 font-medium'">
- <i :class="hasFourStages ? 'fa fa-check-circle' : 'fa fa-exclamation-triangle'" class="mr-1"></i>
- {{ hasFourStages ? '阶段划分完整:已包含引入/成长/成熟/衰退四个阶段' : '阶段划分不完整:未包含所有四个标准阶段' }}
- </span>
- </div>
- <div v-if="detail && !detail.is_complete && detail.completeness_score != null" class="text-sm text-orange-600">
- <i class="fa fa-info-circle mr-1"></i>
- 不完整生命周期{{ currentConfig.entityName }} - 完整性评估得分:{{ detail.completeness_score }}/100(需要达到 80 分以上才能被评为完整周期)
- </div>
- </div>
- </div>
- <div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-20">
- <div class="bg-white rounded-xl p-6 shadow-sm space-y-6">
- <h3 class="text-lg font-semibold text-gray-800">基本指标</h3>
- <div>
- <div class="flex justify-between mb-2">
- <span class="text-sm text-gray-500">总销售额</span>
- <span class="text-sm font-medium text-gray-800">{{ formatCurrency(totalRevenue) }}</span>
- </div>
- <div class="progress-bar"><div class="progress-value bg-blue-500" :style="{ width: revenuePct + '%' }"></div></div>
- </div>
- <div>
- <div class="flex justify-between mb-2">
- <span class="text-sm text-gray-500">总销量</span>
- <span class="text-sm font-medium text-gray-800">{{ totalQty }}</span>
- </div>
- <div class="progress-bar"><div class="progress-value bg-gray-500" :style="{ width: qtyPct + '%' }"></div></div>
- </div>
- <div class="grid grid-cols-2 gap-4 pt-2">
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
- <p class="text-xs text-gray-500 uppercase tracking-wide">峰值销售额</p>
- <p class="text-lg font-medium text-gray-800">{{ formatCurrency(detail && detail.peak_revenue) }}</p>
- <p class="text-xs text-gray-400 mt-1">{{ formatDate(detail && detail.peak_revenue_date) }}</p>
- </div>
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
- <p class="text-xs text-gray-500 uppercase tracking-wide">峰值销量</p>
- <p class="text-lg font-medium text-gray-800">{{ detail && detail.peak_quantity != null ? detail.peak_quantity : '-' }}</p>
- <p class="text-xs text-gray-400 mt-1">{{ formatDate(detail && detail.peak_quantity_date) }}</p>
- </div>
- </div>
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
- <p class="text-xs text-gray-500 uppercase tracking-wide">当前阶段说明</p>
- <p class="text-sm text-gray-700">
- 处于 <span class="font-medium">{{ currentStage }}</span>,已持续
- <span class="font-medium">{{ currentStageDurationDays }}</span> 天。
- </p>
- <p class="text-xs text-gray-600 mt-1">{{ detail && detail.details ? detail.details : '-' }}</p>
- <p v-if="detail && !detail.is_complete && detail.next_stage_prediction" class="text-xs text-blue-600 mt-1">
- 下一阶段预测:{{ detail.next_stage_prediction }}
- </p>
- </div>
- </div>
- <div class="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm">
- <h3 class="text-lg font-semibold text-gray-800 mb-6">阶段分析</h3>
- <div class="overflow-x-auto">
- <table class="min-w-full divide-y divide-gray-200">
- <thead>
- <tr>
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">阶段</th>
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">总销售额</th>
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">销售额占比</th>
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">总销量</th>
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">销量占比</th>
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日均销售额</th>
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日均销量</th>
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">持续天数</th>
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">起止日期</th>
- </tr>
- </thead>
- <tbody class="bg-white divide-y divide-gray-200">
- <tr v-for="(stats, stage) in displayStageStats" :key="stage">
- <td class="px-4 py-3 text-sm"><span class="lifecycle-stage" :class="stageClass(stage)">{{ stage }}</span></td>
- <td class="px-4 py-3 text-sm font-medium text-gray-800">{{ formatCurrency(stats.totalRevenue) }}</td>
- <td class="px-4 py-3 text-sm text-gray-700">{{ formatPercent(stats.revenuePercentage) }}</td>
- <td class="px-4 py-3 text-sm font-medium text-gray-800">{{ stats.totalQuantity != null ? stats.totalQuantity : '-' }}</td>
- <td class="px-4 py-3 text-sm text-gray-700">{{ formatPercent(stats.quantityPercentage) }}</td>
- <td class="px-4 py-3 text-sm text-gray-700">{{ formatCurrency(stats.avgDailyRevenue) }}</td>
- <td class="px-4 py-3 text-sm text-gray-700">{{ stats.avgDailyQuantity != null ? stats.avgDailyQuantity : '-' }}</td>
- <td class="px-4 py-3 text-sm text-gray-700">{{ stats.durationDays }}</td>
- <td class="px-4 py-3 text-xs text-gray-600">{{ formatDate(stats.startDate) }} ~ {{ formatDate(stats.endDate) }}</td>
- </tr>
- </tbody>
- </table>
- </div>
- <div class="mt-8">
- <div class="flex justify-between items-center mb-4">
- <h4 class="text-base font-semibold text-gray-800">阶段对比条形图</h4>
- <div class="text-xs text-gray-500">销售额/销量/持续天数</div>
- </div>
- <div class="h-64">
- <canvas ref="stageCompareRef"></canvas>
- </div>
- </div>
- </div>
- </div>
- <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
- <div class="flex justify-between items-center mb-6">
- <h3 class="text-lg font-semibold text-gray-800">完整性评估细项</h3>
- <div class="text-sm" :class="detail && detail.is_complete ? 'text-green-600' : 'text-orange-600'">
- 总分 {{ detail && detail.completeness_score != null ? detail.completeness_score : 0 }}/100
- <span class="ml-2">{{ detail && detail.is_complete ? '(已达完整标准 80 分)' : '(未达完整标准)' }}</span>
- </div>
- </div>
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- <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"
- >
- <div class="flex items-center gap-3">
- <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">
- {{ item.hit ? '命中' : '未命中' }}
- </span>
- <span class="text-sm text-gray-800">{{ item.label }}</span>
- </div>
- <span class="text-xs text-gray-500 font-medium">权重 {{ item.weight }}</span>
- </div>
- </div>
- </div>
- </template>
- <div v-else class="empty-state shadow-sm">
- <i class="el-icon-upload2"></i>
- <p>暂无{{ currentConfig.entityName }}生命周期分析结果,请先上传文件并开始分析。</p>
- </div>
- </div>
- </template>
- <script>
- import { analyzeFile, analyzeSpuFile, getResults, getSpuResults } from '@/api/lifecycle'
- import { Chart } from 'chart.js'
- import { formatCurrency, formatDate } from '../../../utils/format'
- const STORAGE_KEY = 'lifecycle_analysis_active_view'
- const CACHE_CONFIG = {
- sku: {
- resultsKey: 'analysis_results',
- selectedKey: 'analysis_selectedSku'
- },
- spu: {
- resultsKey: 'spu_analysis_results',
- selectedKey: 'spu_analysis_selectedSpu'
- }
- }
- const stageOrder = ['引入期', '成长期', '成熟期', '衰退期']
- const breakdownMapping = [
- { key: 'sufficient_time', label: '时间长度≥120天' },
- { key: 'reasonable_peak_position', label: '峰值位置25%-75%' },
- { key: 'significant_growth', label: '显著增长(额≥100% 或 量≥80%)' },
- { key: 'noticeable_decline', label: '明显衰退(额≤-35% 或 量≤-30%)' },
- { key: 'has_lifecycle_shape', label: '生命周期形状(额CV≥0.3 且 量CV≥0.25)' },
- { key: 'peak_significance', label: '峰值显著性(≥均值1.8x)' },
- { key: 'data_quality_check', label: '数据质量(长度≥120,峰值>0,增/退均出现)' },
- { key: 'cycle_completeness', label: '周期完整性(时间/峰值/增长/衰退/形状)' },
- { key: 'trend_consistency', label: '趋势一致性(增差≤0.8 或 退差≤0.4 或同向)' }
- ]
- const ANALYSIS_CONFIG = {
- sku: {
- label: 'SKU分析',
- entityName: 'SKU',
- entityDisplayLabel: 'SKU编码',
- maxSizeMB: 300,
- exportPrefix: 'sku_lifecycle_results',
- fetchResults: getResults,
- analyze: analyzeFile
- },
- spu: {
- label: 'SPU分析',
- entityName: 'SPU',
- entityDisplayLabel: 'SPU名称',
- maxSizeMB: 500,
- exportPrefix: 'spu_lifecycle_results',
- fetchResults: getSpuResults,
- analyze: analyzeSpuFile
- }
- }
- function loadCachedAnalysis(view) {
- const config = CACHE_CONFIG[view]
- try {
- const rawResults = localStorage.getItem(config.resultsKey)
- return {
- results: rawResults ? JSON.parse(rawResults) : {},
- selected: localStorage.getItem(config.selectedKey) || ''
- }
- } catch (e) {
- return { results: {}, selected: '' }
- }
- }
- function createUploadState() {
- return {
- isUploading: false,
- fileName: '',
- pendingFileName: '',
- ignoreFileChange: false
- }
- }
- export default {
- name: 'LifecycleAnalysis',
- data() {
- return {
- activeView: localStorage.getItem(STORAGE_KEY) === 'spu' ? 'spu' : 'sku',
- trendChart: null,
- stageCompareChart: null,
- cache: {
- sku: loadCachedAnalysis('sku'),
- spu: loadCachedAnalysis('spu')
- },
- uploads: {
- sku: createUploadState(),
- spu: createUploadState()
- }
- }
- },
- computed: {
- analysisTabs() {
- return [
- { label: 'SKU分析', value: 'sku' },
- { label: 'SPU分析', value: 'spu' }
- ]
- },
- currentConfig() {
- return ANALYSIS_CONFIG[this.activeView]
- },
- upload() {
- return this.uploads[this.activeView]
- },
- currentCache() {
- return this.cache[this.activeView] || { results: {}, selected: '' }
- },
- results() {
- return this.currentCache.results || {}
- },
- hasResults() {
- return Object.keys(this.results || {}).length > 0
- },
- selectedValue: {
- get() {
- return this.currentCache.selected || ''
- },
- set(value) {
- this.setSelectedValue(value)
- }
- },
- entityOptions() {
- return Object.keys(this.results || {}).filter(key => key !== '_analysis_summary_')
- },
- detail() {
- return (this.results && this.selectedValue && this.results[this.selectedValue]) || null
- },
- currentStage() {
- return (this.detail && this.detail.current_stage) || '-'
- },
- currentStageDurationDays() {
- const stats = (this.detail && this.detail.stage_statistics) || {}
- const currentStage = this.detail && this.detail.current_stage
- if (currentStage && stats[currentStage] && stats[currentStage].durationDays != null) {
- return stats[currentStage].durationDays
- }
- return '-'
- },
- stageStats() {
- return (this.detail && this.detail.stage_statistics) || {}
- },
- displayStageStats() {
- const detail = this.detail || {}
- const stats = this.stageStats || {}
- const revenue = detail.revenue_series || detail.smoothed_revenue || []
- const qty = detail.quantity_series || detail.smoothed_quantity || []
- const rawDates = detail.date_series || []
- const labels = rawDates.map(date => formatDate(date))
- if (detail.is_complete) {
- const boundaries = (detail.stage_boundaries || []).map(item => formatDate(item.date))
- const indices = boundaries
- .map(date => labels.indexOf(date))
- .filter(index => index >= 0)
- .sort((a, b) => a - b)
- if (indices.length >= 3) {
- const ranges = [
- { name: stageOrder[0], start: 0, end: indices[0] },
- { name: stageOrder[1], start: indices[0], end: indices[1] },
- { name: stageOrder[2], start: indices[1], end: indices[2] },
- { name: stageOrder[3], start: indices[2], end: labels.length - 1 }
- ]
- const totalRevenue = revenue.reduce((sum, value) => sum + (Number(value) || 0), 0)
- const totalQty = qty.reduce((sum, value) => sum + (Number(value) || 0), 0)
- const built = {}
- ranges.forEach(range => {
- const revenueSum = revenue.slice(range.start, range.end + 1).reduce((sum, value) => sum + (Number(value) || 0), 0)
- const qtySum = qty.slice(range.start, range.end + 1).reduce((sum, value) => sum + (Number(value) || 0), 0)
- const days = Math.max(0, range.end - range.start + 1)
- built[range.name] = {
- totalRevenue: revenueSum,
- revenuePercentage: totalRevenue ? revenueSum / totalRevenue * 100 : 0,
- totalQuantity: qtySum,
- quantityPercentage: totalQty ? qtySum / totalQty * 100 : 0,
- avgDailyRevenue: days ? revenueSum / days : 0,
- avgDailyQuantity: days ? qtySum / days : 0,
- durationDays: days,
- startDate: rawDates[range.start],
- endDate: rawDates[range.end]
- }
- })
- return built
- }
- if (stageOrder.every(stage => stats[stage])) {
- return stats
- }
- }
- const actual = {}
- const keys = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
- keys.forEach(key => {
- const value = stats[key] || {}
- let duration = value.durationDays
- const startIndex = labels.indexOf(formatDate(value.startDate))
- const endIndex = labels.indexOf(formatDate(value.endDate))
- if (startIndex >= 0 && endIndex >= 0 && endIndex >= startIndex) {
- duration = Math.max(0, endIndex - startIndex + 1)
- }
- actual[key] = Object.assign({}, value, { durationDays: duration })
- })
- return actual
- },
- breakdownItems() {
- const detail = this.detail || {}
- const completionDetails = detail.completion_details || {}
- return breakdownMapping.map(item => {
- const current = completionDetails[item.key] || {}
- return {
- key: item.key,
- label: item.label,
- hit: !!current.hit,
- weight: current.weight != null ? current.weight : '-'
- }
- })
- },
- hasFourStages() {
- return stageOrder.every(stage => !!this.stageStats[stage])
- },
- totalRevenue() {
- if (this.detail && this.detail.total_revenue != null) return this.detail.total_revenue
- return Object.values(this.stageStats || {}).reduce((sum, value) => sum + (value.totalRevenue || 0), 0)
- },
- totalQty() {
- if (this.detail && this.detail.total_quantity != null) return this.detail.total_quantity
- return Object.values(this.stageStats || {}).reduce((sum, value) => sum + (value.totalQuantity || 0), 0)
- },
- revenuePct() {
- return 100
- },
- qtyPct() {
- return 100
- }
- },
- mounted() {
- this.initCurrentView()
- },
- beforeDestroy() {
- this.destroyCharts()
- },
- watch: {
- activeView() {
- localStorage.setItem(STORAGE_KEY, this.activeView)
- this.resetUploaderSelection()
- this.initCurrentView()
- },
- detail() {
- this.$nextTick(() => {
- this.renderTrend()
- this.renderStageCompare()
- })
- },
- results() {
- this.ensureCurrentSelection()
- }
- },
- methods: {
- formatCurrency,
- formatDate,
- switchAnalysis(view) {
- if (view === this.activeView) return
- this.activeView = view
- },
- initCurrentView() {
- if (!this.hasResults) {
- this.getList()
- return
- }
- this.ensureCurrentSelection()
- this.$nextTick(() => {
- this.renderTrend()
- this.renderStageCompare()
- })
- },
- getCurrentUploadState() {
- return this.uploads[this.activeView]
- },
- persistCurrentCache() {
- const config = CACHE_CONFIG[this.activeView]
- const cache = this.currentCache
- try {
- localStorage.setItem(config.resultsKey, JSON.stringify(cache.results || {}))
- localStorage.setItem(config.selectedKey, cache.selected || '')
- } catch (e) {}
- },
- setCurrentResults(results) {
- this.$set(this.cache, this.activeView, Object.assign({}, this.currentCache, {
- results: results || {}
- }))
- this.persistCurrentCache()
- },
- setSelectedValue(value) {
- this.$set(this.cache, this.activeView, Object.assign({}, this.currentCache, {
- selected: value || ''
- }))
- this.persistCurrentCache()
- },
- extractResultsPayload(response) {
- if (!response) return null
- if (response.success && response.data) return response.data
- if (response.code === 200 && response.data) return response.data
- if (response.data && typeof response.data === 'object') return response.data
- if (!response.message && !response.msg && typeof response === 'object') return response
- return null
- },
- ensureCurrentSelection() {
- if (!this.hasResults) {
- this.selectedValue = ''
- this.destroyCharts()
- return
- }
- if (!this.selectedValue || !this.results[this.selectedValue]) {
- const first = this.pickFirstValue(this.results)
- if (first) {
- this.selectedValue = first
- }
- }
- },
- getList() {
- this.currentConfig.fetchResults().then(response => {
- const results = this.extractResultsPayload(response)
- if (results) {
- this.setCurrentResults(results)
- const first = this.pickFirstValue(results)
- this.setSelectedValue(first)
- this.$nextTick(() => {
- this.renderTrend()
- this.renderStageCompare()
- })
- } else {
- this.setCurrentResults({})
- }
- }).catch(() => {
- this.setCurrentResults({})
- this.destroyCharts()
- })
- },
- handleFileChange(file, fileList) {
- const upload = this.getCurrentUploadState()
- if (upload.ignoreFileChange) return
- if (!fileList || fileList.length === 0 || !file || !file.raw) return
- upload.pendingFileName = file.name
- upload.fileName = ''
- },
- beforeUpload(file) {
- const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
- file.type === 'application/vnd.ms-excel' ||
- file.type === 'text/csv' ||
- file.name.endsWith('.xlsx') ||
- file.name.endsWith('.xls') ||
- file.name.endsWith('.csv')
- const isLtLimit = file.size / 1024 / 1024 < this.currentConfig.maxSizeMB
- if (!isExcel) {
- this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式')
- return false
- }
- if (!isLtLimit) {
- this.$modal.msgError(`上传文件大小不能超过 ${this.currentConfig.maxSizeMB}MB`)
- return false
- }
- return true
- },
- customUpload(options) {
- const file = options.file
- const upload = this.getCurrentUploadState()
- upload.isUploading = true
- this.currentConfig.analyze(file).then(response => {
- upload.isUploading = false
- if (response && response.success) {
- const results = response.data || {}
- this.setCurrentResults(results)
- const first = this.pickFirstValue(results)
- this.setSelectedValue(first)
- upload.fileName = upload.pendingFileName || file.name
- upload.pendingFileName = ''
- this.$modal.msgSuccess(`${this.currentConfig.entityName}生命周期分析完成`)
- this.$nextTick(() => {
- this.renderTrend()
- this.renderStageCompare()
- })
- options.onSuccess(response)
- } else {
- const message = response && (response.msg || response.message) ? response.msg || response.message : '分析失败'
- this.$modal.msgError(message)
- options.onError(new Error(message))
- }
- }).catch(error => {
- upload.isUploading = false
- const message = (error && error.message) || '文件上传失败,请重试'
- this.$modal.msgError(message)
- options.onError(error)
- }).finally(() => {
- this.resetUploaderSelection()
- })
- },
- submitUpload() {
- const target = this.$refs.toolbarUpload
- const fileList = target && target.uploadFiles ? target.uploadFiles : []
- if (!fileList || fileList.length === 0) {
- this.$modal.msgError('请选择要上传的文件')
- return
- }
- target.submit()
- },
- resetUploaderSelection() {
- if (!this.$refs.toolbarUpload) return
- const upload = this.getCurrentUploadState()
- upload.ignoreFileChange = true
- this.$refs.toolbarUpload.clearFiles()
- this.$nextTick(() => {
- upload.ignoreFileChange = false
- })
- },
- formatPercent(value) {
- if (value == null || Number.isNaN(value)) return '-'
- return `${Number(value).toFixed(1)}%`
- },
- stageClass(stage) {
- if (!stage) return ''
- if (stage.indexOf('引入') >= 0 || stage.indexOf('导入') >= 0) return 'intro'
- if (stage.indexOf('成长') >= 0) return 'growth'
- if (stage.indexOf('成熟') >= 0) return 'maturity'
- if (stage.indexOf('衰退') >= 0) return 'decline'
- return ''
- },
- normalizeDate(dateStr) {
- if (!dateStr) return ''
- const match = String(dateStr).match(/(\d{4}-\d{2}-\d{2})/)
- if (match) return match[1]
- const parts = String(dateStr).split('T')
- return parts[0].split(' ')[0]
- },
- destroyCharts() {
- if (this.trendChart) {
- this.trendChart.destroy()
- this.trendChart = null
- }
- if (this.stageCompareChart) {
- this.stageCompareChart.destroy()
- this.stageCompareChart = null
- }
- },
- renderTrend() {
- const canvas = this.$refs.lifeCycleTrendRef
- if (!canvas || !this.detail) {
- if (this.trendChart) {
- this.trendChart.destroy()
- this.trendChart = null
- }
- return
- }
- const detail = this.detail || {}
- const revenue = detail.smoothed_revenue || detail.revenue_series || []
- const qty = detail.smoothed_quantity || detail.quantity_series || []
- const rawDates = detail.date_series || []
- const labels = rawDates.map(date => formatDate(date))
- const boundaries = (detail.stage_boundaries || []).map(boundary => ({
- type: boundary.type,
- date: boundary.date ? formatDate(boundary.date) : null,
- index: boundary.index
- }))
- const stageColors = {
- [stageOrder[0]]: 'rgba(59,130,246,0.08)',
- [stageOrder[1]]: 'rgba(16,185,129,0.10)',
- [stageOrder[2]]: 'rgba(245,158,11,0.10)',
- [stageOrder[3]]: 'rgba(239,68,68,0.08)'
- }
- const segments = []
- const stagesMap = detail.stages_map || []
- if (stagesMap.length > 0 && stagesMap.length === labels.length) {
- let currentStage = stagesMap[0]
- let segmentStart = 0
- for (let i = 1; i < stagesMap.length; i++) {
- if (stagesMap[i] !== currentStage) {
- if (currentStage && stageColors[currentStage]) {
- segments.push({
- start: segmentStart,
- end: i - 1,
- stage: currentStage
- })
- }
- currentStage = stagesMap[i]
- segmentStart = i
- }
- }
- if (currentStage && stageColors[currentStage] && segmentStart < stagesMap.length) {
- segments.push({
- start: segmentStart,
- end: stagesMap.length - 1,
- stage: currentStage
- })
- }
- } else {
- const stats = this.displayStageStats || {}
- const orderedStages = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
- orderedStages.forEach(stage => {
- const startIndex = labels.indexOf(formatDate(stats[stage] && stats[stage].startDate))
- const endIndex = labels.indexOf(formatDate(stats[stage] && stats[stage].endDate))
- if (startIndex >= 0 && endIndex >= 0 && endIndex >= startIndex) {
- segments.push({ start: startIndex, end: endIndex, stage })
- }
- })
- }
- const peakRevenueDate = detail.peak_revenue_date
- const peakQtyDate = detail.peak_quantity_date
- let peakRevenueIndex = -1
- let peakQtyIndex = -1
- if (peakRevenueDate && rawDates.length > 0) {
- const targetDate = this.normalizeDate(peakRevenueDate)
- peakRevenueIndex = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
- if (peakRevenueIndex < 0 && detail.revenue_peak_idx != null && detail.revenue_peak_idx < rawDates.length) {
- peakRevenueIndex = detail.revenue_peak_idx
- }
- }
- if (peakQtyDate && rawDates.length > 0) {
- const targetDate = this.normalizeDate(peakQtyDate)
- peakQtyIndex = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
- if (peakQtyIndex < 0 && detail.quantity_peak_idx != null && detail.quantity_peak_idx < rawDates.length) {
- peakQtyIndex = detail.quantity_peak_idx
- }
- }
- const boundaryIndices = []
- boundaries.forEach(boundary => {
- let index = -1
- if (boundary.index != null && boundary.index >= 0 && boundary.index < rawDates.length) {
- index = boundary.index
- } else if (boundary.date) {
- const targetDate = this.normalizeDate(boundary.date)
- index = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
- }
- if (index >= 0 && index < rawDates.length) {
- boundaryIndices.push({ index, type: boundary.type })
- }
- })
- if (this.trendChart) this.trendChart.destroy()
- this.trendChart = new Chart(canvas, {
- type: 'line',
- data: {
- labels,
- datasets: [
- {
- label: '销售额',
- data: revenue,
- borderColor: '#3b82f6',
- backgroundColor: 'rgba(59,130,246,0.15)',
- lineTension: 0.25,
- pointRadius: 0,
- pointHoverRadius: 6,
- pointHoverBackgroundColor: '#3b82f6',
- pointHoverBorderColor: '#ffffff',
- pointHoverBorderWidth: 2
- },
- {
- label: '销量',
- data: qty,
- borderColor: '#64748b',
- backgroundColor: 'rgba(100,116,139,0.15)',
- lineTension: 0.25,
- pointRadius: 0,
- pointHoverRadius: 6,
- pointHoverBackgroundColor: '#64748b',
- pointHoverBorderColor: '#ffffff',
- pointHoverBorderWidth: 2
- }
- ]
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- tooltips: {
- enabled: true,
- backgroundColor: 'rgba(0,0,0,0.8)',
- titleFontColor: '#ffffff',
- bodyFontColor: '#ffffff',
- borderColor: '#374151',
- borderWidth: 1,
- cornerRadius: 6,
- displayColors: true,
- callbacks: {
- title(context) {
- return `日期: ${context[0].label}`
- },
- label(context, data) {
- const datasetLabel = data.datasets[context.datasetIndex].label
- return `${datasetLabel}: ${Number(context.value).toLocaleString()}`
- }
- }
- },
- hover: {
- mode: 'index',
- intersect: false
- },
- scales: {
- xAxes: [{
- ticks: {
- maxRotation: 0,
- autoSkip: true,
- maxTicksLimit: 12,
- callback(value, index) {
- return labels[index]
- }
- }
- }]
- }
- },
- plugins: [{
- id: 'stage-backgrounds',
- beforeDatasetsDraw(chart) {
- const ctx = chart.ctx
- const chartArea = chart.chartArea
- const xScale = chart.scales['x-axis-0']
- if (!segments.length) return
- ctx.save()
- segments.forEach(segment => {
- const startX = xScale.getPixelForValue(segment.start)
- const endX = xScale.getPixelForValue(segment.end)
- const color = stageColors[segment.stage] || 'rgba(0,0,0,0.04)'
- ctx.fillStyle = color
- ctx.fillRect(startX, chartArea.top, endX - startX, chartArea.bottom - chartArea.top)
- ctx.fillStyle = '#374151'
- ctx.font = 'bold 12px sans-serif'
- ctx.textAlign = 'left'
- ctx.fillText(segment.stage, startX + 4, chartArea.top + 14)
- })
- ctx.restore()
- }
- }, {
- id: 'stage-markers',
- afterDatasetsDraw(chart) {
- const ctx = chart.ctx
- const chartArea = chart.chartArea
- const xScale = chart.scales['x-axis-0']
- const yScale = chart.scales['y-axis-0']
- ctx.save()
- boundaryIndices.forEach(boundary => {
- const x = xScale.getPixelForValue(boundary.index)
- ctx.strokeStyle = '#ef4444'
- ctx.setLineDash([8, 4])
- ctx.lineWidth = 2
- ctx.beginPath()
- ctx.moveTo(x, chartArea.top)
- ctx.lineTo(x, chartArea.bottom)
- ctx.stroke()
- ctx.setLineDash([])
- const labelText = boundary.type
- const labelWidth = ctx.measureText(labelText).width + 8
- const labelHeight = 16
- const labelX = x - labelWidth / 2
- const labelY = chartArea.top + 8
- ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
- ctx.fillRect(labelX, labelY, labelWidth, labelHeight)
- ctx.strokeStyle = '#ef4444'
- ctx.lineWidth = 1
- ctx.strokeRect(labelX, labelY, labelWidth, labelHeight)
- ctx.fillStyle = '#ef4444'
- ctx.font = 'bold 11px sans-serif'
- ctx.textAlign = 'center'
- ctx.fillText(labelText, x, labelY + 12)
- })
- if (peakRevenueIndex >= 0 && peakRevenueIndex < revenue.length && revenue[peakRevenueIndex] != null) {
- const x = xScale.getPixelForValue(peakRevenueIndex)
- const y = yScale.getPixelForValue(revenue[peakRevenueIndex])
- ctx.fillStyle = '#dc2626'
- ctx.beginPath()
- ctx.arc(x, y, 5, 0, Math.PI * 2)
- ctx.fill()
- ctx.strokeStyle = '#ffffff'
- ctx.lineWidth = 2
- ctx.stroke()
- ctx.fillStyle = '#dc2626'
- ctx.font = 'bold 12px sans-serif'
- ctx.textAlign = 'left'
- ctx.fillText('销售额峰值', x + 10, y - 10)
- ctx.font = '11px sans-serif'
- ctx.fillText(`¥${Number(revenue[peakRevenueIndex]).toLocaleString()}`, x + 10, y + 2)
- }
- if (peakQtyIndex >= 0 && peakQtyIndex < qty.length && qty[peakQtyIndex] != null) {
- const x = xScale.getPixelForValue(peakQtyIndex)
- const y = yScale.getPixelForValue(qty[peakQtyIndex])
- ctx.fillStyle = '#2563eb'
- ctx.beginPath()
- ctx.arc(x, y, 5, 0, Math.PI * 2)
- ctx.fill()
- ctx.strokeStyle = '#ffffff'
- ctx.lineWidth = 2
- ctx.stroke()
- ctx.fillStyle = '#2563eb'
- ctx.font = 'bold 12px sans-serif'
- ctx.textAlign = 'left'
- ctx.fillText('销量峰值', x + 10, y - 10)
- ctx.font = '11px sans-serif'
- ctx.fillText(`${String(qty[peakQtyIndex]).toLocaleString()}件`, x + 10, y + 2)
- }
- ctx.restore()
- }
- }]
- })
- },
- renderStageCompare() {
- const canvas = this.$refs.stageCompareRef
- if (!canvas || !this.detail) {
- if (this.stageCompareChart) {
- this.stageCompareChart.destroy()
- this.stageCompareChart = null
- }
- return
- }
- const stats = this.displayStageStats || {}
- const labels = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
- const revenueData = labels.map(stage => (stats[stage] && stats[stage].totalRevenue) || 0)
- const qtyData = labels.map(stage => (stats[stage] && stats[stage].totalQuantity) || 0)
- const durationData = labels.map(stage => (stats[stage] && stats[stage].durationDays) || 0)
- if (this.stageCompareChart) this.stageCompareChart.destroy()
- this.stageCompareChart = new Chart(canvas, {
- type: 'bar',
- data: {
- labels,
- datasets: [
- { label: '销售额', data: revenueData, backgroundColor: 'rgba(59,130,246,0.7)' },
- { label: '销量', data: qtyData, backgroundColor: 'rgba(16,185,129,0.7)' },
- { label: '持续天数', data: durationData, backgroundColor: 'rgba(245,158,11,0.7)' }
- ]
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- scales: {
- xAxes: [{ stacked: false }],
- yAxes: [{ ticks: { beginAtZero: true } }]
- }
- }
- })
- },
- formatUploadDate(date) {
- const target = date instanceof Date ? date : new Date(date)
- if (Number.isNaN(target.getTime())) return ''
- const year = target.getFullYear()
- const month = String(target.getMonth() + 1).padStart(2, '0')
- const day = String(target.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
- },
- exportResults() {
- if (!this.hasResults) {
- this.$modal.msgError('暂无可导出的分析结果')
- return
- }
- const payload = JSON.stringify(this.results || {}, null, 2)
- const blob = new Blob([payload], { type: 'application/json;charset=utf-8' })
- const url = URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.href = url
- link.download = `${this.currentConfig.exportPrefix}_${this.formatUploadDate(new Date())}.json`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- URL.revokeObjectURL(url)
- },
- pickFirstValue(results) {
- const keys = Object.keys(results || {})
- return keys.find(key => key !== '_analysis_summary_') || ''
- }
- }
- }
- </script>
- <style scoped lang="scss">
- .app-container {
- padding: 20px;
- }
- .page-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- margin-bottom: 20px;
- h2 {
- font-size: 24px;
- font-weight: 600;
- color: #303133;
- margin-bottom: 8px;
- i {
- margin-right: 8px;
- color: #409eff;
- }
- }
- .page-desc {
- color: #909399;
- font-size: 14px;
- margin: 0;
- }
- }
- .analysis-switch {
- display: inline-flex;
- padding: 4px;
- border-radius: 10px;
- background: #f3f6fb;
- border: 1px solid #dce6f2;
- }
- .switch-btn {
- border: none;
- background: transparent;
- color: #606266;
- padding: 8px 16px;
- border-radius: 8px;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s ease;
- }
- .switch-btn.active {
- background: #ffffff;
- color: #2563eb;
- box-shadow: 0 2px 8px rgba(37, 99, 235, 0.12);
- }
- .mb-20 { margin-bottom: 20px; }
- .upload-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- background: #ffffff;
- border: 1px solid #e6eaf2;
- border-radius: 8px;
- padding: 12px 16px;
- margin-bottom: 16px;
- box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
- }
- .toolbar-left {
- display: flex;
- align-items: center;
- gap: 12px;
- }
- .toolbar-upload ::v-deep .el-upload {
- display: inline-flex;
- }
- .toolbar-status {
- font-size: 13px;
- color: #16a34a;
- background: #f0fdf4;
- border: 1px solid #dcfce7;
- border-radius: 6px;
- padding: 6px 10px;
- }
- .toolbar-status.muted {
- color: #6b7280;
- background: #f8fafc;
- border-color: #e2e8f0;
- }
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 12px;
- min-height: 280px;
- background: #ffffff;
- border-radius: 8px;
- border: 1px solid #e6eaf2;
- color: #909399;
- font-size: 14px;
- i {
- font-size: 40px;
- color: #c0c4cc;
- }
- p {
- margin: 0;
- }
- }
- .p-6 { padding: 24px; }
- .p-4 { padding: 16px; }
- .px-3 { padding-left: 12px; padding-right: 12px; }
- .px-4 { padding-left: 16px; padding-right: 16px; }
- .py-1 { padding-top: 4px; padding-bottom: 4px; }
- .py-2 { padding-top: 8px; padding-bottom: 8px; }
- .py-3 { padding-top: 12px; padding-bottom: 12px; }
- .pt-2 { padding-top: 8px; }
- .mb-2 { margin-bottom: 8px; }
- .mb-4 { margin-bottom: 16px; }
- .mb-6 { margin-bottom: 24px; }
- .mb-8 { margin-bottom: 32px; }
- .mt-1 { margin-top: 4px; }
- .mt-4 { margin-top: 16px; }
- .mt-8 { margin-top: 32px; }
- .ml-2 { margin-left: 8px; }
- .mr-1 { margin-right: 4px; }
- .flex { display: flex; }
- .flex-wrap { flex-wrap: wrap; }
- .items-center { align-items: center; }
- .justify-between { justify-content: space-between; }
- .gap-2 { gap: 8px; }
- .gap-3 { gap: 12px; }
- .gap-4 { gap: 16px; }
- .gap-8 { gap: 20px; }
- .grid { display: grid; }
- .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
- .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .lg\:col-span-2 { grid-column: span 2 / span 2; }
- .overflow-x-auto { overflow-x: auto; }
- .min-w-full { min-width: 100%; }
- .text-base { font-size: 16px; line-height: 1.5; }
- .text-lg { font-size: 18px; line-height: 1.5; }
- .text-sm { font-size: 14px; line-height: 1.5; }
- .text-xs { font-size: 12px; line-height: 1.4; }
- .font-semibold { font-weight: 600; }
- .font-medium { font-weight: 500; }
- .text-left { text-align: left; }
- .uppercase { text-transform: uppercase; }
- .tracking-wide { letter-spacing: 0.04em; }
- .tracking-wider { letter-spacing: 0.06em; }
- .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .text-gray-800 { color: #303133; }
- .text-gray-700 { color: #606266; }
- .text-gray-600 { color: #909399; }
- .text-gray-500 { color: #909399; }
- .text-gray-400 { color: #9ca3af; }
- .text-blue-600 { color: #2563eb; }
- .text-green-700 { color: #15803d; }
- .text-green-600 { color: #16a34a; }
- .text-red-600 { color: #dc2626; }
- .text-orange-600 { color: #ea580c; }
- .text-yellow-800 { color: #92400e; }
- .bg-white { background: #ffffff; }
- .bg-gray-50 { background: #f9fafb; }
- .bg-gray-500 { background: #6b7280; }
- .bg-blue-500 { background: #3b82f6; }
- .bg-green-100 { background: #dcfce7; }
- .bg-red-100 { background: #fee2e2; }
- .bg-gradient-to-r { background-image: linear-gradient(to right, var(--from-color), var(--to-color)); }
- .from-yellow-100 { --from-color: #fef9c3; }
- .to-amber-100 { --to-color: #fef3c7; }
- .border { border-width: 1px; border-style: solid; }
- .border-gray-200 { border-color: #e5e7eb; }
- .border-gray-300 { border-color: #d1d5db; }
- .rounded-xl { border-radius: 8px; }
- .rounded-lg { border-radius: 10px; }
- .rounded-full { border-radius: 9999px; }
- .shadow-sm { border: 1px solid #e6eaf2; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); }
- .h-96 { height: 400px; }
- .h-64 { height: 256px; }
- .w-3 { width: 12px; }
- .h-3 { height: 12px; }
- .space-y-2 > * + * { margin-top: 8px; }
- .space-y-6 > * + * { margin-top: 24px; }
- .divide-y > * + * { border-top: 1px solid #e5e7eb; }
- .divide-gray-200 > * + * { border-top-color: #e5e7eb; }
- .focus\:outline-none:focus { outline: none; }
- .focus\:border-transparent:focus { border-color: transparent; }
- .focus\:ring-2:focus { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35); }
- .focus\:ring-blue-500:focus { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.45); }
- .entity-select {
- min-width: 220px;
- background: #ffffff;
- }
- table {
- border-collapse: separate;
- border-spacing: 0;
- }
- thead tr {
- background: #f8fafc;
- }
- thead th:first-child {
- border-top-left-radius: 8px;
- }
- thead th:last-child {
- border-top-right-radius: 8px;
- }
- @media (max-width: 767px) {
- .page-header,
- .upload-toolbar {
- flex-direction: column;
- align-items: stretch;
- }
- .analysis-switch,
- .toolbar-left {
- width: 100%;
- }
- .switch-btn {
- flex: 1;
- }
- }
- @media (min-width: 768px) {
- .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- }
- @media (min-width: 1024px) {
- .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
- .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
- }
- .progress-bar { height: 8px; background: #f3f4f6; border-radius: 999px; overflow: hidden; }
- .progress-value { height: 100%; border-radius: 999px; }
- .lifecycle-stage { padding: 2px 8px; border-radius: 999px; font-size: 12px; }
- .lifecycle-stage.intro { background: #dbeafe; color: #1e40af; }
- .lifecycle-stage.growth { background: #dcfce7; color: #166534; }
- .lifecycle-stage.maturity { background: #fef3c7; color: #92400e; }
- .lifecycle-stage.decline { background: #fee2e2; color: #991b1b; }
- </style>
|