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

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>