| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962 |
- <template>
- <div class="app-container">
- <!-- 页面标题 -->
- <div class="page-header">
- <h2><i class="el-icon-data-analysis"></i> SKU生命周期整体看板</h2>
- <p class="page-desc">全局SKU生命周期分析概览,包含关键指标和趋势分析</p>
- </div>
- <div class="upload-toolbar">
- <div class="toolbar-left">
- <el-upload
- ref="upload"
- 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>
- <!-- 关键指标卡片 -->
- <el-row :gutter="20" class="mb-20">
- <el-col :xs="24" :sm="12" :md="8" :lg="4">
- <el-card class="stat-card">
- <div class="stat-content">
- <div class="stat-info">
- <p class="stat-label">订单总数(估)</p>
- <p class="stat-value">{{ orderCount }}</p>
- <p class="stat-desc">按销量近似</p>
- </div>
- <div class="stat-icon stat-icon-purple">
- <i class="el-icon-s-order"></i>
- </div>
- </div>
- </el-card>
- </el-col>
- <el-col :xs="24" :sm="12" :md="8" :lg="4">
- <el-card class="stat-card">
- <div class="stat-content">
- <div class="stat-info">
- <p class="stat-label">SKU总数</p>
- <p class="stat-value">{{ skuCount }}</p>
- <p class="stat-desc stat-desc-success">分析后</p>
- </div>
- <div class="stat-icon stat-icon-blue">
- <i class="el-icon-box"></i>
- </div>
- </div>
- </el-card>
- </el-col>
- <el-col :xs="24" :sm="12" :md="8" :lg="4">
- <el-card class="stat-card">
- <div class="stat-content">
- <div class="stat-info">
- <p class="stat-label">商品总数</p>
- <p class="stat-value">{{ productCount }}</p>
- <p class="stat-desc">按详情名称去重</p>
- </div>
- <div class="stat-icon stat-icon-teal">
- <i class="el-icon-s-goods"></i>
- </div>
- </div>
- </el-card>
- </el-col>
- <el-col :xs="24" :sm="12" :md="8" :lg="4">
- <el-card class="stat-card">
- <div class="stat-content">
- <div class="stat-info">
- <p class="stat-label">完整生命周期SKU</p>
- <p class="stat-value">{{ completeCount }}</p>
- <p class="stat-desc stat-desc-success">完整占比 {{ completeRatio }}%</p>
- </div>
- <div class="stat-icon stat-icon-green">
- <i class="el-icon-success"></i>
- </div>
- </div>
- </el-card>
- </el-col>
- <el-col :xs="24" :sm="12" :md="8" :lg="4">
- <el-card class="stat-card">
- <div class="stat-content">
- <div class="stat-info">
- <p class="stat-label">平均生命周期时长(天)</p>
- <p class="stat-value">{{ avgLifecycleDays }}</p>
- <p class="stat-desc">仅计算完整SKU</p>
- </div>
- <div class="stat-icon stat-icon-yellow">
- <i class="el-icon-time"></i>
- </div>
- </div>
- </el-card>
- </el-col>
- <el-col :xs="24" :sm="12" :md="8" :lg="4">
- <el-card class="stat-card">
- <div class="stat-content">
- <div class="stat-info">
- <p class="stat-label">数据不足SKU</p>
- <p class="stat-value">{{ insufficientCount }}</p>
- <p class="stat-desc">天数/字段不足</p>
- </div>
- <div class="stat-icon stat-icon-red">
- <i class="el-icon-warning"></i>
- </div>
- </div>
- </el-card>
- </el-col>
- </el-row>
- <!-- 阶段分布与关键指标图 -->
- <el-row :gutter="20" class="mb-20">
- <el-col :xs="24" :sm="24" :md="12" :lg="12">
- <el-card>
- <div slot="header">
- <span><i class="el-icon-pie-chart"></i> SKU生命周期阶段分布</span>
- <span class="header-desc">按数量占比</span>
- </div>
- <div ref="stageDistributionChart" style="height: 400px"></div>
- </el-card>
- </el-col>
- <el-col :xs="24" :sm="24" :md="12" :lg="12">
- <el-card>
- <div slot="header">
- <span><i class="el-icon-data-line"></i> 各阶段关键指标对比</span>
- <span class="header-desc">销售额/销量(估)</span>
- </div>
- <div ref="stageMetricsChart" style="height: 400px"></div>
- </el-card>
- </el-col>
- </el-row>
- <!-- 阶段平均时长与转化漏斗 -->
- <el-row :gutter="20" class="mb-20">
- <el-col :xs="24" :sm="24" :md="12" :lg="12">
- <el-card>
- <div slot="header">
- <span><i class="el-icon-s-marketing"></i> 阶段平均时长(天)</span>
- <span class="header-desc">完整SKU统计</span>
- </div>
- <div ref="avgDurationChart" style="height: 400px"></div>
- </el-card>
- </el-col>
- <el-col :xs="24" :sm="24" :md="12" :lg="12">
- <el-card>
- <div slot="header">
- <span><i class="el-icon-s-data"></i> 阶段转化漏斗</span>
- <span class="header-desc">引入→成长→成熟→衰退</span>
- </div>
- <div ref="funnelChart" style="height: 400px"></div>
- </el-card>
- </el-col>
- </el-row>
- </div>
- </template>
- <script>
- import { analyzeFile, getResults } from '@/api/client'
- import { getToken } from '@/utils/auth'
- import * as echarts from 'echarts'
- require('echarts/theme/macarons')
- export default {
- name: 'LifecycleOverview',
- data() {
- return {
- // 图表实例
- stageDistributionChart: null,
- stageMetricsChart: null,
- avgDurationChart: null,
- funnelChart: null,
- // 数据
- results: {},
- // 计算属性数据
- orderCount: 0,
- skuCount: 0,
- productCount: 0,
- completeCount: 0,
- completeRatio: 0,
- avgLifecycleDays: 0,
- insufficientCount: 0,
- // 文件上传相关
- upload: {
- // 是否显示弹出层
- open: false,
- // 弹出层标题
- title: '',
- // 是否禁用上传
- isUploading: false,
- // 是否更新已经存在的文件
- updateSupport: 0,
- // 设置上传的请求头部
- headers: { Authorization: 'Bearer ' + getToken() },
- // 上传的地址
- url: process.env.VUE_APP_PYTHON_API + '/api/sku-lifecycle/upload',
- // 文件名称
- fileName: '',
- // 已选择文件名称
- pendingFileName: '',
- // 是否忽略文件选择改变
- ignoreFileChange: false,
- }
- }
- },
- computed: {
- hasResults() {
- return Object.keys(this.results || {}).length > 0
- }
- },
- mounted() {
- this.$nextTick(() => {
- this.initCharts()
- })
- // 监听窗口大小变化
- window.addEventListener('resize', this.handleResize)
- },
- beforeDestroy() {
- // 销毁图表实例
- if (this.stageDistributionChart) {
- this.stageDistributionChart.dispose()
- }
- if (this.stageMetricsChart) {
- this.stageMetricsChart.dispose()
- }
- if (this.avgDurationChart) {
- this.avgDurationChart.dispose()
- }
- if (this.funnelChart) {
- this.funnelChart.dispose()
- }
- window.removeEventListener('resize', this.handleResize)
- },
- methods: {
- /** 文件选择改变处理 */
- handleFileChange(file, fileList) {
- if (this.upload.ignoreChange) return
- if (!fileList || fileList.length === 0) return
- if (!file || !file.raw) return
- this.upload.pendingFileName = file.name
- this.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 isLt500M = file.size / 1024 / 1024 < 500
- if (!isExcel) {
- this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
- return false
- }
- if (!isLt500M) {
- this.$modal.msgError('上传文件大小不能超过 500MB!')
- return false
- }
- return true
- },
- /** 文件上传中处理 */
- handleFileUploadProgress(event, file, fileList) {
- this.upload.isUploading = true
- },
- /** 自定义上传方法 */
- customUpload(options) {
- const file = options.file
- this.upload.isUploading = true
- analyzeFile(file).then(response => {
- this.upload.isUploading = false
- if (response && response.success) {
- this.$modal.msgSuccess('文件上传并分析成功')
- this.upload.fileName = this.upload.pendingFileName || file.name
- this.upload.pendingFileName = ''
- this.results = response.data || {}
- this.calculateMetrics()
- this.$nextTick(() => {
- this.renderCharts()
- })
- options.onSuccess(response)
- } else {
- const message = (response && response.message) || '分析失败'
- this.$modal.msgError(message)
- options.onError(new Error(message))
- }
- if (this.$refs.upload) {
- this.upload.ignoreFileChange = true
- this.$refs.upload.clearFiles()
- this.$nextTick(() => {
- this.upload.ignoreFileChange = false
- })
- }
- }).catch(error => {
- this.upload.isUploading = false
- const errorMsg = (error && error.response && error.response.data && error.response.data.message) || error.message || '文件上传失败,请重试'
- this.$modal.msgError(errorMsg)
- options.onError(error)
- })
- },
- /** 提交上传文件 */
- submitUpload() {
- const fileList = this.$refs.upload.uploadFiles
- if (!fileList || fileList.length === 0) {
- this.$modal.msgError('请选择要上传的文件')
- return
- }
- this.$refs.upload.submit()
- },
- /** 重置上传 */
- resetUpload() {
- if (this.$refs.upload) this.$refs.upload.clearFiles()
- if (this.$refs.toolbarUpload) this.$refs.toolbarUpload.clearFiles()
- },
- /** 获取生命周期分析结果 */
- getList() {
- getResults().then(response => {
- if (response && response.success && response.data) {
- this.results = response.data || {}
- this.calculateMetrics()
- this.$nextTick(() => {
- this.renderCharts()
- })
- }
- }).catch(() => {
- this.results = {}
- })
- },
- /** 计算关键指标 */
- calculateMetrics() {
- // 仅取真实SKU结果,排除聚合概要 _analysis_summary_
- const entries = Object.entries(this.results || {})
- const resultsArr = entries.filter(([k]) => k !== '_analysis_summary_').map(([, v]) => v)
- this.skuCount = resultsArr.length
- // 商品总数(按详情名称去重)
- const names = new Set()
- resultsArr.forEach(r => {
- if (r?.details) names.add(r.details)
- })
- this.productCount = names.size
- // 完整生命周期SKU
- this.completeCount = resultsArr.filter(r => !!r?.is_complete).length
- this.completeRatio = this.skuCount ? ((this.completeCount / this.skuCount) * 100).toFixed(1) : 0
- // 平均生命周期时长(仅计算完整SKU)
- const completeList = resultsArr.filter(r => !!r?.is_complete)
- if (completeList.length > 0) {
- const total = completeList.reduce((s, r) => s + this.getLifecycleDays(r), 0)
- this.avgLifecycleDays = Math.round(total / completeList.length)
- } else {
- this.avgLifecycleDays = 0
- }
- // 数据不足SKU
- this.insufficientCount = resultsArr.filter(r => this.isInsufficient(r)).length
- // 订单总数(按销量近似)
- this.orderCount = resultsArr.reduce((s, r) => s + this.sumArray(r?.quantity_series || []), 0)
- },
- /** 初始化图表 */
- initCharts() {
- if (this.$refs.stageDistributionChart) {
- this.stageDistributionChart = echarts.init(this.$refs.stageDistributionChart, 'macarons')
- }
- if (this.$refs.stageMetricsChart) {
- this.stageMetricsChart = echarts.init(this.$refs.stageMetricsChart, 'macarons')
- }
- if (this.$refs.avgDurationChart) {
- this.avgDurationChart = echarts.init(this.$refs.avgDurationChart, 'macarons')
- }
- if (this.$refs.funnelChart) {
- this.funnelChart = echarts.init(this.$refs.funnelChart, 'macarons')
- }
- },
- /** 渲染所有图表 */
- renderCharts() {
- const entries = Object.entries(this.results || {})
- const resultsArr = entries.filter(([k]) => k !== '_analysis_summary_').map(([, v]) => v)
- // 1. SKU生命周期阶段分布(饼图)
- this.renderStageDistribution(resultsArr)
- // 2. 各阶段关键指标对比(柱状图)
- this.renderStageMetrics(resultsArr)
- // 3. 阶段平均时长(柱状图)
- this.renderAvgDuration(resultsArr)
- // 4. 阶段转化漏斗(横向条形图)
- this.renderFunnel(resultsArr)
- },
- /** 渲染阶段分布饼图 */
- renderStageDistribution(list) {
- const dist = { 引入期: 0, 成长期: 0, 成熟期: 0, 衰退期: 0 }
- list.forEach(r => {
- const cur = this.normalizeStage(r?.current_stage)
- if (cur && dist[cur] != null) dist[cur]++
- })
- const data = Object.keys(dist).map(key => ({
- value: dist[key],
- name: key
- }))
- const option = {
- tooltip: {
- trigger: 'item',
- formatter: '{a} <br/>{b}: {c} ({d}%)'
- },
- legend: {
- orient: 'vertical',
- left: 'left',
- data: Object.keys(dist)
- },
- series: [
- {
- name: '阶段分布',
- type: 'pie',
- radius: ['40%', '70%'],
- avoidLabelOverlap: false,
- itemStyle: {
- borderRadius: 10,
- borderColor: '#fff',
- borderWidth: 2
- },
- label: {
- show: true,
- formatter: '{b}: {c}\n({d}%)'
- },
- emphasis: {
- label: {
- show: true,
- fontSize: 16,
- fontWeight: 'bold'
- }
- },
- data: data,
- color: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']
- }
- ]
- }
- if (this.stageDistributionChart) {
- this.stageDistributionChart.setOption(option)
- }
- },
- /** 渲染各阶段关键指标对比 */
- renderStageMetrics(list) {
- const agg = this.aggregateStageMetrics(list)
- const metricLabels = Object.keys(agg)
- const revenueAgg = metricLabels.map(k => agg[k].totalRevenue)
- const qtyAgg = metricLabels.map(k => agg[k].totalQuantity)
- const option = {
- tooltip: {
- trigger: 'axis',
- axisPointer: {
- type: 'shadow'
- }
- },
- legend: {
- data: ['销售额(总计)', '销量(总计)']
- },
- grid: {
- left: '3%',
- right: '4%',
- bottom: '3%',
- containLabel: true
- },
- xAxis: {
- type: 'category',
- data: metricLabels
- },
- yAxis: [
- {
- type: 'value',
- name: '销售额',
- position: 'left'
- },
- {
- type: 'value',
- name: '销量',
- position: 'right'
- }
- ],
- series: [
- {
- name: '销售额(总计)',
- type: 'bar',
- data: revenueAgg,
- itemStyle: {
- color: 'rgba(59,130,246,0.7)'
- }
- },
- {
- name: '销量(总计)',
- type: 'bar',
- yAxisIndex: 1,
- data: qtyAgg,
- itemStyle: {
- color: 'rgba(100,116,139,0.7)'
- }
- }
- ]
- }
- if (this.stageMetricsChart) {
- this.stageMetricsChart.setOption(option)
- }
- },
- /** 渲染阶段平均时长 */
- renderAvgDuration(list) {
- const avgDur = this.averageStageDuration(list)
- const durLabels = Object.keys(avgDur)
- const durValues = Object.values(avgDur)
- const option = {
- tooltip: {
- trigger: 'axis',
- axisPointer: {
- type: 'shadow'
- }
- },
- grid: {
- left: '3%',
- right: '4%',
- bottom: '3%',
- containLabel: true
- },
- xAxis: {
- type: 'category',
- data: durLabels
- },
- yAxis: {
- type: 'value',
- name: '天数'
- },
- series: [
- {
- name: '平均持续天数',
- type: 'bar',
- data: durValues,
- itemStyle: {
- color: 'rgba(245,158,11,0.7)'
- },
- label: {
- show: true,
- position: 'top'
- }
- }
- ]
- }
- if (this.avgDurationChart) {
- this.avgDurationChart.setOption(option)
- }
- },
- /** 渲染阶段转化漏斗 */
- renderFunnel(list) {
- const funnelData = this.stageFunnel(list)
- const funnelLabels = funnelData.labels
- const funnelValues = funnelData.values
- const option = {
- tooltip: {
- trigger: 'axis',
- axisPointer: {
- type: 'shadow'
- }
- },
- grid: {
- left: '3%',
- right: '4%',
- bottom: '3%',
- containLabel: true
- },
- xAxis: {
- type: 'value',
- name: '转化率(%)'
- },
- yAxis: {
- type: 'category',
- data: funnelLabels
- },
- series: [
- {
- name: '转化率',
- type: 'bar',
- data: funnelValues,
- itemStyle: {
- color: 'rgba(59,130,246,0.7)'
- },
- label: {
- show: true,
- position: 'right',
- formatter: '{c}%'
- }
- }
- ]
- }
- if (this.funnelChart) {
- this.funnelChart.setOption(option)
- }
- },
- /** 窗口大小变化处理 */
- handleResize() {
- if (this.stageDistributionChart) {
- this.stageDistributionChart.resize()
- }
- if (this.stageMetricsChart) {
- this.stageMetricsChart.resize()
- }
- if (this.avgDurationChart) {
- this.avgDurationChart.resize()
- }
- if (this.funnelChart) {
- this.funnelChart.resize()
- }
- },
- /** 工具函数 */
- sumArray(arr) {
- return (arr || []).reduce((s, v) => s + (Number(v) || 0), 0)
- },
- getLifecycleDays(r) {
- const stats = r?.stage_statistics || {}
- return Object.values(stats).reduce((s, v) => s + (v.durationDays || 0), 0)
- },
- isInsufficient(r) {
- const days = (r?.date_series || []).length
- const stats = r?.stage_statistics || {}
- return days < 120 || Object.keys(stats).length === 0
- },
- normalizeStage(s) {
- if (!s) return ''
- if (s.includes('导入') || s.includes('引入')) return '引入期'
- if (s.includes('成长')) return '成长期'
- if (s.includes('成熟')) return '成熟期'
- if (s.includes('衰退')) return '衰退期'
- return s
- },
- averageStageDuration(list) {
- // 仅统计完整生命周期SKU
- const filtered = list.filter(r => !!r?.is_complete)
- const agg = { 引入期: [], 成长期: [], 成熟期: [], 衰退期: [] }
- filtered.forEach(r => {
- const stats = r?.stage_statistics || {}
- Object.entries(stats).forEach(([stage, v]) => {
- const key = this.normalizeStage(stage)
- if (agg[key]) agg[key].push(v?.durationDays || 0)
- })
- })
- const res = {}
- Object.entries(agg).forEach(([k, arr]) => {
- res[k] = arr.length ? Math.round(arr.reduce((s, x) => s + (Number(x) || 0), 0) / arr.length) : 0
- })
- return res
- },
- aggregateStageMetrics(list) {
- const agg = {
- 引入期: { totalRevenue: 0, totalQuantity: 0 },
- 成长期: { totalRevenue: 0, totalQuantity: 0 },
- 成熟期: { totalRevenue: 0, totalQuantity: 0 },
- 衰退期: { totalRevenue: 0, totalQuantity: 0 }
- }
- list.forEach(r => {
- const stats = r?.stage_statistics || {}
- Object.entries(stats).forEach(([stage, v]) => {
- const key = this.normalizeStage(stage)
- if (agg[key]) {
- agg[key].totalRevenue += Number(v?.totalRevenue || 0)
- agg[key].totalQuantity += Number(v?.totalQuantity || 0)
- }
- })
- })
- return agg
- },
- stageFunnel(list) {
- // 顺序转化:引入→成长→成熟→衰退
- let intro = 0, growth = 0, maturity = 0, decline = 0
- list.forEach(r => {
- const stats = r?.stage_statistics || {}
- const names = Object.keys(stats).map(this.normalizeStage)
- const hasIntro = names.includes('引入期')
- const hasGrowth = names.includes('成长期')
- const hasMaturity = names.includes('成熟期')
- const hasDecline = names.includes('衰退期')
- if (hasIntro) intro++
- if (hasIntro && hasGrowth) growth++
- if (hasIntro && hasGrowth && hasMaturity) maturity++
- if (hasIntro && hasGrowth && hasMaturity && hasDecline) decline++
- })
- const labels = ['引入期', '成长期', '成熟期', '衰退期']
- const counts = [intro, growth, maturity, decline]
- const values = counts.map((c, i) => {
- if (i === 0) return intro ? 100 : 0
- const prev = counts[i - 1]
- return prev ? Number(((c / prev) * 100).toFixed(1)) : 0
- })
- return { labels, values }
- },
- formatUploadDate(date) {
- const d = date instanceof Date ? date : new Date(date)
- if (Number.isNaN(d.getTime())) return ''
- const y = d.getFullYear()
- const m = String(d.getMonth() + 1).padStart(2, '0')
- const day = String(d.getDate()).padStart(2, '0')
- return `${y}-${m}-${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 a = document.createElement('a')
- a.href = url
- a.download = `sku_lifecycle_results_${this.formatUploadDate(new Date())}.json`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- }
- }
- }
- </script>
- <style scoped lang="scss">
- .app-container {
- padding: 20px;
- }
- .page-header {
- margin-bottom: 20px;
-
- h2 {
- font-size: 24px;
- font-weight: 600;
- color: #303133;
- margin-bottom: 8px;
-
- i {
- margin-right: 8px;
- color: #409EFF;
- }
- }
-
- .page-desc {
- color: #909399;
- font-size: 14px;
- margin: 0;
- }
- }
- .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;
- }
- .upload-card {
- border-radius: 8px;
- border: 1px solid #e6eaf2;
- box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
- }
- .upload-card .el-card__header {
- padding: 12px 16px;
- border-bottom: 1px solid #eef2f7;
- }
- .upload-row {
- display: flex;
- align-items: center;
- gap: 16px;
- }
- .upload-compact {
- flex: 1;
- max-width: 260px;
- }
- .upload-compact ::v-deep .el-upload-dragger {
- width: 100%;
- height: 140px;
- padding: 10px 12px;
- border-radius: 8px;
- }
- .upload-compact ::v-deep .el-icon-upload {
- font-size: 26px;
- margin-bottom: 4px;
- }
- .upload-compact ::v-deep .el-upload__text {
- font-size: 13px;
- }
- .upload-compact ::v-deep .el-upload__tip {
- margin-top: 6px;
- font-size: 12px;
- color: #909399;
- }
- .upload-actions {
- display: flex;
- flex-direction: column;
- gap: 10px;
- min-width: 120px;
- }
- ::v-deep .el-card {
- border-radius: 8px;
- border: 1px solid #e6eaf2;
- box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
- }
- ::v-deep .el-card__header {
- border-bottom: 1px solid #eef2f7;
- }
- .stat-card {
- .stat-content {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
-
- .stat-info {
- flex: 1;
-
- .stat-label {
- font-size: 12px;
- color: #909399;
- margin: 0 0 8px 0;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- }
-
- .stat-value {
- font-size: 28px;
- font-weight: bold;
- color: #303133;
- margin: 0 0 8px 0;
- }
-
- .stat-desc {
- font-size: 12px;
- color: #909399;
- margin: 0;
-
- &.stat-desc-success {
- color: #67C23A;
- }
- }
- }
-
- .stat-icon {
- width: 48px;
- height: 48px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 20px;
-
- &.stat-icon-purple {
- background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
- color: #6366f1;
- }
-
- &.stat-icon-blue {
- background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
- color: #3b82f6;
- }
-
- &.stat-icon-teal {
- background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%);
- color: #14b8a6;
- }
-
- &.stat-icon-green {
- background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
- color: #10b981;
- }
-
- &.stat-icon-yellow {
- background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
- color: #f59e0b;
- }
-
- &.stat-icon-red {
- background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
- color: #ef4444;
- }
- }
- }
- }
- ::v-deep .el-card__header {
- display: flex;
- justify-content: space-between;
- align-items: center;
-
- .header-desc {
- font-size: 12px;
- color: #909399;
- font-weight: normal;
- }
- }
- </style>
|