fix(qa): 第五轮系统性质量保障缺陷修复 + 单元/集成测试

安全/鉴权:登出清服务端 httpOnly 令牌、登录 IP 限流前置 bcrypt、停用账号
令牌在 6 个内联 role handler 失效、admin/items 存在性 oracle 认证前置、
同意偏好 sanitize fail-closed、api-crypto 按 HTTP 方法判体。

CMS:workflow 状态更新+审计收进交互式 $transaction(失败即回滚)、编辑器
richtext 走 textarea 防换行损毁、array/object 子字段按作用域读写防污染、
about/contact/erp-upgrade 补 ISR revalidate 与路由映射。

UI/a11y:AnimatedCounter 入视口前显起点、CLS 累计上报、MetricCard 动画入
effect 并卸载取消 rAF、后台状态四态映射/搜索防抖+序号守卫/媒体单 input/
吞错改横幅/表单 label-aria 关联。

新增对应判别性单测与 pagination/rate-limit/client-ip/sanitize/validate-content-data
等模块及 tests-integration 真库集成层。
This commit is contained in:
2026-09-28 10:48:07 +08:00
parent 040951c0a3
commit a366bd1400
224 changed files with 8938 additions and 5579 deletions
+22 -21
View File
@@ -57,7 +57,7 @@ function HeroSection({ data }: { data: AboutData }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.1, ease: EASE_OUT }}
className="text-sm font-mono tracking-[0.15em] text-text-muted mb-8" className="text-sm font-mono tracking-[0.15em] text-text-muted mb-8"
> >
{data.heroSubtitle || ''} {data.heroSubtitle || ''}
@@ -66,8 +66,8 @@ function HeroSection({ data }: { data: AboutData }) {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.92] tracking-tightest mb-8 sm:mb-10 md:mb-12" className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.92] tracking-tighter mb-8 sm:mb-10 md:mb-12"
> >
{titleLines.map((line, i) => ( {titleLines.map((line, i) => (
<div key={i} className={`block ${i > 0 ? 'mt-4 sm:mt-6' : ''}`}> <div key={i} className={`block ${i > 0 ? 'mt-4 sm:mt-6' : ''}`}>
@@ -80,7 +80,7 @@ function HeroSection({ data }: { data: AboutData }) {
style={{ transformOrigin: 'left' }} style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }} initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 1.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 1.2, ease: EASE_OUT }}
/> />
</span> </span>
</span> </span>
@@ -94,7 +94,7 @@ function HeroSection({ data }: { data: AboutData }) {
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12" className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
> >
{data.heroDescription || ''} {data.heroDescription || ''}
@@ -103,7 +103,7 @@ function HeroSection({ data }: { data: AboutData }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.7, ease: EASE_OUT }}
className="flex flex-col sm:flex-row gap-4 sm:gap-6" className="flex flex-col sm:flex-row gap-4 sm:gap-6"
> >
<CTAButton href={data.heroPrimaryCtaHref || '/contact'} className="min-h-[52px] touch-manipulation"> <CTAButton href={data.heroPrimaryCtaHref || '/contact'} className="min-h-[52px] touch-manipulation">
@@ -137,7 +137,7 @@ function MetricsStrip({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: i * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: i * 0.06, ease: EASE_OUT }}
className="text-center lg:text-left" className="text-center lg:text-left"
> >
<div className={`text-3xl sm:text-4xl md:text-5xl font-black tracking-tight mb-2 ${i === 0 ? 'text-brand-ink' : 'text-ink'}`}> <div className={`text-3xl sm:text-4xl md:text-5xl font-black tracking-tight mb-2 ${i === 0 ? 'text-brand-ink' : 'text-ink'}`}>
@@ -165,7 +165,7 @@ function WhoWeAreSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="lg:col-span-5" className="lg:col-span-5"
> >
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
@@ -194,7 +194,7 @@ function WhoWeAreSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.15, ease: EASE_OUT }}
className="lg:col-span-7" className="lg:col-span-7"
> >
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
@@ -213,12 +213,13 @@ function WhoWeAreSection({ data }: { data: AboutData }) {
className="group relative border-b border-border-primary pb-6 md:pb-8 last:border-0 last:pb-0" className="group relative border-b border-border-primary pb-6 md:pb-8 last:border-0 last:pb-0"
> >
<div className="flex items-start gap-6 md:gap-8"> <div className="flex items-start gap-6 md:gap-8">
{/* 纯装饰序号:与右侧 value.title 并列,无独立语义,标记 aria-hidden 豁免对比度要求 */} {/* 装饰序号:aria-hidden 只把它移出无障碍树,axe 仍按可见文字校验 1.4.3,
<div aria-hidden="true" className="text-4xl md:text-5xl font-black text-text-muted/30 font-mono shrink-0 leading-none pt-1"> 所以用大字档令牌 text-text-hint(对 #F1F5F9 卡底 3.11:1 ≥ AA 大字 3:1) */}
<div aria-hidden="true" className="text-4xl md:text-5xl font-black text-text-hint font-mono shrink-0 leading-none pt-1">
{value.number} {value.number}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-xl md:text-2xl font-bold text-ink mb-3 group-hover:text-brand-ink transition-colors duration-300"> <h3 className="text-xl md:text-2xl font-bold text-ink mb-3 group-hover:text-brand-ink transition-colors duration-normal">
{value.title} {value.title}
</h3> </h3>
<p className="text-text-secondary leading-relaxed">{value.description}</p> <p className="text-text-secondary leading-relaxed">{value.description}</p>
@@ -244,7 +245,7 @@ function MilestonesSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24" className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
> >
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
@@ -268,7 +269,7 @@ function MilestonesSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: '-60px' }} viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.3, delay: idx * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: idx * 0.06, ease: EASE_OUT }}
className="relative pl-8 sm:pl-12 md:pl-16 pb-10 sm:pb-12 md:pb-14 last:pb-0" className="relative pl-8 sm:pl-12 md:pl-16 pb-10 sm:pb-12 md:pb-14 last:pb-0"
> >
<div className="absolute left-0 top-2 bottom-0 w-px bg-border-primary last:hidden" /> <div className="absolute left-0 top-2 bottom-0 w-px bg-border-primary last:hidden" />
@@ -307,7 +308,7 @@ function CertificationsSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl" className="max-w-3xl"
> >
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6"> <div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
@@ -340,7 +341,7 @@ function CertificationsSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
> >
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6"> <div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Certifications Certifications
@@ -355,7 +356,7 @@ function CertificationsSection({ data }: { data: AboutData }) {
return ( return (
<div <div
key={cert.name} key={cert.name}
className="p-5 sm:p-6 border border-border-primary bg-bg-primary hover:bg-bg-secondary hover:border-border-secondary transition-all duration-300" className="p-5 sm:p-6 border border-border-primary bg-bg-primary hover:bg-bg-secondary hover:border-border-secondary transition-all duration-normal"
> >
<div className="w-10 h-10 flex items-center justify-center mb-4 text-text-secondary"> <div className="w-10 h-10 flex items-center justify-center mb-4 text-text-secondary">
<Icon className="w-5 h-5" /> <Icon className="w-5 h-5" />
@@ -374,7 +375,7 @@ function CertificationsSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.15, ease: EASE_OUT }}
> >
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6"> <div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Partners Partners
@@ -386,9 +387,9 @@ function CertificationsSection({ data }: { data: AboutData }) {
{partners.map((partner) => ( {partners.map((partner) => (
<div <div
key={partner} key={partner}
className="aspect-[3/2] flex items-center justify-center border border-border-primary bg-bg-primary hover:bg-bg-secondary hover:border-border-secondary transition-all duration-300 group" className="aspect-[3/2] flex items-center justify-center border border-border-primary bg-bg-primary hover:bg-bg-secondary hover:border-border-secondary transition-all duration-normal group"
> >
<span className="text-sm font-bold text-text-muted group-hover:text-text-secondary transition-colors duration-300"> <span className="text-sm font-bold text-text-muted group-hover:text-text-secondary transition-colors duration-normal">
{partner} {partner}
</span> </span>
</div> </div>
@@ -415,7 +416,7 @@ function CTASection({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl" className="max-w-3xl"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
@@ -75,7 +75,7 @@ export default function BrandContent() {
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.1, ease: EASE_OUT }}
className="mb-8 sm:mb-10" className="mb-8 sm:mb-10"
> >
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full"> <span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full">
@@ -90,7 +90,7 @@ export default function BrandContent() {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl text-ink leading-[1.05] tracking-tight mb-8 sm:mb-10" className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl text-ink leading-[1.05] tracking-tight mb-8 sm:mb-10"
> >
<span className="sr-only">睿新致遠</span> <span className="sr-only">睿新致遠</span>
@@ -106,7 +106,7 @@ export default function BrandContent() {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.4, ease: EASE_OUT }}
className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10" className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10"
> >
{brandStats.map((stat, i) => ( {brandStats.map((stat, i) => (
@@ -125,7 +125,7 @@ export default function BrandContent() {
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/80 max-w-2xl leading-relaxed" className="text-lg sm:text-xl text-text-secondary/80 max-w-2xl leading-relaxed"
> >
公司于 2026 年 1 月正式成立,核心团队专注企业数字化,从战略规划到系统落地,致力于陪伴每一家企业实现可衡量的业务改善。 公司于 2026 年 1 月正式成立,核心团队专注企业数字化,从战略规划到系统落地,致力于陪伴每一家企业实现可衡量的业务改善。
@@ -161,9 +161,9 @@ export default function BrandContent() {
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{brandValues.map((item, i) => ( {brandValues.map((item, i) => (
<ScrollReveal key={item.title} delay={i * 0.1}> <ScrollReveal key={item.title} delay={i * 0.1}>
<div className="group relative bg-bg-primary border border-border-primary p-8 h-full transition-all duration-300 hover:border-brand/20 hover:shadow-md"> <div className="group relative bg-bg-primary border border-border-primary p-8 h-full transition-all duration-normal hover:border-brand/20 hover:shadow-md">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-brand scale-x-0 group-hover:scale-x-100 transition-transform duration-150 origin-left" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-brand scale-x-0 group-hover:scale-x-100 transition-transform duration-150 origin-left" />
<div className="w-12 h-12 flex items-center justify-center bg-brand/[0.06] text-brand-ink mb-6 transition-colors duration-300 group-hover:bg-brand group-hover:text-white"> <div className="w-12 h-12 flex items-center justify-center bg-brand/[0.06] text-brand-ink mb-6 transition-colors duration-normal group-hover:bg-brand group-hover:text-white">
<item.icon className="w-5 h-5" /> <item.icon className="w-5 h-5" />
</div> </div>
<h3 className="text-lg font-bold text-ink mb-3">{item.title}</h3> <h3 className="text-lg font-bold text-ink mb-3">{item.title}</h3>
@@ -323,8 +323,8 @@ export default function BrandContent() {
{ icon: Globe, title: '多行业场景', desc: '方法论适配制造、零售、医疗、金融、教育、物流等多行业场景' }, { icon: Globe, title: '多行业场景', desc: '方法论适配制造、零售、医疗、金融、教育、物流等多行业场景' },
].map((item, i) => ( ].map((item, i) => (
<ScrollReveal key={item.title} delay={i * 0.1}> <ScrollReveal key={item.title} delay={i * 0.1}>
<div className="group relative bg-bg-secondary border border-border-primary p-8 h-full transition-all duration-300 hover:border-brand/20 hover:bg-bg-primary hover:shadow-sm"> <div className="group relative bg-bg-secondary border border-border-primary p-8 h-full transition-all duration-normal hover:border-brand/20 hover:bg-bg-primary hover:shadow-sm">
<div className="w-12 h-12 flex items-center justify-center bg-brand/[0.06] text-brand-ink mb-6 transition-colors duration-300 group-hover:bg-brand group-hover:text-white"> <div className="w-12 h-12 flex items-center justify-center bg-brand/[0.06] text-brand-ink mb-6 transition-colors duration-normal group-hover:bg-brand group-hover:text-white">
<item.icon className="w-5 h-5" /> <item.icon className="w-5 h-5" />
</div> </div>
<h3 className="text-lg font-bold text-ink mb-3">{item.title}</h3> <h3 className="text-lg font-bold text-ink mb-3">{item.title}</h3>
@@ -345,7 +345,7 @@ export default function BrandContent() {
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10"> <div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl"> <ScrollReveal className="max-w-3xl">
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl xl:text-7xl font-black text-dark-text-primary tracking-tightest leading-[0.9] mb-8 sm:mb-10"> <h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl xl:text-7xl font-black text-dark-text-primary tracking-tighter leading-[0.9] mb-8 sm:mb-10">
成为下一个 成为下一个
<br /> <br />
<span className="text-brand-ink">数据驱动型企业</span> <span className="text-brand-ink">数据驱动型企业</span>
-18
View File
@@ -76,24 +76,6 @@ jest.mock('@/components/ui/card', () => {
return { Card, CardContent }; return { Card, CardContent };
}); });
jest.mock('@/components/ui/flip-clock', () => {
const FlipClock = ({ years, months, days }: { years: number; months: number; days: number }) => (
<div data-testid="flip-clock">
{years}年 {months}月 {days}天
</div>
);
FlipClock.displayName = 'FlipClock';
return { FlipClock };
});
jest.mock('@/components/layout/page-nav', () => ({
PageNav: ({ items }: { items: Array<{ label: string }> }) => (
<nav data-testid="page-nav">
{items.map((item, i) => <span key={i}>{item.label}</span>)}
</nav>
),
}));
jest.mock('date-fns', () => ({ jest.mock('date-fns', () => ({
differenceInYears: () => 0, differenceInYears: () => 0,
differenceInMonths: () => 0, differenceInMonths: () => 0,
+2
View File
@@ -3,6 +3,8 @@ import { COMPANY_INFO } from '@/lib/constants';
import { getPublishedItems } from '@/lib/cms/data-server'; import { getPublishedItems } from '@/lib/cms/data-server';
import { AboutClient } from './client'; import { AboutClient } from './client';
export const revalidate = 3600;
export const metadata: Metadata = { export const metadata: Metadata = {
title: `关于我们 - ${COMPANY_INFO.displayName}`, title: `关于我们 - ${COMPANY_INFO.displayName}`,
description: `了解${COMPANY_INFO.name}。专业核心团队,以结果导向的服务理念,做企业数字化转型的同行者与成长伙伴。`, description: `了解${COMPANY_INFO.name}。专业核心团队,以结果导向的服务理念,做企业数字化转型的同行者与成长伙伴。`,
@@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { CaseDetailClient } from './client';
import type { ContentItem } from '@/lib/cms/types';
jest.mock('next/navigation', () => ({
notFound: jest.fn(() => {
throw new Error('NEXT_NOT_FOUND');
}),
}));
jest.mock('@/components/sections/case-detail-page', () => ({
__esModule: true,
default: function MockCaseDetailPage({ data }: { data: unknown }) {
return <div data-testid="case-detail-page">{JSON.stringify(data)}</div>;
},
}));
function makeItem(data: Record<string, unknown>): ContentItem {
return {
id: 'item-1',
modelId: 'model-case-study',
modelCode: 'case-study',
title: '测试案例',
slug: 'test-case',
locale: 'zh-CN',
status: 'published',
data,
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as ContentItem;
}
function renderedData(item: ContentItem): Record<string, unknown> {
render(<CaseDetailClient item={item} />);
return JSON.parse(screen.getByTestId('case-detail-page').textContent ?? '{}');
}
describe('CaseDetailClient · CMS 载荷归一(C-1)', () => {
it('把缺失的 metrics / timeline / services 归一为空数组,challenge 归一为空串', () => {
const data = renderedData(
makeItem({
client: '测试客户',
industry: '制造业',
subtitle: '副标题',
solution: '方案',
result: '成果',
}),
);
expect(data.metrics).toEqual([]);
expect(data.timeline).toEqual([]);
expect(data.services).toEqual([]);
expect(data.challenge).toBe('');
});
it('完整载荷原样透传,不吞掉已有数据', () => {
const data = renderedData(
makeItem({
client: '测试客户',
industry: '制造业',
subtitle: '副标题',
challenge: '真实挑战',
solution: '方案',
result: '成果',
metrics: [{ value: '40%', label: '效率提升' }],
timeline: [{ phase: '诊断', duration: '1周', description: '描述' }],
services: [{ id: 'svc', title: '服务', description: '描述' }],
testimonial: { quote: '很好', author: '张总', role: 'CTO', company: '某企业' },
projectDuration: '16 周',
departments: ['供应链'],
verified: true,
}),
);
expect(data.challenge).toBe('真实挑战');
expect(data.metrics).toEqual([{ value: '40%', label: '效率提升' }]);
expect(data.timeline).toHaveLength(1);
expect(data.services).toHaveLength(1);
expect(data.testimonial).toEqual({ quote: '很好', author: '张总', role: 'CTO,某企业' });
expect(data.verified).toBe(true);
});
});
+5 -11
View File
@@ -2,7 +2,6 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import CaseDetailPage from '@/components/sections/case-detail-page'; import CaseDetailPage from '@/components/sections/case-detail-page';
import { ScrollProgress } from '@/components/ui/scroll-progress';
import type { ContentItem } from '@/lib/cms/types'; import type { ContentItem } from '@/lib/cms/types';
import type { CaseStudyData } from '@/lib/cms/types'; import type { CaseStudyData } from '@/lib/cms/types';
@@ -28,12 +27,12 @@ export function CaseDetailClient({ item }: CaseDetailClientProps) {
industry: data.industry, industry: data.industry,
title: item.title, title: item.title,
subtitle: data.subtitle, subtitle: data.subtitle,
challenge: data.challenge, challenge: data.challenge ?? '',
solution: data.solution, solution: data.solution,
result: data.result, result: data.result,
metrics: data.metrics, metrics: data.metrics ?? [],
timeline: data.timeline, timeline: data.timeline ?? [],
services: data.services, services: data.services ?? [],
testimonial: data.testimonial testimonial: data.testimonial
? { ? {
quote: data.testimonial.quote, quote: data.testimonial.quote,
@@ -48,10 +47,5 @@ export function CaseDetailClient({ item }: CaseDetailClientProps) {
verified: data.verified, verified: data.verified,
}; };
return ( return <CaseDetailPage data={detailData} />;
<>
<ScrollProgress />
<CaseDetailPage data={detailData} />
</>
);
} }
@@ -123,5 +123,20 @@ describe('CaseDetailPage', () => {
expect(metadata.title).toBe('案例未找到 - 睿新致远'); expect(metadata.title).toBe('案例未找到 - 睿新致远');
}); });
it('survives a case published without challenge / metrics (C-1)', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockCase(
{},
{ challenge: undefined, metrics: undefined },
) as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
await expect(
generateMetadata({ params: Promise.resolve({ slug: 'test-case' }) }),
).resolves.toMatchObject({
title: '测试案例 - 制造业案例 - 睿新致远',
});
});
}); });
}); });
+4 -1
View File
@@ -25,10 +25,13 @@ export async function generateMetadata({ params }: CaseDetailPageProps): Promise
} }
const data = item.data as unknown as CaseStudyData; const data = item.data as unknown as CaseStudyData;
const challengeExcerpt = data.challenge?.substring(0, 80) ?? '';
const metricsSummary =
data.metrics?.map((m: { value: string; label: string }) => `${m.value} ${m.label}`).join(',') ?? '';
return { return {
title: `${item.title} - ${data.industry}案例 - ${COMPANY_INFO.displayName}`, title: `${item.title} - ${data.industry}案例 - ${COMPANY_INFO.displayName}`,
description: `${data.client}数字化转型案例:${data.challenge.substring(0, 80)}... ${data.metrics?.map((m: { value: string; label: string }) => `${m.value} ${m.label}`).join(',')}`, description: `${data.client}数字化转型案例:${challengeExcerpt}... ${metricsSummary}`,
}; };
} }
+15 -15
View File
@@ -26,7 +26,7 @@ function CaseCard({ caseItem, index }: { caseItem: CaseStudy; index: number }) {
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.3, delay: index * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: index * 0.06, ease: EASE_OUT }}
className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-bg-elevated hover:bg-bg-secondary transition-all duration-150 flex flex-col h-full" className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-bg-elevated hover:bg-bg-secondary transition-all duration-150 flex flex-col h-full"
> >
<div className="absolute top-0 left-0 h-0 w-[2px] group-hover:h-full transition-all duration-150 origin-top bg-brand" /> <div className="absolute top-0 left-0 h-0 w-[2px] group-hover:h-full transition-all duration-150 origin-top bg-brand" />
@@ -41,7 +41,7 @@ function CaseCard({ caseItem, index }: { caseItem: CaseStudy; index: number }) {
</span> </span>
</div> </div>
<h3 className="text-lg sm:text-xl font-bold text-ink mb-4 leading-tight group-hover:text-brand-ink transition-colors duration-300"> <h3 className="text-lg sm:text-xl font-bold text-ink mb-4 leading-tight group-hover:text-brand-ink transition-colors duration-normal">
{caseItem.title} {caseItem.title}
</h3> </h3>
@@ -86,7 +86,7 @@ function CaseCard({ caseItem, index }: { caseItem: CaseStudy; index: number }) {
</div> </div>
<div className="flex items-center gap-1.5 text-brand-ink text-sm font-semibold"> <div className="flex items-center gap-1.5 text-brand-ink text-sm font-semibold">
查看详情 查看详情
<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-normal" />
</div> </div>
</div> </div>
</div> </div>
@@ -133,7 +133,7 @@ export default function CasesContentV3({
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.1, ease: EASE_OUT }}
className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-8" className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-8"
> >
Case Studies Case Studies
@@ -142,8 +142,8 @@ export default function CasesContentV3({
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16" className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.88] tracking-tighter mb-10 sm:mb-12 md:mb-16"
> >
<div className="block">{heroTitle1}</div> <div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6"> <div className="block mt-4 sm:mt-6">
@@ -155,7 +155,7 @@ export default function CasesContentV3({
style={{ transformOrigin: 'left' }} style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }} initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 1.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 1.2, ease: EASE_OUT }}
/> />
</span> </span>
</span> </span>
@@ -165,7 +165,7 @@ export default function CasesContentV3({
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12" className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12"
> >
{heroDescriptionLines.map((line, i) => ( {heroDescriptionLines.map((line, i) => (
@@ -179,7 +179,7 @@ export default function CasesContentV3({
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.7, ease: EASE_OUT }}
className="flex flex-wrap gap-3" className="flex flex-wrap gap-3"
> >
<span className="px-4 py-2 text-xs font-bold text-brand-ink border border-brand/20 bg-brand/[0.05]"> <span className="px-4 py-2 text-xs font-bold text-brand-ink border border-brand/20 bg-brand/[0.05]">
@@ -212,7 +212,7 @@ export default function CasesContentV3({
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: i * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: i * 0.06, ease: EASE_OUT }}
className="text-center lg:text-left" className="text-center lg:text-left"
> >
<div className={`text-3xl sm:text-4xl md:text-5xl font-black tracking-tight mb-2 ${ <div className={`text-3xl sm:text-4xl md:text-5xl font-black tracking-tight mb-2 ${
@@ -238,7 +238,7 @@ export default function CasesContentV3({
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="lg:max-w-2xl" className="lg:max-w-2xl"
> >
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6"> <div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
@@ -256,7 +256,7 @@ export default function CasesContentV3({
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.15, ease: EASE_OUT }}
> >
{/* 原生 button + aria-pressed:互斥筛选 chip 的可访问语义。 {/* 原生 button + aria-pressed:互斥筛选 chip 的可访问语义。
role="radio" 需 radiogroup 父级 + roving tabindex 才合法, role="radio" 需 radiogroup 父级 + roving tabindex 才合法,
@@ -270,7 +270,7 @@ export default function CasesContentV3({
aria-pressed={selectedIndustry === industry.id} aria-pressed={selectedIndustry === industry.id}
onClick={() => setSelectedIndustry(industry.id)} onClick={() => setSelectedIndustry(industry.id)}
className={cn( className={cn(
'px-4 py-2 text-xs font-medium transition-all duration-300', 'px-4 py-2 text-xs font-medium transition-all duration-normal',
selectedIndustry === industry.id selectedIndustry === industry.id
? 'bg-brand text-white' ? 'bg-brand text-white'
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-ink' : 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-ink'
@@ -306,7 +306,7 @@ export default function CasesContentV3({
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="relative overflow-hidden rounded-[2rem] bg-dark-bg p-10 sm:p-12 shadow-card" className="relative overflow-hidden rounded-[2rem] bg-dark-bg p-10 sm:p-12 shadow-card"
> >
{/* 背景纹理与出口结构沿用首页共创深色卡(home-content-v15 Cases 区) */} {/* 背景纹理与出口结构沿用首页共创深色卡(home-content-v15 Cases 区) */}
@@ -353,7 +353,7 @@ export default function CasesContentV3({
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl" className="max-w-3xl"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
@@ -344,6 +344,18 @@ describe('ContactContentV3 - 联系表单集成测试', () => {
const emailError = errorElements.find((el) => el.textContent?.includes('邮箱')); const emailError = errorElements.find((el) => el.textContent?.includes('邮箱'));
expect(emailError).toBeTruthy(); expect(emailError).toBeTruthy();
}); });
// 验收 A-18:字段校验通过后 errors 仍保留该 key(值为 undefined),
// 「请修正以下 N 项内容」面板因此带着 N 个空条目出现并在输入过程中顶开表单布局。
it('should not render the error summary after valid fields are blurred', async () => {
await renderContactContent();
fillFormFields();
for (const id of ['name-input', 'phone-input', 'email-input', 'subject-input', 'message-input']) {
fireEvent.blur(screen.getByTestId(id));
}
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
}); });
describe('Form Submission', () => { describe('Form Submission', () => {
@@ -28,6 +28,9 @@ import { LegacyTooltip as Tooltip } from '@/components/ui/tooltip';
const EASE_OUT = [0.22, 1, 0.36, 1] as const; const EASE_OUT = [0.22, 1, 0.36, 1] as const;
// jitless 关掉 Zod 模块求值时的 `new Function` eval 探测:探测的 throw 被库吞掉,但浏览器仍就地点报一次 securitypolicyviolation(本站 CSP 无 'unsafe-eval'),会淹没真实违规的定位。
z.config({ jitless: true });
const contactFormSchema = z.object({ const contactFormSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'), name: z.string().min(2, '姓名至少需要2个字符'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'), phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'),
@@ -108,7 +111,14 @@ function ContactFormContent({ data }: { data: ContactData }) {
const validateField = (field: keyof ContactFormData, value: string) => { const validateField = (field: keyof ContactFormData, value: string) => {
try { try {
contactFormSchema.shape[field].parse(value); contactFormSchema.shape[field].parse(value);
setErrors((prev) => ({ ...prev, [field]: undefined })); // 通过时必须删除 key:留成 undefined 会让 Object.keys(errors) 把有效字段也计入
// 「请修正以下 N 项内容」,虚假错误面板在输入过程中插入并顶开表单布局。
setErrors((prev) => {
if (!(field in prev)) {return prev;}
const next = {...prev};
delete next[field];
return next;
});
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
const fieldError = error.issues[0]; const fieldError = error.issues[0];
@@ -235,7 +245,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.1, ease: EASE_OUT }}
className="text-sm font-mono tracking-[0.15em] text-text-muted mb-8" className="text-sm font-mono tracking-[0.15em] text-text-muted mb-8"
> >
{data.heroSubtitle} {data.heroSubtitle}
@@ -245,8 +255,8 @@ function ContactFormContent({ data }: { data: ContactData }) {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16" className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.88] tracking-tighter mb-10 sm:mb-12 md:mb-16"
> >
{heroTitleLines.map((line, i) => ( {heroTitleLines.map((line, i) => (
<div key={i} className={`block ${i > 0 ? 'mt-4 sm:mt-6' : ''}`}> <div key={i} className={`block ${i > 0 ? 'mt-4 sm:mt-6' : ''}`}>
@@ -259,7 +269,10 @@ function ContactFormContent({ data }: { data: ContactData }) {
style={{ transformOrigin: 'left' }} style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }} initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 1.2, ease: EASE_OUT }} // 装饰性标题下划线:delay 属调度不属时长,1.2s 为已批准保留值
// (见 .workbuddy/memory/2026-09-04.md「delay 一律判 P2,不得判 P0;
// 字面 delay >150ms 保留」),且全站另有 6 处同形同值,改一处即跨页不一致。
transition={{ duration: 0.28, delay: 1.2, ease: EASE_OUT }}
/> />
</span> </span>
</span> </span>
@@ -274,7 +287,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16" className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
> >
{data.heroDescription} {data.heroDescription}
@@ -293,7 +306,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
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.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="lg:col-span-2 space-y-8" className="lg:col-span-2 space-y-8"
> >
<div> <div>
@@ -309,7 +322,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
<p className="text-sm text-text-muted mb-1">{data.emailLabel || ''}</p> <p className="text-sm text-text-muted mb-1">{data.emailLabel || ''}</p>
<a <a
href={`mailto:${emailAddress}`} href={`mailto:${emailAddress}`}
className="inline-flex items-center min-h-6 text-ink hover:text-brand-ink transition-colors duration-300" className="inline-flex items-center min-h-6 text-ink hover:text-brand-ink transition-colors duration-normal"
data-testid="email-link" data-testid="email-link"
> >
{emailAddress} {emailAddress}
@@ -365,7 +378,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
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.3, delay: 0.1, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.1, ease: EASE_OUT }}
className="lg:col-span-3" className="lg:col-span-3"
> >
<div className="relative border border-border-primary bg-bg-elevated p-6 sm:p-8 lg:p-10 h-full overflow-hidden" id="contact-form-section"> <div className="relative border border-border-primary bg-bg-elevated p-6 sm:p-8 lg:p-10 h-full overflow-hidden" id="contact-form-section">
@@ -546,7 +559,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
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.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl mx-auto text-center" className="max-w-3xl mx-auto text-center"
> >
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-8"> <div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-8">
+2
View File
@@ -1,6 +1,8 @@
import { getPublishedItems } from '@/lib/cms/data-server'; import { getPublishedItems } from '@/lib/cms/data-server';
import ContactContentV3 from './contact-content-v3'; import ContactContentV3 from './contact-content-v3';
export const revalidate = 3600;
export default async function ContactPage() { export default async function ContactPage() {
const items = await getPublishedItems('contact-page'); const items = await getPublishedItems('contact-page');
const data = items[0]?.data ?? {}; const data = items[0]?.data ?? {};
@@ -178,7 +178,7 @@ describe('HomeContentV15 - 创始人观点区(对标 Accenture CEO Quote)',
expect(screen.getByTestId('founder-quote-section')).toBeInTheDocument(); expect(screen.getByTestId('founder-quote-section')).toBeInTheDocument();
expect(screen.getByText(/数字化不是一次性项目/)).toBeInTheDocument(); expect(screen.getByText(/数字化不是一次性项目/)).toBeInTheDocument();
expect(screen.getByText('Novalon 创始团队')).toBeInTheDocument(); expect(screen.getByText('睿新致远创始团队')).toBeInTheDocument();
}); });
}); });
+18 -18
View File
@@ -112,7 +112,7 @@ const FALLBACK_FOUNDER_QUOTE = {
// 排版约束:3 行均势(17 / 18 / 13 字),避开"量。"孤儿字。 // 排版约束:3 行均势(17 / 18 / 13 字),避开"量。"孤儿字。
// 配合 blockquote 的 whitespace-pre-line 渲染换行。 // 配合 blockquote 的 whitespace-pre-line 渲染换行。
text: '我们相信,数字化不是一次性项目,\n而是持续迭代的能力建设。\n每一份投入,都应当经得起衡量。', text: '我们相信,数字化不是一次性项目,\n而是持续迭代的能力建设。\n每一份投入,都应当经得起衡量。',
name: 'Novalon 创始团队', name: '睿新致远创始团队',
role: '技术咨询 · 数字化转型同行者', role: '技术咨询 · 数字化转型同行者',
}; };
@@ -255,7 +255,7 @@ function HeroSection({ heroData }: {
className={`group inline-flex items-center gap-2 text-sm font-medium transition-colors duration-200 py-4 ${dark ? 'text-white/70 hover:text-white' : 'text-text-secondary hover:text-ink'}`} className={`group inline-flex items-center gap-2 text-sm font-medium transition-colors duration-200 py-4 ${dark ? 'text-white/70 hover:text-white' : 'text-text-secondary hover:text-ink'}`}
> >
{secondaryCtaLabel} {secondaryCtaLabel}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" /> <ArrowRight className="w-4 h-4 transition-transform duration-normal group-hover:translate-x-1" />
</a> </a>
)} )}
</motion.div> </motion.div>
@@ -394,7 +394,7 @@ function NarrativeSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
<div className="grid md:grid-cols-3 gap-px bg-border-primary"> <div className="grid md:grid-cols-3 gap-px bg-border-primary">
{NARRATIVE_ACTS_CMS.map((act) => ( {NARRATIVE_ACTS_CMS.map((act) => (
<StaggerReveal key={act.number} staggerDelay={0.05}> <StaggerReveal key={act.number} staggerDelay={0.05}>
<div className="group relative h-full bg-bg-primary p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary"> <div className="group relative h-full bg-bg-primary p-8 sm:p-10 md:p-12 transition-colors duration-normal hover:bg-bg-secondary">
<div className="flex items-start justify-between mb-8"> <div className="flex items-start justify-between mb-8">
<span <span
aria-hidden="true" aria-hidden="true"
@@ -403,7 +403,7 @@ function NarrativeSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
{act.number} {act.number}
</span> </span>
</div> </div>
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-4 group-hover:text-brand-ink transition-colors duration-300"> <h3 className="text-xl lg:text-2xl font-bold text-ink mb-4 group-hover:text-brand-ink transition-colors duration-normal">
{act.title} {act.title}
</h3> </h3>
<p className="text-text-secondary leading-relaxed mb-8 text-sm"> <p className="text-text-secondary leading-relaxed mb-8 text-sm">
@@ -415,7 +415,7 @@ function NarrativeSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
className="group/link inline-flex items-center min-h-6 gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors" className="group/link inline-flex items-center min-h-6 gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors"
> >
<span>{act.cta}</span> <span>{act.cta}</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover/link:translate-x-1" /> <ArrowRight className="w-4 h-4 transition-transform duration-normal group-hover/link:translate-x-1" />
</a> </a>
</div> </div>
</div> </div>
@@ -464,7 +464,7 @@ function InsightsSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
className="group inline-flex items-center min-h-6 gap-3 text-ink font-semibold text-sm" className="group inline-flex items-center min-h-6 gap-3 text-ink font-semibold text-sm"
> >
查看全部洞察 查看全部洞察
<ArrowRight className="w-4 h-5 transition-transform duration-300 group-hover:translate-x-2 text-text-secondary group-hover:text-brand-ink" /> <ArrowRight className="w-4 h-5 transition-transform duration-normal group-hover:translate-x-2 text-text-secondary group-hover:text-brand-ink" />
</StaticLink> </StaticLink>
</ScrollReveal> </ScrollReveal>
</div> </div>
@@ -474,7 +474,7 @@ function InsightsSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
<StaggerReveal key={i} staggerDelay={0.05}> <StaggerReveal key={i} staggerDelay={0.05}>
<a <a
href={insight.href} href={insight.href}
className="group flex flex-col h-full bg-bg-primary p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary" className="group flex flex-col h-full bg-bg-primary p-8 sm:p-10 md:p-12 transition-colors duration-normal hover:bg-bg-secondary"
> >
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<span className="text-[13px] tracking-[0.12em] font-semibold text-brand-ink"> <span className="text-[13px] tracking-[0.12em] font-semibold text-brand-ink">
@@ -484,7 +484,7 @@ function InsightsSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
<span className="text-xs text-text-muted tabular-nums">{insight.date}</span> <span className="text-xs text-text-muted tabular-nums">{insight.date}</span>
)} )}
</div> </div>
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-4 leading-[1.25] group-hover:text-brand-ink transition-colors duration-300"> <h3 className="text-xl lg:text-2xl font-bold text-ink mb-4 leading-[1.25] group-hover:text-brand-ink transition-colors duration-normal">
{insight.title} {insight.title}
</h3> </h3>
<p className="text-text-secondary leading-relaxed mb-8 text-sm flex-1"> <p className="text-text-secondary leading-relaxed mb-8 text-sm flex-1">
@@ -493,7 +493,7 @@ function InsightsSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
<div className="pt-6 border-t border-border-primary"> <div className="pt-6 border-t border-border-primary">
<span className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors"> <span className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors">
了解详情 了解详情
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" /> <ArrowRight className="w-4 h-4 transition-transform duration-normal group-hover:translate-x-1" />
</span> </span>
</div> </div>
</a> </a>
@@ -543,7 +543,7 @@ function ServicesSection({ services, pageCopy }: { services?: Record<string, any
</div> </div>
<p className="text-text-secondary leading-relaxed mb-8 text-sm">{service.desc}</p> <p className="text-text-secondary leading-relaxed mb-8 text-sm">{service.desc}</p>
<div className="flex flex-wrap gap-2 mb-8"> <div className="flex flex-wrap gap-2 mb-8">
{service.highlights.map((h: string, j: number) => ( {(Array.isArray(service.highlights) ? service.highlights : []).map((h: string, j: number) => (
<span key={j} className="px-3 py-1.5 text-xs text-text-muted border border-border-primary font-medium"> <span key={j} className="px-3 py-1.5 text-xs text-text-muted border border-border-primary font-medium">
{h} {h}
</span> </span>
@@ -565,7 +565,7 @@ function ServicesSection({ services, pageCopy }: { services?: Record<string, any
<div className="mt-auto pt-2"> <div className="mt-auto pt-2">
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors"> <div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors">
<span>了解详情</span> <span>了解详情</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" /> <ArrowRight className="w-4 h-4 transition-transform duration-normal group-hover:translate-x-1" />
</div> </div>
</div> </div>
</div> </div>
@@ -728,7 +728,7 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
className="group inline-flex items-center min-h-6 gap-3 text-ink font-semibold text-sm" className="group inline-flex items-center min-h-6 gap-3 text-ink font-semibold text-sm"
> >
查看全部案例 查看全部案例
<ArrowRight className="w-4 h-5 transition-transform duration-300 group-hover:translate-x-2 text-text-secondary group-hover:text-brand-ink" /> <ArrowRight className="w-4 h-5 transition-transform duration-normal group-hover:translate-x-2 text-text-secondary group-hover:text-brand-ink" />
</StaticLink> </StaticLink>
</ScrollReveal> </ScrollReveal>
</div> </div>
@@ -780,7 +780,7 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
<div className="mt-auto pt-6 border-t border-white/10 flex items-center justify-between gap-6 flex-wrap"> <div className="mt-auto pt-6 border-t border-white/10 flex items-center justify-between gap-6 flex-wrap">
<span className="inline-flex items-center gap-2 text-sm font-semibold text-white group-hover:text-brand-light transition-colors"> <span className="inline-flex items-center gap-2 text-sm font-semibold text-white group-hover:text-brand-light transition-colors">
查看完整案例 查看完整案例
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" /> <ArrowRight className="w-4 h-4 transition-transform duration-normal group-hover:translate-x-1" />
</span> </span>
</div> </div>
</div> </div>
@@ -825,7 +825,7 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
</h3> </h3>
<div className="grid grid-cols-3 gap-4 mb-8"> <div className="grid grid-cols-3 gap-4 mb-8">
{caseItem.metrics.slice(0, 3).map((metric: Record<string, any>, j: number) => ( {(Array.isArray(caseItem.metrics) ? caseItem.metrics : []).slice(0, 3).map((metric: Record<string, any>, j: number) => (
<div key={j}> <div key={j}>
<div className={cn( <div className={cn(
'text-xl sm:text-2xl font-black leading-none mb-2', 'text-xl sm:text-2xl font-black leading-none mb-2',
@@ -843,7 +843,7 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
<div className="mt-auto pt-4 border-t border-border-primary"> <div className="mt-auto pt-4 border-t border-border-primary">
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors"> <div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors">
<span>了解详情</span> <span>了解详情</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" /> <ArrowRight className="w-4 h-4 transition-transform duration-normal group-hover:translate-x-1" />
</div> </div>
</div> </div>
</a> </a>
@@ -884,7 +884,7 @@ function NewsSection({ news, pageCopy }: { news?: NewsItem[]; pageCopy?: HomePag
className="group inline-flex items-center min-h-6 gap-3 text-ink font-semibold text-sm" className="group inline-flex items-center min-h-6 gap-3 text-ink font-semibold text-sm"
> >
{ctaLabel} {ctaLabel}
<ArrowRight className="w-4 h-5 transition-transform duration-300 group-hover:translate-x-2 text-text-secondary group-hover:text-brand-ink" /> <ArrowRight className="w-4 h-5 transition-transform duration-normal group-hover:translate-x-2 text-text-secondary group-hover:text-brand-ink" />
</StaticLink> </StaticLink>
</ScrollReveal> </ScrollReveal>
</div> </div>
@@ -905,7 +905,7 @@ function NewsSection({ news, pageCopy }: { news?: NewsItem[]; pageCopy?: HomePag
{item.date} {item.date}
</span> </span>
</div> </div>
<h3 className="text-lg lg:text-xl font-bold text-ink mb-4 leading-[1.3] line-clamp-2 group-hover:text-brand-ink transition-colors duration-300"> <h3 className="text-lg lg:text-xl font-bold text-ink mb-4 leading-[1.3] line-clamp-2 group-hover:text-brand-ink transition-colors duration-normal">
{item.title} {item.title}
</h3> </h3>
<p className="text-sm text-text-secondary leading-relaxed mb-6 line-clamp-2 flex-1"> <p className="text-sm text-text-secondary leading-relaxed mb-6 line-clamp-2 flex-1">
@@ -914,7 +914,7 @@ function NewsSection({ news, pageCopy }: { news?: NewsItem[]; pageCopy?: HomePag
<div className="pt-4 border-t border-border-primary"> <div className="pt-4 border-t border-border-primary">
<span className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors"> <span className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors">
阅读全文 阅读全文
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" /> <ArrowRight className="w-4 h-4 transition-transform duration-normal group-hover:translate-x-1" />
</span> </span>
</div> </div>
</StaticLink> </StaticLink>
@@ -35,7 +35,7 @@ export function MethodologyContent({ data }: MethodologyContentProps) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.1, ease: EASE_OUT }}
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-brand-ink bg-brand/10 mb-8" className="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-brand-ink bg-brand/10 mb-8"
> >
{heroSubtitle} {heroSubtitle}
@@ -43,7 +43,7 @@ export function MethodologyContent({ data }: MethodologyContentProps) {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold text-ink leading-[1.05] tracking-tight mb-8 whitespace-pre-line" className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold text-ink leading-[1.05] tracking-tight mb-8 whitespace-pre-line"
> >
{heroTitle} {heroTitle}
@@ -51,7 +51,7 @@ export function MethodologyContent({ data }: MethodologyContentProps) {
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.35, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.35, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed" className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed"
> >
{heroDescription} {heroDescription}
@@ -85,7 +85,7 @@ export function MethodologyContent({ data }: MethodologyContentProps) {
{phases.map((phase, idx) => ( {phases.map((phase, idx) => (
<motion.div <motion.div
key={phase.title || idx} key={phase.title || idx}
className="relative overflow-hidden bg-bg-elevated border border-border-primary transition-all duration-300 hover:-translate-y-1 hover:shadow-lg" className="relative overflow-hidden bg-bg-elevated border border-border-primary transition-all duration-normal hover:-translate-y-1 hover:shadow-lg"
> >
<div className="p-7 md:p-8"> <div className="p-7 md:p-8">
<div className="w-11 h-11 rounded-full flex items-center justify-center mb-5 text-base font-bold bg-brand/10 text-brand-ink"> <div className="w-11 h-11 rounded-full flex items-center justify-center mb-5 text-base font-bold bg-brand/10 text-brand-ink">
@@ -31,7 +31,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
<div className="container-wide"> <div className="container-wide">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav aria-label="breadcrumb" className="flex items-center gap-2 text-sm text-[var(--color-text-muted)] mb-8"> <nav aria-label="breadcrumb" className="flex items-center gap-2 text-sm text-[var(--color-text-muted)] mb-8">
<StaticLink href="/news" className="hover:text-[var(--color-brand)] transition-colors duration-300"> <StaticLink href="/news" className="hover:text-brand-ink transition-colors duration-normal">
新闻动态 新闻动态
</StaticLink> </StaticLink>
<span>/</span> <span>/</span>
@@ -41,10 +41,12 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
<motion.div <motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }} initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE }} transition={{ duration: 0.28, ease: EASE }}
className="max-w-4xl" className="max-w-4xl"
> >
<Badge className="mb-4 bg-[var(--color-brand-bg)] text-[var(--color-brand)] hover:bg-[var(--color-brand)]/20 border-0"> {/* hover 底色用 12% 软红令牌而非 /20:半透明底叠加页面底色后,
浅色主题下 text-brand-ink 实测 4.18:1(< AA 4.5:1),12% 为 4.80:1;深色分别 6.22 / 6.60。 */}
<Badge className="mb-4 bg-[var(--color-brand-bg)] text-brand-ink hover:bg-brand-soft border-0">
{news.category} {news.category}
</Badge> </Badge>
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-semibold text-[var(--color-text-primary)] mb-5 tracking-tight leading-tight"> <h1 className="text-3xl sm:text-4xl lg:text-5xl font-semibold text-[var(--color-text-primary)] mb-5 tracking-tight leading-tight">
@@ -68,7 +70,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }} initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.3, ease: EASE }} transition={{ duration: 0.28, ease: EASE }}
className="max-w-4xl" className="max-w-4xl"
> >
{/* Featured Image */} {/* Featured Image */}
@@ -82,7 +84,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
</div> </div>
) : ( ) : (
<div className="aspect-video bg-gradient-to-br from-[var(--color-brand-bg)] to-[var(--color-bg-tertiary)] rounded-xl mb-10 flex items-center justify-center"> <div className="aspect-video bg-gradient-to-br from-[var(--color-brand-bg)] to-[var(--color-bg-tertiary)] rounded-xl mb-10 flex items-center justify-center">
<Newspaper className="w-16 h-16 text-[var(--color-brand)]/30" /> <Newspaper className="w-16 h-16 text-brand-ink/30" />
</div> </div>
)} )}
@@ -105,7 +107,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
<div className="grid md:grid-cols-3 gap-6"> <div className="grid md:grid-cols-3 gap-6">
{relatedNews.map((related) => ( {relatedNews.map((related) => (
<StaticLink key={related.id} href={`/news/${related.id}`}> <StaticLink key={related.id} href={`/news/${related.id}`}>
<Card className="overflow-hidden group h-full transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"> <Card className="overflow-hidden group h-full transition-all duration-normal hover:-translate-y-1 hover:shadow-lg">
<div className="aspect-video overflow-hidden"> <div className="aspect-video overflow-hidden">
{related.image ? ( {related.image ? (
<img <img
@@ -115,13 +117,13 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
/> />
) : ( ) : (
<div className="w-full h-full bg-gradient-to-br from-[var(--color-brand-bg)] to-[var(--color-bg-tertiary)] flex items-center justify-center"> <div className="w-full h-full bg-gradient-to-br from-[var(--color-brand-bg)] to-[var(--color-bg-tertiary)] flex items-center justify-center">
<Newspaper className="w-8 h-8 text-[var(--color-brand)]/30" /> <Newspaper className="w-8 h-8 text-brand-ink/30" />
</div> </div>
)} )}
</div> </div>
<div className="p-4"> <div className="p-4">
<Badge variant="secondary" className="mb-2 text-xs">{related.category}</Badge> <Badge variant="secondary" className="mb-2 text-xs">{related.category}</Badge>
<h3 className="text-base font-semibold text-[var(--color-text-primary)] mb-1 line-clamp-2 group-hover:text-[var(--color-brand)] transition-colors duration-300"> <h3 className="text-base font-semibold text-[var(--color-text-primary)] mb-1 line-clamp-2 group-hover:text-brand-ink transition-colors duration-normal">
{related.title} {related.title}
</h3> </h3>
<p className="text-sm text-[var(--color-text-muted)] line-clamp-2 leading-relaxed">{related.excerpt}</p> <p className="text-sm text-[var(--color-text-muted)] line-clamp-2 leading-relaxed">{related.excerpt}</p>
+3 -3
View File
@@ -24,7 +24,7 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: index * 0.06, ease: EASE_OUT }}
> >
<StaticLink <StaticLink
href={`/news/${newsItem.id}`} href={`/news/${newsItem.id}`}
@@ -58,7 +58,7 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
</div> </div>
</div> </div>
<h2 className="text-lg sm:text-xl font-semibold text-ink mb-3 line-clamp-2 group-hover:text-brand-ink transition-colors duration-300 leading-snug"> <h2 className="text-lg sm:text-xl font-semibold text-ink mb-3 line-clamp-2 group-hover:text-brand-ink transition-colors duration-normal leading-snug">
{newsItem.title} {newsItem.title}
</h2> </h2>
@@ -68,7 +68,7 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
<div className="flex items-center text-brand-ink text-sm font-medium"> <div className="flex items-center text-brand-ink text-sm font-medium">
阅读全文 阅读全文
<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-normal" />
</div> </div>
</div> </div>
</StaticLink> </StaticLink>
@@ -16,7 +16,7 @@ function RelatedNewsCard({ news, index }: { news: NewsItem; index: number }) {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: index * 0.06, ease: EASE_OUT }}
> >
<a <a
href={`/news/${news.id}`} href={`/news/${news.id}`}
@@ -42,7 +42,7 @@ function RelatedNewsCard({ news, index }: { news: NewsItem; index: number }) {
<Badge variant="outline" className="mb-3 text-xs border-brand/30 text-brand-ink"> <Badge variant="outline" className="mb-3 text-xs border-brand/30 text-brand-ink">
{news.category} {news.category}
</Badge> </Badge>
<h3 className="text-base font-semibold text-ink mb-2 line-clamp-2 group-hover:text-brand-ink transition-colors duration-300 leading-snug"> <h3 className="text-base font-semibold text-ink mb-2 line-clamp-2 group-hover:text-brand-ink transition-colors duration-normal leading-snug">
{news.title} {news.title}
</h3> </h3>
<p className="text-sm text-text-secondary line-clamp-2 leading-relaxed">{news.excerpt}</p> <p className="text-sm text-text-secondary line-clamp-2 leading-relaxed">{news.excerpt}</p>
@@ -66,7 +66,7 @@ export default function NewsDetailContentV3({ news, relatedNews = [] }: NewsDeta
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10"> <div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal> <ScrollReveal>
<nav aria-label="breadcrumb" className="flex items-center gap-2 text-sm text-text-muted mb-8 sm:mb-10"> <nav aria-label="breadcrumb" className="flex items-center gap-2 text-sm text-text-muted mb-8 sm:mb-10">
<StaticLink href="/news" className="hover:text-brand-ink transition-colors duration-300"> <StaticLink href="/news" className="hover:text-brand-ink transition-colors duration-normal">
新闻动态 新闻动态
</StaticLink> </StaticLink>
<span>/</span> <span>/</span>
@@ -150,7 +150,7 @@ export default function NewsDetailContentV3({ news, relatedNews = [] }: NewsDeta
<div className="mt-16 sm:mt-20 flex flex-col sm:flex-row justify-center gap-4"> <div className="mt-16 sm:mt-20 flex flex-col sm:flex-row justify-center gap-4">
<StaticLink <StaticLink
href="/news" href="/news"
className="inline-flex items-center justify-center gap-2 px-8 py-4 font-medium text-base text-ink border border-border-primary rounded-full hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300" className="inline-flex items-center justify-center gap-2 px-8 py-4 font-medium text-base text-ink border border-border-primary rounded-full hover:border-border-secondary hover:bg-bg-secondary transition-all duration-normal"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
返回新闻列表 返回新闻列表
@@ -222,7 +222,7 @@ export default function ErpUpgradeContentV2({ item }: ErpUpgradeContentV2Props)
key={index} key={index}
className="text-center p-8 border border-border-primary bg-bg-elevated hover:border-brand/30 hover:bg-bg-secondary transition-all duration-150 group" className="text-center p-8 border border-border-primary bg-bg-elevated hover:border-brand/30 hover:bg-bg-secondary transition-all duration-150 group"
> >
<div className="w-12 h-12 mx-auto mb-6 bg-brand/10 border border-brand/20 flex items-center justify-center group-hover:scale-110 transition-transform duration-300"> <div className="w-12 h-12 mx-auto mb-6 bg-brand/10 border border-brand/20 flex items-center justify-center group-hover:scale-110 transition-transform duration-normal">
<item.icon className="w-6 h-6 text-brand-ink" /> <item.icon className="w-6 h-6 text-brand-ink" />
</div> </div>
<div className="text-4xl md:text-5xl font-bold text-brand-ink mb-3">{item.value}</div> <div className="text-4xl md:text-5xl font-bold text-brand-ink mb-3">{item.value}</div>
@@ -296,8 +296,10 @@ export default function ErpUpgradeContentV2({ item }: ErpUpgradeContentV2Props)
> >
{UPGRADE_PROCESS.map((item, index) => ( {UPGRADE_PROCESS.map((item, index) => (
<div key={index} className="relative"> <div key={index} className="relative">
{/* 大号步骤数字是水印装饰(两主题下都是 10% 透明度),步骤信息由下方标题/描述承载 */} {/* 大号步骤数字仍对读屏隐藏(步骤信息由下方标题/描述承载),但透明度取 /80:
<div aria-hidden="true" className="text-6xl font-bold text-brand-ink/10 mb-4">{item.step}</div> 10% 在两主题下合成后均约 1.07:1,被 axe color-contrast 判 serious(验收 §8-⑤ 实测);
/80 与 about 页步骤号同一档位(about-content-v4.tsx:172),实测见 docs/acceptance/2026-09-21-axe/ */}
<div aria-hidden="true" className="text-6xl font-bold text-brand-ink/80 mb-4">{item.step}</div>
<h3 className="text-xl font-semibold mb-3 text-ink">{item.title}</h3> <h3 className="text-xl font-semibold mb-3 text-ink">{item.title}</h3>
<p className="text-text-secondary mb-4 leading-relaxed">{item.desc}</p> <p className="text-text-secondary mb-4 leading-relaxed">{item.desc}</p>
<div className="inline-flex items-center gap-2 text-sm text-brand-ink"> <div className="inline-flex items-center gap-2 text-sm text-brand-ink">
@@ -360,7 +362,7 @@ export default function ErpUpgradeContentV2({ item }: ErpUpgradeContentV2Props)
key={index} key={index}
className="group text-center p-8 border border-border-primary bg-bg-elevated hover:border-brand/30 hover:bg-bg-secondary transition-all duration-150 min-w-[200px]" className="group text-center p-8 border border-border-primary bg-bg-elevated hover:border-brand/30 hover:bg-bg-secondary transition-all duration-150 min-w-[200px]"
> >
<div className="w-16 h-16 mx-auto mb-4 bg-brand/10 border border-brand/20 flex items-center justify-center group-hover:scale-110 transition-transform duration-300"> <div className="w-16 h-16 mx-auto mb-4 bg-brand/10 border border-brand/20 flex items-center justify-center group-hover:scale-110 transition-transform duration-normal">
<Shield className="w-8 h-8 text-brand-ink" /> <Shield className="w-8 h-8 text-brand-ink" />
</div> </div>
<h4 className="text-base font-semibold mb-1 text-ink">{cert.name}</h4> <h4 className="text-base font-semibold mb-1 text-ink">{cert.name}</h4>
@@ -3,6 +3,8 @@ import { getProductBySlug } from '@/lib/cms/data-server';
import { COMPANY_INFO } from '@/lib/constants'; import { COMPANY_INFO } from '@/lib/constants';
import ErpUpgradeContentV2 from './erp-upgrade-content-v2'; import ErpUpgradeContentV2 from './erp-upgrade-content-v2';
export const revalidate = 3600;
export const metadata: Metadata = { export const metadata: Metadata = {
title: `ERP升级专题 - 企业数字化转型 | ${COMPANY_INFO.displayName}`, title: `ERP升级专题 - 企业数字化转型 | ${COMPANY_INFO.displayName}`,
description: '睿新ERP升级专题,为企业提供全方位数字化转型解决方案,解决数据孤岛、效率低下、安全隐患等痛点。', description: '睿新ERP升级专题,为企业提供全方位数字化转型解决方案,解决数据孤岛、效率低下、安全隐患等痛点。',
@@ -33,7 +33,7 @@ function DetailHero({ product }: DetailHeroProps) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="flex items-center gap-4 mb-8" className="flex items-center gap-4 mb-8"
> >
<div className="w-16 h-16 rounded-2xl bg-brand-soft flex items-center justify-center text-brand-ink"> <div className="w-16 h-16 rounded-2xl bg-brand-soft flex items-center justify-center text-brand-ink">
@@ -61,7 +61,7 @@ function DetailHero({ product }: DetailHeroProps) {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 50 }} initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.15, ease: EASE_OUT }}
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-ink leading-tight tracking-tighter mb-6 sm:mb-8 max-w-4xl" className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-ink leading-tight tracking-tighter mb-6 sm:mb-8 max-w-4xl"
> >
{product.title} {product.title}
@@ -70,7 +70,7 @@ function DetailHero({ product }: DetailHeroProps) {
<motion.p <motion.p
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.3, ease: EASE_OUT }}
className="text-xl lg:text-2xl text-text-secondary max-w-3xl leading-relaxed mb-8" className="text-xl lg:text-2xl text-text-secondary max-w-3xl leading-relaxed mb-8"
> >
{product.description} {product.description}
@@ -79,7 +79,7 @@ function DetailHero({ product }: DetailHeroProps) {
<motion.p <motion.p
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.4, ease: EASE_OUT }}
className="text-lg text-text-muted max-w-3xl leading-relaxed mb-12" className="text-lg text-text-muted max-w-3xl leading-relaxed mb-12"
> >
{product.overview} {product.overview}
@@ -88,7 +88,7 @@ function DetailHero({ product }: DetailHeroProps) {
<motion.div <motion.div
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.55, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.55, ease: EASE_OUT }}
className="flex flex-wrap gap-6" className="flex flex-wrap gap-6"
> >
<CTAButton href="/contact" dataTestId="product-detail-hero-primary"> <CTAButton href="/contact" dataTestId="product-detail-hero-primary">
@@ -175,8 +175,8 @@ function BenefitsSection({ benefits }: BenefitsSectionProps) {
</p> </p>
</ScrollReveal> </ScrollReveal>
<div className="space-y-px bg-border-primary"> <div>
<StaggerReveal staggerDelay={0.05} delayChildren={0.05}> <StaggerReveal staggerDelay={0.05} delayChildren={0.05} className="space-y-px bg-border-primary">
{benefits.map((benefit, idx) => ( {benefits.map((benefit, idx) => (
<div <div
key={idx} key={idx}
@@ -228,8 +228,8 @@ function ProcessSection({ process }: ProcessSectionProps) {
</ScrollReveal> </ScrollReveal>
<div className="relative"> <div className="relative">
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary"> <div>
<StaggerReveal staggerDelay={0.05} delayChildren={0.05}> <StaggerReveal staggerDelay={0.05} delayChildren={0.05} className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary">
{process.map((step, idx) => ( {process.map((step, idx) => (
<div <div
key={idx} key={idx}
@@ -51,8 +51,8 @@ jest.mock('@/components/ui/scroll-reveal', () => ({
ScrollReveal: ({ children, className }: any) => ( ScrollReveal: ({ children, className }: any) => (
<div data-testid="scroll-reveal" className={className}>{children}</div> <div data-testid="scroll-reveal" className={className}>{children}</div>
), ),
StaggerReveal: ({ children, className }: any) => ( StaggerReveal: ({ children, className, role }: any) => (
<div data-testid="stagger-reveal" className={className}>{children}</div> <div data-testid="stagger-reveal" className={className} role={role}>{children}</div>
), ),
})); }));
@@ -303,6 +303,20 @@ describe('ProductsContentV3 - 产品列表页集成测试', () => {
expect(biCard).toHaveAttribute('data-bento-size', 'wide'); expect(biCard).toHaveAttribute('data-bento-size', 'wide');
}); });
// 验收 B-8:/products 上 7 个 role="listitem" 无 list 父级,axe 报 critical aria-required-parent
it('every bento listitem is owned by a role="list" container', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
const { container } = render(<ProductsContentV3 products={allMockProducts} />);
const items = Array.from(container.querySelectorAll('[role="listitem"]'));
const lists = Array.from(container.querySelectorAll('[role="list"]'));
expect(items.length).toBeGreaterThan(0);
expect(lists.length).toBeGreaterThanOrEqual(2);
items.forEach((item) => {
expect(item.closest('[role="list"]')).not.toBeNull();
});
});
it('should render suite combos section', async () => { it('should render suite combos section', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3'); const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />); render(<ProductsContentV3 products={allMockProducts} />);
@@ -6,7 +6,7 @@ import { EASE_OUT } from '@/components/ui/page-decoration';
import { StaticLink } from '@/components/ui/static-link'; import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button'; import { CTAButton } from '@/components/ui/cta-button';
import { MetricsBasisNote } from '@/components/ui/metrics-basis-note'; import { MetricsBasisNote } from '@/components/ui/metrics-basis-note';
import { BentoItem } from '@/components/sections/bento-grid'; import { BentoGrid, BentoItem } from '@/components/sections/bento-grid';
import { ArrowRight, ArrowUpRight, Package, Cpu, TrendingUp, Users, Settings, FileText } from 'lucide-react'; import { ArrowRight, ArrowUpRight, Package, Cpu, TrendingUp, Users, Settings, FileText } from 'lucide-react';
import type { Product } from '@/lib/constants/products'; import type { Product } from '@/lib/constants/products';
import { CTA_LABELS } from '@/lib/constants/cta-labels'; import { CTA_LABELS } from '@/lib/constants/cta-labels';
@@ -43,8 +43,8 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 70 }} initial={{ opacity: 0, y: 70 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.15, ease: EASE_OUT }}
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tightest mb-8 sm:mb-10 md:mb-12" className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tighter mb-8 sm:mb-10 md:mb-12"
> >
<div className="block">{heroTitle1}</div> <div className="block">{heroTitle1}</div>
<div className="block mt-5"> <div className="block mt-5">
@@ -55,7 +55,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
style={{ transformOrigin: 'left' }} style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }} initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 1.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 1.2, ease: EASE_OUT }}
/> />
</span> </span>
</div> </div>
@@ -65,7 +65,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
<motion.p <motion.p
initial={{ opacity: 0, y: 50 }} initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.45, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.45, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12" className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
> >
{heroDescriptionLines.map((line, i) => ( {heroDescriptionLines.map((line, i) => (
@@ -79,7 +79,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 50 }} initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.6, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.6, ease: EASE_OUT }}
className="flex flex-wrap gap-6" className="flex flex-wrap gap-6"
> >
<CTAButton href="/contact" dataTestId="products-hero-primary"> <CTAButton href="/contact" dataTestId="products-hero-primary">
@@ -107,12 +107,12 @@ function ProductCard({ product, size = 'small' }: { product: Product; size?: 'la
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.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="h-full" className="h-full"
> >
<StaticLink <StaticLink
href={linkHref} href={linkHref}
className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-bg-elevated hover:bg-bg-secondary hover:-translate-y-1 hover:shadow-lg transition-all duration-300 flex flex-col h-full after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-300 group-hover:after:scale-x-100" className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-bg-elevated hover:bg-bg-secondary hover:-translate-y-1 hover:shadow-lg transition-all duration-normal flex flex-col h-full after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-normal group-hover:after:scale-x-100"
{...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})} {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
> >
<div className={cn('p-6 sm:p-7 lg:p-8 flex flex-col flex-1', isLarge && 'lg:p-10')}> <div className={cn('p-6 sm:p-7 lg:p-8 flex flex-col flex-1', isLarge && 'lg:p-10')}>
@@ -246,7 +246,7 @@ function EnterpriseProductsSection({ products }: { products: Product[] }) {
</ScrollReveal> </ScrollReveal>
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary"> <BentoGrid>
{enterpriseProducts.map((product) => ( {enterpriseProducts.map((product) => (
<ProductCard <ProductCard
key={product.id} key={product.id}
@@ -254,7 +254,7 @@ function EnterpriseProductsSection({ products }: { products: Product[] }) {
size={product.id === 'erp' ? 'large' : product.id === 'bi' ? 'wide' : 'small'} size={product.id === 'erp' ? 'large' : product.id === 'bi' ? 'wide' : 'small'}
/> />
))} ))}
</div> </BentoGrid>
</div> </div>
</section> </section>
); );
@@ -289,6 +289,7 @@ function SpecializedProductsSection({ products }: { products: Product[] }) {
<StaggerReveal <StaggerReveal
className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary" className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary"
role="list"
staggerDelay={0.05} staggerDelay={0.05}
delayChildren={0.05} delayChildren={0.05}
> >
@@ -75,7 +75,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.3 }} transition={{ duration: 0.28 }}
className="max-w-3xl mx-auto text-center" className="max-w-3xl mx-auto text-center"
> >
<div className="mb-6 flex justify-center"> <div className="mb-6 flex justify-center">
@@ -97,7 +97,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.06 }} transition={{ duration: 0.28, delay: index * 0.06 }}
className="rounded-lg border border-border-primary bg-bg-secondary p-4" className="rounded-lg border border-border-primary bg-bg-secondary p-4"
> >
<div className="mb-2 flex h-8 w-8 items-center justify-center rounded-lg bg-bg-primary text-text-muted"> <div className="mb-2 flex h-8 w-8 items-center justify-center rounded-lg bg-bg-primary text-text-muted">
@@ -134,7 +134,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.3 }} transition={{ duration: 0.28 }}
> >
<h2 className="mb-6 text-2xl font-bold text-ink">核心能力</h2> <h2 className="mb-6 text-2xl font-bold text-ink">核心能力</h2>
{features.length > 0 ? ( {features.length > 0 ? (
@@ -145,7 +145,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
initial={{ opacity: 0, x: -10 }} initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05 }} transition={{ duration: 0.28, delay: index * 0.05 }}
className="flex items-start gap-3" className="flex items-start gap-3"
> >
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-brand-ink" /> <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-brand-ink" />
@@ -168,7 +168,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.3 }} transition={{ duration: 0.28 }}
className="mb-10" className="mb-10"
> >
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -189,7 +189,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05 }} transition={{ duration: 0.28, delay: index * 0.05 }}
className="rounded-lg border border-border-primary bg-bg-primary p-5" className="rounded-lg border border-border-primary bg-bg-primary p-5"
> >
<div className="mb-2 text-sm text-text-muted">{param.name}</div> <div className="mb-2 text-sm text-text-muted">{param.name}</div>
@@ -211,7 +211,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.3 }} transition={{ duration: 0.28 }}
className="mb-10" className="mb-10"
> >
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -232,7 +232,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
initial={{ opacity: 0, x: -10 }} initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05 }} transition={{ duration: 0.28, delay: index * 0.05 }}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-lg border border-border-primary bg-bg-secondary p-5" className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-lg border border-border-primary bg-bg-secondary p-5"
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
@@ -1,19 +1,36 @@
'use client'; "use client";
import { motion } from 'framer-motion'; import { motion } from "framer-motion";
import { CTAButton } from '@/components/ui/cta-button'; import { CTAButton } from "@/components/ui/cta-button";
import { CTA_LABELS } from '@/lib/constants/cta-labels'; import { CTA_LABELS } from "@/lib/constants/cta-labels";
import { CheckCircle2, Zap, Clock, Users, Award, TrendingUp, Shield } from 'lucide-react'; import {
import type { LucideIcon } from 'lucide-react'; CheckCircle2,
import type { Service } from '@/lib/constants/services'; Zap,
import { L3EmptyFallback, CaseStudiesSection } from '@/components/detail'; Clock,
Users,
Award,
TrendingUp,
Shield,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { Service } from "@/lib/constants/services";
import { L3EmptyFallback, CaseStudiesSection } from "@/components/detail";
const EASE_OUT = [0.22, 1, 0.36, 1] as const; const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const FEATURE_ICONS: LucideIcon[] = [Zap, Clock, Users, Award, TrendingUp, Shield]; const FEATURE_ICONS: LucideIcon[] = [
Zap,
Clock,
Users,
Award,
TrendingUp,
Shield,
];
function DetailHero({ service }: { service: Service }) { function DetailHero({ service }: { service: Service }) {
const firstBenefit = service.benefits?.[0] || '效率提升 40%'; // N-30:原先无数据时兜底成编造的「效率提升 40%」,属「零编造」硬违规——
// 页面上出现的具体百分比必须来自数据本身,没有就不渲染这一行。
const firstBenefit = service.benefits?.[0];
return ( return (
<section className="relative overflow-hidden bg-bg-primary"> <section className="relative overflow-hidden bg-bg-primary">
@@ -22,20 +39,20 @@ function DetailHero({ service }: { service: Service }) {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.2, ease: EASE_OUT }}
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-ink leading-[0.95] tracking-tightest mb-6 sm:mb-8" className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-ink leading-[0.95] tracking-tighter mb-6 sm:mb-8"
> >
<div className="block">{service.title}</div> <div className="block">{service.title}</div>
<div className="block mt-4 sm:mt-6"> <div className={firstBenefit ? "block mt-4 sm:mt-6" : "hidden"}>
<span className="relative inline-block"> <span className="relative inline-block">
<span className="relative text-brand-ink font-black"> <span className="relative text-brand-ink font-black">
{firstBenefit} {firstBenefit}
<motion.span <motion.span
className="absolute -bottom-2 left-0 w-full h-[3px] bg-brand" className="absolute -bottom-2 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }} style={{ transformOrigin: "left" }}
initial={{ scaleX: 0 }} initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 1.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 1.2, ease: EASE_OUT }}
/> />
</span> </span>
</span> </span>
@@ -45,7 +62,7 @@ function DetailHero({ service }: { service: Service }) {
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12" className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
> >
{service.description} {service.description}
@@ -54,13 +71,21 @@ function DetailHero({ service }: { service: Service }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.7, ease: EASE_OUT }}
className="flex flex-col sm:flex-row gap-4 sm:gap-6" className="flex flex-col sm:flex-row gap-4 sm:gap-6"
> >
<CTAButton href="/contact" dataTestId="service-detail-hero-primary" className="min-h-[52px]"> <CTAButton
href="/contact"
dataTestId="service-detail-hero-primary"
className="min-h-[52px]"
>
{CTA_LABELS.consult} {CTA_LABELS.consult}
</CTAButton> </CTAButton>
<CTAButton href="#cases" variant="secondary" className="min-h-[52px] touch-manipulation"> <CTAButton
href="#cases"
variant="secondary"
className="min-h-[52px] touch-manipulation"
>
查看案例 查看案例
</CTAButton> </CTAButton>
</motion.div> </motion.div>
@@ -81,8 +106,8 @@ function OverviewSection({ service }: { service: Service }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="lg:col-span-5" className="lg:col-span-5"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
@@ -93,8 +118,8 @@ function OverviewSection({ service }: { service: Service }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.15, ease: EASE_OUT }}
className="lg:col-span-7" className="lg:col-span-7"
> >
<p className="text-text-secondary leading-relaxed text-lg mb-8"> <p className="text-text-secondary leading-relaxed text-lg mb-8">
@@ -125,8 +150,8 @@ function FeaturesSection({ service }: { service: Service }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24" className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
@@ -139,7 +164,7 @@ function FeaturesSection({ service }: { service: Service }) {
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary">
{service.features.map((feature, index) => { {service.features.map((feature, index) => {
const [title, desc] = feature.split(':'); const [title, desc] = feature.split(":");
const Icon = FEATURE_ICONS[index % FEATURE_ICONS.length]!; const Icon = FEATURE_ICONS[index % FEATURE_ICONS.length]!;
return ( return (
@@ -147,13 +172,17 @@ function FeaturesSection({ service }: { service: Service }) {
key={index} key={index}
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.3, delay: index * 0.06, ease: EASE_OUT }} transition={{
duration: 0.28,
delay: index * 0.06,
ease: EASE_OUT,
}}
className="group relative p-8 sm:p-10 transition-all duration-150 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-bg-primary" className="group relative p-8 sm:p-10 transition-all duration-150 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-bg-primary"
> >
<div className="relative z-10"> <div className="relative z-10">
<Icon className="w-8 h-8 mb-6 text-brand-ink" /> <Icon className="w-8 h-8 mb-6 text-brand-ink" />
<h3 className="text-lg sm:text-xl font-bold mb-3 text-ink group-hover:text-brand-ink transition-colors duration-300"> <h3 className="text-lg sm:text-xl font-bold mb-3 text-ink group-hover:text-brand-ink transition-colors duration-normal">
{desc ? title : feature.slice(0, 10)} {desc ? title : feature.slice(0, 10)}
</h3> </h3>
<p className="text-sm leading-relaxed text-text-secondary"> <p className="text-sm leading-relaxed text-text-secondary">
@@ -181,8 +210,8 @@ function ProcessSection({ service }: { service: Service }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24" className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
@@ -195,21 +224,28 @@ function ProcessSection({ service }: { service: Service }) {
<div className="grid md:grid-cols-2 lg:grid-cols-5 gap-px bg-border-primary"> <div className="grid md:grid-cols-2 lg:grid-cols-5 gap-px bg-border-primary">
{service.process.slice(0, 5).map((step, index) => { {service.process.slice(0, 5).map((step, index) => {
const [title, desc] = step.split(':'); const [title, desc] = step.split(":");
return ( return (
<motion.div <motion.div
key={index} key={index}
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.3, delay: index * 0.06, ease: EASE_OUT }} transition={{
duration: 0.28,
delay: index * 0.06,
ease: EASE_OUT,
}}
className="relative group p-6 sm:p-8 bg-bg-primary hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 transition-all duration-150" className="relative group p-6 sm:p-8 bg-bg-primary hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 transition-all duration-150"
> >
<div <div
className="w-2 h-2 rounded-full mb-6" className="w-2 h-2 rounded-full mb-6"
style={{ backgroundColor: index === 0 ? '#C41E3A' : 'var(--color-border-primary)' }} style={{
backgroundColor:
index === 0 ? "#C41E3A" : "var(--color-border-primary)",
}}
/> />
<h3 className="text-lg font-bold mb-2 text-ink group-hover:text-brand-ink transition-colors duration-300"> <h3 className="text-lg font-bold mb-2 text-ink group-hover:text-brand-ink transition-colors duration-normal">
{desc ? title : step.slice(0, 6)} {desc ? title : step.slice(0, 6)}
</h3> </h3>
<p className="text-sm leading-relaxed text-text-secondary"> <p className="text-sm leading-relaxed text-text-secondary">
@@ -237,8 +273,8 @@ function CTASection() {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl" className="max-w-3xl"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
@@ -250,10 +286,19 @@ function CTASection() {
首次咨询免费,没有任何销售压力。 首次咨询免费,没有任何销售压力。
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6"> <div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<CTAButton href="/contact" dataTestId="service-detail-bottom-primary" className="min-h-[52px]"> <CTAButton
href="/contact"
dataTestId="service-detail-bottom-primary"
className="min-h-[52px]"
>
{CTA_LABELS.consult} {CTA_LABELS.consult}
</CTAButton> </CTAButton>
<CTAButton href="/services" variant="secondary" dataTestId="service-detail-bottom-secondary" className="min-h-[52px]"> <CTAButton
href="/services"
variant="secondary"
dataTestId="service-detail-bottom-secondary"
className="min-h-[52px]"
>
返回服务列表 返回服务列表
</CTAButton> </CTAButton>
</div> </div>
@@ -263,7 +308,11 @@ function CTASection() {
); );
} }
export default function ServiceDetailContentV4({ service }: { service: Service }) { export default function ServiceDetailContentV4({
service,
}: {
service: Service;
}) {
return ( return (
<div className="min-h-screen bg-bg-primary text-ink"> <div className="min-h-screen bg-bg-primary text-ink">
<DetailHero service={service} /> <DetailHero service={service} />
@@ -271,7 +320,11 @@ export default function ServiceDetailContentV4({ service }: { service: Service }
<FeaturesSection service={service} /> <FeaturesSection service={service} />
<ProcessSection service={service} /> <ProcessSection service={service} />
<L3EmptyFallback caseStudies={service.caseStudies} /> <L3EmptyFallback caseStudies={service.caseStudies} />
<CaseStudiesSection caseStudies={service.caseStudies} id="cases" heading="真实案例,可衡量的成果" /> <CaseStudiesSection
caseStudies={service.caseStudies}
id="cases"
heading="真实案例,可衡量的成果"
/>
<CTASection /> <CTASection />
</div> </div>
); );
@@ -90,8 +90,8 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.2, ease: EASE_OUT }}
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tightest mb-8 sm:mb-10 md:mb-12" className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tighter mb-8 sm:mb-10 md:mb-12"
> >
<div className="block">{heroTitle1}</div> <div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6"> <div className="block mt-4 sm:mt-6">
@@ -103,7 +103,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
style={{ transformOrigin: 'left' }} style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }} initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 1.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 1.2, ease: EASE_OUT }}
/> />
</span> </span>
</span> </span>
@@ -113,7 +113,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12" className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
> >
{heroDescriptionLines.map((line, i) => ( {heroDescriptionLines.map((line, i) => (
@@ -127,7 +127,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.7, ease: EASE_OUT }}
className="flex flex-col sm:flex-row gap-4 sm:gap-6" className="flex flex-col sm:flex-row gap-4 sm:gap-6"
> >
<CTAButton href="/contact" dataTestId="services-hero-primary" className="min-h-[52px]"> <CTAButton href="/contact" dataTestId="services-hero-primary" className="min-h-[52px]">
@@ -182,7 +182,7 @@ function ServicesGridSection({ services, pageCopy }: { services?: Service[]; pag
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24" className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
@@ -200,14 +200,14 @@ function ServicesGridSection({ services, pageCopy }: { services?: Service[]; pag
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.3, delay: i * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: i * 0.06, ease: EASE_OUT }}
> >
<StaticLink <StaticLink
href={`/services/${service.id}`} href={`/services/${service.id}`}
className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-300 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-bg-primary border border-transparent hover:border-border-primary after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-300 group-hover:after:scale-x-100" className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-normal hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-bg-primary border border-transparent hover:border-border-primary after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-normal group-hover:after:scale-x-100"
> >
<div className="relative z-10"> <div className="relative z-10">
<h3 className="text-xl sm:text-2xl font-bold text-ink group-hover:text-brand-ink transition-colors duration-300 mb-3"> <h3 className="text-xl sm:text-2xl font-bold text-ink group-hover:text-brand-ink transition-colors duration-normal mb-3">
{service.title} {service.title}
</h3> </h3>
@@ -236,9 +236,9 @@ function ServicesGridSection({ services, pageCopy }: { services?: Service[]; pag
<MetricsBasisNote basis={weakestBasis(service.metrics.map((m) => m.basis))} className="mb-6" /> <MetricsBasisNote basis={weakestBasis(service.metrics.map((m) => m.basis))} className="mb-6" />
)} )}
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors duration-300"> <div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors duration-normal">
<span>了解详情</span> <span>了解详情</span>
<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-normal" />
</div> </div>
</div> </div>
</StaticLink> </StaticLink>
@@ -260,7 +260,7 @@ function ProcessModesSection() {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="lg:col-span-7" className="lg:col-span-7"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-[0.95] mb-10 sm:mb-14"> <h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-[0.95] mb-10 sm:mb-14">
@@ -293,7 +293,7 @@ function ProcessModesSection() {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.15, ease: EASE_OUT }}
className="lg:col-span-5" className="lg:col-span-5"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-[0.95] mb-10 sm:mb-14"> <h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-[0.95] mb-10 sm:mb-14">
@@ -304,10 +304,10 @@ function ProcessModesSection() {
{SERVICE_MODES.map((mode) => ( {SERVICE_MODES.map((mode) => (
<div <div
key={mode.title} key={mode.title}
className="group relative p-6 sm:p-7 border border-border-primary bg-bg-primary hover:bg-bg-secondary hover:border-brand/20 hover:shadow-md transition-all duration-300" className="group relative p-6 sm:p-7 border border-border-primary bg-bg-primary hover:bg-bg-secondary hover:border-brand/20 hover:shadow-md transition-all duration-normal"
> >
<div className="relative z-10"> <div className="relative z-10">
<h3 className="text-xl font-bold text-ink mb-3 group-hover:text-brand-ink transition-colors duration-300"> <h3 className="text-xl font-bold text-ink mb-3 group-hover:text-brand-ink transition-colors duration-normal">
{mode.title} {mode.title}
</h3> </h3>
<p className="text-text-secondary leading-relaxed text-sm sm:text-[15px] mb-4">{mode.description}</p> <p className="text-text-secondary leading-relaxed text-sm sm:text-[15px] mb-4">{mode.description}</p>
@@ -337,7 +337,7 @@ function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl" className="max-w-3xl"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
@@ -46,7 +46,7 @@ function DetailHero({ solution }: DetailHeroProps) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="flex items-center gap-4 mb-8" className="flex items-center gap-4 mb-8"
> >
<div className="w-16 h-16 rounded-2xl bg-brand-soft flex items-center justify-center text-brand-ink"> <div className="w-16 h-16 rounded-2xl bg-brand-soft flex items-center justify-center text-brand-ink">
@@ -63,7 +63,7 @@ function DetailHero({ solution }: DetailHeroProps) {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 50 }} initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.15, ease: EASE_OUT }}
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-ink leading-tight tracking-tighter mb-6 sm:mb-8 max-w-4xl" className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-ink leading-tight tracking-tighter mb-6 sm:mb-8 max-w-4xl"
> >
{solution.title} {solution.title}
@@ -72,7 +72,7 @@ function DetailHero({ solution }: DetailHeroProps) {
<motion.p <motion.p
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.3, ease: EASE_OUT }}
className="text-xl lg:text-2xl text-text-secondary max-w-3xl leading-relaxed mb-12" className="text-xl lg:text-2xl text-text-secondary max-w-3xl leading-relaxed mb-12"
> >
{solution.description} {solution.description}
@@ -81,7 +81,7 @@ function DetailHero({ solution }: DetailHeroProps) {
<motion.div <motion.div
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.45, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.45, ease: EASE_OUT }}
className="flex flex-wrap gap-6" className="flex flex-wrap gap-6"
> >
<CTAButton href="/contact" dataTestId="solution-detail-hero-primary"> <CTAButton href="/contact" dataTestId="solution-detail-hero-primary">
@@ -118,8 +118,8 @@ function ChallengesSection({ challenges }: ChallengesSectionProps) {
</p> </p>
</ScrollReveal> </ScrollReveal>
<div className="space-y-px bg-border-primary"> <div>
<StaggerReveal staggerDelay={0.05} delayChildren={0.05}> <StaggerReveal staggerDelay={0.05} delayChildren={0.05} className="space-y-px bg-border-primary">
{challenges.map((challenge, idx) => ( {challenges.map((challenge, idx) => (
<div <div
key={idx} key={idx}
@@ -165,8 +165,8 @@ function SolutionsSection({ solutions }: SolutionsSectionProps) {
</h2> </h2>
</ScrollReveal> </ScrollReveal>
<div className="grid md:grid-cols-2 gap-px bg-border-primary"> <div>
<StaggerReveal staggerDelay={0.05} delayChildren={0.05}> <StaggerReveal staggerDelay={0.05} delayChildren={0.05} className="grid md:grid-cols-2 gap-px bg-border-primary">
{solutions.map((solution, idx) => ( {solutions.map((solution, idx) => (
<div <div
key={idx} key={idx}
@@ -220,8 +220,8 @@ function ValuePropositionSection({ valueProposition }: ValuePropositionProps) {
</h2> </h2>
</ScrollReveal> </ScrollReveal>
<div className="grid md:grid-cols-3 gap-px bg-border-primary"> <div>
<StaggerReveal staggerDelay={0.05} delayChildren={0.05}> <StaggerReveal staggerDelay={0.05} delayChildren={0.05} className="grid md:grid-cols-3 gap-px bg-border-primary">
{valueProposition.points.map((point, idx) => { {valueProposition.points.map((point, idx) => {
const icon = VALUE_ICONS[point.icon] || <Settings className="w-6 h-6" />; const icon = VALUE_ICONS[point.icon] || <Settings className="w-6 h-6" />;
return ( return (
@@ -275,8 +275,8 @@ function SuiteCombinationSection({ suiteCombination, products }: SuiteCombinatio
</h2> </h2>
</ScrollReveal> </ScrollReveal>
<div className="grid lg:grid-cols-5 gap-px bg-border-primary mb-px"> <div>
<StaggerReveal staggerDelay={0.05} delayChildren={0.05}> <StaggerReveal staggerDelay={0.05} delayChildren={0.05} className="grid lg:grid-cols-5 gap-px bg-border-primary mb-px">
{primaryProducts.map((product) => product && ( {primaryProducts.map((product) => product && (
<div <div
key={product.id} key={product.id}
@@ -38,8 +38,8 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.2, ease: EASE_OUT }}
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tightest mb-8 sm:mb-10 md:mb-12" className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tighter mb-8 sm:mb-10 md:mb-12"
> >
<div className="block">{heroTitle1}</div> <div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6"> <div className="block mt-4 sm:mt-6">
@@ -51,7 +51,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
style={{ transformOrigin: 'left' }} style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }} initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 1.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 1.2, ease: EASE_OUT }}
/> />
</span> </span>
</span> </span>
@@ -61,7 +61,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12" className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
> >
{heroDescriptionLines.map((line, i) => ( {heroDescriptionLines.map((line, i) => (
@@ -75,7 +75,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.7, ease: EASE_OUT }}
className="flex flex-col sm:flex-row gap-4 sm:gap-6" className="flex flex-col sm:flex-row gap-4 sm:gap-6"
> >
<CTAButton href="/contact" dataTestId="solutions-hero-primary" className="min-h-[52px]"> <CTAButton href="/contact" dataTestId="solutions-hero-primary" className="min-h-[52px]">
@@ -106,11 +106,11 @@ function SolutionCard({ solution, index, products, featured = false }: { solutio
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.3, delay: index * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: index * 0.06, ease: EASE_OUT }}
> >
<StaticLink <StaticLink
href={`/solutions/${solution.id}`} href={`/solutions/${solution.id}`}
className="group relative block transition-all duration-300 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-bg-elevated border border-border-primary hover:border-brand/20 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-300 group-hover:after:scale-x-100" className="group relative block transition-all duration-normal hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-bg-elevated border border-border-primary hover:border-brand/20 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-normal group-hover:after:scale-x-100"
> >
<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">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@@ -150,9 +150,9 @@ function SolutionCard({ solution, index, products, featured = false }: { solutio
</div> </div>
</div> </div>
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors duration-300 mt-6"> <div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors duration-normal mt-6">
<span>了解详情</span> <span>了解详情</span>
<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-normal" />
</div> </div>
</div> </div>
</StaticLink> </StaticLink>
@@ -173,7 +173,7 @@ function SolutionsGridSection({ solutions, products, pageCopy }: { solutions: So
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24" className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
@@ -204,7 +204,7 @@ function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="max-w-3xl" className="max-w-3xl"
> >
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8"> <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
+12 -13
View File
@@ -70,7 +70,7 @@ function StrengthCard({ strength, index }: { strength: StrengthData; index: numb
initial={shouldReduceMotion ? {} : { opacity: 0, y: 30 }} initial={shouldReduceMotion ? {} : { opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: index * 0.06, ease: EASE_OUT }}
whileHover={{ y: -6 }} whileHover={{ y: -6 }}
> >
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand scale-y-0 group-hover:scale-y-100 transition-transform duration-150 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-150 origin-top" />
@@ -80,9 +80,8 @@ function StrengthCard({ strength, index }: { strength: StrengthData; index: numb
<div className="w-12 h-12 flex items-center justify-center border border-brand/30 bg-brand/[0.08] text-brand-ink"> <div className="w-12 h-12 flex items-center justify-center border border-brand/30 bg-brand/[0.08] text-brand-ink">
<Icon className="w-5 h-5" /> <Icon className="w-5 h-5" />
</div> </div>
{/* 纯装饰序号:与右侧 h3 标题并列,无独立语义。 {/* 装饰序号:aria-hidden 不豁免 WCAG 1.4.3(axe 按可见文字判定),改用大字档令牌 text-text-hint */}
aria-hidden 让 WCAG 1.4.3 对比度要求不适用于装饰内容(1.13:1 无需强行提亮) */} <span aria-hidden="true" className="text-4xl font-bold text-text-hint font-mono">{strength.number}</span>
<span aria-hidden="true" className="text-4xl font-bold text-text-muted/10 font-mono">{strength.number}</span>
</div> </div>
<h3 className="text-xl font-bold text-ink mb-3">{strength.title}</h3> <h3 className="text-xl font-bold text-ink mb-3">{strength.title}</h3>
@@ -105,7 +104,7 @@ function CultureCard({ culture, index }: { culture: CultureData; index: number }
initial={shouldReduceMotion ? {} : { opacity: 0, y: 30 }} initial={shouldReduceMotion ? {} : { opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: index * 0.06, ease: EASE_OUT }}
whileHover={{ y: -8 }} whileHover={{ y: -8 }}
> >
<div className="absolute top-0 left-0 right-0 h-1 bg-brand scale-x-0 group-hover:scale-x-100 transition-transform duration-150 origin-left" /> <div className="absolute top-0 left-0 right-0 h-1 bg-brand scale-x-0 group-hover:scale-x-100 transition-transform duration-150 origin-left" />
@@ -115,7 +114,7 @@ function CultureCard({ culture, index }: { culture: CultureData; index: number }
<Icon className="w-7 h-7" /> <Icon className="w-7 h-7" />
</div> </div>
<span aria-hidden="true" className="block text-6xl font-bold text-text-muted/10 font-mono mb-4">{culture.number}</span> <span aria-hidden="true" className="block text-6xl font-bold text-text-hint font-mono mb-4">{culture.number}</span>
<h3 className="text-2xl font-bold text-ink mb-4">{culture.title}</h3> <h3 className="text-2xl font-bold text-ink mb-4">{culture.title}</h3>
<p className="text-text-secondary leading-relaxed">{culture.description}</p> <p className="text-text-secondary leading-relaxed">{culture.description}</p>
@@ -149,7 +148,7 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.1, ease: EASE_OUT }}
className="mb-8 sm:mb-10" className="mb-8 sm:mb-10"
> >
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full"> <span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full">
@@ -164,8 +163,8 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 40 }} initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.92] tracking-tightest mb-8 sm:mb-10" className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.92] tracking-tighter mb-8 sm:mb-10"
> >
{(teamData.heroTitle || '').split('\n').map((line, i) => ( {(teamData.heroTitle || '').split('\n').map((line, i) => (
<span key={i}> <span key={i}>
@@ -180,7 +179,7 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.4, ease: EASE_OUT }}
className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10 sm:mb-12" className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10 sm:mb-12"
> >
{stats.map((stat, i) => ( {stats.map((stat, i) => (
@@ -201,7 +200,7 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/80 max-w-2xl leading-relaxed mb-12 sm:mb-14" className="text-lg sm:text-xl text-text-secondary/80 max-w-2xl leading-relaxed mb-12 sm:mb-14"
> >
{teamData.heroDescription} {teamData.heroDescription}
@@ -212,7 +211,7 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.6, ease: EASE_OUT }} transition={{ duration: 0.28, delay: 0.6, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6" className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6"
> >
<CTAButton href={teamData.heroPrimaryCtaHref || '/contact'}> <CTAButton href={teamData.heroPrimaryCtaHref || '/contact'}>
@@ -331,7 +330,7 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10"> <div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl"> <ScrollReveal className="max-w-3xl">
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl xl:text-7xl font-black text-dark-text-primary tracking-tightest leading-[0.9] mb-8 sm:mb-10"> <h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl xl:text-7xl font-black text-dark-text-primary tracking-tighter leading-[0.9] mb-8 sm:mb-10">
{teamData.ctaTitle || '成为下一个'} {teamData.ctaTitle || '成为下一个'}
<br /> <br />
<span className="text-brand-ink">数据驱动型企业</span> <span className="text-brand-ink">数据驱动型企业</span>
+1 -1
View File
@@ -12,7 +12,7 @@ const pageVariants: Variants = {
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { transition: {
duration: 0.3, duration: 0.28,
ease: [0.16, 1, 0.3, 1] as [number, number, number, number], ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
}, },
}, },
@@ -0,0 +1,23 @@
import { describe, it, expect, jest } from '@jest/globals';
/**
* S-3 回归:/about、/contact、/products/erp-upgrade 三页曾无任何缓存失效通道
* (无 revalidate 导出、revalidate 路由也无映射),构建后发布的 CMS 内容永不刷新。
* 这里钉死三页的 ISR 窗口与其余营销页(3600s)同档。
*/
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(async () => []),
getProductBySlug: jest.fn(async () => null),
}));
describe.each([
['about', '@/app/(marketing)/about/page'],
['contact', '@/app/(marketing)/contact/page'],
['erp-upgrade', '@/app/(marketing)/products/erp-upgrade/page'],
])('%s 页导出 ISR revalidate', (_name, modulePath) => {
it('revalidate = 3600,与全站营销页一致', () => {
const mod = jest.requireActual(modulePath) as { revalidate?: number | false };
expect(mod.revalidate).toBe(3600);
});
});
@@ -0,0 +1,616 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom';
/**
* 内容编辑器「保存 / 发布」链路的回归测试(B-1)。
*
* 回归背景:编辑器曾在每次保存时回传 status,而后端 PUT /api/admin/items 对状态变更
* 一律 400(src/app/api/admin/items/route.ts:224),导致编辑器每次保存都失败、
* 发布按钮永远不可用。此文件钉死三件事:
* 1. 保存载荷(POST 新建 / PUT 更新)永远不含 status 键;
* 2. 状态变更只经 adminApi.runWorkflow 通道,且必须先保存成功;
* 3. 状态区为只读展示,表单里没有改状态的控件。
*/
// jest.Mock 的返回值必须是 Promise 形态,否则 mockResolvedValue 的参数会被解析成 never
type Fn = jest.Mock<(...args: unknown[]) => Promise<unknown>>;
interface ApiMock {
getModels: Fn;
getItems: Fn;
createItem: Fn;
updateItem: Fn;
runWorkflow: Fn;
}
interface ToastMock {
success: Fn;
error: Fn;
info: Fn;
dismiss: Fn;
}
interface NavMock {
state: { params: Record<string, string> };
push: Fn;
}
interface AuthMock {
state: { user: Record<string, string> | null; loading: boolean };
}
// next/navigation:App Router 动态段与路由跳转在此打桩(factory 内建 mock,避免 hoist TDZ)
// router 对象必须稳定:page.tsx 的 load effect 以 router 为依赖,每次返回新对象会让 load() 反复重跑,
// 从而把 workflow 刚写入的 status 覆盖回条目原状态(真实 Next router 是 context 单例,身份稳定)。
jest.mock('next/navigation', () => {
const push = jest.fn();
const router = { push, prefetch: jest.fn() };
const state = { params: { modelCode: 'news', itemId: 'new' } as Record<string, string> };
return { state, push, useRouter: () => router, useParams: () => state.params };
});
jest.mock('@/components/admin/auth-context', () => {
const state = {
user: { id: 'u1', username: 'admin', nickname: '管理员', role: 'superadmin', avatar: '' },
loading: false,
};
return { state, useAuth: () => ({ user: state.user, loading: state.loading, token: 't', login: jest.fn(), logout: jest.fn() }) };
});
// 只 mock 客户端方法层:断言「调了哪个方法、传了什么」,不碰 fetch / 加密细节
jest.mock('@/lib/admin-api', () => ({
adminApi: {
getModels: jest.fn(),
getItems: jest.fn(),
createItem: jest.fn(),
updateItem: jest.fn(),
runWorkflow: jest.fn(),
},
}));
jest.mock('@/components/ui/sonner', () => ({
Toaster: () => null,
toast: { success: jest.fn(), error: jest.fn(), info: jest.fn(), dismiss: jest.fn() },
}));
import ContentEditorPage from './page';
const nav = jest.requireMock('next/navigation') as NavMock;
const auth = jest.requireMock('@/components/admin/auth-context') as AuthMock;
const api = (jest.requireMock('@/lib/admin-api') as { adminApi: ApiMock }).adminApi;
const toastMock = (jest.requireMock('@/components/ui/sonner') as { toast: ToastMock }).toast;
const MODEL = {
id: 'model-news',
code: 'news',
name: '新闻',
// 不设 required,避免每个用例都要填满字段才能触发保存
fields: [{ name: 'summary', type: 'text', label: '摘要' }],
};
function itemFixture(overrides: Record<string, unknown> = {}) {
return {
id: 'item-1',
title: '原标题',
slug: 'old-news',
status: 'draft',
data: { summary: '原摘要' },
...overrides,
};
}
function itemsResult(items: unknown[]) {
return { items, total: items.length, page: 1, pageSize: 100, totalPages: 1 };
}
/** 渲染并等待 load() 完成(loading 门禁期间表单不存在) */
async function renderEditor(params: { modelCode: string; itemId: string }) {
nav.state.params = params;
const utils = render(<ContentEditorPage />);
await screen.findByPlaceholderText('请输入标题');
return utils;
}
/** page.tsx 的 AUTO_SAVE_DELAY 为 3000ms(真实时长,本仓库单测不使用假定时器)。
* 跨过这个窗口才能断言「自动保存到底有没有发出请求」。 */
const AUTOSAVE_WINDOW = 3500;
async function waitMs(ms: number) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, ms));
});
}
function requireCall(mock: Fn, label: string): unknown[] {
const call = mock.mock.calls[0];
if (!call) throw new Error(`${label} 未被调用`);
return call;
}
function payloadOf(mock: Fn, argIndex = 0, label = 'api'): Record<string, unknown> {
const call = requireCall(mock, label);
return call[argIndex] as Record<string, unknown>;
}
function callsOf(mock: Fn): unknown[][] {
return mock.mock.calls as unknown[][];
}
/** 同名按钮常有页眉/页脚两份(如「保存」「创建」),点第一份即可,两者回调相同。
* 保存/发布回调内部要 await 多个请求,故在 act 作用域里让微任务链走完,否则 React 报未包裹 act */
async function clickButton(name: string, index = 0) {
const buttons = screen.getAllByRole('button', { name });
await act(async () => {
fireEvent.click(buttons[index] as HTMLElement);
});
}
async function typeText(placeholder: string, value: string) {
await changeInput(screen.getByPlaceholderText(placeholder), value);
}
async function changeInput(el: HTMLElement, value: string) {
await act(async () => {
fireEvent.change(el, { target: { value } });
});
}
async function openPublishDialog() {
await clickButton('发布');
const confirmBtn = await screen.findByRole('button', { name: '确认发布' });
await act(async () => {
fireEvent.click(confirmBtn);
});
}
// 每组用例都重建整套默认实现(clearAllMocks 不清实现),避免依赖上一组的残留
function resetMocks() {
jest.clearAllMocks();
auth.state.user = { id: 'u1', username: 'admin', nickname: '管理员', role: 'superadmin', avatar: '' };
auth.state.loading = false;
api.getModels.mockResolvedValue([MODEL]);
api.getItems.mockResolvedValue(itemsResult([itemFixture()]));
api.createItem.mockResolvedValue({ id: 'created-1' });
api.updateItem.mockResolvedValue({ id: 'item-1' });
api.runWorkflow.mockResolvedValue({ status: 'review' });
}
describe('内容编辑器:保存载荷不得携带 status(B-1 回归)', () => {
beforeEach(resetMocks);
it('新建保存走 createItem,且 POST 载荷不含 status 键', async () => {
await renderEditor({ modelCode: 'news', itemId: 'new' });
await typeText('请输入标题', '新条目');
await changeInput(screen.getByLabelText('摘要'), '新摘要');
await clickButton('创建');
await waitFor(() => expect(api.createItem).toHaveBeenCalledTimes(1));
const payload = payloadOf(api.createItem, 0, 'createItem');
expect(payload).toEqual(
expect.objectContaining({
modelId: 'model-news',
modelCode: 'news',
title: '新条目',
data: { summary: '新摘要' },
}),
);
// 后端 POST 只接受 status 缺省或 'draft'(items/route.ts:138),回传其它值直接 400
expect(payload).not.toHaveProperty('status');
expect(api.updateItem).not.toHaveBeenCalled();
await waitFor(() => expect(nav.push).toHaveBeenCalledWith('/admin/content/news'));
});
it('编辑保存走 updateItem,且 PUT 载荷不含 status 键', async () => {
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
await typeText('请输入标题', '改过的标题');
await clickButton('保存');
await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1));
const call = requireCall(api.updateItem, 'updateItem');
expect(call[0]).toBe('item-1');
const payload = call[1] as Record<string, unknown>;
expect(payload).toEqual(
expect.objectContaining({
title: '改过的标题',
data: { summary: '原摘要' },
}),
);
// 此处正是缺陷现场:PUT 拒绝任何状态变更(items/route.ts:224),带上 status 即 400
expect(payload).not.toHaveProperty('status');
expect(api.createItem).not.toHaveBeenCalled();
// 「保存」不导航
expect(nav.push).not.toHaveBeenCalled();
});
it('保存失败时抛出后端错误文案,且不触达 workflow', async () => {
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
api.updateItem.mockRejectedValue(new Error('状态变更请使用 /api/admin/items/[id]/workflow 接口'));
await openPublishDialog();
await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1));
expect(payloadOf(api.updateItem, 1, 'updateItem')).not.toHaveProperty('status');
expect(api.runWorkflow).not.toHaveBeenCalled();
expect(toastMock.error).toHaveBeenCalledWith('状态变更请使用 /api/admin/items/[id]/workflow 接口');
// 保存未成功 → 状态停留在草稿
expect(screen.getByText('草稿')).toBeInTheDocument();
});
});
describe('内容编辑器:状态流转只经 runWorkflow 通道', () => {
beforeEach(resetMocks);
it('draft 项点发布:内容先落库,再 submit 后 approve,UI 落到已发布', async () => {
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
api.runWorkflow
.mockResolvedValueOnce({ status: 'review' })
.mockResolvedValueOnce({ status: 'published' });
await openPublishDialog();
await waitFor(() => expect(screen.getByText('已发布')).toBeInTheDocument());
// 保存先于状态流转
expect(api.updateItem).toHaveBeenCalledTimes(1);
expect(callsOf(api.runWorkflow)).toEqual([
['item-1', 'submit'],
['item-1', 'approve'],
]);
expect(toastMock.success).toHaveBeenCalledWith('新闻资讯已发布');
// 已发布后发布按钮消失
expect(screen.queryByRole('button', { name: '发布' })).toBeNull();
});
it('review 项点发布:跳过 submit,只 approve', async () => {
api.getItems.mockResolvedValue(itemsResult([itemFixture({ status: 'review' })]));
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
expect(screen.getByText('待审核')).toBeInTheDocument();
api.runWorkflow.mockResolvedValue({ status: 'published' });
await openPublishDialog();
await waitFor(() => expect(screen.getByText('已发布')).toBeInTheDocument());
expect(callsOf(api.runWorkflow)).toEqual([['item-1', 'approve']]);
});
it('approve 被拒(403 无 publish 权限):submit 已生效,approve 失败只提示不改状态', async () => {
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
api.runWorkflow
.mockResolvedValueOnce({ status: 'review' })
.mockRejectedValueOnce(new Error('无权限执行该操作'));
await openPublishDialog();
await waitFor(() => expect(callsOf(api.runWorkflow)).toHaveLength(2));
expect(callsOf(api.runWorkflow)).toEqual([
['item-1', 'submit'],
['item-1', 'approve'],
]);
expect(toastMock.error).toHaveBeenCalledWith('无权限执行该操作');
expect(toastMock.success).not.toHaveBeenCalled();
// submit 已在服务端把条目推进到 review,approve 失败不回滚:UI 必须跟随服务端状态显示「待审核」,
// 否则编辑者会以为还是草稿而重复提交。
expect(screen.getByText('待审核')).toBeInTheDocument();
expect(screen.queryByText('草稿')).not.toBeInTheDocument();
});
it('新建项发布:workflow 用 createItem 返回的 id,发布成功回到列表', async () => {
await renderEditor({ modelCode: 'news', itemId: 'new' });
api.createItem.mockResolvedValue({ id: 'created-9' });
api.runWorkflow
.mockResolvedValueOnce({ status: 'review' })
.mockResolvedValueOnce({ status: 'published' });
await typeText('请输入标题', '新条目');
await openPublishDialog();
await waitFor(() => expect(callsOf(api.runWorkflow)).toHaveLength(2));
expect(payloadOf(api.createItem, 0, 'createItem')).not.toHaveProperty('status');
expect(callsOf(api.runWorkflow)).toEqual([
['created-9', 'submit'],
['created-9', 'approve'],
]);
// 新建路径不得走 PUT
expect(api.updateItem).not.toHaveBeenCalled();
await waitFor(() => expect(nav.push).toHaveBeenCalledWith('/admin/content/news'));
});
it('新建但未取回 id:不发 workflow,提示去列表页重开', async () => {
await renderEditor({ modelCode: 'news', itemId: 'new' });
api.createItem.mockResolvedValue({});
await typeText('请输入标题', '新条目');
await openPublishDialog();
await waitFor(() => expect(api.createItem).toHaveBeenCalledTimes(1));
expect(api.runWorkflow).not.toHaveBeenCalled();
expect(toastMock.error).toHaveBeenCalledWith('创建成功但未取回条目 ID,请在列表页重新打开后发布');
expect(nav.push).not.toHaveBeenCalled();
});
it('标题为空时不打开发布对话框,也不保存', async () => {
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
await typeText('请输入标题', '');
await clickButton('发布');
await waitFor(() => expect(toastMock.error).toHaveBeenCalledWith('请修正表单中的错误后再发布'));
expect(screen.queryByRole('button', { name: '确认发布' })).toBeNull();
expect(api.updateItem).not.toHaveBeenCalled();
expect(api.runWorkflow).not.toHaveBeenCalled();
});
});
describe('内容编辑器:状态区只读', () => {
beforeEach(resetMocks);
it('渲染当前状态文案,且表单内不存在任何 select / combobox', async () => {
const { container } = await renderEditor({ modelCode: 'news', itemId: 'item-1' });
expect(screen.getByText('草稿')).toBeInTheDocument();
expect(container.querySelector('select')).toBeNull();
expect(screen.queryByRole('combobox')).toBeNull();
// 状态标签是段落而非 label,说明它不指向任何可交互控件
expect(screen.getByText('状态').tagName).toBe('P');
expect(screen.getByText('状态通过「发布」提交审核变更,不可在此直接修改')).toBeInTheDocument();
});
it('四态文案与 ContentStatus 取值一致,且任一状态下都没有改状态的控件', async () => {
nav.state.params = { modelCode: 'news', itemId: 'item-1' };
const cases: Array<[string, string]> = [
['draft', '草稿'],
['review', '待审核'],
['published', '已发布'],
['archived', '已归档'],
];
for (const [status, label] of cases) {
api.getItems.mockResolvedValue(itemsResult([itemFixture({ status })]));
const { container, unmount } = render(<ContentEditorPage />);
await screen.findByPlaceholderText('请输入标题');
expect(screen.getByText(label)).toBeInTheDocument();
expect(container.querySelector('select')).toBeNull();
// 逐轮卸载,避免同名文案在多份渲染结果里重复命中
unmount();
}
});
});
describe('内容编辑器:条目定位不得依赖列表分页窗口', () => {
beforeEach(resetMocks);
it('分页窗口之外的条目(第 101 条起)按 itemId 精确取回并回填表单', async () => {
// 真实服务端语义:不带 id 的列表请求只回一页数据,目标条目可能不在其中;
// 带 id 的请求才保证回目标条目本身。
api.getItems.mockImplementation((...args: unknown[]) => {
const params = args[0] as Record<string, unknown>;
if (params.id === 'item-101') return Promise.resolve(itemsResult([itemFixture({ id: 'item-101' })]));
return Promise.resolve(itemsResult([]));
});
await renderEditor({ modelCode: 'news', itemId: 'item-101' });
expect(screen.getByPlaceholderText('请输入标题')).toHaveValue('原标题');
expect(screen.getByLabelText('摘要')).toHaveValue('原摘要');
});
it('按 itemId 取不到条目时给出未找到提示,且不渲染任何保存入口', async () => {
api.getItems.mockResolvedValue(itemsResult([]));
nav.state.params = { modelCode: 'news', itemId: 'deleted-item' };
render(<ContentEditorPage />);
expect(await screen.findByText(/条目未找到/)).toBeInTheDocument();
// 空表单一旦可保存,PUT 就会用 { title, data: {} } 覆盖真实记录
expect(screen.queryByPlaceholderText('请输入标题')).toBeNull();
expect(screen.queryByRole('button', { name: '保存并返回' })).toBeNull();
expect(api.updateItem).not.toHaveBeenCalled();
});
});
describe('内容编辑器:自动保存的触发时机与并发顺序', () => {
beforeEach(resetMocks);
it('只打开已有条目、未做任何编辑:不标记未保存,跨过自动保存窗口也不发 PUT', async () => {
nav.state.params = { modelCode: 'news', itemId: 'item-1' };
render(<ContentEditorPage />);
await screen.findByPlaceholderText('请输入标题');
expect(screen.getByPlaceholderText('请输入标题')).toHaveValue('原标题');
expect(screen.queryByText('未保存')).toBeNull();
await waitMs(AUTOSAVE_WINDOW);
// 幻影保存会把 version 加一(items/route.ts:240)并覆盖并发编辑者的写入
expect(api.updateItem).not.toHaveBeenCalled();
});
it('上一次保存仍在飞行中:下一次自动保存排队等待,且排队的是最新快照', async () => {
let releaseFirst: ((value: unknown) => void) | null = null;
api.updateItem.mockImplementation(
() =>
new Promise((resolve) => {
releaseFirst = resolve;
}),
);
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
await changeInput(screen.getByPlaceholderText('请输入标题'), '第一版');
await waitMs(AUTOSAVE_WINDOW);
expect(callsOf(api.updateItem)).toHaveLength(1);
expect(payloadOf(api.updateItem, 1, 'updateItem')).toEqual(
expect.objectContaining({ title: '第一版' }),
);
await changeInput(screen.getByPlaceholderText('请输入标题'), '第二版');
await waitMs(AUTOSAVE_WINDOW);
// 修复前:自动保存不看在飞请求就再发一次,两个 PUT 在服务端的落库顺序不确定,
// 旧快照可能覆盖新快照
expect(callsOf(api.updateItem)).toHaveLength(1);
await act(async () => {
(releaseFirst as (value: unknown) => void)({ id: 'item-1' });
});
await waitFor(() => expect(callsOf(api.updateItem)).toHaveLength(2));
// 排队不能丢更新:第二次的载荷必须是最新快照
expect(callsOf(api.updateItem)[1]?.[1]).toEqual(
expect.objectContaining({ title: '第二版' }),
);
});
});
describe('内容编辑器:richtext / array / object 字段的读写保真(S-21/S-22 回归)', () => {
const RICH_MODEL = {
id: 'model-rich',
code: 'news',
name: '新闻',
fields: [
{ name: 'content', type: 'richtext', label: '正文内容' },
{
name: 'metrics',
type: 'array',
label: '指标',
fields: [
{ name: 'label', type: 'text', label: '标签' },
{ name: 'value', type: 'text', label: '数值' },
],
},
{
name: 'testimonial',
type: 'object',
label: '客户证言',
fields: [
{ name: 'quote', type: 'textarea', label: '证言内容' },
{ name: 'author', type: 'text', label: '作者' },
],
},
],
};
const richItem = {
id: 'item-1',
title: '原标题',
slug: 'rich-news',
status: 'draft',
data: {
content: '第一段\n第二段',
metrics: [{ label: '上线周期', value: '6周' }],
testimonial: { quote: '非常专业', author: '张三' },
},
};
beforeEach(() => {
resetMocks();
api.getModels.mockResolvedValue([RICH_MODEL]);
api.getItems.mockResolvedValue(itemsResult([richItem]));
});
it('富文本渲染为 textarea:编辑不丢换行,保存载荷保留完整段落', async () => {
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
const box = screen.getByLabelText('正文内容');
expect(box.tagName).toBe('TEXTAREA');
expect(box).toHaveValue('第一段\n第二段');
await changeInput(box, '第一段\n第二段\n第三段');
await clickButton('保存');
await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1));
expect(payloadOf(api.updateItem, 1, 'updateItem')).toEqual(
expect.objectContaining({ data: expect.objectContaining({ content: '第一段\n第二段\n第三段' }) }),
);
});
it('array 字段编辑回写数组本身:不产生顶层 label/value 垃圾键', async () => {
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
const box = screen.getByLabelText('指标');
expect(box.tagName).toBe('TEXTAREA');
expect(box).toHaveValue(JSON.stringify(richItem.data.metrics, null, 2));
await changeInput(box, JSON.stringify([{ label: '上线周期', value: '5天' }]));
await clickButton('保存');
await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1));
const data = (payloadOf(api.updateItem, 1, 'updateItem').data as Record<string, unknown>);
expect(data.metrics).toEqual([{ label: '上线周期', value: '5天' }]);
// 修复前:子字段 onChange 写的是顶层键,保存载荷被污染出 label/value
expect(data).not.toHaveProperty('label');
expect(data).not.toHaveProperty('value');
});
it('object 子字段读写父对象:编辑作者只改 testimonial.author,不泄漏顶层键', async () => {
await renderEditor({ modelCode: 'news', itemId: 'item-1' });
// 修复前:子字段读的是顶层 formData(undefined),输入框是空的
expect(screen.getByLabelText('作者')).toHaveValue('张三');
expect(screen.getByLabelText('证言内容')).toHaveValue('非常专业');
await changeInput(screen.getByLabelText('作者'), '李四');
await clickButton('保存');
await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1));
const data = (payloadOf(api.updateItem, 1, 'updateItem').data as Record<string, unknown>);
expect(data.testimonial).toEqual({ quote: '非常专业', author: '李四' });
expect(data).not.toHaveProperty('author');
expect(data).not.toHaveProperty('quote');
});
});
describe('内容编辑器:加载失败时不得出现任何写入口(与「条目未找到」同型的数据丢失面)', () => {
beforeEach(resetMocks);
/** 三处写入口(页眉/页脚)必须全部缺席:只要有一份渲染出来,用户就能对
* 一条从未成功加载的记录发起 PUT/POST。 */
function expectNoWriteAffordances() {
expect(screen.queryByRole('button', { name: '保存' })).toBeNull();
expect(screen.queryByRole('button', { name: '保存并返回' })).toBeNull();
expect(screen.queryByRole('button', { name: '发布' })).toBeNull();
expect(screen.queryByRole('button', { name: '创建' })).toBeNull();
}
it('模型查找失败(回包里没有当前 modelCode):保留「模型未找到」提示,但无任何保存/发布入口,不发 PUT/POST', async () => {
api.getModels.mockResolvedValue([{ ...MODEL, code: 'other-model' }]);
nav.state.params = { modelCode: 'news', itemId: 'item-1' };
render(<ContentEditorPage />);
expect(await screen.findByText('模型未找到')).toBeInTheDocument();
expectNoWriteAffordances();
expect(api.updateItem).not.toHaveBeenCalled();
expect(api.createItem).not.toHaveBeenCalled();
});
it('getModels 网络/5xx 错误(reject):错误文案照常展示,同样无任何写入口', async () => {
api.getModels.mockRejectedValue(new Error('请求失败 (500)'));
nav.state.params = { modelCode: 'news', itemId: 'item-1' };
render(<ContentEditorPage />);
expect(await screen.findByText('请求失败 (500)')).toBeInTheDocument();
expectNoWriteAffordances();
expect(api.updateItem).not.toHaveBeenCalled();
expect(api.createItem).not.toHaveBeenCalled();
});
it('getItems 网络错误:条目从未加载成功,跨过自动保存窗口后 PUT 仍为零,且不渲染写入口', async () => {
api.getItems.mockRejectedValue(new Error('网络异常'));
nav.state.params = { modelCode: 'news', itemId: 'item-1' };
render(<ContentEditorPage />);
expect(await screen.findByText('网络异常')).toBeInTheDocument();
expectNoWriteAffordances();
await waitMs(AUTOSAVE_WINDOW);
expect(api.updateItem).not.toHaveBeenCalled();
});
it('新建路径模型加载失败:无「创建」入口,不发 POST', async () => {
api.getModels.mockResolvedValue([]);
nav.state.params = { modelCode: 'news', itemId: 'new' };
render(<ContentEditorPage />);
expect(await screen.findByText('模型未找到')).toBeInTheDocument();
expectNoWriteAffordances();
expect(api.createItem).not.toHaveBeenCalled();
expect(api.updateItem).not.toHaveBeenCalled();
});
});
@@ -59,6 +59,11 @@ interface ModelData {
// 自动保存延迟(毫秒) // 自动保存延迟(毫秒)
const AUTO_SAVE_DELAY = 3000; const AUTO_SAVE_DELAY = 3000;
// 「已落库内容」的可比较形态:自动保存只应在用户真的改过内容后触发
function snapshotOf(title: string, slug: string, data: Record<string, unknown>): string {
return JSON.stringify([title, slug, data]);
}
function JsonEditor({ function JsonEditor({
id, id,
value, value,
@@ -126,6 +131,14 @@ function FieldError({ error }: { error?: string }) {
); );
} }
// 与 src/lib/cms/types.ts:81 ContentStatus 四态一致
const STATUS_LABELS: Record<string, string> = {
draft: '草稿',
review: '待审核',
published: '已发布',
archived: '已归档',
};
export default function ContentEditorPage() { export default function ContentEditorPage() {
const params = useParams(); const params = useParams();
const modelCode = params.modelCode as string; const modelCode = params.modelCode as string;
@@ -142,14 +155,23 @@ export default function ContentEditorPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [itemNotFound, setItemNotFound] = useState(false);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// 唯一的写入门禁:load() 是否成功取回可编辑对象——新建=模型定义,编辑=条目本身
//(落库快照已写入 persistedSnapshotRef)。模型未找到、网络/4xx/5xx、条目缺失一律为 false。
// 所有保存/发布/workflow/自动保存入口的渲染与触发都过这一个条件,不再逐分支补 if。
const [canEdit, setCanEdit] = useState(false);
// 自动保存状态 // 自动保存状态
const [lastSaved, setLastSaved] = useState<Date | null>(null); const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'unsaved'>('idle'); const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'unsaved'>('idle');
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isInitialMountRef = useRef(true);
const hasUnsavedChangesRef = useRef(false); const hasUnsavedChangesRef = useRef(false);
// null = load() 尚未把条目取回(含未找到),此时自动保存保持解除武装;
// 之后它是「最近一次落库内容」的快照,与当前表单不等才视为脏。
const persistedSnapshotRef = useRef<string | null>(null);
// 保存串行链:上一次请求落定前不发下一次,避免旧快照晚于新快照落库
const saveChainRef = useRef<Promise<unknown>>(Promise.resolve());
// 发布确认对话框 // 发布确认对话框
const [publishDialogOpen, setPublishDialogOpen] = useState(false); const [publishDialogOpen, setPublishDialogOpen] = useState(false);
@@ -180,46 +202,65 @@ export default function ContentEditorPage() {
return errors; return errors;
}, [title, slug, formData, model]); }, [title, slug, formData, model]);
// 实际保存操作 // 实际保存操作。状态不在此处变更:POST 只接受草稿、PUT 拒绝改 status(B-1),
const performSave = useCallback(async (newStatus?: string): Promise<boolean> => { // 发布走 handlePublishConfirm → adminApi.runWorkflow。返回新建项的 id 供发布链使用。
const targetStatus = newStatus ?? status; // 入参是发起保存那一刻的快照,而不是执行时的 state:排队等待期间用户可能又改了内容。
setSaving(true); const submitSnapshot = useCallback(
setError(''); async (snapshot: {
title: string;
try { slug: string;
if (isNew) { data: Record<string, unknown>;
await adminApi.createItem({ }): Promise<{ ok: boolean; id?: string }> => {
modelId: model?.id, try {
modelCode, if (isNew) {
title: title.trim(), const created = (await adminApi.createItem({
slug: slug.trim() || undefined, modelId: model?.id,
status: targetStatus, modelCode,
data: formData, title: snapshot.title,
}); slug: snapshot.slug || undefined,
// 新建完成后不设置 autoSaveStatus,因为会导航离开 data: snapshot.data,
return true; })) as { id?: string };
} else { // 新建完成后不设置 autoSaveStatus,因为会导航离开
return { ok: true, id: created?.id };
}
await adminApi.updateItem(itemId, { await adminApi.updateItem(itemId, {
title: title.trim(), title: snapshot.title,
slug: slug.trim() || undefined, slug: snapshot.slug || undefined,
status: targetStatus, data: snapshot.data,
data: formData,
}); });
persistedSnapshotRef.current = snapshotOf(snapshot.title, snapshot.slug, snapshot.data);
setLastSaved(new Date()); setLastSaved(new Date());
setAutoSaveStatus('saved'); setAutoSaveStatus('saved');
hasUnsavedChangesRef.current = false; hasUnsavedChangesRef.current = false;
return true; return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : '保存失败';
setError(message);
toast.error(message);
setAutoSaveStatus('unsaved');
return { ok: false };
} finally {
setSaving(false);
} }
} catch (err) { },
const message = err instanceof Error ? err.message : '保存失败'; [isNew, itemId, model, modelCode],
setError(message); );
toast.error(message);
setAutoSaveStatus('unsaved'); const performSave = useCallback((): Promise<{ ok: boolean; id?: string }> => {
return false; // 触发侧兜底:手动保存、自动保存、发布前的落库全部汇入这里,canEdit 为 false 时
} finally { // 写入口既不会渲染(见 JSX 门禁)也绝不会发出请求。
setSaving(false); if (!canEdit) return Promise.resolve({ ok: false });
} const snapshot = { title: title.trim(), slug: slug.trim(), data: formData };
}, [isNew, itemId, model, modelCode, title, slug, status, formData]); setSaving(true);
setError('');
const run = saveChainRef.current.then(
() => submitSnapshot(snapshot),
() => submitSnapshot(snapshot),
);
// 链尾吞掉异常,保证下一次保存仍排在链上
saveChainRef.current = run.catch(() => undefined);
return run;
}, [canEdit, title, slug, formData, submitSnapshot]);
// 自动保存(仅编辑已有内容时) // 自动保存(仅编辑已有内容时)
const triggerAutoSave = useCallback(() => { const triggerAutoSave = useCallback(() => {
@@ -241,13 +282,21 @@ export default function ContentEditorPage() {
}, AUTO_SAVE_DELAY); }, AUTO_SAVE_DELAY);
}, [isNew, validate, performSave]); }, [isNew, validate, performSave]);
// 标题/Slug/表单数据变更时触发自动保存 // 标题/Slug/表单数据变更时触发自动保存。
// 判脏的基准是「最近一次落库的快照」而不是「是否首次挂载」:load() 写回条目内容时
// state 也会变化,那一次不是用户改动,不能标记未保存、更不能触发一次 PUT 把 version 加一。
useEffect(() => { useEffect(() => {
if (isInitialMountRef.current) { if (isNew) return;
isInitialMountRef.current = false; const persisted = persistedSnapshotRef.current;
if (persisted === null) return;
const current = snapshotOf(title, slug, formData);
if (current === persisted) {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
return; return;
} }
if (isNew) return;
setAutoSaveStatus('unsaved'); setAutoSaveStatus('unsaved');
hasUnsavedChangesRef.current = true; hasUnsavedChangesRef.current = true;
triggerAutoSave(); triggerAutoSave();
@@ -283,6 +332,8 @@ export default function ContentEditorPage() {
if (!user) return; if (!user) return;
const load = async () => { const load = async () => {
// params 变更重跑时先解除门禁:旧条目的 canEdit 不能让写入口在新 load 完成前复活
setCanEdit(false);
try { try {
const modelsRes = await adminApi.getModels(); const modelsRes = await adminApi.getModels();
const models = (modelsRes as ModelData[]) || []; const models = (modelsRes as ModelData[]) || [];
@@ -297,21 +348,24 @@ export default function ContentEditorPage() {
setFormData(defaults); setFormData(defaults);
if (!isNew) { if (!isNew) {
const itemsRes = await adminApi.getItems({ modelCode, page: 1, pageSize: 100 }); const itemsRes = await adminApi.getItems({ modelCode, id: itemId, page: 1, pageSize: 1 });
const items = (itemsRes as { items: Array<{ id: string; title: string; slug: string; status: string; data: Record<string, unknown> }> }).items || []; const items = (itemsRes as { items: Array<{ id: string; title: string; slug: string; status: string; data: Record<string, unknown> }> }).items || [];
const item = items.find((i) => i.id === itemId); const item = items.find((i) => i.id === itemId);
if (item) { // 条目取不到就是不存在:必须在这里停下,否则空表单一旦保存就用
setTitle(item.title); // { title, data: {} } 覆盖真实记录。自动保存保持解除武装(基准快照仍为 null)。
setSlug(item.slug); if (!item) {
setStatus(item.status); setItemNotFound(true);
setFormData(item.data || {}); return;
} }
setTitle(item.title);
setSlug(item.slug);
setStatus(item.status);
setFormData(item.data || {});
persistedSnapshotRef.current = snapshotOf(item.title, item.slug, item.data || {});
} }
// 编辑态要走到这里才放行:条目已回填且落库快照已建立;新建态模型就绪即放行。
// 初始加载完成后,允许后续变更触发自动保存 // 任一步抛出都到不了这一行,写入口随之保持关闭。
setTimeout(() => { setCanEdit(true);
isInitialMountRef.current = false;
}, 0);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败'); setError(err instanceof Error ? err.message : '加载失败');
} finally { } finally {
@@ -355,8 +409,8 @@ export default function ContentEditorPage() {
return; return;
} }
const success = await performSave(); const saved = await performSave();
if (success) { if (saved.ok) {
toast.success(isNew ? `${modelLabel}创建成功` : `${modelLabel}保存成功`); toast.success(isNew ? `${modelLabel}创建成功` : `${modelLabel}保存成功`);
router.push(`/admin/content/${modelCode}`); router.push(`/admin/content/${modelCode}`);
} }
@@ -373,14 +427,15 @@ export default function ContentEditorPage() {
if (isNew) { if (isNew) {
// 新建内容直接保存并跳转 // 新建内容直接保存并跳转
const success = await performSave(); const saved = await performSave();
if (success) { if (saved.ok) {
toast.success(`${modelLabel}创建成功`); toast.success(`${modelLabel}创建成功`);
router.push(`/admin/content/${modelCode}`); router.push(`/admin/content/${modelCode}`);
} }
} else { } else {
await performSave(); // 用返回值判断而不是 autoSaveStatus:后者是本次渲染闭包里的旧值
if (autoSaveStatus === 'saved') { const saved = await performSave();
if (saved.ok) {
toast.success('已保存'); toast.success('已保存');
} }
} }
@@ -397,94 +452,139 @@ export default function ContentEditorPage() {
setPublishDialogOpen(true); setPublishDialogOpen(true);
}; };
// 确认发布 // 确认发布:内容先落库,状态改由 workflow 通道驱动(B-1 —— POST 只建草稿、PUT 拒绝改状态,
// publish 权限只在 workflow 上校验)。合法路径 draft→submit→review→approve→published;
// 账号无 publish 权限时 approve 返回 403,条目停在「待审核」,这是审批制的预期结果。
const handlePublishConfirm = async () => { const handlePublishConfirm = async () => {
setPublishDialogOpen(false); setPublishDialogOpen(false);
setPublishPending(true); setPublishPending(true);
const success = await performSave('published');
setPublishPending(false); const saved = await performSave();
if (success) { const targetId = isNew ? saved.id : itemId;
toast.success(`${modelLabel}已发布`); if (!saved.ok || !targetId) {
if (isNew) { setPublishPending(false);
if (saved.ok) toast.error('创建成功但未取回条目 ID,请在列表页重新打开后发布');
return;
}
try {
let current = status;
if (current === 'draft') {
current = (await adminApi.runWorkflow(targetId, 'submit')).status ?? 'review';
// submit 此刻已落库:随后 approve 失败(如无 publish 权限的 403)时,
// UI 必须停在「待审核」这个服务端真实状态,而不是继续显示「草稿」。
setStatus(current);
}
if (current === 'review') {
current = (await adminApi.runWorkflow(targetId, 'approve')).status ?? 'published';
}
setStatus(current);
toast.success(current === 'published' ? `${modelLabel}已发布` : `${modelLabel}已提交审核`);
if (isNew && current === 'published') {
router.push(`/admin/content/${modelCode}`); router.push(`/admin/content/${modelCode}`);
} }
} catch (err) {
toast.error(err instanceof Error ? err.message : '发布失败');
} finally {
setPublishPending(false);
} }
}; };
const renderField = (field: FieldDef, prefix = '') => { // scope 非空时本字段是父 object 的子字段:读父对象的对应键、以合并方式写回父对象。
// 历史实现无作用域,子字段直接读写顶层 formData —— 读恒为 undefined,写污染出垃圾顶层键(S-22)。
const renderField = (
field: FieldDef,
prefix = '',
scope?: { parent: Record<string, unknown>; write: (subName: string, v: unknown) => void },
) => {
const key = prefix + field.name; const key = prefix + field.name;
const value = formData[field.name]; const value = scope ? scope.parent[field.name] : formData[field.name];
const set = scope
? (v: unknown) => scope.write(field.name, v)
: (v: unknown) => handleFieldChange(field.name, v);
const error = validationErrors[field.name]; const error = validationErrors[field.name];
const labelled = (
control: React.ReactNode,
opts?: { withBlur?: boolean; withDescription?: boolean; withError?: boolean },
) => (
<div key={key}>
<label htmlFor={key} className="block text-sm font-medium text-text-secondary mb-1">
{field.label}
{field.required && <span className="text-error-text ml-1">*</span>}
</label>
{opts?.withDescription && field.description && (
<p className="text-xs text-text-muted mb-1.5">{field.description}</p>
)}
{control}
{opts?.withError && <FieldError error={error} />}
</div>
);
switch (field.type) { switch (field.type) {
case 'text': case 'text':
return ( return labelled(
<div key={key}> <input
<label htmlFor={key} className="block text-sm font-medium text-text-secondary mb-1"> id={key}
{field.label} type="text"
{field.required && <span className="text-error-text ml-1">*</span>} value={(value as string) || ''}
</label> onChange={(e) => set(e.target.value)}
{field.description && ( onBlur={() => handleFieldBlur(field.name)}
<p className="text-xs text-text-muted mb-1.5">{field.description}</p> placeholder={field.placeholder}
)} className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent ${
<input error ? 'border-error bg-error-bg' : 'border-border-secondary'
id={key} }`}
type="text" />,
value={(value as string) || ''} { withDescription: true, withError: true },
onChange={(e) => handleFieldChange(field.name, e.target.value)}
onBlur={() => handleFieldBlur(field.name)}
placeholder={field.placeholder}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent ${
error ? 'border-error bg-error-bg' : 'border-border-secondary'
}`}
/>
<FieldError error={error} />
</div>
); );
case 'textarea': case 'textarea':
return ( return labelled(
<div key={key}> <textarea
<label htmlFor={key} className="block text-sm font-medium text-text-secondary mb-1"> id={key}
{field.label} value={(value as string) || ''}
{field.required && <span className="text-error-text ml-1">*</span>} onChange={(e) => set(e.target.value)}
</label> onBlur={() => handleFieldBlur(field.name)}
{field.description && ( placeholder={field.placeholder}
<p className="text-xs text-text-muted mb-1.5">{field.description}</p> rows={4}
)} className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent resize-y ${
<textarea error ? 'border-error bg-error-bg' : 'border-border-secondary'
id={key} }`}
value={(value as string) || ''} />,
onChange={(e) => handleFieldChange(field.name, e.target.value)} { withDescription: true, withError: true },
onBlur={() => handleFieldBlur(field.name)} );
placeholder={field.placeholder}
rows={4} case 'richtext':
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent resize-y ${ // 与 textarea 同型渲染。历史缺此分支时 richtext 掉进 default 的单行 input,
error ? 'border-error bg-error-bg' : 'border-border-secondary' // 浏览器对 input[type=text] 的 value 消毒会剥掉全部换行,编辑一次即永久损毁段落
}`} //(新闻正文渲染端为 whitespace-pre-line)。
/> return labelled(
<FieldError error={error} /> <textarea
</div> id={key}
value={(value as string) || ''}
onChange={(e) => set(e.target.value)}
onBlur={() => handleFieldBlur(field.name)}
placeholder={field.placeholder}
rows={12}
className={`w-full px-3 py-2 border rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent resize-y ${
error ? 'border-error bg-error-bg' : 'border-border-secondary'
}`}
/>,
{ withDescription: true, withError: true },
); );
case 'number': case 'number':
return ( return labelled(
<div key={key}> <input
<label htmlFor={key} className="block text-sm font-medium text-text-secondary mb-1"> id={key}
{field.label} type="number"
{field.required && <span className="text-error-text ml-1">*</span>} value={(value as number) ?? ''}
</label> onChange={(e) => set(parseFloat(e.target.value) || 0)}
<input className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent ${
id={key} error ? 'border-error bg-error-bg' : 'border-border-secondary'
type="number" }`}
value={(value as number) ?? ''} />,
onChange={(e) => handleFieldChange(field.name, parseFloat(e.target.value) || 0)} { withError: true },
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent ${
error ? 'border-error bg-error-bg' : 'border-border-secondary'
}`}
/>
<FieldError error={error} />
</div>
); );
case 'boolean': case 'boolean':
@@ -494,7 +594,7 @@ export default function ContentEditorPage() {
id={key} id={key}
type="checkbox" type="checkbox"
checked={!!value} checked={!!value}
onChange={(e) => handleFieldChange(field.name, e.target.checked)} onChange={(e) => set(e.target.checked)}
className="w-4 h-4 rounded border-border-secondary text-text-primary focus:ring-border-dark" className="w-4 h-4 rounded border-border-secondary text-text-primary focus:ring-border-dark"
/> />
<label htmlFor={key} className="text-sm font-medium text-text-secondary">{field.label}</label> <label htmlFor={key} className="text-sm font-medium text-text-secondary">{field.label}</label>
@@ -506,45 +606,32 @@ export default function ContentEditorPage() {
case 'select': case 'select':
case 'dropdown': case 'dropdown':
return ( return labelled(
<div key={key}> <select
<label htmlFor={key} className="block text-sm font-medium text-text-secondary mb-1"> id={key}
{field.label} value={(value as string) || ''}
</label> onChange={(e) => set(e.target.value)}
<select className="w-full px-3 py-2 border border-border-secondary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent"
id={key} >
value={(value as string) || ''} <option value="">请选择</option>
onChange={(e) => handleFieldChange(field.name, e.target.value)} {field.options?.map((opt) => (
className="w-full px-3 py-2 border border-border-secondary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent" <option key={String(opt.value)} value={String(opt.value)}>
> {opt.label}
<option value="">请选择</option> </option>
{field.options?.map((opt) => ( ))}
<option key={String(opt.value)} value={String(opt.value)}> </select>,
{opt.label}
</option>
))}
</select>
</div>
); );
case 'json': case 'json':
return ( return labelled(
<div key={key}> <JsonEditor
<label htmlFor={key} className="block text-sm font-medium text-text-secondary mb-1"> id={key}
{field.label} value={value}
{field.required && <span className="text-error-text ml-1">*</span>} onChange={(json) => set(json)}
</label> placeholder={field.placeholder}
{field.description && ( error={error}
<p className="text-xs text-text-muted mb-1.5">{field.description}</p> />,
)} { withDescription: true, withError: false },
<JsonEditor
id={key}
value={value}
onChange={(json) => handleFieldChange(field.name, json)}
placeholder={field.placeholder}
error={error}
/>
</div>
); );
case 'image': case 'image':
@@ -557,7 +644,7 @@ export default function ContentEditorPage() {
id={key} id={key}
type="text" type="text"
value={(value as string) || ''} value={(value as string) || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)} onChange={(e) => set(e.target.value)}
placeholder="输入图片 URL 或从媒体库选择" placeholder="输入图片 URL 或从媒体库选择"
className="w-full px-3 py-2 border border-border-secondary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent" className="w-full px-3 py-2 border border-border-secondary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent"
/> />
@@ -572,30 +659,50 @@ export default function ContentEditorPage() {
); );
case 'array': case 'array':
// 数组走整体 JSON 编辑(与 json 同控件)。历史写法把 fields 子字段摊平成顶层输入框,
// 编辑既写不进数组又污染 data(S-22)。
return labelled(
<JsonEditor
id={key}
value={value}
onChange={(json) => set(json)}
placeholder={field.placeholder}
error={error}
/>,
{ withDescription: true, withError: false },
);
case 'object': case 'object':
return ( return (
<fieldset key={key} className="border border-border-primary rounded-lg p-4"> <fieldset key={key} className="border border-border-primary rounded-lg p-4">
<legend className="text-sm font-medium text-text-secondary mb-3 px-1"> <legend className="text-sm font-medium text-text-secondary mb-3 px-1">
{field.label} {field.label}
</legend> </legend>
{field.fields?.map((subField) => renderField(subField, `${field.name}.`))} {field.fields?.map((subField) => {
const parentValue = (value && typeof value === 'object' && !Array.isArray(value))
? (value as Record<string, unknown>)
: {};
return (
<div key={subField.name} className="mb-3 last:mb-0">
{renderField(subField, `${field.name}.`, {
parent: parentValue,
write: (subName, v) => set({ ...parentValue, [subName]: v }),
})}
</div>
);
})}
</fieldset> </fieldset>
); );
default: default:
return ( return labelled(
<div key={key}> <input
<label htmlFor={key} className="block text-sm font-medium text-text-secondary mb-1"> id={key}
{field.label} type="text"
</label> value={(value as string) || ''}
<input onChange={(e) => set(e.target.value)}
id={key} className="w-full px-3 py-2 border border-border-secondary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent"
type="text" />,
value={(value as string) || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
className="w-full px-3 py-2 border border-border-secondary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent"
/>
</div>
); );
} }
}; };
@@ -608,6 +715,27 @@ export default function ContentEditorPage() {
); );
} }
// 条目不存在:整个表单(连同所有保存入口)都不渲染,避免空表单 PUT 覆盖真实记录
if (itemNotFound) {
return (
<div className="space-y-4 max-w-3xl">
<div className="flex items-center gap-4">
<button
onClick={() => router.push(`/admin/content/${modelCode}`)}
className="p-2 rounded hover:bg-bg-hover transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-xl font-bold text-text-primary">编辑{modelLabel}</h1>
</div>
<div className="bg-error-bg border border-border-secondary text-error-text rounded-lg px-4 py-3 text-sm">
条目未找到,无法编辑。请返回列表确认后重新打开。
<div className="mt-1 text-xs text-text-muted">{itemId}</div>
</div>
</div>
);
}
return ( return (
<div className="space-y-6 max-w-3xl"> <div className="space-y-6 max-w-3xl">
{/* Header */} {/* Header */}
@@ -631,6 +759,9 @@ export default function ContentEditorPage() {
<p className="text-sm text-text-muted mt-1">{model?.name}</p> <p className="text-sm text-text-muted mt-1">{model?.name}</p>
</div> </div>
</div> </div>
{/* 写入门禁(渲染侧,唯一一处):条目/模型未成功加载前,自动保存指示、
保存、发布、保存并返回整簇都不渲染;下方页脚同款簇同理。 */}
{canEdit && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* 自动保存状态指示器 */} {/* 自动保存状态指示器 */}
{!isNew && ( {!isNew && (
@@ -704,6 +835,7 @@ export default function ContentEditorPage() {
{saving ? '保存中...' : isNew ? '创建' : '保存并返回'} {saving ? '保存中...' : isNew ? '创建' : '保存并返回'}
</button> </button>
</div> </div>
)}
</div> </div>
{error && ( {error && (
@@ -790,19 +922,14 @@ export default function ContentEditorPage() {
<FieldError error={validationErrors.slug} /> <FieldError error={validationErrors.slug} />
</div> </div>
{/* 状态 */} {/* 状态:只读展示。流转必须走 /api/admin/items/[id]/workflow(B-1),
此前此处的 <select> 一旦改动必然被 PUT 拒绝为 400 */}
<div> <div>
<label htmlFor="status" className="block text-sm font-medium text-text-secondary mb-1">状态</label> <p className="text-sm font-medium text-text-secondary mb-1">状态</p>
<select <p className="px-3 py-2 border border-border-secondary rounded-lg text-sm text-text-secondary">
id="status" {STATUS_LABELS[status] ?? status}
value={status} </p>
onChange={(e) => setStatus(e.target.value)} <p className="mt-1 text-xs text-text-muted">状态通过「发布」提交审核变更,不可在此直接修改</p>
className="w-full px-3 py-2 border border-border-secondary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent"
>
<option value="draft">草稿</option>
<option value="published">已发布</option>
<option value="archived">已归档</option>
</select>
</div> </div>
{/* 模型字段 */} {/* 模型字段 */}
@@ -815,6 +942,7 @@ export default function ContentEditorPage() {
<FileText className="w-3.5 h-3.5" /> <FileText className="w-3.5 h-3.5" />
{modelCode} / {isNew ? '新建' : itemId} {modelCode} / {isNew ? '新建' : itemId}
</div> </div>
{canEdit && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* 仅保存按钮(底部) */} {/* 仅保存按钮(底部) */}
{!isNew && ( {!isNew && (
@@ -838,6 +966,7 @@ export default function ContentEditorPage() {
{saving ? '保存中...' : isNew ? '创建' : '保存并返回'} {saving ? '保存中...' : isNew ? '创建' : '保存并返回'}
</button> </button>
</div> </div>
)}
</div> </div>
{/* 发布确认对话框 */} {/* 发布确认对话框 */}
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom';
/**
* 内容列表页「搜索」链路的回归测试。
*
* 钉死两件事:
* 1. 输入框连续按键不得逐次打后端(防抖),只按最终关键词发一次请求;
* 2. 迟到的旧关键词响应不得覆盖新关键词的响应(乱序即错数据)。
*/
type Fn = jest.Mock<(...args: unknown[]) => Promise<unknown>>;
interface ApiMock {
getItems: Fn;
deleteItem: Fn;
}
interface NavMock {
state: { params: Record<string, string> };
push: Fn;
}
interface AuthMock {
state: { user: Record<string, string> | null; loading: boolean };
}
jest.mock('next/navigation', () => {
const push = jest.fn();
const router = { push, prefetch: jest.fn() };
const state = { params: { modelCode: 'news' } as Record<string, string> };
return { state, push, useRouter: () => router, useParams: () => state.params };
});
jest.mock('@/components/admin/auth-context', () => {
const state = {
user: { id: 'u1', username: 'admin', nickname: '管理员', role: 'superadmin', avatar: '' },
loading: false,
};
return { state, useAuth: () => ({ user: state.user, loading: state.loading, token: 't', login: jest.fn(), logout: jest.fn() }) };
});
jest.mock('@/lib/admin-api', () => ({
adminApi: {
getItems: jest.fn(),
deleteItem: jest.fn(),
},
}));
jest.mock('@/components/ui/sonner', () => ({
Toaster: () => null,
toast: { success: jest.fn(), error: jest.fn(), info: jest.fn(), dismiss: jest.fn() },
}));
import ContentListPage from './page';
const nav = jest.requireMock('next/navigation') as NavMock;
const auth = jest.requireMock('@/components/admin/auth-context') as AuthMock;
const api = (jest.requireMock('@/lib/admin-api') as { adminApi: ApiMock }).adminApi;
const SEARCH_INPUT = '搜索新闻资讯...';
function row(title: string) {
return {
id: `row-${title}`,
title,
slug: `slug-${title}`,
status: 'draft',
data: {},
sortOrder: 0,
publishedAt: null,
updatedAt: '2026-01-01T00:00:00.000Z',
};
}
function rowsResult(items: unknown[]) {
return { items, total: items.length, page: 1, pageSize: 10, totalPages: 1 };
}
function callsOf(mock: Fn): unknown[][] {
return mock.mock.calls as unknown[][];
}
/** 每次请求带的 search 关键词序列(undefined = 首屏 / 翻页请求) */
function searchesOf(mock: Fn): Array<string | undefined> {
return callsOf(mock).map((call) => (call[0] as { search?: string })?.search);
}
async function typeSearch(value: string) {
await act(async () => {
fireEvent.change(screen.getByPlaceholderText(SEARCH_INPUT), { target: { value } });
});
}
async function waitMs(ms: number) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, ms));
});
}
function resetMocks() {
jest.clearAllMocks();
auth.state.user = { id: 'u1', username: 'admin', nickname: '管理员', role: 'superadmin', avatar: '' };
auth.state.loading = false;
api.getItems.mockResolvedValue(rowsResult([row('全部内容')]));
api.deleteItem.mockResolvedValue({ message: '删除成功' });
}
describe('内容列表页:搜索请求节流', () => {
beforeEach(resetMocks);
it('连续输入不得逐次请求,只在防抖窗口后按最终关键词发一次', async () => {
nav.state.params = { modelCode: 'news' };
render(<ContentListPage />);
await waitFor(() => expect(api.getItems).toHaveBeenCalledTimes(1));
await typeSearch('a');
await typeSearch('ab');
await typeSearch('abc');
// 修复前:search 直接进 effect 依赖,每个按键立刻打一次请求
expect(callsOf(api.getItems)).toHaveLength(1);
await waitFor(() => expect(searchesOf(api.getItems)).toEqual([undefined, 'abc']));
expect(callsOf(api.getItems)).toHaveLength(2);
});
it('旧关键词的迟到响应不得覆盖新关键词的结果', async () => {
api.getItems.mockImplementation((params: unknown) => {
const search = (params as { search?: string })?.search;
if (search === 'slow') {
return new Promise((resolve) => {
setTimeout(() => resolve(rowsResult([row('慢查询结果')])), 500);
});
}
if (search === 'slowq') return Promise.resolve(rowsResult([row('新查询结果')]));
return Promise.resolve(rowsResult([row('全部内容')]));
});
nav.state.params = { modelCode: 'news' };
render(<ContentListPage />);
await waitFor(() => expect(screen.getByText('全部内容')).toBeInTheDocument());
await typeSearch('slow');
await waitMs(350); // 慢请求已发出,尚未返回
await typeSearch('slowq');
await waitFor(() => expect(screen.getByText('新查询结果')).toBeInTheDocument());
await waitMs(600); // 慢响应此刻才到达
expect(screen.getByText('新查询结果')).toBeInTheDocument();
expect(screen.queryByText('慢查询结果')).toBeNull();
});
});
+27 -5
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useAuth } from '@/components/admin/auth-context'; import { useAuth } from '@/components/admin/auth-context';
import { adminApi } from '@/lib/admin-api'; import { adminApi } from '@/lib/admin-api';
@@ -27,6 +27,17 @@ const MODEL_LABELS: Record<string, string> = {
'stat-item': '数据指标', 'stat-item': '数据指标',
}; };
// 与 src/lib/cms/types.ts ContentStatus 四态一致(见 [itemId]/page.tsx 的 STATUS_LABELS)
const STATUS_LABELS: Record<string, string> = {
draft: '草稿',
review: '待审核',
published: '已发布',
archived: '已归档',
};
// 搜索输入的稳定窗口:逐键打后端既浪费配额,又让乱序响应有机会覆盖正确结果
const SEARCH_DEBOUNCE_MS = 300;
interface ContentItem { interface ContentItem {
id: string; id: string;
title: string; title: string;
@@ -47,16 +58,25 @@ export default function ContentListPage() {
const [items, setItems] = useState<ContentItem[]>([]); const [items, setItems] = useState<ContentItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null); const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const pageSize = 10; const pageSize = 10;
// 单调递增的请求序号:只有最新一次请求的响应可以写进 state
const fetchSeqRef = useRef(0);
const modelLabel = MODEL_LABELS[modelCode] || modelCode; const modelLabel = MODEL_LABELS[modelCode] || modelCode;
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [searchInput]);
const fetchItems = useCallback(async () => { const fetchItems = useCallback(async () => {
const seq = ++fetchSeqRef.current;
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
@@ -66,13 +86,15 @@ export default function ContentListPage() {
pageSize, pageSize,
...(search ? { search } : {}), ...(search ? { search } : {}),
}); });
if (seq !== fetchSeqRef.current) return;
const data = res as { items: ContentItem[]; total: number }; const data = res as { items: ContentItem[]; total: number };
setItems(data?.items || []); setItems(data?.items || []);
setTotal(data?.total || 0); setTotal(data?.total || 0);
} catch (err) { } catch (err) {
if (seq !== fetchSeqRef.current) return;
setError(err instanceof Error ? err.message : '加载失败'); setError(err instanceof Error ? err.message : '加载失败');
} finally { } finally {
setLoading(false); if (seq === fetchSeqRef.current) setLoading(false);
} }
}, [modelCode, page, search]); }, [modelCode, page, search]);
@@ -137,8 +159,8 @@ export default function ContentListPage() {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input <input
type="text" type="text"
value={search} value={searchInput}
onChange={(e) => { setSearch(e.target.value); setPage(1); }} onChange={(e) => { setSearchInput(e.target.value); setPage(1); }}
placeholder={`搜索${modelLabel}...`} placeholder={`搜索${modelLabel}...`}
className="w-full pl-10 pr-4 py-2 border border-border-secondary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent" className="w-full pl-10 pr-4 py-2 border border-border-secondary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-dark focus:border-transparent"
/> />
@@ -201,7 +223,7 @@ export default function ContentListPage() {
: 'bg-bg-hover text-text-tertiary' : 'bg-bg-hover text-text-tertiary'
}`} }`}
> >
{item.status === 'published' ? '已发布' : item.status === 'draft' ? '草稿' : '已归档'} {STATUS_LABELS[item.status] || item.status}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-sm text-text-muted"> <td className="px-4 py-3 text-sm text-text-muted">
+6 -3
View File
@@ -48,10 +48,11 @@ export default function AdminLoginPage() {
)} )}
<div> <div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"> <label htmlFor="admin-login-username" className="block text-sm font-medium text-text-secondary mb-1.5">
用户名 用户名
</label> </label>
<input <input
id="admin-login-username"
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
@@ -63,11 +64,12 @@ export default function AdminLoginPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"> <label htmlFor="admin-login-password" className="block text-sm font-medium text-text-secondary mb-1.5">
密码 密码
</label> </label>
<div className="relative"> <div className="relative">
<input <input
id="admin-login-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@@ -77,7 +79,8 @@ export default function AdminLoginPage() {
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword ? '隐藏密码' : '显示密码'}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-tertiary" className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-tertiary"
> >
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+18 -11
View File
@@ -32,6 +32,7 @@ export default function MediaPage() {
const [items, setItems] = useState<MediaItem[]>([]); const [items, setItems] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null); const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
@@ -42,8 +43,9 @@ export default function MediaPage() {
const res = await adminApi.getMedia({ pageSize: 100 }); const res = await adminApi.getMedia({ pageSize: 100 });
const data = (res as { items: MediaItem[] }); const data = (res as { items: MediaItem[] });
setItems(data?.items || []); setItems(data?.items || []);
setError('');
} catch (err) { } catch (err) {
console.error(err); setError(err instanceof Error ? err.message : '加载失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -99,6 +101,8 @@ export default function MediaPage() {
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => {
setCopiedId(id); setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000); setTimeout(() => setCopiedId(null), 2000);
}).catch(() => {
toast.error('复制失败,请手动复制图片地址');
}); });
}; };
@@ -138,6 +142,12 @@ export default function MediaPage() {
</label> </label>
</div> </div>
{error && (
<div className="bg-error-bg border border-border-secondary text-error-text rounded-lg px-4 py-3 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{items.map((item) => ( {items.map((item) => (
<div <div
@@ -191,23 +201,20 @@ export default function MediaPage() {
))} ))}
</div> </div>
{items.length === 0 && !loading && ( {items.length === 0 && !loading && !error && (
<div className="text-center py-16 bg-bg-primary rounded-lg border border-dashed border-border-secondary"> <div className="text-center py-16 bg-bg-primary rounded-lg border border-dashed border-border-secondary">
{/* eslint-disable-next-line jsx-a11y/alt-text -- lucide Image 图标(装饰性 SVG,非 <img>),aria-hidden 表明装饰意图 */} {/* eslint-disable-next-line jsx-a11y/alt-text -- lucide Image 图标(装饰性 SVG,非 <img>),aria-hidden 表明装饰意图 */}
<Image aria-hidden="true" className="w-12 h-12 mx-auto mb-4 text-text-muted" /> <Image aria-hidden="true" className="w-12 h-12 mx-auto mb-4 text-text-muted" />
<h3 className="text-sm font-medium text-text-primary mb-1">暂无媒体文件</h3> <h3 className="text-sm font-medium text-text-primary mb-1">暂无媒体文件</h3>
<p className="text-sm text-text-muted mb-4">上传图片、文档等资源,即可在内容编辑中使用</p> <p className="text-sm text-text-muted mb-4">上传图片、文档等资源,即可在内容编辑中使用</p>
<label className="inline-flex items-center gap-2 bg-dark-bg text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-dark-bg-secondary cursor-pointer transition-colors"> <button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 bg-dark-bg text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-dark-bg-secondary transition-colors"
>
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
上传文件 上传文件
<input </button>
ref={fileInputRef}
type="file"
onChange={handleUpload}
className="hidden"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip"
/>
</label>
</div> </div>
)} )}
+8 -3
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/admin/auth-context'; import { useAuth } from '@/components/admin/auth-context';
import { adminApi } from '@/lib/admin-api'; import { adminApi } from '@/lib/admin-api';
@@ -63,8 +63,11 @@ export default function NotificationsPage() {
const [unreadOnly, setUnreadOnly] = useState(false); const [unreadOnly, setUnreadOnly] = useState(false);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const pageSize = 20; const pageSize = 20;
// 单调递增的请求序号:只有最新一次请求的响应可以写进 state
const fetchSeqRef = useRef(0);
const fetchNotifications = useCallback(async () => { const fetchNotifications = useCallback(async () => {
const seq = ++fetchSeqRef.current;
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
@@ -80,12 +83,14 @@ export default function NotificationsPage() {
pageSize: number; pageSize: number;
totalPages: number; totalPages: number;
}>(`/api/admin/notifications?${query.toString()}`); }>(`/api/admin/notifications?${query.toString()}`);
if (seq !== fetchSeqRef.current) return;
setNotifications(data.items || []); setNotifications(data.items || []);
setTotal(data.total || 0); setTotal(data.total || 0);
} catch (err) { } catch (err) {
if (seq !== fetchSeqRef.current) return;
setError(err instanceof Error ? err.message : '加载失败'); setError(err instanceof Error ? err.message : '加载失败');
} finally { } finally {
setLoading(false); if (seq === fetchSeqRef.current) setLoading(false);
} }
}, [page, unreadOnly]); }, [page, unreadOnly]);
@@ -164,7 +169,7 @@ export default function NotificationsPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => { setUnreadOnly(!unreadOnly); setPage(1); }} onClick={() => { setUnreadOnly((prev) => !prev); setPage(1); }}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${ className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
unreadOnly unreadOnly
? 'bg-dark-bg text-white border-border-dark' ? 'bg-dark-bg text-white border-border-dark'
+9 -2
View File
@@ -98,6 +98,7 @@ export default function AdminDashboardPage() {
const router = useRouter(); const router = useRouter();
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [fetching, setFetching] = useState(true); const [fetching, setFetching] = useState(true);
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
@@ -106,8 +107,8 @@ export default function AdminDashboardPage() {
} }
if (user) { if (user) {
adminApi.request<DashboardStats>('/api/admin/stats') adminApi.request<DashboardStats>('/api/admin/stats')
.then((s) => setStats(s)) .then((s) => { setStats(s); setError(''); })
.catch(console.error) .catch((err) => setError(err instanceof Error ? err.message : '加载失败'))
.finally(() => setFetching(false)); .finally(() => setFetching(false));
} }
}, [user, authLoading, router]); }, [user, authLoading, router]);
@@ -127,6 +128,12 @@ export default function AdminDashboardPage() {
<p className="text-sm text-text-muted mt-1">欢迎回来,{user?.nickname || user?.username}</p> <p className="text-sm text-text-muted mt-1">欢迎回来,{user?.nickname || user?.username}</p>
</div> </div>
{error && (
<div className="bg-error-bg border border-border-secondary text-error-text rounded-lg px-4 py-3 text-sm">
{error}
</div>
)}
{/* Stats cards */} {/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{[ {[
+10 -8
View File
@@ -42,6 +42,7 @@ export default function RolesPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
// 不依赖 selectedRole:切换角色若触发整体 refetch,服务端数据会覆盖未保存的本地编辑
const fetchRoles = useCallback(async () => { const fetchRoles = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -56,19 +57,17 @@ export default function RolesPage() {
}); });
setPermissions(permSet); setPermissions(permSet);
if (data.roles?.length > 0 && !selectedRole) { // 默认选中第一个非 super_admin 角色,方便配置;已有选中值时不覆盖
// 默认选中第一个非 super_admin 角色,方便配置 const defaultRole = (data.roles || []).find((r) => r.code !== 'super_admin') || data.roles?.[0];
const defaultRole = data.roles.find((r) => r.code !== 'super_admin') || data.roles[0]; if (defaultRole) {
if (defaultRole) { setSelectedRole((prev) => prev || defaultRole.code);
setSelectedRole(defaultRole.code);
}
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败'); setError(err instanceof Error ? err.message : '加载失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selectedRole]); }, []);
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
@@ -241,7 +240,10 @@ export default function RolesPage() {
<code className="text-xs text-text-muted">{model.code}</code> <code className="text-xs text-text-muted">{model.code}</code>
</td> </td>
{ACTION_ORDER.map((action) => { {ACTION_ORDER.map((action) => {
const enabled = hasPermission(selectedRole, model.code, action); // super_admin 无逐行权限记录(运行时短路放行),矩阵整体按已授权呈现,与头部「拥有所有权限」文案一致
const enabled =
selectedRoleInfo?.code === 'super_admin' ||
hasPermission(selectedRole, model.code, action);
return ( return (
<td key={action} className="px-2 py-3 text-center"> <td key={action} className="px-2 py-3 text-center">
<button <button
+29 -11
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/admin/auth-context'; import { useAuth } from '@/components/admin/auth-context';
import { adminApi } from '@/lib/admin-api'; import { adminApi } from '@/lib/admin-api';
@@ -52,6 +52,8 @@ const ROLE_COLORS: Record<string, string> = {
readonly: 'bg-bg-hover text-text-secondary', readonly: 'bg-bg-hover text-text-secondary',
}; };
const SEARCH_DEBOUNCE_MS = 300;
export default function UsersPage() { export default function UsersPage() {
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
@@ -59,11 +61,14 @@ export default function UsersPage() {
const [users, setUsers] = useState<UserData[]>([]); const [users, setUsers] = useState<UserData[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [roles, setRoles] = useState<RoleData[]>([]); const [roles, setRoles] = useState<RoleData[]>([]);
const pageSize = 20; const pageSize = 20;
// 单调递增的请求序号:只有最新一次请求的响应可以写进 state
const fetchSeqRef = useRef(0);
// Create/Edit dialog state // Create/Edit dialog state
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -82,7 +87,13 @@ export default function UsersPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null); const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [searchInput]);
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
const seq = ++fetchSeqRef.current;
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
@@ -98,12 +109,14 @@ export default function UsersPage() {
pageSize: number; pageSize: number;
totalPages: number; totalPages: number;
}>(`/api/admin/users?${query.toString()}`); }>(`/api/admin/users?${query.toString()}`);
if (seq !== fetchSeqRef.current) return;
setUsers(data.users || []); setUsers(data.users || []);
setTotal(data.total || 0); setTotal(data.total || 0);
} catch (err) { } catch (err) {
if (seq !== fetchSeqRef.current) return;
setError(err instanceof Error ? err.message : '加载失败'); setError(err instanceof Error ? err.message : '加载失败');
} finally { } finally {
setLoading(false); if (seq === fetchSeqRef.current) setLoading(false);
} }
}, [page, search]); }, [page, search]);
@@ -259,8 +272,8 @@ export default function UsersPage() {
<input <input
type="text" type="text"
placeholder="搜索用户名、昵称或邮箱..." placeholder="搜索用户名、昵称或邮箱..."
value={search} value={searchInput}
onChange={(e) => { setSearch(e.target.value); setPage(1); }} onChange={(e) => { setSearchInput(e.target.value); setPage(1); }}
className="w-full pl-10 pr-4 py-2 border border-border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-primary" className="w-full pl-10 pr-4 py-2 border border-border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-primary"
/> />
</div> </div>
@@ -419,8 +432,9 @@ export default function UsersPage() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-text-secondary mb-1">用户名</label> <label htmlFor="admin-user-username" className="block text-sm font-medium text-text-secondary mb-1">用户名</label>
<input <input
id="admin-user-username"
type="text" type="text"
value={formData.username} value={formData.username}
onChange={(e) => setFormData((p) => ({ ...p, username: e.target.value }))} onChange={(e) => setFormData((p) => ({ ...p, username: e.target.value }))}
@@ -431,10 +445,11 @@ export default function UsersPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-text-secondary mb-1"> <label htmlFor="admin-user-password" className="block text-sm font-medium text-text-secondary mb-1">
{editingUser ? '新密码(留空不修改)' : '密码'} {editingUser ? '新密码(留空不修改)' : '密码'}
</label> </label>
<input <input
id="admin-user-password"
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => setFormData((p) => ({ ...p, password: e.target.value }))} onChange={(e) => setFormData((p) => ({ ...p, password: e.target.value }))}
@@ -445,8 +460,9 @@ export default function UsersPage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-text-secondary mb-1">昵称</label> <label htmlFor="admin-user-nickname" className="block text-sm font-medium text-text-secondary mb-1">昵称</label>
<input <input
id="admin-user-nickname"
type="text" type="text"
value={formData.nickname} value={formData.nickname}
onChange={(e) => setFormData((p) => ({ ...p, nickname: e.target.value }))} onChange={(e) => setFormData((p) => ({ ...p, nickname: e.target.value }))}
@@ -454,8 +470,9 @@ export default function UsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-text-secondary mb-1">邮箱</label> <label htmlFor="admin-user-email" className="block text-sm font-medium text-text-secondary mb-1">邮箱</label>
<input <input
id="admin-user-email"
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => setFormData((p) => ({ ...p, email: e.target.value }))} onChange={(e) => setFormData((p) => ({ ...p, email: e.target.value }))}
@@ -465,8 +482,9 @@ export default function UsersPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-text-secondary mb-1">手机号</label> <label htmlFor="admin-user-phone" className="block text-sm font-medium text-text-secondary mb-1">手机号</label>
<input <input
id="admin-user-phone"
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => setFormData((p) => ({ ...p, phone: e.target.value }))} onChange={(e) => setFormData((p) => ({ ...p, phone: e.target.value }))}
@@ -475,8 +493,8 @@ export default function UsersPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-text-secondary mb-2">角色分配</label> <span id="admin-user-roles-label" className="block text-sm font-medium text-text-secondary mb-2">角色分配</span>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2" role="group" aria-labelledby="admin-user-roles-label">
{roles.map((r) => ( {roles.map((r) => (
<button <button
key={r.code} key={r.code}
+13 -3
View File
@@ -31,6 +31,7 @@ export default function ZonesPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingZone, setEditingZone] = useState<Zone | null>(null); const [editingZone, setEditingZone] = useState<Zone | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
@@ -40,7 +41,8 @@ export default function ZonesPage() {
if (user) { if (user) {
adminApi.getZones().then((res) => { adminApi.getZones().then((res) => {
setZones((res as Zone[]) || []); setZones((res as Zone[]) || []);
}).catch(console.error).finally(() => setLoading(false)); setError('');
}).catch((err) => setError(err instanceof Error ? err.message : '加载失败')).finally(() => setLoading(false));
} }
}, [user, authLoading, router]); }, [user, authLoading, router]);
@@ -86,6 +88,12 @@ export default function ZonesPage() {
<p className="text-sm text-text-muted mt-1">管理系统各页面的内容区域布局</p> <p className="text-sm text-text-muted mt-1">管理系统各页面的内容区域布局</p>
</div> </div>
{error && (
<div className="bg-error-bg border border-border-secondary text-error-text rounded-lg px-4 py-3 text-sm">
{error}
</div>
)}
<div className="grid gap-4"> <div className="grid gap-4">
{zones.map((zone) => ( {zones.map((zone) => (
<div key={zone.id} className="bg-bg-primary rounded-lg border border-border-primary p-4"> <div key={zone.id} className="bg-bg-primary rounded-lg border border-border-primary p-4">
@@ -111,8 +119,9 @@ export default function ZonesPage() {
<div className="mt-4 pt-4 border-t border-border-light space-y-4"> <div className="mt-4 pt-4 border-t border-border-light space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium text-text-secondary mb-1">名称</label> <label htmlFor="zone-edit-name" className="block text-xs font-medium text-text-secondary mb-1">名称</label>
<input <input
id="zone-edit-name"
type="text" type="text"
value={editingZone.name} value={editingZone.name}
onChange={(e) => setEditingZone({ ...editingZone, name: e.target.value })} onChange={(e) => setEditingZone({ ...editingZone, name: e.target.value })}
@@ -120,8 +129,9 @@ export default function ZonesPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-text-secondary mb-1">页面标识</label> <label htmlFor="zone-edit-page-code" className="block text-xs font-medium text-text-secondary mb-1">页面标识</label>
<input <input
id="zone-edit-page-code"
type="text" type="text"
value={editingZone.pageCode} value={editingZone.pageCode}
onChange={(e) => setEditingZone({ ...editingZone, pageCode: e.target.value })} onChange={(e) => setEditingZone({ ...editingZone, pageCode: e.target.value })}
@@ -36,6 +36,12 @@ jest.mock('@/lib/permissions', () => ({
requirePermission: mockRequirePermission, requirePermission: mockRequirePermission,
})); }));
// Q-8:workflow POST 现会先 authenticateRequest 再探存在性;默认给有效会话。
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
jest.mock('@/lib/auth', () => ({
authenticateRequest: mockAuthenticateRequest,
}));
jest.unmock('./route'); jest.unmock('./route');
import { POST } from './route'; import { POST } from './route';
@@ -77,6 +83,7 @@ beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockContentItemFindUnique.mockResolvedValue(mockItem); mockContentItemFindUnique.mockResolvedValue(mockItem);
mockContentItemFindUniqueOrThrow.mockResolvedValue(mockUpdatedItem); mockContentItemFindUniqueOrThrow.mockResolvedValue(mockUpdatedItem);
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
}); });
describe('POST /api/admin/items/[id]/workflow', () => { describe('POST /api/admin/items/[id]/workflow', () => {
@@ -111,6 +118,16 @@ describe('POST /api/admin/items/[id]/workflow', () => {
expect(body.error).toBe('内容不存在'); expect(body.error).toBe('内容不存在');
}); });
// Q-8:匿名调用者不得用「404 vs 401」枚举 ID —— 认证须先于存在性探针。
it('rejects an unauthenticated workflow with 401 WITHOUT probing item existence', async () => {
mockAuthenticateRequest.mockReturnValue(null);
const request = createMockRequest({ action: 'submit' });
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
expect(response.status).toBe(401);
expect(mockContentItemFindUnique).not.toHaveBeenCalled();
});
it('submit requires update permission', async () => { it('submit requires update permission', async () => {
mockAuthorized('update'); mockAuthorized('update');
const request = createMockRequest({ action: 'submit' }); const request = createMockRequest({ action: 'submit' });
@@ -1,6 +1,7 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { requirePermission } from '@/lib/permissions'; import { requirePermission } from '@/lib/permissions';
import { authenticateRequest } from '@/lib/auth';
import { import {
submitForReview, submitForReview,
approve, approve,
@@ -11,14 +12,17 @@ import {
import { import {
success, success,
notFound, notFound,
unauthorized,
validationError, validationError,
internalError, internalError,
forbidden, forbidden,
} from '@/lib/api-response'; } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto'; import { withCrypto } from '@/lib/api-crypto';
import { parseCmsData } from '@/lib/cms/validate-content-data';
// 与 items/route.ts 同源:脏 data 列不得让这个已成功的审批动作在返回阶段变成 500(B-4)。
function parseItem(item: { data: string; [key: string]: unknown }) { function parseItem(item: { data: string; [key: string]: unknown }) {
return { ...item, data: JSON.parse(item.data as string) }; return { ...item, data: parseCmsData(item.data) };
} }
interface WorkflowBody { interface WorkflowBody {
@@ -51,6 +55,9 @@ export const POST = withCrypto(async (
return validationError(`非法的 action: ${action}`); return validationError(`非法的 action: ${action}`);
} }
// Q-8:认证先于存在性探针,避免匿名枚举内容 ID。
if (!authenticateRequest(request)) return unauthorized();
const existing = await prisma.contentItem.findUnique({ where: { id } }); const existing = await prisma.contentItem.findUnique({ where: { id } });
if (!existing) return notFound('内容不存在'); if (!existing) return notFound('内容不存在');
+273 -4
View File
@@ -10,6 +10,7 @@ const mockContentItemCreate = jest.fn<(args: unknown) => Promise<unknown>>();
const mockContentItemUpdate = jest.fn<(args: unknown) => Promise<unknown>>(); const mockContentItemUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
const mockContentItemDelete = jest.fn<(args: unknown) => Promise<unknown>>(); const mockContentItemDelete = jest.fn<(args: unknown) => Promise<unknown>>();
const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>(); const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>();
const mockContentModelFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
jest.mock('@/lib/db', () => ({ jest.mock('@/lib/db', () => ({
prisma: { prisma: {
@@ -22,6 +23,9 @@ jest.mock('@/lib/db', () => ({
update: mockContentItemUpdate, update: mockContentItemUpdate,
delete: mockContentItemDelete, delete: mockContentItemDelete,
}, },
contentModel: {
findUnique: mockContentModelFindUnique,
},
auditLog: { auditLog: {
create: mockAuditLogCreate, create: mockAuditLogCreate,
}, },
@@ -37,9 +41,27 @@ jest.mock('@/lib/permissions', () => ({
requirePermission: mockRequirePermission, requirePermission: mockRequirePermission,
})); }));
// Q-8:PUT/DELETE 现会先 authenticateRequest 再探存在性;默认给出有效会话,
// 让既有的「已授权」用例继续走通,枚举用例单独置空。
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
jest.mock('@/lib/auth', () => ({
authenticateRequest: mockAuthenticateRequest,
}));
jest.unmock('./route'); jest.unmock('./route');
import { GET, POST, PUT, DELETE } from './route'; import { GET, POST, PUT, DELETE } from './route';
import { CONTENT_TYPE_CONFIGS } from '@/lib/cms/content-types';
const productFields = CONTENT_TYPE_CONFIGS['product'].model.fields;
function mockProductModel() {
mockContentModelFindUnique.mockResolvedValue({
id: 'model-1',
code: 'product',
fields: JSON.stringify(productFields),
});
}
function createMockRequest(options: { function createMockRequest(options: {
url?: string; url?: string;
@@ -98,6 +120,9 @@ beforeEach(() => {
mockContentItemUpdate.mockResolvedValue(mockItem); mockContentItemUpdate.mockResolvedValue(mockItem);
mockContentItemDelete.mockResolvedValue(undefined); mockContentItemDelete.mockResolvedValue(undefined);
mockAuditLogCreate.mockResolvedValue({ id: 'log-1' }); mockAuditLogCreate.mockResolvedValue({ id: 'log-1' });
mockContentModelFindUnique.mockResolvedValue(null);
// Q-8:默认视为已登录(合法会话),使「已授权」路径不受存在性前置门影响。
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'content_editor' });
}); });
describe('/api/admin/items', () => { describe('/api/admin/items', () => {
@@ -131,6 +156,42 @@ describe('/api/admin/items', () => {
}); });
}); });
// Q-7:`page`/`pageSize` 未经校验会把 NaN 传进 Prisma skip/take(真库抛错→500),
// 且 pageSize 无界可被用来整表拉取。修复后经 parsePagination 收敛为有界默认值。
it('sanitizes non-numeric page and clamps oversized pageSize (no NaN/ unbounded take)', async () => {
mockAuthorized();
const response = await GET(
createMockRequest({ url: 'http://localhost/api/admin/items?page=abc&pageSize=99999' })
);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.page).toBe(1);
expect(body.pageSize).toBe(100);
expect(mockContentItemFindMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 0, take: 100 })
);
});
// Q-8:匿名调用者不得通过「404(存在) vs 401(不存在)」枚举内容 ID —— 认证必须先于存在性探针。
it('rejects an unauthenticated PUT with 401 WITHOUT probing item existence (no ID oracle)', async () => {
mockAuthenticateRequest.mockReturnValue(null);
const response = await PUT(
createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' })
);
expect(response.status).toBe(401);
expect(mockContentItemFindUnique).not.toHaveBeenCalled();
});
it('rejects an unauthenticated DELETE with 401 WITHOUT probing item existence (no ID oracle)', async () => {
mockAuthenticateRequest.mockReturnValue(null);
const response = await DELETE(
createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' })
);
expect(response.status).toBe(401);
expect(mockContentItemFindUnique).not.toHaveBeenCalled();
});
it('filters by modelCode and status', async () => { it('filters by modelCode and status', async () => {
mockAuthorized(); mockAuthorized();
await GET(createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&status=published' })); await GET(createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&status=published' }));
@@ -162,6 +223,19 @@ describe('/api/admin/items', () => {
}) })
); );
}); });
it('applies id filter so the editor can locate one item without paging', async () => {
mockAuthorized();
await GET(
createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&id=item-101&page=1&pageSize=1' })
);
expect(mockContentItemFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { modelCode: 'news', id: 'item-101' },
})
);
});
}); });
describe('POST', () => { describe('POST', () => {
@@ -200,7 +274,7 @@ describe('/api/admin/items', () => {
expect(body.error).toContain('已存在'); expect(body.error).toContain('已存在');
}); });
it('creates item and audit log', async () => { it('creates item as draft and writes audit log', async () => {
mockAuthorized(); mockAuthorized();
const response = await POST(createMockRequest({ const response = await POST(createMockRequest({
json: async () => ({ json: async () => ({
@@ -209,7 +283,6 @@ describe('/api/admin/items', () => {
title: '新新闻', title: '新新闻',
slug: 'new-news', slug: 'new-news',
data: { body: 'content' }, data: { body: 'content' },
status: 'published',
sortOrder: 1, sortOrder: 1,
}), }),
})); }));
@@ -224,9 +297,9 @@ describe('/api/admin/items', () => {
modelCode: 'news', modelCode: 'news',
title: '新新闻', title: '新新闻',
slug: 'new-news', slug: 'new-news',
status: 'published', status: 'draft',
sortOrder: 1, sortOrder: 1,
publishedAt: expect.any(Date), publishedAt: null,
}), }),
}) })
); );
@@ -240,6 +313,49 @@ describe('/api/admin/items', () => {
}) })
); );
}); });
// B-1:create 权限不得成为发布通道,且未知 status 不能入库(否则 workflow 表外状态会永久卡死)。
it.each(['published', 'review', 'archived', '任意字符串'])(
'refuses status "%s" on create and writes nothing',
async (status) => {
mockAuthorized();
const response = await POST(createMockRequest({
json: async () => ({
modelId: 'model-1',
modelCode: 'news',
title: '新新闻',
data: { body: 'content' },
status,
}),
}));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('workflow');
expect(mockContentItemCreate).not.toHaveBeenCalled();
expect(mockAuditLogCreate).not.toHaveBeenCalled();
},
);
it('treats an explicit draft status as the default (no bypass, no rejection)', async () => {
mockAuthorized();
const response = await POST(createMockRequest({
json: async () => ({
modelId: 'model-1',
modelCode: 'news',
title: '新新闻',
data: { body: 'content' },
status: 'draft',
}),
}));
expect(response.status).toBe(201);
expect(mockContentItemCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'draft', publishedAt: null }),
})
);
});
}); });
describe('PUT', () => { describe('PUT', () => {
@@ -275,6 +391,21 @@ describe('/api/admin/items', () => {
expect(body.error).toContain('状态变更'); expect(body.error).toContain('状态变更');
}); });
it('accepts a PUT that echoes the unchanged status (admin autosave sends it every time)', async () => {
mockAuthorized();
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=item-1',
json: async () => ({ title: '更新', status: 'draft' }),
}));
expect(response.status).toBe(200);
expect(mockContentItemUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ title: '更新' }),
})
);
});
it('returns 400 when slug duplicated', async () => { it('returns 400 when slug duplicated', async () => {
mockAuthorized(); mockAuthorized();
mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'duplicated' }); mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'duplicated' });
@@ -359,4 +490,142 @@ describe('/api/admin/items', () => {
); );
}); });
}); });
describe('写入侧字段声明校验(B-3)', () => {
it('rejects a metric basis outside the declared options before any write', async () => {
mockAuthorized();
mockProductModel();
const response = await POST(createMockRequest({
json: async () => ({
modelId: 'model-1',
modelCode: 'product',
title: '虚构指标产品',
data: { metrics: [{ value: '99.9%', label: '系统可用率', basis: 'customer-proven' }] },
}),
}));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.code).toBe('VALIDATION_ERROR');
expect(body.details.fields).toEqual([
expect.objectContaining({ path: 'metrics[0].basis', rule: 'option' }),
]);
expect(mockContentItemCreate).not.toHaveBeenCalled();
expect(mockAuditLogCreate).not.toHaveBeenCalled();
});
it('accepts a payload that stays inside the declared constraints', async () => {
mockAuthorized();
mockProductModel();
const response = await POST(createMockRequest({
json: async () => ({
modelId: 'model-1',
modelCode: 'product',
title: 'ERP 套件',
data: {
metrics: [{ value: '30%', label: '库存周转提升', basis: 'target' }],
categoryId: 'enterprise',
status: '研发中',
},
}),
}));
expect(response.status).toBe(201);
expect(mockContentItemCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
data: JSON.stringify({
metrics: [{ value: '30%', label: '库存周转提升', basis: 'target' }],
categoryId: 'enterprise',
status: '研发中',
}),
}),
})
);
});
it('leaves keys the model never declares alone', async () => {
mockAuthorized();
mockProductModel();
const response = await POST(createMockRequest({
json: async () => ({
modelId: 'model-1',
modelCode: 'product',
title: '含未声明键',
data: { notDeclaredAtAll: { basis: 'whatever' }, freeform: '任意结构' },
}),
}));
expect(response.status).toBe(201);
});
it('stays permissive when the model row is missing or its fields column is dirty', async () => {
mockAuthorized();
mockContentModelFindUnique.mockResolvedValue({ id: 'model-1', code: 'product', fields: 'null' });
const missingModel = await POST(createMockRequest({
json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '无模型', data: {} }),
}));
expect(missingModel.status).toBe(201);
mockContentItemCreate.mockClear();
const dirtyFields = await POST(createMockRequest({
json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '脏声明', data: {} }),
}));
expect(dirtyFields.status).toBe(201);
});
it('rejects an invalid PUT payload before touching the row', async () => {
mockAuthorized();
mockProductModel();
mockContentItemFindUnique.mockResolvedValue({ ...mockItem, modelCode: 'product' });
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=item-1',
json: async () => ({ data: { status: '已上线' } }),
}));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.details.fields).toEqual([
expect.objectContaining({ path: 'status', rule: 'option' }),
]);
expect(mockContentItemUpdate).not.toHaveBeenCalled();
expect(mockAuditLogCreate).not.toHaveBeenCalled();
});
});
describe('脏 data 列不再打断读写(B-4)', () => {
it('returns {} instead of 500 when a stored data column is not valid JSON', async () => {
mockAuthorized();
mockContentItemFindMany.mockResolvedValue([{ ...mockItem, data: 'null' }]);
const response = await GET(createMockRequest({}));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.items[0].data).toEqual({});
});
it('serialises a null PUT payload as {} so the column stays readable', async () => {
mockAuthorized();
mockContentItemFindUnique.mockResolvedValue({ ...mockItem, data: 'null' });
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=item-1',
json: async () => ({ data: null }),
}));
expect(response.status).toBe(200);
expect(mockContentItemUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ data: '{}' }),
})
);
});
});
}); });
+68 -8
View File
@@ -1,16 +1,44 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { requirePermission } from '@/lib/permissions'; import { requirePermission } from '@/lib/permissions';
import { authenticateRequest } from '@/lib/auth';
import { import {
success, success,
notFound, notFound,
unauthorized,
validationError, validationError,
internalError, internalError,
} from '@/lib/api-response'; } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto'; import { withCrypto } from '@/lib/api-crypto';
import { parsePagination } from '@/lib/pagination';
import {
parseCmsData,
validateContentData,
type ContentFieldError,
} from '@/lib/cms/validate-content-data';
import type { FieldDefinition } from '@/lib/cms/types';
function parseItem(item: { data: string; [key: string]: unknown }) { function parseItem(item: { data: string; [key: string]: unknown }) {
return { ...item, data: JSON.parse(item.data as string) }; return { ...item, data: parseCmsData(item.data) };
}
/**
* 读取模型的字段声明用于写入侧校验(B-3)。
* 模型缺失或 fields 脏数据时返回空数组,即不施加任何声明约束,保持既有宽松行为。
*/
async function loadDeclaredFields(modelCode: string): Promise<FieldDefinition[]> {
try {
const model = await prisma.contentModel.findUnique({ where: { code: modelCode } });
if (!model) return [];
const fields = parseCmsData<FieldDefinition[]>(model.fields, []);
return Array.isArray(fields) ? fields : [];
} catch {
return [];
}
}
function fieldValidationError(errors: ContentFieldError[]) {
return validationError('内容字段校验未通过', { fields: errors });
} }
/** /**
@@ -58,11 +86,13 @@ export const GET = withCrypto(async (request: NextRequest) => {
try { try {
const status = searchParams.get('status'); const status = searchParams.get('status');
const search = searchParams.get('search'); const search = searchParams.get('search');
const page = parseInt(searchParams.get('page') || '1'); const id = searchParams.get('id');
const pageSize = parseInt(searchParams.get('pageSize') || '10'); const { page, pageSize } = parsePagination(searchParams, { maxPageSize: 100, defaultPageSize: 10 });
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (modelCode) where.modelCode = modelCode; if (modelCode) where.modelCode = modelCode;
// 编辑器按 id 定位单条内容:分页窗口(前 N 条)之外的条目否则永远取不到
if (id) where.id = id;
if (status) where.status = status; if (status) where.status = status;
if (search) { if (search) {
where.OR = [ where.OR = [
@@ -107,6 +137,20 @@ export const POST = withCrypto(async (request: NextRequest) => {
const permission = await requirePermission(request, modelCode, 'create'); const permission = await requirePermission(request, modelCode, 'create');
if ('response' in permission) return permission.response; if ('response' in permission) return permission.response;
// B-1:创建接口不能成为发布通道。status 只接受缺省或 'draft',其余取值一律拒绝并指向
// workflow 接口(那里才校验 publish 权限与合法流转)。这同时堵掉了任意 status 字符串
// 入库后「所有动作都返回 false、条目永久卡死且无反馈」的情况。
if (status !== undefined && status !== 'draft') {
return validationError(
'新建内容只能保存为草稿,状态变更请使用 /api/admin/items/[id]/workflow 接口',
);
}
const fieldErrors = validateContentData(await loadDeclaredFields(modelCode), data);
if (fieldErrors.length > 0) {
return fieldValidationError(fieldErrors);
}
// 未提供 slug 时自动生成唯一 slug;提供时检查唯一性 // 未提供 slug 时自动生成唯一 slug;提供时检查唯一性
const finalSlug = slug const finalSlug = slug
? slug ? slug
@@ -127,12 +171,12 @@ export const POST = withCrypto(async (request: NextRequest) => {
modelCode, modelCode,
title, title,
slug: finalSlug, slug: finalSlug,
status: status || 'draft', status: 'draft',
data: JSON.stringify(data || {}), data: JSON.stringify(data || {}),
sortOrder: sortOrder || 0, sortOrder: sortOrder || 0,
createdBy: permission.user.username, createdBy: permission.user.username,
updatedBy: permission.user.username, updatedBy: permission.user.username,
publishedAt: status === 'published' ? new Date() : null, publishedAt: null,
}, },
}); });
@@ -160,6 +204,9 @@ export const PUT = withCrypto(async (request: NextRequest) => {
const id = searchParams.get('id'); const id = searchParams.get('id');
if (!id) return validationError('缺少 ID'); if (!id) return validationError('缺少 ID');
// 存在性探针必须在认证之后:否则匿名调用者可用「404=存在 vs 401=不存在」枚举内容 ID(Q-8)。
if (!authenticateRequest(request)) return unauthorized();
const existing = await prisma.contentItem.findUnique({ where: { id } }); const existing = await prisma.contentItem.findUnique({ where: { id } });
if (!existing) return notFound('内容不存在'); if (!existing) return notFound('内容不存在');
@@ -180,11 +227,20 @@ export const PUT = withCrypto(async (request: NextRequest) => {
} }
} }
// 状态流转必须通过 /api/admin/items/[id]/workflow 进行 // 状态流转必须通过 /api/admin/items/[id]/workflow 进行。但「原样回传当前状态」不是流转:
if (status !== undefined) { // 管理端每次保存(含自动保存)都会把现有 status 一起 PUT 上来,若一概拒绝会让所有编辑保存变 400。
if (status !== undefined && status !== existing.status) {
return validationError('状态变更请使用 /api/admin/items/[id]/workflow 接口'); return validationError('状态变更请使用 /api/admin/items/[id]/workflow 接口');
} }
const fieldErrors = validateContentData(
await loadDeclaredFields(existing.modelCode),
data,
);
if (fieldErrors.length > 0) {
return fieldValidationError(fieldErrors);
}
const updateData: Record<string, unknown> = { const updateData: Record<string, unknown> = {
updatedBy: permission.user.username, updatedBy: permission.user.username,
updatedAt: new Date(), updatedAt: new Date(),
@@ -194,7 +250,8 @@ export const PUT = withCrypto(async (request: NextRequest) => {
if (title !== undefined) updateData.title = title; if (title !== undefined) updateData.title = title;
// slug 为空字符串时保留原值,避免触发唯一约束冲突 // slug 为空字符串时保留原值,避免触发唯一约束冲突
if (slug !== undefined && slug !== '') updateData.slug = slug; if (slug !== undefined && slug !== '') updateData.slug = slug;
if (data !== undefined) updateData.data = JSON.stringify(data); // data 为 null 时与 POST 同样落库为 `{}`,避免脏列让读取侧整页崩溃(B-4)
if (data !== undefined) updateData.data = JSON.stringify(data ?? {});
if (sortOrder !== undefined) updateData.sortOrder = sortOrder; if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
const item = await prisma.contentItem.update({ const item = await prisma.contentItem.update({
@@ -227,6 +284,9 @@ export const DELETE = withCrypto(async (request: NextRequest) => {
const id = searchParams.get('id'); const id = searchParams.get('id');
if (!id) return validationError('缺少 ID'); if (!id) return validationError('缺少 ID');
// Q-8:认证先于存在性探针,避免匿名枚举内容 ID。
if (!authenticateRequest(request)) return unauthorized();
const existing = await prisma.contentItem.findUnique({ where: { id } }); const existing = await prisma.contentItem.findUnique({ where: { id } });
if (!existing) return notFound('内容不存在'); if (!existing) return notFound('内容不存在');
+37
View File
@@ -22,6 +22,7 @@ jest.mock('@/lib/media/media-service', () => ({
})); }));
import { GET, POST, DELETE } from './route'; import { GET, POST, DELETE } from './route';
import { UploadPolicyError } from '@/lib/media/upload-policy';
function createMockRequest(options: { function createMockRequest(options: {
url?: string; url?: string;
@@ -206,6 +207,42 @@ describe('/api/admin/media', () => {
expect(body.error).toContain('10MB'); expect(body.error).toContain('10MB');
expect(mockUploadMedia).not.toHaveBeenCalled(); expect(mockUploadMedia).not.toHaveBeenCalled();
}); });
// Q-9:多文件批次里第二个文件未过策略时,第一个已成功写入的文件必须被回滚删除,
// 否则孤儿资产落盘 + 入库却无返回、无清理路径。旧实现无回滚 ⇒ mockDeleteMedia 不被调用 ⇒ RED。
it('rolls back already-uploaded files when a later file fails the policy check', async () => {
mockAuthorized();
mockUploadMedia
.mockResolvedValueOnce({ id: 'asset-1' })
.mockRejectedValueOnce(new UploadPolicyError('非法文件类型'));
mockDeleteMedia.mockResolvedValue(undefined);
const formData = new FormData();
formData.append('files', createTestFile('a', 'a.png', 'image/png'));
formData.append('files', createTestFile('b', 'b.png', 'image/png'));
const response = await POST(createMockRequest({ formData: async () => formData }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('非法文件类型');
expect(mockDeleteMedia).toHaveBeenCalledWith('asset-1');
});
// Q-9:预检尺寸——批次中任一文件超限,须在任何写入发生前整批拒绝。
it('rejects the whole batch without uploading when one file is oversized', async () => {
mockAuthorized();
mockUploadMedia.mockResolvedValue({ id: 'asset-x' });
const formData = new FormData();
formData.append('files', createTestFile('ok', 'ok.png', 'image/png'));
formData.append('files', createTestFile('big', 'big.png', 'image/png', 10 * 1024 * 1024 + 1));
const response = await POST(createMockRequest({ formData: async () => formData }));
expect(response.status).toBe(400);
expect(mockUploadMedia).not.toHaveBeenCalled();
});
}); });
describe('DELETE', () => { describe('DELETE', () => {
+33 -13
View File
@@ -12,6 +12,7 @@ import {
validationError, validationError,
internalError, internalError,
} from '@/lib/api-response'; } from '@/lib/api-response';
import { UploadPolicyError } from '@/lib/media/upload-policy';
import { withCrypto } from '@/lib/api-crypto'; import { withCrypto } from '@/lib/api-crypto';
const MODEL_CODE = 'media'; const MODEL_CODE = 'media';
@@ -68,30 +69,49 @@ export const POST = withCrypto(async (request: NextRequest) => {
return validationError('请选择文件'); return validationError('请选择文件');
} }
const results = []; // 预检尺寸:任一无关文件超限即整批拒绝,且此时尚未写入任何文件(不产生孤儿)。
for (const file of files) { for (const file of files) {
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
return validationError(`文件 "${file.name}" 大小不能超过 10MB`); return validationError(`文件 "${file.name}" 大小不能超过 10MB`);
} }
}
const bytes = await file.arrayBuffer(); const results: Awaited<ReturnType<typeof uploadMedia>>[] = [];
const buffer = Buffer.from(bytes); try {
for (const file of files) {
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const asset = await uploadMedia( const asset = await uploadMedia(
{ {
name: file.name, name: file.name,
buffer, buffer,
mimeType: file.type || 'application/octet-stream', mimeType: file.type || 'application/octet-stream',
size: file.size, size: file.size,
}, },
permission.user.username permission.user.username
); );
results.push(asset); results.push(asset);
}
} catch (error) {
// 多文件批次中途失败(如某个文件未过 magic-byte/类型策略):回滚本请求已成功写入的文件,
// 否则前面的文件已落盘 + 入库,而请求返回错误 → 形成无返回、无清理路径的孤儿资产(Q-9)。
if (results.length > 0) {
await Promise.all(results.map((a) => deleteMedia(a.id).catch(() => {})));
}
if (error instanceof UploadPolicyError) {
return validationError(error.message);
}
console.error('Upload media error:', error);
return internalError();
} }
return success(files.length === 1 ? results[0] : results, 201); return success(files.length === 1 ? results[0] : results, 201);
} catch (error) { } catch (error) {
if (error instanceof UploadPolicyError) {
return validationError(error.message);
}
console.error('Upload media error:', error); console.error('Upload media error:', error);
return internalError(); return internalError();
} }
+130 -4
View File
@@ -4,10 +4,12 @@ import { NextRequest } from 'next/server';
// ─── Mock @/lib/db ──────────────────────────────────────────────────────── // ─── Mock @/lib/db ────────────────────────────────────────────────────────
const mockRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>(); const mockRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockPermissionFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>(); const mockPermissionFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockPermissionDeleteMany = jest.fn<(args?: unknown) => Promise<unknown>>(); const mockPermissionDeleteMany = jest.fn<(args?: unknown) => unknown>();
const mockPermissionCreate = jest.fn<(args?: unknown) => Promise<unknown>>(); const mockPermissionCreate = jest.fn<(args?: unknown) => unknown>();
const mock$transaction = jest.fn<(ops: unknown[], options?: unknown) => unknown>();
const mockContentModelFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>(); const mockContentModelFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockUserRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>(); const mockUserRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockUserCount = jest.fn<(args?: unknown) => Promise<number>>();
const mockRoleFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>(); const mockRoleFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>();
jest.mock('@/lib/db', () => ({ jest.mock('@/lib/db', () => ({
@@ -27,6 +29,10 @@ jest.mock('@/lib/db', () => ({
userRole: { userRole: {
findMany: mockUserRoleFindMany, findMany: mockUserRoleFindMany,
}, },
user: {
count: mockUserCount,
},
$transaction: mock$transaction,
}, },
})); }));
@@ -66,9 +72,29 @@ const mockPermissions = [
function mockAuthenticated(roleCodes: string[]) { function mockAuthenticated(roleCodes: string[]) {
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'admin' }); mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'admin' });
mockUserCount.mockResolvedValue(1); // 默认「启用」账号(Q-4 存活判定)
mockUserRoleFindMany.mockResolvedValue(roleCodes.map((code) => ({ roleCode: code }))); mockUserRoleFindMany.mockResolvedValue(roleCodes.map((code) => ({ roleCode: code })));
} }
/**
* Prisma 的 PrismaPromise 是「惰性 + 可 then」的对象。这里用同样的形状冒充,
* 一旦被 await(= 独立提交)就登记标签,从而能断言
* 「deleteMany/create 只作为事务数组的成员交给 $transaction,没有被单独提交」。
*/
function thenableOp<T>(label: string, value: T, awaited: string[]) {
return {
then: jest.fn(
(
onFulfilled?: ((v: T) => unknown) | null,
_onRejected?: ((e: unknown) => unknown) | null
) => {
awaited.push(label);
return Promise.resolve(onFulfilled ? onFulfilled(value) : undefined);
}
),
};
}
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@@ -170,8 +196,17 @@ describe('PUT /api/admin/roles', () => {
it('replaces permissions and filters invalid actions', async () => { it('replaces permissions and filters invalid actions', async () => {
mockAuthenticated(['super_admin']); mockAuthenticated(['super_admin']);
mockRoleFindUnique.mockResolvedValue({ code: 'content_editor', name: '内容编辑' }); mockRoleFindUnique.mockResolvedValue({ code: 'content_editor', name: '内容编辑' });
mockPermissionDeleteMany.mockResolvedValue({ count: 2 });
mockPermissionCreate.mockResolvedValue({}); const awaited: string[] = [];
const deleteOp = thenableOp('deleteMany', { count: 2 }, awaited);
const createOps = [
thenableOp('create:news:read', { id: 'p1' }, awaited),
thenableOp('create:news:update', { id: 'p2' }, awaited),
];
mockPermissionDeleteMany.mockReturnValue(deleteOp);
let created = 0;
mockPermissionCreate.mockImplementation(() => createOps[created++]);
mock$transaction.mockReturnValue(thenableOp('transaction', [], awaited));
const response = await PUT( const response = await PUT(
createMockRequest({ createMockRequest({
@@ -181,6 +216,7 @@ describe('PUT /api/admin/roles', () => {
{ modelCode: 'news', action: 'update' }, { modelCode: 'news', action: 'update' },
{ modelCode: 'news', action: 'hack' }, { modelCode: 'news', action: 'hack' },
{ modelCode: '', action: 'read' }, { modelCode: '', action: 'read' },
{ modelCode: 'news', action: 'read' }, // 重复项应被去重
], ],
}) })
); );
@@ -203,5 +239,95 @@ describe('PUT /api/admin/roles', () => {
expect(body.permissions).toContain('news:read'); expect(body.permissions).toContain('news:read');
expect(body.permissions).toContain('news:update'); expect(body.permissions).toContain('news:update');
expect(body.permissions).not.toContain('news:hack'); expect(body.permissions).not.toContain('news:hack');
// 删除 + 重建作为「一个原子单元」交给 $transaction:数组顺序即执行顺序
expect(mock$transaction).toHaveBeenCalledTimes(1);
expect(mock$transaction.mock.calls[0]![0]).toEqual([deleteOp, createOps[0], createOps[1]]);
// 除事务本身外,没有任何一条语句被独立 await(= 独立提交)
expect(awaited).toEqual(['transaction']);
});
it('commits the rewrite through $transaction so a failing create cannot leave a half-written role', async () => {
mockAuthenticated(['super_admin']);
mockRoleFindUnique.mockResolvedValue({ code: 'content_editor', name: '内容编辑' });
const awaited: string[] = [];
const deleteOp = thenableOp('deleteMany', { count: 5 }, awaited);
const createOps = [
thenableOp('create:news:read', { id: 'p1' }, awaited),
thenableOp('create:product:read', { id: 'p2' }, awaited),
];
mockPermissionDeleteMany.mockReturnValue(deleteOp);
let created = 0;
mockPermissionCreate.mockImplementation(() => createOps[created++]);
// 第 N 条 create 失败:事务整体回滚,$transaction 直接 reject
mock$transaction.mockImplementation(() =>
Promise.reject(new Error('Unique constraint failed on Permission'))
);
const response = await PUT(
createMockRequest({
roleCode: 'content_editor',
permissions: [
{ modelCode: 'news', action: 'read' },
{ modelCode: 'product', action: 'read' },
],
})
);
const body = await response.json();
expect(response.status).toBe(500);
expect(body.code).toBe('INTERNAL_ERROR');
// 失败前没有任何语句被独立提交:整批操作要么全成、要么全滚
expect(awaited).toEqual([]);
expect(mock$transaction.mock.calls[0]![0]).toHaveLength(3);
});
it('clears every permission through a single transaction when the new list is empty', async () => {
mockAuthenticated(['super_admin']);
mockRoleFindUnique.mockResolvedValue({ code: 'readonly', name: '只读' });
const awaited: string[] = [];
const deleteOp = thenableOp('deleteMany', { count: 3 }, awaited);
mockPermissionDeleteMany.mockReturnValue(deleteOp);
mock$transaction.mockReturnValue(thenableOp('transaction', [], awaited));
const response = await PUT(createMockRequest({ roleCode: 'readonly', permissions: [] }));
expect(response.status).toBe(200);
expect(mockPermissionCreate).not.toHaveBeenCalled();
expect(mock$transaction).toHaveBeenCalledTimes(1);
expect(mock$transaction.mock.calls[0]![0]).toEqual([deleteOp]);
expect(awaited).toEqual(['transaction']);
}); });
}); });
/**
* Q-4(N-20 同类):roles GET/PUT 走内联 super_admin 检查但不看 User.status,
* 被停用的超管凭 ≤24h 令牌仍能读权限矩阵或整体重写它(deleteMany+create)。
* 修复后须先过 authenticateActiveRequest 的存活判定,否则 401 且不触达任何写。
*/
describe('Q-4 停用账号令牌:admin/roles 一律 401 且零写入', () => {
it('停用的 super_admin 不能读取角色权限', async () => {
mockAuthenticated(['super_admin']);
mockUserCount.mockResolvedValue(0); // status !== 1
const response = await GET(createMockRequest());
expect(response.status).toBe(401);
});
it('停用的 super_admin 不能重写权限矩阵', async () => {
mockAuthenticated(['super_admin']);
mockUserCount.mockResolvedValue(0);
const response = await PUT(
createMockRequest({ roleCode: 'content_editor', permissions: [{ modelCode: 'news', action: 'read' }] })
);
expect(response.status).toBe(401);
expect(mock$transaction).not.toHaveBeenCalled();
expect(mockPermissionDeleteMany).not.toHaveBeenCalled();
expect(mockPermissionCreate).not.toHaveBeenCalled();
});
});
+24 -13
View File
@@ -1,7 +1,8 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth'; import type { Prisma } from '@/generated/prisma/client';
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response'; import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
import { authenticateActiveRequest } from '@/lib/permissions';
import { withCrypto } from '@/lib/api-crypto'; import { withCrypto } from '@/lib/api-crypto';
// 内置角色,不允许删除 // 内置角色,不允许删除
@@ -9,7 +10,7 @@ const BUILTIN_ROLES = new Set(['super_admin', 'content_admin', 'content_editor',
// GET /api/admin/roles - 获取角色列表及其权限 // GET /api/admin/roles - 获取角色列表及其权限
export const GET = withCrypto(async (request: NextRequest) => { export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = await authenticateActiveRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
// 仅管理员可查看角色权限 // 仅管理员可查看角色权限
@@ -51,7 +52,7 @@ export const GET = withCrypto(async (request: NextRequest) => {
// PUT /api/admin/roles/:roleCode - 更新角色权限 // PUT /api/admin/roles/:roleCode - 更新角色权限
export const PUT = withCrypto(async (request: NextRequest) => { export const PUT = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = await authenticateActiveRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } }); const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
@@ -77,25 +78,35 @@ export const PUT = withCrypto(async (request: NextRequest) => {
const role = await prisma.role.findUnique({ where: { code: roleCode } }); const role = await prisma.role.findUnique({ where: { code: roleCode } });
if (!role) return validationError('角色不存在'); if (!role) return validationError('角色不存在');
// 先删除该角色所有权限,再重新写入
await prisma.permission.deleteMany({ where: { roleCode } });
const validActions = new Set(['create', 'read', 'update', 'delete', 'publish']); const validActions = new Set(['create', 'read', 'update', 'delete', 'publish']);
const uniqueKeys = new Set<string>(); const uniqueKeys = new Set<string>();
const permissionWrites: Prisma.PrismaPromise<unknown>[] = [];
for (const perm of permissions || []) { for (const perm of permissions || []) {
if (!perm.modelCode || !validActions.has(perm.action)) continue; if (!perm.modelCode || !validActions.has(perm.action)) continue;
const key = `${perm.modelCode}:${perm.action}`; const key = `${perm.modelCode}:${perm.action}`;
if (uniqueKeys.has(key)) continue; if (uniqueKeys.has(key)) continue;
uniqueKeys.add(key); uniqueKeys.add(key);
await prisma.permission.create({ // 只构造查询、不 await:交由下面的事务统一提交
data: { permissionWrites.push(
roleCode, prisma.permission.create({
modelCode: perm.modelCode, data: {
action: perm.action, roleCode,
}, modelCode: perm.modelCode,
}); action: perm.action,
},
})
);
} }
// 清空 + 重建必须作为「单个原子单元」提交(验收 B-5):
// 原先 deleteMany 与逐条 create 分别 await,任一 create 失败即留下空权限/半权限角色
// (既是降权也是提权方向)。$transaction(数组) 在 Prisma 中于同一事务内顺序执行,
// 任一语句失败整体回滚。签名见 src/generated/prisma/internal/class.ts:200。
await prisma.$transaction([
prisma.permission.deleteMany({ where: { roleCode } }),
...permissionWrites,
]);
return success({ roleCode, permissions: Array.from(uniqueKeys) }); return success({ roleCode, permissions: Array.from(uniqueKeys) });
} catch (error) { } catch (error) {
console.error('Update roles error:', error); console.error('Update roles error:', error);
+7 -4
View File
@@ -1,13 +1,16 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth'; import { requirePermission } from '@/lib/permissions';
import { success, unauthorized, internalError } from '@/lib/api-response'; import { success, internalError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto'; import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/stats - 获取仪表盘统计数据 // GET /api/admin/stats - 获取仪表盘统计数据
export const GET = withCrypto(async (request: NextRequest) => { export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); // 原实现只验「有没有会话」,而本路由会返回跨全部模型的最近内容条目(title/modelCode/status),
if (!user) return unauthorized(); // 任何登录账号(含零权限角色)都能读到受保护草稿 ⇒ 升级为 content-model 读权限(N-19)。
const permission = await requirePermission(request, 'content-model', 'read');
if ('response' in permission) return permission.response;
const user = permission.user;
try { try {
const [modelCount, itemCount, zoneCount, userCount, pendingReviewCount, unreadCount] = await Promise.all([ const [modelCount, itemCount, zoneCount, userCount, pendingReviewCount, unreadCount] = await Promise.all([
+320
View File
@@ -0,0 +1,320 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
const mockUserRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockUserRoleCreate = jest.fn<(args?: unknown) => Promise<unknown>>();
const mockUserRoleDeleteMany = jest.fn<(args?: unknown) => Promise<unknown>>();
const mockUserRoleCount = jest.fn<(args?: unknown) => Promise<number>>();
const mockRoleFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>();
const mockUserFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>();
const mockUserCount = jest.fn<(args?: unknown) => Promise<number>>();
const mockUserCreate = jest.fn<(args?: unknown) => Promise<unknown>>();
const mockUserUpdate = jest.fn<(args?: unknown) => Promise<unknown>>();
jest.mock('@/lib/db', () => ({
prisma: {
userRole: {
findMany: mockUserRoleFindMany,
create: mockUserRoleCreate,
deleteMany: mockUserRoleDeleteMany,
count: mockUserRoleCount,
},
role: { findUnique: mockRoleFindUnique },
user: {
findUnique: mockUserFindUnique,
create: mockUserCreate,
update: mockUserUpdate,
count: mockUserCount,
},
},
}));
// ─── Mock @/lib/auth ──────────────────────────────────────────────────────
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
const mockHashPassword = jest.fn<(pw: string) => Promise<string>>();
jest.mock('@/lib/auth', () => ({
authenticateRequest: mockAuthenticateRequest,
hashPassword: mockHashPassword,
}));
import { POST, PUT } from './route';
/**
* 调用者身份 + 目标用户 URL。PUT 通过 query `?id=` 指定被改用户,
* 该参数完全由攻击者控制,是 A-2 的攻击面入口。
*/
function req(opts: {
callerId?: string;
targetId?: string;
body?: Record<string, unknown>;
}): NextRequest {
const qs = opts.targetId ? `?id=${opts.targetId}` : '';
return {
url: `http://localhost/api/admin/users${qs}`,
headers: new Headers(),
json: async () => opts.body ?? {},
} as unknown as NextRequest;
}
/** 调用者角色 → userRole.findMany 按其 userId 分支返回。 */
function callers(map: Record<string, string[]>) {
mockUserRoleFindMany.mockImplementation(async (args: unknown) => {
const userId = (args as { where: { userId: string } }).where.userId;
return (map[userId] ?? []).map((roleCode) => ({ roleCode }));
});
}
const ALL_ROLES_EXIST = async () => ({ code: 'anything', name: '角色' });
beforeEach(() => {
jest.clearAllMocks();
mockHashPassword.mockResolvedValue('hashed');
mockRoleFindUnique.mockImplementation(ALL_ROLES_EXIST);
mockUserFindUnique.mockResolvedValue(null);
// 默认调用者为「启用」账号(authenticateActiveRequest 以 user.count(id,status:1) 判活)。
mockUserCount.mockResolvedValue(1);
mockUserCreate.mockResolvedValue({
id: 'u-new',
username: 'victim',
nickname: '',
email: '',
phone: '',
});
mockUserUpdate.mockResolvedValue({ id: 'u-target' });
});
describe('POST /api/admin/users — A-1 角色授予越权', () => {
it('content_admin 不得创建 super_admin 账号', async () => {
callers({ attacker: ['content_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
const res = await POST(
req({ body: { username: 'evil', password: 'Passw0rd!', roleCodes: ['super_admin'] } })
);
expect(res.status).toBe(403);
expect(mockUserRoleCreate).not.toHaveBeenCalled();
});
it('content_admin 不得借「先建号再自我提权」绕过:只允许授予非特权角色', async () => {
callers({ attacker: ['content_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
const res = await POST(
req({ body: { username: 'evil', password: 'Passw0rd!', roleCodes: ['content_editor'] } })
);
expect(res.status).toBe(201);
expect(mockUserRoleCreate).toHaveBeenCalledWith({
data: { userId: 'u-new', roleCode: 'content_editor' },
});
});
it('super_admin 可以创建 super_admin', async () => {
callers({ boss: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'boss', username: 'boss' });
const res = await POST(
req({ body: { username: 'peer', password: 'Passw0rd!', roleCodes: ['super_admin'] } })
);
expect(res.status).toBe(201);
expect(mockUserRoleCreate).toHaveBeenCalledWith({
data: { userId: 'u-new', roleCode: 'super_admin' },
});
});
it('未声明角色时仍回落 readonly(原行为不变)', async () => {
callers({ boss: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'boss', username: 'boss' });
const res = await POST(req({ body: { username: 'plain', password: 'Passw0rd!' } }));
expect(res.status).toBe(201);
expect(mockUserRoleCreate).toHaveBeenCalledWith({
data: { userId: 'u-new', roleCode: 'readonly' },
});
});
});
describe('PUT /api/admin/users — A-2 凭据/状态接管', () => {
beforeEach(() => {
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
});
it('content_admin 不得重置他人密码', async () => {
callers({ attacker: ['content_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { password: 'Pwned123!' } }));
expect(res.status).toBe(403);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('content_admin 不得停用超管账号(DoS 接管面)', async () => {
callers({ attacker: ['content_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { status: 0 } }));
expect(res.status).toBe(403);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('content_admin 不得把超管降级为 readonly(撤销方向同样受限)', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { roleCodes: ['readonly'] } }));
expect(res.status).toBe(403);
expect(mockUserRoleDeleteMany).not.toHaveBeenCalled();
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('任何写操作前必须完成鉴权:资料字段不得先落库再被角色校验拒绝', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
const res = await PUT(
req({ targetId: 'u-target', body: { nickname: 'pwned', roleCodes: ['readonly'] } })
);
expect(res.status).toBe(403);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('super_admin 可以重置他人密码', async () => {
callers({ attacker: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
const res = await PUT(req({ targetId: 'u-target', body: { password: 'Rotated123!' } }));
expect(res.status).toBe(200);
expect(mockUserUpdate).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'u-target' } })
);
});
it('本人仍可修改自己的密码(自助改密不被误伤)', async () => {
callers({ me: ['content_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'me', username: 'me' });
const res = await PUT(req({ targetId: 'me', body: { password: 'Mine12345!' } }));
expect(res.status).toBe(200);
});
it('content_admin 仍可编辑普通用户的资料(未被过度收紧)', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['content_editor'] });
const res = await PUT(req({ targetId: 'u-target', body: { nickname: '新昵称' } }));
expect(res.status).toBe(200);
expect(mockUserUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ nickname: '新昵称' }) })
);
});
});
/**
* 账号级特权护栏的「只改资料」形态。裁定依据 ACCEPTANCE_REVIEW_2026-09-21.md §A-2 修复口径:
* 「目标持有 super_admin 时同样要求调用者为 super_admin」—— 条件挂在目标角色上,不挂在
* 「本次是否提交 roleCodes」上。故省略 roleCodes 不是放行理由,本组用例把它钉成回归契约。
*/
describe('PUT /api/admin/users — 超管账号的资料编辑(A-2 口径:整次 PUT 拒绝,非仅角色变更)', () => {
beforeEach(() => {
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
});
it('profile-only PUT(省略 roleCodes)改超管昵称 ⇒ 403 且零写入', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { nickname: 'pwned' } }));
expect(res.status).toBe(403);
// 零写入 = user.update / userRole.deleteMany / userRole.create 全未触达
expect(mockUserUpdate).not.toHaveBeenCalled();
expect(mockUserRoleDeleteMany).not.toHaveBeenCalled();
expect(mockUserRoleCreate).not.toHaveBeenCalled();
});
it('被拒的 profile-only 请求给出「编辑超管账号」文案,而非「变动角色」文案(避免误读为实现疏漏)', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { phone: '13800000000' } }));
const payload = (await res.json()) as { error?: string };
expect(payload.error).toContain('无权编辑持有特权角色的账号');
expect(payload.error).toContain('super_admin');
expect(payload.error).not.toContain('无权变动特权角色');
});
it('真实 UI 形态(roleCodes 原样回传 + 只改昵称)同样 403 —— 与省略形态结果一致,无旁路', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
// src/app/admin/users/page.tsx 的编辑表单总是回传 roleCodes,并用目标现有角色预填
const res = await PUT(
req({ targetId: 'u-target', body: { nickname: 'pwned', roleCodes: ['super_admin'] } })
);
const payload = (await res.json()) as { error?: string };
expect(res.status).toBe(403);
expect(payload.error).toContain('无权变动特权角色');
expect(mockUserUpdate).not.toHaveBeenCalled();
expect(mockUserRoleDeleteMany).not.toHaveBeenCalled();
expect(mockUserRoleCreate).not.toHaveBeenCalled();
});
it('super_admin 改超管账号资料仍放行(护栏按调用者分级,不是把该账号封死)', async () => {
callers({ attacker: ['super_admin'], 'u-target': ['super_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { nickname: '正确昵称' } }));
expect(res.status).toBe(200);
expect(mockUserUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ nickname: '正确昵称' }) })
);
});
it('省略 roleCodes 不是绕开凭据校验的后门:改普通用户状态依旧 403', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['content_editor'] });
const res = await PUT(req({ targetId: 'u-target', body: { status: 0 } }));
expect(res.status).toBe(403);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
});
/**
* Q-4(N-20 同类):这些 handler 走 authenticateRequest + 内联 role 检查,曾完全不看 User.status,
* 于是「被停用但仍持 ≤24h 有效令牌」的超管仍能列/建/改/删用户。修复后须经
* authenticateActiveRequest 的存活判定(user.count(id,status:1))在角色检查与任何写入之前拒绝。
*/
describe('Q-4 停用账号令牌:admin/users 一律 401 且零写入', () => {
it('停用的 super_admin 不能创建用户', async () => {
callers({ ghost: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'ghost', username: 'ghost' });
mockUserCount.mockResolvedValue(0); // status !== 1 ⇒ 判为不存活
const res = await POST(
req({ body: { username: 'evil', password: 'Passw0rd!', roleCodes: ['content_editor'] } })
);
expect(res.status).toBe(401);
expect(mockUserCreate).not.toHaveBeenCalled();
expect(mockUserRoleCreate).not.toHaveBeenCalled();
});
it('停用的 super_admin 不能编辑他人(含重置密码)', async () => {
callers({ ghost: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'ghost', username: 'ghost' });
mockUserCount.mockResolvedValue(0);
const res = await PUT(req({ targetId: 'u-target', body: { password: 'Pwned123!' } }));
expect(res.status).toBe(401);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
});
+72 -22
View File
@@ -1,12 +1,14 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { authenticateRequest, hashPassword } from '@/lib/auth'; import { hashPassword } from '@/lib/auth';
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response'; import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
import { authenticateActiveRequest, mayChangeCredentials, privilegedRolesInPlay } from '@/lib/permissions';
import { withCrypto } from '@/lib/api-crypto'; import { withCrypto } from '@/lib/api-crypto';
import { parsePagination } from '@/lib/pagination';
// GET /api/admin/users - 获取用户列表 // GET /api/admin/users - 获取用户列表
export const GET = withCrypto(async (request: NextRequest) => { export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = await authenticateActiveRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } }); const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
@@ -17,8 +19,7 @@ export const GET = withCrypto(async (request: NextRequest) => {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10)); const { page, pageSize } = parsePagination(searchParams, { defaultPageSize: 20, maxPageSize: 100 });
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20', 10)));
const search = searchParams.get('search') || ''; const search = searchParams.get('search') || '';
const where = search const where = search
@@ -82,7 +83,7 @@ export const GET = withCrypto(async (request: NextRequest) => {
// POST /api/admin/users - 创建用户 // POST /api/admin/users - 创建用户
export const POST = withCrypto(async (request: NextRequest) => { export const POST = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = await authenticateActiveRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } }); const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
@@ -113,6 +114,19 @@ export const POST = withCrypto(async (request: NextRequest) => {
return validationError('密码长度不能少于 6 位'); return validationError('密码长度不能少于 6 位');
} }
// 分配角色:先定角色再建号,避免越权请求留下无角色的垃圾账号
const rolesToAssign = body.roleCodes?.length ? body.roleCodes : ['readonly'];
const blocked = privilegedRolesInPlay(roleCodes, rolesToAssign, []);
if (blocked.length > 0) {
return forbidden(`无权授予特权角色:${blocked.join(', ')}`);
}
// 残留风险(**按设计保留**,非疏漏):护栏只拦 PRIVILEGED_ROLE_CODES,故 content_admin 仍可
// 建出同类(授予 content_admin)乃至 content_editor / reviewer 等自己不持有的角色。
// 与 roles/route.ts 对比:该路由的 GET/PUT 一律限 super_admin,且显式禁改 super_admin 自身权限,
// 即「角色能做什么」由超管独占,而「谁能被分配一个非特权角色」下放给内容管理员 ——
// 这是 A-1 修复口径(ACCEPTANCE_REVIEW §A-1:仅要求「非 super_admin 不得授予 super_admin」)
// 刻意留下的授权面;收窄它需另行裁定,不在本次变更范围内。
// 检查用户名是否已存在 // 检查用户名是否已存在
const existing = await prisma.user.findUnique({ where: { username: body.username } }); const existing = await prisma.user.findUnique({ where: { username: body.username } });
if (existing) { if (existing) {
@@ -131,8 +145,6 @@ export const POST = withCrypto(async (request: NextRequest) => {
}, },
}); });
// 分配角色
const rolesToAssign = body.roleCodes?.length ? body.roleCodes : ['readonly'];
for (const roleCode of rolesToAssign) { for (const roleCode of rolesToAssign) {
const role = await prisma.role.findUnique({ where: { code: roleCode } }); const role = await prisma.role.findUnique({ where: { code: roleCode } });
if (role) { if (role) {
@@ -158,7 +170,7 @@ export const POST = withCrypto(async (request: NextRequest) => {
// PUT /api/admin/users - 更新用户 // PUT /api/admin/users - 更新用户
export const PUT = withCrypto(async (request: NextRequest) => { export const PUT = withCrypto(async (request: NextRequest) => {
const currentUser = authenticateRequest(request); const currentUser = await authenticateActiveRequest(request);
if (!currentUser) return unauthorized(); if (!currentUser) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } }); const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } });
@@ -181,13 +193,61 @@ export const PUT = withCrypto(async (request: NextRequest) => {
roleCodes?: string[]; roleCodes?: string[];
}; };
const isTargetSelf = userId === currentUser.userId;
const targetRoleCodes = (await prisma.userRole.findMany({ where: { userId } })).map(
(ur) => ur.roleCode
);
// ── 全部授权判定必须先于任何写入 ──
// 原实现在 user.update 之后才做角色校验,被拒时资料字段已经落库。
//
// 账号级特权护栏(验收 A-1 / A-2):调用者非 super_admin 时,只要该账号「变更前或变更后」
// 处于 super_admin,就整次 PUT 拒绝 —— 包括 body.roleCodes 缺省的「只改资料」形态。
// 省略 roleCodes 时把目标现有角色并入判定是**裁定过的策略**,不是回落到默认值的疏漏:
// · 依据 ACCEPTANCE_REVIEW_2026-09-21.md §A-2 修复口径 ——「目标持有 super_admin 时同样要求
// 调用者为 super_admin」,条件挂在目标的角色上,而非「本次是否改角色」上。非超管调用者
// 改超管资料,本质仍是「在不居于其上的账号上写入」:昵称/邮箱会展示在用户列表
// (users/page.tsx 表格)与超管自己的后台侧栏、仪表盘上,改之即可冒充该身份、
// 在管理面内制造误导。这条边界不因请求少了个键而消失。
// · 仓库内唯一的真实调用方 src/app/admin/users/page.tsx 的编辑表单**总是**回传 roleCodes
// (handleSave 组包处),且 openEditDialog 用目标现有角色预填,所以「省略 roleCodes」
// 只可能来自手写/被篡改的请求,不存在被误伤的正常用户流程。
// · 若只在 body.roleCodes 存在时才判定,同一意图(改超管昵称)会因为「有没有带那个键」
// 而一个 403 一个 200,UI 那条仍然 403 —— 策略不可解释,且给直接调 API 的客户端留了旁路。
const resultingRoleCodes = body.roleCodes ?? targetRoleCodes;
const blockedRoles = privilegedRolesInPlay(roleCodes, resultingRoleCodes, targetRoleCodes);
if (blockedRoles.length > 0) {
// 文案按「是否提交角色」分开,避免把资料编辑误读成角色校验写错了对象
return forbidden(
body.roleCodes
? `无权变动特权角色:${blockedRoles.join(', ')}`
: `无权编辑持有特权角色的账号:${blockedRoles.join(', ')}(仅超级管理员可修改其资料)`
);
}
// 不能移除自己的超级管理员角色(否则会自锁在管理面之外)
if (
isTargetSelf &&
body.roleCodes &&
targetRoleCodes.includes('super_admin') &&
!body.roleCodes.includes('super_admin')
) {
return forbidden('不能移除自己的超级管理员角色');
}
const wantsPassword = Boolean(body.password);
const wantsStatus = body.status !== undefined;
if ((wantsPassword || wantsStatus) && !mayChangeCredentials(roleCodes, isTargetSelf)) {
return forbidden('仅超级管理员可重置他人密码或变更他人状态');
}
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (body.nickname !== undefined) updateData.nickname = body.nickname; if (body.nickname !== undefined) updateData.nickname = body.nickname;
if (body.email !== undefined) updateData.email = body.email; if (body.email !== undefined) updateData.email = body.email;
if (body.phone !== undefined) updateData.phone = body.phone; if (body.phone !== undefined) updateData.phone = body.phone;
if (body.status !== undefined) updateData.status = body.status; if (wantsStatus) updateData.status = body.status;
if (body.password) { if (wantsPassword) {
updateData.password = await hashPassword(body.password); updateData.password = await hashPassword(body.password as string);
} }
await prisma.user.update({ await prisma.user.update({
@@ -197,16 +257,6 @@ export const PUT = withCrypto(async (request: NextRequest) => {
// 更新角色分配 // 更新角色分配
if (body.roleCodes) { if (body.roleCodes) {
// 防止将自己从 super_admin 移除
if (userId === currentUser.userId) {
const currentRoles = await prisma.userRole.findMany({ where: { userId } });
const currentIsSuperAdmin = currentRoles.some((r) => r.roleCode === 'super_admin');
const stillHasSuperAdmin = body.roleCodes.includes('super_admin');
if (currentIsSuperAdmin && !stillHasSuperAdmin) {
return forbidden('不能移除自己的超级管理员角色');
}
}
await prisma.userRole.deleteMany({ where: { userId } }); await prisma.userRole.deleteMany({ where: { userId } });
for (const roleCode of body.roleCodes) { for (const roleCode of body.roleCodes) {
const role = await prisma.role.findUnique({ where: { code: roleCode } }); const role = await prisma.role.findUnique({ where: { code: roleCode } });
@@ -225,7 +275,7 @@ export const PUT = withCrypto(async (request: NextRequest) => {
// DELETE /api/admin/users - 删除用户 // DELETE /api/admin/users - 删除用户
export const DELETE = withCrypto(async (request: NextRequest) => { export const DELETE = withCrypto(async (request: NextRequest) => {
const currentUser = authenticateRequest(request); const currentUser = await authenticateActiveRequest(request);
if (!currentUser) return unauthorized(); if (!currentUser) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } }); const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } });
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import type { NextRequest } from 'next/server';
import bcrypt from 'bcryptjs';
const mockFindUnique = jest.fn<(args: unknown) => Promise<unknown>>();
jest.mock('@/lib/db', () => ({
prisma: {
user: {
findUnique: mockFindUnique,
},
},
}));
const mockVerifyPassword = jest.fn<(password: string, hash: string) => Promise<boolean>>();
const mockGenerateTokens = jest.fn<(payload: unknown) => { accessToken: string; refreshToken: string }>();
const mockSetTokenCookie = jest.fn();
jest.mock('@/lib/auth', () => ({
verifyPassword: (password: string, hash: string) => mockVerifyPassword(password, hash),
generateTokens: (payload: unknown) => mockGenerateTokens(payload),
setTokenCookie: (
response: unknown,
accessToken: string,
refreshToken: string,
secure: boolean,
) => mockSetTokenCookie(response, accessToken, refreshToken, secure),
}));
jest.unmock('./route');
import { POST, loginRateLimiter } from './route';
function createRequest(body: Record<string, unknown>): NextRequest {
return {
url: 'http://localhost:3000/api/auth/login',
nextUrl: { protocol: 'http:' },
headers: new Headers(),
json: async () => body,
} as unknown as NextRequest;
}
// 真实 cost-10 哈希:用于比对「未知账号」分支付出的 bcrypt 工作量与真实账号一致。
const storedHash = bcrypt.hashSync('correct-password', 10);
const activeUser = {
id: 'user-1',
username: 'editor',
nickname: '编辑',
password: storedHash,
role: 'content_editor',
status: 1,
avatar: '',
};
const disabledUser = { ...activeUser, id: 'user-2', username: 'banned', status: 0 };
beforeEach(() => {
jest.clearAllMocks();
loginRateLimiter.reset();
mockFindUnique.mockResolvedValue(activeUser);
mockVerifyPassword.mockResolvedValue(true);
mockGenerateTokens.mockReturnValue({ accessToken: 'access-1', refreshToken: 'refresh-1' });
});
describe('POST /api/auth/login', () => {
it('returns 400 when username or password is missing', async () => {
const response = await POST(createRequest({ username: 'editor' }));
expect(response.status).toBe(400);
expect(mockVerifyPassword).not.toHaveBeenCalled();
});
it('runs bcrypt verification even for an unknown username (no timing shortcut)', async () => {
mockFindUnique.mockResolvedValue(null);
const response = await POST(createRequest({ username: 'nobody', password: 'hunter2' }));
expect(response.status).toBe(401);
expect(mockVerifyPassword).toHaveBeenCalledTimes(1);
const [passwordArg, hashArg] = mockVerifyPassword.mock.calls[0]!;
expect(passwordArg).toBe('hunter2');
expect(hashArg).toMatch(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/);
expect(hashArg).not.toBe(activeUser.password);
// cost factor 与 hashPassword(10) 一致 ⇒ 未知账号不会因少跑一轮 bcrypt 而更快响应(B-2)。
expect(bcrypt.getRounds(hashArg)).toBe(bcrypt.getRounds(storedHash));
expect(bcrypt.compareSync('hunter2', hashArg)).toBe(false);
expect(mockGenerateTokens).not.toHaveBeenCalled();
});
it('gives unknown and disabled accounts an identical response shape once the password fails', async () => {
mockFindUnique.mockResolvedValue(null);
mockVerifyPassword.mockResolvedValue(false);
const unknownResponse = await POST(createRequest({ username: 'nobody', password: 'wrong' }));
const unknownBody = await unknownResponse.json();
mockFindUnique.mockResolvedValue({ ...disabledUser, status: 1 });
mockVerifyPassword.mockResolvedValue(false);
const enabledWrongResponse = await POST(
createRequest({ username: 'editor', password: 'wrong' }),
);
const enabledWrongBody = await enabledWrongResponse.json();
expect(unknownResponse.status).toBe(enabledWrongResponse.status);
expect(unknownBody).toEqual(enabledWrongBody);
expect(mockVerifyPassword).toHaveBeenCalledTimes(2);
});
it('reports the disabled state only after the password has been verified', async () => {
mockFindUnique.mockResolvedValue(disabledUser);
mockVerifyPassword.mockResolvedValue(false);
const wrongPassword = await POST(
createRequest({ username: 'banned', password: 'wrong' }),
);
expect(await wrongPassword.json()).toEqual({
error: '用户名或密码错误',
code: 'UNAUTHORIZED',
});
mockVerifyPassword.mockResolvedValue(true);
const rightPassword = await POST(
createRequest({ username: 'banned', password: 'correct' }),
);
expect(rightPassword.status).toBe(401);
expect(await rightPassword.json()).toEqual({
error: '账号已被禁用,请联系管理员',
code: 'UNAUTHORIZED',
});
expect(mockVerifyPassword).toHaveBeenLastCalledWith('correct', disabledUser.password);
expect(mockGenerateTokens).not.toHaveBeenCalled();
});
it('never leaks the disabled state for an unknown username', async () => {
mockFindUnique.mockResolvedValue(null);
mockVerifyPassword.mockResolvedValue(true);
const response = await POST(createRequest({ username: 'nobody', password: 'guess' }));
const body = await response.json();
expect(response.status).toBe(401);
expect(body.error).toBe('用户名或密码错误');
expect(body.error).not.toContain('禁用');
});
it('issues tokens for an enabled user with a valid password', async () => {
const response = await POST(createRequest({ username: 'editor', password: 'correct' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockGenerateTokens).toHaveBeenCalledWith({
userId: 'user-1',
username: 'editor',
role: 'content_editor',
});
expect(body.accessToken).toBe('access-1');
expect(mockSetTokenCookie).toHaveBeenCalledTimes(1);
});
it('throttles by IP before bcrypt once the window budget is exhausted', async () => {
// 前 10 次放行(各自跑一轮 bcrypt),第 11 次应被限流:429 且不再触达 bcrypt。
for (let i = 0; i < 10; i += 1) {
const ok = await POST(createRequest({ username: 'editor', password: 'correct' }));
expect(ok.status).toBe(200);
}
expect(mockVerifyPassword).toHaveBeenCalledTimes(10);
const limited = await POST(createRequest({ username: 'editor', password: 'correct' }));
expect(limited.status).toBe(429);
expect(await limited.json()).toMatchObject({ code: 'TOO_MANY_REQUESTS' });
// 关键:限流判定在 bcrypt 之前 ⇒ 撞库者无法用被拒请求继续消耗 CPU。
expect(mockVerifyPassword).toHaveBeenCalledTimes(10);
});
});
+27 -7
View File
@@ -1,10 +1,32 @@
import { NextRequest } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { verifyPassword, generateTokens, setTokenCookie } from '@/lib/auth'; import { verifyPassword, generateTokens, setTokenCookie } from '@/lib/auth';
import { success, unauthorized, validationError, internalError } from '@/lib/api-response'; import { success, unauthorized, validationError, internalError } from '@/lib/api-response';
import { getClientIp } from '@/lib/client-ip';
import { createMemoryRateLimiter } from '@/lib/rate-limit';
// 与 hashPassword 的 cost factor(10) 一致的固定哑元哈希:用户不存在时也要吃掉一次完整 bcrypt,
// 否则「未知账号」比「已禁用账号」快一个哈希量,构成可枚举用户名与口令的时间侧信道(B-2)。
const TIMING_EQUALIZER_HASH = '$2b$10$VUixc994Qoz7jP1YEF2v/Oo9wc0uojx05w0cQIbcMoXHpPZ8BENyO';
// 登录端点此前无任何限流:匿名者可无限驱动 cost-10 bcrypt(CPU DoS)并离线式撞库。
// 按 IP 限流(不锁账号),与 contact 同姿态;判定在 bcrypt 之前,才真正省掉工作量。
// 导出以便测试跨用例 reset()。
export const loginRateLimiter = createMemoryRateLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
});
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const clientIp = getClientIp(request.headers);
if (loginRateLimiter.isLimited(clientIp)) {
return NextResponse.json(
{ error: '尝试次数过多,请稍后再试', code: 'TOO_MANY_REQUESTS' },
{ status: 429 },
);
}
const body = await request.json(); const body = await request.json();
const { username, password } = body; const { username, password } = body;
@@ -13,19 +35,17 @@ export async function POST(request: NextRequest) {
} }
const user = await prisma.user.findUnique({ where: { username } }); const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
const isValid = await verifyPassword(password, user?.password ?? TIMING_EQUALIZER_HASH);
if (!user || !isValid) {
return unauthorized('用户名或密码错误'); return unauthorized('用户名或密码错误');
} }
// 禁用状态只在凭据校验通过后披露,避免与「用户名不存在」形成可区分响应(B-2)。
if (user.status === 0) { if (user.status === 0) {
return unauthorized('账号已被禁用,请联系管理员'); return unauthorized('账号已被禁用,请联系管理员');
} }
const isValid = await verifyPassword(password, user.password);
if (!isValid) {
return unauthorized('用户名或密码错误');
}
const { accessToken, refreshToken } = generateTokens({ const { accessToken, refreshToken } = generateTokens({
userId: user.id, userId: user.id,
username: user.username, username: user.username,
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import type { NextRequest } from 'next/server';
const mockUserFindUnique = jest.fn<(args: unknown) => Promise<unknown>>();
const mockUserRoleFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
jest.mock('@/lib/db', () => ({
prisma: {
user: { findUnique: mockUserFindUnique },
userRole: { findMany: mockUserRoleFindMany },
},
}));
const mockVerifyRefreshToken = jest.fn<(token: string) => unknown>();
const mockGenerateTokens = jest.fn<(payload: unknown) => { accessToken: string; refreshToken: string }>();
const mockSetTokenCookie = jest.fn();
jest.mock('@/lib/auth', () => ({
verifyRefreshToken: (token: string) => mockVerifyRefreshToken(token),
generateTokens: (payload: unknown) => mockGenerateTokens(payload),
setTokenCookie: (r: unknown, a: string, b: string, s: boolean) => mockSetTokenCookie(r, a, b, s),
}));
jest.unmock('./route');
import { POST } from './route';
const legacyPayload = {
userId: 'user-1',
username: 'editor',
role: 'super_admin',
};
function createRequest(body: Record<string, unknown>): NextRequest {
return {
url: 'http://localhost:3000/api/auth/refresh',
nextUrl: { protocol: 'http:' },
headers: new Headers(),
json: async () => body,
} as unknown as NextRequest;
}
const request = () => createRequest({ refreshToken: 'signed-refresh-token' });
beforeEach(() => {
jest.clearAllMocks();
mockVerifyRefreshToken.mockReturnValue(legacyPayload);
mockUserFindUnique.mockResolvedValue({ id: 'user-1', username: 'editor', role: 'admin', status: 1 });
mockUserRoleFindMany.mockResolvedValue([{ userId: 'user-1', roleCode: 'super_admin' }]);
mockGenerateTokens.mockReturnValue({ accessToken: 'new-access', refreshToken: 'new-refresh' });
});
describe('POST /api/auth/refresh', () => {
it('returns 401 when the refresh token is missing', async () => {
const response = await POST(createRequest({}));
expect(response.status).toBe(401);
expect(mockGenerateTokens).not.toHaveBeenCalled();
});
it('returns 401 when the refresh token signature is invalid', async () => {
mockVerifyRefreshToken.mockImplementation(() => {
throw new Error('jwt expired');
});
const response = await POST(request());
expect(response.status).toBe(401);
expect(mockUserFindUnique).not.toHaveBeenCalled();
expect(mockGenerateTokens).not.toHaveBeenCalled();
});
it('mints no access token for a disabled user', async () => {
mockUserFindUnique.mockResolvedValue({ id: 'user-1', username: 'editor', status: 0 });
const response = await POST(request());
const body = await response.json();
expect(response.status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
expect(mockGenerateTokens).not.toHaveBeenCalled();
expect(mockSetTokenCookie).not.toHaveBeenCalled();
expect(mockUserRoleFindMany).not.toHaveBeenCalled();
});
it('mints no access token for a deleted user', async () => {
mockUserFindUnique.mockResolvedValue(null);
const response = await POST(request());
expect(response.status).toBe(401);
expect(mockGenerateTokens).not.toHaveBeenCalled();
expect(mockSetTokenCookie).not.toHaveBeenCalled();
});
it('looks the account up by the token subject id, not by username', async () => {
await POST(request());
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { id: 'user-1' } });
expect(mockUserRoleFindMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
});
it('re-signs with the demoted role from the database instead of the stale claim', async () => {
mockUserRoleFindMany.mockResolvedValue([{ userId: 'user-1', roleCode: 'content_editor' }]);
const response = await POST(request());
const body = await response.json();
expect(response.status).toBe(200);
expect(mockGenerateTokens).toHaveBeenCalledWith({
userId: 'user-1',
username: 'editor',
role: 'content_editor',
});
expect(mockGenerateTokens.mock.calls[0]![0]).not.toMatchObject({ role: 'super_admin' });
expect(body).toEqual({ accessToken: 'new-access', refreshToken: 'new-refresh' });
});
it('falls back to the stored role column when the account carries no role grants', async () => {
mockUserRoleFindMany.mockResolvedValue([]);
const response = await POST(request());
expect(response.status).toBe(200);
expect(mockGenerateTokens).toHaveBeenCalledWith({
userId: 'user-1',
username: 'editor',
role: 'admin',
});
});
it('re-issues from the current account row rather than the token payload', async () => {
mockUserFindUnique.mockResolvedValue({
id: 'user-1',
username: 'renamed-editor',
role: 'content_editor',
status: 1,
});
mockUserRoleFindMany.mockResolvedValue([{ userId: 'user-1', roleCode: 'reviewer' }]);
await POST(request());
expect(mockGenerateTokens).toHaveBeenCalledWith({
userId: 'user-1',
username: 'renamed-editor',
role: 'reviewer',
});
});
it('returns 500 when the account lookup fails instead of handing out a token', async () => {
mockUserFindUnique.mockRejectedValue(new Error('SQLITE_BUSY'));
const response = await POST(request());
expect(response.status).toBe(500);
expect(mockGenerateTokens).not.toHaveBeenCalled();
});
});
+17 -3
View File
@@ -1,4 +1,5 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyRefreshToken, generateTokens, setTokenCookie } from '@/lib/auth'; import { verifyRefreshToken, generateTokens, setTokenCookie } from '@/lib/auth';
import { success, unauthorized, internalError } from '@/lib/api-response'; import { success, unauthorized, internalError } from '@/lib/api-response';
@@ -18,10 +19,23 @@ export async function POST(request: NextRequest) {
return unauthorized('刷新令牌无效或已过期'); return unauthorized('刷新令牌无效或已过期');
} }
// 原实现只验签名即用旧 payload 重签,被禁用/删除的账号可在 7 天内继续换取访问令牌(B-7)。
const user = await prisma.user.findUnique({ where: { id: payload.userId } });
if (!user || user.status === 0) {
return unauthorized('登录状态已失效,请重新登录');
}
// 角色按 permissions 层的同源查询(UserRole 关联表)实查后重签,避免沿用降权前的旧 claim;
// 无关联角色的历史账号回落到 login 同源的 User.role 标量。
const userRoles = await prisma.userRole.findMany({ where: { userId: user.id } });
const role = userRoles[0]?.roleCode ?? user.role;
// 令牌轮换与吊销(jti / tokenVersion)需要 schema 变更,仍是已记录的 backlog 项:
// 失窃的 refresh token 在账号仍启用的前提下到期前可持续换取新令牌。
const tokens = generateTokens({ const tokens = generateTokens({
userId: payload.userId, userId: user.id,
username: payload.username, username: user.username,
role: payload.role, role,
}); });
const response = success({ const response = success({
@@ -0,0 +1,83 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import type { NextRequest } from 'next/server';
const mockDisable = jest.fn<() => void>();
const mockDraftMode = jest.fn<() => Promise<{ disable: () => void }>>();
jest.mock('next/headers', () => ({
draftMode: () => mockDraftMode(),
}));
// 与 enable 的同名测试同法:jest.setup.js 的 next/server 桩只有 json(),
// 而 N-18 的判据正是 Location 指向何处。
jest.mock('next/server', () => {
const jsonResponse = (body: unknown, init?: { status?: number }) => ({
status: init?.status ?? 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: async () => body,
});
const redirectResponse = (url: URL | string, init?: { status?: number }) => ({
status: init?.status ?? 307,
headers: new Headers({ location: String(url) }),
json: async () => null,
});
return {
NextResponse: { json: jsonResponse, redirect: redirectResponse },
NextRequest: class {},
};
});
jest.unmock('./route');
import { POST } from './route';
function createRequest(body: Record<string, unknown>): NextRequest {
return {
url: 'http://localhost:3000/api/cms/draft/disable',
headers: new Headers(),
json: async () => body,
} as unknown as NextRequest;
}
function locationOf(response: unknown): string {
const headers = (response as { headers?: Headers }).headers;
return String(headers?.get('location') ?? '');
}
beforeEach(() => {
jest.clearAllMocks();
mockDraftMode.mockResolvedValue({ disable: mockDisable });
});
describe('POST /api/cms/draft/disable', () => {
it('disables draft mode and returns json when no redirect is requested', async () => {
const response = await POST(createRequest({}));
expect(response.status).toBe(200);
expect(mockDisable).toHaveBeenCalledTimes(1);
await expect(response.json()).resolves.toMatchObject({ code: 0 });
});
it('redirects to a declared in-site path', async () => {
const response = await POST(createRequest({ redirect: '/cases/demo' }));
expect(response.status).toBe(307);
expect(locationOf(response)).toBe('http://localhost:3000/cases/demo');
expect(mockDisable).toHaveBeenCalledTimes(1);
});
// N-18:修复前本路由把 body.redirect 原样交给 new URL(),任何绝对 URL 都能把用户送出站外,
// 而姊妹路由 enable 早已因 B-6 加了同一道校验。
it.each([
['absolute cross-site url', 'https://evil.example/phish'],
['protocol-relative url', '//evil.example/phish'],
['backslash-obfuscated url', '/\\evil.example/phish'],
['scheme-handler url', 'javascript:alert(1)'],
])('refuses %s redirect with 400 and leaves draft mode untouched', async (_name, redirect) => {
const response = await POST(createRequest({ redirect }));
expect(response.status).toBe(400);
expect(mockDisable).not.toHaveBeenCalled();
await expect(response.json()).resolves.toMatchObject({ code: 400 });
});
});
+9
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { draftMode } from 'next/headers'; import { draftMode } from 'next/headers';
import { isInternalRedirect } from '@/lib/cms/internal-redirect';
// 静态导出(output: 'export')不支持 GET + searchParams 的 API 路由。 // 静态导出(output: 'export')不支持 GET + searchParams 的 API 路由。
// 使用 POST 避免 Next.js 在构建时尝试预渲染。未来迁移到真实后端时可改回 GET。 // 使用 POST 避免 Next.js 在构建时尝试预渲染。未来迁移到真实后端时可改回 GET。
@@ -8,6 +9,14 @@ export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const redirect = typeof body.redirect === 'string' ? body.redirect : null; const redirect = typeof body.redirect === 'string' ? body.redirect : null;
// 与 /api/cms/draft/enable 同序:先校验重定向目标,非法请求不留副作用(N-18)。
if (redirect && !isInternalRedirect(redirect)) {
return NextResponse.json(
{ code: 400, message: 'Redirect must be an in-site path', data: null },
{ status: 400 }
);
}
const draft = await draftMode(); const draft = await draftMode();
draft.disable(); draft.disable();
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import type { NextRequest } from 'next/server';
const mockEnable = jest.fn<() => void>();
const mockDraftMode = jest.fn<() => Promise<{ enable: () => void; isEnabled: boolean }>>();
jest.mock('next/headers', () => ({
draftMode: () => mockDraftMode(),
}));
// jest.setup.js 的全局 next/server 桩只实现 json(),无法承载 redirect 断言(B-6 需要 Location)。
jest.mock('next/server', () => {
const jsonResponse = (body: unknown, init?: { status?: number }) => ({
status: init?.status ?? 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: async () => body,
});
const redirectResponse = (url: URL | string, init?: { status?: number }) => ({
status: init?.status ?? 307,
headers: new Headers({ location: String(url) }),
json: async () => null,
});
return {
NextResponse: { json: jsonResponse, redirect: redirectResponse },
NextRequest: class {},
};
});
jest.unmock('./route');
import { POST } from './route';
function createRequest(body: Record<string, unknown>): NextRequest {
return {
url: 'http://localhost:3000/api/cms/draft/enable',
headers: new Headers(),
json: async () => body,
} as unknown as NextRequest;
}
function locationOf(response: unknown): string {
const headers = (response as { headers?: Headers }).headers;
return String(headers?.get('location') ?? '');
}
beforeEach(() => {
jest.clearAllMocks();
mockDraftMode.mockResolvedValue({ enable: mockEnable, isEnabled: true });
process.env.CMS_PREVIEW_SECRET = 'test-preview-secret';
});
describe('POST /api/cms/draft/enable', () => {
it('fails closed with 500 and never enables draft mode when the secret is not configured', async () => {
delete process.env.CMS_PREVIEW_SECRET;
const response = await POST(createRequest({ secret: 'guessed-by-attacker' }));
const body = await response.json();
expect(response.status).toBe(500);
expect(body).toMatchObject({ code: 500 });
expect(mockDraftMode).not.toHaveBeenCalled();
expect(mockEnable).not.toHaveBeenCalled();
});
it('does not enable draft mode for an unconfigured secret even with a redirect target', async () => {
delete process.env.CMS_PREVIEW_SECRET;
const response = await POST(createRequest({ redirect: '/cases/demo' }));
expect(response.status).toBe(500);
expect(locationOf(response)).toBe('');
expect(mockEnable).not.toHaveBeenCalled();
});
it('rejects a wrong secret with 401 and never enables draft mode', async () => {
const response = await POST(createRequest({ secret: 'wrong-secret' }));
const body = await response.json();
expect(response.status).toBe(401);
expect(body).toMatchObject({ code: 401 });
expect(mockEnable).not.toHaveBeenCalled();
});
it('rejects a missing secret with 401', async () => {
const response = await POST(createRequest({}));
expect(response.status).toBe(401);
expect(mockEnable).not.toHaveBeenCalled();
});
it.each([
['protocol-relative', '//evil.com'],
['absolute cross-origin', 'https://evil.com/path'],
['backslash bypass', '/\\evil.com'],
['leading-space bypass', ' //evil.com'],
['scheme injection', 'javascript:alert(1)'],
])('refuses %s redirect without enabling draft mode', async (_name, redirect) => {
const response = await POST(createRequest({ secret: 'test-preview-secret', redirect }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body).toMatchObject({ code: 400 });
expect(locationOf(response)).not.toContain('evil.com');
expect(mockEnable).not.toHaveBeenCalled();
});
it('enables draft mode and redirects to a declared in-site path', async () => {
const response = await POST(
createRequest({ secret: 'test-preview-secret', redirect: '/cases/demo' }),
);
expect(response.status).toBe(307);
expect(mockEnable).toHaveBeenCalledTimes(1);
expect(locationOf(response)).toBe('http://localhost:3000/cases/demo');
});
it('redirects to the model preview path when only modelCode and slug are given', async () => {
const response = await POST(
createRequest({ secret: 'test-preview-secret', modelCode: 'case-study', slug: 'demo' }),
);
expect(response.status).toBe(307);
expect(mockEnable).toHaveBeenCalledTimes(1);
expect(locationOf(response)).toBe('http://localhost:3000/cases/demo?preview=true');
});
it('confirms activation with JSON when no redirect target is supplied', async () => {
const response = await POST(createRequest({ secret: 'test-preview-secret' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toMatchObject({ code: 0, data: { enabled: true } });
expect(mockEnable).toHaveBeenCalledTimes(1);
});
});
+19 -1
View File
@@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { draftMode } from 'next/headers'; import { draftMode } from 'next/headers';
import { isInternalRedirect } from '@/lib/cms/internal-redirect';
// 静态导出(output: 'export')不支持 GET + searchParams 的 API 路由。 // 静态导出(output: 'export')不支持 GET + searchParams 的 API 路由。
// 使用 POST 避免 Next.js 在构建时尝试预渲染。未来迁移到真实后端时可改回 GET。 // 使用 POST 避免 Next.js 在构建时尝试预渲染。未来迁移到真实后端时可改回 GET。
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
@@ -11,8 +13,24 @@ export async function POST(request: NextRequest) {
const modelCode = typeof body.modelCode === 'string' ? body.modelCode : null; const modelCode = typeof body.modelCode === 'string' ? body.modelCode : null;
const redirect = typeof body.redirect === 'string' ? body.redirect : null; const redirect = typeof body.redirect === 'string' ? body.redirect : null;
// 重定向目标先于 draft.enable() 校验,非法请求不留草稿模式副作用。
if (redirect && !isInternalRedirect(redirect)) {
return NextResponse.json(
{ code: 400, message: 'Redirect must be an in-site path', data: null },
{ status: 400 }
);
}
const expectedSecret = process.env.CMS_PREVIEW_SECRET; const expectedSecret = process.env.CMS_PREVIEW_SECRET;
if (expectedSecret && secret !== expectedSecret) { // 与 /api/cms/revalidate 一致 fail-closed:密钥未配置时原实现 `if (expectedSecret && ...)` 会放行任意请求(B-6)。
if (!expectedSecret) {
return NextResponse.json(
{ code: 500, message: 'Preview secret is not configured', data: null },
{ status: 500 }
);
}
if (secret !== expectedSecret) {
return NextResponse.json( return NextResponse.json(
{ code: 401, message: 'Invalid secret token', data: null }, { code: 401, message: 'Invalid secret token', data: null },
{ status: 401 } { status: 401 }
+29
View File
@@ -257,4 +257,33 @@ describe('POST /api/cms/revalidate', () => {
expect(body.data.revalidatedPaths).toContain('/products'); expect(body.data.revalidatedPaths).toContain('/products');
expect(body.data.revalidatedPaths).not.toContain('/products/standalone/novavis'); expect(body.data.revalidatedPaths).not.toContain('/products/standalone/novavis');
}); });
// S-3:/about、/contact 为单页模型(无 listPage 配置),/products/erp-upgrade 是
// 硬编码页但数据来自 product(erp)。此前映射缺口 ⇒ CMS 发布后这些页面永不刷新。
it('revalidates /about for about-page model', async () => {
const response = await POST(createMockRequest({ modelCode: 'about-page' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/about');
expect(body.data.revalidatedPaths).toContain('/about');
});
it('revalidates /contact for contact-page model', async () => {
const response = await POST(createMockRequest({ modelCode: 'contact-page' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/contact');
expect(body.data.revalidatedPaths).toContain('/contact');
});
it('also revalidates the hardcoded /products/erp-upgrade page when product model changes', async () => {
const response = await POST(createMockRequest({ modelCode: 'product', slug: 'erp' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/products/erp-upgrade');
expect(body.data.revalidatedPaths).toContain('/products/erp-upgrade');
});
}); });
+16
View File
@@ -106,6 +106,22 @@ export async function POST(request: NextRequest) {
revalidatedPaths.push('/team'); revalidatedPaths.push('/team');
} }
// about-page / contact-page 为无 listPage 配置的单页模型,固定映射(S-3)
if (body.modelCode === 'about-page' && !revalidatedPaths.includes('/about')) {
revalidatePath('/about');
revalidatedPaths.push('/about');
}
if (body.modelCode === 'contact-page' && !revalidatedPaths.includes('/contact')) {
revalidatePath('/contact');
revalidatedPaths.push('/contact');
}
// /products/erp-upgrade 是硬编码页面,但数据来自 product 模型(slug=erp)(S-3)
if (body.modelCode === 'product' && !revalidatedPaths.includes('/products/erp-upgrade')) {
revalidatePath('/products/erp-upgrade');
revalidatedPaths.push('/products/erp-upgrade');
}
// 首页相关模型变更时同步刷新 / // 首页相关模型变更时同步刷新 /
if (HOME_AFFECTED_MODELS.has(body.modelCode) && !revalidatedPaths.includes('/')) { if (HOME_AFFECTED_MODELS.has(body.modelCode) && !revalidatedPaths.includes('/')) {
revalidatePath('/'); revalidatePath('/');
+55 -61
View File
@@ -1,100 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import { z } from 'zod'; import { z } from "zod";
import { COMPANY_INFO } from '@/lib/constants'; import { COMPANY_INFO } from "@/lib/constants";
import { getClientIp } from "@/lib/client-ip";
import { createMemoryRateLimiter } from "@/lib/rate-limit";
const contactSchema = z.object({ const contactSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符').max(50, '姓名不能超过50个字符'), name: z
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'), .string()
email: z.string().email('请输入有效的邮箱地址').max(100, '邮箱地址过长'), .min(2, "姓名至少需要2个字符")
subject: z.string().min(2, '主题至少需要2个字符').max(120, '主题不能超过120个字符'), .max(50, "姓名不能超过50个字符"),
message: z.string().min(10, '留言内容至少需要10个字符').max(2000, '留言内容不能超过2000个字符'), phone: z.string().regex(/^1[3-9]\d{9}$/, "请输入有效的手机号码"),
email: z.string().email("请输入有效的邮箱地址").max(100, "邮箱地址过长"),
subject: z
.string()
.min(2, "主题至少需要2个字符")
.max(120, "主题不能超过120个字符"),
message: z
.string()
.min(10, "留言内容至少需要10个字符")
.max(2000, "留言内容不能超过2000个字符"),
}); });
// 简单内存限流:按 IP 地址限制提交频率 // 简单内存限流:按 IP 地址限制提交频率(复用 src/lib/rate-limit,与登录端点同一实现)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); const contactRateLimiter = createMemoryRateLimiter({
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 小时 windowMs: 60 * 60 * 1000, // 1 小时
const RATE_LIMIT_MAX_REQUESTS = 5; max: 5,
});
function getClientIp(request: NextRequest): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0]?.trim() ?? 'unknown';
}
return request.headers.get('x-real-ip') ?? 'unknown';
}
function isRateLimited(clientIp: string): boolean {
const now = Date.now();
const record = rateLimitMap.get(clientIp);
if (!record || now > record.resetAt) {
rateLimitMap.set(clientIp, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return false;
}
record.count += 1;
return record.count > RATE_LIMIT_MAX_REQUESTS;
}
function sanitizeInput(input: string): string { function sanitizeInput(input: string): string {
// 去除首尾空白并过滤潜在危险标签 // 去除首尾空白并过滤潜在危险标签
return input.trim().replace(/[<>]/g, ''); return input.trim().replace(/[<>]/g, "");
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const clientIp = getClientIp(request); const clientIp = getClientIp(request.headers);
if (isRateLimited(clientIp)) { if (contactRateLimiter.isLimited(clientIp)) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: '提交过于频繁,请稍后再试' }, { success: false, message: "提交过于频繁,请稍后再试" },
{ status: 429 } { status: 429 },
); );
} }
const body = await request.formData(); const body = await request.formData();
const parseResult = contactSchema.safeParse({ const parseResult = contactSchema.safeParse({
name: body.get('name'), name: body.get("name"),
phone: body.get('phone'), phone: body.get("phone"),
email: body.get('email'), email: body.get("email"),
subject: body.get('subject'), subject: body.get("subject"),
message: body.get('message'), message: body.get("message"),
}); });
if (!parseResult.success) { if (!parseResult.success) {
const firstError = parseResult.error.issues[0]; const firstError = parseResult.error.issues[0];
return NextResponse.json( return NextResponse.json(
{ success: false, message: firstError?.message ?? '表单数据无效' }, { success: false, message: firstError?.message ?? "表单数据无效" },
{ status: 400 } { status: 400 },
); );
} }
const { name, phone, email, subject, message } = parseResult.data; const { name, phone, email, subject, message } = parseResult.data;
const formBody = new URLSearchParams(); const formBody = new URLSearchParams();
formBody.append('name', sanitizeInput(name)); formBody.append("name", sanitizeInput(name));
formBody.append('phone', sanitizeInput(phone)); formBody.append("phone", sanitizeInput(phone));
formBody.append('email', sanitizeInput(email)); formBody.append("email", sanitizeInput(email));
formBody.append('subject', sanitizeInput(subject)); formBody.append("subject", sanitizeInput(subject));
formBody.append('message', sanitizeInput(message)); formBody.append("message", sanitizeInput(message));
formBody.append('_subject', `网站留言: ${sanitizeInput(subject)}`); formBody.append("_subject", `网站留言: ${sanitizeInput(subject)}`);
formBody.append('_captcha', 'false'); formBody.append("_captcha", "false");
formBody.append('_template', 'table'); formBody.append("_template", "table");
const response = await fetch(`https://formsubmit.co/ajax/${COMPANY_INFO.email}`, { const response = await fetch(
method: 'POST', `https://formsubmit.co/ajax/${COMPANY_INFO.email}`,
headers: { 'Accept': 'application/json' }, {
body: formBody.toString(), method: "POST",
}); headers: { Accept: "application/json" },
body: formBody.toString(),
},
);
const data = await response.json(); const data = await response.json();
return NextResponse.json(data, { status: response.status }); return NextResponse.json(data, { status: response.status });
} catch (error) { } catch (error) {
console.error('Contact form error:', error); console.error("Contact form error:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, message: '服务器内部错误' }, { success: false, message: "服务器内部错误" },
{ status: 500 } { status: 500 },
); );
} }
} }
+4 -4
View File
@@ -23,7 +23,7 @@ export default function Error({
<div className="max-w-2xl mx-auto text-center"> <div className="max-w-2xl mx-auto text-center">
<div className="mb-8"> <div className="mb-8">
<div className="w-24 h-24 bg-[var(--color-brand-bg)] rounded-full flex items-center justify-center mx-auto mb-6"> <div className="w-24 h-24 bg-[var(--color-brand-bg)] rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-12 h-12 text-[var(--color-brand)]" /> <AlertTriangle className="w-12 h-12 text-brand-ink" />
</div> </div>
<div className="w-32 h-1 bg-[var(--color-brand)] mx-auto" /> <div className="w-32 h-1 bg-[var(--color-brand)] mx-auto" />
</div> </div>
@@ -73,7 +73,7 @@ export default function Error({
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-brand-soft)] transition-colors"> <div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<AlertTriangle className="w-5 h-5 text-[var(--color-brand)]" /> <AlertTriangle className="w-5 h-5 text-brand-ink" />
</div> </div>
<div className="text-left"> <div className="text-left">
<div className="font-semibold text-[var(--color-text-primary)]">联系我们</div> <div className="font-semibold text-[var(--color-text-primary)]">联系我们</div>
@@ -86,7 +86,7 @@ export default function Error({
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-brand-soft)] transition-colors"> <div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<RefreshCw className="w-5 h-5 text-[var(--color-brand)]" /> <RefreshCw className="w-5 h-5 text-brand-ink" />
</div> </div>
<div className="text-left"> <div className="text-left">
<div className="font-semibold text-[var(--color-text-primary)]">服务</div> <div className="font-semibold text-[var(--color-text-primary)]">服务</div>
@@ -98,7 +98,7 @@ export default function Error({
<div className="mt-8 text-sm text-[var(--color-text-placeholder)]"> <div className="mt-8 text-sm text-[var(--color-text-placeholder)]">
如果问题持续存在,请{' '} 如果问题持续存在,请{' '}
<StaticLink href="/contact" className="text-[var(--color-brand)] hover:underline"> <StaticLink href="/contact" className="text-brand-ink hover:underline">
联系我们的技术团队 联系我们的技术团队
</StaticLink> </StaticLink>
</div> </div>
+33
View File
@@ -0,0 +1,33 @@
/**
* PR-NEXT: 追加 src/app/global-error.tsx 的回归测试
*/
import { render, screen, fireEvent } from '@testing-library/react';
import GlobalError from './global-error';
describe('GlobalError - 根布局错误兜底页', () => {
it('自带文档壳并声明中文语言(axe html-has-lang)', () => {
const { container } = render(
<GlobalError error={Object.assign(new Error('boom'), { digest: 'd-1' })} retry={jest.fn()} />
);
const html = container.querySelector('html');
expect(html).not.toBeNull();
expect(html).toHaveAttribute('lang', 'zh-CN');
expect(container.querySelector('body')).not.toBeNull();
expect(container.querySelector('title')?.textContent).toContain('四川睿新致远科技有限公司');
});
it('给出可操作出口且重试按钮接线到 retry', () => {
const retry = jest.fn();
render(<GlobalError error={Object.assign(new Error('boom'), { digest: undefined })} retry={retry} />);
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(retry).toHaveBeenCalledTimes(1);
expect(screen.getByRole('link', { name: '返回首页' })).toHaveAttribute('href', '/');
});
it('有 digest 时展示错误编号,供工单定位', () => {
render(
<GlobalError error={Object.assign(new Error('boom'), { digest: 'abc123' })} retry={jest.fn()} />
);
expect(screen.getByText(/abc123/)).toBeInTheDocument();
});
});
+76
View File
@@ -0,0 +1,76 @@
'use client';
// 根布局抛错时 Next.js 用内置文档顶替 root layout,而内置文档的 <html> 没有 lang、
// 也不读本站的 html[data-theme] 令牌,深色下会落到浏览器默认画布(实测 #0A0E14 令牌失效、
// bgMatchesToken=false,axe 报 html-has-lang)。全站 axe 扫描扩到预渲染产物后暴露此缺口。
// 因此这里自带文档壳 + 与 globals.css 同值的 OS 主题样式,不依赖 root layout。
export default function GlobalError({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
return (
<html lang="zh-CN">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>页面无法显示 | 四川睿新致远科技有限公司</title>
<style>{`
:root { color-scheme: light; }
body { margin: 0; background: #FFFFFF; color: #0A0E14;
font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; }
@media (prefers-color-scheme: dark) {
:root { color-scheme: dark; }
body { background: #0A0E14; color: #F8FAFC; }
}
`}</style>
</head>
<body>
<main style={{ maxWidth: 640, margin: '0 auto', padding: '96px 24px', textAlign: 'center' }}>
<h1 style={{ fontSize: 28, fontWeight: 700, margin: '0 0 16px' }}>应用暂时无法显示此页面</h1>
<p style={{ fontSize: 16, lineHeight: 1.7, margin: '0 0 8px' }}>
渲染过程中出现错误,我们已记录该问题。请重试,或返回首页继续浏览。
</p>
{error.digest ? (
<p style={{ fontSize: 13, margin: '0 0 24px' }}>错误编号:{error.digest}</p>
) : (
<p style={{ fontSize: 13, margin: '0 0 24px' }}>请稍后重试</p>
)}
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
<button
type="button"
onClick={() => retry()}
style={{
fontSize: 15,
fontWeight: 600,
padding: '10px 20px',
border: '1px solid currentColor',
background: 'transparent',
color: 'inherit',
cursor: 'pointer',
}}
>
重试
</button>
<a
href="/"
style={{
fontSize: 15,
fontWeight: 600,
padding: '10px 20px',
border: '1px solid currentColor',
background: 'transparent',
color: 'inherit',
textDecoration: 'none',
}}
>
返回首页
</a>
</div>
</main>
</body>
</html>
);
}
+31 -97
View File
@@ -25,6 +25,11 @@ body {
--color-brand-light: #E04A68; --color-brand-light: #E04A68;
--color-brand-soft: rgba(196, 30, 58, 0.12); --color-brand-soft: rgba(196, 30, 58, 0.12);
--color-brand-bg: #FEF2F4; --color-brand-bg: #FEF2F4;
/* Tailwind 无法对「任意值形式的裸 var()」套用 /NN 透明度修饰符(bg-[var(--color-brand-bg)] 再加 /50
编译后 0 条规则,静默失效),故底色通道也要提供 rgb 三元组,配合 tailwind.config.js 的
brand.bg = rgb(var(--color-brand-bg-rgb) / <alpha-value>) 让 bg-brand-bg/50 正确生成。
254 242 244 = #FEF2F4,alpha 默认 1 时与裸 var() 同色,无视觉漂移。 */
--color-brand-bg-rgb: 254 242 244;
--color-brand-rgb: 196 30 58; --color-brand-rgb: 196 30 58;
/* 品牌「文字墨色」专用通道(text-brand-ink)。 /* 品牌「文字墨色」专用通道(text-brand-ink)。
与 --color-brand-rgb 分离的原因:--color-brand-rgb 同时服务 bg-brand / border-brand, 与 --color-brand-rgb 分离的原因:--color-brand-rgb 同时服务 bg-brand / border-brand,
@@ -33,6 +38,10 @@ body {
深色下取 #F87171(248 113 113):于 #0A0E14/#0F1419/#151B23 分别 6.99/6.69/6.26,全达 AA。 */ 深色下取 #F87171(248 113 113):于 #0A0E14/#0F1419/#151B23 分别 6.99/6.69/6.26,全达 AA。 */
--color-brand-ink: #C41E3A; --color-brand-ink: #C41E3A;
--color-brand-ink-rgb: 196 30 58; --color-brand-ink-rgb: 196 30 58;
/* 品牌红文字通道的 hover 档:与 --color-brand-hover(底色通道)分离,
暗色下必须提亮,否则 #A01830 压在 #0A0E14 上仅 2.06:1(验收 A-12 残留项)。 */
--color-brand-ink-hover: #A01830;
--color-brand-ink-hover-rgb: 160 24 48;
/* 详情页 Hero badge 的 accent 文字色(accentColor 由各页 hero-themes 传入, /* 详情页 Hero badge 的 accent 文字色(accentColor 由各页 hero-themes 传入,
经 --badge-accent 自定义属性注入)。浅色 = 原色;深色 = 45% accent + 55% 白的提亮变体 经 --badge-accent 自定义属性注入)。浅色 = 原色;深色 = 45% accent + 55% 白的提亮变体
—— accentColor 多为深色系(#1e3a5f 等),直接用作深底文字仅 1.65:1 */ —— accentColor 多为深色系(#1e3a5f 等),直接用作深底文字仅 1.65:1 */
@@ -76,6 +85,10 @@ body {
--color-bg-secondary-rgb: 248 250 252; --color-bg-secondary-rgb: 248 250 252;
--color-bg-tertiary: #F1F5F9; --color-bg-tertiary: #F1F5F9;
--color-bg-section: #F8FAFC; --color-bg-section: #F8FAFC;
/* 同 --color-brand-bg-rgb 的理由:bg-[var(--color-bg-section)] 再加 /60 这类写法编译后 0 条规则。
与 --color-bg-secondary-rgb 当前同值但语义不同(section ≠ secondary),故独立建三元组,
避免任一 token 调整时静默串色。248 250 252 = #F8FAFC。 */
--color-bg-section-rgb: 248 250 252;
--color-bg-hover: #F1F5F9; --color-bg-hover: #F1F5F9;
--color-bg-elevated: #FFFFFF; --color-bg-elevated: #FFFFFF;
--color-bg-primary-rgb: 255 255 255; --color-bg-primary-rgb: 255 255 255;
@@ -268,12 +281,14 @@ body {
/* ease-out 别名:默认使用水墨曲线(Porsche 风格:快速开始,缓慢结束) */ /* ease-out 别名:默认使用水墨曲线(Porsche 风格:快速开始,缓慢结束) */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
/* 动效延迟序列(错落动效用) */ /* 动效延迟序列(错落动效用)—— 已删除:--stagger-xs…xl 此前零消费者。
--stagger-xs: 30ms; 全站 stagger 全部由 framer-motion 以「秒」为单位的数值驱动
--stagger-sm: 60ms; (transition.delay / variants.staggerChildren),CSS 自定义属性无法被
--stagger-md: 100ms; transition.delay 读取(除非物质化 getComputedStyle),故令牌在此形同虚设。
--stagger-lg: 150ms; 唯一事实来源改为 JS:src/components/ui/scroll-reveal.tsx(StaggerReveal
--stagger-xl: 200ms; 默认 staggerDelay = 0.05)与 page-transition.tsx。
取值区间按 CONTEXT.md「动效设计四原则」4:子元素 stagger 30–60ms、
Section 间 stagger 100–150ms。 */
/* 容器宽度 */ /* 容器宽度 */
--container-max: 1360px; --container-max: 1360px;
@@ -369,15 +384,6 @@ body {
--accordion-trigger-height: 3rem; --accordion-trigger-height: 3rem;
--accordion-trigger-padding-x: 1rem; --accordion-trigger-padding-x: 1rem;
/* Skeleton 骨架屏 */
--skeleton-radius: var(--radius-sm);
--skeleton-bg: var(--color-bg-tertiary);
/* Stats Bar 数据条 */
--stats-bar-gap: var(--spacing-2xl);
--stats-number-size: var(--font-size-4xl);
--stats-label-size: var(--font-size-xs);
/* === 12 列网格系统(Grid System)=== */ /* === 12 列网格系统(Grid System)=== */
--grid-columns: 12; --grid-columns: 12;
--grid-gap-xs: 0.5rem; --grid-gap-xs: 0.5rem;
@@ -419,6 +425,8 @@ html[data-theme='dark'] {
--color-bg-secondary-rgb: 15 20 25; --color-bg-secondary-rgb: 15 20 25;
--color-bg-tertiary: #151B23; --color-bg-tertiary: #151B23;
--color-bg-section: #0F1419; --color-bg-section: #0F1419;
/* 与 :root 的 --color-bg-section-rgb 成对覆写,否则深色下 bg-bg-section/NN 会退回浅色值 */
--color-bg-section-rgb: 15 20 25;
--color-bg-hover: #151B23; --color-bg-hover: #151B23;
--color-bg-elevated: #151B23; --color-bg-elevated: #151B23;
@@ -470,7 +478,11 @@ html[data-theme='dark'] {
此前只翻了 error-bg,导致深色下白字压浅粉底仅 1.04:1(首页「首批客户共创计划」卡)。 */ 此前只翻了 error-bg,导致深色下白字压浅粉底仅 1.04:1(首页「首批客户共创计划」卡)。 */
--color-brand-ink: #F87171; --color-brand-ink: #F87171;
--color-brand-ink-rgb: 248 113 113; --color-brand-ink-rgb: 248 113 113;
--color-brand-ink-hover: #FCA5A5;
--color-brand-ink-hover-rgb: 252 165 165;
--color-brand-bg: #2A1418; --color-brand-bg: #2A1418;
/* 42 20 24 = #2A1418;与上方 --color-brand-bg 同步翻转,否则 bg-brand-bg/NN 在深色下仍是浅粉 */
--color-brand-bg-rgb: 42 20 24;
/* hero 徽章文字:accentColor 直用作深底文字不可读(如 #1e3a5f 于 #0B1119 仅 1.65:1), /* hero 徽章文字:accentColor 直用作深底文字不可读(如 #1e3a5f 于 #0B1119 仅 1.65:1),
深色下按 accent 45% + 白 55% 提亮;--badge-accent 未注入时回退到反色墨字。 */ 深色下按 accent 45% + 白 55% 提亮;--badge-accent 未注入时回退到反色墨字。 */
@@ -1312,16 +1324,13 @@ p, li, span {
/* WCAG 2.5.5 (AAA): 44×44px 触控目标仅作用于移动端导航/页眉/页脚交互元素。 /* WCAG 2.5.5 (AAA): 44×44px 触控目标仅作用于移动端导航/页眉/页脚交互元素。
原全局选择器会撑大正文行内链接,破坏排版节奏与组件自有的尺寸控制。 */ 原全局选择器会撑大正文行内链接,破坏排版节奏与组件自有的尺寸控制。 */
@media (max-width: 768px) { @media (max-width: 768px) {
/* MobileTabBar 是 fixed bottom-0 高度 64px,需给 main 与 footer 都留出安全区 /* MobileTabBar 是 fixed bottom-0 高度 64px(含 env 安全区),需给页面底部留出等高的避让。
防止正文/页脚被遮挡("联系我们"等页脚内链接被截问题)。 全站单一 <main>(ClientLayout)且 <Footer> 就在 main 内,因此这条 padding 是唯一的避让点:
使用 calc + env(safe-area-inset-bottom) 兼容刘海屏/小白条。 此前 main 与 footer 各加一次,safe-area 被算了两遍(验收 R-1,iPhone SE 上底部空 128px+2×inset)。
全站单一 <main>(ClientLayout,2026-09-19 polish 三重 main 收敛后)。 */ 使用 calc + env(safe-area-inset-bottom) 兼容刘海屏/小白条。 */
main { main {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px));
} }
footer {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px));
}
nav a, nav a,
nav button, nav button,
header a, header a,
@@ -1456,78 +1465,3 @@ p, li, span {
.glow-border:hover { .glow-border:hover {
animation-play-state: running; animation-play-state: running;
} }
/* === CMS RichTextEditor (TipTap) 样式 === */
/* 仅用于 CMS Studio 暗色主题,选择器限定在 .rich-text-editor-content */
.rich-text-editor-content {
min-height: 120px;
padding: 0.75rem;
color: white;
font-size: 0.875rem;
line-height: 1.6;
outline: none;
}
.rich-text-editor-content:empty::before {
content: attr(data-placeholder);
color: rgba(255, 255, 255, 0.3);
pointer-events: none;
}
.rich-text-editor-content h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0.75rem 0 0.5rem;
color: white;
}
.rich-text-editor-content h3 {
font-size: 1rem;
font-weight: 600;
margin: 0.5rem 0 0.375rem;
color: white;
}
.rich-text-editor-content p {
margin: 0.375rem 0;
}
.rich-text-editor-content ul,
.rich-text-editor-content ol {
padding-left: 1.25rem;
margin: 0.5rem 0;
}
.rich-text-editor-content ul {
list-style: disc;
}
.rich-text-editor-content ol {
list-style: decimal;
}
.rich-text-editor-content li {
margin: 0.25rem 0;
}
.rich-text-editor-content blockquote {
border-left: 3px solid var(--color-brand);
padding-left: 0.75rem;
margin: 0.5rem 0;
color: rgba(255, 255, 255, 0.7);
font-style: italic;
}
.rich-text-editor-content a {
color: var(--color-brand);
text-decoration: underline;
}
.rich-text-editor-content strong {
font-weight: 600;
color: white;
}
.rich-text-editor-content em {
font-style: italic;
}
+21 -20
View File
@@ -12,6 +12,7 @@ import { OrganizationSchema, WebsiteSchema, LocalBusinessSchema } from "@/compon
import { MobileTabBar } from "@/components/layout/mobile-tab-bar"; import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
import { ErrorBoundary } from "@/components/ui/error-boundary"; import { ErrorBoundary } from "@/components/ui/error-boundary";
import { ScrollProgress } from "@/components/ui/scroll-progress"; import { ScrollProgress } from "@/components/ui/scroll-progress";
import { MotionProvider } from "@/components/ui/motion-provider";
import { BackToTop } from "@/components/ui/back-to-top"; import { BackToTop } from "@/components/ui/back-to-top";
import { ClientLayout } from "@/components/layout/client-layout"; import { ClientLayout } from "@/components/layout/client-layout";
import { getPublishedItems } from "@/lib/cms/data-server"; import { getPublishedItems } from "@/lib/cms/data-server";
@@ -188,8 +189,6 @@ export default async function RootLayout({
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#C41E3A" /> <meta name="theme-color" content="#C41E3A" />
<link rel="dns-prefetch" href="//formsubmit.co" />
<link rel="preconnect" href="//formsubmit.co" crossOrigin="anonymous" />
<OrganizationSchema /> <OrganizationSchema />
<WebsiteSchema /> <WebsiteSchema />
<LocalBusinessSchema /> <LocalBusinessSchema />
@@ -204,24 +203,26 @@ export default async function RootLayout({
> >
跳转到主要内容 跳转到主要内容
</a> </a>
<ScrollProgress /> <MotionProvider>
<GoogleAnalyticsWrapper /> <ScrollProgress />
<GlobalErrorTracker /> <GoogleAnalyticsWrapper />
<PerformanceTracker /> <GlobalErrorTracker />
<OutboundLinkTracker /> <PerformanceTracker />
<ScrollDepthTracker /> <OutboundLinkTracker />
<ErrorBoundary> <ScrollDepthTracker />
<SiteConfigProvider config={siteConfig}> <ErrorBoundary>
<ClientLayout> <SiteConfigProvider config={siteConfig}>
{children} <ClientLayout>
</ClientLayout> {children}
</SiteConfigProvider> </ClientLayout>
</ErrorBoundary> </SiteConfigProvider>
<Suspense fallback={null}> </ErrorBoundary>
<MobileTabBar /> <Suspense fallback={null}>
</Suspense> <MobileTabBar />
<CookieConsent /> </Suspense>
<BackToTop /> <CookieConsent />
<BackToTop />
</MotionProvider>
</body> </body>
</html> </html>
); );
+5 -5
View File
@@ -11,7 +11,7 @@ export function NotFoundContent() {
<div className="container-wide px-4 py-20"> <div className="container-wide px-4 py-20">
<div className="max-w-xl mx-auto text-center"> <div className="max-w-xl mx-auto text-center">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-8xl sm:text-[120px] font-bold text-[var(--color-brand)] leading-none mb-4 opacity-20"> <h1 className="text-8xl sm:text-[120px] font-bold text-brand-ink/80 leading-none mb-4">
404 404
</h1> </h1>
</div> </div>
@@ -44,7 +44,7 @@ export function NotFoundContent() {
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors"> <div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" /> <Search className="w-4 h-4 text-brand-ink" />
</div> </div>
<span className="font-medium text-sm text-[var(--color-text-primary)]">产品</span> <span className="font-medium text-sm text-[var(--color-text-primary)]">产品</span>
</StaticLink> </StaticLink>
@@ -54,7 +54,7 @@ export function NotFoundContent() {
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors"> <div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" /> <Search className="w-4 h-4 text-brand-ink" />
</div> </div>
<span className="font-medium text-sm text-[var(--color-text-primary)]">解决方案</span> <span className="font-medium text-sm text-[var(--color-text-primary)]">解决方案</span>
</StaticLink> </StaticLink>
@@ -64,7 +64,7 @@ export function NotFoundContent() {
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors"> <div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" /> <Search className="w-4 h-4 text-brand-ink" />
</div> </div>
<span className="font-medium text-sm text-[var(--color-text-primary)]">关于我们</span> <span className="font-medium text-sm text-[var(--color-text-primary)]">关于我们</span>
</StaticLink> </StaticLink>
@@ -74,7 +74,7 @@ export function NotFoundContent() {
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group" className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
> >
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors"> <div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" /> <Search className="w-4 h-4 text-brand-ink" />
</div> </div>
<span className="font-medium text-sm text-[var(--color-text-primary)]">联系我们</span> <span className="font-medium text-sm text-[var(--color-text-primary)]">联系我们</span>
</StaticLink> </StaticLink>
+32 -2
View File
@@ -74,7 +74,7 @@ describe('PrivacyPolicyPage', () => {
heroTitle: 'CMS 隐私政策标题', heroTitle: 'CMS 隐私政策标题',
heroDescription: 'CMS 隐私政策描述', heroDescription: 'CMS 隐私政策描述',
lastUpdated: '2026-07-17', lastUpdated: '2026-07-17',
content: '<div data-testid="cms-content"><h2>CMS 内容</h2></div>', content: '<div><h2>CMS 内容</h2></div>',
}, },
version: 1, version: 1,
sortOrder: 0, sortOrder: 0,
@@ -90,7 +90,8 @@ describe('PrivacyPolicyPage', () => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('CMS 隐私政策标题'); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('CMS 隐私政策标题');
expect(screen.getByText('CMS 隐私政策描述')).toBeInTheDocument(); expect(screen.getByText('CMS 隐私政策描述')).toBeInTheDocument();
expect(screen.getByTestId('cms-content')).toBeInTheDocument(); // 净化器按白名单丢弃 data-testid,故以内容而非测试钩子断言 CMS 通路生效
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('CMS 内容');
}); });
it('should fall back to inline content when CMS is unavailable', async () => { it('should fall back to inline content when CMS is unavailable', async () => {
@@ -103,4 +104,33 @@ describe('PrivacyPolicyPage', () => {
expect(screen.getByText('引言')).toBeInTheDocument(); expect(screen.getByText('引言')).toBeInTheDocument();
}); });
}); });
describe('存储型 XSS 净化(验收 A-3)', () => {
function mockCmsContent(content: string) {
mockedGetPublishedItems.mockResolvedValue([
{ data: { pageType: 'privacy', content } },
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
it('CMS 载荷中的脚本与事件属性不出现在 DOM', async () => {
mockCmsContent(
'<p>合法政策段落</p>' +
'<script>alert("xss")</script>' +
'<img src="x" onerror="alert(1)">' +
'<a href="javascript:alert(2)">点我</a>'
);
const { container } = render(await PrivacyPolicyPage());
const html = container.innerHTML;
expect(html).not.toContain('<script');
expect(html).not.toMatch(/onerror|javascript:/);
expect(html).toContain('合法政策段落');
});
it('净化后仍渲染管理员撰写的正常富文本', async () => {
mockCmsContent('<h2>我们收集的信息</h2><ul><li>邮箱</li></ul>');
const { container } = render(await PrivacyPolicyPage());
expect(container).toHaveTextContent('我们收集的信息');
expect(container.querySelector('li')?.textContent).toBe('邮箱');
});
});
}); });
+3 -2
View File
@@ -1,5 +1,6 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getPublishedItems } from '@/lib/cms/data-server'; import { getPublishedItems } from '@/lib/cms/data-server';
import { sanitizeRichText } from '@/lib/sanitize';
import { COMPANY_INFO } from '@/lib/constants'; import { COMPANY_INFO } from '@/lib/constants';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -248,7 +249,7 @@ export default async function PrivacyPolicyPage() {
return ( return (
<div className="min-h-screen bg-[var(--color-bg-primary)]"> <div className="min-h-screen bg-[var(--color-bg-primary)]">
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-brand-section)] py-20"> <div className="bg-gradient-to-br from-[var(--color-brand)] via-brand/80 to-[var(--color-brand-section)] py-20">
<div className="container-wide"> <div className="container-wide">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4"> <h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
{heroTitle} {heroTitle}
@@ -264,7 +265,7 @@ export default async function PrivacyPolicyPage() {
{cmsContent ? ( {cmsContent ? (
<div <div
className="prose prose-lg max-w-none" className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: cmsContent }} dangerouslySetInnerHTML={{ __html: sanitizeRichText(cmsContent) }}
/> />
) : ( ) : (
<PrivacyContent /> <PrivacyContent />
+36 -2
View File
@@ -74,7 +74,7 @@ describe('TermsOfServicePage', () => {
heroTitle: 'CMS 服务条款标题', heroTitle: 'CMS 服务条款标题',
heroDescription: 'CMS 服务条款描述', heroDescription: 'CMS 服务条款描述',
lastUpdated: '2026-07-17', lastUpdated: '2026-07-17',
content: '<div data-testid="cms-content"><h2>CMS 条款内容</h2></div>', content: '<div><h2>CMS 条款内容</h2></div>',
}, },
version: 1, version: 1,
sortOrder: 0, sortOrder: 0,
@@ -90,7 +90,8 @@ describe('TermsOfServicePage', () => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('CMS 服务条款标题'); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('CMS 服务条款标题');
expect(screen.getByText('CMS 服务条款描述')).toBeInTheDocument(); expect(screen.getByText('CMS 服务条款描述')).toBeInTheDocument();
expect(screen.getByTestId('cms-content')).toBeInTheDocument(); // 净化器按白名单丢弃 data-testid,故以内容而非测试钩子断言 CMS 通路生效
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('CMS 条款内容');
}); });
it('should fall back to inline content when CMS is unavailable', async () => { it('should fall back to inline content when CMS is unavailable', async () => {
@@ -103,4 +104,37 @@ describe('TermsOfServicePage', () => {
expect(screen.getByText('引言')).toBeInTheDocument(); expect(screen.getByText('引言')).toBeInTheDocument();
}); });
}); });
describe('存储型 XSS 净化(验收 A-3)', () => {
function mockCmsContent(content: string) {
mockedGetPublishedItems.mockResolvedValue([
{
data: { pageType: 'terms', content },
},
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
it('CMS 载荷中的脚本与事件属性不出现在 DOM', async () => {
mockCmsContent(
'<p>合法条款段落</p>' +
'<script>alert("xss")</script>' +
'<img src="x" onerror="alert("xss-img")">' +
'<a href="javascript:alert("xss-href")">点我</a>' +
'<iframe src="https://evil.example.com"></iframe>'
);
const { container } = render(await TermsOfServicePage());
const html = container.innerHTML;
expect(html).not.toContain('<script');
expect(html).not.toMatch(/onerror|javascript:/);
expect(html).not.toContain('<iframe');
expect(html).toContain('合法条款段落');
});
it('净化后仍渲染管理员撰写的正常富文本', async () => {
mockCmsContent('<h2>第三条 服务内容</h2><p>我们提供 <strong>SaaS</strong> 服务。</p>');
const { container } = render(await TermsOfServicePage());
expect(container).toHaveTextContent('第三条 服务内容');
expect(container.querySelector('strong')?.textContent).toBe('SaaS');
});
});
}); });
+3 -2
View File
@@ -1,5 +1,6 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getPublishedItems } from '@/lib/cms/data-server'; import { getPublishedItems } from '@/lib/cms/data-server';
import { sanitizeRichText } from '@/lib/sanitize';
import { COMPANY_INFO } from '@/lib/constants'; import { COMPANY_INFO } from '@/lib/constants';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -193,7 +194,7 @@ export default async function TermsOfServicePage() {
return ( return (
<div className="min-h-screen bg-[var(--color-bg-primary)]"> <div className="min-h-screen bg-[var(--color-bg-primary)]">
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-brand-section)] py-20"> <div className="bg-gradient-to-br from-[var(--color-brand)] via-brand/80 to-[var(--color-brand-section)] py-20">
<div className="container-wide"> <div className="container-wide">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4"> <h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
{heroTitle} {heroTitle}
@@ -209,7 +210,7 @@ export default async function TermsOfServicePage() {
{cmsContent ? ( {cmsContent ? (
<div <div
className="prose prose-lg max-w-none" className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: cmsContent }} dangerouslySetInnerHTML={{ __html: sanitizeRichText(cmsContent) }}
/> />
) : ( ) : (
<TermsContent /> <TermsContent />
+13 -1
View File
@@ -26,6 +26,18 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { ThemeToggle } from '@/components/theme/theme-toggle'; import { ThemeToggle } from '@/components/theme/theme-toggle';
// 令牌的 role claim 有两个来源:login 签发 User.role 标量(取值见 prisma/seed.ts:199,313,324 与
// schema 默认值 'admin'),refresh 则按 UserRole.roleCode 重签(B-7 修复,refresh/route.ts:31)。
// 侧栏必须同时认这两套取值,否则刷新后的 super_admin 会掉进 else 分支显示成「编辑」。
const ROLE_LABELS: Record<string, string> = {
admin: '管理员',
super_admin: '超级管理员',
content_admin: '内容管理员',
content_editor: '内容编辑',
reviewer: '审核员',
readonly: '只读用户',
};
const menuItems = [ const menuItems = [
{ {
label: '内容管理', label: '内容管理',
@@ -238,7 +250,7 @@ export function AdminLayout({ children }: { children: React.ReactNode }) {
<p className="text-sm font-medium text-text-primary truncate"> <p className="text-sm font-medium text-text-primary truncate">
{user?.nickname || user?.username} {user?.nickname || user?.username}
</p> </p>
<p className="text-xs text-text-muted">{user?.role === 'admin' ? '管理员' : '编辑'}</p> <p className="text-xs text-text-muted">{ROLE_LABELS[user?.role ?? ''] ?? '编辑'}</p>
</div> </div>
<button <button
onClick={handleLogout} onClick={handleLogout}
@@ -0,0 +1,61 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { AuthProvider, useAuth } from '@/components/admin/auth-context';
const push = jest.fn();
jest.mock('next/navigation', () => ({ useRouter: () => ({ push }) }));
describe('AuthContext.logout', () => {
let fetchMock: jest.Mock;
const wrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
beforeEach(() => {
push.mockClear();
fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
global.fetch = fetchMock;
localStorage.setItem('novalon_admin_token', 'tok');
localStorage.setItem(
'novalon_admin_user',
JSON.stringify({ id: '1', username: 'a', nickname: 'A', role: 'x', avatar: '' }),
);
});
afterEach(() => {
localStorage.clear();
});
it('清除服务端 httpOnly cookie(调用 /api/auth/logout)并本地状态与跳转', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.logout();
});
// 关键判据:若不调用 logout 端点,clearTokenCookie 永不触发,
// authenticateRequest 会凭 cookie 在登出后继续放行全站受守卫 API 至多 24h。
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/logout',
expect.objectContaining({ method: 'POST' }),
);
expect(localStorage.getItem('novalon_admin_token')).toBeNull();
expect(localStorage.getItem('novalon_admin_user')).toBeNull();
expect(push).toHaveBeenCalledWith('/admin/login');
});
it('即使网络失败也清理本地状态并跳转', async () => {
fetchMock.mockRejectedValueOnce(new Error('network down'));
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
result.current.logout();
});
expect(localStorage.getItem('novalon_admin_token')).toBeNull();
expect(push).toHaveBeenCalledWith('/admin/login');
});
});
+9
View File
@@ -72,6 +72,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const logout = useCallback(() => { const logout = useCallback(() => {
// 必须请求 /api/auth/logout 让服务端 clearTokenCookie 下发 Max-Age=0 的 Set-Cookie。
// 登录时 httpOnly 的 novalon_token(24h)/novalon_refresh(7d) 由 login 路由写入,
// 而 authenticateRequest 会回退读取 cookie —— 仅清 localStorage 无法真正登出,
// 受守卫 API 会在登出后仍凭 cookie 放行至多 24h。
try {
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
} catch {
// 网络不可用也要继续清理本地并跳转
}
setToken(null); setToken(null);
setUser(null); setUser(null);
localStorage.removeItem('novalon_admin_token'); localStorage.removeItem('novalon_admin_token');
+10 -9
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
updateConsentDetailed, updateConsentDetailed,
trackButtonClick, trackButtonClick,
@@ -36,12 +36,12 @@ export function CookieConsent() {
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const [preferences, setPreferences] = useState<CookiePreferences>(getDefaultPreferences()); const [preferences, setPreferences] = useState<CookiePreferences>(getDefaultPreferences());
const consentCheckedRef = useRef(false);
// 刻意不加「只跑一次」的 ref 守卫:dev 下 React StrictMode 会 effect → cleanup → effect 连跑两次,
// 第一次的 cleanup 已 clearTimeout;若第二次被守卫拦掉,就没有人再挂 2s 定时器,
// 同意条在 E2E 默认的 dev 目标下永远不渲染 —— 这是 A-12 逃过全部自动化门禁的根因。
// 各分支本身可重入:迁移过的第二次走 stored 分支,只重复一次幂等的 updateConsentDetailed。
useEffect(() => { useEffect(() => {
if (consentCheckedRef.current) {return;}
consentCheckedRef.current = true;
if (isAdminRoute()) { if (isAdminRoute()) {
hasConsentBeenHandled = true; hasConsentBeenHandled = true;
return; return;
@@ -139,6 +139,7 @@ export function CookieConsent() {
exit={{ y: 100, opacity: 0 }} exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }} transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-[var(--color-bg-primary)] border-t border-[var(--color-border-secondary)] shadow-lg" className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-[var(--color-bg-primary)] border-t border-[var(--color-border-secondary)] shadow-lg"
data-testid="cookie-consent-banner"
> >
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
{!showSettings ? ( {!showSettings ? (
@@ -149,7 +150,7 @@ export function CookieConsent() {
继续使用即表示您同意我们的{' '} 继续使用即表示您同意我们的{' '}
<a <a
href="/privacy" href="/privacy"
className="text-[var(--color-brand)] hover:text-[var(--color-brand-hover)] underline font-medium" className="text-brand-ink hover:text-brand-ink-hover underline font-medium"
> >
隐私政策 隐私政策
</a> </a>
@@ -201,7 +202,7 @@ export function CookieConsent() {
type="checkbox" type="checkbox"
checked checked
disabled disabled
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand)] focus:ring-[var(--color-brand)] cursor-not-allowed" className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-brand-ink focus:ring-[var(--color-brand)] cursor-not-allowed"
aria-label="必要 Cookie" aria-label="必要 Cookie"
/> />
<div className="flex-1"> <div className="flex-1">
@@ -220,7 +221,7 @@ export function CookieConsent() {
type="checkbox" type="checkbox"
checked={preferences.analytics} checked={preferences.analytics}
onChange={() => handleTogglePreference('analytics')} onChange={() => handleTogglePreference('analytics')}
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand)] focus:ring-[var(--color-brand)] cursor-pointer" className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-brand-ink focus:ring-[var(--color-brand)] cursor-pointer"
aria-label="分析 Cookie" aria-label="分析 Cookie"
/> />
<div className="flex-1"> <div className="flex-1">
@@ -236,7 +237,7 @@ export function CookieConsent() {
type="checkbox" type="checkbox"
checked={preferences.marketing} checked={preferences.marketing}
onChange={() => handleTogglePreference('marketing')} onChange={() => handleTogglePreference('marketing')}
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand)] focus:ring-[var(--color-brand)] cursor-pointer" className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-brand-ink focus:ring-[var(--color-brand)] cursor-pointer"
aria-label="营销 Cookie" aria-label="营销 Cookie"
/> />
<div className="flex-1"> <div className="flex-1">
@@ -1,6 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, act } from '@testing-library/react'; import { render, act, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
// Mock useSyncExternalStore to always return true (mounted in browser environment) // Mock useSyncExternalStore to always return true (mounted in browser environment)
@@ -53,6 +53,7 @@ describe('GoogleAnalytics', () => {
delete (window as any).gtag; delete (window as any).gtag;
mockPathname.mockReturnValue('/'); mockPathname.mockReturnValue('/');
mockSearchParams.mockReturnValue(new URLSearchParams()); mockSearchParams.mockReturnValue(new URLSearchParams());
document.title = '';
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID = 'G-TEST123'; process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID = 'G-TEST123';
}); });
@@ -111,7 +112,7 @@ describe('GoogleAnalytics', () => {
expect(gtagMock).not.toHaveBeenCalled(); expect(gtagMock).not.toHaveBeenCalled();
}); });
it('should call config only when navigating to a different URL', () => { it('should call config only when navigating to a different URL', async () => {
setupGtag(); setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics'); const { GoogleAnalytics } = require('./GoogleAnalytics');
@@ -123,7 +124,7 @@ describe('GoogleAnalytics', () => {
rerender(<GoogleAnalytics />); rerender(<GoogleAnalytics />);
}); });
expect(gtagMock).toHaveBeenCalledTimes(1); await waitFor(() => expect(gtagMock).toHaveBeenCalledTimes(1));
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', { expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', {
page_path: '/contact', page_path: '/contact',
page_title: document.title, page_title: document.title,
@@ -131,7 +132,7 @@ describe('GoogleAnalytics', () => {
}); });
}); });
it('should include searchParams in navigation page_path', () => { it('should include searchParams in navigation page_path', async () => {
setupGtag(); setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics'); const { GoogleAnalytics } = require('./GoogleAnalytics');
@@ -143,12 +144,14 @@ describe('GoogleAnalytics', () => {
rerender(<GoogleAnalytics />); rerender(<GoogleAnalytics />);
}); });
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', expect.objectContaining({ await waitFor(() =>
page_path: '/news?page=2', expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', expect.objectContaining({
})); page_path: '/news?page=2',
}))
);
}); });
it('should skip duplicate config when URL is unchanged across re-renders', () => { it('should skip duplicate config when URL is unchanged across re-renders', async () => {
setupGtag(); setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics'); const { GoogleAnalytics } = require('./GoogleAnalytics');
@@ -158,13 +161,93 @@ describe('GoogleAnalytics', () => {
act(() => { act(() => {
rerender(<GoogleAnalytics />); rerender(<GoogleAnalytics />);
}); });
expect(gtagMock).toHaveBeenCalledTimes(1); await waitFor(() => expect(gtagMock).toHaveBeenCalledTimes(1));
act(() => { act(() => {
rerender(<GoogleAnalytics />); rerender(<GoogleAnalytics />);
}); });
expect(gtagMock).toHaveBeenCalledTimes(1); expect(gtagMock).toHaveBeenCalledTimes(1);
}); });
// 验收 A-17(探针实证):Next.js 在路由 commit 之后才写入 <title>,
// 同步读取 document.title 会把 SPA pageview 的标题发成空串。
it('should read document.title after metadata paints, not during the navigation commit', async () => {
setupGtag();
document.title = '首页 - 睿新致远';
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { rerender } = render(<GoogleAnalytics />);
mockPathname.mockReturnValue('/privacy');
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).not.toHaveBeenCalled();
act(() => {
document.title = '隐私政策 - 睿新致远';
});
await waitFor(() =>
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', expect.objectContaining({
page_path: '/privacy',
page_title: '隐私政策 - 睿新致远',
}))
);
});
// 验收 A-17 复测:真实浏览器里 metadata 不在同一帧落地(chromium-mobile 第 2 帧、
// 桌面第 3–5 帧),只等一帧会把 page_title 发成上一页标题 —— GA4 页面维度被错归属。
it('should keep waiting while the title still belongs to the previous page', async () => {
setupGtag();
document.title = '首页 - 睿新致远';
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { rerender } = render(<GoogleAnalytics />);
mockPathname.mockReturnValue('/privacy');
act(() => {
rerender(<GoogleAnalytics />);
});
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
for (let frame = 0; frame < 4; frame += 1) {
await nextFrame();
}
expect(gtagMock).not.toHaveBeenCalled();
act(() => {
document.title = '隐私政策 - 睿新致远';
});
await waitFor(() =>
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', expect.objectContaining({
page_title: '隐私政策 - 睿新致远',
}))
);
});
// 帧预算只用来「等」,不能吞掉 pageview:两条路由同名时必须在预算耗尽后兜底发出。
it('should still send the pageview when the title never changes', async () => {
setupGtag();
document.title = '固定标题';
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { rerender } = render(<GoogleAnalytics />);
mockPathname.mockReturnValue('/terms');
act(() => {
rerender(<GoogleAnalytics />);
});
await waitFor(() =>
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', expect.objectContaining({
page_path: '/terms',
page_title: '固定标题',
}))
);
});
}); });
describe('rendering guards', () => { describe('rendering guards', () => {
+37 -5
View File
@@ -6,6 +6,14 @@ import { useEffect, useRef, Suspense, useSyncExternalStore } from 'react';
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || ''; const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
/**
* SPA pageview 等待 <title> 落地的帧预算。
* 生产实测(e2e TC-GA4-004):Next.js 的 metadata 提交晚于路由 commit,
* chromium-mobile 在第 2 帧、桌面 chromium 在第 3–5 帧才写入新标题,
* 单帧读取会把 page_title 发成上一页标题或空串(污染 GA4 报表的页面维度)。
*/
const MAX_TITLE_SETTLE_FRAMES = 10;
function useIsMounted() { function useIsMounted() {
return useSyncExternalStore( return useSyncExternalStore(
(callback) => { (callback) => {
@@ -24,6 +32,8 @@ function GoogleAnalyticsContent() {
// null = 尚未导航过:首屏 pageview 由 snippet 内唯一一次 config 发送(GA 用真实 URL)。 // null = 尚未导航过:首屏 pageview 由 snippet 内唯一一次 config 发送(GA 用真实 URL)。
// effect 只在「路由变化且 URL 与上次不同」时再发 config,消除复评指出的双 config。 // effect 只在「路由变化且 URL 与上次不同」时再发 config,消除复评指出的双 config。
const lastNavigationUrlRef = useRef<string | null>(null); const lastNavigationUrlRef = useRef<string | null>(null);
/** 仍在等标题、尚未发出的上一次 pageview;新导航开始前先补发,避免丢计数 */
const pendingPageViewRef = useRef<(() => void) | null>(null);
useEffect(() => { useEffect(() => {
if (!GA_MEASUREMENT_ID || !mounted || typeof window === 'undefined') {return;} if (!GA_MEASUREMENT_ID || !mounted || typeof window === 'undefined') {return;}
@@ -38,11 +48,33 @@ function GoogleAnalyticsContent() {
lastNavigationUrlRef.current = url; lastNavigationUrlRef.current = url;
if (window.gtag) { if (window.gtag) {
window.gtag('config', GA_MEASUREMENT_ID, { pendingPageViewRef.current?.();
page_path: url,
page_title: document.title, const titleBeforeNavigation = document.title;
page_location: window.location.origin + url, let settled = false;
}); const send = () => {
if (settled) {return;}
settled = true;
pendingPageViewRef.current = null;
window.gtag?.('config', GA_MEASUREMENT_ID, {
page_path: url,
page_title: document.title,
page_location: window.location.origin + url,
});
};
pendingPageViewRef.current = send;
let framesWaited = 0;
const waitForTitle = () => {
if (settled) {return;}
if (document.title !== titleBeforeNavigation || framesWaited >= MAX_TITLE_SETTLE_FRAMES) {
send();
return;
}
framesWaited += 1;
requestAnimationFrame(waitForTitle);
};
requestAnimationFrame(waitForTitle);
} }
}, [pathname, searchParams, mounted]); }, [pathname, searchParams, mounted]);
@@ -167,6 +167,22 @@ describe('PerformanceTracker', () => {
expect(mockTrackPerformance).toHaveBeenCalledWith('CLS', 150); expect(mockTrackPerformance).toHaveBeenCalledWith('CLS', 150);
}); });
// S-24:CLS 是会话累计指标。旧实现把累加器放在 observer 回调内,
// 分批到达的 layout-shift 各自只报本批增量,GA 收到的永不是总 CLS。
it('reports cumulative CLS across observer batches, not per-batch deltas', () => {
const { PerformanceTracker } = require('./PerformanceTracker');
render(<PerformanceTracker />);
simulateCLSEntry(0.1);
simulateCLSEntry(0.05);
const clsCalls = mockTrackPerformance.mock.calls.filter((c: unknown[]) => c[0] === 'CLS');
expect(clsCalls).toHaveLength(2);
expect(clsCalls[0][1]).toBeCloseTo(100, 5);
// 第二批上报的是累计值(≈150),而不是本批增量 50
expect(clsCalls[1][1]).toBeCloseTo(150, 5);
});
it('should not track CLS when entry has recent input', () => { it('should not track CLS when entry has recent input', () => {
const { PerformanceTracker } = require('./PerformanceTracker'); const { PerformanceTracker } = require('./PerformanceTracker');
render(<PerformanceTracker />); render(<PerformanceTracker />);
@@ -26,8 +26,10 @@ export function PerformanceTracker() {
} }
}); });
// CLS 是页面会话的累计值:累加器必须在回调外,分批上报每次的总值。
// 旧实现每批从 0 起算,只报本批增量(S-24)。
let clsValue = 0;
const clsObserver = new PerformanceObserver((list) => { const clsObserver = new PerformanceObserver((list) => {
let clsValue = 0;
for (const entry of list.getEntries()) { for (const entry of list.getEntries()) {
if ('value' in entry && !(entry as LayoutShift).hadRecentInput) { if ('value' in entry && !(entry as LayoutShift).hadRecentInput) {
clsValue += (entry as LayoutShift).value; clsValue += (entry as LayoutShift).value;
-314
View File
@@ -1,314 +0,0 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import { RichTextEditor } from './RichTextEditor';
// ─── Mocks ───────────────────────────────────────────────────────────────
// Mock @tiptap/react
const mockEditor = {
getHTML: jest.fn(() => '<p>test content</p>'),
commands: {
setContent: jest.fn(),
},
chain: jest.fn(() => ({
focus: jest.fn(() => ({
toggleBold: jest.fn(() => ({ run: jest.fn() })),
toggleItalic: jest.fn(() => ({ run: jest.fn() })),
toggleHeading: jest.fn(() => ({ run: jest.fn() })),
toggleBulletList: jest.fn(() => ({ run: jest.fn() })),
toggleOrderedList: jest.fn(() => ({ run: jest.fn() })),
toggleBlockquote: jest.fn(() => ({ run: jest.fn() })),
undo: jest.fn(() => ({ run: jest.fn() })),
redo: jest.fn(() => ({ run: jest.fn() })),
extendMarkRange: jest.fn(() => ({
unsetLink: jest.fn(() => ({ run: jest.fn() })),
setLink: jest.fn(() => ({ run: jest.fn() })),
})),
setLink: jest.fn(() => ({ run: jest.fn() })),
run: jest.fn(),
})),
})),
isActive: jest.fn(() => false),
can: jest.fn(() => ({
undo: jest.fn(() => true),
redo: jest.fn(() => true),
})),
getAttributes: jest.fn(() => ({ href: '' })),
};
jest.mock('@tiptap/react', () => ({
useEditor: jest.fn(() => mockEditor),
EditorContent: (_props: {
editor: typeof mockEditor;
}) => <div data-testid="editor-content" />,
}));
// Mock lucide-react icons
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (_props: Record<string, unknown>) => (
<svg data-testid={`icon-${name.toLowerCase()}`} />
);
Icon.displayName = name;
return Icon;
};
return {
Bold: mockIcon('bold'),
Italic: mockIcon('italic'),
Heading2: mockIcon('heading2'),
Heading3: mockIcon('heading3'),
List: mockIcon('list'),
ListOrdered: mockIcon('list-ordered'),
Link: mockIcon('link'),
Quote: mockIcon('quote'),
Undo2: mockIcon('undo2'),
Redo2: mockIcon('redo2'),
};
});
// Mock window.prompt
const mockPrompt = jest.spyOn(window, 'prompt') as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockPrompt.mockReturnValue(null);
});
// ─── Tests ───────────────────────────────────────────────────────────────
describe('RichTextEditor', () => {
describe('Initial State', () => {
it('should render editor content when editor is ready', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
expect(screen.getByTestId('editor-content')).toBeInTheDocument();
});
it('should render loading state when editor is null', () => {
// Temporarily make useEditor return null
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
useEditorMock.mockReturnValueOnce(null);
render(<RichTextEditor value="" onChange={jest.fn()} />);
expect(screen.getByText('加载编辑器...')).toBeInTheDocument();
});
it('should render toolbar with buttons', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
expect(screen.getByTitle('加粗')).toBeInTheDocument();
expect(screen.getByTitle('斜体')).toBeInTheDocument();
expect(screen.getByTitle('标题 2')).toBeInTheDocument();
expect(screen.getByTitle('标题 3')).toBeInTheDocument();
expect(screen.getByTitle('无序列表')).toBeInTheDocument();
expect(screen.getByTitle('有序列表')).toBeInTheDocument();
expect(screen.getByTitle('引用')).toBeInTheDocument();
expect(screen.getByTitle('链接')).toBeInTheDocument();
expect(screen.getByTitle('撤销')).toBeInTheDocument();
expect(screen.getByTitle('重做')).toBeInTheDocument();
});
});
describe('Editor Options', () => {
it('should call useEditor with correct options', () => {
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
render(<RichTextEditor value="initial content" onChange={jest.fn()} placeholder="custom placeholder" />);
expect(useEditorMock).toHaveBeenCalledWith(
expect.objectContaining({
content: 'initial content',
extensions: expect.any(Array),
immediatelyRender: false,
})
);
});
it('should use default placeholder when not provided', () => {
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
render(<RichTextEditor value="" onChange={jest.fn()} />);
expect(useEditorMock).toHaveBeenCalledWith(
expect.objectContaining({
extensions: expect.any(Array),
})
);
});
});
describe('Toolbar Interactions', () => {
it('should toggle bold on click', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
const boldButton = screen.getByTitle('加粗');
fireEvent.click(boldButton);
expect(mockEditor.chain).toHaveBeenCalled();
});
it('should toggle italic on click', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('斜体'));
expect(mockEditor.chain).toHaveBeenCalled();
});
it('should toggle heading 2 on click', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('标题 2'));
expect(mockEditor.chain).toHaveBeenCalled();
});
it('should toggle heading 3 on click', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('标题 3'));
expect(mockEditor.chain).toHaveBeenCalled();
});
it('should toggle bullet list on click', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('无序列表'));
expect(mockEditor.chain).toHaveBeenCalled();
});
it('should toggle ordered list on click', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('有序列表'));
expect(mockEditor.chain).toHaveBeenCalled();
});
it('should toggle blockquote on click', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('引用'));
expect(mockEditor.chain).toHaveBeenCalled();
});
it('should undo on click', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('撤销'));
expect(mockEditor.chain).toHaveBeenCalled();
});
it('should redo on click', () => {
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('重做'));
expect(mockEditor.chain).toHaveBeenCalled();
});
});
describe('Link Button', () => {
it('should prompt for URL when link button is clicked', () => {
mockPrompt.mockReturnValue('https://example.com');
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('链接'));
expect(mockPrompt).toHaveBeenCalledWith('链接 URL', '');
});
it('should unset link when empty URL is provided', () => {
mockPrompt.mockReturnValue('');
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('链接'));
expect(mockPrompt).toHaveBeenCalledWith('链接 URL', '');
});
it('should not modify link when prompt is cancelled', () => {
mockPrompt.mockReturnValue(null);
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('链接'));
expect(mockPrompt).toHaveBeenCalledWith('链接 URL', '');
});
it('should show existing URL when editing a link', () => {
mockEditor.getAttributes.mockReturnValue({ href: 'https://existing.com' });
render(<RichTextEditor value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByTitle('链接'));
expect(mockPrompt).toHaveBeenCalledWith('链接 URL', 'https://existing.com');
});
});
describe('Content Synchronization', () => {
it('should sync content when value changes externally', () => {
const { rerender } = render(
<RichTextEditor value="initial" onChange={jest.fn()} />
);
rerender(<RichTextEditor value="updated" onChange={jest.fn()} />);
expect(mockEditor.commands.setContent).toHaveBeenCalledWith('updated');
});
it('should not sync content when HTML matches the value', () => {
// The component compares value === editor.getHTML()
// So we need to pass the same HTML string as the mock returns
mockEditor.getHTML.mockReturnValue('<p>same content</p>');
const { rerender } = render(
<RichTextEditor value="<p>same content</p>" onChange={jest.fn()} />
);
rerender(<RichTextEditor value="<p>same content</p>" onChange={jest.fn()} />);
// setContent should not be called when HTML matches the value
expect(mockEditor.commands.setContent).not.toHaveBeenCalled();
});
it('should call onUpdate when editor content changes', () => {
// Simulate the onUpdate callback
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
const onChange = jest.fn();
render(<RichTextEditor value="" onChange={onChange} />);
// Get the onUpdate callback from useEditor options
const editorOptions = useEditorMock.mock.calls[0]?.[0] as { onUpdate: (args: { editor: typeof mockEditor }) => void };
const onUpdate = editorOptions.onUpdate;
// Call onUpdate with mock editor
onUpdate({ editor: mockEditor });
expect(mockEditor.getHTML).toHaveBeenCalled();
});
});
describe('Editor State Management', () => {
it('should apply active state to toolbar buttons', () => {
mockEditor.isActive.mockReturnValue(true);
render(<RichTextEditor value="" onChange={jest.fn()} />);
// When active, the button should have brand styling
const boldButton = screen.getByTitle('加粗');
expect(boldButton).toBeInTheDocument();
});
it('should disable undo button when cannot undo', () => {
mockEditor.can.mockReturnValue({
undo: jest.fn(() => false),
redo: jest.fn(() => true),
});
render(<RichTextEditor value="" onChange={jest.fn()} />);
const undoButton = screen.getByTitle('撤销');
expect(undoButton).toBeDisabled();
});
it('should disable redo button when cannot redo', () => {
mockEditor.can.mockReturnValue({
undo: jest.fn(() => true),
redo: jest.fn(() => false),
});
render(<RichTextEditor value="" onChange={jest.fn()} />);
const redoButton = screen.getByTitle('重做');
expect(redoButton).toBeDisabled();
});
});
describe('Edge Cases', () => {
it('should handle null editor in useEffect', () => {
// Force editor to be null after first render
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
useEditorMock.mockReturnValueOnce(null);
const { rerender } = render(
<RichTextEditor value="test" onChange={jest.fn()} />
);
// Rerender with null editor - should not crash
expect(() => {
rerender(<RichTextEditor value="updated" onChange={jest.fn()} />);
}).not.toThrow();
});
it('should handle empty value', () => {
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
render(<RichTextEditor value="" onChange={jest.fn()} />);
const editorOptions = useEditorMock.mock.calls[0]?.[0] as { content: string };
expect(editorOptions.content).toBe('');
});
});
});
-204
View File
@@ -1,204 +0,0 @@
'use client';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect } from 'react';
import {
Bold,
Italic,
Heading2,
Heading3,
List,
ListOrdered,
Link as LinkIcon,
Quote,
Undo2,
Redo2,
} from 'lucide-react';
interface RichTextEditorProps {
value: string;
onChange: (html: string) => void;
placeholder?: string;
}
/**
* TipTap 富文本编辑器
* 用于 CMS ContentEditor 的 richtext 字段
* 支持:加粗、斜体、标题、列表、引用、链接
* 暗色主题适配(CMS Studio 暗色背景)
*/
export function RichTextEditor({ value, onChange, placeholder = '请输入内容...' }: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [2, 3],
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-brand-ink underline',
},
}),
Placeholder.configure({
placeholder,
}),
],
content: value,
immediatelyRender: false,
onUpdate: ({ editor: e }) => {
onChange(e.getHTML());
},
editorProps: {
attributes: {
class: 'rich-text-editor-content',
},
},
});
// 同步外部 value 变化(如切换内容条目时)
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value || '');
}
}, [editor, value]);
if (!editor) {
return (
<div className="min-h-[120px] bg-white/[0.05] border border-white/[0.1] rounded-lg p-3 text-white/40 text-sm">
加载编辑器...
</div>
);
}
return (
<div className="rich-text-editor bg-white/[0.05] border border-white/[0.1] rounded-lg overflow-hidden focus-within:border-brand/50 focus-within:ring-1 focus-within:ring-brand/30 transition-colors">
<Toolbar editor={editor} />
<EditorContent editor={editor} />
</div>
);
}
function Toolbar({ editor }: { editor: Editor }) {
const btnClass = (isActive: boolean) =>
`p-1.5 rounded transition-colors ${
isActive
? 'bg-brand/20 text-brand-ink'
: 'text-white/60 hover:bg-white/[0.05] hover:text-white'
}`;
const setLink = () => {
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('链接 URL', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
};
return (
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-white/[0.08] bg-white/[0.02]">
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={btnClass(editor.isActive('bold'))}
title="加粗"
>
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={btnClass(editor.isActive('italic'))}
title="斜体"
>
<Italic className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-white/[0.08] mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={btnClass(editor.isActive('heading', { level: 2 }))}
title="标题 2"
>
<Heading2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={btnClass(editor.isActive('heading', { level: 3 }))}
title="标题 3"
>
<Heading3 className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-white/[0.08] mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={btnClass(editor.isActive('bulletList'))}
title="无序列表"
>
<List className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={btnClass(editor.isActive('orderedList'))}
title="有序列表"
>
<ListOrdered className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={btnClass(editor.isActive('blockquote'))}
title="引用"
>
<Quote className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-white/[0.08] mx-1" />
<button
type="button"
onClick={setLink}
className={btnClass(editor.isActive('link'))}
title="链接"
>
<LinkIcon className="w-4 h-4" />
</button>
<div className="flex-1" />
<button
type="button"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
className="p-1.5 rounded text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="撤销"
>
<Undo2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
className="p-1.5 rounded text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="重做"
>
<Redo2 className="w-4 h-4" />
</button>
</div>
);
}
+366 -15
View File
@@ -1,21 +1,66 @@
import { describe, it, expect } from '@jest/globals'; import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { MethodologyFramework } from './sections'; import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import {
MethodologyFramework,
TechStackShowcase,
DeliveryTimeline,
FAQSection,
DataProofSection,
type DataMetric,
type MethodologyLayer,
type TechStackCategory,
type TimelineStep,
} from './sections';
// ─── Mocks ───────────────────────────────────────────────────────────────
// framer-motion 换成哑组件:本文件的动画不可见,animate/initial 是唯一能断言的状态输出,
// 因此 FAQ 折叠态等「状态机」只能通过 data-animate 校验(见 sections.test.tsx 同族范式)。
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, variants, whileInView, viewport, transition, className, style, ...props }: any) => (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
data-animate={JSON.stringify(animate)}
data-variants={JSON.stringify(variants)}
data-while-inview={JSON.stringify(whileInView)}
data-viewport={JSON.stringify(viewport)}
data-transition={JSON.stringify(transition)}
className={className}
style={style}
{...props}
>
{children}
</div>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
// ─── Test Data ───────────────────────────────────────────────────────────
const layers: MethodologyLayer[] = [
{ title: '诊断', description: '业务现状诊断', items: ['访谈', '数据分析'] },
{ title: '设计', description: '解决方案设计', items: ['架构设计'] },
];
/** 取答案文本所在的高度动画容器 */
function panelOf(answer: string): HTMLElement {
const el = screen.getByText(answer).closest('[data-testid="motion-div"]');
if (!el) throw new Error(`找不到答案「${answer}」所属的动画容器`);
return el as HTMLElement;
}
function panelAnimate(answer: string): { height?: number | string; opacity?: number } {
return JSON.parse(panelOf(answer).dataset.animate ?? '{}');
}
// ─── MethodologyFramework ────────────────────────────────────────────────
describe('MethodologyFramework', () => { describe('MethodologyFramework', () => {
const layers = [
{
title: '诊断',
description: '业务现状诊断',
items: ['访谈', '数据分析'],
},
{
title: '设计',
description: '解决方案设计',
items: ['架构设计'],
},
];
it('renders title and subtitle', () => { it('renders title and subtitle', () => {
render(<MethodologyFramework title="方法论" subtitle="分层推进" layers={layers} />); render(<MethodologyFramework title="方法论" subtitle="分层推进" layers={layers} />);
expect(screen.getByText('方法论')).toBeInTheDocument(); expect(screen.getByText('方法论')).toBeInTheDocument();
@@ -33,7 +78,313 @@ describe('MethodologyFramework', () => {
it('renders without subtitle', () => { it('renders without subtitle', () => {
const { container } = render(<MethodologyFramework title="方法论" layers={layers} />); const { container } = render(<MethodologyFramework title="方法论" layers={layers} />);
expect(container.querySelector('h2')).toHaveTextContent('方法论'); expect(container.querySelector('h2')).toHaveTextContent('方法论');
// 无 subtitle 时只剩每层描述各一个 <p>,不额外多出引言段
expect(container.querySelectorAll('p')).toHaveLength(layers.length);
});
it('draws a connector only between adjacent layers, never after the last one', () => {
const { container } = render(<MethodologyFramework title="方法论" layers={layers} />);
expect(container.querySelectorAll('[class*="bg-gradient-to-b"]')).toHaveLength(1);
const single = render(<MethodologyFramework title="单步方法论" layers={[layers[0]!]} />);
expect(single.container.querySelectorAll('[class*="bg-gradient-to-b"]')).toHaveLength(0);
});
it('defaults the accent to brand red and honours a custom accentColor', () => {
const { container, unmount } = render(<MethodologyFramework title="M" layers={layers} />);
const dot = container.querySelector('div[class*="rounded-full"]');
expect(dot).toHaveStyle({ backgroundColor: 'rgb(196, 30, 58)' });
unmount();
const custom = render(
<MethodologyFramework title="M" layers={layers} accentColor="#0891B2" />,
);
expect(custom.container.querySelector('div[class*="rounded-full"]')).toHaveStyle({
backgroundColor: 'rgb(8, 145, 178)',
});
});
it('renders only the heading block when layers is empty', () => {
const { container } = render(<MethodologyFramework title="空方法论" layers={[]} />);
expect(screen.getByText('空方法论')).toBeInTheDocument();
expect(container.querySelectorAll('h3')).toHaveLength(0);
});
});
// ─── TechStackShowcase ───────────────────────────────────────────────────
describe('TechStackShowcase', () => {
const categories: TechStackCategory[] = [
{ name: '后端', items: ['Java', 'Node.js'] },
{ name: '数据', items: ['ClickHouse'] },
];
it('renders every category card with its technology chips', () => {
render(
<TechStackShowcase
title="技术栈"
subtitle="按能力域分组"
categories={categories}
/>,
);
expect(screen.getByText('技术栈')).toBeInTheDocument();
expect(screen.getByText('按能力域分组')).toBeInTheDocument();
expect(screen.getAllByText('Java')).toHaveLength(1);
expect(screen.getAllByText('Node.js')).toHaveLength(1);
expect(screen.getAllByText('ClickHouse')).toHaveLength(1);
});
it('omits the subtitle paragraph when subtitle is not given', () => {
const { container } = render(<TechStackShowcase title="技术栈" categories={categories} />);
expect(container.querySelectorAll('p')).toHaveLength(0);
expect(container.querySelectorAll('h3')).toHaveLength(2);
});
it('renders a card without chips when a category has no items', () => {
const { container } = render(
<TechStackShowcase title="技术栈" categories={[{ name: '待定', items: [] }]} />,
);
expect(screen.getByText('待定')).toBeInTheDocument();
expect(container.querySelectorAll('h3')).toHaveLength(1);
// 空 items 下不产生任何 chip(chip 是 span,标题是 h3)
expect(container.querySelectorAll('.flex.flex-wrap.gap-2 span')).toHaveLength(0);
});
});
// ─── Q-5 响应式网格回归 ────────────────────────────────────────────────────
// StaggerReveal 会为每个子项各包一层 motion.div。若把 grid/space 类放在「只含 StaggerReveal
// 这一个子元素」的外层容器上,包装层成为唯一网格项 ⇒ 卡片在桌面端塌成单列、gap/space 全部失效。
// 正确做法是把网格类挂到 StaggerReveal 自身(其 className 落在 motion.div 上,直接子项即各卡片)。
describe('Q-5 网格类必须挂在 StaggerReveal 上(卡片为网格直接子项)', () => {
it('TechStackShowcase: grid-cols-3 元素直接父有每类一张卡', () => {
const { container } = render(
<TechStackShowcase
title="技术栈"
categories={[
{ name: '后端', items: ['Java'] },
{ name: '数据', items: ['ClickHouse'] },
{ name: '前端', items: ['React'] },
]}
/>,
);
const grid = container.querySelector('[class*="grid-cols-3"]');
expect(grid).not.toBeNull();
expect(grid!.className).toContain('grid');
// 旧结构下 grid 元素只有 1 个子项(StaggerReveal 包裹层)⇒ 单列;修复后应为 3
expect(grid!.children.length).toBe(3);
});
it('DataProofSection: grid-cols-4 元素直接父有每个指标一格', () => {
const metrics = [
{ value: '99.9%', label: '可用率' },
{ value: '20+', label: '对接源' },
{ value: '40%', label: '提效' },
];
const { container } = render(<DataProofSection title="成果" metrics={metrics} />);
const grid = container.querySelector('[class*="grid-cols-4"]');
expect(grid).not.toBeNull();
expect(grid!.className).toContain('grid');
expect(grid!.children.length).toBe(3);
});
it('FAQSection: space-y 挂在 StaggerReveal 上,问答条目为其直接子项', () => {
const items = [
{ question: 'Q1', answer: 'A1' },
{ question: 'Q2', answer: 'A2' },
];
const { container } = render(<FAQSection title="常见问题" items={items} />);
const stack = container.querySelector('[class*="space-y-3"]');
expect(stack).not.toBeNull();
// 旧结构下 space-y-3 在外层 div(仅 1 个子 = StaggerReveal 根)⇒ 间距失效
expect(stack!.children.length).toBe(2);
}); });
}); });
// ─── DeliveryTimeline ────────────────────────────────────────────────────
describe('DeliveryTimeline', () => {
const steps: TimelineStep[] = [
{
phase: '诊断',
title: '现状梳理',
description: '访谈与数据盘点',
duration: '2 周',
deliverables: ['诊断报告', '路线图'],
},
{
phase: '交付',
title: '系统上线',
description: '迭代开发与部署',
duration: '8 周',
deliverables: ['生产环境'],
},
{
phase: '陪跑',
title: '运营支持',
description: '指标看板与培训',
duration: '长期',
deliverables: [],
},
];
it('numbers phases from 1 and shows duration, title, description and deliverables', () => {
render(<DeliveryTimeline title="交付路径" subtitle="四步走" steps={steps} />);
expect(screen.getByText('第 1 阶段:诊断')).toBeInTheDocument();
expect(screen.getByText('第 2 阶段:交付')).toBeInTheDocument();
expect(screen.getByText('第 3 阶段:陪跑')).toBeInTheDocument();
expect(screen.getByText('2 周')).toBeInTheDocument();
expect(screen.getByText('现状梳理')).toBeInTheDocument();
expect(screen.getByText('访谈与数据盘点')).toBeInTheDocument();
expect(screen.getByText('诊断报告')).toBeInTheDocument();
expect(screen.getAllByText('阶段产出')).toHaveLength(3);
});
it('alternates the card side per index', () => {
const { container } = render(<DeliveryTimeline title="交付路径" steps={steps} />);
const leftShifted = container.querySelectorAll('[class*="lg:ml-12"]');
const rightShifted = container.querySelectorAll('[class*="lg:mr-12"]');
expect(leftShifted).toHaveLength(2); // index 0 与 2
expect(rightShifted).toHaveLength(1); // index 1
expect(container.querySelectorAll('[class*="lg:flex-row-reverse"]')).toHaveLength(1);
});
it('paints the timeline node border with accentColor', () => {
const { container } = render(
<DeliveryTimeline title="交付路径" steps={steps} accentColor="#1D4ED8" />,
);
const node = container.querySelector('div[class*="rounded-full"]');
expect(node).toHaveStyle({ borderColor: 'rgb(29, 78, 216)' });
});
it('renders the heading only when there are no steps', () => {
const { container } = render(<DeliveryTimeline title="交付路径" steps={[]} />);
expect(screen.getByText('交付路径')).toBeInTheDocument();
expect(container.querySelectorAll('h3')).toHaveLength(0);
expect(container.querySelectorAll('[class*="rounded-full"]')).toHaveLength(0);
});
});
// ─── FAQSection ──────────────────────────────────────────────────────────
describe('FAQSection', () => {
const items = [
{ question: '交付周期多久?', answer: '通常 8-12 周。' },
{ question: '是否私有化部署?', answer: '支持全栈私有化。' },
];
it('keeps the first question open and the rest collapsed on mount', () => {
render(<FAQSection title="常见问题" subtitle="选型答疑" items={items} />);
expect(panelAnimate('通常 8-12 周。')).toEqual({ height: 'auto', opacity: 1 });
expect(panelAnimate('支持全栈私有化。')).toEqual({ height: 0, opacity: 0 });
// 展开项的箭头旋转 180°,折叠项归零
const openChevron = screen.getByText('交付周期多久?').closest('button');
expect(
JSON.parse(openChevron?.querySelector('[data-testid="motion-div"]')?.getAttribute('data-animate') ?? '{}'),
).toEqual({ rotate: 180 });
});
it('opens the clicked question and closes the previously open one', async () => {
render(<FAQSection title="常见问题" items={items} />);
await userEvent.click(screen.getByText('是否私有化部署?'));
expect(panelAnimate('支持全栈私有化。')).toEqual({ height: 'auto', opacity: 1 });
expect(panelAnimate('通常 8-12 周。')).toEqual({ height: 0, opacity: 0 });
});
it('collapses the open question when its own trigger is clicked again', async () => {
render(<FAQSection title="常见问题" items={items} />);
await userEvent.click(screen.getByText('交付周期多久?'));
expect(panelAnimate('通常 8-12 周。')).toEqual({ height: 0, opacity: 0 });
// 全部折叠后箭头不再旋转
const chevron = screen
.getByText('交付周期多久?')
.closest('button')
?.querySelector('[data-testid="motion-div"]');
expect(JSON.parse(chevron?.getAttribute('data-animate') ?? '{}')).toEqual({ rotate: 0 });
});
it('renders no accordion when items is empty', () => {
render(<FAQSection title="常见问题" items={[]} />);
expect(screen.getByText('常见问题')).toBeInTheDocument();
expect(screen.queryAllByRole('button')).toHaveLength(0);
});
});
// ─── DataProofSection ────────────────────────────────────────────────────
describe('DataProofSection', () => {
const metrics: DataMetric[] = [
{
value: '40%',
label: '库存周转提升',
description: '以季度为口径',
trend: '+12% 环比',
trendUp: true,
basis: 'target',
},
{ value: '99.9%', label: '系统可用率', trend: '-0.1% 波动', trendUp: false, basis: 'verified' },
];
it('renders each metric with optional description and trend', () => {
render(<DataProofSection title="可验证的成果" subtitle="口径透明" metrics={metrics} />);
expect(screen.getByText('40%')).toBeInTheDocument();
expect(screen.getByText('库存周转提升')).toBeInTheDocument();
expect(screen.getByText('以季度为口径')).toBeInTheDocument();
expect(screen.getByText('99.9%')).toBeInTheDocument();
expect(screen.getByText('+12% 环比')).toBeInTheDocument();
expect(screen.getByText('-0.1% 波动')).toBeInTheDocument();
});
it('colors up-trends as success and down-trends as brand ink', () => {
const { container } = render(<DataProofSection title="成果" metrics={metrics} />);
const up = screen.getByText('+12% 环比').closest('[class*="inline-flex"]');
const down = screen.getByText('-0.1% 波动').closest('[class*="inline-flex"]');
expect(up?.className).toContain('text-success');
expect(up?.className).not.toContain('text-brand-ink');
expect(down?.className).toContain('text-brand-ink');
expect(container.querySelectorAll('[class*="inline-flex"]')).toHaveLength(2);
});
it('omits description and trend blocks when those fields are absent', () => {
const { container } = render(
<DataProofSection title="成果" metrics={[{ value: '3 座', label: '交付城市' }]} />,
);
expect(screen.getByText('交付城市')).toBeInTheDocument();
expect(container.querySelectorAll('[class*="inline-flex"]')).toHaveLength(0);
// 无 description 时不渲染 <p>,但口径角注本身是一个 <p>
expect(container.querySelectorAll('p')).toHaveLength(1);
});
it('annotates the aggregate basis as the weakest one across metrics', () => {
render(<DataProofSection title="成果" metrics={metrics} />);
// target(0) 弱于 verified(2) → 取 target 文案
expect(screen.getByTestId('metrics-basis-note')).toHaveTextContent('目标口径,非既成结果');
});
it('annotates a stronger note when every metric is verified', () => {
render(
<DataProofSection title="成果" metrics={[{ value: '40%', label: 'X', basis: 'verified' }]} />,
);
expect(screen.getByTestId('metrics-basis-note')).toHaveTextContent('已有可核验的实测出处');
});
it('annotates team-history wording when that is the only declared basis', () => {
render(
<DataProofSection title="成果" metrics={[{ value: '40%', label: 'X', basis: 'team-history' }]} />,
);
expect(screen.getByTestId('metrics-basis-note')).toHaveTextContent('团队历史项目成果');
});
it('renders nothing when metrics is empty', () => {
const { container } = render(<DataProofSection title="成果" metrics={[]} />);
expect(container).toBeEmptyDOMElement();
expect(screen.queryByText('成果')).not.toBeInTheDocument();
});
it('guards a missing metrics prop instead of throwing', () => {
const { container } = render(
<DataProofSection title="成果" metrics={undefined as unknown as DataMetric[]} />,
);
expect(container).toBeEmptyDOMElement();
});
});
+17 -13
View File
@@ -51,7 +51,7 @@ export function MethodologyFramework({
initial={{ opacity: 0, x: -30 }} initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: '-50px' }} viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }} transition={{ duration: 0.28, delay: index * 0.06, ease: EASE_OUT }}
> >
<div className="flex gap-6 lg:gap-10"> <div className="flex gap-6 lg:gap-10">
<div className="flex flex-col items-center pt-2"> <div className="flex flex-col items-center pt-2">
@@ -118,8 +118,11 @@ export function TechStackShowcase({ title, subtitle, categories }: TechStackShow
)} )}
</ScrollReveal> </ScrollReveal>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <StaggerReveal
<StaggerReveal staggerDelay={0.05} delayChildren={0.05}> staggerDelay={0.05}
delayChildren={0.05}
className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"
>
{categories.map((category, index) => ( {categories.map((category, index) => (
<div <div
key={index} key={index}
@@ -130,7 +133,7 @@ export function TechStackShowcase({ title, subtitle, categories }: TechStackShow
{category.items.map((item, i) => ( {category.items.map((item, i) => (
<span <span
key={i} key={i}
className="px-3 py-1.5 text-xs font-medium bg-bg-secondary text-text-secondary border border-border-primary hover:border-brand/30 hover:text-brand-ink transition-colors duration-300" className="px-3 py-1.5 text-xs font-medium bg-bg-secondary text-text-secondary border border-border-primary hover:border-brand/30 hover:text-brand-ink transition-colors duration-normal"
> >
{item} {item}
</span> </span>
@@ -139,7 +142,6 @@ export function TechStackShowcase({ title, subtitle, categories }: TechStackShow
</div> </div>
))} ))}
</StaggerReveal> </StaggerReveal>
</div>
</div> </div>
</section> </section>
); );
@@ -269,12 +271,12 @@ export function FAQSection({ title, subtitle, items }: FAQSectionProps) {
)} )}
</ScrollReveal> </ScrollReveal>
<div className="lg:col-span-8 space-y-3"> <div className="lg:col-span-8">
<StaggerReveal staggerDelay={0.06} delayChildren={0.05}> <StaggerReveal staggerDelay={0.06} delayChildren={0.05} className="space-y-3">
{items.map((item, index) => ( {items.map((item, index) => (
<div <div
key={index} key={index}
className="border border-border-primary bg-bg-primary hover:border-border-secondary transition-colors duration-300" className="border border-border-primary bg-bg-primary hover:border-border-secondary transition-colors duration-normal"
> >
<button <button
onClick={() => setOpenIndex(openIndex === index ? null : index)} onClick={() => setOpenIndex(openIndex === index ? null : index)}
@@ -285,7 +287,7 @@ export function FAQSection({ title, subtitle, items }: FAQSectionProps) {
</span> </span>
<motion.div <motion.div
animate={{ rotate: openIndex === index ? 180 : 0 }} animate={{ rotate: openIndex === index ? 180 : 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="shrink-0" className="shrink-0"
> >
<ChevronDown className="w-5 h-5 text-text-muted" /> <ChevronDown className="w-5 h-5 text-text-muted" />
@@ -297,7 +299,7 @@ export function FAQSection({ title, subtitle, items }: FAQSectionProps) {
height: openIndex === index ? 'auto' : 0, height: openIndex === index ? 'auto' : 0,
opacity: openIndex === index ? 1 : 0, opacity: openIndex === index ? 1 : 0,
}} }}
transition={{ duration: 0.3, ease: EASE_OUT }} transition={{ duration: 0.28, ease: EASE_OUT }}
className="overflow-hidden" className="overflow-hidden"
> >
<div className="px-6 lg:px-7 pb-7 pt-1 text-text-secondary leading-relaxed"> <div className="px-6 lg:px-7 pb-7 pt-1 text-text-secondary leading-relaxed">
@@ -347,8 +349,11 @@ export function DataProofSection({ title, subtitle, metrics }: DataProofSectionP
)} )}
</ScrollReveal> </ScrollReveal>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary"> <StaggerReveal
<StaggerReveal staggerDelay={0.05} delayChildren={0.05}> staggerDelay={0.05}
delayChildren={0.05}
className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary"
>
{metrics.map((metric, index) => ( {metrics.map((metric, index) => (
<div <div
key={index} key={index}
@@ -378,7 +383,6 @@ export function DataProofSection({ title, subtitle, metrics }: DataProofSectionP
</div> </div>
))} ))}
</StaggerReveal> </StaggerReveal>
</div>
<MetricsBasisNote basis={basis} className="mt-6" /> <MetricsBasisNote basis={basis} className="mt-6" />
</div> </div>
</section> </section>
@@ -1,55 +0,0 @@
import { describe, it, expect } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import { TestimonialSection, LogoWall } from './testimonials';
describe('TestimonialSection', () => {
const testimonials = [
{
quote: '服务非常专业',
author: '张三',
title: 'CEO',
company: 'A 公司',
industry: '科技',
},
{
quote: '交付准时',
author: '李四',
title: 'CTO',
company: 'B 公司',
industry: '制造',
},
];
it('renders title and subtitle', () => {
render(<TestimonialSection title="客户评价" subtitle="来自客户的真实反馈" testimonials={testimonials} />);
expect(screen.getByText('客户评价')).toBeInTheDocument();
expect(screen.getByText('来自客户的真实反馈')).toBeInTheDocument();
});
it('renders first testimonial by default', () => {
render(<TestimonialSection title="客户评价" testimonials={testimonials} />);
expect(screen.getByText('张三')).toBeInTheDocument();
expect(screen.getByText('科技')).toBeInTheDocument();
});
it('switches testimonial on dot click', () => {
render(<TestimonialSection title="客户评价" testimonials={testimonials} />);
const dots = screen.getAllByRole('button');
fireEvent.click(dots[1]!);
expect(screen.getByText('李四')).toBeInTheDocument();
expect(screen.getByText('制造')).toBeInTheDocument();
});
it('renders without subtitle', () => {
const { container } = render(<TestimonialSection title="客户评价" testimonials={testimonials} />);
expect(container.querySelector('h2')).toHaveTextContent('客户评价');
});
});
describe('LogoWall (testimonials)', () => {
it('renders logo wall', () => {
render(<LogoWall title="合作企业" logos={[{ name: 'A 公司' }, { name: 'B 公司', industry: '制造' }]} />);
expect(screen.getByText('合作企业')).toBeInTheDocument();
expect(screen.getByText('A 公司')).toBeInTheDocument();
});
});
-155
View File
@@ -1,155 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { Quote } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/utils';
export interface Testimonial {
quote: string;
author: string;
title: string;
company: string;
industry: string;
}
export interface TestimonialSectionProps {
title: string;
subtitle?: string;
testimonials: Testimonial[];
accentColor?: string;
}
export function TestimonialSection({
title,
subtitle,
testimonials,
accentColor = '#C41E3A',
}: TestimonialSectionProps) {
const [activeIndex, setActiveIndex] = useState(0);
return (
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
<SectionLabel>Client Stories</SectionLabel>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
{title}
</h2>
{subtitle && (
<p className="text-text-secondary leading-relaxed text-lg">
{subtitle}
</p>
)}
</ScrollReveal>
<div className="relative">
<div className="relative overflow-hidden">
<motion.div
className="flex"
animate={{ x: `-${activeIndex * 100}%` }}
transition={{ duration: 0.3, ease: EASE_OUT }}
>
{testimonials.map((testimonial, index) => (
<div key={index} className="w-full shrink-0">
<div className="max-w-4xl mx-auto">
<div className="relative p-10 lg:p-16 border border-border-primary bg-bg-primary">
<Quote className="absolute top-8 left-8 w-12 h-12 opacity-10 text-brand-ink" />
<div className="relative z-10">
<p className="text-xl lg:text-2xl text-text-primary leading-relaxed font-light mb-10 lg:mb-14">
「{testimonial.quote}」
</p>
<div className="flex items-center gap-5">
<div
className="w-14 h-14 rounded-full flex items-center justify-center text-white font-bold text-lg"
style={{ backgroundColor: accentColor }}
>
{testimonial.author.charAt(0)}
</div>
<div>
<div className="text-text-primary font-semibold text-lg">
{testimonial.author}
</div>
<div className="text-text-secondary text-sm">
{testimonial.title} · {testimonial.company}
</div>
<div className="text-text-muted text-xs mt-0.5">
{testimonial.industry}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
))}
</motion.div>
</div>
<div className="flex justify-center gap-3 mt-10">
{testimonials.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={cn(
'h-2 rounded-full transition-all duration-300',
activeIndex === index
? 'w-8'
: 'w-2 bg-border-primary hover:bg-border-secondary'
)}
style={activeIndex === index ? { backgroundColor: accentColor } : {}}
/>
))}
</div>
</div>
</div>
</section>
);
}
export interface LogoWallProps {
title: string;
logos: { name: string; industry?: string }[];
}
export function LogoWall({ title, logos }: LogoWallProps) {
return (
<section className="relative py-16 sm:py-20 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal>
<div className="text-center mb-12">
<p className="text-sm text-text-muted tracking-[0.2em] uppercase font-medium">
{title}
</p>
</div>
</ScrollReveal>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-px bg-border-primary">
{logos.map((logo, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05, ease: EASE_OUT }}
className="flex items-center justify-center p-8 bg-bg-primary hover:bg-bg-secondary transition-colors duration-300"
>
<div className="text-center">
<div className="text-text-secondary font-semibold text-base hover:text-text-primary transition-colors duration-300">
{logo.name}
</div>
{logo.industry && (
<div className="text-text-muted text-xs mt-1">
{logo.industry}
</div>
)}
</div>
</motion.div>
))}
</div>
</div>
</section>
);
}
@@ -75,12 +75,12 @@ export function CaseStudiesSection({
{caseStudies.map((study) => ( {caseStudies.map((study) => (
<div <div
key={study.client} key={study.client}
className="group relative block h-full overflow-hidden bg-bg-primary transition-all duration-300 hover:bg-bg-secondary" className="group relative block h-full overflow-hidden bg-bg-primary transition-all duration-normal hover:bg-bg-secondary"
> >
<div className="aspect-[4/3] bg-gradient-to-br from-bg-secondary to-bg-tertiary relative overflow-hidden"> <div className="aspect-[4/3] bg-gradient-to-br from-bg-secondary to-bg-tertiary relative overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
{/* 客户名首字作占位图形(无真实配图时),纯装饰 → aria-hidden 豁免对比度 */} {/* 客户名首字占位图形(无真实配图时):aria-hidden 不豁免 1.4.3,用大字档令牌 text-text-hint */}
<span aria-hidden="true" className="text-6xl font-bold text-text-muted/10"> <span aria-hidden="true" className="text-6xl font-bold text-text-hint">
{study.client.charAt(0)} {study.client.charAt(0)}
</span> </span>
</div> </div>
@@ -92,7 +92,7 @@ export function CaseStudiesSection({
</div> </div>
<div className="p-8"> <div className="p-8">
<h3 className="text-xl font-bold text-ink mb-3 group-hover:translate-x-2 transition-transform duration-300"> <h3 className="text-xl font-bold text-ink mb-3 group-hover:translate-x-2 transition-transform duration-normal">
{study.client} {study.client}
</h3> </h3>
<p className="text-text-secondary text-sm leading-relaxed mb-6"> <p className="text-text-secondary text-sm leading-relaxed mb-6">
+1 -1
View File
@@ -14,7 +14,7 @@ export function CaseStudyCard({ study, index }: CaseStudyCardProps) {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }} viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.3, delay: index * 0.06 }} transition={{ duration: 0.28, delay: index * 0.06 }}
className="rounded-xl border border-border-primary bg-bg-elevated p-6 shadow-sm transition-shadow hover:shadow-md" className="rounded-xl border border-border-primary bg-bg-elevated p-6 shadow-sm transition-shadow hover:shadow-md"
> >
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
@@ -21,7 +21,7 @@ export function CertificationList({ certifications }: CertificationListProps) {
initial: { opacity: 0, scale: 0.95 }, initial: { opacity: 0, scale: 0.95 },
whileInView: { opacity: 1, scale: 1 }, whileInView: { opacity: 1, scale: 1 },
viewport: { once: true, margin: '-50px' }, viewport: { once: true, margin: '-50px' },
transition: { duration: 0.3, delay: index * 0.05 }, transition: { duration: 0.28, delay: index * 0.05 },
className, className,
}; };
const inner = ( const inner = (
@@ -57,7 +57,7 @@ export function CertificationsSection({ certifications }: CertificationsSectionP
{certifications.map((cert, idx) => ( {certifications.map((cert, idx) => (
<div <div
key={idx} key={idx}
className="group flex items-center gap-4 px-8 py-5 bg-bg-primary hover:bg-bg-secondary transition-all duration-300" className="group flex items-center gap-4 px-8 py-5 bg-bg-primary hover:bg-bg-secondary transition-all duration-normal"
> >
<div className="w-12 h-12 rounded-xl bg-brand-soft flex items-center justify-center text-brand-ink"> <div className="w-12 h-12 rounded-xl bg-brand-soft flex items-center justify-center text-brand-ink">
<Shield className="w-6 h-6" /> <Shield className="w-6 h-6" />
+3 -3
View File
@@ -38,7 +38,7 @@ export function DetailCTASection({
initial={{ opacity: 0, y: 32 }} initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }} viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className="max-w-3xl" className="max-w-3xl"
> >
{/* 标签 */} {/* 标签 */}
@@ -61,7 +61,7 @@ export function DetailCTASection({
className="group inline-flex items-center gap-3 px-10 py-4 bg-white text-brand font-bold text-base hover:bg-white/90 transition-all duration-200" className="group inline-flex items-center gap-3 px-10 py-4 bg-white text-brand font-bold text-base hover:bg-white/90 transition-all duration-200"
> >
<span>{primaryAction.label}</span> <span>{primaryAction.label}</span>
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" /> <ArrowRight className="w-5 h-5 transition-transform duration-normal group-hover:translate-x-1" />
</a> </a>
{secondaryAction && ( {secondaryAction && (
@@ -78,7 +78,7 @@ export function DetailCTASection({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }} whileInView={{ opacity: 1 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.5, duration: 0.3 }} transition={{ delay: 0.5, duration: 0.28 }}
className="mt-10 text-sm text-white/70" className="mt-10 text-sm text-white/70"
> >
平均响应时间 &lt; 2小时 · 7×24小时技术支持 · 无需预付费用 平均响应时间 &lt; 2小时 · 7×24小时技术支持 · 无需预付费用
+21 -17
View File
@@ -69,7 +69,7 @@ export function DetailHero({
scale: 1, scale: 1,
filter: 'blur(0px)', filter: 'blur(0px)',
transition: { transition: {
duration: 0.3, duration: 0.28,
ease: [0.22, 1, 0.36, 1] as const, ease: [0.22, 1, 0.36, 1] as const,
}, },
}, },
@@ -101,7 +101,7 @@ export function DetailHero({
y: 0, y: 0,
filter: 'blur(0px)', filter: 'blur(0px)',
transition: { transition: {
duration: 0.3, duration: 0.28,
ease: [0.22, 1, 0.36, 1] as const, ease: [0.22, 1, 0.36, 1] as const,
}, },
}, },
@@ -120,7 +120,7 @@ export function DetailHero({
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { transition: {
duration: 0.3, duration: 0.28,
delay: 0.25, delay: 0.25,
ease: [0.22, 1, 0.36, 1] as const, ease: [0.22, 1, 0.36, 1] as const,
}, },
@@ -139,7 +139,7 @@ export function DetailHero({
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { transition: {
duration: 0.3, duration: 0.28,
delay: 0.4, delay: 0.4,
ease: [0.22, 1, 0.36, 1] as const, ease: [0.22, 1, 0.36, 1] as const,
}, },
@@ -159,7 +159,7 @@ export function DetailHero({
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { transition: {
duration: 0.3, duration: 0.28,
delay: 0.55, delay: 0.55,
ease: [0.22, 1, 0.36, 1] as const, ease: [0.22, 1, 0.36, 1] as const,
}, },
@@ -168,19 +168,23 @@ export function DetailHero({
className={`mt-12 flex flex-wrap gap-5 ${isCenterLayout ? 'justify-center' : ''}`} className={`mt-12 flex flex-wrap gap-5 ${isCenterLayout ? 'justify-center' : ''}`}
> >
{primaryAction && ( {primaryAction && (
<a href={primaryAction.href}> <PressableButton
<PressableButton variant="primary" className="px-10 py-4 text-base shadow-lg group"> href={primaryAction.href}
<span>{primaryAction.label}</span> variant="primary"
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 group-hover:-translate-y-0.5 transition-transform duration-300" /> className="px-10 py-4 text-base shadow-lg group"
</PressableButton> >
</a> <span>{primaryAction.label}</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 group-hover:-translate-y-0.5 transition-transform duration-normal" />
</PressableButton>
)} )}
{secondaryAction && ( {secondaryAction && (
<a href={secondaryAction.href}> <PressableButton
<PressableButton variant="secondary" className="px-8 py-4 text-base"> href={secondaryAction.href}
{secondaryAction.label} variant="secondary"
</PressableButton> className="px-8 py-4 text-base"
</a> >
{secondaryAction.label}
</PressableButton>
)} )}
</motion.div> </motion.div>
)} )}
@@ -189,7 +193,7 @@ export function DetailHero({
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 1.5, duration: 0.3 }} transition={{ delay: 1.5, duration: 0.28 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2" className="absolute bottom-8 left-1/2 -translate-x-1/2"
> >
<div <div
+28 -22
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
CheckCircle2, CheckCircle2,
ArrowRight, ArrowRight,
@@ -70,25 +70,31 @@ function MetricCard({
const suffix = value.replace(/[0-9.]/g, ''); const suffix = value.replace(/[0-9.]/g, '');
const [displayValue, setDisplayValue] = useState('0'); const [displayValue, setDisplayValue] = useState('0');
if (isInView && displayValue === '0') { // 动画必须在 effect 中驱动并在卸载/重跑时取消。历史写法在 render 体内 setState +
// requestAnimationFrame:每一帧的 setState 又触发重渲染并再 spawn 一条从 0 开始的动画链,
// 相互竞争且从不取消(Q-6)。
useEffect(() => {
if (!isInView) return;
if (isNaN(numericValue)) { if (isNaN(numericValue)) {
setDisplayValue(value); setDisplayValue(value);
} else { return;
let startTime: number;
const animate = (currentTime: number) => {
if (!startTime) startTime = currentTime;
const progress = Math.min((currentTime - startTime) / 2000, 1);
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
setDisplayValue(
Number.isInteger(numericValue)
? Math.floor(numericValue * easeOutQuart).toString() + suffix
: (numericValue * easeOutQuart).toFixed(1) + suffix
);
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
} }
} let frame = 0;
let startTime: number | undefined;
const animate = (currentTime: number) => {
if (startTime === undefined) startTime = currentTime;
const progress = Math.min((currentTime - startTime) / 2000, 1);
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
setDisplayValue(
Number.isInteger(numericValue)
? Math.floor(numericValue * easeOutQuart).toString() + suffix
: (numericValue * easeOutQuart).toFixed(1) + suffix,
);
if (progress < 1) frame = requestAnimationFrame(animate);
};
frame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(frame);
}, [isInView, numericValue, suffix, value]);
return ( return (
<div className="bain-card p-8"> <div className="bain-card p-8">
@@ -120,7 +126,7 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
initial={{ opacity: 0, y: 32 }} initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-120px' }} viewport={{ once: true, margin: '-120px' }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className="mb-16 sm:mb-20" className="mb-16 sm:mb-20"
> >
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@@ -149,7 +155,7 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ transition={{
duration: 0.3, duration: 0.28,
delay: index * 0.06, delay: index * 0.06,
ease: [0.22, 1, 0.36, 1], ease: [0.22, 1, 0.36, 1],
}} }}
@@ -190,7 +196,7 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ transition={{
duration: 0.3, duration: 0.28,
delay: index * 0.06, delay: index * 0.06,
ease: [0.22, 1, 0.36, 1], ease: [0.22, 1, 0.36, 1],
}} }}
@@ -225,7 +231,7 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
viewport={{ once: true }} viewport={{ once: true }}
transition={{ transition={{
delay: 0.3 + index * 0.05, delay: 0.3 + index * 0.05,
duration: 0.3, duration: 0.28,
ease: [0.22, 1, 0.36, 1], ease: [0.22, 1, 0.36, 1],
}} }}
className="flex items-start gap-3 text-sm text-text-secondary" className="flex items-start gap-3 text-sm text-text-secondary"
@@ -245,7 +251,7 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
initial={{ opacity: 0, y: 32 }} initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: 0.2 }} transition={{ duration: 0.28, delay: 0.2 }}
className="mt-24" className="mt-24"
> >
<div className="mb-12"> <div className="mb-12">
+26 -10
View File
@@ -2,6 +2,7 @@
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { useRef, useEffect, useState } from 'react'; import { useRef, useEffect, useState } from 'react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { CaseStudyCard } from './detail-case-study'; import { CaseStudyCard } from './detail-case-study';
import { CertificationList } from './detail-certification'; import { CertificationList } from './detail-certification';
import type { CaseStudy, DataProof, Certification } from '@/lib/constants/products'; import type { CaseStudy, DataProof, Certification } from '@/lib/constants/products';
@@ -19,6 +20,7 @@ function AnimatedCounter({ value, duration = 2000 }: { value: string; duration?:
const [displayValue, setDisplayValue] = useState('0'); const [displayValue, setDisplayValue] = useState('0');
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true }); const isInView = useInView(ref, { once: true });
const shouldReduceMotion = useReducedMotion();
useEffect(() => { useEffect(() => {
if (!isInView) return; if (!isInView) return;
@@ -29,7 +31,19 @@ function AnimatedCounter({ value, duration = 2000 }: { value: string; duration?:
return; return;
} }
const suffix = value.replace(/[0-9.]/g, ''); const [, prefix = '', suffix = ''] = value.match(/^([^\d.]*)[\d.]+([^\d.]*)$/) ?? [];
const finalText =
prefix +
(Number.isInteger(numericValue) ? numericValue.toString() : numericValue.toFixed(1)) +
suffix;
// 尊重 prefers-reduced-motion:降级动效用户直接看到终值,不跑 2000ms rAF 计数。
// MotionConfig reducedMotion="user" 只管 framer-motion 的动画属性,管不到裸 rAF(S-9)。
if (shouldReduceMotion) {
setDisplayValue(finalText);
return;
}
let startTime: number; let startTime: number;
let animationFrame: number; let animationFrame: number;
@@ -40,9 +54,11 @@ function AnimatedCounter({ value, duration = 2000 }: { value: string; duration?:
const currentValue = numericValue * easeOutQuart; const currentValue = numericValue * easeOutQuart;
setDisplayValue( setDisplayValue(
Number.isInteger(numericValue) prefix +
? Math.floor(currentValue).toString() + suffix (Number.isInteger(numericValue)
: currentValue.toFixed(1) + suffix ? Math.floor(currentValue).toString()
: currentValue.toFixed(1)) +
suffix
); );
if (progress < 1) { if (progress < 1) {
@@ -53,7 +69,7 @@ function AnimatedCounter({ value, duration = 2000 }: { value: string; duration?:
animationFrame = requestAnimationFrame(animate); animationFrame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrame); return () => cancelAnimationFrame(animationFrame);
}, [isInView, value, duration]); }, [isInView, value, duration, shouldReduceMotion]);
return ( return (
<div ref={ref} className="text-3xl sm:text-4xl font-black text-brand-ink tabular-nums leading-none"> <div ref={ref} className="text-3xl sm:text-4xl font-black text-brand-ink tabular-nums leading-none">
@@ -80,7 +96,7 @@ export function DetailTrustSection({
initial={{ opacity: 0, y: 24 }} initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className="mb-12" className="mb-12"
> >
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@@ -104,7 +120,7 @@ export function DetailTrustSection({
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }} viewport={{ once: true, margin: '-60px' }}
transition={{ transition={{
duration: 0.3, duration: 0.28,
delay: index * 0.06, delay: index * 0.06,
ease: [0.22, 1, 0.36, 1], ease: [0.22, 1, 0.36, 1],
}} }}
@@ -129,7 +145,7 @@ export function DetailTrustSection({
initial={{ opacity: 0, y: 24 }} initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className="mb-12" className="mb-12"
> >
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[0.95]"> <h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[0.95]">
@@ -145,7 +161,7 @@ export function DetailTrustSection({
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }} viewport={{ once: true, margin: '-60px' }}
transition={{ transition={{
duration: 0.3, duration: 0.28,
delay: index * 0.06, delay: index * 0.06,
ease: [0.22, 1, 0.36, 1], ease: [0.22, 1, 0.36, 1],
}} }}
@@ -162,7 +178,7 @@ export function DetailTrustSection({
initial={{ opacity: 0, y: 24 }} initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className="text-center" className="text-center"
> >
<h3 className="text-lg font-semibold text-text-secondary mb-6">资质认证</h3> <h3 className="text-lg font-semibold text-text-secondary mb-6">资质认证</h3>
+56 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, jest } from '@jest/globals'; import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react'; import { render, screen, act } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
// ─── Mocks ─────────────────────────────────────────────────────────────── // ─── Mocks ───────────────────────────────────────────────────────────────
@@ -445,6 +445,61 @@ describe('ProductValueSection', () => {
expect(screen.queryByText('以可量化的目标约束交付')).not.toBeInTheDocument(); expect(screen.queryByText('以可量化的目标约束交付')).not.toBeInTheDocument();
}); });
it('指标计数动画由 effect 驱动并在卸载时取消排队帧(Q-6 回归)', async () => {
// Q-6:动画曾在 render 体内 setState + requestAnimationFrame——每帧 setState 又触发
// 重渲染并重启一条新动画链,且从不 cancelAnimationFrame;卸载后帧回调继续排队。
// 判别面:已排队的帧必须被逐一取消(sched === cancel),旧实现 sched>0 且 cancel=0 必红。
jest.useFakeTimers();
// faithful rAF 桩:cancel 必须真的撤销已排队的回调,否则「卸载后不再排队」无从判别
const pending = new Map<number, ReturnType<typeof setTimeout>>();
let handle = 0;
const sched = jest.fn((cb: FrameRequestCallback) => {
const id = ++handle;
pending.set(
id,
setTimeout(() => {
pending.delete(id);
cb(Date.now());
}, 16),
);
return id;
});
const cancel = jest.fn((id: number) => {
const t = pending.get(id);
if (t) clearTimeout(t);
pending.delete(id);
});
const prevRaf = global.requestAnimationFrame;
const prevCancel = global.cancelAnimationFrame;
global.requestAnimationFrame = sched as unknown as typeof requestAnimationFrame;
global.cancelAnimationFrame = cancel as unknown as typeof cancelAnimationFrame;
try {
const { ProductValueSection } = await import('./detail-product-value');
const { container, unmount } = render(<ProductValueSection product={mockProduct} />);
const card = container.querySelector('.tabular-nums') as HTMLElement;
expect(card.textContent).toBe('0');
await act(async () => {
jest.advanceTimersByTime(200);
});
// 动画真的在推进(中途值),而不是停在 0 或瞬间跳终值
expect(card.textContent).not.toBe('0');
expect(card.textContent).not.toBe('300%');
const scheduledBeforeUnmount = sched.mock.calls.length;
expect(scheduledBeforeUnmount).toBeGreaterThan(0);
unmount();
await act(async () => {
jest.advanceTimersByTime(3000);
});
// 泄漏判别:卸载后动画链必须死透——旧实现(render 期 rAF、从不 cancel)
// 在这里会继续排队每一帧;effect 版在 cleanup 里 cancel 掉最后一帧。
expect(sched.mock.calls.length).toBe(scheduledBeforeUnmount);
} finally {
global.requestAnimationFrame = prevRaf;
global.cancelAnimationFrame = prevCancel;
jest.useRealTimers();
}
});
}); });
describe('DetailTrustSection', () => { describe('DetailTrustSection', () => {
-1
View File
@@ -20,7 +20,6 @@ export { CertificationsSection } from './detail-certifications-section';
export { SolutionValueSection } from './solution-value'; export { SolutionValueSection } from './solution-value';
export { ServiceValueSection } from './service-value'; export { ServiceValueSection } from './service-value';
export { ListPageHero } from './list-page-hero';
export { ProductCard } from './product-card'; export { ProductCard } from './product-card';
export { SolutionCard } from './solution-service-card'; export { SolutionCard } from './solution-service-card';
+2 -2
View File
@@ -129,7 +129,7 @@ function SignalCard({ signal }: { signal: L3Signal }) {
<div <div
className={cn( className={cn(
'group relative h-full p-6 sm:p-8 rounded-lg border border-border-primary bg-bg-primary', 'group relative h-full p-6 sm:p-8 rounded-lg border border-border-primary bg-bg-primary',
'transition-all duration-300 ease-out', 'transition-all duration-normal ease-out',
disabled disabled
? 'opacity-70 cursor-not-allowed' ? 'opacity-70 cursor-not-allowed'
: 'hover:border-brand/40 hover:shadow-md hover:-translate-y-1' : 'hover:border-brand/40 hover:shadow-md hover:-translate-y-1'
@@ -150,7 +150,7 @@ function SignalCard({ signal }: { signal: L3Signal }) {
{title} {title}
{!disabled && ( {!disabled && (
<ArrowUpRight <ArrowUpRight
className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" className="w-4 h-4 transition-transform duration-normal group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
aria-hidden="true" aria-hidden="true"
/> />
)} )}
@@ -1,152 +0,0 @@
// @ts-nocheck
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
// ─── Mocks ───────────────────────────────────────────────────────────────
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, variants, whileInView, viewport, transition, className, ...props }: any) => (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
data-animate={JSON.stringify(animate)}
data-variants={JSON.stringify(variants)}
data-while-inview={JSON.stringify(whileInView)}
data-viewport={JSON.stringify(viewport)}
data-transition={JSON.stringify(transition)}
className={className}
{...props}
>
{children}
</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: jest.fn(() => false),
}));
// ─── Tests ───────────────────────────────────────────────────────────────
describe('ListPageHero', () => {
it('should render title', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(<ListPageHero title="产品服务" subtitle="企业数字化转型全方位解决方案" />);
expect(screen.getByText('产品服务')).toBeInTheDocument();
});
it('should render subtitle', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(<ListPageHero title="产品服务" subtitle="企业数字化转型全方位解决方案" />);
expect(screen.getByText('企业数字化转型全方位解决方案')).toBeInTheDocument();
});
it('should render description when provided', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(
<ListPageHero
title="产品服务"
subtitle="企业数字化转型全方位解决方案"
description="覆盖企业全业务场景的产品矩阵"
/>
);
expect(screen.getByText('覆盖企业全业务场景的产品矩阵')).toBeInTheDocument();
});
it('should not render description when not provided', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(<ListPageHero title="产品服务" subtitle="企业数字化转型全方位解决方案" />);
expect(screen.queryByText('覆盖企业全业务场景的产品矩阵')).not.toBeInTheDocument();
});
it('should render badge when provided', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(
<ListPageHero
title="产品服务"
subtitle="企业数字化转型全方位解决方案"
badge={{ text: '新品发布', variant: 'brand' }}
/>
);
expect(screen.getByText('新品发布')).toBeInTheDocument();
});
it('should not render badge when not provided', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(<ListPageHero title="产品服务" subtitle="企业数字化转型全方位解决方案" />);
expect(screen.queryByText('新品发布')).not.toBeInTheDocument();
});
it('should render stats when provided', async () => {
const { ListPageHero } = await import('./list-page-hero');
const stats = [
{ value: '6', label: '企业套装' },
{ value: '3', label: '专业产品' },
];
render(
<ListPageHero
title="产品服务"
subtitle="企业数字化转型全方位解决方案"
stats={stats}
/>
);
expect(screen.getByText('6')).toBeInTheDocument();
expect(screen.getByText('企业套装')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('专业产品')).toBeInTheDocument();
});
it('should not render stats when not provided', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(<ListPageHero title="产品服务" subtitle="企业数字化转型全方位解决方案" />);
expect(screen.queryByText('6')).not.toBeInTheDocument();
});
it('should render with badge variant "blue"', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(
<ListPageHero
title="解决方案"
subtitle="行业解决方案"
badge={{ text: '推荐', variant: 'blue' }}
/>
);
expect(screen.getByText('推荐')).toBeInTheDocument();
});
it('should render with badge variant "green"', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(
<ListPageHero
title="服务"
subtitle="专业服务"
badge={{ text: '热门', variant: 'green' }}
/>
);
expect(screen.getByText('热门')).toBeInTheDocument();
});
it('should render with badge variant "neutral"', async () => {
const { ListPageHero } = await import('./list-page-hero');
render(
<ListPageHero
title="案例"
subtitle="客户案例"
badge={{ text: '案例', variant: 'neutral' }}
/>
);
const neutralBadges = screen.getAllByText('案例');
expect(neutralBadges.length).toBeGreaterThanOrEqual(1);
});
});

Some files were not shown because too many files have changed in this diff Show More