测试
This commit is contained in:
254
frontend/src/components/ComparisonChart.vue
Normal file
254
frontend/src/components/ComparisonChart.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="comparison-charts">
|
||||
<!-- 性能对比 -->
|
||||
<el-tabs v-model="activeTab" type="card" v-if="type === 'performance' || type === 'all'">
|
||||
<el-tab-pane label="性能分数对比" name="score">
|
||||
<div class="chart-container">
|
||||
<v-chart
|
||||
class="chart"
|
||||
:option="scoreChartOption"
|
||||
:loading="loading"
|
||||
autoresize
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 规格对比 -->
|
||||
<el-tabs v-model="activeTab" type="card" v-if="type === 'specifications' || type === 'all'">
|
||||
<el-tab-pane label="多维度规格对比" name="radar">
|
||||
<div class="chart-container">
|
||||
<v-chart
|
||||
class="chart"
|
||||
:option="radarChartOption"
|
||||
:loading="loading"
|
||||
autoresize
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 综合对比 -->
|
||||
<el-tabs v-model="activeTab" type="card" v-if="type === 'comprehensive' || type === 'all'">
|
||||
<el-tab-pane label="性能分数对比" name="score">
|
||||
<div class="chart-container">
|
||||
<v-chart
|
||||
class="chart"
|
||||
:option="scoreChartOption"
|
||||
:loading="loading"
|
||||
autoresize
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="多维度规格对比" name="radar">
|
||||
<div class="chart-container">
|
||||
<v-chart
|
||||
class="chart"
|
||||
:option="radarChartOption"
|
||||
:loading="loading"
|
||||
autoresize
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { BarChart, RadarChart } from 'echarts/charts'
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
RadarComponent
|
||||
} from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
// 注册必要的组件
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
RadarChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
RadarComponent
|
||||
])
|
||||
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
products: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
comparisonData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'all', // 'performance', 'specifications', 'comprehensive', 'all'
|
||||
validator: (value) => ['performance', 'specifications', 'comprehensive', 'all'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const activeTab = ref('score')
|
||||
|
||||
// 性能分数对比图表配置
|
||||
const scoreChartOption = computed(() => {
|
||||
if (!props.products || props.products.length === 0) {
|
||||
return {
|
||||
title: {
|
||||
text: '暂无数据',
|
||||
left: 'center',
|
||||
top: 'middle'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: '产品性能分数对比',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: props.products.map(p => p.name),
|
||||
top: 30
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['性能分数']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '分数'
|
||||
},
|
||||
series: props.products.map(product => ({
|
||||
name: product.name,
|
||||
type: 'bar',
|
||||
data: [product.performanceScore || 0],
|
||||
itemStyle: {
|
||||
color: getProductColor(props.products.indexOf(product))
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 雷达图对比配置
|
||||
const radarChartOption = computed(() => {
|
||||
if (!props.products || props.products.length === 0) {
|
||||
return {
|
||||
title: {
|
||||
text: '暂无数据',
|
||||
left: 'center',
|
||||
top: 'middle'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取所有规格参数名称作为雷达图指标
|
||||
const allSpecs = new Set()
|
||||
props.products.forEach(product => {
|
||||
if (product.specifications) {
|
||||
product.specifications.forEach(spec => {
|
||||
// 只处理数值类型的规格参数
|
||||
if (spec.value !== null && !isNaN(spec.value)) {
|
||||
allSpecs.add(spec.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const indicators = Array.from(allSpecs).slice(0, 8).map(name => ({ name, max: 100 }))
|
||||
|
||||
// 为每个产品生成雷达图数据
|
||||
const series = props.products.map(product => {
|
||||
const data = indicators.map(indicator => {
|
||||
const spec = product.specifications?.find(s => s.name === indicator.name)
|
||||
return spec ? spec.value : 0
|
||||
})
|
||||
|
||||
return {
|
||||
name: product.name,
|
||||
type: 'radar',
|
||||
data: [
|
||||
{
|
||||
value: data,
|
||||
name: product.name
|
||||
}
|
||||
],
|
||||
itemStyle: {
|
||||
color: getProductColor(props.products.indexOf(product))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: '产品多维度规格对比',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {},
|
||||
legend: {
|
||||
data: props.products.map(p => p.name),
|
||||
top: 30
|
||||
},
|
||||
radar: {
|
||||
indicator: indicators.length > 0 ? indicators : [
|
||||
{ name: '性能', max: 100 },
|
||||
{ name: '功耗', max: 100 },
|
||||
{ name: '温度', max: 100 },
|
||||
{ name: '稳定性', max: 100 }
|
||||
]
|
||||
},
|
||||
series: series
|
||||
}
|
||||
})
|
||||
|
||||
// 获取产品颜色
|
||||
const getProductColor = (index) => {
|
||||
const colors = [
|
||||
'#5470c6', '#91cc75', '#fac858', '#ee6666',
|
||||
'#73c0de', '#3ba272', '#fc8452', '#9a60b4'
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comparison-charts {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
359
frontend/src/components/ComparisonTable.vue
Normal file
359
frontend/src/components/ComparisonTable.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<div class="comparison-table">
|
||||
<!-- 基本信息对比 -->
|
||||
<div v-if="showBasicInfo" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">基本信息对比</h2>
|
||||
|
||||
<el-table :data="basicInfoData" stripe>
|
||||
<el-table-column prop="attribute" label="属性" width="180" />
|
||||
|
||||
<el-table-column
|
||||
v-for="(product, index) in products"
|
||||
:key="product.id"
|
||||
:label="`产品 ${index + 1}`"
|
||||
>
|
||||
<template #default="scope">
|
||||
<div
|
||||
:class="{
|
||||
'text-green-600 font-semibold': scope.row.best === product.id,
|
||||
'text-red-600': scope.row.worst === product.id
|
||||
}"
|
||||
>
|
||||
{{ scope.row[product.id] || '-' }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 性能分数对比 -->
|
||||
<div v-if="showPerformance" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">性能分数对比</h2>
|
||||
|
||||
<el-table :data="performanceData" stripe>
|
||||
<el-table-column prop="benchmark" label="测试项目" width="180" />
|
||||
|
||||
<el-table-column
|
||||
v-for="(product, index) in products"
|
||||
:key="product.id"
|
||||
:label="`产品 ${index + 1}`"
|
||||
>
|
||||
<template #default="scope">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
:class="{
|
||||
'text-green-600 font-semibold': scope.row.best === product.id,
|
||||
'text-red-600': scope.row.worst === product.id
|
||||
}"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ scope.row[product.id] || '-' }}
|
||||
</div>
|
||||
|
||||
<!-- 进度条可视化 -->
|
||||
<div v-if="scope.row[product.id]" class="flex-1">
|
||||
<el-progress
|
||||
:percentage="getScorePercentage(scope.row[product.id], scope.row.benchmark)"
|
||||
:color="getProgressColor(scope.row[product.id], scope.row.best === product.id)"
|
||||
:show-text="false"
|
||||
:stroke-width="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 规格参数对比 -->
|
||||
<div v-if="showSpecifications" class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">规格参数对比</h2>
|
||||
|
||||
<el-collapse v-model="activeSpecGroups">
|
||||
<el-collapse-item
|
||||
v-for="(group, groupName) in specificationData"
|
||||
:key="groupName"
|
||||
:title="groupName"
|
||||
:name="groupName"
|
||||
>
|
||||
<el-table :data="group" stripe>
|
||||
<el-table-column prop="specification" label="参数" width="180" />
|
||||
|
||||
<el-table-column
|
||||
v-for="(product, index) in products"
|
||||
:key="product.id"
|
||||
:label="`产品 ${index + 1}`"
|
||||
>
|
||||
<template #default="scope">
|
||||
<div
|
||||
:class="{
|
||||
'text-green-600 font-semibold': scope.row.best === product.id,
|
||||
'text-red-600': scope.row.worst === product.id
|
||||
}"
|
||||
>
|
||||
{{ scope.row[product.id] || '-' }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
products: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
comparisonData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'all', // 'all', 'basic', 'performance', 'specifications'
|
||||
validator: (value) => ['all', 'basic', 'performance', 'specifications'].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
// 响应式状态
|
||||
const activeSpecGroups = ref([]);
|
||||
|
||||
// 计算属性 - 控制显示哪些部分
|
||||
const showBasicInfo = computed(() => {
|
||||
return props.type === 'all' || props.type === 'basic';
|
||||
});
|
||||
|
||||
const showPerformance = computed(() => {
|
||||
return props.type === 'all' || props.type === 'performance';
|
||||
});
|
||||
|
||||
const showSpecifications = computed(() => {
|
||||
return props.type === 'all' || props.type === 'specifications';
|
||||
});
|
||||
|
||||
// 计算属性 - 基本信息数据
|
||||
const basicInfoData = computed(() => {
|
||||
if (!props.products.length) return [];
|
||||
|
||||
const attributes = [
|
||||
{ key: 'name', label: '产品名称' },
|
||||
{ key: 'model', label: '型号' },
|
||||
{ key: 'manufacturer', label: '制造商' },
|
||||
{ key: 'releaseDate', label: '发布日期' },
|
||||
{ key: 'currentRank', label: '当前排名' }
|
||||
];
|
||||
|
||||
return attributes.map(attr => {
|
||||
const row = { attribute: attr.label };
|
||||
|
||||
// 收集所有产品的值
|
||||
const values = {};
|
||||
props.products.forEach(product => {
|
||||
let value = product[attr.key];
|
||||
|
||||
// 格式化日期
|
||||
if (attr.key === 'releaseDate' && value) {
|
||||
value = new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
// 格式化排名
|
||||
if (attr.key === 'currentRank' && value) {
|
||||
value = `#${value}`;
|
||||
}
|
||||
|
||||
values[product.id] = value;
|
||||
});
|
||||
|
||||
// 确定最优值和最差值
|
||||
const numericValues = Object.values(values).filter(v => !isNaN(parseFloat(v)) && isFinite(v));
|
||||
|
||||
if (numericValues.length > 0) {
|
||||
// 数值越小越好的情况(如排名)
|
||||
if (attr.key === 'currentRank') {
|
||||
const min = Math.min(...numericValues);
|
||||
const max = Math.max(...numericValues);
|
||||
|
||||
Object.keys(values).forEach(productId => {
|
||||
const value = parseFloat(values[productId]);
|
||||
if (value === min) row.best = parseInt(productId);
|
||||
if (value === max) row.worst = parseInt(productId);
|
||||
});
|
||||
} else {
|
||||
// 数值越大越好的情况
|
||||
const max = Math.max(...numericValues);
|
||||
const min = Math.min(...numericValues);
|
||||
|
||||
Object.keys(values).forEach(productId => {
|
||||
const value = parseFloat(values[productId]);
|
||||
if (value === max) row.best = parseInt(productId);
|
||||
if (value === min) row.worst = parseInt(productId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ...row, ...values };
|
||||
});
|
||||
});
|
||||
|
||||
// 计算属性 - 性能数据
|
||||
const performanceData = computed(() => {
|
||||
if (!props.products.length) return [];
|
||||
|
||||
// 收集所有性能测试项目
|
||||
const benchmarks = new Set();
|
||||
props.products.forEach(product => {
|
||||
if (product.performanceScores) {
|
||||
product.performanceScores.forEach(score => {
|
||||
benchmarks.add(score.benchmarkName);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 为每个基准测试创建一行数据
|
||||
return Array.from(benchmarks).map(benchmark => {
|
||||
const row = { benchmark };
|
||||
|
||||
// 收集每个产品的分数
|
||||
const scores = {};
|
||||
props.products.forEach(product => {
|
||||
const score = product.performanceScores?.find(s => s.benchmarkName === benchmark);
|
||||
scores[product.id] = score ? score.score : null;
|
||||
});
|
||||
|
||||
// 确定最高分和最低分
|
||||
const validScores = Object.values(scores).filter(s => s !== null);
|
||||
if (validScores.length > 0) {
|
||||
const max = Math.max(...validScores);
|
||||
const min = Math.min(...validScores);
|
||||
|
||||
Object.keys(scores).forEach(productId => {
|
||||
if (scores[productId] === max) row.best = parseInt(productId);
|
||||
if (scores[productId] === min) row.worst = parseInt(productId);
|
||||
});
|
||||
}
|
||||
|
||||
return { ...row, ...scores };
|
||||
});
|
||||
});
|
||||
|
||||
// 计算属性 - 规格数据
|
||||
const specificationData = computed(() => {
|
||||
if (!props.products.length) return {};
|
||||
|
||||
// 收集所有规格参数并按类别分组
|
||||
const specsByGroup = {};
|
||||
|
||||
props.products.forEach(product => {
|
||||
if (product.specifications) {
|
||||
product.specifications.forEach(spec => {
|
||||
const group = spec.group || '其他';
|
||||
|
||||
if (!specsByGroup[group]) {
|
||||
specsByGroup[group] = {};
|
||||
}
|
||||
|
||||
specsByGroup[group][spec.name] = specsByGroup[group][spec.name] || {};
|
||||
specsByGroup[group][spec.name][product.id] = spec.value;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 转换为数组格式并确定最优值
|
||||
const result = {};
|
||||
|
||||
Object.keys(specsByGroup).forEach(group => {
|
||||
result[group] = Object.keys(specsByGroup[group]).map(specName => {
|
||||
const row = { specification: specName };
|
||||
const values = specsByGroup[group][specName];
|
||||
|
||||
// 尝试确定数值型的最优值
|
||||
const numericValues = Object.values(values).filter(v => {
|
||||
const num = parseFloat(v);
|
||||
return !isNaN(num) && isFinite(num);
|
||||
});
|
||||
|
||||
if (numericValues.length > 0) {
|
||||
// 对于大多数规格,数值越大越好(如频率、核心数等)
|
||||
// 但某些规格是越小越好(如功耗、制程等)
|
||||
const isLessBetter = specName.includes('功耗') ||
|
||||
specName.includes('制程') ||
|
||||
specName.includes('nm') ||
|
||||
specName.includes('TDP');
|
||||
|
||||
const max = Math.max(...numericValues);
|
||||
const min = Math.min(...numericValues);
|
||||
|
||||
Object.keys(values).forEach(productId => {
|
||||
const value = parseFloat(values[productId]);
|
||||
|
||||
if (isLessBetter) {
|
||||
if (value === min) row.best = parseInt(productId);
|
||||
if (value === max) row.worst = parseInt(productId);
|
||||
} else {
|
||||
if (value === max) row.best = parseInt(productId);
|
||||
if (value === min) row.worst = parseInt(productId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { ...row, ...values };
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// 方法 - 计算分数百分比
|
||||
const getScorePercentage = (score, benchmark) => {
|
||||
if (!score) return 0;
|
||||
|
||||
// 根据不同的基准测试设置最大值
|
||||
const maxScores = {
|
||||
'GeekBench Single-Core': 5000,
|
||||
'GeekBench Multi-Core': 20000,
|
||||
'3DMark Time Spy': 20000,
|
||||
'3DMark Fire Strike': 30000,
|
||||
'AnTuTu': 1000000
|
||||
};
|
||||
|
||||
const maxScore = maxScores[benchmark] || 10000;
|
||||
return Math.min(100, Math.round((score / maxScore) * 100));
|
||||
};
|
||||
|
||||
// 方法 - 获取进度条颜色
|
||||
const getProgressColor = (score, isBest) => {
|
||||
if (!score) return '#e0e0e0';
|
||||
|
||||
if (isBest) {
|
||||
return '#67c23a'; // 绿色
|
||||
}
|
||||
|
||||
// 根据分数返回不同颜色
|
||||
const percentage = getScorePercentage(score);
|
||||
if (percentage >= 80) return '#67c23a'; // 绿色
|
||||
if (percentage >= 60) return '#e6a23c'; // 橙色
|
||||
if (percentage >= 40) return '#f56c6c'; // 红色
|
||||
return '#909399'; // 灰色
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-progress {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
193
frontend/src/components/EmptyState.vue
Normal file
193
frontend/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-container">
|
||||
<div class="empty-icon">
|
||||
<el-icon :size="iconSize" :color="iconColor">
|
||||
<component :is="iconComponent" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h3 class="empty-title">{{ title }}</h3>
|
||||
<p class="empty-description">{{ description }}</p>
|
||||
<div class="empty-actions">
|
||||
<el-button v-if="showRefresh" type="primary" @click="handleRefresh">
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button v-if="showBack" @click="handleBack">
|
||||
返回
|
||||
</el-button>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Document,
|
||||
Search,
|
||||
Warning,
|
||||
Refresh,
|
||||
House,
|
||||
Connection,
|
||||
DataLine
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'data',
|
||||
validator: (value) => ['data', 'search', 'error', 'network', 'comparison'].includes(value)
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showRefresh: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showBack: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'data':
|
||||
return DataLine
|
||||
case 'search':
|
||||
return Search
|
||||
case 'error':
|
||||
return Warning
|
||||
case 'network':
|
||||
return Connection
|
||||
case 'comparison':
|
||||
return Document
|
||||
default:
|
||||
return Document
|
||||
}
|
||||
})
|
||||
|
||||
const iconSize = computed(() => {
|
||||
return props.type === 'comparison' ? 80 : 60
|
||||
})
|
||||
|
||||
const iconColor = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'error':
|
||||
return '#F56C6C'
|
||||
case 'network':
|
||||
return '#E6A23C'
|
||||
default:
|
||||
return '#909399'
|
||||
}
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.title) {
|
||||
return props.title
|
||||
}
|
||||
|
||||
switch (props.type) {
|
||||
case 'data':
|
||||
return '暂无数据'
|
||||
case 'search':
|
||||
return '未找到相关结果'
|
||||
case 'error':
|
||||
return '发生错误'
|
||||
case 'network':
|
||||
return '网络连接失败'
|
||||
case 'comparison':
|
||||
return '请选择要对比的产品'
|
||||
default:
|
||||
return '暂无内容'
|
||||
}
|
||||
})
|
||||
|
||||
const description = computed(() => {
|
||||
if (props.description) {
|
||||
return props.description
|
||||
}
|
||||
|
||||
switch (props.type) {
|
||||
case 'data':
|
||||
return '当前没有可显示的数据'
|
||||
case 'search':
|
||||
return '请尝试使用其他关键词搜索'
|
||||
case 'error':
|
||||
return '应用遇到了一些问题,请稍后再试'
|
||||
case 'network':
|
||||
return '请检查您的网络连接,然后重试'
|
||||
case 'comparison':
|
||||
return '请在产品列表中选择2-4个产品进行对比'
|
||||
default:
|
||||
return '当前没有可显示的内容'
|
||||
}
|
||||
})
|
||||
|
||||
const handleRefresh = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-container {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
margin: 0 0 20px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.empty-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-actions .el-button {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/components/ErrorBoundary.vue
Normal file
108
frontend/src/components/ErrorBoundary.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="error-boundary">
|
||||
<div v-if="hasError" class="error-content">
|
||||
<div class="error-icon">
|
||||
<el-icon size="64" color="#F56C6C">
|
||||
<Warning />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="error-text">
|
||||
<h3>{{ title || '出现错误' }}</h3>
|
||||
<p>{{ message || '应用程序遇到了意外错误' }}</p>
|
||||
<el-button type="primary" @click="handleRetry" v-if="retryable">
|
||||
重试
|
||||
</el-button>
|
||||
<el-button @click="$router.push('/')">
|
||||
返回首页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { Warning } from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'ErrorBoundary',
|
||||
components: {
|
||||
Warning
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
retryable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const hasError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const handleRetry = () => {
|
||||
hasError.value = false
|
||||
errorMessage.value = ''
|
||||
emit('retry')
|
||||
}
|
||||
|
||||
const errorCaptured = (err, vm, info) => {
|
||||
hasError.value = true
|
||||
errorMessage.value = err.message
|
||||
console.error('Error caught by error boundary:', err, info)
|
||||
// 防止错误继续向上传播
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
hasError,
|
||||
errorMessage,
|
||||
handleRetry,
|
||||
errorCaptured
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-boundary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-text h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-text p {
|
||||
margin: 0 0 30px 0;
|
||||
color: #909399;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.error-text .el-button {
|
||||
margin: 0 6px;
|
||||
}
|
||||
</style>
|
||||
177
frontend/src/components/ErrorPage.vue
Normal file
177
frontend/src/components/ErrorPage.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<el-icon :size="80" color="#F56C6C">
|
||||
<WarningFilled />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h1 class="error-title">{{ title }}</h1>
|
||||
<p class="error-message">{{ message }}</p>
|
||||
<div class="error-actions">
|
||||
<el-button type="primary" @click="goHome">返回首页</el-button>
|
||||
<el-button @click="refresh">刷新页面</el-button>
|
||||
<el-button v-if="showDetails" @click="toggleDetails">
|
||||
{{ showErrorDetails ? '隐藏详情' : '显示详情' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="showErrorDetails && errorDetails" class="error-details">
|
||||
<el-collapse>
|
||||
<el-collapse-item title="错误详情" name="details">
|
||||
<pre>{{ errorDetails }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { WarningFilled } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
errorCode: {
|
||||
type: Number,
|
||||
default: 500
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '服务器错误'
|
||||
},
|
||||
errorDetails: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const showErrorDetails = ref(false)
|
||||
|
||||
const title = computed(() => {
|
||||
switch (props.errorCode) {
|
||||
case 404:
|
||||
return '页面未找到'
|
||||
case 403:
|
||||
return '访问被拒绝'
|
||||
case 500:
|
||||
return '服务器错误'
|
||||
default:
|
||||
return '发生错误'
|
||||
}
|
||||
})
|
||||
|
||||
const message = computed(() => {
|
||||
if (props.errorMessage) {
|
||||
return props.errorMessage
|
||||
}
|
||||
|
||||
switch (props.errorCode) {
|
||||
case 404:
|
||||
return '抱歉,您访问的页面不存在或已被移除'
|
||||
case 403:
|
||||
return '抱歉,您没有权限访问此页面'
|
||||
case 500:
|
||||
return '抱歉,服务器遇到了问题,请稍后再试'
|
||||
default:
|
||||
return '抱歉,应用遇到了未知错误'
|
||||
}
|
||||
})
|
||||
|
||||
const showDetails = computed(() => {
|
||||
return props.errorDetails && process.env.NODE_ENV === 'development'
|
||||
})
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const toggleDetails = () => {
|
||||
showErrorDetails.value = !showErrorDetails.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
background-color: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-actions .el-button {
|
||||
margin: 0 10px 10px 0;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin-top: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.error-details pre {
|
||||
background-color: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #606266;
|
||||
overflow-x: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-actions .el-button {
|
||||
margin: 0 5px 10px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
269
frontend/src/components/Footer.vue
Normal file
269
frontend/src/components/Footer.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<div class="footer-container">
|
||||
<div class="footer-content">
|
||||
<!-- 关于我们 -->
|
||||
<div class="footer-section">
|
||||
<h3 class="section-title">关于我们</h3>
|
||||
<p class="section-text">
|
||||
硬件性能排行榜提供全面的硬件产品性能数据和对比分析,帮助用户做出明智的硬件选择决策。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 数据来源 -->
|
||||
<div class="footer-section">
|
||||
<h3 class="section-title">数据来源</h3>
|
||||
<ul class="data-sources">
|
||||
<li v-for="source in dataSources" :key="source.name">
|
||||
<el-link :href="source.url" target="_blank" type="primary">
|
||||
{{ source.name }}
|
||||
</el-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 快速链接 -->
|
||||
<div class="footer-section">
|
||||
<h3 class="section-title">快速链接</h3>
|
||||
<ul class="quick-links">
|
||||
<li>
|
||||
<router-link to="/">首页</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/comparison">产品对比</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<el-link href="#" @click.prevent="showAboutDialog">关于我们</el-link>
|
||||
</li>
|
||||
<li>
|
||||
<el-link href="#" @click.prevent="showPrivacyDialog">隐私政策</el-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<div class="copyright">
|
||||
<p>© {{ currentYear }} 硬件性能排行榜. 保留所有权利.</p>
|
||||
</div>
|
||||
<div class="update-info">
|
||||
<p>最后更新时间: {{ lastUpdateTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关于我们对话框 -->
|
||||
<el-dialog
|
||||
v-model="aboutDialogVisible"
|
||||
title="关于我们"
|
||||
width="500px"
|
||||
center
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<h4>硬件性能排行榜</h4>
|
||||
<p>
|
||||
我们致力于为用户提供最准确、最全面的硬件产品性能数据。通过整合多个权威测试平台的数据,
|
||||
我们帮助用户更好地了解和比较各种硬件产品的性能表现。
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
我们的数据来源于公开的基准测试结果,并定期更新以确保信息的时效性和准确性。
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="aboutDialogVisible = false">关闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 隐私政策对话框 -->
|
||||
<el-dialog
|
||||
v-model="privacyDialogVisible"
|
||||
title="隐私政策"
|
||||
width="500px"
|
||||
center
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<h4>数据收集与使用</h4>
|
||||
<p>
|
||||
我们尊重您的隐私权,承诺保护您的个人信息安全。本隐私政策说明了我们如何收集、使用和保护您的信息。
|
||||
</p>
|
||||
<h4 class="mt-4">信息收集</h4>
|
||||
<p>
|
||||
我们仅收集用于改善网站体验的信息,如访问统计数据和用户偏好设置。我们不收集任何个人身份信息。
|
||||
</p>
|
||||
<h4 class="mt-4">信息使用</h4>
|
||||
<p>
|
||||
收集的信息仅用于改进我们的服务和用户体验。我们不会将您的信息出售、租赁或以其他方式提供给第三方。
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="privacyDialogVisible = false">关闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
const lastUpdateTime = ref('')
|
||||
const aboutDialogVisible = ref(false)
|
||||
const privacyDialogVisible = ref(false)
|
||||
|
||||
const dataSources = ref([
|
||||
{ name: 'GeekBench', url: 'https://www.geekbench.com/' },
|
||||
{ name: '3DMark', url: 'https://www.3dmark.com/' },
|
||||
{ name: 'AnTuTu', url: 'https://www.antutu.com/' }
|
||||
])
|
||||
|
||||
const showAboutDialog = () => {
|
||||
aboutDialogVisible.value = true
|
||||
}
|
||||
|
||||
const showPrivacyDialog = () => {
|
||||
privacyDialogVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 设置默认值,等待后端API实现后再启用
|
||||
lastUpdateTime.value = '2024-01-01 00:00:00'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
background-color: #f5f7fa;
|
||||
color: #606266;
|
||||
margin-top: 40px;
|
||||
padding: 40px 0 20px;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 40px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: #409eff;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.data-sources, .quick-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.data-sources li, .quick-links li {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.data-sources li:last-child, .quick-links li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.quick-links a {
|
||||
color: #606266;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.quick-links a:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.copyright p, .update-info p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-content h4 {
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dialog-content p {
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
padding: 30px 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.footer-container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
padding: 20px 0 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
frontend/src/components/GlobalLoading.vue
Normal file
95
frontend/src/components/GlobalLoading.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="fade">
|
||||
<div v-if="show" class="global-loading-overlay" :class="{ 'full-screen': fullScreen }">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner">
|
||||
<el-icon :size="size" class="is-loading">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div v-if="text" class="loading-text">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'GlobalLoading',
|
||||
components: {
|
||||
Loading
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '加载中...'
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 40
|
||||
},
|
||||
fullScreen: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.global-loading-overlay.full-screen {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.global-loading-overlay:not(.full-screen) {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
275
frontend/src/components/Header.vue
Normal file
275
frontend/src/components/Header.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-container">
|
||||
<div class="logo-section">
|
||||
<router-link to="/" class="logo-link">
|
||||
<div class="logo">
|
||||
<el-icon size="32" color="#409eff"><Monitor /></el-icon>
|
||||
</div>
|
||||
<h1 class="app-title">硬件性能排行榜</h1>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
:ellipsis="false"
|
||||
class="category-menu"
|
||||
:default-active="activeCategory"
|
||||
>
|
||||
<el-menu-item index="home">
|
||||
<router-link to="/">首页</router-link>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="categories" v-if="categories.length > 0">
|
||||
<template #title>产品类别</template>
|
||||
<el-menu-item
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:index="category.id"
|
||||
>
|
||||
<router-link :to="`/category/${category.id}`">
|
||||
{{ category.name }}
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="comparison">
|
||||
<router-link to="/comparison">产品对比</router-link>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
type="text"
|
||||
@click="toggleMobileMenu"
|
||||
class="mobile-menu-button"
|
||||
>
|
||||
<el-icon :size="24">
|
||||
<Menu />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单 -->
|
||||
<div
|
||||
class="mobile-menu"
|
||||
:class="{ 'mobile-menu-open': isMobileMenuOpen }"
|
||||
>
|
||||
<div class="mobile-menu-container">
|
||||
<div class="mobile-menu-header">
|
||||
<h2>菜单</h2>
|
||||
<el-button
|
||||
type="text"
|
||||
@click="toggleMobileMenu"
|
||||
class="close-button"
|
||||
>
|
||||
<el-icon :size="24">
|
||||
<Close />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-menu mode="vertical" :default-active="activeCategory">
|
||||
<el-menu-item index="home">
|
||||
<router-link to="/" @click="toggleMobileMenu">首页</router-link>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="categories" v-if="categories.length > 0">
|
||||
<template #title>产品类别</template>
|
||||
<el-menu-item
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:index="category.id"
|
||||
>
|
||||
<router-link :to="`/category/${category.id}`" @click="toggleMobileMenu">
|
||||
{{ category.name }}
|
||||
</router-link>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="comparison">
|
||||
<router-link to="/comparison" @click="toggleMobileMenu">产品对比</router-link>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useCategoryStore } from '../stores/categoryStore'
|
||||
import { Menu, Close, Monitor } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const categoryStore = useCategoryStore()
|
||||
|
||||
const isMobileMenuOpen = ref(false)
|
||||
|
||||
// 从store获取类别
|
||||
const categories = computed(() => categoryStore.categories)
|
||||
|
||||
// 计算当前激活的菜单项
|
||||
const activeCategory = computed(() => {
|
||||
if (route.path === '/') return 'home'
|
||||
if (route.path.startsWith('/category/')) return route.params.categoryId
|
||||
if (route.path === '/comparison') return 'comparison'
|
||||
return ''
|
||||
})
|
||||
|
||||
// 切换移动端菜单
|
||||
const toggleMobileMenu = () => {
|
||||
isMobileMenuOpen.value = !isMobileMenuOpen.value
|
||||
}
|
||||
|
||||
// 组件挂载时获取类别数据
|
||||
onMounted(async () => {
|
||||
if (categories.value.length === 0) {
|
||||
await categoryStore.fetchCategories()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-menu {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.category-menu .el-menu-item {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.category-menu .el-menu-item a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-menu-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端菜单 */
|
||||
.mobile-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1001;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.mobile-menu-open {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mobile-menu-container {
|
||||
width: 70%;
|
||||
max-width: 300px;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-menu-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.nav-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.mobile-menu-container {
|
||||
width: 85%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
frontend/src/components/Layout.vue
Normal file
53
frontend/src/components/Layout.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<Header />
|
||||
|
||||
<main class="main-content">
|
||||
<div class="content-container">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from './Header.vue'
|
||||
import Footer from './Footer.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.content-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content-container {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
246
frontend/src/components/LazyImage.vue
Normal file
246
frontend/src/components/LazyImage.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="lazy-image-container" ref="containerRef">
|
||||
<img
|
||||
v-if="isLoaded || isIntersecting"
|
||||
:src="currentSrc"
|
||||
:alt="alt"
|
||||
:class="imageClass"
|
||||
@load="handleLoad"
|
||||
@error="handleError"
|
||||
/>
|
||||
<div v-else class="image-placeholder" :class="placeholderClass">
|
||||
<el-icon class="placeholder-icon" :size="placeholderSize">
|
||||
<Picture />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '/placeholder.svg'
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '200px'
|
||||
},
|
||||
objectFit: {
|
||||
type: String,
|
||||
default: 'cover'
|
||||
},
|
||||
placeholderSize: {
|
||||
type: Number,
|
||||
default: 40
|
||||
},
|
||||
threshold: {
|
||||
type: Number,
|
||||
default: 0.1
|
||||
},
|
||||
useWebP: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['load', 'error'])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const isIntersecting = ref(false)
|
||||
const isLoaded = ref(false)
|
||||
const hasError = ref(false)
|
||||
const observer = ref(null)
|
||||
|
||||
// 检查浏览器是否支持WebP
|
||||
const supportsWebP = ref(false)
|
||||
|
||||
// 计算当前图片源,优先使用WebP格式
|
||||
const currentSrc = computed(() => {
|
||||
if (!props.src) return ''
|
||||
|
||||
// 如果不使用WebP或浏览器不支持,返回原始URL
|
||||
if (!props.useWebP || !supportsWebP.value) {
|
||||
return props.src
|
||||
}
|
||||
|
||||
// 尝试转换为WebP格式
|
||||
return convertToWebP(props.src)
|
||||
})
|
||||
|
||||
// 计算图片类名
|
||||
const imageClass = computed(() => {
|
||||
return [
|
||||
'lazy-image',
|
||||
{
|
||||
'image-loaded': isLoaded.value,
|
||||
'image-error': hasError.value
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 计算占位符类名
|
||||
const placeholderClass = computed(() => {
|
||||
return [
|
||||
'image-placeholder',
|
||||
{
|
||||
'placeholder-error': hasError.value
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 将图片URL转换为WebP格式
|
||||
const convertToWebP = (url) => {
|
||||
// 如果已经是WebP格式,直接返回
|
||||
if (url.includes('.webp')) {
|
||||
return url
|
||||
}
|
||||
|
||||
// 如果是外部URL,不进行转换
|
||||
if (url.startsWith('http')) {
|
||||
return url
|
||||
}
|
||||
|
||||
// 尝试转换为WebP格式
|
||||
const lastDotIndex = url.lastIndexOf('.')
|
||||
if (lastDotIndex > -1) {
|
||||
return url.substring(0, lastDotIndex) + '.webp'
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// 检查WebP支持
|
||||
const checkWebPSupport = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 1
|
||||
canvas.height = 1
|
||||
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0
|
||||
}
|
||||
|
||||
// 处理图片加载
|
||||
const handleLoad = () => {
|
||||
isLoaded.value = true
|
||||
emit('load')
|
||||
}
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleError = () => {
|
||||
hasError.value = true
|
||||
emit('error')
|
||||
}
|
||||
|
||||
// 设置交叉观察器
|
||||
const setupIntersectionObserver = () => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
observer.value = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
isIntersecting.value = true
|
||||
// 图片进入视口后,停止观察
|
||||
if (observer.value) {
|
||||
observer.value.unobserve(entry.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
threshold: props.threshold
|
||||
}
|
||||
)
|
||||
|
||||
observer.value.observe(containerRef.value)
|
||||
}
|
||||
|
||||
// 组件挂载时初始化
|
||||
onMounted(() => {
|
||||
// 检查WebP支持
|
||||
supportsWebP.value = checkWebPSupport()
|
||||
|
||||
// 设置交叉观察器
|
||||
setupIntersectionObserver()
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (observer.value) {
|
||||
observer.value.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lazy-image-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: v-bind(width);
|
||||
height: v-bind(height);
|
||||
}
|
||||
|
||||
.lazy-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: v-bind(objectFit);
|
||||
transition: opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.lazy-image.image-loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lazy-image.image-error {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f7fa;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.image-placeholder.placeholder-error {
|
||||
background-color: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
131
frontend/src/components/LoadingSkeleton.vue
Normal file
131
frontend/src/components/LoadingSkeleton.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="loading-container">
|
||||
<!-- 产品卡片骨架屏 -->
|
||||
<div v-if="type === 'product-card'" class="product-card-skeleton">
|
||||
<div class="skeleton-header">
|
||||
<el-skeleton-item variant="image" class="skeleton-image" />
|
||||
<div class="skeleton-title">
|
||||
<el-skeleton-item variant="h3" style="width: 50%" />
|
||||
<el-skeleton-item variant="text" style="width: 30%; margin-top: 10px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="skeleton-content">
|
||||
<el-skeleton-item variant="text" style="width: 80%" />
|
||||
<el-skeleton-item variant="text" style="width: 60%" />
|
||||
<el-skeleton-item variant="text" style="width: 40%" />
|
||||
</div>
|
||||
<div class="skeleton-footer">
|
||||
<el-skeleton-item variant="button" style="width: 80px; height: 32px" />
|
||||
<el-skeleton-item variant="button" style="width: 80px; height: 32px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格骨架屏 -->
|
||||
<div v-else-if="type === 'table'" class="table-skeleton">
|
||||
<el-skeleton :rows="rows" animated />
|
||||
</div>
|
||||
|
||||
<!-- 图表骨架屏 -->
|
||||
<div v-else-if="type === 'chart'" class="chart-skeleton">
|
||||
<el-skeleton-item variant="h3" style="width: 30%; margin-bottom: 20px" />
|
||||
<div class="chart-placeholder">
|
||||
<el-skeleton-item variant="rect" style="width: 100%; height: 300px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表骨架屏 -->
|
||||
<div v-else-if="type === 'list'" class="list-skeleton">
|
||||
<div v-for="i in rows" :key="i" class="list-item">
|
||||
<el-skeleton-item variant="circle" style="width: 40px; height: 40px" />
|
||||
<div class="list-item-content">
|
||||
<el-skeleton-item variant="h3" style="width: 60%" />
|
||||
<el-skeleton-item variant="text" style="width: 80%" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 默认骨架屏 -->
|
||||
<div v-else class="default-skeleton">
|
||||
<el-skeleton :rows="rows" animated />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'LoadingSkeleton',
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => [
|
||||
'default', 'product-card', 'table', 'chart', 'list'
|
||||
].includes(value)
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.product-card-skeleton {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skeleton-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-right: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skeleton-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.list-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-item-content {
|
||||
margin-left: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
79
frontend/src/components/LoadingSpinner.vue
Normal file
79
frontend/src/components/LoadingSpinner.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="loading-container" :class="{ 'full-screen': fullScreen }">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner">
|
||||
<el-icon :size="size" class="is-loading">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
</div>
|
||||
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 40
|
||||
},
|
||||
fullScreen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loading-container.full-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.is-loading {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
240
frontend/src/components/PerformanceCharts.vue
Normal file
240
frontend/src/components/PerformanceCharts.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div class="performance-charts">
|
||||
<!-- 性能历史趋势图 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">性能历史趋势</h2>
|
||||
<div v-if="!performanceHistory || performanceHistory.length === 0" class="h-64 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p class="text-gray-500">暂无历史数据</p>
|
||||
</div>
|
||||
<div v-else class="h-80">
|
||||
<v-chart
|
||||
class="h-full w-full"
|
||||
:option="historyChartOption"
|
||||
:loading="loading"
|
||||
autoresize
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 性能指标雷达图 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">性能指标雷达图</h2>
|
||||
<div v-if="!product || !product.performanceScores || product.performanceScores.length === 0" class="h-64 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p class="text-gray-500">暂无性能数据</p>
|
||||
</div>
|
||||
<div v-else class="h-80">
|
||||
<v-chart
|
||||
class="h-full w-full"
|
||||
:option="radarChartOption"
|
||||
:loading="loading"
|
||||
autoresize
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart, RadarChart } from 'echarts/charts'
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
RadarComponent
|
||||
} from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
// 注册必要的组件
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
RadarChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
RadarComponent
|
||||
])
|
||||
|
||||
export default {
|
||||
name: 'PerformanceCharts',
|
||||
components: {
|
||||
VChart
|
||||
},
|
||||
props: {
|
||||
product: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
performanceHistory: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
// 性能历史趋势图配置
|
||||
const historyChartOption = computed(() => {
|
||||
if (!props.performanceHistory || props.performanceHistory.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 准备数据
|
||||
const dates = []
|
||||
const scores = []
|
||||
|
||||
props.performanceHistory.forEach(item => {
|
||||
dates.push(new Date(item.recordDate).toLocaleDateString())
|
||||
scores.push(item.score)
|
||||
})
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: `${props.product.name} 性能趋势`,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
return `${params[0].axisValue}<br/>性能分数: ${params[0].value}`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '性能分数'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '性能分数',
|
||||
type: 'line',
|
||||
data: scores,
|
||||
smooth: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: '#409EFF'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#409EFF'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(64, 158, 255, 0.5)' },
|
||||
{ offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
containLabel: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 性能指标雷达图配置
|
||||
const radarChartOption = computed(() => {
|
||||
if (!props.product || !props.product.performanceScores || props.product.performanceScores.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 准备雷达图数据
|
||||
const indicators = []
|
||||
const values = []
|
||||
|
||||
// 定义常见的基准测试及其最大值
|
||||
const benchmarkMaxValues = {
|
||||
'Geekbench Single-Core': 3000,
|
||||
'Geekbench Multi-Core': 20000,
|
||||
'3DMark Time Spy': 20000,
|
||||
'3DMark Fire Strike': 30000,
|
||||
'AnTuTu': 1000000,
|
||||
'PCMark': 8000,
|
||||
'Cinebench R23': 30000,
|
||||
'Cinebench R20': 8000
|
||||
}
|
||||
|
||||
props.product.performanceScores.forEach(score => {
|
||||
const benchmarkName = score.benchmarkName
|
||||
const maxValue = benchmarkMaxValues[benchmarkName] || 100
|
||||
|
||||
indicators.push({
|
||||
name: benchmarkName,
|
||||
max: maxValue
|
||||
})
|
||||
|
||||
// 将分数转换为百分比(0-100)
|
||||
const percentage = Math.min(100, (score.score / maxValue) * 100)
|
||||
values.push(percentage)
|
||||
})
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: `${props.product.name} 性能指标`,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
radar: {
|
||||
indicator: indicators,
|
||||
center: ['50%', '55%'],
|
||||
radius: '70%'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '性能指标',
|
||||
type: 'radar',
|
||||
data: [
|
||||
{
|
||||
value: values,
|
||||
name: props.product.name,
|
||||
itemStyle: {
|
||||
color: '#409EFF'
|
||||
},
|
||||
areaStyle: {
|
||||
color: 'rgba(64, 158, 255, 0.3)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
historyChartOption,
|
||||
radarChartOption
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.performance-charts {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
130
frontend/src/components/ProductCard.vue
Normal file
130
frontend/src/components/ProductCard.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="product-card bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<div class="p-4">
|
||||
<!-- 产品选择复选框 -->
|
||||
<div class="absolute top-2 right-2">
|
||||
<el-checkbox
|
||||
v-model="selected"
|
||||
@change="handleSelectionChange"
|
||||
:disabled="!canSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 产品图片 -->
|
||||
<div class="flex justify-center mb-4">
|
||||
<LazyImage
|
||||
:src="product.imageUrl || '/placeholder.svg'"
|
||||
:alt="product.name"
|
||||
width="96px"
|
||||
height="96px"
|
||||
object-fit="contain"
|
||||
placeholder-size="32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 产品名称 -->
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2">{{ product.name }}</h3>
|
||||
|
||||
<!-- 产品型号 -->
|
||||
<p class="text-gray-600 text-sm mb-2">{{ product.model }}</p>
|
||||
|
||||
<!-- 制造商 -->
|
||||
<div class="flex items-center mb-3">
|
||||
<span class="text-sm text-gray-500">制造商:</span>
|
||||
<span class="text-sm ml-1">{{ product.manufacturer }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 发布日期 -->
|
||||
<div class="flex items-center mb-3">
|
||||
<span class="text-sm text-gray-500">发布日期:</span>
|
||||
<span class="text-sm ml-1">{{ formatDate(product.releaseDate) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 性能分数和排名 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-500">性能分数:</span>
|
||||
<span class="text-sm ml-1 font-semibold">{{ product.performanceScore }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<el-icon class="text-yellow-500 mr-1"><Trophy /></el-icon>
|
||||
<span class="text-sm font-semibold">#{{ product.currentRank }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { Trophy } from '@element-plus/icons-vue'
|
||||
import { useComparisonStore } from '../stores/comparisonStore'
|
||||
import LazyImage from './LazyImage.vue'
|
||||
|
||||
export default {
|
||||
name: 'ProductCard',
|
||||
components: {
|
||||
Trophy,
|
||||
LazyImage
|
||||
},
|
||||
props: {
|
||||
product: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const comparisonStore = useComparisonStore()
|
||||
|
||||
// 计算属性
|
||||
const selected = computed(() => {
|
||||
return comparisonStore.selectedProducts.some(p => p.id === props.product.id)
|
||||
})
|
||||
|
||||
const canSelect = computed(() => {
|
||||
return !selected.value || comparisonStore.selectedProducts.length <= 4
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleSelectionChange = (isSelected) => {
|
||||
if (isSelected) {
|
||||
comparisonStore.addProduct(props.product)
|
||||
} else {
|
||||
comparisonStore.removeProduct(props.product.id)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '未知'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
canSelect,
|
||||
handleSelectionChange,
|
||||
formatDate
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-card {
|
||||
position: relative;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
147
frontend/src/components/ProductFilter.vue
Normal file
147
frontend/src/components/ProductFilter.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="product-filter bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">筛选条件</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- 品牌筛选 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">品牌</label>
|
||||
<el-select
|
||||
v-model="filters.manufacturer"
|
||||
placeholder="选择品牌"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="manufacturer in manufacturers"
|
||||
:key="manufacturer"
|
||||
:label="manufacturer"
|
||||
:value="manufacturer"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 性能分数区间 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
性能分数: {{ filters.minScore }} - {{ filters.maxScore }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-input-number
|
||||
v-model="filters.minScore"
|
||||
:min="0"
|
||||
:max="10000"
|
||||
size="small"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
<span>-</span>
|
||||
<el-input-number
|
||||
v-model="filters.maxScore"
|
||||
:min="0"
|
||||
:max="10000"
|
||||
size="small"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发布年份筛选 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">发布年份</label>
|
||||
<el-select
|
||||
v-model="filters.releaseYear"
|
||||
placeholder="选择年份"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="year in releaseYears"
|
||||
:key="year"
|
||||
:label="year"
|
||||
:value="year"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 重置按钮 -->
|
||||
<div class="flex items-end">
|
||||
<el-button @click="resetFilters">重置筛选</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useProductStore } from '../stores/productStore'
|
||||
|
||||
export default {
|
||||
name: 'ProductFilter',
|
||||
props: {
|
||||
categoryId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['filter-change'],
|
||||
setup(props, { emit }) {
|
||||
const productStore = useProductStore()
|
||||
|
||||
// 筛选条件
|
||||
const filters = ref({
|
||||
manufacturer: '',
|
||||
minScore: 0,
|
||||
maxScore: 10000,
|
||||
releaseYear: ''
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const manufacturers = computed(() => {
|
||||
return productStore.manufacturers
|
||||
})
|
||||
|
||||
const releaseYears = computed(() => {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = []
|
||||
for (let year = currentYear; year >= 2010; year--) {
|
||||
years.push(year)
|
||||
}
|
||||
return years
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleFilterChange = () => {
|
||||
emit('filter-change', { ...filters.value })
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
manufacturer: '',
|
||||
minScore: 0,
|
||||
maxScore: 10000,
|
||||
releaseYear: ''
|
||||
}
|
||||
emit('filter-change', { ...filters.value })
|
||||
}
|
||||
|
||||
// 监听类别变化,重置筛选条件
|
||||
watch(() => props.categoryId, () => {
|
||||
resetFilters()
|
||||
})
|
||||
|
||||
return {
|
||||
filters,
|
||||
manufacturers,
|
||||
releaseYears,
|
||||
handleFilterChange,
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-filter {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
160
frontend/src/components/ProductSearch.vue
Normal file
160
frontend/src/components/ProductSearch.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="product-search mb-6">
|
||||
<div class="relative">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索产品名称或型号..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
@input="handleSearchInput"
|
||||
@keyup.enter="performSearch"
|
||||
/>
|
||||
|
||||
<!-- 搜索建议下拉框 -->
|
||||
<div
|
||||
v-if="showSuggestions && suggestions.length > 0"
|
||||
class="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-md shadow-lg z-10 mt-1"
|
||||
>
|
||||
<div
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion.id"
|
||||
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
>
|
||||
<div class="font-medium">{{ suggestion.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ suggestion.model }} - {{ suggestion.manufacturer }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ searchResults.length > 0 ? `找到 ${searchResults.length} 个结果` : '' }}
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
v-if="searchQuery"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="performSearch"
|
||||
>
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useProductStore } from '../stores/productStore'
|
||||
|
||||
export default {
|
||||
name: 'ProductSearch',
|
||||
props: {
|
||||
categoryId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['search-results'],
|
||||
setup(props, { emit }) {
|
||||
const productStore = useProductStore()
|
||||
|
||||
// 状态
|
||||
const searchQuery = ref('')
|
||||
const suggestions = ref([])
|
||||
const searchResults = ref([])
|
||||
const showSuggestions = ref(false)
|
||||
let debounceTimer = null
|
||||
|
||||
// 自定义防抖函数
|
||||
const debounce = (func, delay) => {
|
||||
return function(...args) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => func.apply(this, args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖处理搜索建议
|
||||
const debouncedFetchSuggestions = debounce(async (query) => {
|
||||
if (!query.trim()) {
|
||||
suggestions.value = []
|
||||
showSuggestions.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await productStore.searchProducts(query, props.categoryId)
|
||||
suggestions.value = results.slice(0, 5) // 只显示前5个建议
|
||||
showSuggestions.value = true
|
||||
} catch (error) {
|
||||
console.error('获取搜索建议失败:', error)
|
||||
suggestions.value = []
|
||||
showSuggestions.value = false
|
||||
}
|
||||
}, 300)
|
||||
|
||||
// 方法
|
||||
const handleSearchInput = () => {
|
||||
debouncedFetchSuggestions(searchQuery.value)
|
||||
}
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
searchResults.value = []
|
||||
emit('search-results', [])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await productStore.searchProducts(searchQuery.value, props.categoryId)
|
||||
searchResults.value = results
|
||||
emit('search-results', results)
|
||||
showSuggestions.value = false
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
searchResults.value = []
|
||||
emit('search-results', [])
|
||||
}
|
||||
}
|
||||
|
||||
const selectSuggestion = (suggestion) => {
|
||||
searchQuery.value = `${suggestion.name} ${suggestion.model}`
|
||||
showSuggestions.value = false
|
||||
performSearch()
|
||||
}
|
||||
|
||||
// 监听类别变化,清空搜索
|
||||
watch(() => props.categoryId, () => {
|
||||
searchQuery.value = ''
|
||||
suggestions.value = []
|
||||
searchResults.value = []
|
||||
showSuggestions.value = false
|
||||
})
|
||||
|
||||
// 点击外部关闭建议框
|
||||
document.addEventListener('click', (e) => {
|
||||
const searchElement = document.querySelector('.product-search')
|
||||
if (searchElement && !searchElement.contains(e.target)) {
|
||||
showSuggestions.value = false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
suggestions,
|
||||
searchResults,
|
||||
showSuggestions,
|
||||
handleSearchInput,
|
||||
performSearch,
|
||||
selectSuggestion
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-search {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
128
frontend/src/components/SkeletonScreen.vue
Normal file
128
frontend/src/components/SkeletonScreen.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="skeleton-container">
|
||||
<!-- 产品列表骨架屏 -->
|
||||
<div v-if="type === 'product-list'" class="product-list-skeleton">
|
||||
<div v-for="i in count" :key="i" class="product-skeleton-item">
|
||||
<el-skeleton :rows="3" animated />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品详情骨架屏 -->
|
||||
<div v-else-if="type === 'product-detail'" class="product-detail-skeleton">
|
||||
<div class="product-detail-header">
|
||||
<el-skeleton-item variant="image" style="width: 100px; height: 100px;" />
|
||||
<div class="product-detail-info">
|
||||
<el-skeleton-item variant="h1" style="width: 50%; margin-bottom: 10px;" />
|
||||
<el-skeleton-item variant="text" style="width: 30%; margin-bottom: 10px;" />
|
||||
<el-skeleton-item variant="text" style="width: 40%;" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-detail-content">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格骨架屏 -->
|
||||
<div v-else-if="type === 'table'" class="table-skeleton">
|
||||
<el-table :data="Array(count).fill({})" style="width: 100%">
|
||||
<el-table-column v-for="column in columns" :key="column.prop" :prop="column.prop" :label="column.label">
|
||||
<template #default>
|
||||
<el-skeleton-item variant="text" style="width: 80%;" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 默认骨架屏 -->
|
||||
<div v-else class="default-skeleton">
|
||||
<el-skeleton :rows="rows" animated />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'product-list', 'product-detail', 'table'].includes(value)
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ prop: 'name', label: '名称' },
|
||||
{ prop: 'value', label: '值' }
|
||||
]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-list-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.product-skeleton-item {
|
||||
padding: 20px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.product-detail-skeleton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-detail-header {
|
||||
display: flex;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.product-detail-info {
|
||||
margin-left: 20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-detail-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-skeleton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.default-skeleton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.product-list-skeleton {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-detail-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-detail-info {
|
||||
margin-left: 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user