This commit is contained in:
2025-11-03 17:03:57 +08:00
commit 7a04b85667
16804 changed files with 2492292 additions and 0 deletions

79
frontend/src/App.vue Normal file
View 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>

View 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

View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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')

View 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

View 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

View 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}`)
}
}

View File

@@ -0,0 +1,8 @@
import api from './api'
export const comparisonService = {
// 对比产品
compare(productIds) {
return api.post('/comparison', { productIds })
}
}

View 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 })
}
}

View 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
}
}
}
})

View 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
}
}
}
})

View 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
}
})

View 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
}
}
})

View 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
View 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>

View 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>

View 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>

View 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>