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:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: '测试案例 - 制造业案例 - 睿新致远',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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">
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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('内容不存在');
|
||||||
|
|
||||||
|
|||||||
@@ -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: '{}' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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('内容不存在');
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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('/');
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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('邮箱');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
平均响应时间 < 2小时 · 7×24小时技术支持 · 无需预付费用
|
平均响应时间 < 2小时 · 7×24小时技术支持 · 无需预付费用
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user