测试
This commit is contained in:
79
frontend/src/App.vue
Normal file
79
frontend/src/App.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<Layout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</Layout>
|
||||
<GlobalLoading :show="loadingStore.isLoading" :text="loadingStore.loadingText" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Layout from './components/Layout.vue'
|
||||
import GlobalLoading from './components/GlobalLoading.vue'
|
||||
import { useLoadingStore } from './stores/loadingStore'
|
||||
|
||||
const loadingStore = useLoadingStore()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f7fa;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
.el-menu--horizontal .el-menu-item {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.el-menu--horizontal .el-menu-item.is-active {
|
||||
border-bottom: 2px solid #409eff;
|
||||
}
|
||||
|
||||
/* 路由过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 滑动过渡动画 */
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
3
frontend/src/assets/images/404.svg
Normal file
3
frontend/src/assets/images/404.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#409EFF" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 216 B |
19
frontend/src/assets/style.css
Normal file
19
frontend/src/assets/style.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 6px;
|
||||
}
|
||||
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>
|
||||
52
frontend/src/main.js
Normal file
52
frontend/src/main.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus, { ElNotification } from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './assets/style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
// 全局错误处理
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
console.error('全局错误:', err)
|
||||
console.error('错误组件实例:', instance)
|
||||
console.error('错误信息:', info)
|
||||
|
||||
// 使用Element Plus显示错误通知
|
||||
ElNotification({
|
||||
title: '应用错误',
|
||||
message: `应用发生错误: ${err.message || '未知错误'}`,
|
||||
type: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
|
||||
// 捕获未处理的Promise错误
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
console.error('未处理的Promise错误:', event.reason)
|
||||
|
||||
ElNotification({
|
||||
title: '未处理的错误',
|
||||
message: `应用发生未处理的错误: ${event.reason?.message || '未知错误'}`,
|
||||
type: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
|
||||
// 阻止默认控制台错误输出
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
70
frontend/src/router/index.js
Normal file
70
frontend/src/router/index.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../views/Home.vue'),
|
||||
meta: {
|
||||
title: '硬件性能排行榜 - 首页'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/category/:id',
|
||||
name: 'CategoryRanking',
|
||||
component: () => import('../views/CategoryRanking.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
title: '硬件性能排行榜 - 类别排名'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/product/:id',
|
||||
name: 'ProductDetail',
|
||||
component: () => import('../views/ProductDetail.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
title: '硬件性能排行榜 - 产品详情'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/compare',
|
||||
name: 'ProductComparison',
|
||||
component: () => import('../views/ProductComparison.vue'),
|
||||
meta: {
|
||||
title: '硬件性能排行榜 - 产品对比'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('../views/NotFound.vue'),
|
||||
meta: {
|
||||
title: '硬件性能排行榜 - 页面未找到'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
// 平滑滚动
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { top: 0, behavior: 'smooth' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 路由前置守卫,用于设置页面标题
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
162
frontend/src/services/api.js
Normal file
162
frontend/src/services/api.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
import { useLoadingStore } from '../stores/loadingStore'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5172/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 重试配置
|
||||
const maxRetry = 2
|
||||
const retryDelay = 1000
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
// 添加请求时间戳,防止缓存
|
||||
if (config.method === 'get') {
|
||||
config.params = {
|
||||
...config.params,
|
||||
_t: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// 添加请求ID用于跟踪
|
||||
config.metadata = { startTime: new Date() }
|
||||
|
||||
// 显示全局加载状态(仅对非静默请求)
|
||||
if (!config.silent) {
|
||||
const loadingStore = useLoadingStore()
|
||||
loadingStore.showLoading(config.loadingText || '加载中...')
|
||||
}
|
||||
|
||||
// 在发送请求之前做些什么
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
// 对请求错误做些什么
|
||||
ElMessage.error('请求配置错误,请稍后重试')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 隐藏全局加载状态(仅对非静默请求)
|
||||
if (!response.config.silent) {
|
||||
const loadingStore = useLoadingStore()
|
||||
loadingStore.hideLoading()
|
||||
}
|
||||
|
||||
// 对响应数据做点什么
|
||||
return response.data
|
||||
},
|
||||
async error => {
|
||||
// 隐藏全局加载状态(仅对非静默请求)
|
||||
if (!error.config?.silent) {
|
||||
const loadingStore = useLoadingStore()
|
||||
loadingStore.hideLoading()
|
||||
}
|
||||
|
||||
const originalRequest = error.config
|
||||
|
||||
// 如果配置了不重试或者已经重试过,直接处理错误
|
||||
if (!originalRequest._retry || originalRequest._retry >= maxRetry) {
|
||||
handleError(error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 对于网络错误和5xx错误进行重试
|
||||
if (!error.response || error.response.status >= 500) {
|
||||
originalRequest._retry = (originalRequest._retry || 0) + 1
|
||||
|
||||
// 显示重试提示
|
||||
if (originalRequest._retry === 1) {
|
||||
ElMessage.info('网络不稳定,正在重试...')
|
||||
}
|
||||
|
||||
// 延迟后重试
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay))
|
||||
|
||||
return api(originalRequest)
|
||||
}
|
||||
|
||||
// 其他错误直接处理
|
||||
handleError(error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 错误处理函数
|
||||
function handleError(error) {
|
||||
if (error.response) {
|
||||
// 服务器返回了错误状态码
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
ElMessage.error(`请求参数错误: ${data.message || '请检查输入参数'}`)
|
||||
break
|
||||
case 401:
|
||||
ElMessage.error(`未授权访问: ${data.message || '请先登录'}`)
|
||||
break
|
||||
case 403:
|
||||
ElMessage.error(`禁止访问: ${data.message || '权限不足'}`)
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error(`资源未找到: ${data.message || '请求的资源不存在'}`)
|
||||
break
|
||||
case 422:
|
||||
// 处理验证错误
|
||||
if (data.errors && typeof data.errors === 'object') {
|
||||
const errorMessages = Object.values(data.errors).flat()
|
||||
ElMessage.error(`验证失败: ${errorMessages.join(', ')}`)
|
||||
} else {
|
||||
ElMessage.error(`验证失败: ${data.message || '输入数据不符合要求'}`)
|
||||
}
|
||||
break
|
||||
case 429:
|
||||
ElMessage.error(`请求过于频繁: ${data.message || '请稍后再试'}`)
|
||||
break
|
||||
case 500:
|
||||
ElNotification({
|
||||
title: '服务器错误',
|
||||
message: data.message || '服务器出现问题,请稍后再试',
|
||||
type: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
break
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
ElNotification({
|
||||
title: '服务不可用',
|
||||
message: '服务器暂时无法响应,请稍后再试',
|
||||
type: 'warning',
|
||||
duration: 5000
|
||||
})
|
||||
break
|
||||
default:
|
||||
ElMessage.error(`未知错误: ${data.message || '发生未知错误,请联系管理员'}`)
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 请求已发出,但没有收到响应
|
||||
ElNotification({
|
||||
title: '网络错误',
|
||||
message: '无法连接到服务器,请检查网络连接',
|
||||
type: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
} else {
|
||||
// 在设置请求时发生了错误
|
||||
ElMessage.error(`请求配置错误: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
13
frontend/src/services/categoryService.js
Normal file
13
frontend/src/services/categoryService.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import api from './api'
|
||||
|
||||
export const categoryService = {
|
||||
// 获取所有类别
|
||||
getAll() {
|
||||
return api.get('/categories')
|
||||
},
|
||||
|
||||
// 根据ID获取类别详情
|
||||
getById(id) {
|
||||
return api.get(`/categories/${id}`)
|
||||
}
|
||||
}
|
||||
8
frontend/src/services/comparisonService.js
Normal file
8
frontend/src/services/comparisonService.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import api from './api'
|
||||
|
||||
export const comparisonService = {
|
||||
// 对比产品
|
||||
compare(productIds) {
|
||||
return api.post('/comparison', { productIds })
|
||||
}
|
||||
}
|
||||
18
frontend/src/services/productService.js
Normal file
18
frontend/src/services/productService.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import api from './api'
|
||||
|
||||
export const productService = {
|
||||
// 获取产品列表(支持分页和筛选)
|
||||
getAll(params = {}) {
|
||||
return api.get('/products', { params })
|
||||
},
|
||||
|
||||
// 根据ID获取产品详情
|
||||
getById(id) {
|
||||
return api.get(`/products/${id}`)
|
||||
},
|
||||
|
||||
// 搜索产品
|
||||
search(params = {}) {
|
||||
return api.get('/products/search', { params })
|
||||
}
|
||||
}
|
||||
38
frontend/src/stores/categoryStore.js
Normal file
38
frontend/src/stores/categoryStore.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { categoryService } from '../services/categoryService'
|
||||
|
||||
export const useCategoryStore = defineStore('category', {
|
||||
state: () => ({
|
||||
categories: [],
|
||||
loading: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchCategories() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const response = await categoryService.getAll()
|
||||
this.categories = response
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
console.error('获取类别列表失败:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async getCategoryById(id) {
|
||||
try {
|
||||
const response = await categoryService.getById(id)
|
||||
return response
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
console.error('获取类别详情失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
68
frontend/src/stores/comparisonStore.js
Normal file
68
frontend/src/stores/comparisonStore.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { comparisonService } from '../services/comparisonService'
|
||||
|
||||
export const useComparisonStore = defineStore('comparison', {
|
||||
state: () => ({
|
||||
selectedProducts: [],
|
||||
comparisonResult: null,
|
||||
loading: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
canCompare: (state) => state.selectedProducts.length >= 2 && state.selectedProducts.length <= 4,
|
||||
maxProductsReached: (state) => state.selectedProducts.length >= 4
|
||||
},
|
||||
|
||||
actions: {
|
||||
addProduct(product) {
|
||||
if (this.selectedProducts.length >= 4) {
|
||||
this.error = '最多只能选择4个产品进行对比'
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查产品是否已经在对比列表中
|
||||
const exists = this.selectedProducts.some(p => p.id === product.id)
|
||||
if (!exists) {
|
||||
this.selectedProducts.push(product)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
removeProduct(productId) {
|
||||
const index = this.selectedProducts.findIndex(p => p.id === productId)
|
||||
if (index !== -1) {
|
||||
this.selectedProducts.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
clearSelection() {
|
||||
this.selectedProducts = []
|
||||
this.comparisonResult = null
|
||||
},
|
||||
|
||||
async compareProducts() {
|
||||
if (!this.canCompare) {
|
||||
this.error = '请选择2-4个产品进行对比'
|
||||
return null
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const productIds = this.selectedProducts.map(p => p.id)
|
||||
const response = await comparisonService.compare(productIds)
|
||||
this.comparisonResult = response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
console.error('产品对比失败:', error)
|
||||
return null
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
39
frontend/src/stores/loadingStore.js
Normal file
39
frontend/src/stores/loadingStore.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useLoadingStore = defineStore('loading', () => {
|
||||
// 全局加载状态
|
||||
const isLoading = ref(false)
|
||||
const loadingText = ref('加载中...')
|
||||
const loadingCounter = ref(0) // 用于处理多个并发请求
|
||||
|
||||
// 显示加载状态
|
||||
const showLoading = (text = '加载中...') => {
|
||||
loadingCounter.value++
|
||||
loadingText.value = text
|
||||
isLoading.value = true
|
||||
}
|
||||
|
||||
// 隐藏加载状态
|
||||
const hideLoading = () => {
|
||||
loadingCounter.value--
|
||||
if (loadingCounter.value <= 0) {
|
||||
loadingCounter.value = 0
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置加载状态
|
||||
const resetLoading = () => {
|
||||
loadingCounter.value = 0
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
loadingText,
|
||||
showLoading,
|
||||
hideLoading,
|
||||
resetLoading
|
||||
}
|
||||
})
|
||||
136
frontend/src/stores/productStore.js
Normal file
136
frontend/src/stores/productStore.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { productService } from '../services/productService'
|
||||
|
||||
export const useProductStore = defineStore('product', {
|
||||
state: () => ({
|
||||
products: [],
|
||||
productDetail: null,
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
},
|
||||
filters: {
|
||||
categoryId: null,
|
||||
manufacturer: '',
|
||||
minScore: null,
|
||||
maxScore: null,
|
||||
year: null
|
||||
},
|
||||
sortBy: 'performanceScore',
|
||||
sortOrder: 'desc',
|
||||
searchQuery: '',
|
||||
loading: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchProducts() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: this.pagination.currentPage,
|
||||
pageSize: this.pagination.pageSize,
|
||||
sortBy: this.sortBy,
|
||||
order: this.sortOrder
|
||||
}
|
||||
|
||||
if (this.filters.categoryId) params.categoryId = this.filters.categoryId
|
||||
if (this.filters.manufacturer) params.manufacturer = this.filters.manufacturer
|
||||
if (this.filters.minScore !== null) params.minScore = this.filters.minScore
|
||||
if (this.filters.maxScore !== null) params.maxScore = this.filters.maxScore
|
||||
if (this.filters.year) params.year = this.filters.year
|
||||
|
||||
const response = await productService.getAll(params)
|
||||
this.products = response.data.items
|
||||
this.pagination.total = response.data.total
|
||||
this.pagination.currentPage = response.data.currentPage
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
console.error('获取产品列表失败:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchProductById(id) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const response = await productService.getById(id)
|
||||
this.productDetail = response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
console.error('获取产品详情失败:', error)
|
||||
return null
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async getProductById(id) {
|
||||
// 直接调用API获取产品信息,不更新store状态
|
||||
try {
|
||||
const response = await productService.getById(id)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('获取产品详情失败:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async searchProducts(query) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const params = { q: query }
|
||||
if (this.filters.categoryId) params.categoryId = this.filters.categoryId
|
||||
|
||||
const response = await productService.search(params)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
console.error('搜索产品失败:', error)
|
||||
return []
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
setFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters }
|
||||
this.pagination.currentPage = 1 // 重置到第一页
|
||||
},
|
||||
|
||||
setSorting(sortBy, sortOrder) {
|
||||
this.sortBy = sortBy
|
||||
this.sortOrder = sortOrder
|
||||
this.pagination.currentPage = 1 // 重置到第一页
|
||||
},
|
||||
|
||||
setPage(page) {
|
||||
this.pagination.currentPage = page
|
||||
},
|
||||
|
||||
setPageSize(pageSize) {
|
||||
this.pagination.pageSize = pageSize
|
||||
this.pagination.currentPage = 1 // 重置到第一页
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.filters = {
|
||||
categoryId: null,
|
||||
manufacturer: '',
|
||||
minScore: null,
|
||||
maxScore: null,
|
||||
year: null
|
||||
}
|
||||
this.pagination.currentPage = 1
|
||||
}
|
||||
}
|
||||
})
|
||||
457
frontend/src/views/CategoryRanking.vue
Normal file
457
frontend/src/views/CategoryRanking.vue
Normal file
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<ErrorBoundary @retry="fetchProducts">
|
||||
<div class="category-ranking">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ category?.name || '产品排名' }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
|
||||
<h1 class="text-2xl font-bold">{{ category?.name }} 排名</h1>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="selectedProducts.length < 2"
|
||||
@click="navigateToComparison"
|
||||
>
|
||||
对比选中产品 ({{ selectedProducts.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选区域 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-4 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- 搜索框 -->
|
||||
<div class="lg:col-span-2">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索产品名称或型号"
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 品牌筛选 -->
|
||||
<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>
|
||||
|
||||
<!-- 排序 -->
|
||||
<el-select
|
||||
v-model="sorting"
|
||||
placeholder="排序方式"
|
||||
@change="handleSortingChange"
|
||||
>
|
||||
<el-option label="性能分数 (高到低)" value="performanceScore_desc" />
|
||||
<el-option label="性能分数 (低到高)" value="performanceScore_asc" />
|
||||
<el-option label="发布日期 (新到旧)" value="releaseDate_desc" />
|
||||
<el-option label="发布日期 (旧到新)" value="releaseDate_asc" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 高级筛选 -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- 性能分数范围 -->
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 mb-1">性能分数范围</div>
|
||||
<el-slider
|
||||
v-model="scoreRange"
|
||||
range
|
||||
:min="0"
|
||||
:max="10000"
|
||||
:step="100"
|
||||
:format-tooltip="formatScore"
|
||||
@change="handleScoreRangeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 发布年份 -->
|
||||
<el-select
|
||||
v-model="filters.year"
|
||||
placeholder="发布年份"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="year in years"
|
||||
:key="year"
|
||||
:label="year"
|
||||
:value="year"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 重置按钮 -->
|
||||
<el-button @click="resetFilters">重置筛选</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品列表 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div v-for="i in 8" :key="i" class="skeleton-wrapper">
|
||||
<LoadingSkeleton type="product-card" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="error"
|
||||
:title="'加载失败'"
|
||||
:description="error"
|
||||
@retry="fetchProducts"
|
||||
/>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="products.length === 0"
|
||||
:title="'没有找到产品'"
|
||||
:description="'没有符合筛选条件的产品,请尝试调整筛选条件'"
|
||||
/>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="(product, index) in products"
|
||||
:key="product.id"
|
||||
class="product-card bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-300"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<!-- 选择框 -->
|
||||
<div class="mr-3 pt-1">
|
||||
<el-checkbox
|
||||
:model-value="isProductSelected(product.id)"
|
||||
@change="toggleProductSelection(product)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 排名 -->
|
||||
<div class="mr-4 text-center min-w-[60px]">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ index + 1 + (pagination.currentPage - 1) * pagination.pageSize }}</div>
|
||||
<div class="text-xs text-gray-500">排名</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品信息 -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start">
|
||||
<div class="w-16 h-16 bg-gray-200 rounded mr-4 flex items-center justify-center">
|
||||
<el-icon size="24"><Picture /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold mb-1">{{ product.name }}</h3>
|
||||
<p class="text-sm text-gray-600 mb-2">{{ product.model }}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<el-tag size="small">{{ product.manufacturer }}</el-tag>
|
||||
<el-tag size="small" type="info">{{ product.releaseYear }}年</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right min-w-[120px]">
|
||||
<div class="text-2xl font-bold text-green-600">{{ product.performanceScore }}</div>
|
||||
<div class="text-xs text-gray-500">性能分数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-3 flex justify-end">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="navigateToProductDetail(product.id)"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="products.length > 0" class="mt-8 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handlePageSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useCategoryStore } from '../stores/categoryStore'
|
||||
import { useProductStore } from '../stores/productStore'
|
||||
import { useComparisonStore } from '../stores/comparisonStore'
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary.vue'
|
||||
import LoadingSkeleton from '@/components/LoadingSkeleton.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
export default {
|
||||
name: 'CategoryRanking',
|
||||
components: {
|
||||
Picture
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const categoryStore = useCategoryStore()
|
||||
const productStore = useProductStore()
|
||||
const comparisonStore = useComparisonStore()
|
||||
|
||||
// 状态
|
||||
const category = ref(null)
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const scoreRange = ref([0, 10000])
|
||||
const sorting = ref('performanceScore_desc')
|
||||
const filters = ref({
|
||||
manufacturer: '',
|
||||
year: null
|
||||
})
|
||||
const pagination = ref({
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const selectedProducts = computed(() => comparisonStore.selectedProducts)
|
||||
|
||||
const manufacturers = computed(() => {
|
||||
// 从产品列表中提取所有不重复的制造商
|
||||
const allManufacturers = products.value.map(p => p.manufacturer).filter(Boolean)
|
||||
return [...new Set(allManufacturers)].sort()
|
||||
})
|
||||
|
||||
const years = computed(() => {
|
||||
// 从产品列表中提取所有不重复的发布年份
|
||||
const allYears = products.value.map(p => p.releaseYear).filter(Boolean)
|
||||
return [...new Set(allYears)].sort((a, b) => b - a) // 降序排列
|
||||
})
|
||||
|
||||
// 方法
|
||||
const fetchCategory = async () => {
|
||||
try {
|
||||
category.value = await categoryStore.getCategoryById(props.id)
|
||||
} catch (err) {
|
||||
error.value = '获取类别信息失败'
|
||||
console.error('获取类别信息失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProducts = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// 设置筛选条件
|
||||
productStore.setFilters({
|
||||
categoryId: props.id,
|
||||
manufacturer: filters.value.manufacturer,
|
||||
minScore: scoreRange.value[0],
|
||||
maxScore: scoreRange.value[1],
|
||||
year: filters.value.year
|
||||
})
|
||||
|
||||
// 设置排序
|
||||
const [sortBy, sortOrder] = sorting.value.split('_')
|
||||
productStore.setSorting(sortBy, sortOrder)
|
||||
|
||||
// 设置分页
|
||||
productStore.setPage(pagination.value.currentPage)
|
||||
productStore.setPageSize(pagination.value.pageSize)
|
||||
|
||||
// 获取产品列表
|
||||
await productStore.fetchProducts()
|
||||
|
||||
products.value = productStore.products
|
||||
pagination.value.total = productStore.pagination.total
|
||||
pagination.value.currentPage = productStore.pagination.currentPage
|
||||
} catch (err) {
|
||||
error.value = '获取产品列表失败'
|
||||
console.error('获取产品列表失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (query) => {
|
||||
// 防抖处理
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
|
||||
searchTimer = setTimeout(() => {
|
||||
if (query.trim()) {
|
||||
performSearch(query)
|
||||
} else {
|
||||
fetchProducts()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const performSearch = async (query) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const searchResults = await productStore.searchProducts(query)
|
||||
products.value = searchResults
|
||||
pagination.value.total = searchResults.length
|
||||
} catch (err) {
|
||||
error.value = '搜索失败'
|
||||
console.error('搜索失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
pagination.value.currentPage = 1
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
const handleSortingChange = () => {
|
||||
pagination.value.currentPage = 1
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
const handleScoreRangeChange = () => {
|
||||
pagination.value.currentPage = 1
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
pagination.value.currentPage = page
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.currentPage = 1
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
searchQuery.value = ''
|
||||
scoreRange.value = [0, 10000]
|
||||
sorting.value = 'performanceScore_desc'
|
||||
filters.value = {
|
||||
manufacturer: '',
|
||||
year: null
|
||||
}
|
||||
pagination.value.currentPage = 1
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
const isProductSelected = (productId) => {
|
||||
return selectedProducts.value.some(p => p.id === productId)
|
||||
}
|
||||
|
||||
const toggleProductSelection = (product) => {
|
||||
if (isProductSelected(product.id)) {
|
||||
comparisonStore.removeProduct(product.id)
|
||||
} else {
|
||||
comparisonStore.addProduct(product)
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToProductDetail = (productId) => {
|
||||
router.push(`/product/${productId}`)
|
||||
}
|
||||
|
||||
const navigateToComparison = () => {
|
||||
router.push('/compare')
|
||||
}
|
||||
|
||||
const formatScore = (value) => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
let searchTimer = null
|
||||
|
||||
// 监听路由参数变化
|
||||
watch(() => props.id, () => {
|
||||
if (props.id) {
|
||||
fetchCategory()
|
||||
fetchProducts()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
category,
|
||||
products,
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
scoreRange,
|
||||
sorting,
|
||||
filters,
|
||||
pagination,
|
||||
selectedProducts,
|
||||
manufacturers,
|
||||
years,
|
||||
fetchProducts,
|
||||
handleSearch,
|
||||
handleFilterChange,
|
||||
handleSortingChange,
|
||||
handleScoreRangeChange,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
resetFilters,
|
||||
isProductSelected,
|
||||
toggleProductSelection,
|
||||
navigateToProductDetail,
|
||||
navigateToComparison,
|
||||
formatScore
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.skeleton-wrapper {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
354
frontend/src/views/Home.vue
Normal file
354
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">硬件性能排行榜</h1>
|
||||
<p class="hero-subtitle">专业硬件性能对比平台,助您选择最佳硬件配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="categories-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">选择硬件类别</h2>
|
||||
<p class="section-description">点击下方卡片查看各类硬件性能排名</p>
|
||||
</div>
|
||||
|
||||
<div v-if="categoryStore.loading" class="loading-container">
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="categoryStore.error" class="error-container">
|
||||
<el-result
|
||||
icon="warning"
|
||||
title="加载失败"
|
||||
:sub-title="categoryStore.error"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="categoryStore.fetchCategories">重新加载</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
|
||||
<div v-else-if="categoryStore.categories.length === 0" class="empty-container">
|
||||
<el-empty description="暂无硬件类别数据" />
|
||||
</div>
|
||||
|
||||
<div v-else class="categories-grid">
|
||||
<div
|
||||
v-for="category in categoryStore.categories"
|
||||
:key="category.id"
|
||||
class="category-card"
|
||||
@click="navigateToCategory(category.id)"
|
||||
>
|
||||
<div class="category-icon">
|
||||
<el-icon :size="40">
|
||||
<component :is="getCategoryIcon(category.name)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h3 class="category-name">{{ category.name }}</h3>
|
||||
<p class="category-description">{{ category.description }}</p>
|
||||
<div class="category-footer">
|
||||
<span class="product-count">{{ category.productCount || 0 }} 个产品</span>
|
||||
<el-icon class="arrow-icon">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="features-section">
|
||||
<div class="features-container">
|
||||
<h2 class="features-title">平台特色</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<el-icon :size="36" class="feature-icon">
|
||||
<TrendCharts />
|
||||
</el-icon>
|
||||
<h3 class="feature-title">性能对比</h3>
|
||||
<p class="feature-description">全面对比各类硬件性能指标,助您做出明智选择</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<el-icon :size="36" class="feature-icon">
|
||||
<DataAnalysis />
|
||||
</el-icon>
|
||||
<h3 class="feature-title">专业数据</h3>
|
||||
<p class="feature-description">基于权威测试数据,提供准确可靠的性能评估</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<el-icon :size="36" class="feature-icon">
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
<h3 class="feature-title">实时更新</h3>
|
||||
<p class="feature-description">定期更新硬件性能数据,保持信息时效性</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCategoryStore } from '../stores/categoryStore'
|
||||
import {
|
||||
ArrowRight,
|
||||
Monitor,
|
||||
Cpu,
|
||||
VideoCamera,
|
||||
TrendCharts,
|
||||
DataAnalysis,
|
||||
Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const categoryStore = useCategoryStore()
|
||||
|
||||
// 根据类别名称获取对应图标
|
||||
const getCategoryIcon = (categoryName) => {
|
||||
if (categoryName && categoryName.toLowerCase().includes('cpu')) {
|
||||
return Cpu
|
||||
} else if (categoryName && categoryName.toLowerCase().includes('gpu')) {
|
||||
return VideoCamera
|
||||
} else {
|
||||
return Monitor
|
||||
}
|
||||
}
|
||||
|
||||
// 导航到类别页面
|
||||
const navigateToCategory = (categoryId) => {
|
||||
router.push(`/category/${categoryId}`)
|
||||
}
|
||||
|
||||
// 页面加载时获取类别数据
|
||||
onMounted(() => {
|
||||
categoryStore.fetchCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 英雄区域样式 */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #409eff 0%, #36cfc9 100%);
|
||||
color: white;
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 20px;
|
||||
opacity: 0.9;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 类别选择区域样式 */
|
||||
.categories-section {
|
||||
padding: 60px 20px;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-description {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-count {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: #409eff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 特色功能区域样式 */
|
||||
.features-section {
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.features-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
color: #409eff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 加载和错误状态样式 */
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.hero-title {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.features-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.categories-grid,
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero-section {
|
||||
padding: 60px 15px;
|
||||
}
|
||||
|
||||
.categories-section,
|
||||
.features-section {
|
||||
padding: 40px 15px;
|
||||
}
|
||||
|
||||
.category-card,
|
||||
.feature-card {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
125
frontend/src/views/NotFound.vue
Normal file
125
frontend/src/views/NotFound.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<h1 class="error-code">404</h1>
|
||||
<h2 class="error-title">页面未找到</h2>
|
||||
<p class="error-description">
|
||||
抱歉,您访问的页面不存在或已被移除。
|
||||
</p>
|
||||
<div class="error-actions">
|
||||
<el-button type="primary" @click="goHome">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回上一页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-image">
|
||||
<img src="@/assets/images/404.svg" alt="404错误" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { HomeFilled, ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 70vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.not-found-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: 700;
|
||||
color: #409eff;
|
||||
line-height: 1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.error-image {
|
||||
max-width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.error-image img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-code {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-actions .el-button {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
478
frontend/src/views/ProductComparison.vue
Normal file
478
frontend/src/views/ProductComparison.vue
Normal file
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<ErrorBoundary @retry="compareProducts">
|
||||
<div class="product-comparison container mx-auto px-4 py-8">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="breadcrumb mb-6">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>产品对比</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-800">产品对比</h1>
|
||||
<p class="text-gray-600 mt-2">对比不同硬件产品的性能和规格参数</p>
|
||||
</div>
|
||||
|
||||
<!-- 产品选择区域 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">选择要对比的产品 (最多4个)</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div
|
||||
v-for="(product, index) in selectedProducts"
|
||||
:key="product.id"
|
||||
class="border rounded-lg p-4 relative hover:shadow-md transition-shadow"
|
||||
>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
circle
|
||||
class="absolute top-2 right-2"
|
||||
@click="removeProduct(product.id)"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<div class="pr-8">
|
||||
<div class="font-medium mb-1 truncate">{{ product.name }}</div>
|
||||
<div class="text-sm text-gray-600 mb-2 truncate">{{ product.model }}</div>
|
||||
<div class="text-sm">{{ product.manufacturer }}</div>
|
||||
<div v-if="product.performanceScore" class="text-sm font-medium text-blue-600 mt-1">
|
||||
性能分数: {{ product.performanceScore }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加产品按钮 -->
|
||||
<div
|
||||
v-for="index in (4 - selectedProducts.length)"
|
||||
:key="`empty-${index}`"
|
||||
class="border border-dashed rounded-lg p-4 flex items-center justify-center hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="showProductSelector"
|
||||
>
|
||||
<el-icon class="mr-1"><Plus /></el-icon>
|
||||
添加产品
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!canCompare"
|
||||
:loading="loading"
|
||||
@click="compareProducts"
|
||||
>
|
||||
开始对比
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
@click="clearSelection"
|
||||
:disabled="selectedProducts.length === 0"
|
||||
>
|
||||
清空选择
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<el-loading-directive></el-loading-directive>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-state text-center py-12">
|
||||
<el-result
|
||||
icon="warning"
|
||||
title="对比失败"
|
||||
:sub-title="error"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="compareProducts">重新加载</el-button>
|
||||
<el-button @click="$router.push('/')">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
|
||||
<!-- 对比结果 -->
|
||||
<div v-else-if="comparisonResult" class="space-y-6">
|
||||
<!-- 对比选项卡 -->
|
||||
<el-tabs v-model="activeTab" class="comparison-tabs">
|
||||
<!-- 性能对比 -->
|
||||
<el-tab-pane label="性能对比" name="performance">
|
||||
<ComparisonChart
|
||||
:products="selectedProducts"
|
||||
:comparison-data="comparisonResult"
|
||||
type="performance"
|
||||
/>
|
||||
<ComparisonTable
|
||||
:products="selectedProducts"
|
||||
:comparison-data="comparisonResult"
|
||||
type="performance"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 规格对比 -->
|
||||
<el-tab-pane label="规格对比" name="specifications">
|
||||
<ComparisonChart
|
||||
:products="selectedProducts"
|
||||
:comparison-data="comparisonResult"
|
||||
type="specifications"
|
||||
/>
|
||||
<ComparisonTable
|
||||
:products="selectedProducts"
|
||||
:comparison-data="comparisonResult"
|
||||
type="specifications"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 基本信息对比 -->
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<ComparisonTable
|
||||
:products="selectedProducts"
|
||||
:comparison-data="comparisonResult"
|
||||
type="basic"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 综合对比 -->
|
||||
<el-tab-pane label="综合对比" name="comprehensive">
|
||||
<div class="comprehensive-comparison">
|
||||
<ComparisonChart
|
||||
:products="selectedProducts"
|
||||
:comparison-data="comparisonResult"
|
||||
type="all"
|
||||
/>
|
||||
<div class="mt-8">
|
||||
<ComparisonTable
|
||||
:products="selectedProducts"
|
||||
:comparison-data="comparisonResult"
|
||||
type="all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!loading && selectedProducts.length === 0" class="empty-state text-center py-12">
|
||||
<el-empty description="请选择要对比的产品">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="$router.push('/')">浏览产品</el-button>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 产品选择对话框 -->
|
||||
<el-dialog
|
||||
v-model="productSelectorVisible"
|
||||
title="选择产品"
|
||||
width="70%"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<el-input
|
||||
v-model="productSearchQuery"
|
||||
placeholder="搜索产品名称或型号"
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
@input="handleProductSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="searchLoading" class="search-loading mt-4 text-center">
|
||||
<el-icon class="is-loading">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<span class="ml-2">搜索中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-96 overflow-y-auto">
|
||||
<el-table
|
||||
:data="searchResults"
|
||||
@selection-change="handleProductSelection"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="name" label="产品名称" />
|
||||
<el-table-column prop="model" label="型号" />
|
||||
<el-table-column prop="manufacturer" label="制造商" />
|
||||
<el-table-column prop="performanceScore" label="性能分数" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="productSelectorVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="addSelectedProducts"
|
||||
:disabled="tempSelectedProducts.length === 0"
|
||||
>
|
||||
添加选中产品
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useComparisonStore } from '../stores/comparisonStore'
|
||||
import { useProductStore } from '../stores/productStore'
|
||||
import { Close, Plus, Loading } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary.vue'
|
||||
import LoadingSkeleton from '@/components/LoadingSkeleton.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ComparisonTable from '@/components/ComparisonTable.vue'
|
||||
import ComparisonChart from '@/components/ComparisonChart.vue'
|
||||
|
||||
// 状态管理
|
||||
const comparisonStore = useComparisonStore()
|
||||
const productStore = useProductStore()
|
||||
|
||||
// 响应式状态
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const productSelectorVisible = ref(false)
|
||||
const productSearchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const searchLoading = ref(false)
|
||||
const tempSelectedProducts = ref([])
|
||||
const barChart = ref(null)
|
||||
const radarChart = ref(null)
|
||||
const activeTab = ref('performance')
|
||||
|
||||
// 计算属性
|
||||
const selectedProducts = computed(() => comparisonStore.selectedProducts)
|
||||
const comparisonResult = computed(() => comparisonStore.comparisonResult)
|
||||
const canCompare = computed(() => comparisonStore.canCompare)
|
||||
|
||||
// 方法
|
||||
const compareProducts = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await comparisonStore.compareProducts()
|
||||
|
||||
// 渲染图表
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (err) {
|
||||
error.value = '产品对比失败'
|
||||
console.error('产品对比失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
if (!comparisonResult.value) return
|
||||
|
||||
// 暂时禁用图表渲染,避免echarts依赖问题
|
||||
// 后续可以添加其他图表库或使用简单的CSS图表
|
||||
console.log('图表渲染功能暂时禁用')
|
||||
}
|
||||
|
||||
const removeProduct = (productId) => {
|
||||
comparisonStore.removeProduct(productId)
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
comparisonStore.clearSelection()
|
||||
}
|
||||
|
||||
const showProductSelector = () => {
|
||||
productSelectorVisible.value = true
|
||||
productSearchQuery.value = ''
|
||||
searchResults.value = []
|
||||
tempSelectedProducts.value = []
|
||||
}
|
||||
|
||||
const handleProductSearch = async () => {
|
||||
if (!productSearchQuery.value.trim()) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchLoading.value = true
|
||||
try {
|
||||
const results = await productStore.searchProducts(productSearchQuery.value)
|
||||
searchResults.value = results.filter(product =>
|
||||
!selectedProducts.value.some(p => p.id === product.id)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('搜索产品失败:', err)
|
||||
ElMessage.error('搜索产品失败')
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleProductSelection = (selection) => {
|
||||
tempSelectedProducts.value = selection
|
||||
}
|
||||
|
||||
const addSelectedProducts = () => {
|
||||
const availableSlots = 4 - selectedProducts.value.length
|
||||
const productsToAdd = tempSelectedProducts.value.slice(0, availableSlots)
|
||||
|
||||
let addedCount = 0
|
||||
productsToAdd.forEach(product => {
|
||||
if (comparisonStore.addProduct(product)) {
|
||||
addedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if (addedCount > 0) {
|
||||
ElMessage.success(`已添加 ${addedCount} 个产品到对比列表`)
|
||||
}
|
||||
|
||||
productSelectorVisible.value = false
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(async () => {
|
||||
// 检查是否有从URL参数传入的产品ID
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const productIds = urlParams.get('productIds')
|
||||
|
||||
if (productIds) {
|
||||
// 从URL参数加载产品
|
||||
const ids = productIds.split(',').filter(id => id.trim())
|
||||
|
||||
if (ids.length > 0) {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 获取每个产品的详细信息
|
||||
for (const id of ids) {
|
||||
const product = await productStore.getProductById(parseInt(id))
|
||||
if (product) {
|
||||
comparisonStore.addProduct(product)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有产品被添加,自动执行对比
|
||||
if (comparisonStore.canCompare) {
|
||||
await compareProducts()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('从URL加载产品失败:', err)
|
||||
error.value = '从URL加载产品失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-comparison {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 产品卡片样式 */
|
||||
.border {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.border:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 对比选项卡样式 */
|
||||
.comparison-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.comparison-tabs :deep(.el-tabs__nav-wrap) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.comparison-tabs :deep(.el-tabs__item) {
|
||||
font-weight: 500;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.comparison-tabs :deep(.el-tabs__item.is-active) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.comparison-tabs :deep(.el-tabs__active-bar) {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 综合对比区域 */
|
||||
.comprehensive-comparison {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* 搜索加载状态 */
|
||||
.search-loading {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.error-state {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.product-comparison {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.comparison-tabs :deep(.el-tabs__item) {
|
||||
padding: 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid-cols-1.md\:grid-cols-2.lg\:grid-cols-4 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.comparison-tabs :deep(.el-tabs__nav) {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
500
frontend/src/views/ProductDetail.vue
Normal file
500
frontend/src/views/ProductDetail.vue
Normal file
@@ -0,0 +1,500 @@
|
||||
<template>
|
||||
<div class="product-detail">
|
||||
<LoadingSkeleton v-if="loading" />
|
||||
<EmptyState v-else-if="error" :message="error" @retry="fetchProductDetail" />
|
||||
<div v-else-if="product" class="container mx-auto px-4 py-6">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="flex items-center mb-6 text-sm">
|
||||
<button @click="goBack" class="text-blue-600 hover:text-blue-800 flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
返回
|
||||
</button>
|
||||
<span class="mx-2">/</span>
|
||||
<span v-if="category" class="text-gray-600">{{ category.name }}</span>
|
||||
<span class="mx-2">/</span>
|
||||
<span class="text-gray-900 font-medium">{{ product.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 产品基本信息 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<!-- 产品图片 -->
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="aspect-square bg-gray-100 rounded-lg overflow-hidden">
|
||||
<LazyImage
|
||||
:src="product.imageUrl || '/placeholder-product.png'"
|
||||
:alt="product.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品信息 -->
|
||||
<div class="w-full md:w-2/3">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">{{ product.name }}</h1>
|
||||
<p class="text-lg text-gray-600 mb-4">{{ product.model }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<span class="text-gray-500">制造商:</span>
|
||||
<span class="font-medium">{{ product.manufacturer }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">发布日期:</span>
|
||||
<span class="font-medium">{{ formatDate(product.releaseDate) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">当前排名:</span>
|
||||
<span class="font-medium">#{{ product.currentRank || 'N/A' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">性能分数:</span>
|
||||
<span class="font-medium text-lg" :class="getScoreColorClass(product.performanceScore)">
|
||||
{{ product.performanceScore }}
|
||||
</span>
|
||||
<el-tag
|
||||
:type="getScoreTagType(product.performanceScore)"
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ getScoreLevel(product.performanceScore) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="addToComparison"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
添加到对比
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 性能测试分数 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">性能测试分数</h2>
|
||||
|
||||
<div v-if="!product.performanceScores || product.performanceScores.length === 0" class="text-center py-8 text-gray-500">
|
||||
暂无性能测试数据
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="score in product.performanceScores"
|
||||
:key="score.id"
|
||||
class="border border-gray-200 rounded-lg p-5 hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<h3 class="font-semibold text-lg text-gray-800">{{ score.benchmarkName }}</h3>
|
||||
<el-tag
|
||||
:type="getScoreTagType(score.score)"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ getScoreLevel(score.score) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex items-baseline mb-2">
|
||||
<span class="text-3xl font-bold mr-2" :class="getScoreColorClass(score.score)">{{ score.score }}</span>
|
||||
<span class="text-sm text-gray-500">分</span>
|
||||
</div>
|
||||
|
||||
<!-- 进度条可视化 -->
|
||||
<div class="relative">
|
||||
<el-progress
|
||||
:percentage="getScorePercentage(score.score, score.benchmarkName)"
|
||||
:color="getProgressColor(score.score)"
|
||||
:show-text="false"
|
||||
:stroke-width="10"
|
||||
:define-back-color="'#f3f4f6'"
|
||||
/>
|
||||
<div class="flex justify-between mt-1">
|
||||
<span class="text-xs text-gray-400">0</span>
|
||||
<span class="text-xs text-gray-400">{{ getBenchmarkMax(score.benchmarkName) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 space-y-1 border-t pt-3">
|
||||
<div class="flex justify-between">
|
||||
<span>测试日期:</span>
|
||||
<span>{{ formatDate(score.testDate) }}</span>
|
||||
</div>
|
||||
<div v-if="score.dataSource" class="flex justify-between">
|
||||
<span>数据来源:</span>
|
||||
<span class="text-blue-600 cursor-pointer hover:underline" @click="openDataSource(score.dataSourceUrl)">
|
||||
{{ score.dataSource }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 性能分数说明 -->
|
||||
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 mb-2">性能等级说明</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2 text-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500 mr-2"></div>
|
||||
<span>顶级 (90+)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-orange-500 mr-2"></div>
|
||||
<span>高端 (80-89)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-blue-500 mr-2"></div>
|
||||
<span>中高端 (70-79)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-green-500 mr-2"></div>
|
||||
<span>中端 (60-69)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-teal-500 mr-2"></div>
|
||||
<span>入门级 (50-59)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-gray-500 mr-2"></div>
|
||||
<span>低端 (<50)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规格参数 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">规格参数</h2>
|
||||
|
||||
<div v-if="!product.specifications || product.specifications.length === 0" class="text-center py-8 text-gray-500">
|
||||
暂无规格参数数据
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<el-collapse v-model="activeSpecGroups">
|
||||
<el-collapse-item
|
||||
v-for="(specs, groupName) in groupedSpecifications"
|
||||
:key="groupName"
|
||||
:title="groupName"
|
||||
:name="groupName"
|
||||
>
|
||||
<el-table :data="specs" stripe>
|
||||
<el-table-column prop="name" label="参数名称" width="200" />
|
||||
<el-table-column prop="value" label="参数值">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.value" class="font-medium">{{ scope.row.value }}</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="100" />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 性能图表 -->
|
||||
<div class="performance-charts">
|
||||
<PerformanceCharts
|
||||
:product="product"
|
||||
:performance-history="performanceHistory"
|
||||
:loading="chartsLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 数据来源信息 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mt-6">
|
||||
<h2 class="text-xl font-semibold mb-4">数据来源</h2>
|
||||
|
||||
<div v-if="!product.dataSources || product.dataSources.length === 0" class="text-center py-8 text-gray-500">
|
||||
暂无数据来源信息
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="source in product.dataSources"
|
||||
:key="source.id"
|
||||
class="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<h3 class="font-medium">{{ source.name }}</h3>
|
||||
<p class="text-sm text-gray-500">最后更新: {{ formatDate(source.lastUpdated) }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="openDataSource(source.url)"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
查看来源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useProductStore } from '../stores/productStore'
|
||||
import { useCategoryStore } from '../stores/categoryStore'
|
||||
import { useComparisonStore } from '../stores/comparisonStore'
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import LoadingSkeleton from '@/components/LoadingSkeleton.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import PerformanceCharts from '@/components/PerformanceCharts.vue'
|
||||
import LazyImage from '@/components/LazyImage.vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const productStore = useProductStore()
|
||||
const categoryStore = useCategoryStore()
|
||||
const comparisonStore = useComparisonStore()
|
||||
|
||||
// 状态
|
||||
const product = ref(null)
|
||||
const category = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const activeSpecGroups = ref([])
|
||||
const performanceHistory = ref([])
|
||||
const chartsLoading = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const groupedSpecifications = computed(() => {
|
||||
if (!product.value || !product.value.specifications || product.value.specifications.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const groups = {}
|
||||
|
||||
product.value.specifications.forEach(spec => {
|
||||
// 根据规格参数名称判断分组
|
||||
let groupName = '其他规格'
|
||||
|
||||
if (spec.name.includes('核心') || spec.name.includes('线程') || spec.name.includes('频率') ||
|
||||
spec.name.includes('缓存') || spec.name.includes('架构') || spec.name.includes('制程')) {
|
||||
groupName = '处理器规格'
|
||||
} else if (spec.name.includes('显存') || spec.name.includes('核心频率') ||
|
||||
spec.name.includes('显存频率') || spec.name.includes('流处理器') ||
|
||||
spec.name.includes('CUDA') || spec.name.includes('ROP')) {
|
||||
groupName = '图形规格'
|
||||
} else if (spec.name.includes('功耗') || spec.name.includes('TDP') ||
|
||||
spec.name.includes('电压') || spec.name.includes('温度')) {
|
||||
groupName = '功耗与散热'
|
||||
} else if (spec.name.includes('接口') || spec.name.includes('插槽') ||
|
||||
spec.name.includes('连接') || spec.name.includes('总线')) {
|
||||
groupName = '接口规格'
|
||||
} else if (spec.name.includes('尺寸') || spec.name.includes('重量') ||
|
||||
spec.name.includes('材质') || spec.name.includes('颜色')) {
|
||||
groupName = '物理规格'
|
||||
} else if (spec.name.includes('内存') || spec.name.includes('存储') ||
|
||||
spec.name.includes('硬盘') || spec.name.includes('SSD')) {
|
||||
groupName = '存储规格'
|
||||
}
|
||||
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = []
|
||||
}
|
||||
|
||||
groups[groupName].push(spec)
|
||||
})
|
||||
|
||||
// 默认展开第一个分组
|
||||
const groupNames = Object.keys(groups)
|
||||
if (groupNames.length > 0 && activeSpecGroups.value.length === 0) {
|
||||
activeSpecGroups.value = [groupNames[0]]
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 方法
|
||||
// 获取分数等级
|
||||
const getScoreLevel = (score) => {
|
||||
if (score >= 90) return '顶级'
|
||||
if (score >= 80) return '高端'
|
||||
if (score >= 70) return '中高端'
|
||||
if (score >= 60) return '中端'
|
||||
if (score >= 50) return '入门级'
|
||||
return '低端'
|
||||
}
|
||||
|
||||
// 获取分数标签类型
|
||||
const getScoreTagType = (score) => {
|
||||
if (score >= 90) return 'danger'
|
||||
if (score >= 80) return 'warning'
|
||||
if (score >= 70) return 'primary'
|
||||
if (score >= 60) return 'success'
|
||||
if (score >= 50) return 'info'
|
||||
return ''
|
||||
}
|
||||
|
||||
// 获取分数颜色类
|
||||
const getScoreColorClass = (score) => {
|
||||
if (score >= 90) return 'text-red-600'
|
||||
if (score >= 80) return 'text-orange-600'
|
||||
if (score >= 70) return 'text-blue-600'
|
||||
if (score >= 60) return 'text-green-600'
|
||||
if (score >= 50) return 'text-teal-600'
|
||||
return 'text-gray-600'
|
||||
}
|
||||
|
||||
// 获取基准测试最大值
|
||||
const getBenchmarkMax = (benchmarkName) => {
|
||||
const benchmarks = {
|
||||
'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
|
||||
}
|
||||
|
||||
return benchmarks[benchmarkName] || 100
|
||||
}
|
||||
|
||||
// 获取分数百分比
|
||||
const getScorePercentage = (score, benchmarkName) => {
|
||||
const maxScore = getBenchmarkMax(benchmarkName)
|
||||
return Math.min(100, Math.round((score / maxScore) * 100))
|
||||
}
|
||||
|
||||
// 获取进度条颜色
|
||||
const getProgressColor = (score) => {
|
||||
if (score >= 90) return '#f56565' // red-500
|
||||
if (score >= 80) return '#ed8936' // orange-500
|
||||
if (score >= 70) return '#4299e1' // blue-500
|
||||
if (score >= 60) return '#48bb78' // green-500
|
||||
if (score >= 50) return '#38b2ac' // teal-500
|
||||
return '#718096' // gray-500
|
||||
}
|
||||
|
||||
const fetchProductDetail = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
product.value = await productStore.fetchProductById(props.id)
|
||||
|
||||
// 获取产品类别信息
|
||||
if (product.value && product.value.categoryId) {
|
||||
category.value = await categoryStore.getCategoryById(product.value.categoryId)
|
||||
}
|
||||
|
||||
// 获取性能历史数据
|
||||
await fetchPerformanceHistory()
|
||||
} catch (err) {
|
||||
error.value = '获取产品详情失败'
|
||||
console.error('获取产品详情失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取性能历史数据
|
||||
const fetchPerformanceHistory = async () => {
|
||||
chartsLoading.value = true
|
||||
|
||||
try {
|
||||
// 这里应该调用API获取性能历史数据
|
||||
// 暂时使用模拟数据
|
||||
const mockHistory = []
|
||||
const currentDate = new Date()
|
||||
|
||||
// 生成过去12个月的模拟数据
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const date = new Date(currentDate)
|
||||
date.setMonth(date.getMonth() - i)
|
||||
|
||||
// 模拟性能分数变化(随机波动)
|
||||
const baseScore = product.value.performanceScore || 1000
|
||||
const variation = Math.random() * 200 - 100 // -100 到 +100 的随机变化
|
||||
const score = Math.round(baseScore + variation)
|
||||
|
||||
mockHistory.push({
|
||||
recordDate: date.toISOString(),
|
||||
score: score
|
||||
})
|
||||
}
|
||||
|
||||
performanceHistory.value = mockHistory
|
||||
} catch (err) {
|
||||
console.error('获取性能历史数据失败:', err)
|
||||
} finally {
|
||||
chartsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '未知'
|
||||
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const openDataSource = (url) => {
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const addToComparison = () => {
|
||||
if (product.value) {
|
||||
const success = comparisonStore.addProduct(product.value)
|
||||
|
||||
if (success) {
|
||||
ElMessage.success('已添加到对比列表')
|
||||
} else {
|
||||
ElMessage.warning(comparisonStore.error || '添加失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
if (category.value) {
|
||||
router.push(`/category/${category.value.id}`)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由参数变化
|
||||
watch(() => props.id, () => {
|
||||
if (props.id) {
|
||||
fetchProductDetail()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.performance-charts {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user