初始化
Some checks failed
CI/CD Pipeline / 测试 (18.x) (push) Has been cancelled
CI/CD Pipeline / 测试 (20.x) (push) Has been cancelled
CI/CD Pipeline / 安全检查 (push) Has been cancelled
CI/CD Pipeline / 部署 (push) Has been cancelled
CI/CD Pipeline / 通知 (push) Has been cancelled

This commit is contained in:
2025-11-03 19:47:36 +08:00
parent 7a04b85667
commit f25b0307db
454 changed files with 37064 additions and 4544 deletions

146
frontend/.eslintrc.js Normal file
View File

@@ -0,0 +1,146 @@
module.exports = {
root: true,
env: {
node: true,
browser: true,
es2021: true
},
extends: [
'plugin:vue/vue3-essential',
'plugin:vue/vue3-strongly-recommended',
'plugin:vue/vue3-recommended',
'eslint:recommended',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
rules: {
// Vue规则
'vue/multi-word-component-names': 'off',
'vue/no-unused-vars': 'error',
'vue/no-unused-components': 'warn',
'vue/require-default-prop': 'error',
'vue/require-explicit-emits': 'error',
'vue/require-prop-types': 'error',
'vue/component-definition-name-casing': ['error', 'PascalCase'],
'vue/component-name-in-template-casing': ['error', 'kebab-case'],
'vue/custom-event-name-casing': ['error', 'camelCase'],
'vue/define-macros-order': ['error', {
order: ['defineProps', 'defineEmits']
}],
'vue/html-self-closing': ['error', {
html: {
void: 'never',
normal: 'always',
component: 'always'
},
svg: 'always',
math: 'always'
}],
'vue/max-attributes-per-line': ['error', {
singleline: 3,
multiline: 1
}],
'vue/no-v-html': 'warn',
'vue/padding-line-between-blocks': ['error', 'always'],
'vue/prefer-import-from-vue': 'error',
'vue/prefer-separate-static-class': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/v-for-delimiter-style': ['error', 'in'],
'vue/v-on-event-hyphenation': ['error', 'always'],
'vue/v-on-function-call': 'error',
'vue/v-slot-style': ['error', 'shorthand'],
// JavaScript规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': 'error',
'no-undef': 'error',
'no-var': 'error',
'prefer-const': 'error',
'prefer-arrow-callback': 'error',
'arrow-spacing': 'error',
'no-duplicate-imports': 'error',
'no-useless-constructor': 'error',
'no-useless-rename': 'error',
'object-shorthand': 'error',
'prefer-template': 'error',
'template-curly-spacing': 'error',
'yield-star-spacing': 'error',
'yoda': 'error',
'no-nested-ternary': 'error',
'no-unneeded-ternary': 'error',
'spaced-comment': 'error',
'eqeqeq': ['error', 'always'],
'curly': 'error',
'brace-style': ['error', '1tbs', { allowSingleLine: true }],
'comma-dangle': ['error', 'never'],
'comma-spacing': 'error',
'comma-style': 'error',
'computed-property-spacing': 'error',
'func-call-spacing': 'error',
'indent': ['error', 2, { SwitchCase: 1 }],
'key-spacing': 'error',
'keyword-spacing': 'error',
'linebreak-style': ['error', 'unix'],
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'no-trailing-spaces': 'error',
'object-curly-spacing': ['error', 'always'],
'quotes': ['error', 'single', { avoidEscape: true }],
'semi': ['error', 'never'],
'space-before-blocks': 'error',
'space-before-function-paren': ['error', {
anonymous: 'always',
named: 'never',
asyncArrow: 'always'
}],
'space-in-parens': 'error',
'space-infix-ops': 'error',
'space-unary-ops': 'error',
'unicode-bom': ['error', 'never'],
// ES6+规则
'arrow-body-style': ['error', 'as-needed'],
'arrow-parens': ['error', 'as-needed'],
'no-confusing-arrow': 'error',
'no-duplicate-imports': 'error',
'no-useless-computed-key': 'error',
'no-useless-constructor': 'error',
'no-useless-rename': 'error',
'object-shorthand': 'error',
'prefer-arrow-callback': 'error',
'prefer-const': 'error',
'prefer-destructuring': ['error', {
array: false,
object: true
}],
'prefer-rest-params': 'error',
'prefer-spread': 'error',
'prefer-template': 'error',
'rest-spread-spacing': 'error',
'template-curly-spacing': 'error',
'yield-star-spacing': 'error'
},
overrides: [
{
files: ['*.vue'],
rules: {
'indent': 'off',
'vue/html-indent': ['error', 2],
'vue/script-indent': ['error', 2, { baseIndent: 0 }]
}
},
{
files: ['**/__tests__/**/*', '**/*.{test,spec}.*'],
env: {
jest: true,
vitest: true
},
rules: {
'no-unused-expressions': 'off'
}
}
]
}

80
frontend/.prettierrc.js Normal file
View File

@@ -0,0 +1,80 @@
module.exports = {
// 一行最多字符数
printWidth: 100,
// 使用制表符而不是空格缩进
useTabs: false,
// 缩进
tabWidth: 2,
// 结尾不用分号
semi: false,
// 使用单引号
singleQuote: true,
// 对象的 key 仅在必要时用引号
quoteProps: 'as-needed',
// jsx 不使用单引号,而使用双引号
jsxSingleQuote: false,
// 末尾不需要逗号
trailingComma: 'none',
// 大括号内的首尾需要空格
bracketSpacing: true,
// jsx 标签的反尖括号需要换行
jsxBracketSameLine: false,
// 箭头函数,只有一个参数的时候,也需要括号
arrowParens: 'avoid',
// 每个文件格式化的范围是文件的全部内容
rangeStart: 0,
rangeEnd: Infinity,
// 不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准
proseWrap: 'preserve',
// 根据显示样式决定 html 要不要折行
htmlWhitespaceSensitivity: 'css',
// vue 文件中的 script 和 style 内不用缩进
vueIndentScriptAndStyle: false,
// 换行符使用 lf
endOfLine: 'lf',
// 格式化嵌入的内容
embeddedLanguageFormatting: 'auto',
// html, vue, jsx 中每个属性占一行
singleAttributePerLine: false,
// 特定文件类型的配置
overrides: [
{
files: '*.vue',
options: {
parser: 'vue'
}
},
{
files: '*.json',
options: {
printWidth: 200,
tabWidth: 2
}
},
{
files: '*.md',
options: {
printWidth: 100,
proseWrap: 'always'
}
},
{
files: '*.{css,scss,less}',
options: {
singleQuote: false,
tabWidth: 2
}
},
{
files: '*.{js,ts}',
options: {
printWidth: 100,
tabWidth: 2
}
}
]
}

354
frontend/docs/TESTING.md Normal file
View File

@@ -0,0 +1,354 @@
# 前端自动化测试和代码质量检查
本文档介绍了前端项目的自动化测试和代码质量检查流程、工具和最佳实践。
## 目录
- [测试策略](#测试策略)
- [测试工具](#测试工具)
- [测试环境设置](#测试环境设置)
- [运行测试](#运行测试)
- [代码质量检查](#代码质量检查)
- [持续集成](#持续集成)
- [测试覆盖率](#测试覆盖率)
- [常见问题](#常见问题)
## 测试策略
我们采用多层次测试策略,确保代码质量和应用稳定性:
1. **单元测试**:测试单个函数、组件或模块
2. **集成测试**:测试多个组件或模块之间的交互
3. **端到端测试**:模拟用户操作,测试完整用户流程
### 测试金字塔
```
/\
/ \
/ E2E \ 少量端到端测试
/______\
/ \
/Integration\ 适量集成测试
/__________\
/ \
/ Unit Tests \ 大量单元测试
/______________\
```
## 测试工具
### 单元测试和集成测试
- **Vitest**:基于 Vite 的快速单元测试框架
- **Vue Test Utils**Vue.js 官方测试工具库
- **jsdom**:轻量级 DOM 实现,用于在 Node.js 中模拟浏览器环境
### 端到端测试
- **Playwright**:现代端到端测试框架,支持多浏览器
- **Playwright Test**Playwright 的测试运行器
### 代码质量检查
- **ESLint**JavaScript 代码风格和错误检查工具
- **Prettier**:代码格式化工具
- **TypeScript**:静态类型检查
- **Husky**Git hooks 管理
- **lint-staged**:对暂存文件运行检查
## 测试环境设置
### 安装依赖
```bash
npm install
```
### 环境变量
创建 `.env.test` 文件用于测试环境配置:
```env
VITE_API_BASE_URL=http://localhost:7001/api
VITE_APP_TITLE=硬件性能对比平台 - 测试环境
```
## 运行测试
### 单元测试
运行所有单元测试:
```bash
npm run test:unit
```
监听模式运行单元测试(文件变化时自动重新运行):
```bash
npm run test:unit:watch
```
运行单元测试并生成覆盖率报告:
```bash
npm run test:unit:coverage
```
仅运行组件测试:
```bash
npm run test:component
```
仅运行 API 服务测试:
```bash
npm run test:api
```
仅运行状态管理测试:
```bash
npm run test:store
```
### 端到端测试
运行所有 E2E 测试:
```bash
npm run test:e2e
```
以 UI 模式运行 E2E 测试:
```bash
npm run test:e2e:ui
```
以调试模式运行 E2E 测试:
```bash
npm run test:e2e:debug
```
### 所有测试
运行所有测试(单元测试 + E2E 测试):
```bash
npm test
```
以 CI 模式运行所有测试:
```bash
npm run test:ci
```
## 代码质量检查
### ESLint
运行 ESLint 检查并自动修复问题:
```bash
npm run lint
```
### Prettier
格式化代码:
```bash
npm run format
```
检查代码格式(不修改文件):
```bash
npm run format:check
```
### TypeScript 类型检查
运行 TypeScript 类型检查:
```bash
npm run type-check
```
### 综合质量检查
运行 ESLint 和 Prettier
```bash
npm run quality
```
生成代码质量报告:
```bash
npm run quality-report
```
## 持续集成
项目使用 GitHub Actions 进行持续集成,配置文件位于 `.github/workflows/ci.yml`
CI 流程包括:
1. 代码检出
2. 依赖安装
3. 类型检查
4. 代码风格检查
5. 单元测试和覆盖率报告
6. 应用构建
7. E2E 测试
8. 安全审计
9. 部署(仅 main 分支)
### Git Hooks
项目配置了以下 Git Hooks
- **pre-commit**:对暂存文件运行 ESLint 和 Prettier
- **pre-push**:运行单元测试
这些 hooks 通过 Husky 和 lint-staged 管理。
## 测试覆盖率
### 覆盖率目标
- **行覆盖率**:≥ 80%
- **函数覆盖率**:≥ 80%
- **分支覆盖率**:≥ 75%
- **语句覆盖率**:≥ 80%
### 覆盖率报告
运行 `npm run test:unit:coverage` 后,覆盖率报告将生成在 `coverage/` 目录下。
- `coverage/lcov-report/index.html`HTML 格式的详细报告
- `coverage/coverage-summary.json`JSON 格式的摘要数据
### 覆盖率徽章
可以在 README.md 中添加覆盖率徽章:
```markdown
![Coverage](https://img.shields.io/badge/coverage-80%25-brightgreen)
```
## 测试编写指南
### 单元测试示例
```javascript
// tests/unit/components/MyComponent.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MyComponent from '@/components/MyComponent.vue'
describe('MyComponent', () => {
it('renders correctly', () => {
const wrapper = mount(MyComponent, {
props: {
title: 'Test Title'
}
})
expect(wrapper.find('h1').text()).toBe('Test Title')
})
it('emits event when button is clicked', async () => {
const wrapper = mount(MyComponent)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted()).toHaveProperty('button-clicked')
})
})
```
### E2E 测试示例
```javascript
// tests/e2e/user-journey.spec.js
import { test, expect } from '@playwright/test'
test('user can view product details', async ({ page }) => {
// 导航到首页
await page.goto('/')
// 点击产品类别
await page.click('[data-testid="category-cpu"]')
// 点击第一个产品
await page.click('[data-testid="product-card"]:first-child')
// 验证产品详情页面
await expect(page.locator('h1')).toContainText('产品详情')
await expect(page.locator('[data-testid="product-specs"]')).toBeVisible()
})
```
### 测试最佳实践
1. **保持测试简单**:每个测试只验证一个功能点
2. **使用描述性测试名称**:清楚说明测试的目的
3. **使用数据-testid**:避免依赖 CSS 类名或元素结构
4. **模拟外部依赖**:使用 mock 隔离测试
5. **保持测试独立性**:测试之间不应相互依赖
6. **使用页面对象模式**:对于 E2E 测试,使用页面对象封装页面操作
## 常见问题
### 问题:测试运行缓慢
**解决方案**
1. 使用 `vitest` 的并行测试功能
2. 使用 `vitest --mode=development` 进行开发时测试
3. 使用 `vitest --reporter=verbose` 查看详细输出
4. 考虑使用 `vitest --no-coverage` 跳过覆盖率收集
### 问题E2E 测试不稳定
**解决方案**
1. 使用 `page.waitForSelector()` 等待元素出现
2. 使用 `page.waitForLoadState()` 等待页面加载完成
3. 增加测试超时时间
4. 使用 `test.beforeEach()``test.afterEach()` 进行测试隔离
### 问题:覆盖率报告不准确
**解决方案**
1. 检查 `vitest.config.js` 中的覆盖率配置
2. 确保所有源文件都被包含在覆盖率统计中
3. 使用 `coverage.exclude` 排除不需要测试的文件
### 问题ESLint 与 Prettier 冲突
**解决方案**
1. 使用 `eslint-config-prettier` 禁用与 Prettier 冲突的 ESLint 规则
2.`.eslintrc.js` 中添加 `"extends": ["prettier"]`
3. 确保 `package.json` 中的脚本顺序正确
## 参考资源
- [Vitest 官方文档](https://vitest.dev/)
- [Vue Test Utils 官方文档](https://test-utils.vuejs.org/)
- [Playwright 官方文档](https://playwright.dev/)
- [ESLint 官方文档](https://eslint.org/)
- [Prettier 官方文档](https://prettier.io/)
---
如有其他问题,请查看项目 Wiki 或联系开发团队。

View File

@@ -3,11 +3,206 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />
<meta name="theme-color" content="#409EFF" />
<meta name="description" content="硬件性能对比 - 专业的CPU、GPU性能数据对比平台" />
<meta name="keywords" content="CPU,GPU,性能对比,硬件,排行榜" />
<meta name="author" content="硬件性能对比团队" />
<!-- PWA配置 -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="硬件性能对比" />
<link rel="apple-touch-icon" href="/icons/icon-152x152.png" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- 预加载关键资源 -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href="/placeholder.svg" as="image" />
<title>硬件性能对比</title>
<!-- 内联关键CSS -->
<style>
/* 防止页面闪烁的关键样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f7fa;
color: #303133;
}
#app {
height: 100vh;
display: flex;
flex-direction: column;
}
/* 顶部导航样式 */
.header {
position: sticky;
top: 0;
z-index: 1000;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.nav {
display: flex;
align-items: center;
padding: 0 20px;
height: 60px;
}
.logo {
font-size: 20px;
font-weight: 600;
color: #409EFF;
text-decoration: none;
}
/* 首屏内容样式 */
.main-content {
flex: 1;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.category-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
transition: transform 0.3s, box-shadow 0.3s;
}
.category-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
}
/* 加载状态样式 */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid #f3f3f3;
border-top: 3px solid #409EFF;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* 错误状态样式 */
.error {
text-align: center;
padding: 20px;
color: #F56C6C;
}
.error-message {
margin-bottom: 15px;
}
/* 图片懒加载样式 */
img.lazy-loading {
background-color: #f5f7fa;
opacity: 0;
}
img.lazy-loaded {
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
img.lazy-error {
background-color: #f5f7fa;
opacity: 0.7;
}
/* 初始加载动画 */
.initial-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
flex-direction: column;
}
.initial-loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #409EFF;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式设计 */
@media (max-width: 768px) {
.nav {
padding: 0 15px;
height: 50px;
}
.main-content {
padding: 15px;
}
.category-card {
padding: 15px;
}
}
</style>
</head>
<body>
<div id="app"></div>
<div id="app">
<!-- 初始加载动画 -->
<div class="initial-loading">
<div class="initial-loading-spinner"></div>
<div>正在加载应用...</div>
</div>
</div>
<!-- Service Worker注册 -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('[SW] 注册成功:', registration.scope);
})
.catch(error => {
console.error('[SW] 注册失败:', error);
});
});
}
</script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

137
frontend/node_modules/.vue-global-types/vue_3.5_0.d.ts generated vendored Normal file
View File

@@ -0,0 +1,137 @@
// @ts-nocheck
export {};
; declare global {
const __VLS_directiveBindingRestFields: { instance: null, oldValue: null, modifiers: any, dir: any };
const __VLS_unref: typeof import('vue').unref;
const __VLS_placeholder: any;
type __VLS_NativeElements = __VLS_SpreadMerge<SVGElementTagNameMap, HTMLElementTagNameMap>;
type __VLS_IntrinsicElements = import('vue/jsx-runtime').JSX.IntrinsicElements;
type __VLS_Element = import('vue/jsx-runtime').JSX.Element;
type __VLS_GlobalComponents = import('vue').GlobalComponents;
type __VLS_GlobalDirectives = import('vue').GlobalDirectives;
type __VLS_IsAny<T> = 0 extends 1 & T ? true : false;
type __VLS_PickNotAny<A, B> = __VLS_IsAny<A> extends true ? B : A;
type __VLS_SpreadMerge<A, B> = Omit<A, keyof B> & B;
type __VLS_WithComponent<N0 extends string, LocalComponents, Self, N1 extends string, N2 extends string, N3 extends string> =
N1 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N1] } :
N2 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N2] } :
N3 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N3] } :
Self extends object ? { [K in N0]: Self } :
N1 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N1] } :
N2 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N2] } :
N3 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N3] } :
{};
type __VLS_FunctionalComponentCtx<T, K> = __VLS_PickNotAny<'__ctx' extends keyof __VLS_PickNotAny<K, {}>
? K extends { __ctx?: infer Ctx } ? NonNullable<Ctx> : never : any
, T extends (props: any, ctx: infer Ctx) => any ? Ctx : any
>;
type __VLS_FunctionalComponentProps<T, K> = '__ctx' extends keyof __VLS_PickNotAny<K, {}>
? K extends { __ctx?: { props?: infer P } } ? NonNullable<P> : never
: T extends (props: infer P, ...args: any) => any ? P
: {};
type __VLS_FunctionalComponent<T> = (props: (T extends { $props: infer Props } ? Props : {}) & Record<string, unknown>, ctx?: any) => __VLS_Element & {
__ctx?: {
attrs?: any;
slots?: T extends { $slots: infer Slots } ? Slots : Record<string, any>;
emit?: T extends { $emit: infer Emit } ? Emit : {};
props?: (T extends { $props: infer Props } ? Props : {}) & Record<string, unknown>;
expose?: (exposed: T) => void;
};
};
type __VLS_IsFunction<T, K> = K extends keyof T
? __VLS_IsAny<T[K]> extends false
? unknown extends T[K]
? false
: true
: false
: false;
type __VLS_NormalizeComponentEvent<
Props,
Emits,
onEvent extends keyof Props,
Event extends keyof Emits,
CamelizedEvent extends keyof Emits,
> = __VLS_IsFunction<Props, onEvent> extends true
? Props
: __VLS_IsFunction<Emits, Event> extends true
? { [K in onEvent]?: Emits[Event] }
: __VLS_IsFunction<Emits, CamelizedEvent> extends true
? { [K in onEvent]?: Emits[CamelizedEvent] }
: Props;
// fix https://github.com/vuejs/language-tools/issues/926
type __VLS_UnionToIntersection<U> = (U extends unknown ? (arg: U) => unknown : never) extends ((arg: infer P) => unknown) ? P : never;
type __VLS_OverloadUnionInner<T, U = unknown> = U & T extends (...args: infer A) => infer R
? U extends T
? never
: __VLS_OverloadUnionInner<T, Pick<T, keyof T> & U & ((...args: A) => R)> | ((...args: A) => R)
: never;
type __VLS_OverloadUnion<T> = Exclude<
__VLS_OverloadUnionInner<(() => never) & T>,
T extends () => never ? never : () => never
>;
type __VLS_ConstructorOverloads<T> = __VLS_OverloadUnion<T> extends infer F
? F extends (event: infer E, ...args: infer A) => any
? { [K in E & string]: (...args: A) => void; }
: never
: never;
type __VLS_NormalizeEmits<T> = __VLS_PrettifyGlobal<
__VLS_UnionToIntersection<
__VLS_ConstructorOverloads<T> & {
[K in keyof T]: T[K] extends any[] ? { (...args: T[K]): void } : never
}
>
>;
type __VLS_EmitsToProps<T> = __VLS_PrettifyGlobal<{
[K in string & keyof T as `on${Capitalize<K>}`]?:
(...args: T[K] extends (...args: infer P) => any ? P : T[K] extends null ? any[] : never) => any;
}>;
type __VLS_ResolveEmits<
Comp,
Emits,
TypeEmits = {},
NormalizedEmits = __VLS_NormalizeEmits<Emits> extends infer E ? string extends keyof E ? {} : E : never,
> = __VLS_SpreadMerge<NormalizedEmits, TypeEmits>;
type __VLS_ResolveDirectives<T> = {
[K in keyof T & string as `v${Capitalize<K>}`]: T[K];
};
type __VLS_PrettifyGlobal<T> = { [K in keyof T as K]: T[K]; } & {};
type __VLS_WithDefaultsGlobal<P, D> = {
[K in keyof P as K extends keyof D ? K : never]-?: P[K];
} & {
[K in keyof P as K extends keyof D ? never : K]: P[K];
};
type __VLS_UseTemplateRef<T> = Readonly<import('vue').ShallowRef<T | null>>;
type __VLS_ProxyRefs<T> = import('vue').ShallowUnwrapRef<T>;
function __VLS_getVForSourceType<T extends number | string | any[] | Iterable<any>>(source: T): [
item: T extends number ? number
: T extends string ? string
: T extends any[] ? T[number]
: T extends Iterable<infer T1> ? T1
: any,
index: number,
][];
function __VLS_getVForSourceType<T>(source: T): [
item: T[keyof T],
key: keyof T,
index: number,
][];
function __VLS_getSlotParameters<S, D extends S>(slot: S, decl?: D):
D extends (...args: infer P) => any ? P : any[];
function __VLS_asFunctionalDirective<T>(dir: T): T extends import('vue').ObjectDirective
? NonNullable<T['created' | 'beforeMount' | 'mounted' | 'beforeUpdate' | 'updated' | 'beforeUnmount' | 'unmounted']>
: T extends (...args: any) => any
? T
: (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown) => void;
function __VLS_asFunctionalComponent<T, K = T extends new (...args: any) => any ? InstanceType<T> : unknown>(t: T, instance?: K):
T extends new (...args: any) => any ? __VLS_FunctionalComponent<K>
: T extends () => any ? (props: {}, ctx?: any) => ReturnType<T>
: T extends (...args: any) => any ? T
: __VLS_FunctionalComponent<{}>;
function __VLS_functionalComponentArgsRest<T extends (...args: any) => any>(t: T): 2 extends Parameters<T>['length'] ? [any] : [];
function __VLS_asFunctionalElement<T>(tag: T, endTag?: T): (attrs: T & Record<string, unknown>) => void;
function __VLS_asFunctionalSlot<S>(slot: S): S extends () => infer R ? (props: {}) => R : NonNullable<S>;
function __VLS_tryAsConstant<const T>(t: T): T;
}

View File

@@ -1,12 +1,31 @@
{
"name": "hardware-performance-frontend",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"description": "硬件性能对比平台前端应用",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"build:pwa": "vite build --mode production",
"serve:pwa": "vite preview --port 4173 --host",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"test": "node scripts/test.js",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:coverage": "vitest run --coverage",
"test:component": "vitest run --dir tests/unit/components",
"test:api": "vitest run --dir tests/unit/services",
"test:store": "vitest run --dir tests/unit/stores",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:ci": "node scripts/test.js --ci",
"quality": "npm run lint && npm run format",
"quality-report": "node scripts/quality-report.js",
"prepare": "husky install"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
@@ -14,16 +33,102 @@
"echarts": "^5.6.0",
"element-plus": "^2.4.0",
"pinia": "^2.1.7",
"vant": "^4.8.0",
"vue": "^3.4.0",
"vue-echarts": "^6.7.3",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"@vue/test-utils": "^2.4.1",
"@vue/tsconfig": "^0.4.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.48.0",
"eslint-plugin-vue": "^9.17.0",
"husky": "^8.0.3",
"jsdom": "^22.1.0",
"lint-staged": "^14.0.1",
"playwright": "^1.37.1",
"postcss": "^8.4.0",
"prettier": "^3.0.3",
"tailwindcss": "^3.3.0",
"terser": "^5.44.0",
"vite": "^5.0.0"
"typescript": "~5.2.2",
"vite": "^5.0.0",
"vitest": "^0.34.4",
"vue-tsc": "^1.8.8"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
],
"pwa": {
"name": "硬件性能对比",
"short_name": "硬件性能",
"description": "专业的CPU、GPU性能数据对比平台",
"theme_color": "#409EFF",
"background_color": "#f5f7fa",
"display": "standalone",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
},
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss,json,md}": [
"prettier --write"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "npm run test:unit"
}
}
}

View File

@@ -0,0 +1,104 @@
import { defineConfig, devices } from '@playwright/test'
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './tests/e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results.json' }],
['junit', { outputFile: 'test-results.xml' }],
process.env.CI ? 'line' : 'list'
],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.BASE_URL || 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Take screenshot on failure */
screenshot: 'only-on-failure',
/* Record video on failure */
video: 'retain-on-failure',
/* Global timeout for each action */
actionTimeout: 10000,
/* Global timeout for navigation */
navigationTimeout: 30000
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
/* Test against branded browsers. */
{
name: 'Microsoft Edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
/* Global setup and teardown */
globalSetup: './tests/e2e/global-setup.js',
globalTeardown: './tests/e2e/global-teardown.js',
/* Test timeout */
timeout: 30000,
/* Expect timeout */
expect: {
timeout: 5000
},
/* Output directory for test artifacts */
outputDir: 'test-results/',
})

View File

@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PWA图标生成器</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.icon-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 20px;
}
.icon-item {
text-align: center;
}
.icon-item canvas {
border: 1px solid #ddd;
}
button {
background-color: #409EFF;
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
margin-top: 20px;
}
button:hover {
background-color: #3a8ee6;
}
</style>
</head>
<body>
<h1>PWA图标生成器</h1>
<p>此页面用于生成PWA所需的不同尺寸图标。右键点击图标并选择"另存为"来下载。</p>
<button id="generateIcons">生成图标</button>
<div class="icon-container" id="iconContainer"></div>
<script>
document.getElementById('generateIcons').addEventListener('click', function() {
const container = document.getElementById('iconContainer');
container.innerHTML = '';
// 需要生成的图标尺寸
const sizes = [
{ size: 72, name: 'icon-72x72.png' },
{ size: 96, name: 'icon-96x96.png' },
{ size: 128, name: 'icon-128x128.png' },
{ size: 144, name: 'icon-144x144.png' },
{ size: 152, name: 'icon-152x152.png' },
{ size: 192, name: 'icon-192x192.png' },
{ size: 384, name: 'icon-384x384.png' },
{ size: 512, name: 'icon-512x512.png' }
];
sizes.forEach(item => {
const canvas = document.createElement('canvas');
canvas.width = item.size;
canvas.height = item.size;
const ctx = canvas.getContext('2d');
// 绘制背景
ctx.fillStyle = '#409EFF';
ctx.fillRect(0, 0, item.size, item.size);
// 绘制圆角
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
ctx.roundRect(0, 0, item.size, item.size, item.size * 0.125);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
// 绘制图标
const center = item.size / 2;
const outerRadius = center * 0.75;
const innerRadius = center * 0.5;
const dotRadius = center * 0.125;
// 外圆
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(center, center, outerRadius, 0, 2 * Math.PI);
ctx.fill();
// 内圆
ctx.fillStyle = '#409EFF';
ctx.beginPath();
ctx.arc(center, center, innerRadius, 0, 2 * Math.PI);
ctx.fill();
// 中心点
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(center, center, dotRadius, 0, 2 * Math.PI);
ctx.fill();
// 创建容器
const iconItem = document.createElement('div');
iconItem.className = 'icon-item';
// 添加标题
const title = document.createElement('div');
title.textContent = `${item.name} (${item.size}x${item.size})`;
iconItem.appendChild(title);
// 添加画布
iconItem.appendChild(canvas);
// 添加下载链接
const downloadLink = document.createElement('a');
downloadLink.href = canvas.toDataURL('image/png');
downloadLink.download = item.name;
downloadLink.textContent = '下载';
downloadLink.style.display = 'block';
downloadLink.style.marginTop = '5px';
iconItem.appendChild(downloadLink);
container.appendChild(iconItem);
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
<svg width="152" height="152" viewBox="0 0 152 152" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="152" height="152" rx="19" fill="#409EFF"/>
<path d="M76 38C55 38 38 55 38 76C38 97 55 114 76 114C97 114 114 97 114 76C114 55 97 38 76 38ZM76 104C60.5 104 48 91.5 48 76C48 60.5 60.5 48 76 48C91.5 48 104 60.5 104 76C104 91.5 91.5 104 76 104Z" fill="white"/>
<path d="M76 57C65.5 57 57 65.5 57 76C57 86.5 65.5 95 76 95C86.5 95 95 86.5 95 76C95 65.5 86.5 57 76 57ZM76 85C71.6 85 68 81.4 68 77C68 72.6 71.6 69 76 69C80.4 69 84 72.6 84 77C84 81.4 80.4 85 76 85Z" fill="white"/>
<circle cx="76" cy="76" r="9" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 644 B

View File

@@ -0,0 +1,6 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="192" height="192" rx="24" fill="#409EFF"/>
<path d="M96 48C69.5 48 48 69.5 48 96C48 122.5 69.5 144 96 144C122.5 144 144 122.5 144 96C144 69.5 122.5 48 96 48ZM96 132C75.6 132 60 116.4 60 96C60 75.6 75.6 60 96 60C116.4 60 132 75.6 132 96C132 116.4 116.4 132 96 132Z" fill="white"/>
<path d="M96 72C82.7 72 72 82.7 72 96C72 109.3 82.7 120 96 120C109.3 120 120 109.3 120 96C120 82.7 109.3 72 96 72ZM96 108C89.4 108 84 102.6 84 96C84 89.4 89.4 84 96 84C102.6 84 108 89.4 108 96C108 102.6 102.6 108 96 108Z" fill="white"/>
<circle cx="96" cy="96" r="12" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 690 B

View File

@@ -0,0 +1,142 @@
{
"name": "硬件性能排行榜",
"short_name": "硬件排行",
"description": "专业的硬件性能排行榜应用提供CPU、GPU等硬件性能数据和对比功能",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#409EFF",
"orientation": "portrait-primary",
"scope": "/",
"lang": "zh-CN",
"categories": ["utilities", "productivity", "reference"],
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"screenshots": [
{
"src": "/screenshots/home-desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "首页 - 桌面版"
},
{
"src": "/screenshots/home-mobile.png",
"sizes": "375x667",
"type": "image/png",
"form_factor": "narrow",
"label": "首页 - 移动版"
},
{
"src": "/screenshots/ranking-desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "排行榜 - 桌面版"
},
{
"src": "/screenshots/ranking-mobile.png",
"sizes": "375x667",
"type": "image/png",
"form_factor": "narrow",
"label": "排行榜 - 移动版"
}
],
"shortcuts": [
{
"name": "CPU排行榜",
"short_name": "CPU排行",
"description": "查看CPU性能排行榜",
"url": "/category/1",
"icons": [
{
"src": "/icons/shortcut-cpu.png",
"sizes": "96x96",
"type": "image/png"
}
]
},
{
"name": "GPU排行榜",
"short_name": "GPU排行",
"description": "查看GPU性能排行榜",
"url": "/category/2",
"icons": [
{
"src": "/icons/shortcut-gpu.png",
"sizes": "96x96",
"type": "image/png"
}
]
},
{
"name": "产品对比",
"short_name": "对比",
"description": "对比硬件产品性能",
"url": "/comparison",
"icons": [
{
"src": "/icons/shortcut-compare.png",
"sizes": "96x96",
"type": "image/png"
}
]
}
],
"related_applications": [],
"prefer_related_applications": false,
"edge_side_panel": {
"preferred_width": 400
},
"launch_handler": {
"client_mode": "focus-existing"
}
}

287
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,287 @@
// Service Worker for PWA functionality
const CACHE_NAME = 'it-hardware-ranking-v1'
const RUNTIME_CACHE = 'it-hardware-ranking-runtime'
// 需要预缓存的资源列表
const STATIC_CACHE_URLS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
// 添加其他需要预缓存的静态资源
]
// 需要网络优先的资源
const NETWORK_FIRST_URLS = [
'/api/',
// 添加其他需要网络优先的API路径
]
// 需要缓存优先的资源
const CACHE_FIRST_URLS = [
'/static/',
'/assets/',
// 添加其他需要缓存优先的静态资源路径
]
// 安装事件 - 预缓存静态资源
self.addEventListener('install', (event) => {
console.log('[SW] Install event triggered')
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] Caching static resources')
return cache.addAll(STATIC_CACHE_URLS)
})
.then(() => {
console.log('[SW] Static resources cached successfully')
// 强制激活新的Service Worker
return self.skipWaiting()
})
.catch((error) => {
console.error('[SW] Failed to cache static resources:', error)
})
)
})
// 激活事件 - 清理旧缓存
self.addEventListener('activate', (event) => {
console.log('[SW] Activate event triggered')
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
// 删除旧版本的缓存
if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
console.log('[SW] Deleting old cache:', cacheName)
return caches.delete(cacheName)
}
})
)
})
.then(() => {
console.log('[SW] Old caches cleaned up')
// 立即控制所有客户端
return self.clients.claim()
})
.catch((error) => {
console.error('[SW] Failed to clean up old caches:', error)
})
)
})
// 网络请求拦截
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
// 跳过非HTTP(S)请求
if (!url.protocol.startsWith('http')) {
return
}
// 跳过Chrome扩展请求
if (url.protocol === 'chrome-extension:') {
return
}
// 根据请求URL选择缓存策略
if (isNetworkFirst(url)) {
// 网络优先策略
event.respondWith(networkFirst(request))
} else if (isCacheFirst(url)) {
// 缓存优先策略
event.respondWith(cacheFirst(request))
} else {
// 缓存优先,网络作为后备策略
event.respondWith(staleWhileRevalidate(request))
}
})
// 判断是否使用网络优先策略
function isNetworkFirst(url) {
return NETWORK_FIRST_URLS.some(path => url.pathname.startsWith(path))
}
// 判断是否使用缓存优先策略
function isCacheFirst(url) {
return CACHE_FIRST_URLS.some(path => url.pathname.startsWith(path))
}
// 网络优先策略
async function networkFirst(request) {
const cache = await caches.open(RUNTIME_CACHE)
try {
// 尝试从网络获取
const response = await fetch(request)
// 如果响应成功,缓存它
if (response.ok) {
cache.put(request, response.clone())
}
return response
} catch (error) {
console.log('[SW] Network request failed, trying cache:', error)
// 网络失败,尝试从缓存获取
const cachedResponse = await cache.match(request)
if (cachedResponse) {
return cachedResponse
}
// 如果缓存也没有,返回离线页面
return new Response('离线状态,请检查网络连接', {
status: 503,
statusText: 'Service Unavailable'
})
}
}
// 缓存优先策略
async function cacheFirst(request) {
const cache = await caches.open(RUNTIME_CACHE)
const cachedResponse = await cache.match(request)
if (cachedResponse) {
return cachedResponse
}
try {
// 缓存中没有,从网络获取
const response = await fetch(request)
// 如果响应成功,缓存它
if (response.ok) {
cache.put(request, response.clone())
}
return response
} catch (error) {
console.log('[SW] Network request failed:', error)
// 返回错误响应
return new Response('网络请求失败', {
status: 500,
statusText: 'Internal Server Error'
})
}
}
// 缓存优先,网络作为后备策略
async function staleWhileRevalidate(request) {
const cache = await caches.open(RUNTIME_CACHE)
const cachedResponse = await cache.match(request)
// 在后台发起网络请求
const fetchPromise = fetch(request).then((response) => {
// 如果响应成功,更新缓存
if (response.ok) {
cache.put(request, response.clone())
}
return response
}).catch((error) => {
console.log('[SW] Background fetch failed:', error)
// 返回错误响应,但不影响缓存的响应
return new Response('网络请求失败', {
status: 500,
statusText: 'Internal Server Error'
})
})
// 如果有缓存,立即返回缓存
if (cachedResponse) {
return cachedResponse
}
// 没有缓存,等待网络请求
return fetchPromise
}
// 后台同步事件
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync event:', event.tag)
if (event.tag === 'background-sync') {
event.waitUntil(doBackgroundSync())
}
})
// 执行后台同步
async function doBackgroundSync() {
try {
// 这里可以执行需要在网络恢复时同步的任务
console.log('[SW] Performing background sync')
// 例如:同步离线时的数据
// await syncOfflineData()
} catch (error) {
console.error('[SW] Background sync failed:', error)
}
}
// 推送通知事件
self.addEventListener('push', (event) => {
console.log('[SW] Push event received')
if (!event.data) {
return
}
const options = event.data.json()
event.waitUntil(
self.registration.showNotification(options.title || '新消息', {
body: options.body || '您有新消息',
icon: options.icon || '/favicon.ico',
badge: options.badge || '/favicon.ico',
data: options.data || {},
actions: options.actions || []
})
)
})
// 通知点击事件
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification click event')
event.notification.close()
// 处理通知点击
if (event.action) {
// 处理特定的操作按钮点击
handleNotificationAction(event.action, event.notification.data)
} else {
// 处理通知主体点击
handleNotificationClick(event.notification.data)
}
})
// 处理通知点击
function handleNotificationClick(data) {
// 打开应用或特定页面
event.waitUntil(
clients.openWindow(data.url || '/')
)
}
// 处理通知操作
function handleNotificationAction(action, data) {
// 根据不同的操作执行不同的逻辑
switch (action) {
case 'view':
clients.openWindow(data.url || '/')
break
case 'dismiss':
// 关闭通知,无需其他操作
break
default:
console.log('[SW] Unknown notification action:', action)
}
}

View File

@@ -0,0 +1,443 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
// 创建报告目录
const reportDir = path.resolve(process.cwd(), 'quality-report')
if (!fs.existsSync(reportDir)) {
fs.mkdirSync(reportDir, { recursive: true })
}
// 生成时间戳
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const reportFile = path.join(reportDir, `quality-report-${timestamp}.html`)
// 报告模板
const reportTemplate = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>代码质量报告</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1, h2, h3 {
color: #2c3e50;
}
.header {
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.section {
margin-bottom: 40px;
}
.metric {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.metric-name {
font-weight: 500;
}
.metric-value {
font-weight: bold;
}
.success {
color: #27ae60;
}
.warning {
color: #f39c12;
}
.error {
color: #e74c3c;
}
.progress-bar {
width: 100%;
height: 20px;
background-color: #f0f0f0;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
.progress-fill {
height: 100%;
transition: width 0.3s ease;
}
.progress-success {
background-color: #27ae60;
}
.progress-warning {
background-color: #f39c12;
}
.progress-error {
background-color: #e74c3c;
}
.code-block {
background-color: #f8f9fa;
border-radius: 4px;
padding: 16px;
overflow-x: auto;
font-family: Consolas, Monaco, 'Andale Mono', monospace;
font-size: 14px;
line-height: 1.45;
}
.summary {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 20px;
}
.summary-card {
flex: 1;
min-width: 200px;
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.summary-card h3 {
margin-top: 0;
color: #495057;
}
.summary-value {
font-size: 2rem;
font-weight: bold;
margin: 10px 0;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6c757d;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>代码质量报告</h1>
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
</div>
<div class="section">
<h2>概览</h2>
<div class="summary">
<div class="summary-card">
<h3>测试覆盖率</h3>
<div class="summary-value" id="test-coverage">-</div>
<p>代码行覆盖率</p>
</div>
<div class="summary-card">
<h3>ESLint 问题</h3>
<div class="summary-value" id="eslint-issues">-</div>
<p>代码风格问题</p>
</div>
<div class="summary-card">
<h3>类型错误</h3>
<div class="summary-value" id="type-errors">-</div>
<p>TypeScript 类型错误</p>
</div>
<div class="summary-card">
<h3>安全漏洞</h3>
<div class="summary-value" id="security-vulnerabilities">-</div>
<p>npm audit 结果</p>
</div>
</div>
</div>
<div class="section">
<h2>测试覆盖率</h2>
<div id="coverage-details"></div>
</div>
<div class="section">
<h2>代码风格检查</h2>
<div id="eslint-details"></div>
</div>
<div class="section">
<h2>类型检查</h2>
<div id="type-check-details"></div>
</div>
<div class="section">
<h2>安全检查</h2>
<div id="security-details"></div>
</div>
<div class="footer">
<p>此报告由自动化代码质量检查工具生成</p>
</div>
</body>
</html>
`
// 写入报告文件
fs.writeFileSync(reportFile, reportTemplate)
console.log(`✅ 代码质量报告已生成: ${reportFile}`)
// 运行各种检查并更新报告
async function generateQualityReport() {
try {
// 1. 运行测试覆盖率
console.log('🔍 运行测试覆盖率...')
try {
execSync('npm run test:unit:coverage', { stdio: 'pipe' })
const coverageSummary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'))
updateTestCoverage(coverageSummary)
} catch (error) {
console.error('❌ 测试覆盖率检查失败:', error.message)
updateTestCoverage(null, error.message)
}
// 2. 运行 ESLint
console.log('🔍 运行 ESLint...')
try {
const eslintOutput = execSync('npm run lint -- --format=json', { stdio: 'pipe' }).toString()
const eslintResults = JSON.parse(eslintOutput)
updateESLintResults(eslintResults)
} catch (error) {
console.error('❌ ESLint 检查失败:', error.message)
updateESLintResults(null, error.message)
}
// 3. 运行类型检查
console.log('🔍 运行类型检查...')
try {
execSync('npm run type-check', { stdio: 'pipe' })
updateTypeCheckResults(true)
} catch (error) {
console.error('❌ 类型检查失败:', error.message)
updateTypeCheckResults(false, error.message)
}
// 4. 运行安全审计
console.log('🔍 运行安全审计...')
try {
const auditOutput = execSync('npm audit --json', { stdio: 'pipe' }).toString()
const auditResults = JSON.parse(auditOutput)
updateSecurityResults(auditResults)
} catch (error) {
console.error('❌ 安全审计失败:', error.message)
updateSecurityResults(null, error.message)
}
console.log('✅ 代码质量报告更新完成')
} catch (error) {
console.error('❌ 生成代码质量报告时出错:', error)
}
}
// 更新测试覆盖率信息
function updateTestCoverage(coverageSummary, error) {
let html = ''
if (error) {
html = `
<div class="metric">
<span class="metric-name">状态</span>
<span class="metric-value error">检查失败</span>
</div>
<div class="code-block">${error}</div>
`
} else {
const { lines, functions, branches, statements } = coverageSummary.total
const linesPercent = parseFloat(lines.pct)
html = `
<div class="metric">
<span class="metric-name">行覆盖率</span>
<span class="metric-value ${linesPercent >= 80 ? 'success' : linesPercent >= 60 ? 'warning' : 'error'}">${lines.pct}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill ${linesPercent >= 80 ? 'progress-success' : linesPercent >= 60 ? 'progress-warning' : 'progress-error'}" style="width: ${lines.pct}%"></div>
</div>
<div class="metric">
<span class="metric-name">函数覆盖率</span>
<span class="metric-value">${functions.pct}%</span>
</div>
<div class="metric">
<span class="metric-name">分支覆盖率</span>
<span class="metric-value">${branches.pct}%</span>
</div>
<div class="metric">
<span class="metric-name">语句覆盖率</span>
<span class="metric-value">${statements.pct}%</span>
</div>
`
// 更新概览
updateOverview('test-coverage', lines.pct + '%', linesPercent >= 80 ? 'success' : linesPercent >= 60 ? 'warning' : 'error')
}
updateReportSection('coverage-details', html)
}
// 更新 ESLint 结果
function updateESLintResults(eslintResults, error) {
let html = ''
if (error) {
html = `
<div class="metric">
<span class="metric-name">状态</span>
<span class="metric-value error">检查失败</span>
</div>
<div class="code-block">${error}</div>
`
} else {
let totalErrors = 0
let totalWarnings = 0
eslintResults.forEach(file => {
totalErrors += file.errorCount
totalWarnings += file.warningCount
})
const status = totalErrors === 0 && totalWarnings === 0 ? 'success' : totalErrors === 0 ? 'warning' : 'error'
html = `
<div class="metric">
<span class="metric-name">错误</span>
<span class="metric-value ${totalErrors === 0 ? 'success' : 'error'}">${totalErrors}</span>
</div>
<div class="metric">
<span class="metric-name">警告</span>
<span class="metric-value ${totalWarnings === 0 ? 'success' : 'warning'}">${totalWarnings}</span>
</div>
<div class="metric">
<span class="metric-name">状态</span>
<span class="metric-value ${status}">${totalErrors === 0 && totalWarnings === 0 ? '通过' : totalErrors === 0 ? '警告' : '失败'}</span>
</div>
`
// 更新概览
updateOverview('eslint-issues', totalErrors + totalWarnings, status)
}
updateReportSection('eslint-details', html)
}
// 更新类型检查结果
function updateTypeCheckResults(success, error) {
let html = ''
if (success) {
html = `
<div class="metric">
<span class="metric-name">状态</span>
<span class="metric-value success">通过</span>
</div>
<div class="metric">
<span class="metric-name">类型错误</span>
<span class="metric-value success">0</span>
</div>
`
// 更新概览
updateOverview('type-errors', '0', 'success')
} else {
html = `
<div class="metric">
<span class="metric-name">状态</span>
<span class="metric-value error">失败</span>
</div>
<div class="code-block">${error}</div>
`
// 更新概览
updateOverview('type-errors', '>', 'error')
}
updateReportSection('type-check-details', html)
}
// 更新安全检查结果
function updateSecurityResults(auditResults, error) {
let html = ''
if (error) {
html = `
<div class="metric">
<span class="metric-name">状态</span>
<span class="metric-value error">检查失败</span>
</div>
<div class="code-block">${error}</div>
`
// 更新概览
updateOverview('security-vulnerabilities', '?', 'error')
} else {
const { vulnerabilities } = auditResults.metadata
const { low, moderate, high, critical } = vulnerabilities
const totalVulns = low + moderate + high + critical
const status = critical > 0 || high > 0 ? 'error' : moderate > 0 ? 'warning' : 'success'
html = `
<div class="metric">
<span class="metric-name">严重</span>
<span class="metric-value ${critical === 0 ? 'success' : 'error'}">${critical}</span>
</div>
<div class="metric">
<span class="metric-name">高危</span>
<span class="metric-value ${high === 0 ? 'success' : 'error'}">${high}</span>
</div>
<div class="metric">
<span class="metric-name">中危</span>
<span class="metric-value ${moderate === 0 ? 'success' : 'warning'}">${moderate}</span>
</div>
<div class="metric">
<span class="metric-name">低危</span>
<span class="metric-value ${low === 0 ? 'success' : 'warning'}">${low}</span>
</div>
<div class="metric">
<span class="metric-name">总计</span>
<span class="metric-value ${status}">${totalVulns}</span>
</div>
`
// 更新概览
updateOverview('security-vulnerabilities', totalVulns, status)
}
updateReportSection('security-details', html)
}
// 更新概览
function updateOverview(id, value, status) {
const elementId = id
const className = status === 'success' ? 'success' : status === 'warning' ? 'warning' : 'error'
// 这里需要使用 DOM 操作,但在 Node.js 环境中无法直接操作 HTML
// 实际实现中可以使用 cheerio 或其他 HTML 解析库
console.log(`更新概览 ${id}: ${value} (${status})`)
}
// 更新报告部分
function updateReportSection(id, html) {
// 这里需要使用 DOM 操作,但在 Node.js 环境中无法直接操作 HTML
// 实际实现中可以使用 cheerio 或其他 HTML 解析库
console.log(`更新报告部分 ${id}`)
}
// 运行报告生成
generateQualityReport()
module.exports = {
generateQualityReport
}

263
frontend/scripts/test.js Normal file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env node
const { spawn } = require('child_process')
const path = require('path')
const fs = require('fs')
// 颜色输出函数
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
}
function colorLog(color, message) {
console.log(`${colors[color]}${message}${colors.reset}`)
}
// 解析命令行参数
const args = process.argv.slice(2)
const options = {
unit: args.includes('--unit'),
e2e: args.includes('--e2e'),
coverage: args.includes('--coverage'),
watch: args.includes('--watch'),
verbose: args.includes('--verbose'),
component: args.includes('--component'),
api: args.includes('--api'),
store: args.includes('--store'),
ci: args.includes('--ci')
}
// 如果没有指定测试类型,默认运行所有测试
if (!options.unit && !options.e2e && !options.component && !options.api && !options.store) {
options.unit = true
options.e2e = true
}
// 构建测试命令
function buildTestCommand(type) {
let command = 'npx'
let args = []
if (type === 'unit') {
args.push('vitest', 'run')
if (options.coverage) {
args.push('--coverage')
}
if (options.watch) {
args = args.filter(arg => arg !== 'run')
args.push('--watch')
}
if (options.verbose) {
args.push('--verbose')
}
// 添加特定的测试文件模式
if (options.component) {
args.push('--dir', 'tests/unit/components')
} else if (options.api) {
args.push('--dir', 'tests/unit/services')
} else if (options.store) {
args.push('--dir', 'tests/unit/stores')
}
} else if (type === 'e2e') {
args.push('playwright', 'test')
if (options.ci) {
args.push('--reporter=line')
} else {
args.push('--reporter=list')
args.push('--reporter=html')
}
if (options.verbose) {
args.push('--verbose')
}
}
return { command, args }
}
// 运行测试命令
function runTestCommand(type) {
return new Promise((resolve, reject) => {
const { command, args } = buildTestCommand(type)
colorLog('cyan', `运行 ${type} 测试...`)
colorLog('blue', `${command} ${args.join(' ')}`)
const testProcess = spawn(command, args, {
stdio: 'inherit',
shell: true,
cwd: path.resolve(__dirname, '..')
})
testProcess.on('close', (code) => {
if (code === 0) {
colorLog('green', `${type} 测试通过!`)
resolve(code)
} else {
colorLog('red', `${type} 测试失败!`)
reject(new Error(`${type} 测试失败,退出码: ${code}`))
}
})
testProcess.on('error', (error) => {
colorLog('red', `运行 ${type} 测试时出错: ${error.message}`)
reject(error)
})
})
}
// 运行代码质量检查
function runCodeQualityChecks() {
return new Promise((resolve, reject) => {
colorLog('cyan', '运行代码质量检查...')
// 运行ESLint
const eslintProcess = spawn('npx', ['eslint', '--ext', '.js,.vue,.ts', 'src/', 'tests/'], {
stdio: 'inherit',
shell: true,
cwd: path.resolve(__dirname, '..')
})
eslintProcess.on('close', (code) => {
if (code === 0) {
colorLog('green', 'ESLint 检查通过!')
// 运行Prettier检查
const prettierProcess = spawn('npx', ['prettier', '--check', 'src/**/*.{js,vue,ts,css,scss,json,md}'], {
stdio: 'inherit',
shell: true,
cwd: path.resolve(__dirname, '..')
})
prettierProcess.on('close', (prettierCode) => {
if (prettierCode === 0) {
colorLog('green', 'Prettier 检查通过!')
resolve()
} else {
colorLog('red', 'Prettier 检查失败!')
reject(new Error(`Prettier 检查失败,退出码: ${prettierCode}`))
}
})
prettierProcess.on('error', (error) => {
colorLog('red', `运行 Prettier 检查时出错: ${error.message}`)
reject(error)
})
} else {
colorLog('red', 'ESLint 检查失败!')
reject(new Error(`ESLint 检查失败,退出码: ${code}`))
}
})
eslintProcess.on('error', (error) => {
colorLog('red', `运行 ESLint 检查时出错: ${error.message}`)
reject(error)
})
})
}
// 生成测试报告
function generateTestReports() {
return new Promise((resolve) => {
colorLog('cyan', '生成测试报告...')
// 确保报告目录存在
const reportsDir = path.resolve(__dirname, '../reports')
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir, { recursive: true })
}
// 创建测试报告摘要
const summaryPath = path.join(reportsDir, 'test-summary.json')
const summary = {
timestamp: new Date().toISOString(),
tests: {
unit: options.unit,
e2e: options.e2e,
component: options.component,
api: options.api,
store: options.store
},
coverage: options.coverage,
watch: options.watch,
verbose: options.verbose
}
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2))
colorLog('green', '测试报告已生成!')
resolve()
})
}
// 主函数
async function main() {
try {
colorLog('bright', '开始运行自动化测试和代码质量检查...')
// 运行代码质量检查
await runCodeQualityChecks()
// 运行单元测试
if (options.unit) {
await runTestCommand('unit')
}
// 运行E2E测试
if (options.e2e) {
await runTestCommand('e2e')
}
// 生成测试报告
await generateTestReports()
colorLog('bright', colorLog('green', '所有测试和代码质量检查通过!'))
process.exit(0)
} catch (error) {
colorLog('red', `测试或代码质量检查失败: ${error.message}`)
process.exit(1)
}
}
// 显示帮助信息
function showHelp() {
colorLog('bright', '自动化测试和代码质量检查工具')
console.log('')
console.log('用法: node scripts/test.js [选项]')
console.log('')
console.log('选项:')
console.log(' --unit 运行单元测试')
console.log(' --e2e 运行端到端测试')
console.log(' --component 运行组件测试')
console.log(' --api 运行API服务测试')
console.log(' --store 运行状态管理测试')
console.log(' --coverage 生成测试覆盖率报告')
console.log(' --watch 监视模式运行测试')
console.log(' --verbose 详细输出')
console.log(' --ci CI模式运行测试')
console.log('')
console.log('示例:')
console.log(' node scripts/test.js --unit --coverage')
console.log(' node scripts/test.js --e2e --verbose')
console.log(' node scripts/test.js --component --watch')
}
// 检查是否需要显示帮助信息
if (args.includes('--help') || args.includes('-h')) {
showHelp()
process.exit(0)
}
// 运行主函数
main()

View File

@@ -1,22 +1,67 @@
<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" />
<ErrorBoundary>
<ResourcePreloader />
<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" />
<PWAInstallPrompt />
</ErrorBoundary>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import Layout from './components/Layout.vue'
import GlobalLoading from './components/GlobalLoading.vue'
import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
import ErrorBoundary from './components/ErrorBoundary.vue'
import ResourcePreloader from './components/ResourcePreloader.vue'
import { useLoadingStore } from './stores/loadingStore'
import { smartPreloadResources } from './utils/resourceLoader'
import { initAuth } from './utils/auth'
import globalErrorHandler from './utils/errorHandler'
const loadingStore = useLoadingStore()
// 应用初始化
onMounted(() => {
// 初始化认证状态
initAuth(() => {
// token过期处理
console.log('Token已过期需要重新登录')
})
// 预加载关键资源
smartPreloadResources([
{
url: '/placeholder.svg',
type: 'image',
priority: 'high'
},
{
url: 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',
type: 'style',
priority: 'high'
}
], {
networkThreshold: '3g',
idleTimeout: 1000
})
// 配置全局错误处理
globalErrorHandler.configure({
showNotification: true,
logError: true,
reportError: import.meta.env.PROD, // 生产环境才上报错误
reportUrl: '/api/errors/report'
})
})
</script>
<style>

View File

@@ -0,0 +1,201 @@
/**
* 缓存状态指示器组件
* 显示当前缓存状态和基本信息
*/
<template>
<div class="cache-status-indicator">
<el-popover placement="bottom" :width="300" trigger="click">
<template #reference>
<div class="status-icon" :class="statusClass">
<el-icon><DataLine /></el-icon>
<span class="status-text">{{ statusText }}</span>
</div>
</template>
<div class="cache-details">
<div class="detail-item">
<span class="label">缓存项数量:</span>
<span class="value">{{ cacheStats.size }}</span>
</div>
<div class="detail-item">
<span class="label">最大缓存:</span>
<span class="value">{{ cacheStats.maxSize }}</span>
</div>
<div class="detail-item">
<span class="label">总大小:</span>
<span class="value">{{ formatSize(cacheStats.totalSize) }}</span>
</div>
<div class="detail-item">
<span class="label">过期项:</span>
<span class="value">{{ cacheStats.expiredCount }}</span>
</div>
<div class="detail-item">
<span class="label">策略:</span>
<el-tag size="small">{{ cacheStats.strategy }}</el-tag>
</div>
<div class="actions">
<el-button size="small" @click="refreshStats">刷新</el-button>
<el-button size="small" @click="clearExpired">清除过期</el-button>
</div>
</div>
</el-popover>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { DataLine } from '@element-plus/icons-vue'
import { getCacheStats, clearExpiredCache } from '../utils/componentCache'
export default {
name: 'CacheStatusIndicator',
components: {
DataLine
},
setup() {
// 响应式数据
const cacheStats = ref({
size: 0,
maxSize: 0,
totalSize: 0,
memoryThreshold: 0,
expiredCount: 0,
strategy: ''
})
let refreshInterval = null
// 计算属性
const statusClass = computed(() => {
const ratio = cacheStats.value.size / cacheStats.value.maxSize
if (ratio > 0.9) return 'status-warning'
if (ratio > 0.7) return 'status-normal'
return 'status-good'
})
const statusText = computed(() => {
const ratio = cacheStats.value.size / cacheStats.value.maxSize
if (ratio > 0.9) return '缓存接近满'
if (ratio > 0.7) return '缓存正常'
return '缓存良好'
})
// 刷新统计信息
const refreshStats = () => {
Object.assign(cacheStats.value, getCacheStats())
}
// 清除过期缓存
const clearExpired = () => {
const count = clearExpiredCache()
refreshStats()
ElMessage.success(`已清除 ${count} 个过期缓存项`)
}
// 格式化大小
const formatSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 组件挂载时开始定时刷新
onMounted(() => {
refreshStats()
// 每30秒刷新一次统计信息
refreshInterval = setInterval(refreshStats, 30000)
})
// 组件卸载时清除定时器
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
return {
cacheStats,
statusClass,
statusText,
refreshStats,
clearExpired,
formatSize
}
}
}
</script>
<style scoped>
.cache-status-indicator {
display: inline-block;
}
.status-icon {
display: flex;
align-items: center;
padding: 6px 12px;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
}
.status-good {
background-color: #f0f9ff;
color: #0ea5e9;
}
.status-normal {
background-color: #fffbeb;
color: #f59e0b;
}
.status-warning {
background-color: #fef2f2;
color: #ef4444;
}
.status-icon:hover {
opacity: 0.8;
}
.status-text {
margin-left: 6px;
font-size: 14px;
font-weight: 500;
}
.cache-details {
padding: 10px 0;
}
.detail-item {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.label {
color: #666;
font-size: 14px;
}
.value {
font-weight: 500;
}
.actions {
margin-top: 15px;
display: flex;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,292 @@
/**
* 可缓存组件
* 提供组件级别的缓存功能,支持动态缓存策略配置
*/
<template>
<div class="cacheable-component">
<!-- 缓存状态指示器开发模式 -->
<div v-if="isDevelopment && showCacheIndicator" class="cache-indicator">
<el-tag
:type="cacheStatus === 'hit' ? 'success' : cacheStatus === 'miss' ? 'danger' : 'info'"
size="small"
>
{{ cacheStatusText }}
</el-tag>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<slot name="loading">
<el-skeleton :rows="5" animated />
</slot>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<slot name="error" :error="error" :retry="load">
<el-alert
:title="errorMessage"
type="error"
:description="error.message || '未知错误'"
show-icon
:closable="false"
>
<template #default>
<el-button @click="load" type="primary" size="small">重试</el-button>
</template>
</el-alert>
</slot>
</div>
<!-- 组件内容 -->
<div v-else class="content-container">
<slot :data="cachedData" :refresh="refresh" :cacheStatus="cacheStatus"></slot>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { getCache, setCache, deleteCache, hasCache, CacheStrategy } from '../utils/componentCache'
export default {
name: 'CacheableComponent',
props: {
// 缓存键,必须唯一
cacheKey: {
type: String,
required: true
},
// 缓存策略
cacheStrategy: {
type: String,
default: CacheStrategy.TIME_BASED,
validator: (value) => Object.values(CacheStrategy).includes(value)
},
// 缓存过期时间(毫秒)
cacheExpire: {
type: Number,
default: 5 * 60 * 1000 // 5分钟
},
// 是否启用缓存
enableCache: {
type: Boolean,
default: true
},
// 是否自动加载
autoLoad: {
type: Boolean,
default: true
},
// 加载函数必须返回Promise
loader: {
type: Function,
required: true
},
// 依赖项,当依赖项变化时重新加载
dependencies: {
type: Array,
default: () => []
},
// 是否显示缓存指示器(仅开发模式)
showCacheIndicator: {
type: Boolean,
default: true
},
// 错误消息
errorMessage: {
type: String,
default: '加载数据失败'
},
// 是否在组件挂载时清除缓存
clearOnMount: {
type: Boolean,
default: false
},
// 是否在组件卸载时清除缓存
clearOnUnmount: {
type: Boolean,
default: false
},
// 是否在依赖项变化时清除缓存
clearOnDependencyChange: {
type: Boolean,
default: true
}
},
emits: ['cache-hit', 'cache-miss', 'load-success', 'load-error', 'refresh'],
setup(props, { emit }) {
// 响应式数据
const loading = ref(false)
const error = ref(null)
const cachedData = ref(null)
const cacheStatus = ref('none') // 'hit', 'miss', 'none'
const isDevelopment = ref(process.env.NODE_ENV === 'development')
// 计算属性
const cacheStatusText = computed(() => {
switch (cacheStatus.value) {
case 'hit': return '缓存命中'
case 'miss': return '缓存未命中'
default: return '无缓存'
}
})
// 加载数据
const load = async (forceRefresh = false) => {
if (!props.enableCache || forceRefresh) {
return loadDataFromSource()
}
// 尝试从缓存获取数据
if (hasCache(props.cacheKey)) {
const data = getCache(props.cacheKey)
if (data !== null) {
cachedData.value = data
cacheStatus.value = 'hit'
emit('cache-hit', data)
return data
}
}
// 缓存未命中,从源加载
return loadDataFromSource()
}
// 从源加载数据
const loadDataFromSource = async () => {
loading.value = true
error.value = null
cacheStatus.value = 'miss'
try {
const data = await props.loader()
// 缓存数据
if (props.enableCache) {
setCache(props.cacheKey, data, {
strategy: props.cacheStrategy,
expire: props.cacheExpire
})
}
cachedData.value = data
emit('cache-miss', data)
emit('load-success', data)
return data
} catch (err) {
error.value = err
emit('load-error', err)
ElMessage.error(props.errorMessage)
throw err
} finally {
loading.value = false
}
}
// 刷新数据(强制从源加载)
const refresh = async () => {
try {
const data = await loadDataFromSource()
emit('refresh', data)
return data
} catch (err) {
console.error('刷新数据失败:', err)
throw err
}
}
// 清除缓存
const clearCache = () => {
deleteCache(props.cacheKey)
cacheStatus.value = 'none'
}
// 监听依赖项变化
watch(
() => props.dependencies,
(newDeps, oldDeps) => {
// 检查依赖项是否真的发生了变化
const hasChanged = JSON.stringify(newDeps) !== JSON.stringify(oldDeps)
if (hasChanged) {
if (props.clearOnDependencyChange) {
clearCache()
}
if (props.autoLoad) {
load()
}
}
},
{ deep: true }
)
// 组件挂载时
onMounted(() => {
if (props.clearOnMount) {
clearCache()
}
if (props.autoLoad) {
nextTick(() => {
load()
})
}
})
// 组件卸载时
onUnmounted(() => {
if (props.clearOnUnmount) {
clearCache()
}
})
return {
loading,
error,
cachedData,
cacheStatus,
cacheStatusText,
isDevelopment,
load,
refresh,
clearCache
}
}
}
</script>
<style scoped>
.cacheable-component {
position: relative;
width: 100%;
}
.cache-indicator {
position: absolute;
top: 0;
right: 0;
z-index: 10;
padding: 4px;
}
.loading-container,
.error-container,
.content-container {
width: 100%;
}
.error-container {
padding: 16px 0;
}
.content-container {
min-height: 50px;
}
</style>

View File

@@ -8,13 +8,28 @@
</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>
<p>{{ message || errorMessage || '应用程序遇到了意外错误' }}</p>
<div class="error-actions">
<el-button type="primary" @click="handleRetry" v-if="retryable">
重试
</el-button>
<el-button @click="$router.push('/')">
返回首页
</el-button>
<el-button v-if="showDetails" type="text" @click="toggleDetails">
{{ showDetailsContent ? '隐藏' : '显示' }}详情
</el-button>
</div>
<div v-if="showDetailsContent" class="error-details">
<el-collapse>
<el-collapse-item title="错误详情" name="error">
<pre>{{ errorDetails }}</pre>
</el-collapse-item>
<el-collapse-item title="组件堆栈" name="stack">
<pre>{{ componentStack }}</pre>
</el-collapse-item>
</el-collapse>
</div>
</div>
</div>
<slot v-else />
@@ -22,8 +37,10 @@
</template>
<script>
import { ref } from 'vue'
import { ref, computed, onErrorCaptured, onMounted } from 'vue'
import { Warning } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import monitor from '@/utils/monitor'
export default {
name: 'ErrorBoundary',
@@ -42,31 +59,129 @@ export default {
retryable: {
type: Boolean,
default: true
},
showDetails: {
type: Boolean,
default: false
},
onError: {
type: Function,
default: null
}
},
setup(props, { emit }) {
const hasError = ref(false)
const errorMessage = ref('')
const error = ref(null)
const errorInfo = ref(null)
const showDetailsContent = ref(false)
// 计算属性
const errorMessage = computed(() => {
if (error.value?.message) {
return error.value.message
}
return props.message || '应用程序遇到了意外错误'
})
const errorDetails = computed(() => {
if (!error.value) return '无错误详情'
return JSON.stringify({
name: error.value.name,
message: error.value.message,
stack: error.value.stack
}, null, 2)
})
const componentStack = computed(() => {
return errorInfo.value || '无组件堆栈信息'
})
// 方法
const handleRetry = () => {
hasError.value = false
errorMessage.value = ''
error.value = null
errorInfo.value = null
emit('retry')
}
const errorCaptured = (err, vm, info) => {
const toggleDetails = () => {
showDetailsContent.value = !showDetailsContent.value
}
// 捕获子组件错误
onErrorCaptured((err, instance, info) => {
console.error('[ErrorBoundary] 捕获到错误:', err)
console.error('[ErrorBoundary] 组件实例:', instance)
console.error('[ErrorBoundary] 错误信息:', info)
hasError.value = true
errorMessage.value = err.message
console.error('Error caught by error boundary:', err, info)
error.value = err
errorInfo.value = info
// 记录错误到监控系统
monitor.logError(err, {
component: instance?.$options?.name || 'Unknown',
info,
userAgent: navigator.userAgent,
url: window.location.href
})
// 调用自定义错误处理函数
if (props.onError && typeof props.onError === 'function') {
props.onError(err, instance, info)
}
// 显示错误提示
ElMessage.error('应用发生错误,请查看详情或重试')
// 防止错误继续向上传播
return false
}
})
// 监听全局错误
onMounted(() => {
// 监听未捕获的Promise错误
window.addEventListener('unhandledrejection', (event) => {
console.error('[ErrorBoundary] 未捕获的Promise错误:', event.reason)
// 记录错误到监控系统
monitor.logError(event.reason, {
type: 'unhandledrejection',
promise: event.promise,
userAgent: navigator.userAgent,
url: window.location.href
})
// 显示错误提示
ElMessage.error('发生了未处理的错误,请刷新页面重试')
})
// 监听全局JavaScript错误
window.addEventListener('error', (event) => {
console.error('[ErrorBoundary] 全局JavaScript错误:', event.error)
// 记录错误到监控系统
monitor.logError(event.error, {
type: 'javascript',
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
userAgent: navigator.userAgent,
url: window.location.href
})
})
})
return {
hasError,
errorMessage,
errorDetails,
componentStack,
showDetailsContent,
handleRetry,
errorCaptured
toggleDetails,
errorCaptured: onErrorCaptured
}
}
}
@@ -90,6 +205,10 @@ export default {
margin-bottom: 20px;
}
.error-text {
max-width: 600px;
}
.error-text h3 {
margin: 0 0 10px 0;
color: #303133;
@@ -102,7 +221,54 @@ export default {
max-width: 500px;
}
.error-text .el-button {
margin: 0 6px;
.error-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 20px;
}
.error-details {
width: 100%;
max-width: 600px;
text-align: left;
margin-top: 20px;
}
.error-details pre {
white-space: pre-wrap;
word-wrap: break-word;
font-family: monospace;
font-size: 12px;
color: #606266;
background-color: #f5f7fa;
padding: 12px;
border-radius: 4px;
overflow: auto;
max-height: 200px;
}
@media (max-width: 480px) {
.error-content {
padding: 40px 15px;
}
.error-text h3 {
font-size: 18px;
}
.error-text p {
font-size: 14px;
}
.error-actions {
flex-direction: column;
align-items: center;
}
.error-actions .el-button {
width: 200px;
margin: 5px 0;
}
}
</style>

View File

@@ -37,10 +37,15 @@
<el-menu-item index="comparison">
<router-link to="/comparison">产品对比</router-link>
</el-menu-item>
<el-menu-item index="monitor">
<router-link to="/monitor">性能监控</router-link>
</el-menu-item>
</el-menu>
</nav>
<div class="header-actions">
<CacheStatusIndicator />
<el-button
type="text"
@click="toggleMobileMenu"
@@ -93,6 +98,10 @@
<el-menu-item index="comparison">
<router-link to="/comparison" @click="toggleMobileMenu">产品对比</router-link>
</el-menu-item>
<el-menu-item index="monitor">
<router-link to="/monitor" @click="toggleMobileMenu">性能监控</router-link>
</el-menu-item>
</el-menu>
</div>
</div>
@@ -104,6 +113,7 @@ 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'
import CacheStatusIndicator from './CacheStatusIndicator.vue'
const route = useRoute()
const categoryStore = useCategoryStore()
@@ -118,6 +128,7 @@ const activeCategory = computed(() => {
if (route.path === '/') return 'home'
if (route.path.startsWith('/category/')) return route.params.categoryId
if (route.path === '/comparison') return 'comparison'
if (route.path === '/monitor') return 'monitor'
return ''
})

View File

@@ -17,8 +17,9 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Picture } from '@element-plus/icons-vue'
import { preloadImage } from '@/utils/resourceLoader'
const props = defineProps({
src: {
@@ -56,22 +57,35 @@ const props = defineProps({
useWebP: {
type: Boolean,
default: true
},
priority: {
type: String,
default: 'normal', // 'high', 'normal', 'low'
},
preload: {
type: Boolean,
default: false
},
fallbackSrc: {
type: String,
default: ''
}
})
const emit = defineEmits(['load', 'error'])
const emit = defineEmits(['load', 'error', 'load-start'])
const containerRef = ref(null)
const isIntersecting = ref(false)
const isLoaded = ref(false)
const hasError = ref(false)
const observer = ref(null)
const currentSrc = ref('')
// 检查浏览器是否支持WebP
const supportsWebP = ref(false)
// 计算当前图片源优先使用WebP格式
const currentSrc = computed(() => {
const computedSrc = computed(() => {
if (!props.src) return ''
// 如果不使用WebP或浏览器不支持返回原始URL
@@ -133,16 +147,56 @@ const checkWebPSupport = () => {
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0
}
// 加载图片
const loadImage = async () => {
if (!props.src || isLoaded.value || hasError.value) return
emit('load-start')
try {
// 如果启用预加载,使用预加载工具
if (props.preload) {
const img = await preloadImage(props.src, {
priority: props.priority,
useWebP: props.useWebP,
fallbackSrc: props.fallbackSrc
})
currentSrc.value = img.src
} else {
currentSrc.value = computedSrc.value
}
} catch (error) {
console.error('Image preload failed:', error)
// 降级到直接设置src
currentSrc.value = computedSrc.value
}
}
// 处理图片加载
const handleLoad = () => {
isLoaded.value = true
hasError.value = false
emit('load')
}
// 处理图片加载错误
const handleError = () => {
hasError.value = true
emit('error')
if (!hasError.value && props.fallbackSrc) {
// 尝试加载降级图片
currentSrc.value = props.fallbackSrc
} else {
hasError.value = true
isLoaded.value = false
emit('error')
}
}
// 重置状态
const reset = () => {
isLoaded.value = false
hasError.value = false
currentSrc.value = ''
isIntersecting.value = false
}
// 设置交叉观察器
@@ -154,6 +208,7 @@ const setupIntersectionObserver = () => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
isIntersecting.value = true
loadImage()
// 图片进入视口后,停止观察
if (observer.value) {
observer.value.unobserve(entry.target)
@@ -169,6 +224,14 @@ const setupIntersectionObserver = () => {
observer.value.observe(containerRef.value)
}
// 监听src变化
watch(() => props.src, () => {
reset()
if (isIntersecting.value) {
loadImage()
}
})
// 组件挂载时初始化
onMounted(() => {
// 检查WebP支持
@@ -176,6 +239,12 @@ onMounted(() => {
// 设置交叉观察器
setupIntersectionObserver()
// 如果不启用懒加载,直接加载图片
if (props.threshold === 0) {
isIntersecting.value = true
loadImage()
}
})
// 组件卸载时清理
@@ -184,6 +253,12 @@ onUnmounted(() => {
observer.value.disconnect()
}
})
// 暴露方法
defineExpose({
reset,
loadImage
})
</script>
<style scoped>

View File

@@ -0,0 +1,199 @@
<template>
<div v-if="showInstallPrompt" class="pwa-install-prompt">
<div class="prompt-content">
<div class="prompt-icon">
<img src="/favicon.ico" alt="应用图标" />
</div>
<div class="prompt-text">
<h3>安装应用</h3>
<p>将硬件性能排行榜安装到主屏幕获得更好的使用体验</p>
</div>
<div class="prompt-actions">
<el-button type="primary" size="small" @click="installApp">安装</el-button>
<el-button size="small" @click="dismissPrompt">暂不</el-button>
</div>
<div class="prompt-close" @click="dismissPrompt">
<el-icon><Close /></el-icon>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Close } from '@element-plus/icons-vue'
import { checkPWAInstallable } from '@/utils/pwa'
// 响应式数据
const showInstallPrompt = ref(false)
let deferredPrompt = null
// 组件挂载时设置安装提示
onMounted(() => {
// 检查是否已安装或已拒绝
const isDismissed = localStorage.getItem('pwa-install-dismissed')
const pwaInfo = checkPWAInstallable()
// 如果不是PWA模式且用户没有拒绝过则显示安装提示
if (!pwaInfo.isInstalled && !isDismissed && pwaInfo.isInstallable) {
// 监听安装提示事件
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
}
})
// 处理安装提示事件
const handleBeforeInstallPrompt = (e) => {
// 阻止默认的安装横幅
e.preventDefault()
// 保存事件引用,稍后使用
deferredPrompt = e
// 显示安装提示
showInstallPrompt.value = true
}
// 安装应用
const installApp = async () => {
if (!deferredPrompt) {
ElMessage.warning('无法安装应用,请尝试使用浏览器的"添加到主屏幕"功能')
return
}
try {
// 显示安装提示
deferredPrompt.prompt()
// 等待用户响应
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
ElMessage.success('应用安装成功!')
} else {
console.log('[PWA] 用户拒绝了安装')
}
// 清除事件引用
deferredPrompt = null
// 隐藏安装提示
showInstallPrompt.value = false
} catch (error) {
console.error('[PWA] 安装应用失败:', error)
ElMessage.error('安装应用失败,请稍后重试')
}
}
// 关闭安装提示
const dismissPrompt = () => {
showInstallPrompt.value = false
// 记录用户已拒绝,下次不再显示
localStorage.setItem('pwa-install-dismissed', 'true')
// 如果有保存的事件引用,清除它
if (deferredPrompt) {
deferredPrompt = null
}
}
</script>
<style scoped>
.pwa-install-prompt {
position: fixed;
bottom: 20px;
left: 20px;
right: 20px;
z-index: 9999;
max-width: 400px;
margin: 0 auto;
}
.prompt-content {
display: flex;
align-items: center;
padding: 16px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
position: relative;
}
.prompt-icon {
margin-right: 12px;
}
.prompt-icon img {
width: 48px;
height: 48px;
border-radius: 8px;
}
.prompt-text {
flex: 1;
margin-right: 12px;
}
.prompt-text h3 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 500;
}
.prompt-text p {
margin: 0;
font-size: 14px;
color: #666;
}
.prompt-actions {
display: flex;
gap: 8px;
}
.prompt-close {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #999;
border-radius: 50%;
transition: background-color 0.2s;
}
.prompt-close:hover {
background-color: #f5f5f5;
color: #666;
}
@media (max-width: 480px) {
.pwa-install-prompt {
bottom: 10px;
left: 10px;
right: 10px;
}
.prompt-content {
padding: 12px;
}
.prompt-icon img {
width: 40px;
height: 40px;
}
.prompt-text h3 {
font-size: 14px;
}
.prompt-text p {
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,318 @@
<template>
<div class="performance-dashboard">
<el-card class="dashboard-card">
<template #header>
<div class="card-header">
<span>性能监控仪表板</span>
<div class="header-actions">
<el-switch
v-model="autoRefresh"
active-text="自动刷新"
@change="toggleAutoRefresh"
/>
<el-button size="small" @click="refreshData">刷新数据</el-button>
<el-button size="small" type="danger" @click="clearData">清除数据</el-button>
</div>
</div>
</template>
<el-tabs v-model="activeTab" type="border-card">
<!-- 性能指标标签页 -->
<el-tab-pane label="性能指标" name="performance">
<div class="metrics-container">
<el-row :gutter="20">
<el-col :span="8">
<el-statistic title="平均页面加载时间" :value="metrics.avgPageLoadTime" suffix="ms" />
</el-col>
<el-col :span="8">
<el-statistic title="平均API响应时间" :value="metrics.avgApiResponseTime" suffix="ms" />
</el-col>
<el-col :span="8">
<el-statistic title="平均路由切换时间" :value="metrics.avgRouteChangeTime" suffix="ms" />
</el-col>
</el-row>
<div class="chart-container">
<h4>Web Vitals</h4>
<el-row :gutter="20">
<el-col :span="6">
<div class="vital-item">
<div class="vital-label">LCP (Largest Contentful Paint)</div>
<div class="vital-value" :class="getVitalClass(webVitals.lcp, 'lcp')">{{ webVitals.lcp }}ms</div>
</div>
</el-col>
<el-col :span="6">
<div class="vital-item">
<div class="vital-label">FID (First Input Delay)</div>
<div class="vital-value" :class="getVitalClass(webVitals.fid, 'fid')">{{ webVitals.fid }}ms</div>
</div>
</el-col>
<el-col :span="6">
<div class="vital-item">
<div class="vital-label">CLS (Cumulative Layout Shift)</div>
<div class="vital-value" :class="getVitalClass(webVitals.cls, 'cls')">{{ webVitals.cls }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="vital-item">
<div class="vital-label">FCP (First Contentful Paint)</div>
<div class="vital-value" :class="getVitalClass(webVitals.fcp, 'fcp')">{{ webVitals.fcp }}ms</div>
</div>
</el-col>
</el-row>
</div>
</div>
</el-tab-pane>
<!-- 错误日志标签页 -->
<el-tab-pane label="错误日志" name="errors">
<div class="error-container">
<el-table :data="errors" style="width: 100%" max-height="400">
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="scope">
{{ formatTime(scope.row.timestamp) }}
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="120">
<template #default="scope">
<el-tag :type="getErrorTagType(scope.row.type)">{{ scope.row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="错误信息" show-overflow-tooltip />
<el-table-column prop="url" label="URL" show-overflow-tooltip />
<el-table-column label="操作" width="100">
<template #default="scope">
<el-button size="small" @click="showErrorDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- API请求标签页 -->
<el-tab-pane label="API请求" name="api">
<div class="api-container">
<el-table :data="apiRequests" style="width: 100%" max-height="400">
<el-table-column prop="url" label="URL" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="scope">
<el-tag :type="scope.row.success ? 'success' : 'danger'">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration" label="耗时" width="100">
<template #default="scope">
{{ scope.row.duration }}ms
</template>
</el-table-column>
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="scope">
{{ formatTime(scope.row.timestamp) }}
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 错误详情对话框 -->
<el-dialog v-model="errorDetailVisible" title="错误详情" width="50%">
<div v-if="selectedError">
<el-descriptions :column="1" border>
<el-descriptions-item label="时间">{{ formatTime(selectedError.timestamp) }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ selectedError.type }}</el-descriptions-item>
<el-descriptions-item label="错误信息">{{ selectedError.message }}</el-descriptions-item>
<el-descriptions-item label="URL">{{ selectedError.url || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户代理">{{ selectedError.userAgent || '-' }}</el-descriptions-item>
<el-descriptions-item label="堆栈信息" v-if="selectedError.stack">
<pre>{{ selectedError.stack }}</pre>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue'
import monitor from '@/utils/monitor'
import { getMetrics, clearMetrics, getMetricsOverview, recordCustomMetric } from '@/utils/performanceMetrics'
// 响应式数据
const activeTab = ref('performance')
const errorDetailVisible = ref(false)
const selectedError = ref(null)
const autoRefresh = ref(false)
const refreshTimer = ref(null)
// 性能指标
const metrics = computed(() => monitor.getMetrics())
// 性能指标概览
const performanceOverview = computed(() => getMetricsOverview())
// Web Vitals
const webVitals = computed(() => {
const overview = performanceOverview.value
return {
lcp: overview.lcp || 0,
fid: overview.fid || 0,
cls: overview.cls || 0,
fcp: overview.fcp || 0
}
})
// 错误日志
const errors = computed(() => monitor.getErrors())
// API请求记录
const apiRequests = computed(() => monitor.getApiRequests())
// 刷新数据
const refreshData = () => {
monitor.collectMetrics()
// 记录自定义指标:性能面板刷新
recordCustomMetric('dashboard-refresh', 1, 'count')
}
// 清除数据
const clearData = () => {
monitor.clearData()
clearMetrics()
}
// 切换自动刷新
const toggleAutoRefresh = (enabled) => {
if (enabled) {
refreshTimer.value = setInterval(refreshData, 5000) // 每5秒刷新一次
} else {
if (refreshTimer.value) {
clearInterval(refreshTimer.value)
refreshTimer.value = null
}
}
}
// 显示错误详情
const showErrorDetail = (error) => {
selectedError.value = error
errorDetailVisible.value = true
}
// 格式化时间
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleString()
}
// 获取Web Vitals等级样式
const getVitalClass = (value, type) => {
// 根据Web Vitals的推荐阈值设置不同的样式
if (type === 'cls') {
if (value < 0.1) return 'good'
if (value < 0.25) return 'needs-improvement'
return 'poor'
}
if (value < 1000) return 'good'
if (value < 3000) return 'needs-improvement'
return 'poor'
}
// 获取错误标签类型
const getErrorTagType = (type) => {
const typeMap = {
'js_error': 'danger',
'api_error': 'warning',
'unhandled_promise_rejection': 'danger',
'resource_error': 'warning'
}
return typeMap[type] || 'info'
}
// 组件挂载时收集初始指标
onMounted(() => {
refreshData()
// 记录自定义指标:性能面板加载
recordCustomMetric('dashboard-load', 1, 'count')
})
// 组件卸载时清除定时器
onUnmounted(() => {
if (refreshTimer.value) {
clearInterval(refreshTimer.value)
}
})
</script>
<style scoped>
.performance-dashboard {
padding: 20px;
}
.dashboard-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.metrics-container {
padding: 20px 0;
}
.chart-container {
margin-top: 30px;
}
.vital-item {
text-align: center;
padding: 15px;
border-radius: 4px;
background-color: #f5f7fa;
}
.vital-label {
font-size: 14px;
color: #606266;
margin-bottom: 8px;
}
.vital-value {
font-size: 24px;
font-weight: bold;
}
.vital-value.good {
color: #67c23a;
}
.vital-value.needs-improvement {
color: #e6a23c;
}
.vital-value.poor {
color: #f56c6c;
}
.error-container,
.api-container {
padding: 10px 0;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
max-height: 200px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<div class="resource-preloader" v-if="isLoading">
<div class="loading-indicator">
<div class="spinner"></div>
<p class="loading-text">正在加载资源...</p>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
</div>
<p class="loading-progress">{{ loadedResources }}/{{ totalResources }} 资源已加载</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { preloadCriticalResources } from '../utils/resourcePreloader'
const isLoading = ref(true)
const progress = ref(0)
const loadedResources = ref(0)
const totalResources = ref(0)
const preloadedResources = ref(new Set())
// 预加载关键资源
const preloadResources = async () => {
try {
// 定义关键资源列表
const criticalResources = [
// 关键图片
{
url: '/images/logo.png',
type: 'image',
priority: 'high'
},
// 关键字体
{
url: '/fonts/main-font.woff2',
type: 'font',
priority: 'high'
},
// 关键CSS
{
url: '/css/critical.css',
type: 'style',
priority: 'high'
},
// 关键JS
{
url: '/js/critical.js',
type: 'script',
priority: 'high'
}
]
totalResources.value = criticalResources.length
// 预加载资源
const results = await preloadCriticalResources(criticalResources, (loaded, total) => {
loadedResources.value = loaded
progress.value = Math.round((loaded / total) * 100)
})
// 记录预加载结果
results.forEach(result => {
if (result.success) {
preloadedResources.value.add(result.url)
}
})
// 延迟一点时间让用户看到100%进度
await new Promise(resolve => setTimeout(resolve, 300))
isLoading.value = false
} catch (error) {
console.error('资源预加载失败:', error)
// 即使预加载失败,也要隐藏加载界面
isLoading.value = false
}
}
// 监听资源加载完成事件
const handleResourceLoad = (event) => {
if (event.target && event.target.src) {
preloadedResources.value.add(event.target.src)
}
}
onMounted(() => {
// 监听资源加载事件
window.addEventListener('load', handleResourceLoad)
// 开始预加载资源
preloadResources()
})
onUnmounted(() => {
window.removeEventListener('load', handleResourceLoad)
})
</script>
<style scoped>
.resource-preloader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.95);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
backdrop-filter: blur(5px);
}
.loading-indicator {
text-align: center;
max-width: 300px;
width: 80%;
}
.spinner {
width: 40px;
height: 40px;
margin: 0 auto 20px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #409eff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
margin: 0 0 15px;
font-size: 16px;
color: #333;
font-weight: 500;
}
.progress-bar {
width: 100%;
height: 6px;
background-color: #e4e7ed;
border-radius: 3px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background-color: #409eff;
border-radius: 3px;
transition: width 0.3s ease;
}
.loading-progress {
margin: 0;
font-size: 12px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<form
class="secure-form"
:class="{ 'secure-form--loading': loading }"
@submit.prevent="handleSubmit"
>
<slot></slot>
<div class="secure-form__actions" v-if="showActions">
<el-button
v-if="showReset"
type="default"
:disabled="loading"
@click="handleReset"
>
{{ resetText }}
</el-button>
<el-button
type="primary"
native-type="submit"
:loading="loading"
:disabled="!isValid || loading"
>
{{ submitText }}
</el-button>
</div>
<div v-if="showSecurityInfo" class="secure-form__security-info">
<el-alert
title="安全提示"
type="info"
:closable="false"
show-icon
>
<template #default>
<p>您的数据将通过加密连接传输并受到严格的安全保护</p>
<p v-if="hasSensitiveFields">此表单包含敏感信息提交后将被加密存储</p>
</template>
</el-alert>
</div>
</form>
</template>
<script setup>
import { ref, computed, provide, watch, onMounted, onUnmounted } from 'vue'
import { ElButton, ElAlert, ElMessage } from 'element-plus'
import { encryptData, generateCSRFToken } from '@/utils/security'
import { logSecurityEvent } from '@/middleware/security'
const props = defineProps({
modelValue: {
type: Object,
default: () => ({})
},
validationRules: {
type: Object,
default: () => ({})
},
encryptFields: {
type: Array,
default: () => []
},
submitText: {
type: String,
default: '提交'
},
resetText: {
type: String,
default: '重置'
},
showActions: {
type: Boolean,
default: true
},
showReset: {
type: Boolean,
default: true
},
showSecurityInfo: {
type: Boolean,
default: false
},
encryptBeforeSubmit: {
type: Boolean,
default: true
},
validateOnSubmit: {
type: Boolean,
default: true
},
resetOnSubmit: {
type: Boolean,
default: false
},
csrfProtection: {
type: Boolean,
default: true
},
submitUrl: {
type: String,
default: ''
},
submitMethod: {
type: String,
default: 'POST',
validator: (value) => ['POST', 'PUT', 'PATCH'].includes(value.toUpperCase())
}
})
const emit = defineEmits(['update:modelValue', 'submit', 'reset', 'validation-error', 'submit-success', 'submit-error'])
const formData = ref({ ...props.modelValue })
const formErrors = ref({})
const loading = ref(false)
const fields = ref([])
// 计算表单是否有效
const isValid = computed(() => {
return Object.keys(formErrors.value).length === 0
})
// 计算是否包含敏感字段
const hasSensitiveFields = computed(() => {
return props.encryptFields.length > 0
})
// 注册表单字段
const registerField = (field) => {
if (!fields.value.includes(field)) {
fields.value.push(field)
}
}
// 注销表单字段
const unregisterField = (field) => {
const index = fields.value.indexOf(field)
if (index > -1) {
fields.value.splice(index, 1)
}
}
// 设置字段错误
const setFieldError = (field, error) => {
if (error) {
formErrors.value[field] = error
} else {
delete formErrors.value[field]
}
}
// 清除所有错误
const clearErrors = () => {
formErrors.value = {}
}
// 验证表单
const validateForm = () => {
clearErrors()
let isValid = true
// 验证每个字段
for (const field in props.validationRules) {
const rules = props.validationRules[field]
const value = formData.value[field]
for (const rule of rules) {
if (rule.required && (!value || value.toString().trim() === '')) {
setFieldError(field, rule.message || `${field}是必填项`)
isValid = false
break
}
if (rule.pattern && value && !rule.pattern.test(value)) {
setFieldError(field, rule.message || `${field}格式不正确`)
isValid = false
break
}
if (rule.min && value && value.length < rule.min) {
setFieldError(field, rule.message || `${field}长度不能少于${rule.min}个字符`)
isValid = false
break
}
if (rule.max && value && value.length > rule.max) {
setFieldError(field, rule.message || `${field}长度不能超过${rule.max}个字符`)
isValid = false
break
}
if (rule.validator && value) {
const result = rule.validator(value)
if (result !== true) {
setFieldError(field, result || `${field}验证失败`)
isValid = false
break
}
}
}
}
return isValid
}
// 加密敏感字段
const encryptSensitiveFields = async (data) => {
const encryptedData = { ...data }
for (const field of props.encryptFields) {
if (encryptedData[field]) {
try {
encryptedData[field] = await encryptData(encryptedData[field])
} catch (error) {
console.error(`加密字段${field}失败:`, error)
throw new Error(`数据加密失败: ${error.message}`)
}
}
}
return encryptedData
}
// 处理表单提交
const handleSubmit = async () => {
// 记录表单提交事件
logSecurityEvent('form_submit_attempt', {
formId: props.id || 'unknown',
hasSensitiveFields: hasSensitiveFields.value,
timestamp: new Date().toISOString()
})
// 验证表单
if (props.validateOnSubmit && !validateForm()) {
emit('validation-error', formErrors.value)
return
}
loading.value = true
try {
let dataToSubmit = { ...formData.value }
// 加密敏感字段
if (props.encryptBeforeSubmit && hasSensitiveFields.value) {
dataToSubmit = await encryptSensitiveFields(dataToSubmit)
}
// 添加CSRF令牌
if (props.csrfProtection) {
dataToSubmit._csrf = generateCSRFToken()
}
// 如果提供了提交URL则直接提交
if (props.submitUrl) {
const response = await fetch(props.submitUrl, {
method: props.submitMethod,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(dataToSubmit)
})
if (!response.ok) {
throw new Error(`提交失败: ${response.statusText}`)
}
const result = await response.json()
// 记录提交成功事件
logSecurityEvent('form_submit_success', {
formId: props.id || 'unknown',
timestamp: new Date().toISOString()
})
emit('submit-success', result)
// 重置表单
if (props.resetOnSubmit) {
handleReset()
}
} else {
// 否则触发提交事件
emit('submit', dataToSubmit)
}
} catch (error) {
console.error('表单提交失败:', error)
// 记录提交失败事件
logSecurityEvent('form_submit_error', {
formId: props.id || 'unknown',
error: error.message,
timestamp: new Date().toISOString()
})
ElMessage.error(error.message || '提交失败,请稍后重试')
emit('submit-error', error)
} finally {
loading.value = false
}
}
// 处理表单重置
const handleReset = () => {
formData.value = { ...props.modelValue }
clearErrors()
emit('reset')
}
// 更新表单数据
const updateFormData = (field, value) => {
formData.value[field] = value
emit('update:modelValue', formData.value)
// 清除该字段的错误
if (formErrors.value[field]) {
setFieldError(field, null)
}
}
// 提供给子组件的方法和数据
provide('secureForm', {
formData,
formErrors,
registerField,
unregisterField,
setFieldError,
updateFormData,
loading
})
// 监听modelValue变化
watch(() => props.modelValue, (newVal) => {
formData.value = { ...newVal }
}, { deep: true })
// 暴露方法
defineExpose({
validateForm,
clearErrors,
handleReset,
formData,
formErrors
})
</script>
<style scoped>
.secure-form {
width: 100%;
}
.secure-form--loading {
position: relative;
}
.secure-form--loading::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.7);
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
.secure-form__actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.secure-form__security-info {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,442 @@
<template>
<div class="secure-input" :class="{ 'has-error': error }">
<label v-if="label" class="secure-input__label" :for="inputId">
{{ label }}
<span v-if="required" class="secure-input__required">*</span>
</label>
<div class="secure-input__wrapper">
<input
:id="inputId"
ref="inputRef"
v-model="inputValue"
:type="showPassword ? 'text' : type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxLength"
:autocomplete="autocomplete"
:class="inputClasses"
@input="handleInput"
@blur="handleBlur"
@focus="handleFocus"
@keyup.enter="handleEnter"
/>
<button
v-if="type === 'password' && showToggle"
type="button"
class="secure-input__toggle"
@click="togglePasswordVisibility"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
>
<el-icon v-if="showPassword"><Hide /></el-icon>
<el-icon v-else><View /></el-icon>
</button>
<button
v-if="clearable && inputValue && !disabled && !readonly"
type="button"
class="secure-input__clear"
@click="clearInput"
aria-label="清除输入"
>
<el-icon><Close /></el-icon>
</button>
<div v-if="strengthIndicator && type === 'password'" class="secure-input__strength">
<div class="secure-input__strength-bar">
<div
class="secure-input__strength-fill"
:class="passwordStrengthClass"
:style="{ width: `${passwordStrengthPercentage}%` }"
></div>
</div>
<span class="secure-input__strength-text">{{ passwordStrengthText }}</span>
</div>
</div>
<div v-if="error" class="secure-input__error">{{ error }}</div>
<div v-else-if="hint" class="secure-input__hint">{{ hint }}</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { View, Hide, Close } from '@element-plus/icons-vue'
import { validatePassword, sanitizeInput } from '@/utils/security'
import { ElMessage } from 'element-plus'
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
type: {
type: String,
default: 'text',
validator: (value) => ['text', 'password', 'email', 'tel', 'url', 'number'].includes(value)
},
label: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
required: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
clearable: {
type: Boolean,
default: false
},
showToggle: {
type: Boolean,
default: true
},
strengthIndicator: {
type: Boolean,
default: false
},
maxLength: {
type: Number,
default: 255
},
autocomplete: {
type: String,
default: 'off'
},
validateOnBlur: {
type: Boolean,
default: true
},
sanitize: {
type: Boolean,
default: true
},
error: {
type: String,
default: ''
},
hint: {
type: String,
default: ''
},
size: {
type: String,
default: 'default',
validator: (value) => ['small', 'default', 'large'].includes(value)
}
})
const emit = defineEmits(['update:modelValue', 'input', 'blur', 'focus', 'enter', 'clear', 'strength-change'])
const inputRef = ref(null)
const inputValue = ref(props.modelValue)
const showPassword = ref(false)
const isFocused = ref(false)
const passwordStrength = ref(0)
// 生成唯一ID
const inputId = computed(() => `secure-input-${Math.random().toString(36).substring(2, 9)}`)
// 计算输入框样式类
const inputClasses = computed(() => {
return [
'secure-input__field',
`secure-input__field--${props.size}`,
{
'secure-input__field--focused': isFocused.value,
'secure-input__field--disabled': props.disabled,
'secure-input__field--readonly': props.readonly,
'secure-input__field--has-error': props.error,
'secure-input__field--password': props.type === 'password'
}
]
})
// 计算密码强度类
const passwordStrengthClass = computed(() => {
if (passwordStrength.value <= 2) return 'weak'
if (passwordStrength.value <= 3) return 'medium'
return 'strong'
})
// 计算密码强度百分比
const passwordStrengthPercentage = computed(() => {
return Math.min(passwordStrength.value * 20, 100)
})
// 计算密码强度文本
const passwordStrengthText = computed(() => {
if (passwordStrength.value <= 2) return '弱'
if (passwordStrength.value <= 3) return '中等'
return '强'
})
// 监听modelValue变化
watch(() => props.modelValue, (newVal) => {
inputValue.value = newVal
})
// 监听输入值变化
watch(inputValue, (newVal) => {
// 如果启用了输入清理,则清理输入
const processedValue = props.sanitize ? sanitizeInput(newVal) : newVal
// 更新密码强度仅当类型为password时
if (props.type === 'password' && props.strengthIndicator) {
updatePasswordStrength(processedValue)
}
// 触发更新事件
emit('update:modelValue', processedValue)
emit('input', processedValue)
})
// 更新密码强度
const updatePasswordStrength = (password) => {
if (!password) {
passwordStrength.value = 0
return
}
try {
const result = validatePassword(password)
passwordStrength.value = result.score
emit('strength-change', result)
} catch (error) {
console.error('密码强度验证失败:', error)
passwordStrength.value = 0
}
}
// 处理输入事件
const handleInput = (event) => {
// 输入事件已在watch中处理
}
// 处理焦点事件
const handleFocus = (event) => {
isFocused.value = true
emit('focus', event)
}
// 处理失焦事件
const handleBlur = (event) => {
isFocused.value = false
emit('blur', event)
}
// 处理回车事件
const handleEnter = (event) => {
emit('enter', event)
}
// 切换密码可见性
const togglePasswordVisibility = () => {
showPassword.value = !showPassword.value
}
// 清除输入
const clearInput = () => {
inputValue.value = ''
emit('clear')
nextTick(() => {
inputRef.value?.focus()
})
}
// 聚焦输入框
const focus = () => {
inputRef.value?.focus()
}
// 失焦输入框
const blur = () => {
inputRef.value?.blur()
}
// 选择文本
const select = () => {
inputRef.value?.select()
}
// 暴露方法
defineExpose({
focus,
blur,
select,
inputRef
})
onMounted(() => {
// 初始化密码强度
if (props.type === 'password' && props.strengthIndicator && inputValue.value) {
updatePasswordStrength(inputValue.value)
}
})
</script>
<style scoped>
.secure-input {
width: 100%;
margin-bottom: 16px;
}
.secure-input__label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.secure-input__required {
color: #f56c6c;
margin-left: 4px;
}
.secure-input__wrapper {
position: relative;
width: 100%;
}
.secure-input__field {
width: 100%;
padding: 12px 16px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
background-color: #fff;
transition: border-color 0.2s;
outline: none;
box-sizing: border-box;
}
.secure-input__field:focus {
border-color: #409eff;
}
.secure-input__field--small {
padding: 8px 12px;
font-size: 12px;
}
.secure-input__field--large {
padding: 16px 20px;
font-size: 16px;
}
.secure-input__field--disabled {
background-color: #f5f7fa;
color: #c0c4cc;
cursor: not-allowed;
}
.secure-input__field--readonly {
background-color: #f5f7fa;
color: #606266;
cursor: default;
}
.secure-input__field--has-error {
border-color: #f56c6c;
}
.secure-input__field--password {
padding-right: 80px;
}
.secure-input__toggle,
.secure-input__clear {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: #909399;
font-size: 16px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.secure-input__toggle:hover,
.secure-input__clear:hover {
color: #409eff;
}
.secure-input__toggle {
right: 12px;
}
.secure-input__clear {
right: 40px;
}
.secure-input__strength {
margin-top: 8px;
}
.secure-input__strength-bar {
height: 4px;
background-color: #e4e7ed;
border-radius: 2px;
overflow: hidden;
}
.secure-input__strength-fill {
height: 100%;
transition: width 0.3s, background-color 0.3s;
}
.secure-input__strength-fill.weak {
background-color: #f56c6c;
}
.secure-input__strength-fill.medium {
background-color: #e6a23c;
}
.secure-input__strength-fill.strong {
background-color: #67c23a;
}
.secure-input__strength-text {
font-size: 12px;
color: #909399;
margin-top: 4px;
display: block;
}
.secure-input__error {
color: #f56c6c;
font-size: 12px;
margin-top: 4px;
}
.secure-input__hint {
color: #909399;
font-size: 12px;
margin-top: 4px;
}
.secure-input.has-error .secure-input__field {
border-color: #f56c6c;
}
</style>

View File

@@ -0,0 +1,282 @@
/**
* 安全配置文件
* 集中管理前端安全相关的配置
*/
// 安全配置
export const securityConfig = {
// API安全配置
api: {
// 请求超时时间(毫秒)
timeout: 30000,
// 最大重试次数
maxRetries: 3,
// 重试延迟(毫秒)
retryDelay: 1000,
// 是否启用请求签名
enableRequestSigning: true,
// 是否启用响应验证
enableResponseValidation: true,
// 是否启用CSRF保护
enableCSRFProtection: true,
// CSRF令牌存储键名
csrfTokenKey: 'csrf_token',
// 敏感操作需要二次验证
sensitiveOperationsRequire2FA: true
},
// 数据加密配置
encryption: {
// 默认加密算法
algorithm: 'AES',
// 默认密钥(实际项目中应该从环境变量获取)
defaultKey: 'HardwarePerformance2023SecretKey',
// 默认向量(实际项目中应该从环境变量获取)
defaultIv: 'Hardware2023IV',
// 加密模式
mode: 'CBC',
// 填充方式
padding: 'Pkcs7',
// 是否加密敏感数据
encryptSensitiveData: true,
// 敏感数据键名模式
sensitiveDataKeyPattern: /password|token|secret|key|credential/i
},
// 存储安全配置
storage: {
// 是否加密localStorage
encryptLocalStorage: true,
// 是否加密sessionStorage
encryptSessionStorage: true,
// 是否启用安全Cookie
useSecureCookies: true,
// Cookie SameSite属性
cookieSameSite: 'Strict',
// Cookie过期时间
cookieExpirationDays: 7,
// 敏感数据存储键名
sensitiveStorageKeys: ['token', 'user', 'credentials']
},
// 内容安全策略配置
csp: {
// 是否启用CSP
enabled: true,
// 默认源
defaultSrc: "'self'",
// 脚本源
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
// 样式源
styleSrc: ["'self'", "'unsafe-inline'"],
// 图片源
imgSrc: ["'self'", "data:", "https:"],
// 连接源
connectSrc: ["'self'"],
// 字体源
fontSrc: ["'self'", "data:"],
// 对象源
objectSrc: "'none'",
// 媒体源
mediaSrc: "'self'",
// 框架源
frameSrc: "'none'",
// 表单源
formAction: "'self'",
// 基础URI
baseUri: "'self'",
// 是否启用报告
reportOnly: false,
// 报告URI
reportUri: '/api/csp-report'
},
// XSS防护配置
xss: {
// 是否启用XSS防护
enabled: true,
// 是否启用输入过滤
enableInputFiltering: true,
// 是否启用输出编码
enableOutputEncoding: true,
// 是否启用DOMPurify需要安装dompurify库
enableDOMPurify: false,
// 危险标签列表
dangerousTags: ['script', 'iframe', 'object', 'embed', 'link', 'meta', 'style'],
// 危险属性列表
dangerousAttributes: ['onload', 'onerror', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
// 允许的HTML标签
allowedTags: ['p', 'div', 'span', 'a', 'img', 'br', 'strong', 'em', 'ul', 'ol', 'li'],
// 允许的属性
allowedAttributes: ['href', 'src', 'alt', 'title', 'class', 'id', 'style']
},
// CSRF防护配置
csrf: {
// 是否启用CSRF防护
enabled: true,
// 令牌存储位置
tokenStorage: 'localStorage', // localStorage, sessionStorage, cookie
// 令牌过期时间(分钟)
tokenExpirationMinutes: 60,
// 令牌刷新阈值(分钟)
tokenRefreshThresholdMinutes: 10,
// 需要CSRF保护的HTTP方法
protectedMethods: ['POST', 'PUT', 'DELETE', 'PATCH'],
// 不需要CSRF保护的URL模式
excludedUrls: ['/api/auth/login', '/api/auth/register', '/api/csrf-token']
},
// 密码策略配置
passwordPolicy: {
// 最小长度
minLength: 8,
// 最大长度
maxLength: 128,
// 是否需要小写字母
requireLowercase: true,
// 是否需要大写字母
requireUppercase: true,
// 是否需要数字
requireNumbers: true,
// 是否需要特殊字符
requireSpecialChars: true,
// 允许的特殊字符
allowedSpecialChars: '!@#$%^&*()_+-=[]{}|;:,.<>?',
// 禁止的常见密码
forbiddenPasswords: ['password', '123456', 'qwerty', 'admin', 'letmein'],
// 密码历史记录数量(防止重复使用旧密码)
passwordHistoryCount: 5,
// 密码过期天数
passwordExpirationDays: 90
},
// 会话安全配置
session: {
// 会话超时时间(分钟)
timeoutMinutes: 30,
// 会话警告时间(分钟)
warningMinutes: 5,
// 是否启用会话心跳
enableHeartbeat: true,
// 心跳间隔(分钟)
heartbeatIntervalMinutes: 5,
// 是否启用会话锁定
enableLocking: true,
// 空闲时间(分钟)后锁定
lockAfterIdleMinutes: 10,
// 是否启用多设备登录检测
enableMultiDeviceDetection: true,
// 最大允许设备数
maxDevices: 3
},
// 安全日志配置
logging: {
// 是否启用安全日志
enabled: true,
// 日志级别
level: 'info', // debug, info, warn, error
// 是否记录到服务器
logToServer: true,
// 服务器日志端点
logEndpoint: '/api/security/log',
// 本地日志最大条数
maxLocalLogEntries: 1000,
// 日志保留天数
retentionDays: 30,
// 记录的安全事件类型
eventTypes: [
'login_attempt',
'login_success',
'login_failure',
'logout',
'session_expired',
'password_change',
'account_locked',
'suspicious_activity',
'xss_attempt',
'csrf_attempt',
'injection_attempt'
]
},
// 安全监控配置
monitoring: {
// 是否启用安全监控
enabled: true,
// 是否监控异常登录
monitorAnomalousLogin: true,
// 异常登录阈值(次)
anomalousLoginThreshold: 3,
// 是否监控暴力破解
monitorBruteForce: true,
// 暴力破解阈值(次)
bruteForceThreshold: 5,
// 暴力破解时间窗口(分钟)
bruteForceTimeWindowMinutes: 15,
// 是否监控异常API调用
monitorAnomalousApiCalls: true,
// 异常API调用阈值
anomalousApiCallsThreshold: 100,
// 异常API调用时间窗口分钟
anomalousApiCallsTimeWindowMinutes: 10,
// 是否启用实时警报
enableRealTimeAlerts: true,
// 警报端点
alertEndpoint: '/api/security/alert'
}
}
// 环境特定配置
export const getEnvironmentConfig = () => {
const env = import.meta.env.MODE || 'development'
switch (env) {
case 'development':
return {
...securityConfig,
csp: {
...securityConfig.csp,
reportOnly: true
},
logging: {
...securityConfig.logging,
level: 'debug',
logToServer: false
}
}
case 'staging':
return {
...securityConfig,
csp: {
...securityConfig.csp,
reportOnly: true
},
logging: {
...securityConfig.logging,
level: 'info'
}
}
case 'production':
return {
...securityConfig,
csp: {
...securityConfig.csp,
reportOnly: false
},
logging: {
...securityConfig.logging,
level: 'warn'
}
}
default:
return securityConfig
}
}
// 获取当前环境的安全配置
export const currentSecurityConfig = getEnvironmentConfig()

View File

@@ -6,10 +6,52 @@ import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from './router'
import App from './App.vue'
import './assets/style.css'
import monitor from './utils/monitor'
import { init as initPerformanceMetrics, recordInteraction } from './utils/performanceMetrics'
import { initPWA, checkPWAInstallable } from './utils/pwa'
import { initLazyLoad } from './utils/lazyLoad'
import { installPersistPlugin } from './plugins/piniaPersistPlugin'
// 初始化性能监控
monitor.init({
reportUrl: '/api/monitor/report', // 错误和性能数据上报地址
enablePerformance: true, // 是否启用性能监控
enableErrorTracking: true, // 是否启用错误追踪
maxErrorCount: 50, // 最大错误记录数
reportInterval: 60000 // 上报间隔(毫秒)
})
// 初始化性能指标收集
initPerformanceMetrics({
reportUrl: '/api/performance/report',
sampleRate: 1.0,
reportInterval: 30000
})
// 初始化PWA功能
initPWA()
// 检查PWA安装状态
const pwaInfo = checkPWAInstallable()
console.log('[PWA] 安装状态:', pwaInfo)
// 初始化图片懒加载
initLazyLoad({
selector: 'img[data-src]',
placeholder: '/images/placeholder.png',
threshold: 0.1,
enableNativeLazyLoad: true
})
// 记录应用启动时间
monitor.logAppStart()
const app = createApp(App)
const pinia = createPinia()
// 安装Pinia持久化插件
installPersistPlugin(pinia)
// 注册所有Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
@@ -21,6 +63,12 @@ app.config.errorHandler = (err, instance, info) => {
console.error('错误组件实例:', instance)
console.error('错误信息:', info)
// 使用监控记录错误
monitor.logError(err, {
component: instance?.$options?.name || 'Unknown',
info
})
// 使用Element Plus显示错误通知
ElNotification({
title: '应用错误',
@@ -34,6 +82,12 @@ app.config.errorHandler = (err, instance, info) => {
window.addEventListener('unhandledrejection', event => {
console.error('未处理的Promise错误:', event.reason)
// 使用监控记录错误
monitor.logError(event.reason, {
type: 'unhandled_promise_rejection',
promise: event.promise
})
ElNotification({
title: '未处理的错误',
message: `应用发生未处理的错误: ${event.reason?.message || '未知错误'}`,
@@ -49,4 +103,26 @@ app.use(pinia)
app.use(router)
app.use(ElementPlus)
// 记录路由切换性能
router.beforeEach((to, from, next) => {
// 记录路由切换开始时间
window.routeChangeStartTime = performance.now()
next()
})
router.afterEach((to) => {
// 计算路由切换耗时
if (window.routeChangeStartTime) {
recordInteraction('route-change', window.routeChangeStartTime, performance.now(), {
from: from?.path,
to: to.path
})
}
// 更新页面标题
if (to.meta?.title) {
document.title = to.meta.title
}
})
app.mount('#app')

View File

@@ -0,0 +1,511 @@
/**
* 安全中间件
* 提供前端安全防护功能
*/
import { currentSecurityConfig } from '../config/security'
import {
aesEncrypt,
aesDecrypt,
generateCSRFToken,
validateCSRFToken,
sanitizeHtml,
sanitizeInput,
secureLocalStorageSet,
secureLocalStorageGet,
secureSessionStorageSet,
secureSessionStorageGet,
enableBrowserSecurity
} from '../utils/security'
/**
* 初始化安全中间件
*/
export function initSecurityMiddleware() {
// 启用浏览器安全策略
enableBrowserSecurity()
// 初始化CSRF令牌
initCSRFToken()
// 初始化内容安全策略
initCSP()
// 初始化XSS防护
initXSSProtection()
// 初始化会话管理
initSessionManagement()
// 初始化安全监控
initSecurityMonitoring()
}
/**
* 初始化CSRF令牌
*/
function initCSRFToken() {
const config = currentSecurityConfig.csrf
if (!config.enabled) return
const tokenKey = config.tokenStorage === 'localStorage'
? 'csrf_token'
: 'csrf_token'
let token = config.tokenStorage === 'localStorage'
? secureLocalStorageGet(tokenKey)
: secureSessionStorageGet(tokenKey)
// 如果令牌不存在或已过期,生成新令牌
if (!token || isTokenExpired(token)) {
token = generateCSRFToken()
if (config.tokenStorage === 'localStorage') {
secureLocalStorageSet(tokenKey, token)
} else {
secureSessionStorageSet(tokenKey, token)
}
}
}
/**
* 检查令牌是否过期
* @param {string} token 令牌
* @returns {boolean} 是否过期
*/
function isTokenExpired(token) {
// 简单实现,实际项目中应该在令牌中包含过期时间
const storedTime = localStorage.getItem('csrf_token_time') || sessionStorage.getItem('csrf_token_time')
if (!storedTime) return true
const expirationTime = parseInt(storedTime) + (currentSecurityConfig.csrf.tokenExpirationMinutes * 60 * 1000)
return Date.now() > expirationTime
}
/**
* 获取CSRF令牌
* @returns {string} CSRF令牌
*/
export function getCSRFToken() {
const config = currentSecurityConfig.csrf
const tokenKey = 'csrf_token'
return config.tokenStorage === 'localStorage'
? secureLocalStorageGet(tokenKey)
: secureSessionStorageGet(tokenKey)
}
/**
* 验证CSRF令牌
* @param {string} token 要验证的令牌
* @returns {boolean} 是否有效
*/
export function validateCSRF(token) {
const config = currentSecurityConfig.csrf
if (!config.enabled) return true
const storedToken = getCSRFToken()
return validateCSRFToken(token, storedToken)
}
/**
* 初始化内容安全策略
*/
function initCSP() {
const config = currentSecurityConfig.csp
if (!config.enabled) return
// 构建CSP策略字符串
const cspPolicy = [
`default-src ${config.defaultSrc}`,
`script-src ${config.scriptSrc.join(' ')}`,
`style-src ${config.styleSrc.join(' ')}`,
`img-src ${config.imgSrc.join(' ')}`,
`connect-src ${config.connectSrc.join(' ')}`,
`font-src ${config.fontSrc.join(' ')}`,
`object-src ${config.objectSrc}`,
`media-src ${config.mediaSrc}`,
`frame-src ${config.frameSrc}`,
`form-action ${config.formAction}`,
`base-uri ${config.baseUri}`
].join('; ')
// 创建并添加CSP meta标签
const meta = document.createElement('meta')
meta.httpEquiv = config.reportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'
meta.content = cspPolicy
document.head.appendChild(meta)
}
/**
* 初始化XSS防护
*/
function initXSSProtection() {
const config = currentSecurityConfig.xss
if (!config.enabled) return
// 添加X-XSS-Protection头虽然现代浏览器已弃用但仍可作为后备
const meta = document.createElement('meta')
meta.httpEquiv = 'X-XSS-Protection'
meta.content = '1; mode=block'
document.head.appendChild(meta)
// 重写innerHTML和outerHTML以进行XSS过滤
if (config.enableOutputEncoding) {
const originalInnerHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')
const originalOuterHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'outerHTML')
Object.defineProperty(Element.prototype, 'innerHTML', {
set: function(value) {
if (config.enableInputFiltering) {
value = sanitizeHtml(value)
}
originalInnerHTML.set.call(this, value)
},
get: originalInnerHTML.get
})
Object.defineProperty(Element.prototype, 'outerHTML', {
set: function(value) {
if (config.enableInputFiltering) {
value = sanitizeHtml(value)
}
originalOuterHTML.set.call(this, value)
},
get: originalOuterHTML.get
})
}
}
/**
* 初始化会话管理
*/
function initSessionManagement() {
const config = currentSecurityConfig.session
if (!config.enableHeartbeat) return
// 设置会话超时警告
const warningTimeout = setTimeout(() => {
showSessionWarning()
}, (config.timeoutMinutes - config.warningMinutes) * 60 * 1000)
// 设置会话超时
const timeout = setTimeout(() => {
handleSessionTimeout()
}, config.timeoutMinutes * 60 * 1000)
// 设置心跳
if (config.enableHeartbeat) {
const heartbeatInterval = setInterval(() => {
sendHeartbeat()
}, config.heartbeatIntervalMinutes * 60 * 1000)
// 清理定时器
window.addEventListener('beforeunload', () => {
clearTimeout(warningTimeout)
clearTimeout(timeout)
clearInterval(heartbeatInterval)
})
}
}
/**
* 显示会话警告
*/
function showSessionWarning() {
const config = currentSecurityConfig.session
// 这里可以使用Element Plus的MessageBox或其他通知组件
// 为了简化这里使用浏览器原生confirm
const result = confirm(`您的会话将在${config.warningMinutes}分钟后过期,是否继续?`)
if (result) {
// 用户确认继续,重置会话
resetSession()
} else {
// 用户取消,立即登出
logout()
}
}
/**
* 处理会话超时
*/
function handleSessionTimeout() {
// 记录会话超时事件
logSecurityEvent('session_expired', { timestamp: Date.now() })
// 显示超时消息
alert('您的会话已过期,请重新登录')
// 执行登出
logout()
}
/**
* 重置会话
*/
function resetSession() {
// 发送心跳请求以重置会话
sendHeartbeat()
// 重新设置会话超时警告
const config = currentSecurityConfig.session
setTimeout(() => {
showSessionWarning()
}, (config.timeoutMinutes - config.warningMinutes) * 60 * 1000)
}
/**
* 发送心跳
*/
function sendHeartbeat() {
// 这里应该发送API请求到服务器以保持会话活跃
// 为了简化,这里只是一个示例
console.log('Sending heartbeat to keep session alive')
}
/**
* 登出
*/
function logout() {
// 清除本地存储
localStorage.clear()
sessionStorage.clear()
// 重定向到登录页面
window.location.href = '/login'
}
/**
* 初始化安全监控
*/
function initSecurityMonitoring() {
const config = currentSecurityConfig.monitoring
if (!config.enabled) return
// 监控异常登录
if (config.monitorAnomalousLogin) {
monitorAnomalousLogin()
}
// 监控暴力破解
if (config.monitorBruteForce) {
monitorBruteForce()
}
// 监控异常API调用
if (config.monitorAnomalousApiCalls) {
monitorAnomalousApiCalls()
}
}
/**
* 监控异常登录
*/
function monitorAnomalousLogin() {
// 实现异常登录监控逻辑
// 这里只是一个示例
console.log('Monitoring anomalous login attempts')
}
/**
* 监控暴力破解
*/
function monitorBruteForce() {
// 实现暴力破解监控逻辑
// 这里只是一个示例
console.log('Monitoring brute force attempts')
}
/**
* 监控异常API调用
*/
function monitorAnomalousApiCalls() {
// 实现异常API调用监控逻辑
// 这里只是一个示例
console.log('Monitoring anomalous API calls')
}
/**
* 记录安全事件
* @param {string} eventType 事件类型
* @param {object} eventData 事件数据
*/
export function logSecurityEvent(eventType, eventData = {}) {
const config = currentSecurityConfig.logging
if (!config.enabled || !config.eventTypes.includes(eventType)) return
const event = {
type: eventType,
timestamp: Date.now(),
userAgent: navigator.userAgent,
url: window.location.href,
...eventData
}
// 记录到本地存储
const logs = secureLocalStorageGet('security_logs', false, [])
logs.push(event)
// 限制日志数量
if (logs.length > config.maxLocalLogEntries) {
logs.splice(0, logs.length - config.maxLocalLogEntries)
}
secureLocalStorageSet('security_logs', logs, false)
// 发送到服务器
if (config.logToServer) {
sendSecurityLogToServer(event)
}
}
/**
* 发送安全日志到服务器
* @param {object} event 安全事件
*/
function sendSecurityLogToServer(event) {
// 这里应该发送API请求到服务器
// 为了简化,这里只是一个示例
console.log('Sending security log to server:', event)
}
/**
* 安全请求拦截器
* @param {object} config 请求配置
* @returns {object} 修改后的请求配置
*/
export function secureRequestInterceptor(config) {
const securityConfig = currentSecurityConfig.api
// 添加CSRF令牌
if (securityConfig.enableCSRFProtection &&
securityConfig.protectedMethods.includes(config.method?.toUpperCase())) {
const token = getCSRFToken()
if (token) {
config.headers['X-CSRF-Token'] = token
}
}
// 添加请求签名
if (securityConfig.enableRequestSigning) {
const timestamp = Date.now().toString()
const nonce = generateNonce()
const signature = generateRequestSignature(config, timestamp, nonce)
config.headers['X-Timestamp'] = timestamp
config.headers['X-Nonce'] = nonce
config.headers['X-Signature'] = signature
}
// 加密敏感数据
if (securityConfig.encryptSensitiveData && config.data) {
config.data = encryptSensitiveData(config.data)
}
return config
}
/**
* 安全响应拦截器
* @param {object} response 响应对象
* @returns {object} 修改后的响应对象
*/
export function secureResponseInterceptor(response) {
const securityConfig = currentSecurityConfig.api
// 验证响应签名
if (securityConfig.enableResponseValidation && response.headers['x-signature']) {
const isValid = validateResponseSignature(response)
if (!isValid) {
throw new Error('Invalid response signature')
}
}
// 解密敏感数据
if (securityConfig.encryptSensitiveData && response.data) {
response.data = decryptSensitiveData(response.data)
}
return response
}
/**
* 生成随机数
* @returns {string} 随机数
*/
function generateNonce() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
}
/**
* 生成请求签名
* @param {object} config 请求配置
* @param {string} timestamp 时间戳
* @param {string} nonce 随机数
* @returns {string} 签名
*/
function generateRequestSignature(config, timestamp, nonce) {
// 这里应该实现请求签名逻辑
// 为了简化,这里只是一个示例
const data = JSON.stringify(config.data || '') + timestamp + nonce
return aesEncrypt(data)
}
/**
* 验证响应签名
* @param {object} response 响应对象
* @returns {boolean} 是否有效
*/
function validateResponseSignature(response) {
// 这里应该实现响应签名验证逻辑
// 为了简化,这里只是一个示例
return true
}
/**
* 加密敏感数据
* @param {object} data 数据对象
* @returns {object} 加密后的数据对象
*/
function encryptSensitiveData(data) {
const config = currentSecurityConfig.encryption
const encryptedData = { ...data }
for (const key in encryptedData) {
if (config.sensitiveDataKeyPattern.test(key)) {
encryptedData[key] = aesEncrypt(JSON.stringify(encryptedData[key]))
}
}
return encryptedData
}
/**
* 解密敏感数据
* @param {object} data 数据对象
* @returns {object} 解密后的数据对象
*/
function decryptSensitiveData(data) {
const config = currentSecurityConfig.encryption
const decryptedData = { ...data }
for (const key in decryptedData) {
if (config.sensitiveDataKeyPattern.test(key)) {
try {
decryptedData[key] = JSON.parse(aesDecrypt(decryptedData[key]))
} catch (error) {
console.error(`Failed to decrypt sensitive data for key: ${key}`, error)
}
}
}
return decryptedData
}

View File

@@ -0,0 +1,23 @@
/**
* Pinia持久化插件
* 集成到主应用中自动为所有store添加持久化功能
*/
import { createPersistPlugin } from '../utils/piniaPersist'
/**
* 安装Pinia持久化插件
* @param {Object} pinia Pinia实例
*/
export const installPersistPlugin = (pinia) => {
// 为所有store添加持久化功能
pinia.use(({ store, options }) => {
// 如果store已经配置了persist选项则应用持久化插件
if (options.persist) {
const persistPlugin = createPersistPlugin(options.persist)
return persistPlugin({ store, options })
}
})
}
export default installPersistPlugin

View File

@@ -1,46 +1,147 @@
import { createRouter, createWebHistory } from 'vue-router'
import monitor from '@/utils/monitor'
import { preloadNextRoutes, smartPreload } from '@/utils/routePreloader'
import { dynamicImport, preloadComponent, batchPreloadComponents } from '@/utils/dynamicImport'
import { globalBeforeGuard, globalAfterGuard } from './securityGuards'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue'),
component: () => dynamicImport(
() => import('../views/Home.vue'),
'home-page',
{ timeout: 5000 }
),
meta: {
title: '硬件性能排行榜 - 首页'
title: '硬件性能排行榜 - 首页',
securityLevel: 'low'
}
},
{
path: '/category/:id',
name: 'CategoryRanking',
component: () => import('../views/CategoryRanking.vue'),
component: () => dynamicImport(
() => import('../views/CategoryRanking.vue'),
'category-ranking-page',
{ timeout: 5000 }
),
props: true,
meta: {
title: '硬件性能排行榜 - 类别排名'
title: '硬件性能排行榜 - 类别排名',
securityLevel: 'low'
}
},
{
path: '/product/:id',
name: 'ProductDetail',
component: () => import('../views/ProductDetail.vue'),
component: () => dynamicImport(
() => import('../views/ProductDetail.vue'),
'product-detail-page',
{ timeout: 5000 }
),
props: true,
meta: {
title: '硬件性能排行榜 - 产品详情'
title: '硬件性能排行榜 - 产品详情',
securityLevel: 'low'
}
},
{
path: '/compare',
name: 'ProductComparison',
component: () => import('../views/ProductComparison.vue'),
component: () => dynamicImport(
() => import('../views/ProductComparison.vue'),
'product-comparison-page',
{ timeout: 5000 }
),
meta: {
title: '硬件性能排行榜 - 产品对比'
title: '硬件性能排行榜 - 产品对比',
securityLevel: 'low'
}
},
{
path: '/monitor',
name: 'PerformanceMonitor',
component: () => dynamicImport(
() => import('../views/PerformanceMonitor.vue'),
'performance-monitor-page',
{ timeout: 5000 }
),
meta: {
title: '硬件性能排行榜 - 性能监控',
securityLevel: 'medium',
requiresAuth: true
}
},
{
path: '/cache-management',
name: 'CacheManagement',
component: () => dynamicImport(
() => import('../views/CacheManagement.vue'),
'cache-management-page',
{ timeout: 5000 }
),
meta: {
title: '硬件性能排行榜 - 缓存管理',
securityLevel: 'medium',
requiresAuth: true
}
},
{
path: '/login',
name: 'Login',
component: () => dynamicImport(
() => import('../views/Login.vue'),
'login-page',
{ timeout: 5000 }
),
meta: {
title: '硬件性能排行榜 - 登录',
securityLevel: 'medium',
requiresSecurityCheck: true
}
},
{
path: '/profile',
name: 'UserProfile',
component: () => dynamicImport(
() => import('../views/UserProfile.vue'),
'user-profile-page',
{ timeout: 5000 }
),
meta: {
title: '硬件性能排行榜 - 个人资料',
securityLevel: 'high',
requiresAuth: true
}
},
{
path: '/admin',
name: 'AdminDashboard',
component: () => dynamicImport(
() => import('../views/AdminDashboard.vue'),
'admin-dashboard-page',
{ timeout: 5000 }
),
meta: {
title: '硬件性能排行榜 - 管理后台',
securityLevel: 'critical',
requiresAuth: true,
requiresAdmin: true,
roles: ['admin', 'superadmin']
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../views/NotFound.vue'),
component: () => dynamicImport(
() => import('../views/NotFound.vue'),
'not-found-page',
{ timeout: 3000 }
),
meta: {
title: '硬件性能排行榜 - 页面未找到'
title: '硬件性能排行榜 - 页面未找到',
securityLevel: 'low'
}
}
]
@@ -58,13 +159,47 @@ const router = createRouter({
}
})
// 路由前置守卫,用于设置页面标题
router.beforeEach((to, from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = to.meta.title
}
next()
// 路由前置守卫,用于设置页面标题和安全检查
router.beforeEach(globalBeforeGuard)
// 路由后置守卫,用于记录路由切换性能和安全日志
router.afterEach(globalAfterGuard)
// 初始化路由
router.isReady().then(() => {
// 设置智能预加载
smartPreload(router)
// 预加载关键组件
batchPreloadComponents([
{
importFn: () => import('@/views/Home.vue'),
cacheKey: 'home-page',
options: { delay: 2000 }
},
{
importFn: () => import('@/views/CategoryRanking.vue'),
cacheKey: 'category-ranking-page',
options: { delay: 3000 }
},
{
importFn: () => import('@/components/Header.vue'),
cacheKey: 'header-component',
options: { delay: 1000 }
},
{
importFn: () => import('@/components/Footer.vue'),
cacheKey: 'footer-component',
options: { delay: 1000 }
}
])
// 记录路由初始化完成
monitor.logPerformance({
name: 'router-ready',
value: performance.now() - window.performanceStartTime,
operation: '路由初始化'
})
})
export default router

View File

@@ -0,0 +1,349 @@
import { logSecurityEvent, validateSession } from '@/middleware/security'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/userStore'
/**
* 安全路由守卫
* 提供路由级别的安全检查和访问控制
*/
/**
* 全局前置守卫
* 在路由导航前执行安全检查
* @param {Object} to - 目标路由对象
* @param {Object} from - 来源路由对象
* @param {Function} next - 导航函数
*/
export const globalBeforeGuard = async (to, from, next) => {
// 记录路由导航事件
logSecurityEvent('route_navigation', {
from: from.path,
to: to.path,
timestamp: new Date().toISOString()
})
// 检查路由是否需要认证
if (to.meta.requiresAuth) {
const userStore = useUserStore()
// 验证用户是否已登录
if (!userStore.isAuthenticated) {
logSecurityEvent('unauthorized_access_attempt', {
path: to.path,
timestamp: new Date().toISOString()
})
ElMessage.error('请先登录后再访问此页面')
next('/login')
return
}
// 验证会话是否有效
try {
const isValid = await validateSession()
if (!isValid) {
logSecurityEvent('session_expired', {
path: to.path,
timestamp: new Date().toISOString()
})
ElMessage.error('登录已过期,请重新登录')
userStore.logout()
next('/login')
return
}
} catch (error) {
logSecurityEvent('session_validation_error', {
path: to.path,
error: error.message,
timestamp: new Date().toISOString()
})
ElMessage.error('会话验证失败,请重新登录')
userStore.logout()
next('/login')
return
}
}
// 检查路由权限
if (to.meta.roles && to.meta.roles.length > 0) {
const userStore = useUserStore()
if (!userStore.hasAnyRole(to.meta.roles)) {
logSecurityEvent('insufficient_permissions', {
path: to.path,
requiredRoles: to.meta.roles,
userRoles: userStore.roles,
timestamp: new Date().toISOString()
})
ElMessage.error('您没有权限访问此页面')
next('/403')
return
}
}
// 检查路由是否需要特定权限
if (to.meta.permissions && to.meta.permissions.length > 0) {
const userStore = useUserStore()
if (!userStore.hasAnyPermission(to.meta.permissions)) {
logSecurityEvent('insufficient_permissions', {
path: to.path,
requiredPermissions: to.meta.permissions,
userPermissions: userStore.permissions,
timestamp: new Date().toISOString()
})
ElMessage.error('您没有权限访问此页面')
next('/403')
return
}
}
// 检查路由是否需要安全验证
if (to.meta.requiresSecurityCheck) {
// 这里可以添加额外的安全检查逻辑
// 例如:检查设备指纹、地理位置等
try {
// 示例:检查用户是否在允许的地理位置
const isAllowedLocation = await checkUserLocation()
if (!isAllowedLocation) {
logSecurityEvent('location_blocked', {
path: to.path,
timestamp: new Date().toISOString()
})
ElMessage.error('您的当前位置不允许访问此页面')
next('/403')
return
}
} catch (error) {
logSecurityEvent('security_check_error', {
path: to.path,
error: error.message,
timestamp: new Date().toISOString()
})
ElMessage.error('安全验证失败,请稍后再试')
next(from.path)
return
}
}
// 检查路由是否需要管理员权限
if (to.meta.requiresAdmin) {
const userStore = useUserStore()
if (!userStore.isAdmin) {
logSecurityEvent('admin_access_denied', {
path: to.path,
timestamp: new Date().toISOString()
})
ElMessage.error('需要管理员权限才能访问此页面')
next('/403')
return
}
}
// 所有安全检查通过,继续导航
next()
}
/**
* 全局后置守卫
* 在路由导航完成后执行安全日志记录
* @param {Object} to - 目标路由对象
* @param {Object} from - 来源路由对象
*/
export const globalAfterGuard = (to, from) => {
// 记录成功导航事件
logSecurityEvent('route_navigation_success', {
from: from.path,
to: to.path,
timestamp: new Date().toISOString()
})
// 更新页面访问统计
updatePageAccessStats(to.path)
}
/**
* 检查用户地理位置是否允许访问
* @returns {Promise<boolean>} 是否允许访问
*/
const checkUserLocation = async () => {
// 这里可以实现地理位置检查逻辑
// 例如使用IP地理位置API或浏览器地理位置API
try {
// 示例使用浏览器地理位置API
return new Promise((resolve) => {
if (!navigator.geolocation) {
// 如果浏览器不支持地理位置API默认允许访问
resolve(true)
return
}
navigator.geolocation.getCurrentPosition(
(position) => {
// 这里可以添加地理位置验证逻辑
// 例如:检查是否在允许的国家/地区
resolve(true)
},
(error) => {
// 如果用户拒绝提供地理位置或获取失败,默认允许访问
resolve(true)
}
)
})
} catch (error) {
// 如果发生错误,默认允许访问
return true
}
}
/**
* 更新页面访问统计
* @param {string} path - 页面路径
*/
const updatePageAccessStats = (path) => {
try {
// 获取当前访问统计
const stats = JSON.parse(localStorage.getItem('pageAccessStats') || '{}')
// 更新当前页面的访问次数
if (!stats[path]) {
stats[path] = {
count: 0,
firstAccess: new Date().toISOString(),
lastAccess: new Date().toISOString()
}
}
stats[path].count += 1
stats[path].lastAccess = new Date().toISOString()
// 保存更新后的统计
localStorage.setItem('pageAccessStats', JSON.stringify(stats))
} catch (error) {
console.error('Failed to update page access stats:', error)
}
}
/**
* 获取页面访问统计
* @returns {Object} 页面访问统计
*/
export const getPageAccessStats = () => {
try {
return JSON.parse(localStorage.getItem('pageAccessStats') || '{}')
} catch (error) {
console.error('Failed to get page access stats:', error)
return {}
}
}
/**
* 清除页面访问统计
*/
export const clearPageAccessStats = () => {
try {
localStorage.removeItem('pageAccessStats')
} catch (error) {
console.error('Failed to clear page access stats:', error)
}
}
/**
* 检查路由是否安全
* @param {Object} route - 路由对象
* @returns {boolean} 路由是否安全
*/
export const isRouteSecure = (route) => {
// 检查路由是否使用HTTPS
if (process.env.NODE_ENV === 'production' && window.location.protocol !== 'https:') {
return false
}
// 检查路由是否在允许的路径列表中
const allowedPaths = [
'/',
'/home',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/404',
'/403',
'/500'
]
// 如果路由在允许的路径列表中,则认为是安全的
if (allowedPaths.includes(route.path)) {
return true
}
// 检查路由是否符合安全模式
const securePatterns = [
/^\/products\/?$/,
/^\/products\/category\/[\w-]+\/?$/,
/^\/products\/[\w-]+\/?$/,
/^\/comparison\/?$/,
/^\/about\/?$/,
/^\/contact\/?$/,
/^\/privacy\/?$/,
/^\/terms\/?$/,
/^\/help\/?$/,
/^\/dashboard\/?$/,
/^\/profile\/?$/,
/^\/settings\/?$/
]
return securePatterns.some(pattern => pattern.test(route.path))
}
/**
* 获取路由安全级别
* @param {Object} route - 路由对象
* @returns {string} 安全级别low, medium, high
*/
export const getRouteSecurityLevel = (route) => {
// 公开页面 - 低安全级别
const lowSecurityRoutes = ['/', '/home', '/about', '/contact', '/privacy', '/terms', '/help']
if (lowSecurityRoutes.includes(route.path)) {
return 'low'
}
// 认证页面 - 中安全级别
const mediumSecurityRoutes = ['/login', '/register', '/forgot-password', '/reset-password']
if (mediumSecurityRoutes.includes(route.path)) {
return 'medium'
}
// 用户页面 - 高安全级别
const highSecurityRoutes = ['/dashboard', '/profile', '/settings']
if (highSecurityRoutes.some(path => route.path.startsWith(path))) {
return 'high'
}
// 管理员页面 - 最高安全级别
if (route.path.startsWith('/admin')) {
return 'critical'
}
// 默认安全级别
return 'medium'
}
export default {
globalBeforeGuard,
globalAfterGuard,
getPageAccessStats,
clearPageAccessStats,
isRouteSecure,
getRouteSecurityLevel
}

View File

@@ -1,42 +1,112 @@
import axios from 'axios'
import { ElMessage, ElNotification } from 'element-plus'
import { useLoadingStore } from '../stores/loadingStore'
import { processApiResponse } from '@/utils/dataValidator'
import { getCache, setCache, updateCacheStats } from '@/utils/cacheManager'
import { addRetryInterceptor } from '@/utils/retryManager'
import monitor from '@/utils/monitor'
import {
initSecurityMiddleware,
secureRequestInterceptor,
secureResponseInterceptor,
logSecurityEvent
} from '../middleware/security'
// 创建axios实例
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5172/api',
timeout: 10000,
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://localhost:7001/api',
timeout: 15000,
headers: {
'Content-Type': 'application/json'
}
})
// 初始化安全中间件
initSecurityMiddleware(api)
// 添加重试拦截器
addRetryInterceptor(api, {
maxRetries: 2,
retryDelay: 1000,
operation: 'API请求'
})
// 重试配置
const maxRetry = 2
const retryDelay = 1000
// 请求取消控制器存储
const pendingRequests = new Map()
// 生成请求的唯一键
const generateRequestKey = (config) => {
const { method, url, params, data } = config
return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
}
// 取消重复请求
const cancelPendingRequest = (config) => {
const requestKey = generateRequestKey(config)
if (pendingRequests.has(requestKey)) {
const cancel = pendingRequests.get(requestKey)
cancel('取消重复请求')
pendingRequests.delete(requestKey)
}
}
// 请求拦截器
api.interceptors.request.use(
config => {
// 记录请求开始时间
config.requestStartTime = Date.now()
// 应用安全中间件
const secureConfig = secureRequestInterceptor(config)
// 取消重复请求
cancelPendingRequest(secureConfig)
// 创建取消令牌
const controller = new AbortController()
secureConfig.signal = controller.signal
// 存储取消函数
const requestKey = generateRequestKey(secureConfig)
pendingRequests.set(requestKey, () => controller.abort())
// 检查缓存仅对GET请求
if (secureConfig.method?.toLowerCase() === 'get') {
const cachedData = getCache(secureConfig.url, secureConfig.params)
if (cachedData) {
updateCacheStats(true)
// 标记请求为缓存命中,这样响应拦截器可以识别并直接返回缓存
secureConfig.fromCache = true
secureConfig.cachedData = cachedData
} else {
updateCacheStats(false)
}
}
// 添加请求时间戳,防止缓存
if (config.method === 'get') {
config.params = {
...config.params,
if (secureConfig.method === 'get') {
secureConfig.params = {
...secureConfig.params,
_t: Date.now()
}
}
// 添加请求ID用于跟踪
config.metadata = { startTime: new Date() }
secureConfig.metadata = { startTime: new Date() }
// 显示全局加载状态(仅对非静默请求)
if (!config.silent) {
if (!secureConfig.silent) {
const loadingStore = useLoadingStore()
loadingStore.showLoading(config.loadingText || '加载中...')
loadingStore.showLoading(secureConfig.loadingText || '加载中...')
}
// 在发送请求之前做些什么
return config
return secureConfig
},
error => {
// 对请求错误做些什么
@@ -48,22 +118,104 @@ api.interceptors.request.use(
// 响应拦截器
api.interceptors.response.use(
response => {
// 计算请求耗时
const requestTime = Date.now() - response.config.requestStartTime
// 应用安全中间件
const secureResponse = secureResponseInterceptor(response)
// 记录API请求性能
monitor.logApiRequest({
url: response.config.url,
method: response.config.method,
status: response.status,
duration: requestTime,
success: true
})
// 请求完成后从pendingRequests中移除
const requestKey = generateRequestKey(response.config)
pendingRequests.delete(requestKey)
// 隐藏全局加载状态(仅对非静默请求)
if (!response.config.silent) {
const loadingStore = useLoadingStore()
loadingStore.hideLoading()
}
// 对响应数据做点什么
return response.data
// 如果是缓存命中的请求,直接返回缓存数据
if (response.config.fromCache) {
return {
...response,
data: response.config.cachedData,
fromCache: true
}
}
// 处理API响应数据
const processedResponse = processApiResponse(secureResponse.data, response.config.dataType)
if (!processedResponse.success) {
// 如果验证失败,抛出错误
const error = new Error(processedResponse.error || '数据验证失败')
error.isValidationError = true
return Promise.reject(error)
}
// 缓存GET请求的响应仅对成功响应
if (response.config.method?.toLowerCase() === 'get' && processedResponse.data) {
setCache(response.config.url, response.config.params, processedResponse.data)
}
// 返回处理后的数据
return {
...response,
data: processedResponse.data,
fromCache: false
}
},
async error => {
// 计算请求耗时
const requestTime = error.config ? Date.now() - error.config.requestStartTime : 0
// 记录API请求错误
if (error.config) {
monitor.logApiRequest({
url: error.config.url,
method: error.config.method,
status: error.response?.status || 0,
duration: requestTime,
success: false,
error: error.message
})
}
// 记录错误
monitor.logError(error, {
type: 'api_error',
url: error.config?.url,
method: error.config?.method,
status: error.response?.status
})
// 请求失败后从pendingRequests中移除
if (error.config) {
const requestKey = generateRequestKey(error.config)
pendingRequests.delete(requestKey)
}
// 隐藏全局加载状态(仅对非静默请求)
if (!error.config?.silent) {
const loadingStore = useLoadingStore()
loadingStore.hideLoading()
}
// 如果是取消的请求,直接返回
if (axios.isCancel(error)) {
console.log('请求被取消:', error.message)
return Promise.reject(error)
}
const originalRequest = error.config
// 如果配置了不重试或者已经重试过,直接处理错误
@@ -94,69 +246,68 @@ api.interceptors.response.use(
)
// 错误处理函数
function handleError(error) {
const handleError = (error) => {
if (error.response) {
// 服务器返回了错误状态码
const { status, data } = error.response
switch (status) {
case 400:
ElMessage.error(`请求参数错误: ${data.message || '请检查输入参数'}`)
ElMessage.error(data?.message || '请求参数错误,请检查输入')
break
case 401:
ElMessage.error(`未授权访问: ${data.message || '请先登录'}`)
ElMessage.error('登录已过期,请重新登录')
// 可以在这里添加跳转到登录页的逻辑
break
case 403:
ElMessage.error(`禁止访问: ${data.message || '权限不足'}`)
ElMessage.error('没有权限访问此资源')
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 || '输入数据不符合要求'}`)
}
ElMessage.error('请求的资源不存在')
break
case 429:
ElMessage.error(`请求过于频繁: ${data.message || '请稍后再试'}`)
ElMessage.error('请求过于频繁,请稍后再试')
break
case 500:
ElNotification({
title: '服务器错误',
message: data.message || '服务器出现问题,请稍后再试',
type: 'error',
duration: 5000
})
ElMessage.error('服务器内部错误,请稍后重试')
break
case 502:
ElMessage.error('网关错误,请稍后重试')
break
case 503:
case 504:
ElNotification({
title: '服务不可用',
message: '服务器暂时无法响应,请稍后再试',
type: 'warning',
duration: 5000
})
ElMessage.error('服务暂时不可用,请稍后重试')
break
default:
ElMessage.error(`未知错误: ${data.message || '发生未知错误,请联系管理员'}`)
ElMessage.error(`请求失败,错误: ${status}`)
}
} else if (error.request) {
// 请求已发出但没有收到响应
ElNotification({
title: '网络错误',
message: '无法连接到服务器,请检查网络连接',
type: 'error',
duration: 5000
})
// 请求已发出但没有收到响应
ElMessage.error('网络连接失败,请检查网络设置')
} else {
// 在设置请求时发生了错误
ElMessage.error(`请求配置错误: ${error.message}`)
// 其他错误
ElMessage.error(error.message || '未知错误,请稍后重试')
}
}
// 导出API实例和辅助函数
export { api }
// 导出请求取消相关函数
export const cancelAllRequests = () => {
pendingRequests.forEach((cancel) => {
cancel('取消所有请求')
})
pendingRequests.clear()
}
export const getRequestCount = () => {
return pendingRequests.size
}
export const createCancelToken = () => {
return new AbortController()
}
// 导出缓存管理函数
export { clearCache, getCacheStats } from '@/utils/cacheManager'
export default api

View File

@@ -1,13 +1,19 @@
import api from './api'
import { api, createCancelToken } from './api'
export const categoryService = {
// 获取所有类别
getAll() {
return api.get('/categories')
async getAll() {
return await api.get('/api/categories', {
signal: createCancelToken().signal,
dataType: 'category'
})
},
// 根据ID获取类别详情
getById(id) {
return api.get(`/categories/${id}`)
async getById(id) {
return await api.get(`/api/categories/${id}`, {
signal: createCancelToken().signal,
dataType: 'category'
})
}
}

View File

@@ -1,8 +1,11 @@
import api from './api'
import { api, createCancelToken } from './api'
export const comparisonService = {
// 对比产品
compare(productIds) {
return api.post('/comparison', { productIds })
async compare(productIds) {
return await api.post('/api/comparison', productIds, {
signal: createCancelToken().signal,
dataType: 'comparison'
})
}
}

View File

@@ -1,18 +1,29 @@
import api from './api'
import { api, createCancelToken } from './api'
export const productService = {
// 获取产品列表(支持分页和筛选)
getAll(params = {}) {
return api.get('/products', { params })
// 获取产品列表
async getAll(params = {}) {
return await api.get('/api/products', {
params,
signal: createCancelToken().signal,
dataType: 'product'
})
},
// 根据ID获取产品详情
getById(id) {
return api.get(`/products/${id}`)
async getById(id) {
return await api.get(`/api/products/${id}`, {
signal: createCancelToken().signal,
dataType: 'product'
})
},
// 搜索产品
search(params = {}) {
return api.get('/products/search', { params })
async search(params = {}) {
return await api.get('/api/products/search', {
params,
signal: createCancelToken().signal,
dataType: 'product'
})
}
}

View File

@@ -1,5 +1,7 @@
import { defineStore } from 'pinia'
import { categoryService } from '../services/categoryService'
import { ElMessage } from 'element-plus'
import { createPersistPlugin, presetConfigs } from '../utils/piniaPersist'
export const useCategoryStore = defineStore('category', {
state: () => ({
@@ -8,31 +10,115 @@ export const useCategoryStore = defineStore('category', {
error: null
}),
getters: {
// 获取所有类别
allCategories: (state) => state.categories,
// 根据ID获取类别
getCategoryById: (state) => (id) => {
return state.categories.find(category => category.id === id)
},
// 检查是否正在加载
isLoading: (state) => state.loading,
// 获取错误信息
errorMessage: (state) => state.error
},
actions: {
// 获取所有类别
async fetchCategories() {
this.loading = true
this.error = null
try {
const response = await categoryService.getAll()
this.categories = response
// 数据转换和验证
if (response && response.success && Array.isArray(response.data)) {
this.categories = response.data.map(category => ({
id: category.id,
name: category.name || '未知类别',
description: category.description || '',
productCount: category.productCount || 0
}))
} else {
// 处理不规范的响应格式
console.warn('API响应格式不符合预期:', response)
this.categories = Array.isArray(response) ? response : []
this.error = '数据格式异常'
}
} catch (error) {
this.error = error.message
console.error('获取类别列表失败:', error)
this.error = error.message || '获取类别列表失败'
ElMessage.error(this.error)
} finally {
this.loading = false
}
},
// 根据ID获取类别详情
async getCategoryById(id) {
// 先检查本地是否已有该类别数据
const existingCategory = this.categories.find(category => category.id === id)
if (existingCategory) {
return existingCategory
}
this.loading = true
this.error = null
try {
const response = await categoryService.getById(id)
return response
// 数据转换和验证
if (response && response.success && response.data) {
const category = {
id: response.data.id,
name: response.data.name || '未知类别',
description: response.data.description || '',
productCount: response.data.productCount || 0
}
// 将新获取的类别添加到本地状态
this.categories.push(category)
return category
} else {
throw new Error('获取类别详情失败:响应数据格式不正确')
}
} catch (error) {
this.error = error.message
console.error('获取类别详情失败:', error)
return null
console.error(`获取类别详情失败 (ID: ${id}):`, error)
this.error = error.message || '获取类别详情失败'
ElMessage.error(this.error)
throw error
} finally {
this.loading = false
}
},
// 清除错误状态
clearError() {
this.error = null
},
// 重置状态
resetState() {
this.categories = []
this.loading = false
this.error = null
}
},
// 添加持久化配置
persist: {
...presetConfigs.tempData,
paths: ['categories'], // 只持久化类别数据不持久化loading和error状态
beforeRestore: ({ store }) => {
console.log('正在恢复categoryStore状态...')
},
afterRestore: ({ store }) => {
console.log('categoryStore状态恢复完成')
}
}
})

View File

@@ -1,5 +1,7 @@
import { defineStore } from 'pinia'
import { comparisonService } from '../services/comparisonService'
import { ElMessage } from 'element-plus'
import { createPersistPlugin, presetConfigs } from '../utils/piniaPersist'
export const useComparisonStore = defineStore('comparison', {
state: () => ({
@@ -10,41 +12,96 @@ export const useComparisonStore = defineStore('comparison', {
}),
getters: {
// 检查是否可以进行对比
canCompare: (state) => state.selectedProducts.length >= 2 && state.selectedProducts.length <= 4,
maxProductsReached: (state) => state.selectedProducts.length >= 4
// 检查是否已达到最大产品数量
maxProductsReached: (state) => state.selectedProducts.length >= 4,
// 获取已选择的产品数量
selectedCount: (state) => state.selectedProducts.length,
// 获取错误信息
errorMessage: (state) => state.error,
// 检查是否正在加载
isLoading: (state) => state.loading,
// 获取对比结果
comparisonData: (state) => state.comparisonResult
},
actions: {
// 添加产品到对比列表
addProduct(product) {
// 验证产品数据
if (!product || !product.id) {
this.error = '无效的产品数据'
ElMessage.error(this.error)
return false
}
if (this.selectedProducts.length >= 4) {
this.error = '最多只能选择4个产品进行对比'
ElMessage.warning(this.error)
return false
}
// 检查产品是否已经在对比列表中
const exists = this.selectedProducts.some(p => p.id === product.id)
if (!exists) {
this.selectedProducts.push(product)
// 确保产品数据结构完整
const normalizedProduct = {
id: product.id,
name: product.name || '未知产品',
model: product.model || '',
manufacturer: product.manufacturer || '未知制造商',
performanceScore: product.performanceScore || 0,
currentRank: product.currentRank || 0,
categoryId: product.categoryId,
specifications: Array.isArray(product.specifications) ? product.specifications : [],
price: product.price || 0
}
this.selectedProducts.push(normalizedProduct)
ElMessage.success(`已添加 ${normalizedProduct.name} 到对比列表`)
return true
} else {
ElMessage.info('该产品已在对比列表中')
return false
}
},
// 从对比列表中移除产品
removeProduct(productId) {
if (!productId) {
this.error = '无效的产品ID'
return false
}
const index = this.selectedProducts.findIndex(p => p.id === productId)
if (index !== -1) {
const removedProduct = this.selectedProducts[index]
this.selectedProducts.splice(index, 1)
ElMessage.success(`已移除 ${removedProduct.name}`)
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
this.error = null
ElMessage.info('已清空对比列表')
},
// 执行产品对比
async compareProducts() {
if (!this.canCompare) {
this.error = '请选择2-4个产品进行对比'
ElMessage.warning(this.error)
return null
}
@@ -54,15 +111,64 @@ export const useComparisonStore = defineStore('comparison', {
try {
const productIds = this.selectedProducts.map(p => p.id)
const response = await comparisonService.compare(productIds)
this.comparisonResult = response.data
return response.data
// 数据转换和验证
if (response && response.success && response.data) {
// 确保对比结果数据结构完整
this.comparisonResult = {
products: Array.isArray(response.data.products) ? response.data.products.map(product => ({
id: product.id,
name: product.name || '未知产品',
model: product.model || '',
manufacturer: product.manufacturer || '未知制造商',
performanceScore: product.performanceScore || 0,
currentRank: product.currentRank || 0,
specifications: Array.isArray(product.specifications) ? product.specifications : [],
price: product.price || 0
})) : [],
comparisonMatrix: response.data.comparisonMatrix || {},
bestValues: response.data.bestValues || {},
worstValues: response.data.worstValues || {}
}
ElMessage.success('产品对比完成')
return this.comparisonResult
} else {
throw new Error('产品对比失败:响应数据格式不正确')
}
} catch (error) {
this.error = error.message
console.error('产品对比失败:', error)
this.error = error.message || '产品对比失败'
ElMessage.error(this.error)
return null
} finally {
this.loading = false
}
},
// 清除错误状态
clearError() {
this.error = null
},
// 重置状态
resetState() {
this.selectedProducts = []
this.comparisonResult = null
this.loading = false
this.error = null
}
},
// 添加持久化配置
persist: {
...presetConfigs.tempData,
paths: ['selectedProducts'], // 只持久化已选择的产品列表不持久化对比结果和loading/error状态
beforeRestore: ({ store }) => {
console.log('正在恢复comparisonStore状态...')
},
afterRestore: ({ store }) => {
console.log('comparisonStore状态恢复完成')
}
}
})

View File

@@ -1,5 +1,7 @@
import { defineStore } from 'pinia'
import { productService } from '../services/productService'
import { ElMessage } from 'element-plus'
import { createPersistPlugin, presetConfigs } from '../utils/piniaPersist'
export const useProductStore = defineStore('product', {
state: () => ({
@@ -24,7 +26,41 @@ export const useProductStore = defineStore('product', {
error: null
}),
getters: {
// 获取所有产品
allProducts: (state) => state.products,
// 获取当前页产品
currentPageProducts: (state) => {
const start = (state.pagination.currentPage - 1) * state.pagination.pageSize
const end = start + state.pagination.pageSize
return state.products.slice(start, end)
},
// 获取分页信息
paginationInfo: (state) => state.pagination,
// 获取筛选条件
currentFilters: (state) => state.filters,
// 获取排序条件
currentSorting: (state) => ({
sortBy: state.sortBy,
sortOrder: state.sortOrder
}),
// 检查是否正在加载
isLoading: (state) => state.loading,
// 获取错误信息
errorMessage: (state) => state.error,
// 获取产品详情
productDetailData: (state) => state.productDetail
},
actions: {
// 获取产品列表
async fetchProducts() {
this.loading = true
this.error = null
@@ -44,45 +80,106 @@ export const useProductStore = defineStore('product', {
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
// 数据转换和验证
if (response && response.success && response.data) {
// 确保分页数据结构正确
this.pagination.total = response.data.total || 0
this.pagination.currentPage = response.data.currentPage || 1
// 转换产品数据
this.products = Array.isArray(response.data.items) ? response.data.items.map(product => ({
id: product.id,
name: product.name || '未知产品',
model: product.model || '',
manufacturer: product.manufacturer || '未知制造商',
releaseDate: product.releaseDate || '',
currentRank: product.currentRank || 0,
imageUrl: product.imageUrl || '',
categoryId: product.categoryId,
performanceScore: product.performanceScore || 0,
specifications: product.specifications || [],
price: product.price || 0
})) : []
} else {
console.warn('API响应格式不符合预期:', response)
this.products = []
this.error = '数据格式异常'
}
} catch (error) {
this.error = error.message
console.error('获取产品列表失败:', error)
this.error = error.message || '获取产品列表失败'
ElMessage.error(this.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
// 数据转换和验证
if (response && response.success && response.data) {
this.productDetail = {
id: response.data.id,
name: response.data.name || '未知产品',
model: response.data.model || '',
manufacturer: response.data.manufacturer || '未知制造商',
releaseDate: response.data.releaseDate || '',
currentRank: response.data.currentRank || 0,
imageUrl: response.data.imageUrl || '',
categoryId: response.data.categoryId,
performanceScore: response.data.performanceScore || 0,
specifications: Array.isArray(response.data.specifications) ? response.data.specifications : [],
price: response.data.price || 0
}
return this.productDetail
} else {
throw new Error('获取产品详情失败:响应数据格式不正确')
}
} catch (error) {
this.error = error.message
console.error('获取产品详情失败:', error)
this.error = error.message || '获取产品详情失败'
ElMessage.error(this.error)
return null
} finally {
this.loading = false
}
},
// 直接获取产品信息不更新store状态
async getProductById(id) {
// 直接调用API获取产品信息不更新store状态
try {
const response = await productService.getById(id)
return response.data
if (response && response.success && response.data) {
return {
id: response.data.id,
name: response.data.name || '未知产品',
model: response.data.model || '',
manufacturer: response.data.manufacturer || '未知制造商',
releaseDate: response.data.releaseDate || '',
currentRank: response.data.currentRank || 0,
imageUrl: response.data.imageUrl || '',
categoryId: response.data.categoryId,
performanceScore: response.data.performanceScore || 0,
specifications: Array.isArray(response.data.specifications) ? response.data.specifications : [],
price: response.data.price || 0
}
}
return null
} catch (error) {
console.error('获取产品详情失败:', error)
return null
}
},
// 搜索产品
async searchProducts(query) {
this.loading = true
this.error = null
@@ -92,36 +189,61 @@ export const useProductStore = defineStore('product', {
if (this.filters.categoryId) params.categoryId = this.filters.categoryId
const response = await productService.search(params)
return response.data
// 数据转换和验证
if (response && response.success && Array.isArray(response.data)) {
return response.data.map(product => ({
id: product.id,
name: product.name || '未知产品',
model: product.model || '',
manufacturer: product.manufacturer || '未知制造商',
releaseDate: product.releaseDate || '',
currentRank: product.currentRank || 0,
imageUrl: product.imageUrl || '',
categoryId: product.categoryId,
performanceScore: product.performanceScore || 0,
specifications: product.specifications || [],
price: product.price || 0
}))
} else {
console.warn('搜索API响应格式不符合预期:', response)
return []
}
} catch (error) {
this.error = error.message
console.error('搜索产品失败:', error)
this.error = error.message || '搜索产品失败'
ElMessage.error(this.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,
@@ -131,6 +253,46 @@ export const useProductStore = defineStore('product', {
year: null
}
this.pagination.currentPage = 1
},
// 清除错误状态
clearError() {
this.error = null
},
// 重置状态
resetState() {
this.products = []
this.productDetail = null
this.pagination = {
currentPage: 1,
pageSize: 20,
total: 0
}
this.filters = {
categoryId: null,
manufacturer: '',
minScore: null,
maxScore: null,
year: null
}
this.sortBy = 'performanceScore'
this.sortOrder = 'desc'
this.searchQuery = ''
this.loading = false
this.error = null
}
},
// 添加持久化配置
persist: {
...presetConfigs.tempData,
paths: ['filters', 'sortBy', 'sortOrder', 'pagination.pageSize'], // 持久化筛选条件、排序和每页数量设置
beforeRestore: ({ store }) => {
console.log('正在恢复productStore状态...')
},
afterRestore: ({ store }) => {
console.log('productStore状态恢复完成')
}
}
})

164
frontend/src/utils/auth.js Normal file
View File

@@ -0,0 +1,164 @@
/**
* 认证工具
* 用于管理用户token和认证状态
*/
const TOKEN_KEY = 'performance_ranking_token'
const REFRESH_TOKEN_KEY = 'performance_ranking_refresh_token'
const USER_INFO_KEY = 'performance_ranking_user_info'
/**
* 获取token
*/
export function getToken() {
return localStorage.getItem(TOKEN_KEY)
}
/**
* 设置token
* @param {string} token - 访问令牌
*/
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token)
}
/**
* 获取刷新token
*/
export function getRefreshToken() {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
/**
* 设置刷新token
* @param {string} refreshToken - 刷新令牌
*/
export function setRefreshToken(refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
}
/**
* 移除token
*/
export function removeToken() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(USER_INFO_KEY)
}
/**
* 获取用户信息
*/
export function getUserInfo() {
const userInfo = localStorage.getItem(USER_INFO_KEY)
return userInfo ? JSON.parse(userInfo) : null
}
/**
* 设置用户信息
* @param {Object} userInfo - 用户信息
*/
export function setUserInfo(userInfo) {
localStorage.setItem(USER_INFO_KEY, JSON.stringify(userInfo))
}
/**
* 检查是否已登录
*/
export function isLoggedIn() {
return !!getToken()
}
/**
* 检查token是否即将过期
* @param {number} threshold - 阈值默认5分钟
*/
export function isTokenExpiringSoon(threshold = 300) {
const token = getToken()
if (!token) return true
try {
// 解析JWT token
const payload = JSON.parse(atob(token.split('.')[1]))
const now = Math.floor(Date.now() / 1000)
const expirationTime = payload.exp
// 如果没有过期时间或已过期返回true
if (!expirationTime || expirationTime <= now) {
return true
}
// 检查是否在阈值范围内
return (expirationTime - now) <= threshold
} catch (error) {
console.error('解析token失败:', error)
return true
}
}
/**
* 刷新token
* @param {Function} refreshCallback - 刷新token的回调函数
*/
export async function refreshToken(refreshCallback) {
const refreshToken = getRefreshToken()
if (!refreshToken) {
throw new Error('没有刷新token')
}
try {
const response = await refreshCallback(refreshToken)
const { token, refreshToken: newRefreshToken } = response
// 更新token
setToken(token)
if (newRefreshToken) {
setRefreshToken(newRefreshToken)
}
return token
} catch (error) {
// 刷新失败,清除所有认证信息
removeToken()
throw error
}
}
/**
* 登出
*/
export function logout() {
removeToken()
// 可以在这里添加其他登出逻辑,如清除缓存等
}
/**
* 初始化认证状态
* @param {Function} onTokenExpired - token过期回调
*/
export function initAuth(onTokenExpired) {
// 检查token是否过期
if (isLoggedIn() && isTokenExpiringSoon()) {
if (onTokenExpired && typeof onTokenExpired === 'function') {
onTokenExpired()
} else {
// 默认行为移除token
removeToken()
}
}
}
export default {
getToken,
setToken,
getRefreshToken,
setRefreshToken,
removeToken,
getUserInfo,
setUserInfo,
isLoggedIn,
isTokenExpiringSoon,
refreshToken,
logout,
initAuth
}

View File

@@ -0,0 +1,217 @@
/**
* API请求缓存管理
* 用于缓存API响应减少重复请求
*/
// 缓存存储
const cache = new Map()
// 默认缓存配置
const defaultConfig = {
// 默认缓存时间(毫秒)
defaultTTL: 5 * 60 * 1000, // 5分钟
// 最大缓存条目数
maxEntries: 100,
// 不同API端点的特定缓存时间
endpointTTL: {
'/api/categories': 10 * 60 * 1000, // 10分钟
'/api/products': 3 * 60 * 1000, // 3分钟
'/api/products/search': 0, // 不缓存搜索结果
'/api/comparison': 0 // 不缓存对比结果
}
}
/**
* 生成缓存键
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @returns {string} 缓存键
*/
function generateCacheKey(url, params = {}) {
// 将参数对象转换为排序后的字符串
const sortedParams = Object.keys(params)
.sort()
.reduce((result, key) => {
if (params[key] !== undefined && params[key] !== null) {
result[key] = params[key]
}
return result
}, {})
const paramsStr = JSON.stringify(sortedParams)
return `${url}?${paramsStr}`
}
/**
* 清理过期的缓存条目
*/
function cleanExpiredEntries() {
const now = Date.now()
for (const [key, entry] of cache.entries()) {
if (entry.expiryTime < now) {
cache.delete(key)
}
}
}
/**
* 清理最旧的缓存条目(当缓存超过最大条目数时)
*/
function cleanOldestEntries() {
if (cache.size <= defaultConfig.maxEntries) {
return
}
// 按创建时间排序,删除最旧的条目
const sortedEntries = Array.from(cache.entries())
.sort((a, b) => a[1].createTime - b[1].createTime)
const entriesToDelete = sortedEntries.slice(0, sortedEntries.length - defaultConfig.maxEntries)
entriesToDelete.forEach(([key]) => cache.delete(key))
}
/**
* 获取特定端点的缓存时间
* @param {string} url - 请求URL
* @returns {number} 缓存时间(毫秒)
*/
function getEndpointTTL(url) {
// 检查是否完全匹配
if (defaultConfig.endpointTTL[url] !== undefined) {
return defaultConfig.endpointTTL[url]
}
// 检查是否匹配前缀
for (const [endpoint, ttl] of Object.entries(defaultConfig.endpointTTL)) {
if (url.startsWith(endpoint)) {
return ttl
}
}
// 返回默认缓存时间
return defaultConfig.defaultTTL
}
/**
* 缓存API响应
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @param {any} data - 响应数据
* @param {number} [customTTL] - 自定义缓存时间(可选)
*/
export function setCache(url, params, data, customTTL) {
const cacheKey = generateCacheKey(url, params)
const ttl = customTTL !== undefined ? customTTL : getEndpointTTL(url)
// 如果TTL为0不缓存
if (ttl <= 0) {
return
}
// 清理过期条目
cleanExpiredEntries()
// 清理最旧条目(如果需要)
cleanOldestEntries()
const now = Date.now()
cache.set(cacheKey, {
data,
createTime: now,
expiryTime: now + ttl
})
}
/**
* 获取缓存的响应
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @returns {any|null} 缓存的数据如果不存在或已过期则返回null
*/
export function getCache(url, params) {
const cacheKey = generateCacheKey(url, params)
const entry = cache.get(cacheKey)
// 如果缓存不存在返回null
if (!entry) {
return null
}
// 检查是否过期
const now = Date.now()
if (entry.expiryTime < now) {
cache.delete(cacheKey)
return null
}
return entry.data
}
/**
* 清除特定URL的缓存
* @param {string} url - 请求URL可选
* @param {Object} params - 请求参数(可选)
*/
export function clearCache(url, params) {
// 如果没有提供URL清除所有缓存
if (!url) {
cache.clear()
return
}
// 如果提供了URL和参数清除特定缓存
if (params) {
const cacheKey = generateCacheKey(url, params)
cache.delete(cacheKey)
return
}
// 如果只提供了URL清除该URL的所有缓存
const keysToDelete = []
for (const key of cache.keys()) {
if (key.startsWith(`${url}?`)) {
keysToDelete.push(key)
}
}
keysToDelete.forEach(key => cache.delete(key))
}
/**
* 获取缓存统计信息
* @returns {Object} 缓存统计
*/
export function getCacheStats() {
const now = Date.now()
let expiredCount = 0
for (const entry of cache.values()) {
if (entry.expiryTime < now) {
expiredCount++
}
}
return {
totalEntries: cache.size,
expiredEntries: expiredCount,
maxEntries: defaultConfig.maxEntries,
hitRate: window._cacheHits / (window._cacheHits + window._cacheMisses) || 0
}
}
// 初始化缓存统计
window._cacheHits = 0
window._cacheMisses = 0
/**
* 更新缓存统计
* @param {boolean} isHit - 是否命中缓存
*/
export function updateCacheStats(isHit) {
if (isHit) {
window._cacheHits++
} else {
window._cacheMisses++
}
}

View File

@@ -0,0 +1,371 @@
/**
* 组件缓存工具
* 提供组件级别的缓存功能,支持动态组件缓存、缓存策略配置等
*/
// 缓存策略枚举
export const CacheStrategy = {
// 永久缓存,直到手动清除
FOREVER: 'forever',
// 基于时间的缓存,过期后自动清除
TIME_BASED: 'time-based',
// 基于使用频率的缓存LRU算法
FREQUENCY_BASED: 'frequency-based',
// 基于内存压力的缓存,内存不足时自动清除
MEMORY_PRESSURE: 'memory-pressure'
}
// 默认配置
const defaultConfig = {
// 默认缓存策略
strategy: CacheStrategy.TIME_BASED,
// 默认缓存时间(毫秒)
defaultExpire: 5 * 60 * 1000, // 5分钟
// 最大缓存数量
maxCacheSize: 50,
// 内存阈值(字节),超过此值时清除缓存
memoryThreshold: 50 * 1024 * 1024, // 50MB
// 键名前缀
prefix: 'comp_cache_',
// 错误处理函数
onError: (error) => console.error('Component cache error:', error)
}
/**
* 组件缓存管理器
*/
class ComponentCacheManager {
constructor(config = {}) {
this.config = { ...defaultConfig, ...config }
this.cache = new Map()
this.accessTimes = new Map() // 记录访问时间用于LRU算法
this.accessCounts = new Map() // 记录访问次数,用于频率统计
this.initMemoryMonitor()
}
/**
* 初始化内存监控
*/
initMemoryMonitor() {
if (typeof window === 'undefined' || !window.performance || !window.performance.memory) {
console.warn('Memory monitoring not supported')
return
}
// 定期检查内存使用情况
this.memoryCheckInterval = setInterval(() => {
this.checkMemoryPressure()
}, 30000) // 每30秒检查一次
}
/**
* 检查内存压力
*/
checkMemoryPressure() {
if (!window.performance || !window.performance.memory) return
const memoryInfo = window.performance.memory
const usedMemory = memoryInfo.usedJSHeapSize
if (usedMemory > this.config.memoryThreshold) {
console.warn(`Memory usage (${(usedMemory / 1024 / 1024).toFixed(2)}MB) exceeds threshold (${(this.config.memoryThreshold / 1024 / 1024).toFixed(2)}MB)`)
this.clearByMemoryPressure()
}
}
/**
* 生成完整的键名
*/
getFullKey(key) {
return `${this.config.prefix}${key}`
}
/**
* 获取当前时间戳
*/
now() {
return Date.now()
}
/**
* 设置缓存
*/
set(key, value, options = {}) {
const {
strategy = this.config.strategy,
expire = this.config.defaultExpire
} = options
const fullKey = this.getFullKey(key)
const now = this.now()
// 检查缓存大小限制
if (this.cache.size >= this.config.maxCacheSize && !this.cache.has(fullKey)) {
this.evictByStrategy(strategy)
}
// 创建缓存项
const cacheItem = {
value,
createdAt: now,
lastAccessedAt: now,
accessCount: 1,
expire: strategy === CacheStrategy.TIME_BASED && expire > 0 ? now + expire : 0,
strategy,
size: this.estimateSize(value)
}
// 更新缓存
this.cache.set(fullKey, cacheItem)
this.accessTimes.set(fullKey, now)
this.accessCounts.set(fullKey, 1)
return true
}
/**
* 获取缓存
*/
get(key) {
const fullKey = this.getFullKey(key)
const cacheItem = this.cache.get(fullKey)
if (!cacheItem) {
return null
}
const now = this.now()
// 检查是否过期
if (cacheItem.expire > 0 && now > cacheItem.expire) {
this.delete(key)
return null
}
// 更新访问信息
cacheItem.lastAccessedAt = now
cacheItem.accessCount++
this.accessTimes.set(fullKey, now)
this.accessCounts.set(fullKey, cacheItem.accessCount)
return cacheItem.value
}
/**
* 删除缓存
*/
delete(key) {
const fullKey = this.getFullKey(key)
const deleted = this.cache.delete(fullKey)
this.accessTimes.delete(fullKey)
this.accessCounts.delete(fullKey)
return deleted
}
/**
* 清空所有缓存
*/
clear() {
this.cache.clear()
this.accessTimes.clear()
this.accessCounts.clear()
}
/**
* 根据策略淘汰缓存
*/
evictByStrategy(strategy) {
switch (strategy) {
case CacheStrategy.FREQUENCY_BASED:
this.evictByLFU() // Least Frequently Used
break
case CacheStrategy.MEMORY_PRESSURE:
this.evictByMemoryPressure()
break
case CacheStrategy.TIME_BASED:
default:
this.evictByLRU() // Least Recently Used
break
}
}
/**
* LRU淘汰算法
*/
evictByLRU() {
let oldestKey = null
let oldestTime = this.now()
for (const [key, time] of this.accessTimes) {
if (time < oldestTime) {
oldestTime = time
oldestKey = key
}
}
if (oldestKey) {
const key = oldestKey.substring(this.config.prefix.length)
this.delete(key)
}
}
/**
* LFU淘汰算法
*/
evictByLFU() {
let leastUsedKey = null
let leastCount = Infinity
for (const [key, count] of this.accessCounts) {
if (count < leastCount) {
leastCount = count
leastUsedKey = key
}
}
if (leastUsedKey) {
const key = leastUsedKey.substring(this.config.prefix.length)
this.delete(key)
}
}
/**
* 基于内存压力的淘汰算法
*/
evictByMemoryPressure() {
// 按照缓存项大小排序,优先淘汰大的
const entries = Array.from(this.cache.entries())
entries.sort((a, b) => b[1].size - a[1].size)
// 淘汰最大的几个缓存项直到缓存大小减少至少20%
const targetSize = Math.floor(this.config.maxCacheSize * 0.8)
let evictedCount = 0
for (const [key] of entries) {
if (this.cache.size <= targetSize) break
const cacheKey = key.substring(this.config.prefix.length)
this.delete(cacheKey)
evictedCount++
}
console.log(`Evicted ${evictedCount} cache items due to memory pressure`)
}
/**
* 清除过期缓存
*/
clearExpired() {
const now = this.now()
const expiredKeys = []
for (const [key, item] of this.cache) {
if (item.expire > 0 && now > item.expire) {
expiredKeys.push(key)
}
}
for (const key of expiredKeys) {
const cacheKey = key.substring(this.config.prefix.length)
this.delete(cacheKey)
}
return expiredKeys.length
}
/**
* 估算对象大小(字节)
*/
estimateSize(obj) {
try {
return JSON.stringify(obj).length * 2 // 假设每个字符占用2字节
} catch (error) {
return 1024 // 默认1KB
}
}
/**
* 获取缓存统计信息
*/
getStats() {
const now = this.now()
let totalSize = 0
let expiredCount = 0
for (const [key, item] of this.cache) {
totalSize += item.size
if (item.expire > 0 && now > item.expire) {
expiredCount++
}
}
return {
size: this.cache.size,
maxSize: this.config.maxCacheSize,
totalSize,
memoryThreshold: this.config.memoryThreshold,
expiredCount,
strategy: this.config.strategy
}
}
/**
* 获取所有缓存键
*/
keys() {
return Array.from(this.cache.keys()).map(key =>
key.substring(this.config.prefix.length)
)
}
/**
* 检查是否存在缓存
*/
has(key) {
const fullKey = this.getFullKey(key)
const cacheItem = this.cache.get(fullKey)
if (!cacheItem) {
return false
}
const now = this.now()
// 检查是否过期
if (cacheItem.expire > 0 && now > cacheItem.expire) {
this.delete(key)
return false
}
return true
}
/**
* 销毁缓存管理器
*/
destroy() {
if (this.memoryCheckInterval) {
clearInterval(this.memoryCheckInterval)
this.memoryCheckInterval = null
}
this.clear()
}
}
// 创建默认实例
const defaultCacheManager = new ComponentCacheManager()
// 导出便捷方法
export const setCache = (key, value, options) => defaultCacheManager.set(key, value, options)
export const getCache = (key) => defaultCacheManager.get(key)
export const deleteCache = (key) => defaultCacheManager.delete(key)
export const clearCache = () => defaultCacheManager.clear()
export const hasCache = (key) => defaultCacheManager.has(key)
export const getCacheStats = () => defaultCacheManager.getStats()
export const clearExpiredCache = () => defaultCacheManager.clearExpired()
// 导出管理器类
export { ComponentCacheManager }
// 导出默认实例
export default defaultCacheManager

View File

@@ -0,0 +1,379 @@
/**
* 关键CSS内联工具
* 用于内联关键CSS减少渲染阻塞优化首屏加载速度
*/
/**
* 关键CSS配置
*/
const criticalCSSConfig = {
// 是否启用关键CSS提取
enabled: true,
// 关键CSS选择器列表
selectors: [
// 基础布局
'html', 'body', '#app',
// 顶部导航
'.header', '.nav', '.logo',
// 首屏内容
'.hero', '.main-content', '.category-card',
// 加载状态
'.loading', '.spinner',
// 错误状态
'.error', '.error-message'
],
// 最大内联CSS大小字节
maxInlineSize: 15000,
// 是否压缩CSS
minify: true
}
/**
* 关键CSS提取器
*/
class CriticalCSSExtractor {
constructor(config = {}) {
this.config = { ...criticalCSSConfig, ...config }
this.criticalCSS = ''
this.originalCSS = ''
}
/**
* 从CSS中提取关键CSS
* @param {string} css - 原始CSS
* @param {string} html - HTML内容
*/
extractCriticalCSS(css, html = '') {
if (!this.config.enabled) return ''
this.originalCSS = css
// 如果提供了HTML使用更精确的方法提取关键CSS
if (html) {
this.criticalCSS = this.extractFromHTML(css, html)
} else {
// 否则使用选择器匹配
this.criticalCSS = this.extractBySelectors(css)
}
// 压缩CSS
if (this.config.minify) {
this.criticalCSS = this.minifyCSS(this.criticalCSS)
}
// 检查大小限制
if (this.criticalCSS.length > this.config.maxInlineSize) {
console.warn(`关键CSS大小 (${this.criticalCSS.length} bytes) 超过限制 (${this.config.maxInlineSize} bytes)`)
// 截断CSS
this.criticalCSS = this.truncateCSS(this.criticalCSS, this.config.maxInlineSize)
}
return this.criticalCSS
}
/**
* 基于HTML内容提取关键CSS
* @param {string} css - 原始CSS
* @param {string} html - HTML内容
*/
extractFromHTML(css, html) {
// 创建DOM解析器
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
// 获取所有在首屏可见的元素
const visibleElements = this.getVisibleElements(doc)
// 提取这些元素的CSS规则
const criticalRules = this.extractRulesForElements(css, visibleElements)
return criticalRules
}
/**
* 获取首屏可见元素
* @param {Document} doc - 文档对象
*/
getVisibleElements(doc) {
const elements = []
const viewportHeight = window.innerHeight || 800
const allElements = doc.querySelectorAll('*')
for (const element of allElements) {
// 获取元素位置
const rect = element.getBoundingClientRect()
// 检查元素是否在首屏可见
if (rect.top < viewportHeight && rect.left >= 0 && rect.left <= window.innerWidth) {
elements.push(element)
}
}
return elements
}
/**
* 提取元素的CSS规则
* @param {string} css - 原始CSS
* @param {Array} elements - 元素列表
*/
extractRulesForElements(css, elements) {
// 解析CSS为规则
const rules = this.parseCSS(css)
const criticalRules = []
// 获取所有元素的类名、ID和标签名
const elementSelectors = new Set()
for (const element of elements) {
// 添加标签名
elementSelectors.add(element.tagName.toLowerCase())
// 添加ID
if (element.id) {
elementSelectors.add(`#${element.id}`)
}
// 添加类名
for (const className of element.classList) {
elementSelectors.add(`.${className}`)
}
// 添加伪类和伪元素
const computedStyle = window.getComputedStyle(element)
if (computedStyle) {
// 这里可以添加更复杂的逻辑来检测伪类和伪元素
}
}
// 筛选匹配元素的规则
for (const rule of rules) {
if (this.ruleMatchesSelectors(rule, Array.from(elementSelectors))) {
criticalRules.push(rule)
}
}
return criticalRules.join('\n')
}
/**
* 基于选择器列表提取关键CSS
* @param {string} css - 原始CSS
*/
extractBySelectors(css) {
const rules = this.parseCSS(css)
const criticalRules = []
for (const rule of rules) {
if (this.ruleMatchesSelectors(rule, this.config.selectors)) {
criticalRules.push(rule)
}
}
return criticalRules.join('\n')
}
/**
* 解析CSS为规则数组
* @param {string} css - CSS字符串
*/
parseCSS(css) {
const rules = []
// 简单的CSS解析器实际项目中可以使用更专业的CSS解析库
const ruleRegex = /([^{]+)\{([^}]*)\}/g
let match
while ((match = ruleRegex.exec(css)) !== null) {
const selector = match[1].trim()
const properties = match[2].trim()
if (selector && properties) {
rules.push(`${selector} { ${properties} }`)
}
}
return rules
}
/**
* 检查规则是否匹配任何选择器
* @param {string} rule - CSS规则
* @param {Array} selectors - 选择器列表
*/
ruleMatchesSelectors(rule, selectors) {
// 提取规则中的选择器部分
const selectorMatch = rule.match(/^([^{]+)/)
if (!selectorMatch) return false
const ruleSelectors = selectorMatch[1].split(',').map(s => s.trim())
// 检查是否有任何选择器匹配
for (const ruleSelector of ruleSelectors) {
for (const selector of selectors) {
if (this.selectorMatches(ruleSelector, selector)) {
return true
}
}
}
return false
}
/**
* 检查单个选择器是否匹配
* @param {string} ruleSelector - 规则选择器
* @param {string} targetSelector - 目标选择器
*/
selectorMatches(ruleSelector, targetSelector) {
// 简单的选择器匹配逻辑
// 实际项目中应该使用更完整的选择器匹配库
// 直接匹配
if (ruleSelector === targetSelector) {
return true
}
// 包含匹配(例如,.card 匹配 .product-card
if (targetSelector.startsWith('.') && ruleSelector.includes(targetSelector)) {
return true
}
// ID匹配
if (targetSelector.startsWith('#') && ruleSelector.includes(targetSelector)) {
return true
}
// 标签匹配
if (!targetSelector.startsWith('.') && !targetSelector.startsWith('#') &&
ruleSelector.split(/\s+/).includes(targetSelector)) {
return true
}
return false
}
/**
* 压缩CSS
* @param {string} css - CSS字符串
*/
minifyCSS(css) {
return css
// 移除注释
.replace(/\/\*[\s\S]*?\*\//g, '')
// 移除多余的空白字符
.replace(/\s+/g, ' ')
// 移除分号前的空格
.replace(/\s*;\s*/g, ';')
// 移除花括号前后的空格
.replace(/\s*{\s*/g, '{')
.replace(/\s*}\s*/g, '}')
// 移除冒号后的空格
.replace(/:\s+/g, ':')
// 移除逗号后的空格
.replace(/,\s+/g, ',')
.trim()
}
/**
* 截断CSS到指定大小
* @param {string} css - CSS字符串
* @param {number} maxSize - 最大大小
*/
truncateCSS(css, maxSize) {
if (css.length <= maxSize) return css
// 尝试在规则边界截断
let truncated = css.substring(0, maxSize)
const lastBraceIndex = truncated.lastIndexOf('}')
if (lastBraceIndex > 0) {
truncated = truncated.substring(0, lastBraceIndex + 1)
}
return truncated
}
/**
* 获取非关键CSS
*/
getNonCriticalCSS() {
if (!this.originalCSS || !this.criticalCSS) return ''
// 简单地从原始CSS中移除关键CSS部分
// 实际项目中应该使用更精确的方法
let nonCriticalCSS = this.originalCSS
for (const rule of this.parseCSS(this.criticalCSS)) {
nonCriticalCSS = nonCriticalCSS.replace(rule, '')
}
return nonCriticalCSS.trim()
}
}
/**
* 内联关键CSS到HTML
* @param {string} html - HTML字符串
* @param {string} css - CSS字符串
* @param {Object} config - 配置选项
*/
export function inlineCriticalCSS(html, css, config = {}) {
const extractor = new CriticalCSSExtractor(config)
const criticalCSS = extractor.extractCriticalCSS(css, html)
if (!criticalCSS) return html
// 查找head标签
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i)
if (!headMatch) {
// 如果没有head标签在html标签后插入
return html.replace(/<html[^>]*>/i, `$&\n<style>${criticalCSS}</style>`)
}
// 在head标签内插入关键CSS
const headContent = headMatch[1]
const newHeadContent = `<style>${criticalCSS}</style>\n${headContent}`
return html.replace(headMatch[0], `<head>${newHeadContent}</head>`)
}
/**
* 创建关键CSS样式标签
* @param {string} css - CSS字符串
* @param {Object} config - 配置选项
*/
export function createCriticalStyleTag(css, config = {}) {
const extractor = new CriticalCSSExtractor(config)
const criticalCSS = extractor.extractCriticalCSS(css)
if (!criticalCSS) return ''
return `<style>${criticalCSS}</style>`
}
/**
* 提取关键CSS
* @param {string} css - CSS字符串
* @param {string} html - HTML内容
* @param {Object} config - 配置选项
*/
export function extractCriticalCSS(css, html = '', config = {}) {
const extractor = new CriticalCSSExtractor(config)
return extractor.extractCriticalCSS(css, html)
}
/**
* 获取非关键CSS
* @param {string} css - CSS字符串
* @param {string} html - HTML内容
* @param {Object} config - 配置选项
*/
export function getNonCriticalCSS(css, html = '', config = {}) {
const extractor = new CriticalCSSExtractor(config)
extractor.extractCriticalCSS(css, html)
return extractor.getNonCriticalCSS()
}
export default CriticalCSSExtractor

View File

@@ -0,0 +1,261 @@
/**
* API响应数据验证和转换工具
* 用于确保从后端API返回的数据符合前端预期格式
*/
/**
* 验证API响应的基本结构
* @param {Object} response - API响应对象
* @returns {Object} 验证结果 { isValid: boolean, data: any, error: string|null }
*/
export function validateApiResponse(response) {
if (!response || typeof response !== 'object') {
return {
isValid: false,
data: null,
error: 'API响应格式错误响应不是有效的对象'
};
}
// 检查响应中是否有data字段
if (response.data === undefined) {
return {
isValid: false,
data: null,
error: 'API响应格式错误缺少data字段'
};
}
// 检查响应中是否有success字段
if (response.success === undefined) {
return {
isValid: false,
data: null,
error: 'API响应格式错误缺少success字段'
};
}
// 如果success为false检查是否有message字段
if (!response.success && !response.message) {
return {
isValid: false,
data: null,
error: 'API响应格式错误失败的响应缺少message字段'
};
}
return {
isValid: true,
data: response.success ? response.data : null,
error: response.success ? null : response.message
};
}
/**
* 验证并转换类别数据
* @param {any} data - API返回的类别数据
* @returns {Object} 验证转换后的类别数据
*/
export function validateCategoryData(data) {
// 如果是单个类别对象
if (data && typeof data === 'object' && !Array.isArray(data)) {
return {
id: Number(data.id) || 0,
name: String(data.name || ''),
description: String(data.description || ''),
productCount: Number(data.productCount) || 0
};
}
// 如果是类别数组
if (Array.isArray(data)) {
return data.map(item => validateCategoryData(item));
}
// 默认返回空对象
return {
id: 0,
name: '',
description: '',
productCount: 0
};
}
/**
* 验证并转换产品数据
* @param {any} data - API返回的产品数据
* @returns {Object} 验证转换后的产品数据
*/
export function validateProductData(data) {
// 如果是单个产品对象
if (data && typeof data === 'object' && !Array.isArray(data)) {
return {
id: Number(data.id) || 0,
name: String(data.name || ''),
model: String(data.model || ''),
manufacturer: String(data.manufacturer || ''),
categoryId: Number(data.categoryId) || 0,
categoryName: String(data.categoryName || ''),
releaseDate: data.releaseDate ? new Date(data.releaseDate) : null,
currentRank: Number(data.currentRank) || 0,
imageUrl: String(data.imageUrl || ''),
specifications: Array.isArray(data.specifications) ? data.specifications.map(spec => ({
id: Number(spec.id) || 0,
name: String(spec.name || ''),
value: String(spec.value || ''),
unit: String(spec.unit || '')
})) : [],
performanceScores: Array.isArray(data.performanceScores) ? data.performanceScores.map(score => ({
id: Number(score.id) || 0,
benchmarkName: String(score.benchmarkName || ''),
score: Number(score.score) || 0,
recordDate: score.recordDate ? new Date(score.recordDate) : null
})) : []
};
}
// 如果是产品数组
if (Array.isArray(data)) {
return data.map(item => validateProductData(item));
}
// 默认返回空对象
return {
id: 0,
name: '',
model: '',
manufacturer: '',
categoryId: 0,
categoryName: '',
releaseDate: null,
currentRank: 0,
imageUrl: '',
specifications: [],
performanceScores: []
};
}
/**
* 验证并转换分页数据
* @param {any} data - API返回的分页数据
* @returns {Object} 验证转换后的分页数据
*/
export function validatePaginationData(data) {
if (!data || typeof data !== 'object') {
return {
currentPage: 1,
totalPages: 1,
pageSize: 10,
totalCount: 0,
hasNext: false,
hasPrevious: false
};
}
return {
currentPage: Number(data.currentPage) || 1,
totalPages: Number(data.totalPages) || 1,
pageSize: Number(data.pageSize) || 10,
totalCount: Number(data.totalCount) || 0,
hasNext: Boolean(data.hasNext),
hasPrevious: Boolean(data.hasPrevious)
};
}
/**
* 验证并转换产品对比数据
* @param {any} data - API返回的产品对比数据
* @returns {Object} 验证转换后的产品对比数据
*/
export function validateComparisonData(data) {
if (!data || typeof data !== 'object') {
return {
products: [],
comparisonMatrix: [],
summary: {
bestPerformer: null,
worstPerformer: null,
comparisonDate: null
}
};
}
return {
products: Array.isArray(data.products) ? validateProductData(data.products) : [],
comparisonMatrix: Array.isArray(data.comparisonMatrix) ? data.comparisonMatrix.map(row => ({
category: String(row.category || ''),
values: Array.isArray(row.values) ? row.values.map(val => ({
productId: Number(val.productId) || 0,
value: String(val.value || ''),
isBest: Boolean(val.isBest),
isWorst: Boolean(val.isWorst)
})) : []
})) : [],
summary: {
bestPerformer: data.summary && data.summary.bestPerformer ? validateProductData(data.summary.bestPerformer) : null,
worstPerformer: data.summary && data.summary.worstPerformer ? validateProductData(data.summary.worstPerformer) : null,
comparisonDate: data.summary && data.summary.comparisonDate ? new Date(data.summary.comparisonDate) : null
}
};
}
/**
* 处理API响应统一进行验证和转换
* @param {Object} response - API响应对象
* @param {string} dataType - 数据类型:'category', 'product', 'pagination', 'comparison'
* @returns {Object} 处理后的数据 { success: boolean, data: any, error: string|null }
*/
export function processApiResponse(response, dataType) {
// 首先验证响应基本结构
const validation = validateApiResponse(response);
if (!validation.isValid) {
return {
success: false,
data: null,
error: validation.error
};
}
// 如果响应本身表示失败
if (validation.error) {
return {
success: false,
data: null,
error: validation.error
};
}
// 根据数据类型进行相应的转换
let processedData;
try {
switch (dataType) {
case 'category':
processedData = validateCategoryData(validation.data);
break;
case 'product':
processedData = validateProductData(validation.data);
break;
case 'pagination':
processedData = validatePaginationData(validation.data);
break;
case 'comparison':
processedData = validateComparisonData(validation.data);
break;
default:
processedData = validation.data;
}
return {
success: true,
data: processedData,
error: null
};
} catch (error) {
console.error('数据转换错误:', error);
return {
success: false,
data: null,
error: `数据转换错误: ${error.message}`
};
}
}

View File

@@ -0,0 +1,232 @@
/**
* 动态导入工具
* 用于优化组件和资源的加载,提供更好的用户体验
*/
// 导入状态缓存
const importCache = new Map()
// 导入状态枚举
const ImportStatus = {
PENDING: 'pending',
SUCCESS: 'success',
FAILED: 'failed'
}
/**
* 动态导入组件或模块
* @param {Function} importFn - 返回Promise的导入函数
* @param {string} cacheKey - 缓存键
* @param {Object} options - 选项
* @returns {Promise} 导入结果
*/
const dynamicImport = (importFn, cacheKey, options = {}) => {
const {
timeout = 10000, // 默认超时时间10秒
retryCount = 2, // 默认重试次数
fallback = null // 降级方案
} = options
// 检查缓存
if (importCache.has(cacheKey)) {
const cached = importCache.get(cacheKey)
if (cached.status === ImportStatus.SUCCESS) {
return Promise.resolve(cached.data)
} else if (cached.status === ImportStatus.PENDING) {
return cached.promise
}
}
// 创建导入Promise
let resolve, reject
const importPromise = new Promise((res, rej) => {
resolve = res
reject = rej
})
// 设置缓存状态为pending
importCache.set(cacheKey, {
status: ImportStatus.PENDING,
promise: importPromise
})
// 执行导入
const executeImport = (attempt = 0) => {
const timeoutPromise = new Promise((_, timeoutReject) => {
setTimeout(() => timeoutReject(new Error('Import timeout')), timeout)
})
Promise.race([importFn(), timeoutPromise])
.then(module => {
// 更新缓存状态为成功
importCache.set(cacheKey, {
status: ImportStatus.SUCCESS,
data: module
})
resolve(module)
})
.catch(error => {
console.error(`Import failed (attempt ${attempt + 1}):`, error)
// 重试逻辑
if (attempt < retryCount) {
setTimeout(() => executeImport(attempt + 1), 1000 * (attempt + 1))
} else {
// 使用降级方案
if (fallback) {
try {
const fallbackModule = typeof fallback === 'function' ? fallback() : fallback
importCache.set(cacheKey, {
status: ImportStatus.SUCCESS,
data: fallbackModule
})
resolve(fallbackModule)
} catch (fallbackError) {
// 更新缓存状态为失败
importCache.set(cacheKey, {
status: ImportStatus.FAILED,
error
})
reject(error)
}
} else {
// 更新缓存状态为失败
importCache.set(cacheKey, {
status: ImportStatus.FAILED,
error
})
reject(error)
}
}
})
}
executeImport()
return importPromise
}
/**
* 预加载组件
* @param {Function} importFn - 导入函数
* @param {string} cacheKey - 缓存键
* @param {Object} options - 选项
*/
const preloadComponent = (importFn, cacheKey, options = {}) => {
// 延迟预加载,避免影响当前页面性能
setTimeout(() => {
dynamicImport(importFn, cacheKey, options)
.then(() => {
console.log(`Component preloaded: ${cacheKey}`)
})
.catch(error => {
console.error(`Failed to preload component: ${cacheKey}`, error)
})
}, options.delay || 1000)
}
/**
* 批量预加载组件
* @param {Array} components - 组件列表,每个元素包含 { importFn, cacheKey, options }
*/
const batchPreloadComponents = (components) => {
// 使用requestIdleCallback在浏览器空闲时预加载
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
components.forEach(({ importFn, cacheKey, options }) => {
preloadComponent(importFn, cacheKey, options)
})
})
} else {
// 降级方案:延迟预加载
setTimeout(() => {
components.forEach(({ importFn, cacheKey, options }) => {
preloadComponent(importFn, cacheKey, options)
})
}, 3000)
}
}
/**
* 清除导入缓存
* @param {string} cacheKey - 缓存键,不提供则清除所有
*/
const clearImportCache = (cacheKey = null) => {
if (cacheKey) {
importCache.delete(cacheKey)
} else {
importCache.clear()
}
}
/**
* 获取导入状态
* @param {string} cacheKey - 缓存键
* @returns {Object} 导入状态
*/
const getImportStatus = (cacheKey) => {
const cached = importCache.get(cacheKey)
if (!cached) {
return { status: 'not_loaded' }
}
return {
status: cached.status,
hasData: cached.status === ImportStatus.SUCCESS,
hasError: cached.status === ImportStatus.FAILED
}
}
/**
* 获取所有导入状态
* @returns {Object} 所有导入状态
*/
const getAllImportStatus = () => {
const result = {}
importCache.forEach((value, key) => {
result[key] = {
status: value.status,
hasData: value.status === ImportStatus.SUCCESS,
hasError: value.status === ImportStatus.FAILED
}
})
return result
}
/**
* 创建高阶组件,包装动态导入逻辑
* @param {Function} importFn - 导入函数
* @param {Object} options - 选项
* @returns {Function} 高阶组件
*/
const createLazyComponent = (importFn, options = {}) => {
const {
loadingComponent = null,
errorComponent = null,
delay = 200,
timeout = 10000
} = options
return {
async setup(props, { slots }) {
const cacheKey = options.cacheKey || importFn.toString()
try {
const module = await dynamicImport(importFn, cacheKey, { timeout })
return module.default || module
} catch (error) {
console.error('Failed to load component:', error)
return errorComponent || { template: '<div>加载失败</div>' }
}
}
}
}
export {
dynamicImport,
preloadComponent,
batchPreloadComponents,
clearImportCache,
getImportStatus,
getAllImportStatus,
createLazyComponent,
ImportStatus
}

View File

@@ -0,0 +1,374 @@
/**
* 全局错误处理工具
* 用于统一处理应用中的各种错误
*/
import { ElMessage, ElNotification } from 'element-plus'
import router from '@/router'
// 错误类型枚举
export const ErrorTypes = {
NETWORK: 'network',
API: 'api',
VALIDATION: 'validation',
PERMISSION: 'permission',
BUSINESS: 'business',
UNKNOWN: 'unknown'
}
// 错误级别枚举
export const ErrorLevels = {
INFO: 'info',
WARNING: 'warning',
ERROR: 'error',
CRITICAL: 'critical'
}
// 错误处理配置
const errorHandlingConfig = {
// 是否显示错误通知
showNotification: true,
// 是否记录错误日志
logError: true,
// 是否上报错误
reportError: true,
// 错误上报URL
reportUrl: '/api/errors/report',
// 最大重试次数
maxRetries: 3,
// 重试延迟(毫秒)
retryDelay: 1000
}
/**
* 错误处理器类
*/
class ErrorHandler {
constructor() {
this.errorQueue = []
this.retryCount = new Map()
this.initGlobalErrorHandlers()
}
/**
* 初始化全局错误处理器
*/
initGlobalErrorHandlers() {
// 监听未捕获的Promise错误
window.addEventListener('unhandledrejection', (event) => {
this.handleError(event.reason, {
type: ErrorTypes.UNKNOWN,
level: ErrorLevels.ERROR,
context: 'unhandledrejection',
promise: event.promise
})
})
// 监听全局JavaScript错误
window.addEventListener('error', (event) => {
this.handleError(event.error || new Error(event.message), {
type: ErrorTypes.UNKNOWN,
level: ErrorLevels.ERROR,
context: 'javascript',
filename: event.filename,
lineno: event.lineno,
colno: event.colno
})
})
}
/**
* 处理错误
* @param {Error} error - 错误对象
* @param {Object} options - 错误处理选项
*/
handleError(error, options = {}) {
const {
type = ErrorTypes.UNKNOWN,
level = ErrorLevels.ERROR,
context = '',
showMessage = true,
customMessage = '',
retryCallback = null,
...otherOptions
} = options
// 构建错误信息
const errorInfo = {
message: error?.message || '未知错误',
stack: error?.stack || '',
type,
level,
context,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
...otherOptions
}
// 记录错误
if (errorHandlingConfig.logError) {
console.error(`[ErrorHandler] ${level.toUpperCase()}:`, error, errorInfo)
}
// 上报错误
if (errorHandlingConfig.reportError) {
this.reportError(errorInfo)
}
// 显示错误消息
if (showMessage) {
this.showErrorMessage(errorInfo, customMessage)
}
// 执行重试回调
if (retryCallback && typeof retryCallback === 'function') {
this.executeRetry(retryCallback, errorInfo)
}
// 将错误添加到队列
this.errorQueue.push(errorInfo)
// 限制错误队列大小
if (this.errorQueue.length > 100) {
this.errorQueue.shift()
}
return errorInfo
}
/**
* 处理网络错误
* @param {Error} error - 错误对象
* @param {Object} options - 错误处理选项
*/
handleNetworkError(error, options = {}) {
return this.handleError(error, {
type: ErrorTypes.NETWORK,
level: ErrorLevels.WARNING,
context: 'network',
customMessage: '网络连接失败,请检查网络设置',
...options
})
}
/**
* 处理API错误
* @param {Object} response - API响应对象
* @param {Object} options - 错误处理选项
*/
handleApiError(response, options = {}) {
const { status, data } = response
let message = '服务器错误'
let level = ErrorLevels.ERROR
// 根据状态码设置错误消息和级别
switch (status) {
case 400:
message = data?.message || '请求参数错误'
level = ErrorLevels.WARNING
break
case 401:
message = '未授权,请重新登录'
level = ErrorLevels.WARNING
// 跳转到登录页
router.push('/login')
break
case 403:
message = '没有权限访问该资源'
level = ErrorLevels.WARNING
break
case 404:
message = '请求的资源不存在'
level = ErrorLevels.WARNING
break
case 500:
message = '服务器内部错误'
level = ErrorLevels.ERROR
break
case 502:
case 503:
case 504:
message = '服务暂时不可用,请稍后重试'
level = ErrorLevels.ERROR
break
default:
message = data?.message || `服务器错误 (${status})`
level = ErrorLevels.ERROR
}
return this.handleError(new Error(message), {
type: ErrorTypes.API,
level,
context: 'api',
status,
responseData: data,
...options
})
}
/**
* 处理验证错误
* @param {Object} errors - 验证错误对象
* @param {Object} options - 错误处理选项
*/
handleValidationErrors(errors, options = {}) {
let message = '输入验证失败'
// 如果是数组,取第一个错误
if (Array.isArray(errors) && errors.length > 0) {
message = errors[0].message || message
}
// 如果是对象,取第一个错误
else if (typeof errors === 'object' && errors !== null) {
const firstKey = Object.keys(errors)[0]
if (firstKey && errors[firstKey]) {
message = Array.isArray(errors[firstKey])
? errors[firstKey][0]
: errors[firstKey].message || message
}
}
return this.handleError(new Error(message), {
type: ErrorTypes.VALIDATION,
level: ErrorLevels.WARNING,
context: 'validation',
validationErrors: errors,
...options
})
}
/**
* 显示错误消息
* @param {Object} errorInfo - 错误信息
* @param {string} customMessage - 自定义消息
*/
showErrorMessage(errorInfo, customMessage = '') {
const message = customMessage || errorInfo.message
const { level } = errorInfo
if (!errorHandlingConfig.showNotification) {
return
}
// 根据错误级别选择不同的显示方式
switch (level) {
case ErrorLevels.INFO:
ElMessage.info(message)
break
case ErrorLevels.WARNING:
ElMessage.warning(message)
break
case ErrorLevels.ERROR:
ElMessage.error(message)
break
case ErrorLevels.CRITICAL:
ElNotification({
title: '严重错误',
message,
type: 'error',
duration: 0, // 不自动关闭
showClose: true
})
break
default:
ElMessage.error(message)
}
}
/**
* 上报错误
* @param {Object} errorInfo - 错误信息
*/
reportError(errorInfo) {
try {
// 使用navigator.sendBeacon进行非阻塞上报
if (navigator.sendBeacon) {
const data = new Blob([JSON.stringify(errorInfo)], {
type: 'application/json'
})
navigator.sendBeacon(errorHandlingConfig.reportUrl, data)
} else {
// 降级使用fetch
fetch(errorHandlingConfig.reportUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(errorInfo),
keepalive: true // 尝试保持连接
}).catch(err => {
console.error('[ErrorHandler] 上报错误失败:', err)
})
}
} catch (err) {
console.error('[ErrorHandler] 上报错误异常:', err)
}
}
/**
* 执行重试
* @param {Function} callback - 重试回调
* @param {Object} errorInfo - 错误信息
*/
executeRetry(callback, errorInfo) {
const errorKey = `${errorInfo.type}_${errorInfo.context}`
const currentRetryCount = this.retryCount.get(errorKey) || 0
if (currentRetryCount < errorHandlingConfig.maxRetries) {
this.retryCount.set(errorKey, currentRetryCount + 1)
ElMessage.info(`正在重试 (${currentRetryCount + 1}/${errorHandlingConfig.maxRetries})...`)
setTimeout(() => {
try {
callback()
} catch (err) {
this.handleError(err, {
type: errorInfo.type,
level: errorInfo.level,
context: `${errorInfo.context}_retry`,
showMessage: false
})
}
}, errorHandlingConfig.retryDelay)
} else {
ElMessage.error('已达到最大重试次数,请稍后再试')
this.retryCount.delete(errorKey)
}
}
/**
* 获取错误队列
*/
getErrorQueue() {
return [...this.errorQueue]
}
/**
* 清空错误队列
*/
clearErrorQueue() {
this.errorQueue = []
}
/**
* 配置错误处理
* @param {Object} config - 配置选项
*/
configure(config) {
Object.assign(errorHandlingConfig, config)
}
}
// 创建全局错误处理器实例
const globalErrorHandler = new ErrorHandler()
export default globalErrorHandler
// 导出便捷方法
export const handleError = (error, options) => globalErrorHandler.handleError(error, options)
export const handleNetworkError = (error, options) => globalErrorHandler.handleNetworkError(error, options)
export const handleApiError = (response, options) => globalErrorHandler.handleApiError(response, options)
export const handleValidationErrors = (errors, options) => globalErrorHandler.handleValidationErrors(errors, options)
export const configureErrorHandler = (config) => globalErrorHandler.configure(config)

View File

@@ -0,0 +1,422 @@
/**
* 图片懒加载工具
* 用于延迟加载图片,优化页面加载性能
*/
/**
* 懒加载配置
*/
const lazyLoadConfig = {
// 根边距,提前多少像素开始加载
rootMargin: '50px',
// 阈值,目标元素可见比例达到多少时开始加载
threshold: 0.1,
// 是否启用淡入效果
enableFadeIn: true,
// 淡入动画持续时间(毫秒)
fadeInDuration: 300,
// 占位图片URL
placeholderImage: '/placeholder.svg',
// 错误图片URL
errorImage: '/placeholder.svg',
// 是否使用WebP格式
useWebP: true,
// 是否启用响应式图片
enableResponsive: true,
// 断点配置
breakpoints: {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
xxl: 1536
}
}
/**
* 图片懒加载管理器
*/
class ImageLazyLoader {
constructor(config = {}) {
this.config = { ...lazyLoadConfig, ...config }
this.observer = null
this.loadedImages = new Set()
this.init()
}
/**
* 初始化懒加载器
*/
init() {
// 检查浏览器支持
if (!('IntersectionObserver' in window)) {
console.warn('浏览器不支持IntersectionObserver将回退到传统懒加载方式')
this.initFallback()
return
}
// 创建Intersection Observer
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
rootMargin: this.config.rootMargin,
threshold: this.config.threshold
}
)
// 初始化现有图片
this.initExistingImages()
// 监听DOM变化
this.observeDOMChanges()
}
/**
* 初始化现有图片
*/
initExistingImages() {
const images = document.querySelectorAll('img[data-src]')
images.forEach(img => this.observeImage(img))
}
/**
* 观察图片
* @param {HTMLImageElement} img - 图片元素
*/
observeImage(img) {
if (!this.observer || this.loadedImages.has(img)) return
// 设置占位图片
if (!img.src && this.config.placeholderImage) {
img.src = this.config.placeholderImage
}
// 添加加载状态类
img.classList.add('lazy-loading')
// 观察图片
this.observer.observe(img)
}
/**
* 停止观察图片
* @param {HTMLImageElement} img - 图片元素
*/
unobserveImage(img) {
if (!this.observer) return
this.observer.unobserve(img)
}
/**
* 处理图片进入视口
* @param {Array} entries - IntersectionObserver条目
*/
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
this.loadImage(img)
this.unobserveImage(img)
}
})
}
/**
* 加载图片
* @param {HTMLImageElement} img - 图片元素
*/
loadImage(img) {
const src = img.dataset.src
if (!src || this.loadedImages.has(img)) return
// 创建新图片对象进行预加载
const newImg = new Image()
// 处理加载成功
newImg.onload = () => {
// 设置实际图片源
img.src = src
// 移除加载状态类
img.classList.remove('lazy-loading')
img.classList.add('lazy-loaded')
// 添加淡入效果
if (this.config.enableFadeIn) {
img.style.opacity = '0'
img.style.transition = `opacity ${this.config.fadeInDuration}ms ease-in-out`
// 触发重排以应用过渡效果
img.offsetHeight
img.style.opacity = '1'
}
// 标记为已加载
this.loadedImages.add(img)
// 触发自定义事件
img.dispatchEvent(new CustomEvent('lazyload', { detail: { img, src } }))
}
// 处理加载失败
newImg.onerror = () => {
// 设置错误图片
if (this.config.errorImage) {
img.src = this.config.errorImage
}
// 移除加载状态类
img.classList.remove('lazy-loading')
img.classList.add('lazy-error')
// 触发自定义事件
img.dispatchEvent(new CustomEvent('lazyloaderror', { detail: { img, src } }))
}
// 设置图片源
newImg.src = this.processImageSrc(src)
}
/**
* 处理图片源
* @param {string} src - 原始图片源
*/
processImageSrc(src) {
// 如果启用WebP且浏览器支持
if (this.config.useWebP && this.supportsWebP() && !src.match(/\.(webp)(\?.*)?$/i)) {
// 检查是否已经包含查询参数
const separator = src.includes('?') ? '&' : '?'
return `${src}${separator}format=webp`
}
return src
}
/**
* 检查浏览器是否支持WebP
*/
supportsWebP() {
// 简单的WebP支持检测
const canvas = document.createElement('canvas')
canvas.width = 1
canvas.height = 1
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0
}
/**
* 初始化回退方案
*/
initFallback() {
// 使用传统的滚动事件监听
let ticking = false
const checkImages = () => {
const images = document.querySelectorAll('img[data-src]:not(.lazy-loaded):not(.lazy-error)')
images.forEach(img => {
if (this.isInViewport(img)) {
this.loadImage(img)
}
})
ticking = false
}
const requestTick = () => {
if (!ticking) {
requestAnimationFrame(checkImages)
ticking = true
}
}
// 监听滚动事件
window.addEventListener('scroll', requestTick)
window.addEventListener('resize', requestTick)
// 初始检查
checkImages()
}
/**
* 检查元素是否在视口中
* @param {Element} element - 要检查的元素
*/
isInViewport(element) {
const rect = element.getBoundingClientRect()
const rootMargin = this.parseRootMargin(this.config.rootMargin)
return (
rect.bottom >= -rootMargin.bottom &&
rect.right >= -rootMargin.left &&
rect.top <= window.innerHeight + rootMargin.top &&
rect.left <= window.innerWidth + rootMargin.right
)
}
/**
* 解析根边距
* @param {string} rootMargin - 根边距字符串
*/
parseRootMargin(rootMargin) {
const values = rootMargin.split(/\s+/).map(v => parseInt(v) || 0)
// 默认值
let top = 0, right = 0, bottom = 0, left = 0
switch (values.length) {
case 1:
top = right = bottom = left = values[0]
break
case 2:
top = bottom = values[0]
left = right = values[1]
break
case 3:
top = values[0]
left = right = values[1]
bottom = values[2]
break
case 4:
top = values[0]
right = values[1]
bottom = values[2]
left = values[3]
break
}
return { top, right, bottom, left }
}
/**
* 监听DOM变化
*/
observeDOMChanges() {
if (!('MutationObserver' in window)) return
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
// 检查新增的节点是否是图片或包含图片
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.tagName === 'IMG' && node.dataset.src) {
this.observeImage(node)
} else if (node.querySelectorAll) {
const images = node.querySelectorAll('img[data-src]')
images.forEach(img => this.observeImage(img))
}
}
})
})
})
observer.observe(document.body, {
childList: true,
subtree: true
})
}
/**
* 创建响应式图片
* @param {string} baseSrc - 基础图片URL
* @param {Object} options - 选项
*/
createResponsiveImage(baseSrc, options = {}) {
if (!this.config.enableResponsive) return baseSrc
const { sizes, breakpoints = this.config.breakpoints } = options
// 如果没有提供sizes使用默认断点
if (!sizes) {
return baseSrc
}
// 创建srcset
const srcset = Object.entries(sizes)
.sort(([a], [b]) => {
// 按断点大小排序
return breakpoints[a] - breakpoints[b]
})
.map(([breakpoint, width]) => {
const breakpointWidth = breakpoints[breakpoint]
const separator = baseSrc.includes('?') ? '&' : '?'
return `${baseSrc}${separator}width=${width} ${breakpointWidth}w`
})
.join(', ')
return srcset
}
/**
* 重置懒加载器
*/
reset() {
// 停止观察所有图片
if (this.observer) {
this.observer.disconnect()
}
// 清空已加载图片集合
this.loadedImages.clear()
// 重新初始化
this.init()
}
/**
* 销毁懒加载器
*/
destroy() {
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
this.loadedImages.clear()
}
}
// 创建默认懒加载器实例
const defaultLazyLoader = new ImageLazyLoader()
/**
* 初始化图片懒加载
* @param {Object} config - 配置选项
*/
export function initLazyLoad(config = {}) {
return new ImageLazyLoader(config)
}
/**
* 观察单个图片
* @param {HTMLImageElement} img - 图片元素
* @param {Object} config - 配置选项
*/
export function observeImage(img, config = {}) {
if (config.createNew) {
const loader = new ImageLazyLoader(config)
loader.observeImage(img)
return loader
} else {
defaultLazyLoader.observeImage(img)
}
}
/**
* 创建响应式图片
* @param {string} baseSrc - 基础图片URL
* @param {Object} options - 选项
*/
export function createResponsiveImage(baseSrc, options = {}) {
return defaultLazyLoader.createResponsiveImage(baseSrc, options)
}
/**
* 检查浏览器是否支持WebP
*/
export function supportsWebP() {
return defaultLazyLoader.supportsWebP()
}
export default defaultLazyLoader

View File

@@ -0,0 +1,467 @@
/**
* 前端性能监控和错误上报工具
* 用于收集应用性能数据和错误信息,并上报到服务器
*/
// 配置信息
const config = {
// 错误上报API地址
errorReportUrl: '/api/errors',
// 性能数据上报API地址
performanceReportUrl: '/api/performance',
// 是否启用错误上报
enableErrorReporting: true,
// 是否启用性能监控
enablePerformanceMonitoring: true,
// 上报批次大小
batchSize: 10,
// 上报间隔(毫秒)
reportInterval: 30000, // 30秒
// 最大重试次数
maxRetries: 3,
// 是否在开发环境也上报
reportInDevelopment: false
}
// 错误数据存储
const errorQueue = []
const performanceQueue = []
// 性能指标收集
const performanceMetrics = {
// 页面加载时间
pageLoadTime: 0,
// 首次内容绘制时间
firstContentfulPaint: 0,
// 最大内容绘制时间
largestContentfulPaint: 0,
// 首次输入延迟
firstInputDelay: 0,
// 累积布局偏移
cumulativeLayoutShift: 0,
// API请求时间
apiRequestTimes: {},
// 路由切换时间
routeChangeTimes: {}
}
/**
* 获取当前环境
* @returns {string} 当前环境development, production
*/
function getCurrentEnvironment() {
return import.meta.env.MODE || 'development'
}
/**
* 判断是否应该上报数据
* @returns {boolean} 是否应该上报
*/
function shouldReport() {
const env = getCurrentEnvironment()
return config.enableErrorReporting && (env === 'production' || config.reportInDevelopment)
}
/**
* 收集浏览器和设备信息
* @returns {Object} 浏览器和设备信息
*/
function collectBrowserInfo() {
const ua = navigator.userAgent
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection
return {
userAgent: ua,
url: window.location.href,
timestamp: Date.now(),
environment: getCurrentEnvironment(),
// 屏幕信息
screen: {
width: screen.width,
height: screen.height,
colorDepth: screen.colorDepth
},
// 视口信息
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
// 网络信息
connection: connection ? {
effectiveType: connection.effectiveType,
downlink: connection.downlink,
rtt: connection.rtt
} : null,
// 内存信息(如果支持)
memory: performance.memory ? {
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
} : null
}
}
/**
* 上报数据到服务器
* @param {string} url - 上报URL
* @param {Object} data - 上报数据
* @param {number} retryCount - 当前重试次数
*/
// 上报数据到服务器
const reportData = async (data) => {
if (!config.reportUrl || config.isDevelopment) {
console.log('开发环境不上报数据:', data)
return
}
try {
await fetch(config.reportUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
console.log('数据上报成功:', data)
} catch (error) {
console.error('数据上报失败:', error)
}
}
/**
* 批量上报错误数据
*/
function reportErrors() {
if (errorQueue.length === 0) return
const errors = errorQueue.splice(0, config.batchSize)
reportData(config.errorReportUrl, {
errors,
browserInfo: collectBrowserInfo()
})
}
/**
* 批量上报性能数据
*/
function reportPerformance() {
if (performanceQueue.length === 0) return
const metrics = performanceQueue.splice(0, config.batchSize)
reportData(config.performanceReportUrl, {
metrics,
browserInfo: collectBrowserInfo()
})
}
/**
* 定期上报数据
*/
function startPeriodicReporting() {
if (!shouldReport()) return
setInterval(() => {
reportErrors()
reportPerformance()
}, config.reportInterval)
}
/**
* 捕获JavaScript错误
* @param {Error} error - 错误对象
* @param {Object} errorInfo - 错误信息
*/
function captureError(error, errorInfo = {}) {
if (!config.enableErrorReporting) return
const errorData = {
message: error.message || '未知错误',
stack: error.stack || '',
name: error.name || 'Error',
...errorInfo,
timestamp: Date.now(),
url: window.location.href
}
errorQueue.push(errorData)
// 如果队列达到批次大小,立即上报
if (errorQueue.length >= config.batchSize) {
reportErrors()
}
}
/**
* 捕获未处理的Promise错误
* @param {PromiseRejectionEvent} event - Promise拒绝事件
*/
function captureUnhandledRejection(event) {
captureError(new Error(event.reason), {
type: 'unhandledRejection'
})
}
/**
* 记录API请求时间
* @param {string} url - API URL
* @param {number} duration - 请求耗时(毫秒)
* @param {number} status - HTTP状态码
* @param {string} method - HTTP方法
*/
function recordApiRequest(url, duration, status, method = 'GET') {
if (!config.enablePerformanceMonitoring) return
const apiInfo = {
url,
duration,
status,
method,
timestamp: Date.now()
}
// 存储到性能队列
performanceQueue.push({
type: 'api',
data: apiInfo
})
// 更新API请求时间统计
if (!performanceMetrics.apiRequestTimes[url]) {
performanceMetrics.apiRequestTimes[url] = []
}
performanceMetrics.apiRequestTimes[url].push({
duration,
status,
timestamp: Date.now()
})
// 如果队列达到批次大小,立即上报
if (performanceQueue.length >= config.batchSize) {
reportPerformance()
}
}
/**
* 记录路由切换时间
* @param {string} from - 来源路由
* @param {string} to - 目标路由
* @param {number} duration - 切换耗时(毫秒)
*/
function recordRouteChange(from, to, duration) {
if (!config.enablePerformanceMonitoring) return
const routeInfo = {
from,
to,
duration,
timestamp: Date.now()
}
// 存储到性能队列
performanceQueue.push({
type: 'route',
data: routeInfo
})
// 更新路由切换时间统计
const routeKey = `${from} -> ${to}`
if (!performanceMetrics.routeChangeTimes[routeKey]) {
performanceMetrics.routeChangeTimes[routeKey] = []
}
performanceMetrics.routeChangeTimes[routeKey].push({
duration,
timestamp: Date.now()
})
// 如果队列达到批次大小,立即上报
if (performanceQueue.length >= config.batchSize) {
reportPerformance()
}
}
/**
* 收集Web Vitals性能指标
*/
function collectWebVitals() {
if (!config.enablePerformanceMonitoring) return
// 首次内容绘制时间
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
performanceMetrics.firstContentfulPaint = entry.startTime
performanceQueue.push({
type: 'web-vital',
data: {
metric: 'firstContentfulPaint',
value: entry.startTime,
timestamp: Date.now()
}
})
}
}
})
try {
observer.observe({ entryTypes: ['paint'] })
} catch (e) {
// 某些浏览器可能不支持
}
// 最大内容绘制时间
if ('PerformanceObserver' in window) {
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
performanceMetrics.largestContentfulPaint = lastEntry.startTime
performanceQueue.push({
type: 'web-vital',
data: {
metric: 'largestContentfulPaint',
value: lastEntry.startTime,
timestamp: Date.now()
}
})
})
try {
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
} catch (e) {
// 某些浏览器可能不支持
}
}
// 累积布局偏移
if ('PerformanceObserver' in window) {
let clsValue = 0
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value
}
}
performanceMetrics.cumulativeLayoutShift = clsValue
performanceQueue.push({
type: 'web-vital',
data: {
metric: 'cumulativeLayoutShift',
value: clsValue,
timestamp: Date.now()
}
})
})
try {
clsObserver.observe({ entryTypes: ['layout-shift'] })
} catch (e) {
// 某些浏览器可能不支持
}
}
// 首次输入延迟
if ('PerformanceObserver' in window) {
const fidObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
performanceMetrics.firstInputDelay = entry.processingStart - entry.startTime
performanceQueue.push({
type: 'web-vital',
data: {
metric: 'firstInputDelay',
value: entry.processingStart - entry.startTime,
timestamp: Date.now()
}
})
}
})
try {
fidObserver.observe({ entryTypes: ['first-input'] })
} catch (e) {
// 某些浏览器可能不支持
}
}
}
/**
* 初始化性能监控和错误上报
*/
function init() {
// 页面加载完成后收集性能指标
window.addEventListener('load', () => {
// 页面加载时间
const navigation = performance.getEntriesByType('navigation')[0]
if (navigation) {
performanceMetrics.pageLoadTime = navigation.loadEventEnd - navigation.fetchStart
performanceQueue.push({
type: 'page-load',
data: {
value: performanceMetrics.pageLoadTime,
timestamp: Date.now()
}
})
}
// 收集Web Vitals
collectWebVitals()
})
// 捕获JavaScript错误
window.addEventListener('error', (event) => {
captureError(event.error || new Error(event.message), {
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
type: 'javascript'
})
})
// 捕获未处理的Promise错误
window.addEventListener('unhandledrejection', captureUnhandledRejection)
// 启动定期上报
startPeriodicReporting()
}
/**
* 获取当前性能指标
* @returns {Object} 性能指标对象
*/
function getPerformanceMetrics() {
return { ...performanceMetrics }
}
/**
* 配置监控选项
* @param {Object} options - 配置选项
*/
function configure(options) {
Object.assign(config, options)
}
// 导出API
export default {
init,
configure,
captureError,
recordApiRequest,
recordRouteChange,
getPerformanceMetrics,
reportErrors: () => reportErrors(),
reportPerformance: () => reportPerformance()
}

View File

@@ -0,0 +1,455 @@
/**
* 性能指标收集和分析工具
* 用于收集和分析前端应用的各种性能指标
*/
// 性能指标数据
const performanceMetrics = {
// 导航指标
navigation: {},
// 资源加载指标
resources: [],
// 用户交互指标
interactions: [],
// 自定义指标
custom: [],
// 长任务
longTasks: [],
// 内存使用
memory: {}
}
// 性能观察器
let navigationObserver = null
let resourceObserver = null
let longTaskObserver = null
let interactionObserver = null
// 配置选项
const config = {
// 是否启用性能监控
enabled: true,
// 上报URL
reportUrl: '/api/performance/report',
// 采样率 (0-1)
sampleRate: 1.0,
// 最大指标数量
maxMetrics: 100,
// 上报间隔 (毫秒)
reportInterval: 30000,
// 长任务阈值 (毫秒)
longTaskThreshold: 50,
// 用户交互延迟阈值 (毫秒)
interactionDelayThreshold: 100
}
// 上报定时器
let reportTimer = null
/**
* 初始化性能监控
* @param {Object} options - 配置选项
*/
const init = (options = {}) => {
Object.assign(config, options)
if (!config.enabled) return
// 设置定时上报
if (config.reportInterval > 0) {
reportTimer = setInterval(() => {
reportMetrics()
}, config.reportInterval)
}
// 页面卸载时上报数据
window.addEventListener('beforeunload', () => {
reportMetrics()
})
// 页面隐藏时上报数据
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
reportMetrics()
}
})
// 收集导航指标
collectNavigationMetrics()
// 设置性能观察器
setupPerformanceObservers()
// 收集内存信息
collectMemoryMetrics()
console.log('性能监控已初始化')
}
/**
* 收集导航指标
*/
const collectNavigationMetrics = () => {
if (!window.performance || !window.performance.getEntriesByType) return
const navigationEntries = window.performance.getEntriesByType('navigation')
if (navigationEntries.length > 0) {
const nav = navigationEntries[0]
performanceMetrics.navigation = {
// DNS查询时间
dnsLookup: nav.domainLookupEnd - nav.domainLookupStart,
// TCP连接时间
tcpConnection: nav.connectEnd - nav.connectStart,
// 请求响应时间
requestResponse: nav.responseEnd - nav.requestStart,
// DOM解析时间
domParsing: nav.domContentLoadedEventEnd - nav.domLoading,
// 资源加载时间
resourceLoading: nav.loadEventEnd - nav.domContentLoadedEventEnd,
// 首字节时间
firstByte: nav.responseStart - nav.requestStart,
// 首次渲染时间
firstPaint: null,
// 首次内容渲染时间
firstContentfulPaint: null,
// 最大内容渲染时间
largestContentfulPaint: null,
// 首次输入延迟
firstInputDelay: null,
// 累计布局偏移
cumulativeLayoutShift: null
}
// 收集Paint指标
const paintEntries = window.performance.getEntriesByType('paint')
paintEntries.forEach(entry => {
if (entry.name === 'first-paint') {
performanceMetrics.navigation.firstPaint = entry.startTime
} else if (entry.name === 'first-contentful-paint') {
performanceMetrics.navigation.firstContentfulPaint = entry.startTime
}
})
}
}
/**
* 设置性能观察器
*/
const setupPerformanceObservers = () => {
// 观察资源加载
if ('PerformanceObserver' in window) {
try {
// 资源加载观察器
resourceObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
if (performanceMetrics.resources.length >= config.maxMetrics) {
performanceMetrics.resources.shift()
}
performanceMetrics.resources.push({
name: entry.name,
type: entry.initiatorType,
duration: entry.duration,
size: entry.transferSize || 0,
startTime: entry.startTime,
responseEnd: entry.responseEnd
})
})
})
resourceObserver.observe({ entryTypes: ['resource'] })
// 长任务观察器
if ('PerformanceLongTaskTiming' in window) {
longTaskObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
if (entry.duration > config.longTaskThreshold) {
if (performanceMetrics.longTasks.length >= config.maxMetrics) {
performanceMetrics.longTasks.shift()
}
performanceMetrics.longTasks.push({
duration: entry.duration,
startTime: entry.startTime,
name: entry.name || 'unknown'
})
}
})
})
longTaskObserver.observe({ entryTypes: ['longtask'] })
}
// 最大内容渲染时间观察器
if ('PerformanceObserver' in window && 'largest-contentful-paint' in PerformanceObserver.supportedEntryTypes) {
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
performanceMetrics.navigation.largestContentfulPaint = lastEntry.startTime
})
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
}
// 累计布局偏移观察器
if ('PerformanceObserver' in window && 'layout-shift' in PerformanceObserver.supportedEntryTypes) {
let clsValue = 0
const clsObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
if (!entry.hadRecentInput) {
clsValue += entry.value
}
})
performanceMetrics.navigation.cumulativeLayoutShift = clsValue
})
clsObserver.observe({ entryTypes: ['layout-shift'] })
}
// 首次输入延迟观察器
if ('PerformanceObserver' in window && 'first-input' in PerformanceObserver.supportedEntryTypes) {
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
if (entry.processingStart - entry.startTime > 0) {
performanceMetrics.navigation.firstInputDelay = entry.processingStart - entry.startTime
}
})
})
fidObserver.observe({ entryTypes: ['first-input'] })
}
} catch (error) {
console.error('设置性能观察器失败:', error)
}
}
}
/**
* 收集内存指标
*/
const collectMemoryMetrics = () => {
if (window.performance && window.performance.memory) {
performanceMetrics.memory = {
usedJSHeapSize: window.performance.memory.usedJSHeapSize,
totalJSHeapSize: window.performance.memory.totalJSHeapSize,
jsHeapSizeLimit: window.performance.memory.jsHeapSizeLimit
}
}
}
/**
* 记录用户交互指标
* @param {string} name - 交互名称
* @param {number} startTime - 开始时间
* @param {number} endTime - 结束时间
* @param {Object} metadata - 元数据
*/
const recordInteraction = (name, startTime, endTime, metadata = {}) => {
if (!config.enabled) return
const duration = endTime - startTime
if (duration > config.interactionDelayThreshold) {
if (performanceMetrics.interactions.length >= config.maxMetrics) {
performanceMetrics.interactions.shift()
}
performanceMetrics.interactions.push({
name,
duration,
startTime,
endTime,
metadata
})
}
}
/**
* 记录自定义指标
* @param {string} name - 指标名称
* @param {number} value - 指标值
* @param {Object} metadata - 元数据
*/
const recordCustomMetric = (name, value, metadata = {}) => {
if (!config.enabled) return
if (performanceMetrics.custom.length >= config.maxMetrics) {
performanceMetrics.custom.shift()
}
performanceMetrics.custom.push({
name,
value,
timestamp: Date.now(),
metadata
})
}
/**
* 获取页面加载性能评分
* @returns {Object} 性能评分
*/
const getPerformanceScore = () => {
const nav = performanceMetrics.navigation
const score = {
overall: 0,
metrics: {}
}
// FCP评分 (0-100)
if (nav.firstContentfulPaint !== null) {
let fcpScore = 100
if (nav.firstContentfulPaint > 1800) {
fcpScore = 50
} else if (nav.firstContentfulPaint > 3000) {
fcpScore = 0
}
score.metrics.firstContentfulPaint = fcpScore
}
// LCP评分 (0-100)
if (nav.largestContentfulPaint !== null) {
let lcpScore = 100
if (nav.largestContentfulPaint > 2500) {
lcpScore = 50
} else if (nav.largestContentfulPaint > 4000) {
lcpScore = 0
}
score.metrics.largestContentfulPaint = lcpScore
}
// FID评分 (0-100)
if (nav.firstInputDelay !== null) {
let fidScore = 100
if (nav.firstInputDelay > 100) {
fidScore = 50
} else if (nav.firstInputDelay > 300) {
fidScore = 0
}
score.metrics.firstInputDelay = fidScore
}
// CLS评分 (0-100)
if (nav.cumulativeLayoutShift !== null) {
let clsScore = 100
if (nav.cumulativeLayoutShift > 0.1) {
clsScore = 50
} else if (nav.cumulativeLayoutShift > 0.25) {
clsScore = 0
}
score.metrics.cumulativeLayoutShift = clsScore
}
// 计算总体评分
const metricValues = Object.values(score.metrics)
if (metricValues.length > 0) {
score.overall = Math.round(metricValues.reduce((sum, value) => sum + value, 0) / metricValues.length)
}
return score
}
/**
* 上报性能指标
*/
const reportMetrics = () => {
if (!config.enabled) return
// 采样率检查
if (Math.random() > config.sampleRate) {
return
}
// 收集最新的内存指标
collectMemoryMetrics()
// 获取性能评分
const score = getPerformanceScore()
const data = {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
metrics: performanceMetrics,
score
}
// 发送数据
if (navigator.sendBeacon) {
navigator.sendBeacon(config.reportUrl, JSON.stringify(data))
} else {
fetch(config.reportUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data),
keepalive: true
}).catch(error => {
console.error('上报性能指标失败:', error)
})
}
}
/**
* 清除性能指标
*/
const clearMetrics = () => {
performanceMetrics.navigation = {}
performanceMetrics.resources = []
performanceMetrics.interactions = []
performanceMetrics.custom = []
performanceMetrics.longTasks = []
performanceMetrics.memory = {}
}
/**
* 获取性能指标
* @returns {Object} 性能指标
*/
const getMetrics = () => {
// 收集最新的内存指标
collectMemoryMetrics()
return {
...performanceMetrics,
score: getPerformanceScore()
}
}
/**
* 销毁性能监控
*/
const destroy = () => {
if (reportTimer) {
clearInterval(reportTimer)
reportTimer = null
}
if (resourceObserver) {
resourceObserver.disconnect()
resourceObserver = null
}
if (longTaskObserver) {
longTaskObserver.disconnect()
longTaskObserver = null
}
if (interactionObserver) {
interactionObserver.disconnect()
interactionObserver = null
}
}
export {
init,
recordInteraction,
recordCustomMetric,
getMetrics,
getPerformanceScore,
reportMetrics,
clearMetrics,
destroy,
config
}

View File

@@ -0,0 +1,257 @@
/**
* Pinia状态持久化插件
* 自动将Pinia store状态持久化到本地存储
* 支持选择性持久化、数据加密、过期时间等功能
*/
import { StatePersistManager, StorageType } from './statePersist'
/**
* 创建Pinia持久化插件
* @param {Object} options 配置选项
* @param {string} options.key store的唯一标识默认使用store.$id
* @param {Array|string} options.paths 需要持久化的状态路径,默认持久化所有状态
* @param {string} options.storage 存储类型默认localStorage
* @param {boolean} options.encrypt 是否加密默认false
* @param {number} options.expire 过期时间(毫秒)0表示永不过期
* @param {Function} options.serializer 自定义序列化函数
* @param {Function} options.deserializer 自定义反序列化函数
* @param {Function} options.beforeRestore 恢复前钩子
* @param {Function} options.afterRestore 恢复后钩子
* @param {Function} options.filter 过滤函数返回false的状态将不会被持久化
* @returns {Function} Pinia插件函数
*/
export const createPersistPlugin = (options = {}) => {
// 默认配置
const defaultOptions = {
storage: StorageType.LOCAL,
encrypt: false,
expire: 0,
serializer: JSON.stringify,
deserializer: JSON.parse
}
const config = { ...defaultOptions, ...options }
// 创建持久化管理器
const persistManager = new StatePersistManager({
storageType: config.storage,
encrypt: config.encrypt,
defaultExpire: config.expire
})
// 返回Pinia插件函数
return (context) => {
const { store, options: piniaOptions } = context
const storeId = config.key || store.$id
// 初始化时从存储中恢复状态
const initPersist = async () => {
try {
// 从存储中获取数据
const persistedState = await persistManager.get(storeId)
if (persistedState) {
// 执行恢复前钩子
if (config.beforeRestore) {
await config.beforeRestore({ store, persistedState })
}
// 恢复状态
if (config.paths) {
// 选择性恢复指定路径的状态
if (Array.isArray(config.paths)) {
config.paths.forEach(path => {
if (persistedState[path] !== undefined) {
store[path] = persistedState[path]
}
})
} else {
// 单个路径
if (persistedState[config.paths] !== undefined) {
store[config.paths] = persistedState[config.paths]
}
}
} else {
// 恢复所有状态
store.$patch(persistedState)
}
// 执行恢复后钩子
if (config.afterRestore) {
await config.afterRestore({ store })
}
}
} catch (error) {
console.error(`Failed to restore state for store ${storeId}:`, error)
}
}
// 监听状态变化并持久化
const persistState = async (state) => {
try {
let stateToPersist = state
// 应用过滤器
if (config.filter) {
if (config.paths) {
stateToPersist = {}
if (Array.isArray(config.paths)) {
config.paths.forEach(path => {
if (config.filter(path, state[path])) {
stateToPersist[path] = state[path]
}
})
} else {
if (config.filter(config.paths, state[config.paths])) {
stateToPersist[config.paths] = state[config.paths]
}
}
} else {
stateToPersist = {}
Object.keys(state).forEach(key => {
if (config.filter(key, state[key])) {
stateToPersist[key] = state[key]
}
})
}
} else if (config.paths) {
// 选择性持久化指定路径的状态
stateToPersist = {}
if (Array.isArray(config.paths)) {
config.paths.forEach(path => {
if (state[path] !== undefined) {
stateToPersist[path] = state[path]
}
})
} else {
if (state[config.paths] !== undefined) {
stateToPersist[config.paths] = state[config.paths]
}
}
}
// 持久化状态
await persistManager.set(storeId, stateToPersist)
} catch (error) {
console.error(`Failed to persist state for store ${storeId}:`, error)
}
}
// 初始化持久化
initPersist()
// 订阅状态变化
store.$subscribe(async (mutation, state) => {
// 只在直接变更状态时持久化,避免循环更新
if (mutation.type === 'direct') {
await persistState(state)
}
}, { detached: true })
// 添加手动持久化方法
store.$persist = async (state) => {
await persistState(state || store.$state)
}
// 添加手动恢复方法
store.$restore = async () => {
await initPersist()
}
// 添加清除持久化数据方法
store.$clearPersist = async () => {
await persistManager.remove(storeId)
}
}
}
/**
* 创建带有持久化功能的store
* @param {string} id store ID
* @param {Object} options store选项
* @param {Object} persistOptions 持久化选项
* @returns {Object} Pinia定义对象
*/
export const definePersistStore = (id, options, persistOptions = {}) => {
return {
id,
...options,
actions: {
...options.actions,
// 添加手动持久化方法
async $persist(state) {
const persistManager = new StatePersistManager({
storageType: persistOptions.storage || StorageType.LOCAL,
encrypt: persistOptions.encrypt || false,
defaultExpire: persistOptions.expire || 0
})
await persistManager.set(id, state || this.$state)
},
// 添加手动恢复方法
async $restore() {
const persistManager = new StatePersistManager({
storageType: persistOptions.storage || StorageType.LOCAL,
encrypt: persistOptions.encrypt || false,
defaultExpire: persistOptions.expire || 0
})
const persistedState = await persistManager.get(id)
if (persistedState) {
this.$patch(persistedState)
}
},
// 添加清除持久化数据方法
async $clearPersist() {
const persistManager = new StatePersistManager({
storageType: persistOptions.storage || StorageType.LOCAL,
encrypt: persistOptions.encrypt || false,
defaultExpire: persistOptions.expire || 0
})
await persistManager.remove(id)
}
}
}
}
/**
* 常用预设配置
*/
export const presetConfigs = {
// 用户信息持久化配置使用sessionStorage关闭浏览器后清除
userSession: {
storage: StorageType.SESSION,
encrypt: false,
expire: 0, // sessionStorage本身会在关闭浏览器后清除
paths: ['user', 'token']
},
// 应用设置持久化配置(长期保存)
appSettings: {
storage: StorageType.LOCAL,
encrypt: false,
expire: 0, // 永不过期
paths: ['settings', 'theme', 'language']
},
// 敏感数据持久化配置(加密存储)
sensitiveData: {
storage: StorageType.LOCAL,
encrypt: true,
expire: 7 * 24 * 60 * 60 * 1000, // 7天过期
paths: ['credentials', 'secrets']
},
// 临时数据持久化配置(短期存储)
tempData: {
storage: StorageType.LOCAL,
encrypt: false,
expire: 24 * 60 * 60 * 1000, // 24小时过期
paths: ['cache', 'temp']
}
}
// 导出默认插件
export default createPersistPlugin

355
frontend/src/utils/pwa.js Normal file
View File

@@ -0,0 +1,355 @@
// PWA工具文件用于管理Service Worker和离线功能
import { ElMessage, ElNotification } from 'element-plus'
// Service Worker注册状态
let swRegistration = null
let isOnline = navigator.onLine
// PWA初始化
export async function initPWA() {
try {
// 注册Service Worker
await registerServiceWorker()
// 监听网络状态变化
setupNetworkListeners()
// 检查更新
checkForUpdates()
console.log('[PWA] 初始化成功')
} catch (error) {
console.error('[PWA] 初始化失败:', error)
}
}
// 注册Service Worker
export async function registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
swRegistration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
})
console.log('[PWA] Service Worker 注册成功:', swRegistration.scope)
// 监听Service Worker更新
swRegistration.addEventListener('updatefound', () => {
const newWorker = swRegistration.installing
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本可用
showUpdateNotification()
}
})
})
return swRegistration
} catch (error) {
console.error('[PWA] Service Worker 注册失败:', error)
throw error
}
} else {
console.warn('[PWA] 当前浏览器不支持Service Worker')
throw new Error('浏览器不支持Service Worker')
}
}
// 检查Service Worker更新
export async function checkForUpdates() {
if (!swRegistration) return
try {
await swRegistration.update()
console.log('[PWA] 检查Service Worker更新完成')
} catch (error) {
console.error('[PWA] 检查Service Worker更新失败:', error)
}
}
// 显示更新通知
function showUpdateNotification() {
ElNotification({
title: '应用更新',
message: '发现新版本,点击刷新获取最新功能',
type: 'info',
duration: 0, // 不自动关闭
onClick: () => {
// 刷新页面以应用更新
window.location.reload()
}
})
}
// 应用更新
export async function applyUpdate() {
if (!swRegistration || !swRegistration.waiting) {
console.warn('[PWA] 没有可用的更新')
return false
}
try {
// 发送消息给等待中的Service Worker告诉它跳过等待
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' })
// 刷新页面以应用更新
window.location.reload()
return true
} catch (error) {
console.error('[PWA] 应用更新失败:', error)
return false
}
}
// 设置网络状态监听
function setupNetworkListeners() {
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
}
// 处理网络连接
function handleOnline() {
isOnline = true
console.log('[PWA] 网络已连接')
ElMessage.success('网络已连接')
// 触发后台同步
triggerBackgroundSync()
}
// 处理网络断开
function handleOffline() {
isOnline = false
console.log('[PWA] 网络已断开')
ElMessage.warning('网络已断开,当前处于离线模式')
}
// 触发后台同步
export async function triggerBackgroundSync() {
if (!swRegistration) return
try {
// 注册后台同步事件
await swRegistration.sync.register('background-sync')
console.log('[PWA] 后台同步注册成功')
} catch (error) {
console.error('[PWA] 后台同步注册失败:', error)
}
}
// 订阅推送通知
export async function subscribeToPushNotifications() {
if (!swRegistration) {
console.error('[PWA] Service Worker未注册')
return null
}
try {
// 请求推送通知权限
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
console.warn('[PWA] 用户拒绝了推送通知权限')
return null
}
// 订阅推送通知
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.VITE_VAPID_PUBLIC_KEY)
})
console.log('[PWA] 推送通知订阅成功:', subscription)
// 将订阅信息发送到服务器
await sendSubscriptionToServer(subscription)
return subscription
} catch (error) {
console.error('[PWA] 推送通知订阅失败:', error)
return null
}
}
// 取消推送通知订阅
export async function unsubscribeFromPushNotifications() {
if (!swRegistration) return false
try {
const subscription = await swRegistration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
console.log('[PWA] 推送通知订阅已取消')
// 通知服务器删除订阅信息
await removeSubscriptionFromServer(subscription)
return true
}
return false
} catch (error) {
console.error('[PWA] 取消推送通知订阅失败:', error)
return false
}
}
// 将VAPID公钥转换为Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
// 将订阅信息发送到服务器
async function sendSubscriptionToServer(subscription) {
try {
const response = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
if (!response.ok) {
throw new Error('服务器响应错误')
}
console.log('[PWA] 订阅信息已发送到服务器')
} catch (error) {
console.error('[PWA] 发送订阅信息到服务器失败:', error)
throw error
}
}
// 从服务器删除订阅信息
async function removeSubscriptionFromServer(subscription) {
try {
const response = await fetch('/api/notifications/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
if (!response.ok) {
throw new Error('服务器响应错误')
}
console.log('[PWA] 订阅信息已从服务器删除')
} catch (error) {
console.error('[PWA] 从服务器删除订阅信息失败:', error)
throw error
}
}
// 显示本地通知
export function showLocalNotification(title, options = {}) {
if (!('Notification' in window)) {
console.warn('[PWA] 当前浏览器不支持通知')
return
}
if (Notification.permission === 'granted') {
const notification = new Notification(title, {
icon: '/favicon.ico',
badge: '/favicon.ico',
...options
})
// 自动关闭通知
if (options.autoClose !== false) {
setTimeout(() => {
notification.close()
}, options.duration || 5000)
}
return notification
} else if (Notification.permission !== 'denied') {
// 请求通知权限
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
showLocalNotification(title, options)
}
})
}
}
// 检查PWA安装条件
export function checkPWAInstallable() {
// 检查是否在PWA模式下运行
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true
// 检查是否支持安装
const isInstallable = 'beforeinstallprompt' in window
return {
isPWA,
isInstallable,
isInstalled: isPWA
}
}
// 获取网络状态信息
export function getNetworkInfo() {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection
return {
isOnline,
effectiveType: connection ? connection.effectiveType : 'unknown',
downlink: connection ? connection.downlink : 'unknown',
rtt: connection ? connection.rtt : 'unknown',
saveData: connection ? connection.saveData : false
}
}
// 缓存管理
export const cacheManager = {
// 获取缓存大小
async getCacheSize() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate()
return {
usage: estimate.usage,
quota: estimate.quota,
usagePercentage: (estimate.usage / estimate.quota * 100).toFixed(2)
}
}
return null
},
// 清除缓存
async clearCache() {
if ('caches' in window) {
try {
const cacheNames = await caches.keys()
await Promise.all(cacheNames.map(name => caches.delete(name)))
console.log('[PWA] 缓存已清除')
return true
} catch (error) {
console.error('[PWA] 清除缓存失败:', error)
return false
}
}
return false
}
}
// 导出Service Worker注册状态
export { swRegistration, isOnline }

View File

@@ -0,0 +1,297 @@
/**
* Axios请求工具
* 统一处理HTTP请求和响应
*/
import axios from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import { getToken, removeToken } from '@/utils/auth'
import router from '@/router'
import globalErrorHandler, { ErrorTypes } from './errorHandler'
// 创建axios实例
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 15000, // 请求超时时间
headers: {
'Content-Type': 'application/json'
}
})
// 存储当前请求的loading实例
let loadingInstance = null
// 存储当前请求数量
let requestCount = 0
/**
* 显示loading
*/
const showLoading = () => {
if (requestCount === 0) {
loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(0, 0, 0, 0.7)'
})
}
requestCount++
}
/**
* 隐藏loading
*/
const hideLoading = () => {
requestCount--
if (requestCount <= 0) {
requestCount = 0
if (loadingInstance) {
loadingInstance.close()
loadingInstance = null
}
}
}
/**
* 请求拦截器
*/
service.interceptors.request.use(
config => {
// 显示loading可选
if (config.showLoading !== false) {
showLoading()
}
// 添加token到请求头
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 添加请求ID用于追踪
config.headers['X-Request-ID'] = generateRequestId()
// 添加时间戳防止缓存
if (config.method === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}
return config
},
error => {
// 请求错误处理
hideLoading()
globalErrorHandler.handleError(error, {
type: ErrorTypes.NETWORK,
context: 'request_interceptor',
showMessage: true
})
return Promise.reject(error)
}
)
/**
* 响应拦截器
*/
service.interceptors.response.use(
response => {
// 隐藏loading
hideLoading()
// 获取响应数据
const res = response.data
// 根据后端约定的响应码处理
if (response.status === 200) {
// 如果响应中包含code字段根据code判断
if (res.code !== undefined) {
// 成功响应
if (res.code === 200 || res.code === 0) {
return res.data || res
}
// token过期或无效
else if (res.code === 401) {
ElMessage.error('登录已过期,请重新登录')
removeToken()
router.push('/login')
return Promise.reject(new Error('登录已过期'))
}
// 权限不足
else if (res.code === 403) {
ElMessage.error('权限不足')
return Promise.reject(new Error('权限不足'))
}
// 其他业务错误
else {
const message = res.message || '服务器响应错误'
ElMessage.error(message)
return Promise.reject(new Error(message))
}
}
// 如果没有code字段直接返回数据
else {
return res
}
} else {
// 处理非200状态码
return handleErrorResponse(response)
}
},
error => {
// 隐藏loading
hideLoading()
// 处理响应错误
if (error.response) {
// 服务器返回了响应但状态码不在2xx范围内
return handleErrorResponse(error.response)
} else if (error.request) {
// 请求已发出,但没有收到响应
globalErrorHandler.handleNetworkError(error, {
context: 'no_response',
showMessage: true
})
return Promise.reject(error)
} else {
// 请求配置出错
globalErrorHandler.handleError(error, {
type: ErrorTypes.NETWORK,
context: 'request_config',
showMessage: true
})
return Promise.reject(error)
}
}
)
/**
* 处理错误响应
* @param {Object} response - 响应对象
*/
function handleErrorResponse(response) {
const { status, data } = response
// 使用全局错误处理器处理API错误
globalErrorHandler.handleApiError(response, {
showMessage: true
})
return Promise.reject(new Error(data?.message || `请求失败 (${status})`))
}
/**
* 生成请求ID
*/
function generateRequestId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
/**
* 封装GET请求
* @param {string} url - 请求地址
* @param {Object} params - 请求参数
* @param {Object} config - 请求配置
*/
export function get(url, params = {}, config = {}) {
return service.get(url, {
params,
...config
})
}
/**
* 封装POST请求
* @param {string} url - 请求地址
* @param {Object} data - 请求数据
* @param {Object} config - 请求配置
*/
export function post(url, data = {}, config = {}) {
return service.post(url, data, config)
}
/**
* 封装PUT请求
* @param {string} url - 请求地址
* @param {Object} data - 请求数据
* @param {Object} config - 请求配置
*/
export function put(url, data = {}, config = {}) {
return service.put(url, data, config)
}
/**
* 封装DELETE请求
* @param {string} url - 请求地址
* @param {Object} config - 请求配置
*/
export function del(url, config = {}) {
return service.delete(url, config)
}
/**
* 封装上传文件请求
* @param {string} url - 请求地址
* @param {FormData} formData - 表单数据
* @param {Object} config - 请求配置
*/
export function upload(url, formData, config = {}) {
return service.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
...config
})
}
/**
* 封装下载文件请求
* @param {string} url - 请求地址
* @param {Object} params - 请求参数
* @param {string} filename - 下载文件名
*/
export function download(url, params = {}, filename = '') {
return service.get(url, {
params,
responseType: 'blob'
}).then(response => {
// 创建下载链接
const blob = new Blob([response])
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
// 设置下载文件名
link.download = filename || `download_${Date.now()}`
// 触发下载
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 释放URL对象
window.URL.revokeObjectURL(downloadUrl)
return response
})
}
/**
* 取消请求
* @param {string} url - 请求地址
*/
export function cancelRequest(url) {
// 这里可以实现取消特定请求的逻辑
// 例如使用CancelToken或AbortController
}
/**
* 取消所有请求
*/
export function cancelAllRequests() {
// 这里可以实现取消所有请求的逻辑
// 例如存储所有请求的CancelToken或AbortController
}
export default service

View File

@@ -0,0 +1,410 @@
/**
* 资源加载优化工具
* 用于优化图片、字体和其他静态资源的加载
*/
// 资源缓存
const resourceCache = new Map()
// 加载状态枚举
const LoadStatus = {
PENDING: 'pending',
SUCCESS: 'success',
FAILED: 'failed'
}
/**
* 预加载图片
* @param {string} src - 图片URL
* @param {Object} options - 选项
* @returns {Promise} 加载结果
*/
const preloadImage = (src, options = {}) => {
const {
priority = 'normal', // 'high', 'normal', 'low'
useWebP = true, // 是否尝试使用WebP格式
fallbackSrc = null, // 降级图片URL
timeout = 10000 // 超时时间
} = options
// 检查缓存
if (resourceCache.has(src)) {
const cached = resourceCache.get(src)
if (cached.status === LoadStatus.SUCCESS) {
return Promise.resolve(cached.data)
} else if (cached.status === LoadStatus.PENDING) {
return cached.promise
}
}
// 创建加载Promise
let resolve, reject
const loadPromise = new Promise((res, rej) => {
resolve = res
reject = rej
})
// 设置缓存状态为pending
resourceCache.set(src, {
status: LoadStatus.PENDING,
promise: loadPromise
})
// 处理图片URL
const processImageUrl = (url) => {
if (useWebP && !url.endsWith('.webp') && !url.includes('?')) {
// 尝试使用WebP格式
return `${url}?format=webp`
}
return url
}
// 执行加载
const loadImage = (url, attempt = 0) => {
const img = new Image()
const timeoutId = setTimeout(() => {
reject(new Error(`Image load timeout: ${src}`))
}, timeout)
img.onload = () => {
clearTimeout(timeoutId)
// 更新缓存状态为成功
resourceCache.set(src, {
status: LoadStatus.SUCCESS,
data: img
})
resolve(img)
}
img.onerror = () => {
clearTimeout(timeoutId)
// 尝试降级方案
if (fallbackSrc && attempt === 0) {
loadImage(fallbackSrc, 1)
} else if (useWebP && !url.includes('format=webp') && attempt === 0) {
// 尝试不使用WebP格式
loadImage(src.replace('?format=webp', ''), 1)
} else {
// 更新缓存状态为失败
resourceCache.set(src, {
status: LoadStatus.FAILED,
error: new Error(`Failed to load image: ${src}`)
})
reject(new Error(`Failed to load image: ${src}`))
}
}
// 根据优先级设置加载策略
if (priority === 'high') {
img.fetchPriority = 'high'
} else if (priority === 'low') {
img.fetchPriority = 'low'
}
img.src = processImageUrl(url)
}
loadImage(src)
return loadPromise
}
/**
* 批量预加载图片
* @param {Array} images - 图片URL数组
* @param {Object} options - 选项
* @returns {Promise} 所有图片加载结果
*/
const batchPreloadImages = (images, options = {}) => {
const {
concurrent = 3, // 并发加载数量
delay = 100 // 延迟时间
} = options
// 延迟执行,避免影响页面初始加载
return new Promise((resolve) => {
setTimeout(() => {
const results = []
let currentIndex = 0
const loadNext = () => {
if (currentIndex >= images.length) {
resolve(results)
return
}
const batch = images.slice(currentIndex, currentIndex + concurrent)
currentIndex += concurrent
const batchPromises = batch.map(src =>
preloadImage(src, options)
.then(img => ({ src, status: 'success', img }))
.catch(error => ({ src, status: 'failed', error }))
)
Promise.all(batchPromises).then(batchResults => {
results.push(...batchResults)
// 继续加载下一批
setTimeout(loadNext, delay)
})
}
loadNext()
}, delay)
})
}
/**
* 预加载字体
* @param {string} fontUrl - 字体URL
* @param {string} fontFamily - 字体族名称
* @param {Object} options - 选项
* @returns {Promise} 加载结果
*/
const preloadFont = (fontUrl, fontFamily, options = {}) => {
const {
display = 'swap', // 字体显示策略
timeout = 10000 // 超时时间
} = options
// 检查缓存
const cacheKey = `font:${fontUrl}`
if (resourceCache.has(cacheKey)) {
const cached = resourceCache.get(cacheKey)
if (cached.status === LoadStatus.SUCCESS) {
return Promise.resolve(cached.data)
} else if (cached.status === LoadStatus.PENDING) {
return cached.promise
}
}
// 创建加载Promise
let resolve, reject
const loadPromise = new Promise((res, rej) => {
resolve = res
reject = rej
})
// 设置缓存状态为pending
resourceCache.set(cacheKey, {
status: LoadStatus.PENDING,
promise: loadPromise
})
// 创建字体
const font = new FontFace(fontFamily, `url(${fontUrl})`, { display })
// 设置超时
const timeoutId = setTimeout(() => {
reject(new Error(`Font load timeout: ${fontUrl}`))
}, timeout)
font.load()
.then(loadedFont => {
clearTimeout(timeoutId)
// 添加字体到文档
document.fonts.add(loadedFont)
// 更新缓存状态为成功
resourceCache.set(cacheKey, {
status: LoadStatus.SUCCESS,
data: loadedFont
})
resolve(loadedFont)
})
.catch(error => {
clearTimeout(timeoutId)
// 更新缓存状态为失败
resourceCache.set(cacheKey, {
status: LoadStatus.FAILED,
error
})
reject(error)
})
return loadPromise
}
/**
* 预加载资源
* @param {string} url - 资源URL
* @param {string} as - 资源类型 ('script', 'style', 'image', 'font', etc.)
* @param {Object} options - 选项
* @returns {Promise} 加载结果
*/
const preloadResource = (url, as, options = {}) => {
const {
crossorigin = null, // 跨域设置
integrity = null, // 完整性校验
timeout = 10000 // 超时时间
} = options
// 检查缓存
const cacheKey = `${as}:${url}`
if (resourceCache.has(cacheKey)) {
const cached = resourceCache.get(cacheKey)
if (cached.status === LoadStatus.SUCCESS) {
return Promise.resolve(cached.data)
} else if (cached.status === LoadStatus.PENDING) {
return cached.promise
}
}
// 创建加载Promise
let resolve, reject
const loadPromise = new Promise((res, rej) => {
resolve = res
reject = rej
})
// 设置缓存状态为pending
resourceCache.set(cacheKey, {
status: LoadStatus.PENDING,
promise: loadPromise
})
// 创建link元素
const link = document.createElement('link')
link.rel = 'preload'
link.href = url
link.as = as
if (crossorigin) {
link.crossOrigin = crossorigin
}
if (integrity) {
link.integrity = integrity
}
// 设置超时
const timeoutId = setTimeout(() => {
document.head.removeChild(link)
reject(new Error(`Resource preload timeout: ${url}`))
}, timeout)
link.onload = () => {
clearTimeout(timeoutId)
// 更新缓存状态为成功
resourceCache.set(cacheKey, {
status: LoadStatus.SUCCESS,
data: link
})
resolve(link)
}
link.onerror = () => {
clearTimeout(timeoutId)
document.head.removeChild(link)
// 更新缓存状态为失败
resourceCache.set(cacheKey, {
status: LoadStatus.FAILED,
error: new Error(`Failed to preload resource: ${url}`)
})
reject(new Error(`Failed to preload resource: ${url}`))
}
document.head.appendChild(link)
return loadPromise
}
/**
* 智能预加载资源
* 根据用户行为和网络状态智能预加载资源
* @param {Array} resources - 资源列表
* @param {Object} options - 选项
*/
const smartPreloadResources = (resources, options = {}) => {
const {
networkThreshold = '4g', // 网络阈值,低于此值不预加载
idleTimeout = 2000 // 空闲超时时间
} = options
// 检查网络状态
if (navigator.connection && navigator.connection.effectiveType) {
const connectionType = navigator.connection.effectiveType
if (connectionType !== networkThreshold &&
['slow-2g', '2g', '3g'].includes(connectionType)) {
console.log('Network too slow, skipping preload')
return
}
}
// 在空闲时预加载
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
resources.forEach(resource => {
const { url, type, priority = 'normal' } = resource
switch (type) {
case 'image':
preloadImage(url, { priority })
break
case 'font':
preloadFont(url, resource.fontFamily || 'CustomFont')
break
default:
preloadResource(url, type, { priority })
}
})
}, { timeout: idleTimeout })
} else {
// 降级方案:延迟预加载
setTimeout(() => {
resources.forEach(resource => {
const { url, type, priority = 'normal' } = resource
switch (type) {
case 'image':
preloadImage(url, { priority })
break
case 'font':
preloadFont(url, resource.fontFamily || 'CustomFont')
break
default:
preloadResource(url, type, { priority })
}
})
}, idleTimeout)
}
}
/**
* 清除资源缓存
* @param {string} key - 缓存键,不提供则清除所有
*/
const clearResourceCache = (key = null) => {
if (key) {
resourceCache.delete(key)
} else {
resourceCache.clear()
}
}
/**
* 获取资源加载状态
* @param {string} key - 缓存键
* @returns {Object} 加载状态
*/
const getResourceStatus = (key) => {
const cached = resourceCache.get(key)
if (!cached) {
return { status: 'not_loaded' }
}
return {
status: cached.status,
hasData: cached.status === LoadStatus.SUCCESS,
hasError: cached.status === LoadStatus.FAILED
}
}
export {
preloadImage,
batchPreloadImages,
preloadFont,
preloadResource,
smartPreloadResources,
clearResourceCache,
getResourceStatus,
LoadStatus
}

View File

@@ -0,0 +1,522 @@
/**
* 资源预加载工具
* 用于智能预加载关键资源,优化首屏加载速度
*/
/**
* 资源类型枚举
*/
export const ResourceType = {
SCRIPT: 'script',
STYLE: 'style',
IMAGE: 'image',
FONT: 'font',
VIDEO: 'video',
AUDIO: 'audio',
DOCUMENT: 'document'
}
/**
* 预加载优先级枚举
*/
export const PreloadPriority = {
HIGH: 'high',
MEDIUM: 'medium',
LOW: 'low'
}
/**
* 网络类型枚举
*/
export const NetworkType = {
SLOW_2G: 'slow-2g',
_2G: '2g',
_3G: '3g',
_4G: '4g'
}
/**
* 资源预加载配置
*/
const defaultConfig = {
// 网络阈值,低于此网络类型不进行预加载
networkThreshold: NetworkType._3G,
// 空闲超时时间(毫秒)
idleTimeout: 2000,
// 最大并发预加载数量
maxConcurrent: 3,
// 预加载超时时间(毫秒)
preloadTimeout: 10000,
// 是否使用请求空闲回调
useRequestIdleCallback: true,
// 是否使用Intersection Observer
useIntersectionObserver: true,
// 是否启用缓存
enableCache: true,
// 缓存过期时间(毫秒)
cacheExpiration: 3600000 // 1小时
}
/**
* 资源预加载管理器
*/
class ResourcePreloader {
constructor(config = {}) {
this.config = { ...defaultConfig, ...config }
this.preloadQueue = []
this.activePreloads = 0
this.cache = new Map()
this.observer = null
this.init()
}
/**
* 初始化预加载器
*/
init() {
// 检查浏览器支持
this.checkBrowserSupport()
// 初始化Intersection Observer
if (this.config.useIntersectionObserver && 'IntersectionObserver' in window) {
this.initIntersectionObserver()
}
// 清理过期缓存
this.cleanExpiredCache()
}
/**
* 检查浏览器支持
*/
checkBrowserSupport() {
this.supports = {
linkRelPreload: document.createElement('link').relList.supports('preload'),
requestIdleCallback: 'requestIdleCallback' in window,
intersectionObserver: 'IntersectionObserver' in window,
webp: document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp') === 0
}
}
/**
* 初始化Intersection Observer
*/
initIntersectionObserver() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target
const url = element.dataset.preloadUrl
const type = element.dataset.preloadType
if (url && type) {
this.preloadResource(url, type)
this.observer.unobserve(element)
}
}
})
}, {
rootMargin: '50px' // 提前50px开始预加载
})
}
/**
* 预加载单个资源
* @param {string} url - 资源URL
* @param {string} type - 资源类型
* @param {Object} options - 预加载选项
*/
preloadResource(url, type, options = {}) {
// 检查缓存
if (this.config.enableCache && this.cache.has(url)) {
const cached = this.cache.get(url)
if (Date.now() - cached.timestamp < this.config.cacheExpiration) {
return Promise.resolve(cached.data)
}
}
// 检查网络条件
if (!this.shouldPreload()) {
return Promise.reject(new Error('网络条件不满足预加载要求'))
}
// 检查并发限制
if (this.activePreloads >= this.config.maxConcurrent) {
return this.queuePreload(url, type, options)
}
this.activePreloads++
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.activePreloads--
reject(new Error(`预加载超时: ${url}`))
}, this.config.preloadTimeout)
const handleComplete = (data) => {
clearTimeout(timeoutId)
this.activePreloads--
// 缓存结果
if (this.config.enableCache) {
this.cache.set(url, {
data,
timestamp: Date.now()
})
}
resolve(data)
}
const handleError = (error) => {
clearTimeout(timeoutId)
this.activePreloads--
reject(error)
}
// 根据资源类型选择预加载方法
switch (type) {
case ResourceType.SCRIPT:
this.preloadScript(url, options).then(handleComplete).catch(handleError)
break
case ResourceType.STYLE:
this.preloadStyle(url, options).then(handleComplete).catch(handleError)
break
case ResourceType.IMAGE:
this.preloadImage(url, options).then(handleComplete).catch(handleError)
break
case ResourceType.FONT:
this.preloadFont(url, options).then(handleComplete).catch(handleError)
break
default:
this.preloadGeneric(url, type, options).then(handleComplete).catch(handleError)
}
})
}
/**
* 预加载脚本
*/
preloadScript(url, options = {}) {
return new Promise((resolve, reject) => {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'script'
link.href = url
link.onload = resolve
link.onerror = reject
// 添加到head
document.head.appendChild(link)
})
}
/**
* 预加载样式
*/
preloadStyle(url, options = {}) {
return new Promise((resolve, reject) => {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'style'
link.href = url
link.onload = resolve
link.onerror = reject
// 添加到head
document.head.appendChild(link)
})
}
/**
* 预加载图片
*/
preloadImage(url, options = {}) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
// 如果支持WebP且URL中没有指定格式尝试使用WebP
if (this.supports.webp && !url.match(/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i)) {
img.src = `${url}${url.includes('?') ? '&' : '?'}format=webp`
} else {
img.src = url
}
})
}
/**
* 预加载字体
*/
preloadFont(url, options = {}) {
return new Promise((resolve, reject) => {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'font'
link.href = url
link.crossOrigin = 'anonymous'
link.onload = resolve
link.onerror = reject
// 添加到head
document.head.appendChild(link)
})
}
/**
* 通用预加载方法
*/
preloadGeneric(url, type, options = {}) {
return new Promise((resolve, reject) => {
if (this.supports.linkRelPreload) {
const link = document.createElement('link')
link.rel = 'preload'
link.as = type
link.href = url
link.onload = resolve
link.onerror = reject
// 添加到head
document.head.appendChild(link)
} else {
// 降级使用fetch
fetch(url, { mode: 'no-cors' })
.then(() => resolve())
.catch(reject)
}
})
}
/**
* 智能预加载多个资源
* @param {Array} resources - 资源列表
* @param {Object} options - 预加载选项
*/
smartPreload(resources, options = {}) {
// 按优先级排序
const sortedResources = [...resources].sort((a, b) => {
const priorityOrder = {
[PreloadPriority.HIGH]: 3,
[PreloadPriority.MEDIUM]: 2,
[PreloadPriority.LOW]: 1
}
return (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0)
})
// 分批预加载
const batches = this.createBatches(sortedResources, this.config.maxConcurrent)
// 使用requestIdleCallback或setTimeout延迟执行
const executePreload = () => {
this.processBatch(batches, 0)
}
if (this.config.useRequestIdleCallback && this.supports.requestIdleCallback) {
requestIdleCallback(executePreload, { timeout: this.config.idleTimeout })
} else {
setTimeout(executePreload, this.config.idleTimeout)
}
}
/**
* 创建预加载批次
*/
createBatches(resources, batchSize) {
const batches = []
for (let i = 0; i < resources.length; i += batchSize) {
batches.push(resources.slice(i, i + batchSize))
}
return batches
}
/**
* 处理预加载批次
*/
processBatch(batches, index) {
if (index >= batches.length) return
const batch = batches[index]
const promises = batch.map(resource => {
return this.preloadResource(resource.url, resource.type, resource.options)
.catch(error => {
console.warn(`预加载失败: ${resource.url}`, error)
return null
})
})
Promise.all(promises).then(() => {
// 处理下一批次
if (this.config.useRequestIdleCallback && this.supports.requestIdleCallback) {
requestIdleCallback(() => this.processBatch(batches, index + 1))
} else {
setTimeout(() => this.processBatch(batches, index + 1), 100)
}
})
}
/**
* 将预加载加入队列
*/
queuePreload(url, type, options) {
return new Promise((resolve, reject) => {
this.preloadQueue.push({
url,
type,
options,
resolve,
reject
})
})
}
/**
* 处理队列中的预加载
*/
processQueue() {
while (this.activePreloads < this.config.maxConcurrent && this.preloadQueue.length > 0) {
const item = this.preloadQueue.shift()
this.preloadResource(item.url, item.type, item.options)
.then(item.resolve)
.catch(item.reject)
}
}
/**
* 检查是否应该预加载
*/
shouldPreload() {
// 检查网络连接
if (navigator.connection) {
const connection = navigator.connection
const effectiveType = connection.effectiveType
const saveData = connection.saveData
// 如果用户开启了省流量模式,不预加载
if (saveData) return false
// 检查网络类型是否满足阈值
const networkOrder = [
NetworkType.SLOW_2G,
NetworkType._2G,
NetworkType._3G,
NetworkType._4G
]
const thresholdIndex = networkOrder.indexOf(this.config.networkThreshold)
const currentIndex = networkOrder.indexOf(effectiveType)
if (currentIndex < thresholdIndex) return false
}
return true
}
/**
* 清理过期缓存
*/
cleanExpiredCache() {
if (!this.config.enableCache) return
const now = Date.now()
for (const [url, cached] of this.cache.entries()) {
if (now - cached.timestamp > this.config.cacheExpiration) {
this.cache.delete(url)
}
}
}
/**
* 清空缓存
*/
clearCache() {
this.cache.clear()
}
/**
* 获取缓存统计
*/
getCacheStats() {
return {
size: this.cache.size,
entries: Array.from(this.cache.entries()).map(([url, cached]) => ({
url,
timestamp: cached.timestamp,
age: Date.now() - cached.timestamp
}))
}
}
/**
* 观察元素进行预加载
* @param {Element} element - 要观察的元素
* @param {string} url - 预加载URL
* @param {string} type - 资源类型
*/
observeElement(element, url, type) {
if (!this.observer) return
element.dataset.preloadUrl = url
element.dataset.preloadType = type
this.observer.observe(element)
}
/**
* 停止观察元素
* @param {Element} element - 要停止观察的元素
*/
unobserveElement(element) {
if (!this.observer) return
this.observer.unobserve(element)
}
/**
* 销毁预加载器
*/
destroy() {
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
this.clearCache()
this.preloadQueue = []
}
}
// 创建默认预加载器实例
const defaultPreloader = new ResourcePreloader()
/**
* 智能预加载资源
* @param {Array} resources - 资源列表
* @param {Object} config - 配置选项
*/
export function smartPreloadResources(resources, config = {}) {
const preloader = new ResourcePreloader(config)
preloader.smartPreload(resources)
}
/**
* 预加载单个资源
* @param {string} url - 资源URL
* @param {string} type - 资源类型
* @param {Object} options - 预加载选项
*/
export function preloadResource(url, type, options = {}) {
return defaultPreloader.preloadResource(url, type, options)
}
/**
* 观察元素进行预加载
* @param {Element} element - 要观察的元素
* @param {string} url - 预加载URL
* @param {string} type - 资源类型
*/
export function observeForPreload(element, url, type) {
defaultPreloader.observeElement(element, url, type)
}
export default defaultPreloader

View File

@@ -0,0 +1,238 @@
/**
* API请求重试机制
* 提供自动重试功能和UI反馈
*/
import { ElMessage, ElNotification } from 'element-plus'
// 重试配置
const retryConfig = {
// 默认最大重试次数
defaultMaxRetries: 3,
// 默认重试延迟(毫秒)
defaultRetryDelay: 1000,
// 指数退避因子
backoffFactor: 2,
// 最大重试延迟(毫秒)
maxRetryDelay: 10000,
// 需要重试的HTTP状态码
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
// 需要重试的网络错误类型
retryableErrorTypes: ['NETWORK_ERROR', 'TIMEOUT', 'SERVER_ERROR']
}
/**
* 计算重试延迟时间
* @param {number} retryCount - 当前重试次数
* @param {number} baseDelay - 基础延迟时间
* @returns {number} 计算后的延迟时间
*/
function calculateRetryDelay(retryCount, baseDelay = retryConfig.defaultRetryDelay) {
const delay = baseDelay * Math.pow(retryConfig.backoffFactor, retryCount - 1)
return Math.min(delay, retryConfig.maxRetryDelay)
}
/**
* 判断错误是否可重试
* @param {Error} error - 错误对象
* @returns {boolean} 是否可重试
*/
function isRetryableError(error) {
// 检查是否是网络错误
if (error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT') {
return true
}
// 检查HTTP状态码
if (error.response && error.response.status) {
return retryConfig.retryableStatusCodes.includes(error.response.status)
}
// 检查错误类型
if (error.type && retryConfig.retryableErrorTypes.includes(error.type)) {
return true
}
return false
}
/**
* 显示重试通知
* @param {string} operation - 操作描述
* @param {number} retryCount - 当前重试次数
* @param {number} maxRetries - 最大重试次数
* @param {number} delay - 延迟时间
* @returns {Object} 通知对象
*/
function showRetryNotification(operation, retryCount, maxRetries, delay) {
const message = `${operation} 失败,正在第 ${retryCount}/${maxRetries} 次重试...`
return ElNotification({
title: '请求重试',
message,
type: 'warning',
duration: delay,
showClose: false
})
}
/**
* 显示重试失败通知
* @param {string} operation - 操作描述
* @param {Error} error - 错误对象
*/
function showRetryFailedNotification(operation, error) {
ElMessage.error({
message: `${operation} 失败,已达到最大重试次数: ${error.message || '未知错误'}`,
duration: 5000
})
}
/**
* 带重试机制的请求函数
* @param {Function} requestFn - 请求函数
* @param {Object} options - 重试选项
* @param {string} options.operation - 操作描述
* @param {number} options.maxRetries - 最大重试次数
* @param {number} options.retryDelay - 基础重试延迟
* @param {Function} options.onRetry - 重试回调
* @param {Function} options.onFinalError - 最终错误回调
* @returns {Promise} 请求结果
*/
export async function retryRequest(requestFn, options = {}) {
const {
operation = '请求',
maxRetries = retryConfig.defaultMaxRetries,
retryDelay = retryConfig.defaultRetryDelay,
onRetry,
onFinalError
} = options
let lastError = null
let retryCount = 0
// 第一次尝试
try {
return await requestFn()
} catch (error) {
lastError = error
// 如果错误不可重试,直接抛出
if (!isRetryableError(error)) {
throw error
}
}
// 重试循环
for (retryCount = 1; retryCount <= maxRetries; retryCount++) {
const delay = calculateRetryDelay(retryCount, retryDelay)
// 显示重试通知
const notification = showRetryNotification(operation, retryCount, maxRetries, delay)
// 调用重试回调
if (onRetry) {
onRetry(retryCount, maxRetries, delay, lastError)
}
// 等待延迟
await new Promise(resolve => setTimeout(resolve, delay))
// 关闭通知
notification.close()
try {
// 执行重试请求
const result = await requestFn()
// 重试成功,显示成功消息
ElMessage.success({
message: `${operation} 在第 ${retryCount} 次重试后成功`,
duration: 3000
})
return result
} catch (error) {
lastError = error
// 如果错误不可重试或已达到最大重试次数,跳出循环
if (!isRetryableError(error) || retryCount === maxRetries) {
break
}
}
}
// 所有重试都失败
showRetryFailedNotification(operation, lastError)
// 调用最终错误回调
if (onFinalError) {
onFinalError(lastError, retryCount)
}
throw lastError
}
/**
* 创建带重试机制的API请求函数
* @param {Function} apiRequest - API请求函数
* @param {Object} retryOptions - 重试选项
* @returns {Function} 带重试机制的请求函数
*/
export function createRetryableRequest(apiRequest, retryOptions = {}) {
return async function(...args) {
return retryRequest(() => apiRequest(...args), retryOptions)
}
}
/**
* 为axios实例添加重试拦截器
* @param {Object} axiosInstance - axios实例
* @param {Object} options - 重试选项
*/
export function addRetryInterceptor(axiosInstance, options = {}) {
axiosInstance.interceptors.response.use(
response => response,
async error => {
const config = error.config
// 如果没有配置对象或已禁用重试,直接抛出错误
if (!config || config.disableRetry === true) {
return Promise.reject(error)
}
// 获取重试配置
const maxRetries = config.maxRetries || options.maxRetries || retryConfig.defaultMaxRetries
const retryDelay = config.retryDelay || options.retryDelay || retryConfig.defaultRetryDelay
const operation = config.operation || options.operation || 'API请求'
// 初始化重试计数
config.retryCount = config.retryCount || 0
// 如果已达到最大重试次数或错误不可重试,直接抛出错误
if (config.retryCount >= maxRetries || !isRetryableError(error)) {
return Promise.reject(error)
}
// 增加重试计数
config.retryCount += 1
// 计算延迟时间
const delay = calculateRetryDelay(config.retryCount, retryDelay)
// 显示重试通知
showRetryNotification(operation, config.retryCount, maxRetries, delay)
// 等待延迟
await new Promise(resolve => setTimeout(resolve, delay))
// 重新发起请求
return axiosInstance(config)
}
)
}

View File

@@ -0,0 +1,171 @@
/**
* 路由预加载工具
* 用于在用户可能访问某个路由前预加载对应的组件
*/
// 预加载状态
const preloadState = {
preloadedRoutes: new Set(),
isPreloading: false
}
// 路由预加载映射表
const routePreloadMap = {
// 首页 -> 类别排名
'Home': ['CategoryRanking'],
// 类别排名 -> 产品详情
'CategoryRanking': ['ProductDetail'],
// 产品详情 -> 产品对比
'ProductDetail': ['ProductComparison'],
// 产品对比 -> 类别排名
'ProductComparison': ['CategoryRanking']
}
// 路由组件映射
const routeComponentMap = {
'Home': () => import('../views/Home.vue'),
'CategoryRanking': () => import('../views/CategoryRanking.vue'),
'ProductDetail': () => import('../views/ProductDetail.vue'),
'ProductComparison': () => import('../views/ProductComparison.vue'),
'PerformanceMonitor': () => import('../views/PerformanceMonitor.vue'),
'NotFound': () => import('../views/NotFound.vue')
}
/**
* 预加载指定路由的组件
* @param {string} routeName - 路由名称
*/
const preloadRouteComponent = async (routeName) => {
if (preloadState.preloadedRoutes.has(routeName)) {
return
}
try {
const componentLoader = routeComponentMap[routeName]
if (componentLoader) {
await componentLoader()
preloadState.preloadedRoutes.add(routeName)
console.log(`预加载路由组件成功: ${routeName}`)
}
} catch (error) {
console.error(`预加载路由组件失败: ${routeName}`, error)
}
}
/**
* 根据当前路由预加载可能访问的下一个路由
* @param {string} currentRouteName - 当前路由名称
*/
const preloadNextRoutes = (currentRouteName) => {
const nextRoutes = routePreloadMap[currentRouteName] || []
// 延迟预加载,避免影响当前页面性能
setTimeout(() => {
nextRoutes.forEach(routeName => {
preloadRouteComponent(routeName)
})
}, 1000) // 1秒后开始预加载
}
/**
* 预加载所有路由组件
*/
const preloadAllRoutes = async () => {
if (preloadState.isPreloading) return
preloadState.isPreloading = true
try {
const preloadPromises = Object.keys(routeComponentMap).map(routeName =>
preloadRouteComponent(routeName)
)
await Promise.all(preloadPromises)
console.log('所有路由组件预加载完成')
} catch (error) {
console.error('预加载所有路由组件失败', error)
} finally {
preloadState.isPreloading = false
}
}
/**
* 清除预加载状态
*/
const clearPreloadedRoutes = () => {
preloadState.preloadedRoutes.clear()
preloadState.isPreloading = false
}
/**
* 获取预加载状态
* @returns {Object} 预加载状态
*/
const getPreloadState = () => {
return {
preloadedRoutes: Array.from(preloadState.preloadedRoutes),
isPreloading: preloadState.isPreloading
}
}
/**
* 智能预加载 - 根据用户行为预测可能访问的路由
*/
const smartPreload = {
// 鼠标悬停预加载
setupHoverPreload(router) {
document.addEventListener('mouseover', (event) => {
const linkElement = event.target.closest('a[href]')
if (!linkElement) return
const href = linkElement.getAttribute('href')
if (!href || href.startsWith('http')) return
// 解析路由路径
const routePath = href.replace(/^\//, '')
// 根据路径预测路由名称
let routeName = null
if (routePath === '') routeName = 'Home'
else if (routePath.startsWith('category/')) routeName = 'CategoryRanking'
else if (routePath.startsWith('product/')) routeName = 'ProductDetail'
else if (routePath === 'compare') routeName = 'ProductComparison'
else if (routePath === 'monitor') routeName = 'PerformanceMonitor'
if (routeName && !preloadState.preloadedRoutes.has(routeName)) {
// 延迟预加载,避免用户只是快速划过
setTimeout(() => {
if (linkElement.matches(':hover')) {
preloadRouteComponent(routeName)
}
}, 200)
}
})
},
// 空闲时间预加载
setupIdlePreload() {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
// 在浏览器空闲时预加载常用路由
preloadRouteComponent('Home')
preloadRouteComponent('CategoryRanking')
})
} else {
// 降级方案:延迟预加载
setTimeout(() => {
preloadRouteComponent('Home')
preloadRouteComponent('CategoryRanking')
}, 3000)
}
}
}
export {
preloadRouteComponent,
preloadNextRoutes,
preloadAllRoutes,
clearPreloadedRoutes,
getPreloadState,
smartPreload
}

View File

@@ -0,0 +1,484 @@
/**
* 安全工具类
* 提供数据加密、解密、安全验证等功能
*/
import CryptoJS from 'crypto-js'
// 加密配置
const ENCRYPTION_CONFIG = {
// 默认密钥(实际项目中应该从环境变量获取)
defaultKey: 'HardwarePerformance2023SecretKey',
// 默认向量(实际项目中应该从环境变量获取)
defaultIv: 'Hardware2023IV',
// 加密算法
algorithm: 'AES',
// 加密模式
mode: 'CBC',
// 填充方式
padding: 'Pkcs7'
}
/**
* AES加密
* @param {string} data 要加密的数据
* @param {string} key 加密密钥
* @param {string} iv 初始化向量
* @returns {string} 加密后的字符串
*/
export function aesEncrypt(data, key = ENCRYPTION_CONFIG.defaultKey, iv = ENCRYPTION_CONFIG.defaultIv) {
try {
const keyWords = CryptoJS.enc.Utf8.parse(key)
const ivWords = CryptoJS.enc.Utf8.parse(iv)
const encrypted = CryptoJS.AES.encrypt(data, keyWords, {
iv: ivWords,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
})
return encrypted.toString()
} catch (error) {
console.error('加密失败:', error)
return ''
}
}
/**
* AES解密
* @param {string} encryptedData 要解密的数据
* @param {string} key 解密密钥
* @param {string} iv 初始化向量
* @returns {string} 解密后的字符串
*/
export function aesDecrypt(encryptedData, key = ENCRYPTION_CONFIG.defaultKey, iv = ENCRYPTION_CONFIG.defaultIv) {
try {
const keyWords = CryptoJS.enc.Utf8.parse(key)
const ivWords = CryptoJS.enc.Utf8.parse(iv)
const decrypted = CryptoJS.AES.decrypt(encryptedData, keyWords, {
iv: ivWords,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
})
return decrypted.toString(CryptoJS.enc.Utf8)
} catch (error) {
console.error('解密失败:', error)
return ''
}
}
/**
* Base64编码
* @param {string} data 要编码的数据
* @returns {string} Base64编码后的字符串
*/
export function base64Encode(data) {
try {
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data))
} catch (error) {
console.error('Base64编码失败:', error)
return ''
}
}
/**
* Base64解码
* @param {string} base64Data Base64编码的数据
* @returns {string} 解码后的字符串
*/
export function base64Decode(base64Data) {
try {
return CryptoJS.enc.Base64.parse(base64Data).toString(CryptoJS.enc.Utf8)
} catch (error) {
console.error('Base64解码失败:', error)
return ''
}
}
/**
* MD5哈希
* @param {string} data 要哈希的数据
* @returns {string} MD5哈希值
*/
export function md5Hash(data) {
try {
return CryptoJS.MD5(data).toString()
} catch (error) {
console.error('MD5哈希失败:', error)
return ''
}
}
/**
* SHA256哈希
* @param {string} data 要哈希的数据
* @returns {string} SHA256哈希值
*/
export function sha256Hash(data) {
try {
return CryptoJS.SHA256(data).toString()
} catch (error) {
console.error('SHA256哈希失败:', error)
return ''
}
}
/**
* 生成随机字符串
* @param {number} length 字符串长度
* @param {string} charset 字符集
* @returns {string} 随机字符串
*/
export function generateRandomString(length = 16, charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') {
let result = ''
for (let i = 0; i < length; i++) {
result += charset.charAt(Math.floor(Math.random() * charset.length))
}
return result
}
/**
* 生成安全令牌
* @param {number} length 令牌长度
* @returns {string} 安全令牌
*/
export function generateSecureToken(length = 32) {
// 使用更安全的字符集
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?'
return generateRandomString(length, charset)
}
/**
* 验证密码强度
* @param {string} password 密码
* @returns {object} 密码强度信息
*/
export function validatePasswordStrength(password) {
const result = {
score: 0,
feedback: [],
isStrong: false
}
// 检查长度
if (password.length < 8) {
result.feedback.push('密码长度至少需要8位')
} else {
result.score += 1
}
// 检查是否包含小写字母
if (!/[a-z]/.test(password)) {
result.feedback.push('密码需要包含小写字母')
} else {
result.score += 1
}
// 检查是否包含大写字母
if (!/[A-Z]/.test(password)) {
result.feedback.push('密码需要包含大写字母')
} else {
result.score += 1
}
// 检查是否包含数字
if (!/[0-9]/.test(password)) {
result.feedback.push('密码需要包含数字')
} else {
result.score += 1
}
// 检查是否包含特殊字符
if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) {
result.feedback.push('密码需要包含特殊字符')
} else {
result.score += 1
}
// 判断密码强度
result.isStrong = result.score >= 4
return result
}
/**
* 验证邮箱格式
* @param {string} email 邮箱地址
* @returns {boolean} 是否为有效邮箱
*/
export function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
/**
* 验证手机号格式(中国)
* @param {string} phone 手机号
* @returns {boolean} 是否为有效手机号
*/
export function validatePhone(phone) {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
/**
* 验证URL格式
* @param {string} url URL地址
* @returns {boolean} 是否为有效URL
*/
export function validateUrl(url) {
try {
new URL(url)
return true
} catch (error) {
return false
}
}
/**
* 防XSS攻击 - 清理HTML
* @param {string} html HTML字符串
* @returns {string} 清理后的HTML
*/
export function sanitizeHtml(html) {
// 简单的XSS防护实际项目中建议使用DOMPurify等专业库
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '')
}
/**
* 防SQL注入 - 清理输入
* @param {string} input 输入字符串
* @returns {string} 清理后的字符串
*/
export function sanitizeInput(input) {
// 简单的SQL注入防护实际项目中应在后端使用参数化查询
return input
.replace(/['"\\;]/g, '')
.replace(/--/g, '')
.replace(/\/\*/g, '')
.replace(/\*\//g, '')
}
/**
* 生成CSRF令牌
* @returns {string} CSRF令牌
*/
export function generateCSRFToken() {
return generateSecureToken(64)
}
/**
* 验证CSRF令牌
* @param {string} token 要验证的令牌
* @param {string} sessionToken 会话中的令牌
* @returns {boolean} 是否有效
*/
export function validateCSRFToken(token, sessionToken) {
return token && sessionToken && token === sessionToken
}
/**
* 创建安全的localStorage包装器
* @param {string} key 键名
* @param {any} value 值
* @param {boolean} encrypt 是否加密
* @returns {boolean} 是否成功
*/
export function secureLocalStorageSet(key, value, encrypt = false) {
try {
const data = JSON.stringify(value)
const finalData = encrypt ? aesEncrypt(data) : data
localStorage.setItem(key, finalData)
return true
} catch (error) {
console.error('localStorage存储失败:', error)
return false
}
}
/**
* 创建安全的localStorage读取器
* @param {string} key 键名
* @param {boolean} decrypt 是否解密
* @param {any} defaultValue 默认值
* @returns {any} 读取的值
*/
export function secureLocalStorageGet(key, decrypt = false, defaultValue = null) {
try {
const data = localStorage.getItem(key)
if (!data) return defaultValue
const finalData = decrypt ? aesDecrypt(data) : data
return JSON.parse(finalData)
} catch (error) {
console.error('localStorage读取失败:', error)
return defaultValue
}
}
/**
* 创建安全的sessionStorage包装器
* @param {string} key 键名
* @param {any} value 值
* @param {boolean} encrypt 是否加密
* @returns {boolean} 是否成功
*/
export function secureSessionStorageSet(key, value, encrypt = false) {
try {
const data = JSON.stringify(value)
const finalData = encrypt ? aesEncrypt(data) : data
sessionStorage.setItem(key, finalData)
return true
} catch (error) {
console.error('sessionStorage存储失败:', error)
return false
}
}
/**
* 创建安全的sessionStorage读取器
* @param {string} key 键名
* @param {boolean} decrypt 是否解密
* @param {any} defaultValue 默认值
* @returns {any} 读取的值
*/
export function secureSessionStorageGet(key, decrypt = false, defaultValue = null) {
try {
const data = sessionStorage.getItem(key)
if (!data) return defaultValue
const finalData = decrypt ? aesDecrypt(data) : data
return JSON.parse(finalData)
} catch (error) {
console.error('sessionStorage读取失败:', error)
return defaultValue
}
}
/**
* 创建安全的Cookie包装器
* @param {string} name Cookie名称
* @param {string} value Cookie值
* @param {number} days 过期天数
* @param {boolean} secure 是否仅HTTPS
* @param {boolean} httpOnly 是否仅HTTP
* @param {string} sameSite SameSite属性
* @returns {boolean} 是否成功
*/
export function secureCookieSet(name, value, days = 7, secure = true, httpOnly = false, sameSite = 'Strict') {
try {
let expires = ''
if (days) {
const date = new Date()
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000))
expires = '; expires=' + date.toUTCString()
}
let cookieString = `${name}=${value || ''}${expires}; path=/`
if (secure) {
cookieString += '; secure'
}
if (httpOnly) {
cookieString += '; HttpOnly'
}
if (sameSite) {
cookieString += `; SameSite=${sameSite}`
}
document.cookie = cookieString
return true
} catch (error) {
console.error('Cookie设置失败:', error)
return false
}
}
/**
* 创建安全的Cookie读取器
* @param {string} name Cookie名称
* @returns {string|null} Cookie值
*/
export function secureCookieGet(name) {
try {
const nameEQ = name + '='
const ca = document.cookie.split(';')
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
while (c.charAt(0) === ' ') {
c = c.substring(1, c.length)
}
if (c.indexOf(nameEQ) === 0) {
return decodeURIComponent(c.substring(nameEQ.length, c.length))
}
}
return null
} catch (error) {
console.error('Cookie读取失败:', error)
return null
}
}
/**
* 删除Cookie
* @param {string} name Cookie名称
* @returns {boolean} 是否成功
*/
export function secureCookieDelete(name) {
try {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
return true
} catch (error) {
console.error('Cookie删除失败:', error)
return false
}
}
/**
* 防止点击劫持 - 设置X-Frame-Options
* 在实际项目中,这应该由服务器设置
*/
export function preventClickjacking() {
// 这只是一个示例实际X-Frame-Options应该由服务器设置
if (window.self !== window.top) {
// 页面被嵌入到iframe中
window.top.location = window.self.location
}
}
/**
* 防止内容嗅探 - 设置X-Content-Type-Options
* 在实际项目中,这应该由服务器设置
*/
export function preventContentTypeSniffing() {
// 这只是一个示例实际X-Content-Type-Options应该由服务器设置
const meta = document.createElement('meta')
meta.httpEquiv = 'X-Content-Type-Options'
meta.content = 'nosniff'
document.head.appendChild(meta)
}
/**
* 防止MIME类型混淆攻击
* 在实际项目中,这应该由服务器设置
*/
export function preventMimeSniffing() {
// 这只是一个示例实际X-Content-Type-Options应该由服务器设置
const meta = document.createElement('meta')
meta.httpEquiv = 'X-Content-Type-Options'
meta.content = 'nosniff'
document.head.appendChild(meta)
}
/**
* 启用浏览器安全策略
*/
export function enableBrowserSecurity() {
preventClickjacking()
preventContentTypeSniffing()
preventMimeSniffing()
}

View File

@@ -0,0 +1,489 @@
/**
* 状态持久化工具
* 支持多种存储方式localStorage、sessionStorage、IndexedDB、内存存储
* 支持数据加密、过期时间、版本控制等功能
*/
// 存储类型枚举
export const StorageType = {
LOCAL: 'localStorage',
SESSION: 'sessionStorage',
INDEXED_DB: 'indexedDB',
MEMORY: 'memory'
}
// 默认配置
const defaultConfig = {
// 默认存储类型
storageType: StorageType.LOCAL,
// 是否启用加密
encrypt: false,
// 加密密钥
encryptKey: 'default-key',
// 默认过期时间(毫秒)0表示永不过期
defaultExpire: 0,
// 版本号,用于数据迁移
version: '1.0.0',
// 键名前缀
prefix: 'app_',
// 错误处理函数
onError: (error) => console.error('Storage error:', error)
}
/**
* 状态持久化管理器
*/
class StatePersistManager {
constructor(config = {}) {
this.config = { ...defaultConfig, ...config }
this.memoryStorage = new Map()
this.indexedDBCache = new Map()
this.initIndexedDB()
}
/**
* 初始化IndexedDB
*/
async initIndexedDB() {
if (typeof window === 'undefined' || !window.indexedDB) {
console.warn('IndexedDB not supported')
return
}
try {
this.db = await this.openIndexedDB()
} catch (error) {
this.config.onError(error)
}
}
/**
* 打开IndexedDB数据库
*/
openIndexedDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('StatePersistDB', 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains('stateStore')) {
db.createObjectStore('stateStore', { keyPath: 'key' })
}
}
})
}
/**
* 生成完整的键名
*/
getFullKey(key) {
return `${this.config.prefix}${key}`
}
/**
* 序列化数据
*/
serialize(data, expire = 0) {
const payload = {
data,
timestamp: Date.now(),
expire: expire > 0 ? Date.now() + expire : 0,
version: this.config.version
}
let serialized = JSON.stringify(payload)
// 加密处理
if (this.config.encrypt) {
serialized = this.encrypt(serialized, this.config.encryptKey)
}
return serialized
}
/**
* 反序列化数据
*/
deserialize(serialized) {
try {
// 解密处理
if (this.config.encrypt) {
serialized = this.decrypt(serialized, this.config.encryptKey)
}
const payload = JSON.parse(serialized)
// 检查是否过期
if (payload.expire > 0 && Date.now() > payload.expire) {
return null
}
return payload.data
} catch (error) {
this.config.onError(error)
return null
}
}
/**
* 简单加密(实际项目中应使用更安全的加密算法)
*/
encrypt(text, key) {
// 这里使用简单的Base64编码实际项目中应使用更安全的加密算法
return btoa(unescape(encodeURIComponent(text)))
}
/**
* 简单解密
*/
decrypt(text, key) {
try {
return decodeURIComponent(escape(atob(text)))
} catch (error) {
this.config.onError(error)
return ''
}
}
/**
* 设置数据
*/
async set(key, value, options = {}) {
const {
storageType = this.config.storageType,
expire = this.config.defaultExpire
} = options
const fullKey = this.getFullKey(key)
const serialized = this.serialize(value, expire)
try {
switch (storageType) {
case StorageType.LOCAL:
localStorage.setItem(fullKey, serialized)
break
case StorageType.SESSION:
sessionStorage.setItem(fullKey, serialized)
break
case StorageType.INDEXED_DB:
await this.setIndexedDB(fullKey, serialized, expire)
break
case StorageType.MEMORY:
this.memoryStorage.set(fullKey, { value: serialized, expire })
break
default:
throw new Error(`Unsupported storage type: ${storageType}`)
}
return true
} catch (error) {
this.config.onError(error)
return false
}
}
/**
* 获取数据
*/
async get(key, options = {}) {
const {
storageType = this.config.storageType,
defaultValue = null
} = options
const fullKey = this.getFullKey(key)
try {
let serialized
switch (storageType) {
case StorageType.LOCAL:
serialized = localStorage.getItem(fullKey)
break
case StorageType.SESSION:
serialized = sessionStorage.getItem(fullKey)
break
case StorageType.INDEXED_DB:
serialized = await this.getIndexedDB(fullKey)
break
case StorageType.MEMORY:
const memoryItem = this.memoryStorage.get(fullKey)
if (memoryItem) {
// 检查是否过期
if (memoryItem.expire > 0 && Date.now() > memoryItem.expire) {
this.memoryStorage.delete(fullKey)
return defaultValue
}
serialized = memoryItem.value
}
break
default:
throw new Error(`Unsupported storage type: ${storageType}`)
}
if (!serialized) {
return defaultValue
}
const data = this.deserialize(serialized)
return data !== null ? data : defaultValue
} catch (error) {
this.config.onError(error)
return defaultValue
}
}
/**
* 删除数据
*/
async remove(key, options = {}) {
const {
storageType = this.config.storageType
} = options
const fullKey = this.getFullKey(key)
try {
switch (storageType) {
case StorageType.LOCAL:
localStorage.removeItem(fullKey)
break
case StorageType.SESSION:
sessionStorage.removeItem(fullKey)
break
case StorageType.INDEXED_DB:
await this.removeIndexedDB(fullKey)
break
case StorageType.MEMORY:
this.memoryStorage.delete(fullKey)
break
default:
throw new Error(`Unsupported storage type: ${storageType}`)
}
return true
} catch (error) {
this.config.onError(error)
return false
}
}
/**
* 清空所有数据
*/
async clear(options = {}) {
const {
storageType = this.config.storageType
} = options
try {
switch (storageType) {
case StorageType.LOCAL:
// 只清空带前缀的键
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(this.config.prefix)) {
localStorage.removeItem(key)
}
}
break
case StorageType.SESSION:
// 只清空带前缀的键
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (key && key.startsWith(this.config.prefix)) {
sessionStorage.removeItem(key)
}
}
break
case StorageType.INDEXED_DB:
await this.clearIndexedDB()
break
case StorageType.MEMORY:
this.memoryStorage.clear()
break
default:
throw new Error(`Unsupported storage type: ${storageType}`)
}
return true
} catch (error) {
this.config.onError(error)
return false
}
}
/**
* 获取所有键名
*/
async keys(options = {}) {
const {
storageType = this.config.storageType
} = options
try {
let keys = []
switch (storageType) {
case StorageType.LOCAL:
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(this.config.prefix)) {
keys.push(key.substring(this.config.prefix.length))
}
}
break
case StorageType.SESSION:
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (key && key.startsWith(this.config.prefix)) {
keys.push(key.substring(this.config.prefix.length))
}
}
break
case StorageType.INDEXED_DB:
keys = await this.keysIndexedDB()
break
case StorageType.MEMORY:
for (const key of this.memoryStorage.keys()) {
if (key.startsWith(this.config.prefix)) {
keys.push(key.substring(this.config.prefix.length))
}
}
break
default:
throw new Error(`Unsupported storage type: ${storageType}`)
}
return keys
} catch (error) {
this.config.onError(error)
return []
}
}
/**
* IndexedDB操作方法
*/
async setIndexedDB(key, value, expire) {
if (!this.db) {
await this.initIndexedDB()
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['stateStore'], 'readwrite')
const store = transaction.objectStore('stateStore')
const request = store.put({
key,
value,
expire: expire > 0 ? Date.now() + expire : 0
})
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
}
async getIndexedDB(key) {
if (!this.db) {
await this.initIndexedDB()
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['stateStore'], 'readonly')
const store = transaction.objectStore('stateStore')
const request = store.get(key)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const result = request.result
if (!result) {
resolve(null)
return
}
// 检查是否过期
if (result.expire > 0 && Date.now() > result.expire) {
this.removeIndexedDB(key)
resolve(null)
return
}
resolve(result.value)
}
})
}
async removeIndexedDB(key) {
if (!this.db) {
await this.initIndexedDB()
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['stateStore'], 'readwrite')
const store = transaction.objectStore('stateStore')
const request = store.delete(key)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
}
async clearIndexedDB() {
if (!this.db) {
await this.initIndexedDB()
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['stateStore'], 'readwrite')
const store = transaction.objectStore('stateStore')
const request = store.clear()
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
}
async keysIndexedDB() {
if (!this.db) {
await this.initIndexedDB()
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['stateStore'], 'readonly')
const store = transaction.objectStore('stateStore')
const request = store.getAllKeys()
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const keys = request.result
const filteredKeys = keys
.filter(key => key.startsWith(this.config.prefix))
.map(key => key.substring(this.config.prefix.length))
resolve(filteredKeys)
}
})
}
}
// 创建默认实例
const defaultPersistManager = new StatePersistManager()
// 导出便捷方法
export const setItem = (key, value, options) => defaultPersistManager.set(key, value, options)
export const getItem = (key, options) => defaultPersistManager.get(key, options)
export const removeItem = (key, options) => defaultPersistManager.remove(key, options)
export const clearItems = (options) => defaultPersistManager.clear(options)
export const getKeys = (options) => defaultPersistManager.keys(options)
// 导出管理器类
export { StatePersistManager }
// 导出默认实例
export default defaultPersistManager

View File

@@ -0,0 +1,257 @@
/**
* 缓存管理页面
* 提供缓存状态查看和管理功能
*/
<template>
<div class="cache-management">
<el-card class="cache-stats-card">
<template #header>
<div class="card-header">
<span>缓存统计信息</span>
<el-button type="primary" @click="refreshStats">刷新</el-button>
</div>
</template>
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="缓存项数量" :value="cacheStats.size" />
</el-col>
<el-col :span="6">
<el-statistic title="最大缓存数量" :value="cacheStats.maxSize" />
</el-col>
<el-col :span="6">
<el-statistic title="总大小" :value="formatSize(cacheStats.totalSize)" />
</el-col>
<el-col :span="6">
<el-statistic title="过期项数量" :value="cacheStats.expiredCount" />
</el-col>
</el-row>
<div class="cache-strategy">
<span>当前缓存策略: </span>
<el-tag>{{ cacheStats.strategy }}</el-tag>
</div>
</el-card>
<el-card class="cache-operations-card">
<template #header>
<div class="card-header">
<span>缓存操作</span>
</div>
</template>
<el-space wrap>
<el-button type="danger" @click="clearAllCache">清空所有缓存</el-button>
<el-button type="warning" @click="clearExpiredCache">清除过期缓存</el-button>
<el-button @click="showCacheKeys">查看缓存键</el-button>
</el-space>
</el-card>
<el-card class="cache-keys-card" v-if="showKeys">
<template #header>
<div class="card-header">
<span>缓存键列表</span>
<el-button size="small" @click="showKeys = false">隐藏</el-button>
</div>
</template>
<el-table :data="cacheKeysData" style="width: 100%">
<el-table-column prop="key" label="键名" />
<el-table-column prop="exists" label="是否存在">
<template #default="scope">
<el-tag :type="scope.row.exists ? 'success' : 'danger'">
{{ scope.row.exists ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button
size="small"
type="danger"
@click="deleteCacheByKey(scope.row.key)"
:disabled="!scope.row.exists"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 缓存值查看对话框 -->
<el-dialog v-model="showValueDialog" title="缓存值查看" width="50%">
<pre>{{ cacheValue }}</pre>
</el-dialog>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getCacheStats,
clearCache,
clearExpiredCache,
getCache,
deleteCache,
hasCache,
keys
} from '../utils/componentCache'
export default {
name: 'CacheManagement',
setup() {
// 响应式数据
const cacheStats = reactive({
size: 0,
maxSize: 0,
totalSize: 0,
memoryThreshold: 0,
expiredCount: 0,
strategy: ''
})
const showKeys = ref(false)
const cacheKeysData = ref([])
const showValueDialog = ref(false)
const cacheValue = ref('')
// 刷新统计信息
const refreshStats = () => {
Object.assign(cacheStats, getCacheStats())
}
// 清空所有缓存
const clearAllCache = async () => {
try {
await ElMessageBox.confirm(
'确定要清空所有缓存吗?此操作不可恢复。',
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
clearCache()
refreshStats()
ElMessage.success('已清空所有缓存')
} catch (error) {
// 用户取消操作
}
}
// 清除过期缓存
const clearExpiredItems = () => {
const count = clearExpiredCache()
refreshStats()
ElMessage.success(`已清除 ${count} 个过期缓存项`)
}
// 显示缓存键
const showCacheKeys = () => {
const keysList = keys()
cacheKeysData.value = keysList.map(key => ({
key,
exists: hasCache(key)
}))
showKeys.value = true
}
// 根据键删除缓存
const deleteCacheByKey = (key) => {
deleteCache(key)
refreshStats()
// 更新键列表中的存在状态
const item = cacheKeysData.value.find(item => item.key === key)
if (item) {
item.exists = false
}
ElMessage.success(`已删除缓存项: ${key}`)
}
// 查看缓存值
const viewCacheValue = (key) => {
const value = getCache(key)
cacheValue.value = JSON.stringify(value, null, 2)
showValueDialog.value = true
}
// 格式化大小
const formatSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 组件挂载时刷新统计信息
onMounted(() => {
refreshStats()
})
return {
cacheStats,
showKeys,
cacheKeysData,
showValueDialog,
cacheValue,
refreshStats,
clearAllCache,
clearExpiredCache: clearExpiredItems,
showCacheKeys,
deleteCacheByKey,
viewCacheValue,
formatSize
}
}
}
</script>
<style scoped>
.cache-management {
padding: 20px;
}
.cache-stats-card,
.cache-operations-card,
.cache-keys-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.cache-strategy {
margin-top: 20px;
display: flex;
align-items: center;
}
.cache-strategy span {
margin-right: 10px;
font-weight: bold;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
max-height: 400px;
overflow-y: auto;
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<div class="performance-monitor-page">
<div class="page-header">
<h1>性能监控</h1>
<p>监控应用性能指标错误日志和API请求状态</p>
</div>
<PerformanceDashboard />
</div>
</template>
<script setup>
import PerformanceDashboard from '@/components/PerformanceDashboard.vue'
</script>
<style scoped>
.performance-monitor-page {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-header h1 {
margin: 0 0 10px 0;
font-size: 24px;
color: #303133;
}
.page-header p {
margin: 0;
color: #606266;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,72 @@
const { chromium } = require('@playwright/test')
async function globalSetup(config) {
console.log('🚀 开始全局设置...')
// 获取浏览器实例
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
try {
// 设置测试数据
await setupTestData(page)
// 设置用户认证
await setupAuthentication(page)
// 设置测试环境变量
await setupEnvironmentVariables()
console.log('✅ 全局设置完成')
} catch (error) {
console.error('❌ 全局设置失败:', error)
throw error
} finally {
await context.close()
await browser.close()
}
}
async function setupTestData(page) {
console.log('📊 设置测试数据...')
// 这里可以设置测试数据,例如:
// 1. 创建测试用户
// 2. 准备测试产品数据
// 3. 设置测试类别等
// 示例通过API设置测试数据
// await page.goto('/api/test/setup')
// await page.waitForResponse(response => response.status() === 200)
}
async function setupAuthentication(page) {
console.log('🔐 设置用户认证...')
// 这里可以设置测试用户认证,例如:
// 1. 创建测试用户
// 2. 登录测试用户
// 3. 保存认证令牌
// 示例通过API登录
// await page.goto('/api/auth/login')
// await page.fill('[data-testid="username"]', 'testuser')
// await page.fill('[data-testid="password"]', 'testpassword')
// await page.click('[data-testid="login-button"]')
// await page.waitForResponse(response => response.status() === 200)
// 保存认证状态
// await context.storageState({ path: 'test-auth-state.json' })
}
async function setupEnvironmentVariables() {
console.log('🌍 设置环境变量...')
// 设置测试环境变量
process.env.TEST_MODE = 'true'
process.env.API_BASE_URL = 'http://localhost:7001/api'
process.env.TEST_TIMEOUT = '30000'
}
module.exports = globalSetup

View File

@@ -0,0 +1,120 @@
const { chromium } = require('@playwright/test')
async function globalTeardown(config) {
console.log('🧹 开始全局清理...')
// 获取浏览器实例
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
try {
// 清理测试数据
await cleanupTestData(page)
// 清理用户认证
await cleanupAuthentication()
// 清理环境变量
await cleanupEnvironmentVariables()
// 清理测试文件
await cleanupTestFiles()
console.log('✅ 全局清理完成')
} catch (error) {
console.error('❌ 全局清理失败:', error)
throw error
} finally {
await context.close()
await browser.close()
}
}
async function cleanupTestData(page) {
console.log('📊 清理测试数据...')
// 这里可以清理测试数据,例如:
// 1. 删除测试用户
// 2. 清理测试产品数据
// 3. 重置测试类别等
// 示例通过API清理测试数据
// await page.goto('/api/test/cleanup')
// await page.waitForResponse(response => response.status() === 200)
}
async function cleanupAuthentication() {
console.log('🔐 清理用户认证...')
// 这里可以清理用户认证,例如:
// 1. 删除测试用户
// 2. 清理认证令牌
// 示例:删除认证状态文件
// const fs = require('fs')
// if (fs.existsSync('test-auth-state.json')) {
// fs.unlinkSync('test-auth-state.json')
// }
}
async function cleanupEnvironmentVariables() {
console.log('🌍 清理环境变量...')
// 清理测试环境变量
delete process.env.TEST_MODE
delete process.env.API_BASE_URL
delete process.env.TEST_TIMEOUT
}
async function cleanupTestFiles() {
console.log('📁 清理测试文件...')
// 清理测试生成的文件
const fs = require('fs')
const path = require('path')
// 清理测试报告目录
const reportDirs = ['test-results', 'playwright-report']
reportDirs.forEach(dir => {
const dirPath = path.resolve(process.cwd(), dir)
if (fs.existsSync(dirPath)) {
// 保留目录,但清理其中的文件
const files = fs.readdirSync(dirPath)
files.forEach(file => {
const filePath = path.join(dirPath, file)
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
// 递归删除子目录
deleteFolderRecursive(filePath)
} else {
// 删除文件
fs.unlinkSync(filePath)
}
})
}
})
}
// 递归删除文件夹
function deleteFolderRecursive(folderPath) {
const fs = require('fs')
const path = require('path')
if (fs.existsSync(folderPath)) {
fs.readdirSync(folderPath).forEach(file => {
const curPath = path.join(folderPath, file)
if (fs.lstatSync(curPath).isDirectory()) {
// 递归删除子目录
deleteFolderRecursive(curPath)
} else {
// 删除文件
fs.unlinkSync(curPath)
}
})
fs.rmdirSync(folderPath)
}
}
module.exports = globalTeardown

View File

@@ -0,0 +1,315 @@
import { test, expect, chromium } from '@playwright/test'
test.describe('用户旅程测试', () => {
let browser
let context
let page
test.beforeAll(async () => {
// 启动浏览器
browser = await chromium.launch()
})
test.afterAll(async () => {
// 关闭浏览器
await browser.close()
})
test.beforeEach(async () => {
// 创建新的浏览器上下文和页面
context = await browser.newContext()
page = await context.newPage()
// 设置视口大小
await page.setViewportSize({ width: 1280, height: 720 })
})
test.afterEach(async () => {
// 关闭浏览器上下文
await context.close()
})
test('用户应该能够浏览首页并选择产品类别', async () => {
// 访问首页
await page.goto('/')
// 检查页面标题
await expect(page).toHaveTitle(/硬件性能排行榜/)
// 检查应用标题
await expect(page.locator('.header__title')).toContainText('硬件性能排行榜')
// 等待类别卡片加载
await page.waitForSelector('.category-card')
// 检查类别卡片数量
const categoryCards = await page.locator('.category-card').count()
expect(categoryCards).toBeGreaterThan(0)
// 点击第一个类别卡片
await page.locator('.category-card').first().click()
// 检查是否跳转到类别排名页面
await expect(page).toHaveURL(/\/category\/\d+/)
// 检查页面标题是否更新为类别名称
const categoryName = await page.locator('.page-title').textContent()
expect(categoryName).toBeTruthy()
})
test('用户应该能够浏览产品排名列表', async () => {
// 直接访问类别排名页面
await page.goto('/category/1')
// 等待产品列表加载
await page.waitForSelector('.product-card')
// 检查产品卡片数量
const productCards = await page.locator('.product-card').count()
expect(productCards).toBeGreaterThan(0)
// 检查排名显示
const firstProductRank = await page.locator('.product-card').first().locator('.product-rank').textContent()
expect(firstProductRank).toContain('#1')
// 点击第一个产品卡片
await page.locator('.product-card').first().click()
// 检查是否跳转到产品详情页面
await expect(page).toHaveURL(/\/product\/\d+/)
// 检查产品详情页面内容
await expect(page.locator('.product-detail')).toBeVisible()
await expect(page.locator('.product-name')).toBeVisible()
await expect(page.locator('.product-specs')).toBeVisible()
})
test('用户应该能够使用搜索功能查找产品', async () => {
// 访问类别排名页面
await page.goto('/category/1')
// 等待搜索框加载
await page.waitForSelector('.search-input')
// 输入搜索关键词
await page.fill('.search-input', 'Intel')
// 点击搜索按钮
await page.click('.search-button')
// 等待搜索结果加载
await page.waitForSelector('.product-card')
// 检查搜索结果
const productCards = await page.locator('.product-card').count()
expect(productCards).toBeGreaterThan(0)
// 检查搜索结果是否包含搜索关键词
const firstProductName = await page.locator('.product-card').first().locator('.product-name').textContent()
expect(firstProductName.toLowerCase()).toContain('intel'.toLowerCase())
})
test('用户应该能够使用筛选功能过滤产品', async () => {
// 访问类别排名页面
await page.goto('/category/1')
// 等待筛选器加载
await page.waitForSelector('.product-filter')
// 选择品牌筛选
await page.selectOption('.brand-filter', 'Intel')
// 设置性能分数范围
await page.fill('.min-score-input', '5000')
await page.fill('.max-score-input', '10000')
// 点击应用筛选按钮
await page.click('.apply-filter-button')
// 等待筛选结果加载
await page.waitForSelector('.product-card')
// 检查筛选结果
const productCards = await page.locator('.product-card').count()
expect(productCards).toBeGreaterThan(0)
// 检查第一个产品的品牌
const firstProductBrand = await page.locator('.product-card').first().locator('.product-brand').textContent()
expect(firstProductBrand).toContain('Intel')
})
test('用户应该能够使用排序功能对产品进行排序', async () => {
// 访问类别排名页面
await page.goto('/category/1')
// 等待排序选择器加载
await page.waitForSelector('.sort-select')
// 选择按性能分数降序排序
await page.selectOption('.sort-select', 'score-desc')
// 等待排序结果加载
await page.waitForSelector('.product-card')
// 检查第一个产品的性能分数是否最高
const firstProductScore = await page.locator('.product-card').first().locator('.product-score').textContent()
const secondProductScore = await page.locator('.product-card').nth(1).locator('.product-score').textContent()
// 提取数字部分进行比较
const firstScore = parseInt(firstProductScore.replace(/[^0-9]/g, ''))
const secondScore = parseInt(secondProductScore.replace(/[^0-9]/g, ''))
expect(firstScore).toBeGreaterThanOrEqual(secondScore)
})
test('用户应该能够选择产品进行对比', async () => {
// 访问类别排名页面
await page.goto('/category/1')
// 等待产品列表加载
await page.waitForSelector('.product-card')
// 选择第一个产品的复选框
await page.check('.product-card').first().locator('.compare-checkbox')
// 选择第二个产品的复选框
await page.check('.product-card').nth(1).locator('.compare-checkbox')
// 点击对比按钮
await page.click('.compare-button')
// 检查是否跳转到产品对比页面
await expect(page).toHaveURL(/\/compare/)
// 检查对比页面内容
await expect(page.locator('.comparison-table')).toBeVisible()
await expect(page.locator('.comparison-chart')).toBeVisible()
})
test('用户应该能够查看性能监控页面', async () => {
// 访问性能监控页面
await page.goto('/monitor')
// 等待页面加载
await page.waitForSelector('.monitor-dashboard')
// 检查性能指标卡片
await expect(page.locator('.metric-card')).toBeVisible()
// 检查性能图表
await expect(page.locator('.performance-chart')).toBeVisible()
// 检查系统状态
await expect(page.locator('.system-status')).toBeVisible()
})
test('用户应该能够在移动设备上正常使用应用', async () => {
// 设置移动设备视口
await page.setViewportSize({ width: 375, height: 667 })
// 访问首页
await page.goto('/')
// 检查移动端菜单按钮
await expect(page.locator('.header__mobile-menu')).toBeVisible()
// 点击移动端菜单按钮
await page.click('.header__mobile-menu')
// 检查移动端菜单是否展开
await expect(page.locator('.mobile-menu')).toBeVisible()
// 点击类别卡片
await page.click('.category-card').first()
// 检查是否跳转到类别排名页面
await expect(page).toHaveURL(/\/category\/\d+/)
// 检查移动端产品列表布局
await expect(page.locator('.product-card')).toBeVisible()
// 检查移动端筛选器
await expect(page.locator('.mobile-filter')).toBeVisible()
})
test('用户应该能够处理网络错误', async () => {
// 模拟网络错误
await page.route('**/api/**', route => route.abort())
// 访问首页
await page.goto('/')
// 等待错误提示
await page.waitForSelector('.error-message')
// 检查错误提示内容
await expect(page.locator('.error-message')).toContainText('网络错误')
// 点击重试按钮
await page.click('.retry-button')
// 恢复网络连接
await page.unroute('**/api/**')
// 检查页面是否恢复正常
await page.waitForSelector('.category-card')
await expect(page.locator('.category-card')).toBeVisible()
})
test('用户应该能够使用页面导航', async () => {
// 访问首页
await page.goto('/')
// 点击导航栏中的性能监控链接
await page.click('[data-testid="monitor-button"]')
// 检查是否跳转到性能监控页面
await expect(page).toHaveURL('/monitor')
// 点击浏览器后退按钮
await page.goBack()
// 检查是否返回到首页
await expect(page).toHaveURL('/')
// 点击浏览器前进按钮
await page.goForward()
// 检查是否前进到性能监控页面
await expect(page).toHaveURL('/monitor')
})
test('用户应该能够使用分页功能浏览更多产品', async () => {
// 访问类别排名页面
await page.goto('/category/1')
// 等待产品列表和分页组件加载
await page.waitForSelector('.product-card')
await page.waitForSelector('.pagination')
// 记录第一页的产品
const firstPageProducts = await page.locator('.product-card').allTextContents()
// 点击下一页按钮
await page.click('.pagination-next')
// 等待新页面加载
await page.waitForSelector('.product-card')
// 记录第二页的产品
const secondPageProducts = await page.locator('.product-card').allTextContents()
// 检查两页的产品是否不同
expect(firstPageProducts).not.toEqual(secondPageProducts)
// 点击上一页按钮
await page.click('.pagination-prev')
// 等待页面加载
await page.waitForSelector('.product-card')
// 检查是否返回到第一页
const backToFirstPageProducts = await page.locator('.product-card').allTextContents()
expect(backToFirstPageProducts).toEqual(firstPageProducts)
})
})

203
frontend/tests/setup.js Normal file
View File

@@ -0,0 +1,203 @@
import { vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'
import { config } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ElementPlus from 'element-plus'
// 全局模拟
// 模拟window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// 模拟ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor(cb) {
this.cb = cb
}
observe() {}
unobserve() {}
disconnect() {}
}
// 模拟IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor(cb) {
this.cb = cb
}
observe() {}
unobserve() {}
disconnect() {}
}
// 模拟window.location
Object.defineProperty(window, 'location', {
value: {
href: 'http://localhost:3000',
origin: 'http://localhost:3000',
protocol: 'http:',
host: 'localhost:3000',
hostname: 'localhost',
port: '3000',
pathname: '/',
search: '',
hash: '',
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
writable: true,
})
// 模拟localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn(),
}
global.localStorage = localStorageMock
// 模拟sessionStorage
const sessionStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn(),
}
global.sessionStorage = sessionStorageMock
// 模拟navigator
Object.defineProperty(window, 'navigator', {
value: {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
language: 'en-US',
languages: ['en-US', 'en'],
platform: 'Win32',
cookieEnabled: true,
onLine: true,
geolocation: {
getCurrentPosition: vi.fn(),
watchPosition: vi.fn(),
clearWatch: vi.fn(),
},
},
writable: true,
})
// 模拟performance
Object.defineProperty(window, 'performance', {
value: {
now: vi.fn(() => Date.now()),
timing: {
navigationStart: Date.now() - 1000,
loadEventEnd: Date.now(),
},
getEntriesByType: vi.fn(() => []),
mark: vi.fn(),
measure: vi.fn(),
getEntriesByName: vi.fn(() => []),
clearMarks: vi.fn(),
clearMeasures: vi.fn(),
},
writable: true,
})
// 模拟console方法以减少测试输出噪音
global.console = {
...console,
log: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}
// 配置Vue Test Utils
config.global.plugins = [ElementPlus, createPinia()]
// 配置全局组件
config.global.stubs = {
'el-button': true,
'el-input': true,
'el-form': true,
'el-form-item': true,
'el-select': true,
'el-option': true,
'el-table': true,
'el-table-column': true,
'el-pagination': true,
'el-dialog': true,
'el-drawer': true,
'el-tooltip': true,
'el-popover': true,
'el-alert': true,
'el-loading': true,
'el-icon': true,
'router-link': true,
'router-view': true,
}
// 配置全局mocks
config.global.mocks = {
$t: (key) => key,
$router: {
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
},
$route: {
path: '/',
name: 'Home',
params: {},
query: {},
hash: '',
fullPath: '/',
matched: [],
meta: {},
},
}
// 在每个测试前设置Pinia
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
})
// 在所有测试前执行
beforeAll(() => {
// 设置测试环境变量
process.env.NODE_ENV = 'test'
// 模拟API基础URL
process.env.VITE_API_BASE_URL = 'http://localhost:3000/api'
})
// 在所有测试后执行
afterAll(() => {
// 清理测试环境
})
// 在每个测试后执行
afterEach(() => {
// 清理模拟
vi.clearAllMocks()
// 重置模块注册表
vi.resetModules()
})

View File

@@ -0,0 +1,281 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Header from '@/components/Header.vue'
import { useCategoryStore } from '@/stores/categoryStore'
// 模拟路由
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
currentRoute: {
value: {
path: '/',
name: 'Home',
params: {},
query: {},
hash: '',
fullPath: '/',
matched: [],
meta: {},
}
}
}
// 模拟Element Plus组件
vi.mock('element-plus', () => ({
ElMenu: {
name: 'ElMenu',
template: '<div class="el-menu"><slot></slot></div>',
props: ['mode', 'default-active', 'router']
},
ElMenuItem: {
name: 'ElMenuItem',
template: '<div class="el-menu-item" @click="$emit(\'click\')"><slot></slot></div>',
props: ['index']
},
ElSubMenu: {
name: 'ElSubMenu',
template: '<div class="el-sub-menu"><slot name="title"></slot><slot></slot></div>',
props: ['index']
},
ElButton: {
name: 'ElButton',
template: '<button class="el-button" @click="$emit(\'click\')"><slot></slot></button>',
props: ['type', 'size', 'icon']
},
ElIcon: {
name: 'ElIcon',
template: '<i class="el-icon"><slot></slot></i>'
},
ElDropdown: {
name: 'ElDropdown',
template: '<div class="el-dropdown"><slot></slot></div>',
props: ['trigger']
},
ElDropdownMenu: {
name: 'ElDropdownMenu',
template: '<div class="el-dropdown-menu"><slot></slot></div>'
},
ElDropdownItem: {
name: 'ElDropdownItem',
template: '<div class="el-dropdown-item" @click="$emit(\'click\')"><slot></slot></div>'
},
ElDrawer: {
name: 'ElDrawer',
template: '<div class="el-drawer" v-if="modelValue"><slot></slot></div>',
props: ['modelValue', 'title', 'direction', 'size'],
emits: ['update:modelValue']
}
}))
// 模拟Element Plus图标
vi.mock('@element-plus/icons-vue', () => ({
Menu: { name: 'Menu', template: '<i class="icon-menu"></i>' },
Close: { name: 'Close', template: '<i class="icon-close"></i>' },
User: { name: 'User', template: '<i class="icon-user"></i>' },
Setting: { name: 'Setting', template: '<i class="icon-setting"></i>' },
Monitor: { name: 'Monitor', template: '<i class="icon-monitor"></i>' },
DataAnalysis: { name: 'DataAnalysis', template: '<i class="icon-data-analysis"></i>' }
}))
describe('Header.vue', () => {
let wrapper
let pinia
let categoryStore
beforeEach(() => {
// 创建Pinia实例
pinia = createPinia()
setActivePinia(pinia)
// 获取store实例
categoryStore = useCategoryStore()
// 模拟store数据
categoryStore.categories = [
{ id: 1, name: '手机CPU', productCount: 50 },
{ id: 2, name: '手机GPU', productCount: 40 },
{ id: 3, name: '电脑CPU', productCount: 60 },
{ id: 4, name: '电脑GPU', productCount: 45 }
]
// 挂载组件
wrapper = mount(Header, {
global: {
plugins: [pinia],
mocks: {
$router: mockRouter
},
stubs: {
'router-link': true,
'router-view': true,
'cache-status-indicator': true
}
}
})
})
it('应该正确渲染Header组件', () => {
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('.header').exists()).toBe(true)
expect(wrapper.find('.header__logo').exists()).toBe(true)
expect(wrapper.find('.header__nav').exists()).toBe(true)
})
it('应该显示应用标题', () => {
const title = wrapper.find('.header__title')
expect(title.exists()).toBe(true)
expect(title.text()).toBe('硬件性能排行榜')
})
it('应该显示产品类别菜单', async () => {
// 等待组件加载完成
await wrapper.vm.$nextTick()
// 检查类别菜单是否存在
const categoryMenu = wrapper.find('.category-menu')
expect(categoryMenu.exists()).toBe(true)
// 检查类别数量
const menuItems = wrapper.findAll('.el-menu-item')
expect(menuItems.length).toBeGreaterThan(0)
})
it('应该显示产品对比按钮', () => {
const compareButton = wrapper.find('[data-testid="compare-button"]')
expect(compareButton.exists()).toBe(true)
expect(compareButton.text()).toContain('产品对比')
})
it('应该显示性能监控按钮', () => {
const monitorButton = wrapper.find('[data-testid="monitor-button"]')
expect(monitorButton.exists()).toBe(true)
expect(monitorButton.text()).toContain('性能监控')
})
it('应该响应式地在移动端显示菜单按钮', async () => {
// 模拟移动端窗口大小
global.innerWidth = 500
window.dispatchEvent(new Event('resize'))
await wrapper.vm.$nextTick()
// 检查移动端菜单按钮是否存在
const menuButton = wrapper.find('.header__mobile-menu')
expect(menuButton.exists()).toBe(true)
})
it('应该正确处理菜单点击事件', async () => {
// 获取第一个菜单项
const firstMenuItem = wrapper.find('.el-menu-item')
expect(firstMenuItem.exists()).toBe(true)
// 点击菜单项
await firstMenuItem.trigger('click')
// 检查是否触发了路由导航
// 注意由于使用了router-link stub这里需要检查组件内部逻辑
// 在实际测试中你可能需要模拟router-link的行为或检查组件内部状态
})
it('应该正确处理产品对比按钮点击', async () => {
const compareButton = wrapper.find('[data-testid="compare-button"]')
expect(compareButton.exists()).toBe(true)
// 点击产品对比按钮
await compareButton.trigger('click')
// 检查是否触发了路由导航
expect(mockRouter.push).toHaveBeenCalledWith('/compare')
})
it('应该正确处理性能监控按钮点击', async () => {
const monitorButton = wrapper.find('[data-testid="monitor-button"]')
expect(monitorButton.exists()).toBe(true)
// 点击性能监控按钮
await monitorButton.trigger('click')
// 检查是否触发了路由导航
expect(mockRouter.push).toHaveBeenCalledWith('/monitor')
})
it('应该在移动端正确处理菜单展开/收起', async () => {
// 模拟移动端窗口大小
global.innerWidth = 500
window.dispatchEvent(new Event('resize'))
await wrapper.vm.$nextTick()
// 获取菜单按钮
const menuButton = wrapper.find('.header__mobile-menu')
expect(menuButton.exists()).toBe(true)
// 点击菜单按钮
await menuButton.trigger('click')
// 检查菜单是否展开
expect(wrapper.vm.mobileMenuVisible).toBe(true)
// 再次点击菜单按钮
await menuButton.trigger('click')
// 检查菜单是否收起
expect(wrapper.vm.mobileMenuVisible).toBe(false)
})
it('应该正确计算当前激活的菜单项', async () => {
// 模拟当前路由为类别页面
mockRouter.currentRoute.value.path = '/category/1'
await wrapper.vm.$nextTick()
// 检查当前激活的菜单项
expect(wrapper.vm.activeMenuItem).toBe('category')
})
it('应该在组件挂载时加载类别数据', async () => {
// 模拟fetchCategories方法
const fetchCategoriesSpy = vi.spyOn(categoryStore, 'fetchCategories')
// 重新挂载组件
wrapper = mount(Header, {
global: {
plugins: [pinia],
mocks: {
$router: mockRouter
},
stubs: {
'router-link': true,
'router-view': true,
'cache-status-indicator': true
}
}
})
// 等待组件加载完成
await wrapper.vm.$nextTick()
// 检查是否调用了fetchCategories
expect(fetchCategoriesSpy).toHaveBeenCalled()
})
it('应该正确处理窗口大小变化', async () => {
// 模拟桌面端窗口大小
global.innerWidth = 1200
window.dispatchEvent(new Event('resize'))
await wrapper.vm.$nextTick()
// 检查isMobile计算属性
expect(wrapper.vm.isMobile).toBe(false)
// 模拟移动端窗口大小
global.innerWidth = 500
window.dispatchEvent(new Event('resize'))
await wrapper.vm.$nextTick()
// 检查isMobile计算属性
expect(wrapper.vm.isMobile).toBe(true)
})
})

View File

@@ -0,0 +1,354 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { api, clearCache, cancelAllRequests } from '@/services/api'
import axios from 'axios'
// 模拟axios
vi.mock('axios')
const mockedAxios = vi.mocked(axios)
// 模拟localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
}
global.localStorage = localStorageMock
// 模拟console方法
const consoleSpy = {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
log: vi.spyOn(console, 'log').mockImplementation(() => {})
}
// 模拟window.performance
Object.defineProperty(window, 'performance', {
value: {
now: vi.fn(() => Date.now())
}
})
describe('API Service', () => {
beforeEach(() => {
// 重置所有模拟
vi.clearAllMocks()
// 模拟axios.create返回值
mockedAxios.create.mockReturnValue({
request: vi.fn(),
interceptors: {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
})
})
afterEach(() => {
// 清理所有请求
cancelAllRequests()
clearCache()
})
describe('API实例', () => {
it('应该正确创建API实例', () => {
expect(api).toBeDefined()
expect(typeof api.get).toBe('function')
expect(typeof api.post).toBe('function')
expect(typeof api.put).toBe('function')
expect(typeof api.delete).toBe('function')
expect(typeof api.request).toBe('function')
})
it('应该设置正确的baseURL', () => {
// 由于我们模拟了axios.create这里我们检查调用参数
expect(mockedAxios.create).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: 'https://localhost:7001/api',
timeout: 15000
})
)
})
})
describe('请求拦截器', () => {
it('应该在请求前添加时间戳', async () => {
// 模拟请求
const mockRequest = {
url: '/test',
method: 'get',
headers: {}
}
// 模拟请求拦截器
const requestInterceptor = mockedAxios.create().interceptors.request.use.mock.calls[0][0]
const processedRequest = requestInterceptor(mockRequest)
// 检查是否添加了时间戳
expect(processedRequest.metadata).toBeDefined()
expect(processedRequest.metadata.startTime).toBeDefined()
})
it('应该在请求前添加CSRF令牌', async () => {
// 模拟localStorage中的CSRF令牌
localStorageMock.getItem.mockReturnValue('test-csrf-token')
// 模拟请求
const mockRequest = {
url: '/test',
method: 'post',
headers: {}
}
// 模拟请求拦截器
const requestInterceptor = mockedAxios.create().interceptors.request.use.mock.calls[0][0]
const processedRequest = requestInterceptor(mockRequest)
// 检查是否添加了CSRF令牌
expect(processedRequest.headers['X-CSRF-Token']).toBe('test-csrf-token')
})
it('应该在请求前添加认证令牌', async () => {
// 模拟localStorage中的认证令牌
localStorageMock.getItem.mockImplementation((key) => {
if (key === 'auth_token') return 'test-auth-token'
return null
})
// 模拟请求
const mockRequest = {
url: '/test',
method: 'get',
headers: {}
}
// 模拟请求拦截器
const requestInterceptor = mockedAxios.create().interceptors.request.use.mock.calls[0][0]
const processedRequest = requestInterceptor(mockRequest)
// 检查是否添加了认证令牌
expect(processedRequest.headers.Authorization).toBe('Bearer test-auth-token')
})
})
describe('响应拦截器', () => {
it('应该正确处理成功响应', async () => {
// 模拟响应
const mockResponse = {
data: { success: true, data: { id: 1, name: 'Test' } },
status: 200,
statusText: 'OK',
headers: {},
config: {
url: '/test',
method: 'get',
metadata: { startTime: Date.now() - 100 }
}
}
// 模拟响应拦截器
const responseInterceptor = mockedAxios.create().interceptors.response.use.mock.calls[0][0]
const processedResponse = responseInterceptor(mockResponse)
// 检查响应处理
expect(processedResponse.data).toEqual({ success: true, data: { id: 1, name: 'Test' } })
})
it('应该记录请求耗时', async () => {
// 模拟响应
const mockResponse = {
data: { success: true, data: { id: 1, name: 'Test' } },
status: 200,
statusText: 'OK',
headers: {},
config: {
url: '/test',
method: 'get',
metadata: { startTime: Date.now() - 100 }
}
}
// 模拟响应拦截器
const responseInterceptor = mockedAxios.create().interceptors.response.use.mock.calls[0][0]
responseInterceptor(mockResponse)
// 检查是否记录了请求耗时
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('请求完成'),
expect.anything()
)
})
it('应该缓存GET请求响应', async () => {
// 模拟响应
const mockResponse = {
data: { success: true, data: { id: 1, name: 'Test' } },
status: 200,
statusText: 'OK',
headers: {},
config: {
url: '/test',
method: 'get',
metadata: { startTime: Date.now() - 100 }
}
}
// 模拟响应拦截器
const responseInterceptor = mockedAxios.create().interceptors.response.use.mock.calls[0][0]
responseInterceptor(mockResponse)
// 检查是否缓存了响应
expect(localStorageMock.setItem).toHaveBeenCalledWith(
expect.stringContaining('api_cache_'),
expect.any(String)
)
})
})
describe('错误处理', () => {
it('应该正确处理网络错误', async () => {
// 模拟网络错误
const networkError = new Error('Network Error')
networkError.code = 'NETWORK_ERROR'
// 模拟错误拦截器
const errorInterceptor = mockedAxios.create().interceptors.response.use.mock.calls[0][1]
// 处理错误
const result = errorInterceptor(networkError)
// 检查错误处理
expect(result).rejects.toThrow()
expect(consoleSpy.error).toHaveBeenCalledWith(
'网络错误',
expect.any(Error)
)
})
it('应该正确处理超时错误', async () => {
// 模拟超时错误
const timeoutError = new Error('Timeout')
timeoutError.code = 'ECONNABORTED'
// 模拟错误拦截器
const errorInterceptor = mockedAxios.create().interceptors.response.use.mock.calls[0][1]
// 处理错误
const result = errorInterceptor(timeoutError)
// 检查错误处理
expect(result).rejects.toThrow()
expect(consoleSpy.error).toHaveBeenCalledWith(
'请求超时',
expect.any(Error)
)
})
it('应该正确处理服务器错误', async () => {
// 模拟服务器错误响应
const serverError = {
response: {
status: 500,
statusText: 'Internal Server Error',
data: { message: '服务器内部错误' }
}
}
// 模拟错误拦截器
const errorInterceptor = mockedAxios.create().interceptors.response.use.mock.calls[0][1]
// 处理错误
const result = errorInterceptor(serverError)
// 检查错误处理
expect(result).rejects.toThrow()
expect(consoleSpy.error).toHaveBeenCalledWith(
'服务器错误',
expect.any(Object)
)
})
it('应该正确处理401未授权错误', async () => {
// 模拟401错误响应
const unauthorizedError = {
response: {
status: 401,
statusText: 'Unauthorized',
data: { message: '未授权访问' }
}
}
// 模拟错误拦截器
const errorInterceptor = mockedAxios.create().interceptors.response.use.mock.calls[0][1]
// 处理错误
const result = errorInterceptor(unauthorizedError)
// 检查错误处理
expect(result).rejects.toThrow()
expect(consoleSpy.error).toHaveBeenCalledWith(
'未授权访问',
expect.any(Object)
)
})
})
describe('缓存管理', () => {
it('应该正确缓存GET请求', () => {
// 模拟缓存数据
const cacheKey = 'api_cache_/test'
const cacheData = {
data: { id: 1, name: 'Test' },
timestamp: Date.now(),
expiry: Date.now() + 300000 // 5分钟后过期
}
// 设置缓存
localStorageMock.setItem.mockReturnValue()
localStorageMock.getItem.mockReturnValue(JSON.stringify(cacheData))
// 检查缓存设置
localStorageMock.setItem(cacheKey, JSON.stringify(cacheData))
expect(localStorageMock.setItem).toHaveBeenCalledWith(cacheKey, JSON.stringify(cacheData))
})
it('应该正确清除缓存', () => {
// 调用清除缓存函数
clearCache()
// 检查是否清除了所有缓存
expect(localStorageMock.removeItem).toHaveBeenCalledWith('api_cache_/test')
})
})
describe('请求取消', () => {
it('应该正确取消重复请求', () => {
// 模拟请求配置
const requestConfig = {
url: '/test',
method: 'get'
}
// 模拟CancelToken
const cancelToken = {
token: 'test-token',
cancel: vi.fn()
}
// 检查是否创建了CancelToken
expect(cancelToken.token).toBe('test-token')
})
it('应该正确取消所有请求', () => {
// 调用取消所有请求函数
cancelAllRequests()
// 检查是否取消了所有请求
// 这里需要根据实际实现进行检查
})
})
})

View File

@@ -0,0 +1,338 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useCategoryStore } from '@/stores/categoryStore'
import categoryService from '@/services/categoryService'
// 模拟categoryService
vi.mock('@/services/categoryService', () => ({
default: {
getCategories: vi.fn(),
getCategoryById: vi.fn()
}
}))
// 模拟console方法
const consoleSpy = {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
log: vi.spyOn(console, 'log').mockImplementation(() => {})
}
describe('Category Store', () => {
let categoryStore
beforeEach(() => {
// 创建Pinia实例
const pinia = createPinia()
setActivePinia(pinia)
// 获取store实例
categoryStore = useCategoryStore()
// 重置所有模拟
vi.clearAllMocks()
})
afterEach(() => {
// 重置store状态
categoryStore.$reset()
})
describe('初始状态', () => {
it('应该有正确的初始状态', () => {
expect(categoryStore.categories).toEqual([])
expect(categoryStore.currentCategory).toBeNull()
expect(categoryStore.loading).toBe(false)
expect(categoryStore.error).toBeNull()
})
})
describe('getters', () => {
beforeEach(() => {
// 设置测试数据
categoryStore.categories = [
{ id: 1, name: '手机CPU', productCount: 50 },
{ id: 2, name: '手机GPU', productCount: 40 },
{ id: 3, name: '电脑CPU', productCount: 60 },
{ id: 4, name: '电脑GPU', productCount: 45 }
]
})
it('应该正确获取所有类别', () => {
const categories = categoryStore.getAllCategories
expect(categories).toEqual([
{ id: 1, name: '手机CPU', productCount: 50 },
{ id: 2, name: '手机GPU', productCount: 40 },
{ id: 3, name: '电脑CPU', productCount: 60 },
{ id: 4, name: '电脑GPU', productCount: 45 }
])
})
it('应该正确获取类别总数', () => {
const count = categoryStore.getCategoryCount
expect(count).toBe(4)
})
it('应该正确获取有产品的类别', () => {
const categoriesWithProducts = categoryStore.getCategoriesWithProducts
expect(categoriesWithProducts).toEqual([
{ id: 1, name: '手机CPU', productCount: 50 },
{ id: 2, name: '手机GPU', productCount: 40 },
{ id: 3, name: '电脑CPU', productCount: 60 },
{ id: 4, name: '电脑GPU', productCount: 45 }
])
})
it('应该正确获取类别名称列表', () => {
const categoryNames = categoryStore.getCategoryNames
expect(categoryNames).toEqual(['手机CPU', '手机GPU', '电脑CPU', '电脑GPU'])
})
it('应该根据ID正确获取类别', () => {
const category = categoryStore.getCategoryById(1)
expect(category).toEqual({ id: 1, name: '手机CPU', productCount: 50 })
})
it('应该根据名称正确获取类别', () => {
const category = categoryStore.getCategoryByName('手机CPU')
expect(category).toEqual({ id: 1, name: '手机CPU', productCount: 50 })
})
it('应该正确获取当前类别名称', () => {
categoryStore.currentCategory = { id: 1, name: '手机CPU' }
const categoryName = categoryStore.getCurrentCategoryName
expect(categoryName).toBe('手机CPU')
})
it('应该正确检查是否有加载错误', () => {
expect(categoryStore.hasError).toBe(false)
categoryStore.error = '加载失败'
expect(categoryStore.hasError).toBe(true)
})
it('应该正确获取错误信息', () => {
expect(categoryStore.errorMessage).toBe('')
categoryStore.error = '加载失败'
expect(categoryStore.errorMessage).toBe('加载失败')
})
})
describe('actions', () => {
describe('fetchCategories', () => {
it('应该成功获取所有类别', async () => {
// 模拟API响应
const mockCategories = [
{ id: 1, name: '手机CPU', productCount: 50 },
{ id: 2, name: '手机GPU', productCount: 40 }
]
categoryService.getCategories.mockResolvedValue({
data: {
success: true,
data: mockCategories
}
})
// 调用action
await categoryStore.fetchCategories()
// 检查结果
expect(categoryService.getCategories).toHaveBeenCalled()
expect(categoryStore.categories).toEqual(mockCategories)
expect(categoryStore.loading).toBe(false)
expect(categoryStore.error).toBeNull()
})
it('应该处理获取类别失败的情况', async () => {
// 模拟API错误
const errorMessage = '获取类别失败'
categoryService.getCategories.mockRejectedValue(new Error(errorMessage))
// 调用action
await categoryStore.fetchCategories()
// 检查结果
expect(categoryService.getCategories).toHaveBeenCalled()
expect(categoryStore.categories).toEqual([])
expect(categoryStore.loading).toBe(false)
expect(categoryStore.error).toBe(errorMessage)
expect(consoleSpy.error).toHaveBeenCalledWith(
'获取类别失败:',
expect.any(Error)
)
})
it('应该处理API返回失败状态的情况', async () => {
// 模拟API返回失败状态
categoryService.getCategories.mockResolvedValue({
data: {
success: false,
message: '服务器错误'
}
})
// 调用action
await categoryStore.fetchCategories()
// 检查结果
expect(categoryService.getCategories).toHaveBeenCalled()
expect(categoryStore.categories).toEqual([])
expect(categoryStore.loading).toBe(false)
expect(categoryStore.error).toBe('服务器错误')
})
})
describe('fetchCategoryById', () => {
it('应该成功获取指定类别', async () => {
// 模拟API响应
const mockCategory = { id: 1, name: '手机CPU', productCount: 50 }
categoryService.getCategoryById.mockResolvedValue({
data: {
success: true,
data: mockCategory
}
})
// 调用action
await categoryStore.fetchCategoryById(1)
// 检查结果
expect(categoryService.getCategoryById).toHaveBeenCalledWith(1)
expect(categoryStore.currentCategory).toEqual(mockCategory)
expect(categoryStore.loading).toBe(false)
expect(categoryStore.error).toBeNull()
})
it('应该处理获取指定类别失败的情况', async () => {
// 模拟API错误
const errorMessage = '获取类别详情失败'
categoryService.getCategoryById.mockRejectedValue(new Error(errorMessage))
// 调用action
await categoryStore.fetchCategoryById(1)
// 检查结果
expect(categoryService.getCategoryById).toHaveBeenCalledWith(1)
expect(categoryStore.currentCategory).toBeNull()
expect(categoryStore.loading).toBe(false)
expect(categoryStore.error).toBe(errorMessage)
expect(consoleSpy.error).toHaveBeenCalledWith(
'获取类别详情失败:',
expect.any(Error)
)
})
})
describe('setCurrentCategory', () => {
it('应该正确设置当前类别', () => {
const mockCategory = { id: 1, name: '手机CPU', productCount: 50 }
// 调用action
categoryStore.setCurrentCategory(mockCategory)
// 检查结果
expect(categoryStore.currentCategory).toEqual(mockCategory)
})
it('应该正确清除当前类别', () => {
// 设置初始状态
categoryStore.currentCategory = { id: 1, name: '手机CPU', productCount: 50 }
// 调用action
categoryStore.setCurrentCategory(null)
// 检查结果
expect(categoryStore.currentCategory).toBeNull()
})
})
describe('clearError', () => {
it('应该正确清除错误信息', () => {
// 设置初始状态
categoryStore.error = '测试错误'
// 调用action
categoryStore.clearError()
// 检查结果
expect(categoryStore.error).toBeNull()
})
})
describe('reset', () => {
it('应该正确重置store状态', () => {
// 设置初始状态
categoryStore.categories = [{ id: 1, name: '手机CPU', productCount: 50 }]
categoryStore.currentCategory = { id: 1, name: '手机CPU', productCount: 50 }
categoryStore.loading = true
categoryStore.error = '测试错误'
// 调用action
categoryStore.reset()
// 检查结果
expect(categoryStore.categories).toEqual([])
expect(categoryStore.currentCategory).toBeNull()
expect(categoryStore.loading).toBe(false)
expect(categoryStore.error).toBeNull()
})
})
})
describe('复杂场景测试', () => {
it('应该正确处理并发请求', async () => {
// 模拟API响应
const mockCategories = [
{ id: 1, name: '手机CPU', productCount: 50 },
{ id: 2, name: '手机GPU', productCount: 40 }
]
categoryService.getCategories.mockResolvedValue({
data: {
success: true,
data: mockCategories
}
})
// 并发调用action
const [result1, result2] = await Promise.all([
categoryStore.fetchCategories(),
categoryStore.fetchCategories()
])
// 检查结果
expect(categoryService.getCategories).toHaveBeenCalledTimes(2)
expect(categoryStore.categories).toEqual(mockCategories)
expect(categoryStore.loading).toBe(false)
expect(categoryStore.error).toBeNull()
})
it('应该正确处理数据更新', async () => {
// 设置初始数据
categoryStore.categories = [
{ id: 1, name: '手机CPU', productCount: 50 }
]
// 模拟API响应更新后的数据
const updatedCategories = [
{ id: 1, name: '手机CPU', productCount: 55 },
{ id: 2, name: '手机GPU', productCount: 40 }
]
categoryService.getCategories.mockResolvedValue({
data: {
success: true,
data: updatedCategories
}
})
// 调用action
await categoryStore.fetchCategories()
// 检查结果
expect(categoryStore.categories).toEqual(updatedCategories)
})
})
})

57
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,57 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Additional options */
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules",
"dist",
"**/*.spec.ts",
"**/*.test.ts"
]
}

View File

@@ -1,9 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
// 打包体积分析插件
visualizer({
open: false,
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html'
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
@@ -14,15 +24,51 @@ export default defineConfig({
rollupOptions: {
output: {
// 手动分割代码
manualChunks: {
// 将Element Plus相关代码分割到单独的chunk
'element-plus': ['element-plus'],
// 将Vue相关代码分割到单独的chunk
'vue-vendor': ['vue', 'vue-router', 'pinia'],
// 将图表库分割到单独的chunk
'charts': ['echarts', 'vue-echarts'],
// 将工具库分割到单独的chunk
'utils': ['axios', 'dayjs', 'lodash-es']
manualChunks(id) {
// 将node_modules中的依赖分组
if (id.includes('node_modules')) {
// Element Plus
if (id.includes('element-plus')) {
return 'element-plus'
}
// Vue生态系统
if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
return 'vue-vendor'
}
// 图表库
if (id.includes('echarts')) {
return 'charts'
}
// 工具库
if (id.includes('axios') || id.includes('dayjs') || id.includes('lodash-es')) {
return 'utils'
}
// 其他第三方库
return 'vendor'
}
// 将业务代码按模块分组
if (id.includes('src/views')) {
return 'views'
}
if (id.includes('src/components')) {
return 'components'
}
if (id.includes('src/stores')) {
return 'stores'
}
if (id.includes('src/utils')) {
return 'utils'
}
if (id.includes('src/services')) {
return 'services'
}
},
// 优化chunk命名
chunkFileNames: (chunkInfo) => {
// 根据模块类型生成不同的文件名
const facadeModuleId = chunkInfo.facadeModuleId ? chunkInfo.facadeModuleId.split('/').pop() : 'chunk'
return `js/[name]-[hash].js`
}
}
},
@@ -39,7 +85,19 @@ export default defineConfig({
}
},
// 启用CSS代码分割
cssCodeSplit: true
cssCodeSplit: true,
// 优化chunk大小警告限制
chunkSizeWarningLimit: 1000,
// 设置输出目录
outDir: 'dist',
// 设置静态资源目录
assetsDir: 'assets',
// 优化资源内联阈值小于此值的资源将内联为base64
assetsInlineLimit: 4096,
// 启用CSS模块化
cssTarget: 'chrome61',
// 设置最大并行请求数
maxParallelFileOps: 5
},
server: {
port: 3000,
@@ -49,5 +107,27 @@ export default defineConfig({
changeOrigin: true
}
}
}
},
// 优化依赖预构建
optimizeDeps: {
include: [
'vue',
'vue-router',
'pinia',
'axios',
'element-plus',
'element-plus/es/components/message/style/css',
'element-plus/es/components/notification/style/css',
'element-plus/es/components/message-box/style/css'
]
},
// PWA配置
define: {
__VUE_OPTIONS_API__: false,
__VUE_PROD_DEVTOOLS__: false
},
// 确保Service Worker文件被正确复制
publicDir: 'public',
// 确保manifest.json被正确处理
assetsInclude: ['**/*.svg', '**/*.png', '**/*.jpg', '**/*.jpeg', '**/*.gif', '**/*.webp']
})

97
frontend/vitest.config.js Normal file
View File

@@ -0,0 +1,97 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, './src')
}
},
test: {
// 启用类似Jest的API
globals: true,
// 测试环境
environment: 'jsdom',
// 包含的测试文件
include: [
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
],
// 排除的测试文件
exclude: [
'node_modules',
'dist',
'.idea',
'.git',
'.cache'
],
// 覆盖率配置
coverage: {
// 启用覆盖率报告
enabled: true,
// 覆盖率报告格式
reporter: ['text', 'json', 'html'],
// 覆盖率输出目录
reportsDirectory: 'coverage',
// 覆盖率阈值
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
// 包含的文件
include: [
'src/**/*.{js,jsx,ts,tsx,vue}'
],
// 排除的文件
exclude: [
'src/main.js',
'src/**/*.d.ts',
'src/**/index.js',
'src/**/*.stories.{js,jsx,ts,tsx}',
'src/**/__tests__/**/*',
'src/**/test/**/*',
'src/**/*.test.{js,jsx,ts,tsx}',
'src/**/*.spec.{js,jsx,ts,tsx}'
]
},
// 测试超时时间(毫秒)
testTimeout: 10000,
// 钩子超时时间(毫秒)
hookTimeout: 10000,
// 并发测试
threads: true,
// 监听模式下是否只运行相关测试
watchExclude: [
'node_modules',
'dist',
'.idea',
'.git',
'.cache'
],
// 设置全局变量
setupFiles: ['./tests/setup.js'],
// 模拟文件
mockReset: true,
// 清除模拟
clearMocks: true,
// 强制退出
bail: 0,
// 详细输出
verbose: true,
// 静默输出
silent: false,
// 报告器
reporter: ['verbose', 'html', 'json'],
// 输出文件
outputFile: {
html: './tests/reports/index.html',
json: './tests/reports/report.json'
}
}
})