fix(regression): resolve dogfood findings across marketing, auth, e2e and docs
- Fix list-to-detail navigation on product/service/solution/case pages - Fix soft 404 on service detail by removing (marketing)/loading.tsx and using force-dynamic - Fix contact form submission feedback and news placeholder image handling - Unify SSR/client authentication state in auth.ts - Add "新闻动态" to main navigation - Fix Playwright storageState path and Firefox footer link flakiness - Add E2E coverage for nav dropdown, cases filter and auth token parsing - Update visual regression baselines (desktop/tablet/mobile, chromium/webkit/firefox) - Update README, lessons-learned and add REGRESSION_REPORT_2026-07-27.md - Ignore .lighthouseci/ and heading-hierarchy-report.json
@@ -250,6 +250,7 @@ test-email.js
|
|||||||
test-screenshot.png
|
test-screenshot.png
|
||||||
hero-check.png
|
hero-check.png
|
||||||
playwright-test-not-portal.js
|
playwright-test-not-portal.js
|
||||||
|
heading-hierarchy-report.json
|
||||||
|
|
||||||
# Test reports
|
# Test reports
|
||||||
reports/e2e/
|
reports/e2e/
|
||||||
@@ -258,6 +259,7 @@ reports/coverage/
|
|||||||
|
|
||||||
# Performance audit results
|
# Performance audit results
|
||||||
lighthouse-reports/
|
lighthouse-reports/
|
||||||
|
.lighthouseci/
|
||||||
performance-baseline.json
|
performance-baseline.json
|
||||||
|
|
||||||
# Test data exports
|
# Test data exports
|
||||||
|
|||||||
@@ -54,6 +54,10 @@
|
|||||||
- [x] 首页 UI 设计优化(2026-07-23):按 `impeccable` 默认流程完成 colorize/harden/layout/typeset/polish,落地 `home-content-v14.tsx`(克制白底 Hero、等宽三列服务卡片、紧凑案例快照、FALLBACK_SERVICES 兜底);更新 chromium desktop/tablet/mobile 视觉基线;修复 `e2e/p5-edge-cases.spec.ts` header 选择器严格模式问题;type-check / lint / unit 942 条 / 视觉 63 条 / 功能 E2E 174 条全部通过
|
- [x] 首页 UI 设计优化(2026-07-23):按 `impeccable` 默认流程完成 colorize/harden/layout/typeset/polish,落地 `home-content-v14.tsx`(克制白底 Hero、等宽三列服务卡片、紧凑案例快照、FALLBACK_SERVICES 兜底);更新 chromium desktop/tablet/mobile 视觉基线;修复 `e2e/p5-edge-cases.spec.ts` header 选择器严格模式问题;type-check / lint / unit 942 条 / 视觉 63 条 / 功能 E2E 174 条全部通过
|
||||||
- [x] 顶部导航栏对齐修复(2026-07-23):统一桌面端下拉项与普通链接的 wrapper 结构(`relative -mx-2 px-2 py-2`)与 `inline-flex items-center` 显示方式,修复顶部导航文字不在同一水平线的问题;同步更新 desktop/tablet/mobile/webkit/firefox 视觉基线;chromium/webkit 功能 E2E 与视觉回归全部通过
|
- [x] 顶部导航栏对齐修复(2026-07-23):统一桌面端下拉项与普通链接的 wrapper 结构(`relative -mx-2 px-2 py-2`)与 `inline-flex items-center` 显示方式,修复顶部导航文字不在同一水平线的问题;同步更新 desktop/tablet/mobile/webkit/firefox 视觉基线;chromium/webkit 功能 E2E 与视觉回归全部通过
|
||||||
- [x] CMS / Admin 后台 dogfood 修复与回归(2026-07-25):修复登录后无限重定向、内容保存 400/500、编辑提交 status、原生 confirm 阻塞自动化、表单可访问性、标签输入、操作 Toast 反馈、后台 Cookie 横幅、媒体库空状态等 9 项问题;生成 [`dogfood-cms-regression/report.md`](./dogfood-cms-regression/report.md),19 步回归检查全部通过;type-check / lint(0 errors,172 既有 warnings)/ test:unit(71 suites / 943 tests)全部通过
|
- [x] CMS / Admin 后台 dogfood 修复与回归(2026-07-25):修复登录后无限重定向、内容保存 400/500、编辑提交 status、原生 confirm 阻塞自动化、表单可访问性、标签输入、操作 Toast 反馈、后台 Cookie 横幅、媒体库空状态等 9 项问题;生成 [`dogfood-cms-regression/report.md`](./dogfood-cms-regression/report.md),19 步回归检查全部通过;type-check / lint(0 errors,172 既有 warnings)/ test:unit(71 suites / 943 tests)全部通过
|
||||||
|
- [x] 全站 dogfood 系统性深度测试(2026-07-25):覆盖营销页面与 CMS/Admin 后台,识别 12 项问题(High 3 / Medium 6 / Low 3),生成 [`dogfood-output/report.md`](./dogfood-output/report.md) 及截图/视频证据
|
||||||
|
- [x] 全站 dogfood 问题修复(2026-07-25):修复列表详情页导航、CMS 登录状态、服务详情页软 404 等 High 优先级问题;其中软 404 根因定位为 `(marketing)/loading.tsx` 形成的 Loading/Suspense 边界吞掉了动态路由 `notFound()` 抛出的 NEXT_NOT_FOUND 错误,导致未知 slug 返回 HTTP 200,已移除该 loading.tsx 并修复根 `not-found.tsx`;type-check / lint(0 errors,172 既有 warnings)/ test:unit(72 suites / 954 tests)/ 功能 E2E 140 条全部通过
|
||||||
|
- [x] dogfood 剩余问题验证与视觉基线更新(2026-07-26):浏览器实测确认主导航「产品/解决方案」下拉菜单 click/hover 均可正常展开,问题已随 `(marketing)/loading.tsx` 移除解决;同步修正 `e2e/nav-dropdown.spec.ts` 中断言文本(`睿新ERP管理系统` → `ERP 管理系统`、`行业方案` → `行业解决方案`);更新 desktop/tablet/mobile/webkit/firefox 全量视觉回归基线(105 张快照);type-check / lint(0 errors,172 既有 warnings)/ test:unit(72 suites / 954 tests)/ 下拉菜单 E2E 全部通过
|
||||||
|
- [x] 全量回归与 E2E 配置修复(2026-07-27):修复 `e2e/playwright.config.ts` 中 `storageState` 路径错误(由 `./e2e/storageState.json` 改为基于配置文件目录的绝对路径),该错误导致 firefox/webkit 全量 E2E 运行时无法读取 Cookie 偏好状态而全部失败;将 `e2e/p2-functional-e2e.spec.ts` 中「Footer 导航链接可点击」拆分为隐私政策、服务条款两个独立测试,并改用 `page.evaluate` 直接触发点击,避免 Firefox 中连续全页导航与 Playwright locator 评估的竞态超时;重新执行全量回归:type-check / lint(0 errors,172 既有 warnings)/ test:unit(72 suites / 954 tests)/ E2E 全量(631 passed / 8 skipped / 0 failed,含视觉回归、CMS 工作流)全部通过
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 全量回归测试报告 — 2026-07-27
|
||||||
|
|
||||||
|
## 1. 回归目标
|
||||||
|
|
||||||
|
在完成 dogfood 系统性深度测试及问题修复后,执行全量回归验证,确认:
|
||||||
|
|
||||||
|
1. 所有 dogfood 修复未引入新回归。
|
||||||
|
2. 质量门禁(类型检查、Lint、单元测试、E2E、视觉回归)全部通过。
|
||||||
|
3. 配置与测试代码中的隐式缺陷被识别并修复。
|
||||||
|
|
||||||
|
## 2. 测试范围
|
||||||
|
|
||||||
|
| 类别 | 覆盖内容 |
|
||||||
|
|------|---------|
|
||||||
|
| 静态门禁 | TypeScript 类型检查、ESLint |
|
||||||
|
| 单元测试 | Jest,覆盖 `src/**/*.test.{ts,tsx}` |
|
||||||
|
| E2E 功能测试 | Playwright:首页、产品、方案、服务、案例、新闻、团队、关于、联系、法律页、导航、表单、404、SEO、A11y、性能、兼容性、边界场景 |
|
||||||
|
| CMS 工作流测试 | Playwright:管理员发布、非法状态拦截、多角色权限分离 |
|
||||||
|
| 视觉回归测试 | Playwright:desktop / tablet / mobile / firefox / webkit 全页面与组件截图对比 |
|
||||||
|
|
||||||
|
## 3. 执行环境
|
||||||
|
|
||||||
|
- **日期**:2026-07-27
|
||||||
|
- **分支**:当前工作区(基于 `10404db` 之后的 dogfood 修复集合)
|
||||||
|
- **Node.js**:v22.23.1
|
||||||
|
- **Next.js**:16.2.11
|
||||||
|
- **Playwright**:1.58.2
|
||||||
|
- **浏览器**:Chromium / Firefox / WebKit
|
||||||
|
|
||||||
|
## 4. 执行结果
|
||||||
|
|
||||||
|
### 4.1 静态门禁
|
||||||
|
|
||||||
|
| 检查项 | 命令 | 结果 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 类型检查 | `npm run type-check` | ✅ 通过(无错误) |
|
||||||
|
| 代码风格 | `npm run lint` | ✅ 通过(0 errors,172 既有 warnings) |
|
||||||
|
|
||||||
|
### 4.2 单元测试
|
||||||
|
|
||||||
|
| 检查项 | 命令 | 结果 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 单元测试 | `npm run test:unit` | ✅ 72 suites / 954 tests 全部通过 |
|
||||||
|
|
||||||
|
### 4.3 E2E 全量测试
|
||||||
|
|
||||||
|
| 检查项 | 命令 | 结果 |
|
||||||
|
|--------|------|------|
|
||||||
|
| E2E 全量 | `npm run test` | ✅ 631 passed / 8 skipped / 0 failed |
|
||||||
|
|
||||||
|
8 个 skipped 为 `cms-workflow.spec.ts` 在 Firefox / WebKit 项目中被显式跳过(`test.skip(browserName !== 'chromium')`),属于预期行为,避免跨浏览器共享数据库导致数据竞争。
|
||||||
|
|
||||||
|
E2E 覆盖明细:
|
||||||
|
|
||||||
|
- 视觉回归:desktop / tablet / mobile / firefox / webkit 全通过
|
||||||
|
- CMS 工作流:chromium 下 3 个测试全部通过
|
||||||
|
- 跨浏览器兼容性:chromium / firefox / webkit 功能用例全部通过
|
||||||
|
|
||||||
|
## 5. 回归过程中发现与修复的问题
|
||||||
|
|
||||||
|
### 5.1 Playwright `storageState` 路径错误
|
||||||
|
|
||||||
|
- **现象**:首次全量 E2E 运行时,Firefox / WebKit 下所有测试瞬间失败,报错 `Error reading storage state from ./e2e/storageState.json: ENOENT`。
|
||||||
|
- **根因**:`e2e/playwright.config.ts` 中 `storageState: './e2e/storageState.json'`,但 `npm run test` 实际执行 `cd e2e && npx playwright test`,导致 Playwright 查找 `e2e/e2e/storageState.json`。
|
||||||
|
- **修复**:改为 `path.resolve(__dirname, 'storageState.json')`,以配置文件所在目录为基准,兼容两种执行方式。
|
||||||
|
- **验证**:重新执行全量 E2E,Firefox / WebKit 全部通过。
|
||||||
|
|
||||||
|
### 5.2 Firefox 中 Footer 链接测试 flaky
|
||||||
|
|
||||||
|
- **现象**:`e2e/p2-functional-e2e.spec.ts` 中「Footer 导航链接可点击」在 Firefox 中反复超时,报错 `waiting for "http://localhost:3000/" navigation to finish`。
|
||||||
|
- **根因**:Playwright locator API 会在页面存在未完成导航时阻塞;Firefox 对 `window.location.href` 全页导航处理较慢,单次测试内连续两次 `page.goto('/')` + 点击 StaticLink 触发全页导航,容易触发竞态。
|
||||||
|
- **修复**:
|
||||||
|
1. 将测试拆分为「Footer 导航链接可点击」(隐私政策)和「Footer 服务条款链接可点击」两个独立 test case。
|
||||||
|
2. 改用 `page.evaluate(() => element.click())` 直接触发点击,绕过 locator 的导航状态检查。
|
||||||
|
3. 点击后通过 `page.waitForURL()` 显式等待目标 URL。
|
||||||
|
- **验证**:`--project=firefox --repeat-each=3` 6 次运行全部通过;全量 E2E 中该用例通过。
|
||||||
|
|
||||||
|
## 6. 变更文件清单
|
||||||
|
|
||||||
|
本次回归修复涉及以下文件变更:
|
||||||
|
|
||||||
|
- `e2e/playwright.config.ts`:修复 `storageState` 路径。
|
||||||
|
- `e2e/p2-functional-e2e.spec.ts`:拆分 Footer 链接测试并改用 `page.evaluate` 点击。
|
||||||
|
- `README.md`:更新项目进度。
|
||||||
|
- `docs/lessons-learned.md`:新增两条测试经验教训。
|
||||||
|
|
||||||
|
其余未提交修改(如视觉基线快照、dogfood 修复的源码文件)来自先前已完成的修复工作,本次回归验证确认其无新增回归。
|
||||||
|
|
||||||
|
## 7. 结论
|
||||||
|
|
||||||
|
全量回归测试通过,所有质量门禁达标,dogfood 修复未引入新回归。项目当前状态满足继续推进或封版条件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**报告生成时间**:2026-07-27
|
||||||
|
**执行者**:AI Agent(基于 `AGENTS.md` 工作流)
|
||||||
@@ -108,6 +108,46 @@
|
|||||||
- **方案**:设置覆盖率门槛(branches 35%, functions/lines/statements 45%),分阶段提升至 80%;后续通过 TDD 流程提升至 85%+。
|
- **方案**:设置覆盖率门槛(branches 35%, functions/lines/statements 45%),分阶段提升至 80%;后续通过 TDD 流程提升至 85%+。
|
||||||
- **来源**:`docs/test-coverage-improvement-plan.md`,`CLAUDE.md` 测试命令。
|
- **来源**:`docs/test-coverage-improvement-plan.md`,`CLAUDE.md` 测试命令。
|
||||||
|
|
||||||
|
### 5.3 E2E 断言文本必须与渲染文本保持一致
|
||||||
|
|
||||||
|
- **问题**:`e2e/nav-dropdown.spec.ts` 中 hover 测试失败,断言查找 `睿新ERP管理系统` 与 `行业方案`,但实际渲染为 `ERP 管理系统` 与 `行业解决方案`。
|
||||||
|
- **根因**:UI 文案迭代后,E2E 测试的文本断言未同步更新,导致测试假阴性(功能正常但测试报错)。
|
||||||
|
- **方案**:
|
||||||
|
1. 将 UI 文案断言改为稳定标识(如 `data-testid`、角色/aria 属性)或语义化选择器,减少对文案的强依赖。
|
||||||
|
2. 若必须使用文本,在修改文案时同步搜索并更新 `e2e/` 中的对应断言。
|
||||||
|
3. 建立「文案变更检查清单」,将 E2E 文本断言纳入审查范围。
|
||||||
|
- **来源**:2026-07-26 dogfood 后续验证,`e2e/nav-dropdown.spec.ts` 修复。
|
||||||
|
|
||||||
|
### 5.4 视觉回归基线需在 UI 变更后主动更新
|
||||||
|
|
||||||
|
- **问题**:dogfood 修复后(列表卡片链接、案例筛选、联系表单反馈、新闻占位图等),视觉回归测试大面积失败。
|
||||||
|
- **根因**:上述修复属于有意的 UI/交互变更,导致已有快照与当前渲染不一致;若不更新基线,后续所有构建都会报告假阳性。
|
||||||
|
- **方案**:
|
||||||
|
1. 任何涉及视觉/布局的修复完成后,运行 `npx playwright test visual-regression.spec.ts --update-snapshots` 更新全项目视觉基线。
|
||||||
|
2. 更新前通过浏览器截图/人工复核确认差异符合预期,避免将未发现的回归写入基线。
|
||||||
|
3. 将快照变更作为独立提交或 PR 变更的一部分,方便评审时比对视觉差异。
|
||||||
|
- **来源**:2026-07-26 更新 105 张视觉回归快照。
|
||||||
|
|
||||||
|
### 5.5 Playwright `storageState` 路径必须以配置文件为基准
|
||||||
|
|
||||||
|
- **问题**:全量 E2E 运行时 firefox/webkit 下所有测试瞬间失败,报错 `Error reading storage state from ./e2e/storageState.json: ENOENT`。
|
||||||
|
- **根因**:`e2e/playwright.config.ts` 中 `storageState` 被写成 `./e2e/storageState.json`,而 `npm run test` 实际在 `e2e/` 目录下执行(`cd e2e && npx playwright test`),导致 Playwright 去查找 `e2e/e2e/storageState.json`。
|
||||||
|
- **方案**:
|
||||||
|
1. 配置文件中的相对路径应基于配置文件自身目录,使用 `path.resolve(__dirname, 'storageState.json')`。
|
||||||
|
2. 修改 `storageState`、快照目录、报告目录等路径时,必须同时验证 `npm run test` 与从项目根目录直接运行 `npx playwright test --config=e2e/playwright.config.ts` 两种方式。
|
||||||
|
3. 将 `storageState.json` 纳入版本控制并作为 E2E 环境准备检查项。
|
||||||
|
- **来源**:2026-07-27 全量 E2E 回归失败排查,`e2e/playwright.config.ts` 修复。
|
||||||
|
|
||||||
|
### 5.6 Firefox 中连续全页导航易触发 Playwright locator 超时
|
||||||
|
|
||||||
|
- **问题**:`e2e/p2-functional-e2e.spec.ts` 中「Footer 导航链接可点击」在 Firefox 中反复超时,报错 `waiting for "http://localhost:3000/" navigation to finish`,即使页面已渲染完成。
|
||||||
|
- **根因**:Playwright 的 locator API 会在页面存在未完成的导航时阻塞;Firefox 对 `window.location.href` 全页导航的处理比 Chromium 更慢,连续两次 `page.goto('/')` + 点击 StaticLink 触发全页导航后,locator 评估极易进入等待导航的竞态。
|
||||||
|
- **方案**:
|
||||||
|
1. 将涉及两次独立全页导航的测试拆分为两个独立 test case,减少单次测试内的导航次数。
|
||||||
|
2. 在 Firefox 等容易触发竞态的浏览器中,对必须触发的全页导航链接点击,改用 `page.evaluate(() => element.click())` 绕过 locator 的导航状态检查。
|
||||||
|
3. 优先使用 `data-testid` 或语义选择器定位,点击后通过 `page.waitForURL()` 显式等待目标 URL,而非依赖 locator 的隐式等待。
|
||||||
|
- **来源**:2026-07-27 `e2e/p2-functional-e2e.spec.ts` Footer 链接测试修复。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 代码组织
|
## 6. 代码组织
|
||||||
@@ -128,8 +168,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 7. App Router 路由行为
|
||||||
|
|
||||||
|
### 7.1 `loading.tsx` 会吞掉子动态路由的 `notFound()`
|
||||||
|
|
||||||
|
- **问题**:`/services/[id]`、`/products/[id]`、`/solutions/[id]`、`/news/[slug]` 等动态路由对未知 slug 调用 `notFound()` 后,仍然返回 HTTP 200(软 404)。
|
||||||
|
- **根因**:`(marketing)/loading.tsx` 在路由组层级创建了 Next.js Loading/Suspense 边界,该边界会捕获子页面抛出的 `NEXT_NOT_FOUND` 错误,导致 `not-found.tsx` 无法被渲染,HTTP 状态也保持为 200。
|
||||||
|
- **方案**:移除 `(marketing)/loading.tsx`;如需加载状态,改为在页面内部使用 `<Suspense>` 包裹特定慢加载区块,而不是在路由组层级全局添加 `loading.tsx`。
|
||||||
|
- **验证**:`curl -I http://localhost:3000/services/unknown` 返回 `404 Not Found`;`e2e/p5-edge-cases.spec.ts` 404 相关用例全部通过。
|
||||||
|
- **来源**:2026-07-25 dogfood 修复,`dogfood-output/report.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 更新记录
|
## 更新记录
|
||||||
|
|
||||||
| 日期 | 更新内容 | 来源 |
|
| 日期 | 更新内容 | 来源 |
|
||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| 2026-07-07 | 初始创建,从 project_memory.md 和 docs/ 中提取 | 现有文档 + 记忆文件 |
|
| 2026-07-07 | 初始创建,从 project_memory.md 和 docs/ 中提取 | 现有文档 + 记忆文件 |
|
||||||
|
| 2026-07-25 | 增加 App Router `loading.tsx` 与 `notFound()` 交互的经验教训 | dogfood 修复 |
|
||||||
|
| 2026-07-26 | 增加 E2E 文本断言同步、视觉回归基线更新两条经验教训 | dogfood 后续验证 |
|
||||||
|
| 2026-07-27 | 增加 Playwright storageState 路径、Firefox 全页导航竞态两条经验教训 | 全量 E2E 回归失败排查 |
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('案例页行业筛选按钮可以正确过滤列表', async ({ page }) => {
|
||||||
|
await page.goto('/cases', { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
// 默认显示全部 6 个案例
|
||||||
|
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(6);
|
||||||
|
|
||||||
|
// 点击“制造业”筛选
|
||||||
|
await page.getByRole('radio', { name: '制造业' }).click();
|
||||||
|
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(1);
|
||||||
|
await expect(page.locator('text=大型制造企业 ERP 升级与数字化转型')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击“贸易零售”筛选
|
||||||
|
await page.getByRole('radio', { name: '贸易零售' }).click();
|
||||||
|
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(1);
|
||||||
|
await expect(page.locator('text=连锁零售全渠道数字化升级')).toBeVisible();
|
||||||
|
|
||||||
|
// 切回全部
|
||||||
|
await page.getByRole('radio', { name: '全部行业' }).click();
|
||||||
|
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(6);
|
||||||
|
});
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
import { test, expect, APIRequestContext } from '@playwright/test';
|
import { test, expect, APIRequestContext } from '@playwright/test';
|
||||||
|
import { config as dotenvConfig } from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// 加载项目根目录的 .env.local,使 E2E 测试与 dev/preview 服务器使用相同的 CMS_REVALIDATE_SECRET
|
||||||
|
// Playwright 配置已负责设置 process.env,此处仅做兜底加载,不再 override 以避免覆盖 webServer 环境变量
|
||||||
|
dotenvConfig({ path: path.resolve(__dirname, '../.env.local') });
|
||||||
|
|
||||||
|
// CMS 工作流依赖共享数据库与角色权限,浏览器差异可忽略,仅在 chromium 项目运行以避免跨 project 数据竞争
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium', 'CMS workflow test runs only in chromium');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CMS 内容发布工作流 E2E 测试
|
* CMS 内容发布工作流 E2E 测试
|
||||||
@@ -13,8 +22,60 @@ const REVIEWER_CREDENTIALS = { username: 'e2e_reviewer', password: 'reviewer123'
|
|||||||
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
|
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
|
||||||
const REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
|
const REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
|
||||||
|
|
||||||
|
// 跟踪当前测试创建的内容条目 ID,afterEach 只清理当前测试的数据,避免并行/跨 project 误删
|
||||||
|
const createdItemIds: string[] = [];
|
||||||
|
|
||||||
test.setTimeout(120000);
|
test.setTimeout(120000);
|
||||||
|
|
||||||
|
async function deleteItemsByIds(request: APIRequestContext, token: string, ids: string[]): Promise<void> {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
ids.map((id) =>
|
||||||
|
request.delete(`${BASE_URL}/api/admin/items?id=${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupStaleE2ENews(request: APIRequestContext, token: string): Promise<void> {
|
||||||
|
const response = await request.get(`${BASE_URL}/api/admin/items`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
params: { modelCode: 'news', pageSize: '1000' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
console.warn('Failed to fetch news items for cleanup:', await response.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
const items = (body.items || body.data || []) as Array<{ id: string; title: string }>;
|
||||||
|
const staleItems = items.filter((item) => item.title?.startsWith('E2E 测试新闻'));
|
||||||
|
|
||||||
|
await deleteItemsByIds(
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
staleItems.map((item) => item.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
// 清理历史残留 E2E 测试数据(仅在 chromium 项目执行,不会误删其他 project 数据)
|
||||||
|
const token = await loginAdmin(request);
|
||||||
|
await cleanupStaleE2ENews(request, token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
const token = await loginAdmin(request);
|
||||||
|
// 优先按 ID 清理当前测试创建的条目,避免误删其他并行测试的数据
|
||||||
|
const ids = createdItemIds.splice(0);
|
||||||
|
await deleteItemsByIds(request, token, ids);
|
||||||
|
// 再兜底清理历史残留
|
||||||
|
await cleanupStaleE2ENews(request, token);
|
||||||
|
});
|
||||||
|
|
||||||
async function login(
|
async function login(
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
credentials: { username: string; password: string }
|
credentials: { username: string; password: string }
|
||||||
@@ -83,6 +144,7 @@ async function createNewsDraft(
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
expect(body.status).toBe('draft');
|
expect(body.status).toBe('draft');
|
||||||
|
|
||||||
|
createdItemIds.push(body.id);
|
||||||
return { id: body.id, slug, title };
|
return { id: body.id, slug, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +170,7 @@ async function approveItem(
|
|||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
token: string,
|
token: string,
|
||||||
itemId: string
|
itemId: string
|
||||||
): Promise<void> {
|
): Promise<{ status: string; slug: string }> {
|
||||||
const response = await request.post(`${BASE_URL}/api/admin/items/${itemId}/workflow`, {
|
const response = await request.post(`${BASE_URL}/api/admin/items/${itemId}/workflow`, {
|
||||||
data: { action: 'approve' },
|
data: { action: 'approve' },
|
||||||
headers: {
|
headers: {
|
||||||
@@ -120,6 +182,7 @@ async function approveItem(
|
|||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
expect(body.status).toBe('published');
|
expect(body.status).toBe('published');
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revalidateNews(request: APIRequestContext, slug: string): Promise<void> {
|
async function revalidateNews(request: APIRequestContext, slug: string): Promise<void> {
|
||||||
@@ -133,8 +196,11 @@ async function revalidateNews(request: APIRequestContext, slug: string): Promise
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const responseBody = await response.text();
|
||||||
|
console.log('[DEBUG] revalidate status=%s body=%s', response.status(), responseBody);
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
const body = await response.json();
|
const body = JSON.parse(responseBody);
|
||||||
expect(body.code).toBe(0);
|
expect(body.code).toBe(0);
|
||||||
expect(body.data.revalidatedPaths).toContain('/news');
|
expect(body.data.revalidatedPaths).toContain('/news');
|
||||||
expect(body.data.revalidatedPaths).toContain(`/news/${slug}`);
|
expect(body.data.revalidatedPaths).toContain(`/news/${slug}`);
|
||||||
@@ -162,18 +228,28 @@ async function ensureRolePermissions(
|
|||||||
|
|
||||||
async function verifyNewsVisibleOnFrontend(
|
async function verifyNewsVisibleOnFrontend(
|
||||||
page: import('@playwright/test').Page,
|
page: import('@playwright/test').Page,
|
||||||
|
request: APIRequestContext,
|
||||||
slug: string,
|
slug: string,
|
||||||
title: string,
|
title: string,
|
||||||
suffix: string
|
suffix: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// 先用 API context 拉取本地页面,确认服务端渲染正常
|
||||||
|
const apiResponse = await request.get(`${BASE_URL}/news/${slug}`);
|
||||||
|
const apiHtml = await apiResponse.text();
|
||||||
|
console.log('[DEBUG] api status=%s title=%s', apiResponse.status(), apiHtml.match(/<title>([^<]+)<\/title>/)?.[1]);
|
||||||
|
|
||||||
// 直接访问详情页验证 ISR 刷新后的内容可见(比列表页更稳定,避免整页缓存波动)
|
// 直接访问详情页验证 ISR 刷新后的内容可见(比列表页更稳定,避免整页缓存波动)
|
||||||
await page.goto(`/news/${slug}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
await page.goto(`/news/${slug}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
|
||||||
|
const currentUrl = page.url();
|
||||||
|
const h1Text = (await page.locator('h1').first().textContent().catch(() => '')) || '';
|
||||||
|
console.log('[DEBUG] page.url=%s h1=%s expected title=%s', currentUrl, h1Text, title);
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const h1Text = (await page.locator('h1').first().textContent().catch(() => '')) || '';
|
const text = (await page.locator('h1').first().textContent().catch(() => '')) || '';
|
||||||
return h1Text.includes(title);
|
return text.includes(title);
|
||||||
},
|
},
|
||||||
{ timeout: 20000, intervals: [1000, 2000, 2000] }
|
{ timeout: 20000, intervals: [1000, 2000, 2000] }
|
||||||
)
|
)
|
||||||
@@ -183,15 +259,21 @@ async function verifyNewsVisibleOnFrontend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe('CMS 内容发布工作流', () => {
|
test.describe('CMS 内容发布工作流', () => {
|
||||||
|
// 同一 worker 内串行执行,避免共享 DB/角色权限在并行时相互影响
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
test('admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见', async ({ request, page }) => {
|
test('admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见', async ({ request, page }) => {
|
||||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
const token = await loginAdmin(request);
|
const token = await loginAdmin(request);
|
||||||
const { id, slug, title } = await createNewsDraft(request, token, suffix);
|
const { id, slug, title } = await createNewsDraft(request, token, suffix);
|
||||||
|
console.log('[DEBUG] created item id=%s slug=%s title=%s', id, slug, title);
|
||||||
await submitForReview(request, token, id);
|
await submitForReview(request, token, id);
|
||||||
await approveItem(request, token, id);
|
const afterApprove = await approveItem(request, token, id);
|
||||||
|
console.log('[DEBUG] after approve status=%s slug=%s', afterApprove.status, afterApprove.slug);
|
||||||
await revalidateNews(request, slug);
|
await revalidateNews(request, slug);
|
||||||
await verifyNewsVisibleOnFrontend(page, slug, title, suffix);
|
console.log('[DEBUG] revalidate done, navigating to /news/%s', slug);
|
||||||
|
await verifyNewsVisibleOnFrontend(page, request, slug, title, suffix);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('非法状态流转会被拦截', async ({ request }) => {
|
test('非法状态流转会被拦截', async ({ request }) => {
|
||||||
@@ -255,6 +337,6 @@ test.describe('CMS 内容发布工作流', () => {
|
|||||||
|
|
||||||
// 5. 刷新 ISR 缓存并验证前台可见
|
// 5. 刷新 ISR 缓存并验证前台可见
|
||||||
await revalidateNews(request, slug);
|
await revalidateNews(request, slug);
|
||||||
await verifyNewsVisibleOnFrontend(page, slug, title, suffix);
|
await verifyNewsVisibleOnFrontend(page, request, slug, title, suffix);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('主导航产品下拉菜单可在 hover 时展开', async ({ page }) => {
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
const productsButton = page.locator('nav[aria-label="主导航"] button:has-text("产品")');
|
||||||
|
await expect(productsButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
|
await productsButton.hover();
|
||||||
|
await expect(productsButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await expect(page.locator('text=企业套装').first()).toBeVisible();
|
||||||
|
await expect(page.locator('text=ERP 管理系统').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('主导航解决方案下拉菜单可在 hover 时展开', async ({ page }) => {
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
const solutionsButton = page.locator('nav[aria-label="主导航"] button:has-text("解决方案")');
|
||||||
|
await expect(solutionsButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
|
await solutionsButton.hover();
|
||||||
|
await expect(solutionsButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await expect(page.locator('text=行业解决方案').first()).toBeVisible();
|
||||||
|
await expect(page.locator('text=制造业').first()).toBeVisible();
|
||||||
|
});
|
||||||
@@ -647,27 +647,46 @@ test.describe('Footer - 全局底部区域', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Footer 导航链接可点击', async ({ page }) => {
|
test('Footer 导航链接可点击', async ({ page }) => {
|
||||||
const footer = page.locator('footer, [data-testid="footer"]').first();
|
// 隐私政策链接:独立测试,避免 Firefox 中连续全页导航的竞态问题
|
||||||
|
|
||||||
// 检查隐私政策和服务条款链接
|
|
||||||
const privacyLink = footer.locator('a:has-text("隐私政策")');
|
|
||||||
if (await privacyLink.count() > 0 && await privacyLink.isVisible()) {
|
|
||||||
await privacyLink.click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
expect(page.url()).toContain('/privacy');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回首页检查服务条款
|
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
await page.waitForTimeout(1500);
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const termsLink = footer.locator('a:has-text("服务条款")');
|
const privacyClicked = await page.evaluate(() => {
|
||||||
if (await termsLink.count() > 0 && await termsLink.isVisible()) {
|
const link = document.querySelector('footer a, [data-testid="footer"] a') as HTMLAnchorElement | null;
|
||||||
await termsLink.click();
|
if (!link) return false;
|
||||||
await page.waitForTimeout(1000);
|
const privacy = Array.from(document.querySelectorAll('footer a, [data-testid="footer"] a'))
|
||||||
expect(page.url()).toContain('/terms');
|
.find((el) => el.textContent?.includes('隐私政策')) as HTMLAnchorElement | undefined;
|
||||||
|
if (privacy) {
|
||||||
|
privacy.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||||
|
privacy.click();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(privacyClicked).toBe(true);
|
||||||
|
await page.waitForURL(/\/privacy/, { timeout: 15000 });
|
||||||
|
expect(page.url()).toContain('/privacy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Footer 服务条款链接可点击', async ({ page }) => {
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const termsClicked = await page.evaluate(() => {
|
||||||
|
const terms = Array.from(document.querySelectorAll('footer a, [data-testid="footer"] a'))
|
||||||
|
.find((el) => el.textContent?.includes('服务条款')) as HTMLAnchorElement | undefined;
|
||||||
|
if (terms) {
|
||||||
|
terms.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||||
|
terms.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(termsClicked).toBe(true);
|
||||||
|
await page.waitForURL(/\/terms/, { timeout: 15000 });
|
||||||
|
expect(page.url()).toContain('/terms');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Footer 包含联系方式(邮箱)', async ({ page }) => {
|
test('Footer 包含联系方式(邮箱)', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { config as dotenvConfig } from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
// 为 CMS 工作流 E2E 测试提供默认 revalidate secret
|
// 加载项目根目录的 .env.local,使 Playwright webServer 与测试进程使用相同的 CMS_REVALIDATE_SECRET
|
||||||
|
// 若 .env.local 不存在则回退到默认测试 secret
|
||||||
|
dotenvConfig({ path: path.resolve(__dirname, '../.env.local') });
|
||||||
process.env.CMS_REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
|
process.env.CMS_REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -17,7 +21,8 @@ export default defineConfig({
|
|||||||
snapshotPathTemplate: '{snapshotDir}/{projectName}/{testFilePath}/{arg}-{projectName}{ext}',
|
snapshotPathTemplate: '{snapshotDir}/{projectName}/{testFilePath}/{arg}-{projectName}{ext}',
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
|
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
|
||||||
storageState: './storageState.json',
|
// 路径以配置文件所在目录为基准,兼容 `cd e2e` 与从项目根目录直接调用两种执行方式
|
||||||
|
storageState: path.resolve(__dirname, 'storageState.json'),
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 568 KiB After Width: | Height: | Size: 570 KiB |
|
Before Width: | Height: | Size: 966 B After Width: | Height: | Size: 914 B |
|
Before Width: | Height: | Size: 970 B After Width: | Height: | Size: 912 B |
|
Before Width: | Height: | Size: 959 B After Width: | Height: | Size: 912 B |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 591 KiB After Width: | Height: | Size: 593 KiB |
|
Before Width: | Height: | Size: 1000 KiB After Width: | Height: | Size: 932 KiB |
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 709 KiB After Width: | Height: | Size: 692 KiB |
|
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 757 KiB |
|
Before Width: | Height: | Size: 504 KiB After Width: | Height: | Size: 505 KiB |
|
Before Width: | Height: | Size: 593 KiB After Width: | Height: | Size: 595 KiB |
|
Before Width: | Height: | Size: 582 KiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 583 KiB After Width: | Height: | Size: 585 KiB |
|
Before Width: | Height: | Size: 591 KiB After Width: | Height: | Size: 593 KiB |
|
Before Width: | Height: | Size: 591 KiB After Width: | Height: | Size: 593 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 796 KiB After Width: | Height: | Size: 801 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 776 KiB After Width: | Height: | Size: 777 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 677 KiB After Width: | Height: | Size: 678 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 991 KiB |
|
Before Width: | Height: | Size: 1015 KiB After Width: | Height: | Size: 1019 KiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 789 KiB After Width: | Height: | Size: 790 KiB |
|
Before Width: | Height: | Size: 831 KiB After Width: | Height: | Size: 832 KiB |
|
Before Width: | Height: | Size: 629 KiB After Width: | Height: | Size: 630 KiB |
|
Before Width: | Height: | Size: 804 KiB After Width: | Height: | Size: 806 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 916 KiB After Width: | Height: | Size: 919 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
@@ -15,41 +15,34 @@ test.describe('网站全面测试验收', () => {
|
|||||||
const logo = page.locator('header img[alt*="睿新致远"], [data-testid="logo"]');
|
const logo = page.locator('header img[alt*="睿新致远"], [data-testid="logo"]');
|
||||||
const logoCount = await logo.count();
|
const logoCount = await logo.count();
|
||||||
|
|
||||||
|
async function assertLogoBoundingBox(logoLocator: ReturnType<typeof page.locator>) {
|
||||||
|
await expect(logoLocator).toBeVisible({ timeout: 10000 });
|
||||||
|
// 等待 layout 完成,避免 WebKit 中 boundingBox() 首次返回 null
|
||||||
|
const logoBox = await expect.poll(async () => logoLocator.boundingBox(), {
|
||||||
|
timeout: 5000,
|
||||||
|
intervals: [100, 200, 200],
|
||||||
|
}).not.toBeNull();
|
||||||
|
|
||||||
|
const header = page.locator('header');
|
||||||
|
const headerBox = await expect.poll(async () => header.boundingBox(), {
|
||||||
|
timeout: 5000,
|
||||||
|
intervals: [100, 200, 200],
|
||||||
|
}).not.toBeNull();
|
||||||
|
|
||||||
|
if (logoBox && headerBox) {
|
||||||
|
expect(logoBox.x).toBeGreaterThanOrEqual(headerBox.x);
|
||||||
|
expect(logoBox.y).toBeGreaterThanOrEqual(headerBox.y);
|
||||||
|
expect(logoBox.x + logoBox.width).toBeLessThanOrEqual(headerBox.x + headerBox.width);
|
||||||
|
expect(logoBox.y + logoBox.height).toBeLessThanOrEqual(headerBox.y + headerBox.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (logoCount === 0) {
|
if (logoCount === 0) {
|
||||||
const allImages = page.locator('header img');
|
const allImages = page.locator('header img');
|
||||||
expect(await allImages.count()).toBeGreaterThan(0);
|
expect(await allImages.count()).toBeGreaterThan(0);
|
||||||
const firstLogo = allImages.first();
|
await assertLogoBoundingBox(allImages.first());
|
||||||
await expect(firstLogo).toBeVisible();
|
|
||||||
|
|
||||||
const logoBox = await firstLogo.boundingBox();
|
|
||||||
expect(logoBox).not.toBeNull();
|
|
||||||
|
|
||||||
const header = page.locator('header');
|
|
||||||
const headerBox = await header.boundingBox();
|
|
||||||
expect(headerBox).not.toBeNull();
|
|
||||||
|
|
||||||
if (logoBox && headerBox) {
|
|
||||||
expect(logoBox.x).toBeGreaterThanOrEqual(headerBox.x);
|
|
||||||
expect(logoBox.y).toBeGreaterThanOrEqual(headerBox.y);
|
|
||||||
expect(logoBox.x + logoBox.width).toBeLessThanOrEqual(headerBox.x + headerBox.width);
|
|
||||||
expect(logoBox.y + logoBox.height).toBeLessThanOrEqual(headerBox.y + headerBox.height);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await expect(logo.first()).toBeVisible();
|
await assertLogoBoundingBox(logo.first());
|
||||||
|
|
||||||
const logoBox = await logo.first().boundingBox();
|
|
||||||
expect(logoBox).not.toBeNull();
|
|
||||||
|
|
||||||
const header = page.locator('header');
|
|
||||||
const headerBox = await header.boundingBox();
|
|
||||||
expect(headerBox).not.toBeNull();
|
|
||||||
|
|
||||||
if (logoBox && headerBox) {
|
|
||||||
expect(logoBox.x).toBeGreaterThanOrEqual(headerBox.x);
|
|
||||||
expect(logoBox.y).toBeGreaterThanOrEqual(headerBox.y);
|
|
||||||
expect(logoBox.x + logoBox.width).toBeLessThanOrEqual(headerBox.x + headerBox.width);
|
|
||||||
expect(logoBox.y + logoBox.height).toBeLessThanOrEqual(headerBox.y + headerBox.height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 172 KiB |
@@ -172,7 +172,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
|
|||||||
company: formData.subject,
|
company: formData.subject,
|
||||||
});
|
});
|
||||||
trackConversion('contact_form_submission');
|
trackConversion('contact_form_submission');
|
||||||
setToastMessage(data.successMessage || '');
|
setToastMessage(data.successMessage || '提交成功!我们会尽快与您联系。');
|
||||||
setToastType('success');
|
setToastType('success');
|
||||||
setShowToast(true);
|
setShowToast(true);
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
@@ -373,8 +373,8 @@ function ContactFormContent({ data }: { data: ContactData }) {
|
|||||||
<div className="w-20 h-20 bg-brand rounded-full flex items-center justify-center mx-auto mb-6">
|
<div className="w-20 h-20 bg-brand rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<CheckCircle2 className="w-10 h-10 text-white" />
|
<CheckCircle2 className="w-10 h-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-2xl font-bold text-ink mb-3">{data.successTitle || ''}</h4>
|
<h4 className="text-2xl font-bold text-ink mb-3">{data.successTitle || '提交成功'}</h4>
|
||||||
<p className="text-text-secondary">{data.successMessage || ''}</p>
|
<p className="text-text-secondary">{data.successMessage || '感谢您的留言,我们会尽快与您联系。'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
|
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-[60vh] flex items-center justify-center">
|
|
||||||
<div className="page-transition-loader">
|
|
||||||
<div className="page-transition-loader__bar" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,8 @@ import { getPublishedItemBySlug, getPublishedItems, getAllPublishedSlugs } from
|
|||||||
import type { NewsItem } from '@/lib/constants/news';
|
import type { NewsItem } from '@/lib/constants/news';
|
||||||
import NewsDetailContentV3 from '../news-detail-content-v3';
|
import NewsDetailContentV3 from '../news-detail-content-v3';
|
||||||
|
|
||||||
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
|
// 使用 force-dynamic 确保未找到 slug 返回硬 404,同时允许 CMS 新发布内容在首次请求时动态渲染
|
||||||
export const revalidate = 3600;
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const slugs = await getAllPublishedSlugs('news');
|
const slugs = await getAllPublishedSlugs('news');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useMemo, ChangeEvent } from 'react';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Search, Calendar, ArrowRight, Newspaper, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Search, Calendar, ArrowRight, Newspaper, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
@@ -15,6 +16,7 @@ const categories = ['全部', '公司新闻', '研发动态'];
|
|||||||
const ITEMS_PER_PAGE = 9;
|
const ITEMS_PER_PAGE = 9;
|
||||||
|
|
||||||
function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
|
function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -22,17 +24,18 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
|
|||||||
viewport={{ once: true, margin: '-80px' }}
|
viewport={{ once: true, margin: '-80px' }}
|
||||||
transition={{ duration: 0.5, delay: index * 0.08, ease: EASE_OUT }}
|
transition={{ duration: 0.5, delay: index * 0.08, ease: EASE_OUT }}
|
||||||
>
|
>
|
||||||
<a
|
<StaticLink
|
||||||
href={`/news/${newsItem.id}`}
|
href={`/news/${newsItem.id}`}
|
||||||
className="group relative block border border-border-primary hover:border-brand/30 bg-white transition-all duration-500 hover:-translate-y-2 overflow-hidden h-full"
|
className="group relative block border border-border-primary hover:border-brand/30 bg-white transition-all duration-500 hover:-translate-y-2 overflow-hidden h-full"
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand scale-y-0 group-hover:scale-y-100 transition-transform duration-500 origin-top" />
|
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand scale-y-0 group-hover:scale-y-100 transition-transform duration-500 origin-top" />
|
||||||
|
|
||||||
{newsItem.image ? (
|
{newsItem.image && !imageError ? (
|
||||||
<div className="aspect-video bg-bg-secondary overflow-hidden">
|
<div className="aspect-video bg-bg-secondary overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={newsItem.image}
|
src={newsItem.image}
|
||||||
alt={newsItem.title}
|
alt={newsItem.title}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +69,7 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
|
|||||||
<ArrowRight className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-300" />
|
<ArrowRight className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-300" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</StaticLink>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-ser
|
|||||||
import type { Product } from '@/lib/constants/products';
|
import type { Product } from '@/lib/constants/products';
|
||||||
import ProductDetailContentV3 from '../product-detail-content-v3';
|
import ProductDetailContentV3 from '../product-detail-content-v3';
|
||||||
|
|
||||||
export const revalidate = 3600;
|
// 使用 force-dynamic 确保未找到 slug 返回硬 404,同时允许 CMS 新发布内容在首次请求时动态渲染
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const slugs = await getAllPublishedSlugs('product');
|
const slugs = await getAllPublishedSlugs('product');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { motion, useInView } from 'framer-motion';
|
|||||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||||
import { EASE_OUT } from '@/components/ui/page-decoration';
|
import { EASE_OUT } from '@/components/ui/page-decoration';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { ArrowRight, Package, Cpu, BarChart3, Users, Settings, FileText } from 'lucide-react';
|
import { ArrowRight, Package, Cpu, BarChart3, Users, Settings, FileText } from 'lucide-react';
|
||||||
import type { Product } from '@/lib/constants/products';
|
import type { Product } from '@/lib/constants/products';
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
@@ -149,12 +150,14 @@ function ProductCard({ product }: { product: Product }) {
|
|||||||
const bundleLabel = product.bundle === 'enterprise' ? '企业套装' : '专业产品';
|
const bundleLabel = product.bundle === 'enterprise' ? '企业套装' : '专业产品';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.a
|
<motion.div
|
||||||
href={`/products/${product.id}`}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: '-60px' }}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
transition={{ duration: 0.6, ease: EASE_OUT }}
|
transition={{ duration: 0.6, ease: EASE_OUT }}
|
||||||
|
>
|
||||||
|
<StaticLink
|
||||||
|
href={`/products/${product.id}`}
|
||||||
className="group block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-500 flex flex-col h-full"
|
className="group block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-500 flex flex-col h-full"
|
||||||
>
|
>
|
||||||
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
|
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
|
||||||
@@ -187,7 +190,8 @@ function ProductCard({ product }: { product: Product }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.a>
|
</StaticLink>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-ser
|
|||||||
import type { Service } from '@/lib/constants/services';
|
import type { Service } from '@/lib/constants/services';
|
||||||
import { ServiceDetailClient } from './client';
|
import { ServiceDetailClient } from './client';
|
||||||
|
|
||||||
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
|
// 使用 force-dynamic 确保未找到 slug 返回硬 404,同时允许 CMS 新发布内容在首次请求时动态渲染
|
||||||
export const revalidate = 3600;
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const slugs = await getAllPublishedSlugs('service');
|
const slugs = await getAllPublishedSlugs('service');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowUpRight, CheckCircle2 } from 'lucide-react';
|
import { ArrowUpRight, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import type { Service } from '@/lib/constants/services';
|
import type { Service } from '@/lib/constants/services';
|
||||||
|
|
||||||
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
||||||
@@ -192,13 +193,15 @@ function ServicesGridSection({ services }: { services?: Service[] }) {
|
|||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-px bg-border-primary">
|
<div className="grid md:grid-cols-2 gap-px bg-border-primary">
|
||||||
{displayServices.map((service, i) => (
|
{displayServices.map((service, i) => (
|
||||||
<motion.a
|
<motion.div
|
||||||
key={service.id}
|
key={service.id}
|
||||||
href={`/services/${service.id}`}
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: '-60px' }}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
transition={{ duration: 0.6, delay: i * 0.08, ease: EASE_OUT }}
|
transition={{ duration: 0.6, delay: i * 0.08, ease: EASE_OUT }}
|
||||||
|
>
|
||||||
|
<StaticLink
|
||||||
|
href={`/services/${service.id}`}
|
||||||
className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-transparent hover:border-border-primary"
|
className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-transparent hover:border-border-primary"
|
||||||
>
|
>
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
@@ -233,7 +236,8 @@ function ServicesGridSection({ services }: { services?: Service[] }) {
|
|||||||
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
|
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.a>
|
</StaticLink>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import type { Solution } from '@/lib/constants/solutions';
|
|||||||
import type { Product } from '@/lib/constants/products';
|
import type { Product } from '@/lib/constants/products';
|
||||||
import { SolutionDetailClient } from './client';
|
import { SolutionDetailClient } from './client';
|
||||||
|
|
||||||
export const revalidate = 3600;
|
// 使用 force-dynamic 确保未找到 slug 返回硬 404,同时允许 CMS 新发布内容在首次请求时动态渲染
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const slugs = await getAllPublishedSlugs('solution');
|
const slugs = await getAllPublishedSlugs('solution');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowUpRight, Factory, ShoppingCart, Heart, GraduationCap, Shield, Truck, type LucideIcon } from 'lucide-react';
|
import { ArrowUpRight, Factory, ShoppingCart, Heart, GraduationCap, Shield, Truck, type LucideIcon } from 'lucide-react';
|
||||||
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { type Solution } from '@/lib/constants/solutions';
|
import { type Solution } from '@/lib/constants/solutions';
|
||||||
import type { Product } from '@/lib/constants/products';
|
import type { Product } from '@/lib/constants/products';
|
||||||
|
|
||||||
@@ -115,12 +116,14 @@ function SolutionCard({ solution, index, products }: { solution: Solution; index
|
|||||||
.filter(Boolean) as string[];
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.a
|
<motion.div
|
||||||
href={`/solutions/${solution.id}`}
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: '-60px' }}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
transition={{ duration: 0.6, delay: index * 0.08, ease: EASE_OUT }}
|
transition={{ duration: 0.6, delay: index * 0.08, ease: EASE_OUT }}
|
||||||
|
>
|
||||||
|
<StaticLink
|
||||||
|
href={`/solutions/${solution.id}`}
|
||||||
className="group relative block transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-border-primary hover:border-brand/20"
|
className="group relative block transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-border-primary hover:border-brand/20"
|
||||||
>
|
>
|
||||||
<div className="relative z-10 p-8 sm:p-10 md:p-12">
|
<div className="relative z-10 p-8 sm:p-10 md:p-12">
|
||||||
@@ -163,7 +166,8 @@ function SolutionCard({ solution, index, products }: { solution: Solution; index
|
|||||||
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
|
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.a>
|
</StaticLink>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||||
|
|
||||||
|
process.env.JWT_SECRET = 'test-jwt-secret';
|
||||||
|
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
|
||||||
|
|
||||||
|
jest.mock('jsonwebtoken', () => ({
|
||||||
|
verify: jest.fn(),
|
||||||
|
sign: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unmock('./auth');
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { authenticateRequest, getTokenFromRequest, getTokenFromCookie } from './auth';
|
||||||
|
import type { JwtPayload } from './auth';
|
||||||
|
|
||||||
|
const mockJwtVerify = jwt.verify as jest.MockedFunction<typeof jwt.verify>;
|
||||||
|
|
||||||
|
type NextRequestInit = NonNullable<ConstructorParameters<typeof NextRequest>[1]>;
|
||||||
|
|
||||||
|
function createRequest(init?: NextRequestInit): NextRequest {
|
||||||
|
const request = new NextRequest('http://localhost/api/admin/models', init);
|
||||||
|
const cookieHeader = init?.headers && 'cookie' in init.headers ? init.headers.cookie : undefined;
|
||||||
|
const cookieMap = new Map<string, string>();
|
||||||
|
if (typeof cookieHeader === 'string') {
|
||||||
|
cookieHeader.split(';').forEach((pair) => {
|
||||||
|
const [name, value] = pair.trim().split('=');
|
||||||
|
if (name && value !== undefined) cookieMap.set(name, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Object.defineProperty(request, 'cookies', {
|
||||||
|
value: {
|
||||||
|
get: (name: string) => {
|
||||||
|
const value = cookieMap.get(name);
|
||||||
|
return value !== undefined ? { name, value } : undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getTokenFromRequest', () => {
|
||||||
|
it('returns token from Authorization header', () => {
|
||||||
|
const request = createRequest({
|
||||||
|
headers: { Authorization: 'Bearer valid-token' },
|
||||||
|
});
|
||||||
|
expect(getTokenFromRequest(request)).toBe('valid-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when Authorization header is missing', () => {
|
||||||
|
const request = createRequest();
|
||||||
|
expect(getTokenFromRequest(request)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when Authorization header is not Bearer', () => {
|
||||||
|
const request = createRequest({
|
||||||
|
headers: { Authorization: 'Basic valid-token' },
|
||||||
|
});
|
||||||
|
expect(getTokenFromRequest(request)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTokenFromCookie', () => {
|
||||||
|
it('returns token from novalon_token cookie', () => {
|
||||||
|
const request = createRequest({
|
||||||
|
headers: { cookie: 'novalon_token=cookie-token; other=value' },
|
||||||
|
});
|
||||||
|
expect(getTokenFromCookie(request)).toBe('cookie-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when novalon_token cookie is missing', () => {
|
||||||
|
const request = createRequest({
|
||||||
|
headers: { cookie: 'other=value' },
|
||||||
|
});
|
||||||
|
expect(getTokenFromCookie(request)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no cookies are present', () => {
|
||||||
|
const request = createRequest();
|
||||||
|
expect(getTokenFromCookie(request)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authenticateRequest', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates from Authorization header', () => {
|
||||||
|
const payload: JwtPayload = { userId: 'user-1', username: 'admin', role: 'super_admin' };
|
||||||
|
mockJwtVerify.mockReturnValue(payload as unknown as void);
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
headers: { Authorization: 'Bearer header-token' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authenticateRequest(request)).toEqual(payload);
|
||||||
|
expect(mockJwtVerify).toHaveBeenCalledWith('header-token', 'test-jwt-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to cookie when Authorization header is missing', () => {
|
||||||
|
const payload: JwtPayload = { userId: 'user-2', username: 'editor', role: 'editor' };
|
||||||
|
mockJwtVerify.mockReturnValue(payload as unknown as void);
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
headers: { cookie: 'novalon_token=cookie-token' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authenticateRequest(request)).toEqual(payload);
|
||||||
|
expect(mockJwtVerify).toHaveBeenCalledWith('cookie-token', 'test-jwt-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers Authorization header over cookie when both are present', () => {
|
||||||
|
const payload: JwtPayload = { userId: 'user-3', username: 'admin', role: 'super_admin' };
|
||||||
|
mockJwtVerify.mockReturnValue(payload as unknown as void);
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer header-token',
|
||||||
|
cookie: 'novalon_token=cookie-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
authenticateRequest(request);
|
||||||
|
expect(mockJwtVerify).toHaveBeenCalledWith('header-token', 'test-jwt-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no token is present', () => {
|
||||||
|
const request = createRequest();
|
||||||
|
expect(authenticateRequest(request)).toBeNull();
|
||||||
|
expect(mockJwtVerify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when token verification fails', () => {
|
||||||
|
mockJwtVerify.mockImplementation(() => {
|
||||||
|
throw new Error('invalid token');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
headers: { Authorization: 'Bearer invalid-token' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authenticateRequest(request)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,6 +47,8 @@ export function verifyRefreshToken(token: string): JwtPayload {
|
|||||||
return jwt.verify(token, JWT_REFRESH_SECRET!) as JwtPayload;
|
return jwt.verify(token, JWT_REFRESH_SECRET!) as JwtPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_COOKIE = 'novalon_token';
|
||||||
|
|
||||||
// ============ 请求认证 ============
|
// ============ 请求认证 ============
|
||||||
|
|
||||||
export function getTokenFromRequest(request: NextRequest): string | null {
|
export function getTokenFromRequest(request: NextRequest): string | null {
|
||||||
@@ -57,8 +59,19 @@ export function getTokenFromRequest(request: NextRequest): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求 cookie 中读取 access token。
|
||||||
|
* 中间件(Edge Runtime)与 API 路由(Node Runtime)均使用同一 cookie 名,
|
||||||
|
* 保证 SSR/页面路由与 API 调用的认证状态一致。
|
||||||
|
*/
|
||||||
|
export function getTokenFromCookie(request: NextRequest): string | null {
|
||||||
|
return request.cookies.get(ACCESS_TOKEN_COOKIE)?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function authenticateRequest(request: NextRequest): JwtPayload | null {
|
export function authenticateRequest(request: NextRequest): JwtPayload | null {
|
||||||
const token = getTokenFromRequest(request);
|
// 优先使用 Authorization 头(adminApi 客户端显式携带),同时兼容 cookie 认证
|
||||||
|
// 使中间件与 API 路由共享同一认证来源,避免 SSR/客户端状态不一致。
|
||||||
|
const token = getTokenFromRequest(request) || getTokenFromCookie(request);
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
return verifyAccessToken(token);
|
return verifyAccessToken(token);
|
||||||
@@ -85,11 +98,11 @@ export function setTokenCookie(
|
|||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
secure = false,
|
secure = false,
|
||||||
): void {
|
): void {
|
||||||
response.headers.set('Set-Cookie', buildCookie('novalon_token', accessToken, 86400, secure));
|
response.headers.set('Set-Cookie', buildCookie(ACCESS_TOKEN_COOKIE, accessToken, 86400, secure));
|
||||||
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', refreshToken, 604800, secure));
|
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', refreshToken, 604800, secure));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearTokenCookie(response: Response, secure = false): void {
|
export function clearTokenCookie(response: Response, secure = false): void {
|
||||||
response.headers.set('Set-Cookie', buildCookie('novalon_token', '', 0, secure));
|
response.headers.set('Set-Cookie', buildCookie(ACCESS_TOKEN_COOKIE, '', 0, secure));
|
||||||
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', '', 0, secure));
|
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', '', 0, secure));
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,7 @@ export const NAVIGATION_V2: NavigationItemV2[] = [
|
|||||||
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
|
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
|
||||||
{ id: 'services', label: '服务', href: '/services' },
|
{ id: 'services', label: '服务', href: '/services' },
|
||||||
{ id: 'cases', label: '案例', href: '/cases' },
|
{ id: 'cases', label: '案例', href: '/cases' },
|
||||||
|
{ id: 'news', label: '新闻动态', href: '/news' },
|
||||||
{ id: 'about', label: '关于我们', href: '/about' },
|
{ id: 'about', label: '关于我们', href: '/about' },
|
||||||
{ id: 'contact', label: '联系我们', href: '/contact' },
|
{ id: 'contact', label: '联系我们', href: '/contact' },
|
||||||
];
|
];
|
||||||
|
|||||||