feat: 统一品牌中心入口与治理页体验

This commit is contained in:
chenpei 2026-08-15 16:56:43 +08:00
parent cc6f6578be
commit 5e99c74e79
12 changed files with 738 additions and 147 deletions

View File

@ -284,23 +284,26 @@ export default function AdminPage() {
{/* Toast通知 */}
{toast && (
<div className={`fixed top-20 right-4 z-50 px-6 py-3 rounded-lg shadow-lg ${
<div className={`fixed right-4 top-20 z-50 border border-[#e7e8ea] px-5 py-3 text-sm ${
toast.type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
? 'bg-[#f1fbf5] text-[#157a4b]'
: 'bg-[#fff5f4] text-[#a23c36]'
} animate-fade-in`}>
{toast.message}
</div>
)}
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-semibold text-[#1a1c1d]">治理与设置</h1>
<main className="container mx-auto max-w-7xl px-4 py-10 sm:py-14">
<div className="mb-10 flex items-end justify-between gap-4 border-b border-[#e7e8ea] pb-5">
<div>
<p className="font-en text-[11px] uppercase tracking-[0.14em] text-[#86898e]">Governance</p>
<h1 className="mt-2 text-[clamp(28px,4vw,42px)] font-semibold tracking-[-0.03em] text-[#1a1c1d]">治理与设置</h1>
</div>
{canManageSystemTabs && (
<button
type="button"
onClick={() => router.push('/admin/settings')}
className="btn-primary flex items-center gap-2"
className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-[#1a1c1d] px-4 text-[13px] font-semibold text-white transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<title>外观设置</title>
@ -312,7 +315,7 @@ export default function AdminPage() {
</div>
{/* Tabs */}
<div className="flex gap-4 overflow-x-auto border-b border-[#e7e8ea] mb-8 [&>button]:shrink-0">
<div className="mb-8 flex gap-4 overflow-x-auto border-b border-[#e7e8ea] [&>button]:shrink-0">
<button
type="button"
onClick={() => setActiveTab('dataStats')}

View File

@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
import MaterialsHeader from '@/components/board/MaterialsHeader'
import BrandLogo from '@/components/BrandLogo'
import { useToast } from '@/hooks/useToast'
import ToastBubble from '@/components/ToastBubble'
@ -280,8 +280,8 @@ export default function SystemSettings() {
if (status === 'loading' || loading) {
return (
<div className="min-h-screen flex items-center justify-center dark:bg-gray-900">
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
<div className="flex min-h-screen items-center justify-center bg-[#fafafa]">
<div className="text-[13px] text-[#86898e]">加载中...</div>
</div>
)
}
@ -294,16 +294,17 @@ export default function SystemSettings() {
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<MaterialsHeader session={session} sectionLabel="系统设置" />
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">系统设置</h1>
<p className="text-gray-600 dark:text-gray-400">自定义网站外观和品牌信息</p>
<main className="container mx-auto max-w-4xl px-4 py-10 sm:py-14">
<div className="mb-10 border-b border-[#e7e8ea] pb-5">
<p className="font-en text-[11px] uppercase tracking-[0.14em] text-[#86898e]">System settings</p>
<h1 className="mt-2 text-[clamp(28px,4vw,42px)] font-semibold tracking-[-0.03em] text-[#1a1c1d]">系统设置</h1>
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">管理品牌外观、存储连接与维护通知。</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8">
<div className="space-y-8 border border-[#e7e8ea] bg-white p-6 sm:p-8">
{/* 网站名称设置 */}
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">

View File

@ -1,16 +1,16 @@
/*
* 方向契约(用户裁定方向:Zen Minimal × Future Organic 融合 · 2026-08-15)
* THESIS: 看板首页是一张"编辑页"——白纸、黑字、发丝线、超大留白;五个板块排成一列
* THESIS: 看板首页是一张"编辑页"——白纸、黑字、发丝线、超大留白;七个板块排成一列
* 编号索引(作品集列表语言),悬停整行浮现;唯一的柔光来自一枚有机渐变光晕。
* 拒绝卡片阵列、拒绝重色块、拒绝装饰;安静得像一本品牌中心的白皮书。
* OWN-WORLD: 白 #fafafa/#fff、墨 #1a1c1d、正文灰 #6b7075、注脚灰 #86898e、发丝线 #e7e8ea、
* 柔光紫 #6e79c8 系(唯一彩色氛围)+ 浅晕 #eef0fa、微型大写字距标注、
* 柔粉色场(唯一彩色氛围)+ 微型大写字距标注、
* 黑实心按钮 8px 圆角、无阴影平面化;全部文字对比过 WCAG(≥4.5:1 / 大字 ≥3:1)。
* STORY: 打开即读:巨字「品牌中心」+ 一句矿脉定位;视线沿编号索引下移——
* 01 组织管理、02 资产是活的(可点,行变黑);03 仪表盘在长;04/05 是待启程的灰行。
* 两块已上线板块各有一张白卡给入口;页脚是跟踪字距的微型注脚。
* 01 资产、02 组织、03 考核、04 过稿是活的(可点,行变黑);后续板块仍在生长。
* 四块已上线板块以克制的白卡提供入口;页脚是跟踪字距的微型注脚。
* FIRST VIEWPORT: 极简顶栏(logo+由壹点·品牌中心)→ 超大 600 字重「品牌中心」+ 定位语 →
* 右侧一枚柔光渐变光晕压角 → 下方五条发丝线分隔的编号索引行。
* 右侧一枚柔粉渐变色场压角 → 下方七条发丝线分隔的编号索引行。
* FORM: 用户在 Stitch 自生成的候选 3(Zen Minimal)与 4(Future Organic)融合,
* 参考作品集文件夹两份 DESIGN.md(Monochrome/Studio Minimalist)裁定。
* FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
@ -25,6 +25,7 @@ import { useSession } from 'next-auth/react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import MaterialsHeader from '@/components/board/MaterialsHeader'
import BrandFluidField from '@/components/board/BrandFluidField'
gsap.registerPlugin(useGSAP)
@ -42,7 +43,7 @@ const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#
function SkeletonIndex() {
return (
<div className="border-t border-[#e7e8ea]">
{Array.from({ length: 5 }).map((_, i) => (
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border-b border-[#e7e8ea] py-9 md:gap-8">
<div className="h-3 w-8 shrink-0 animate-pulse bg-[#eceef0]" />
<div className="min-w-0 flex-1 space-y-2.5">
@ -93,6 +94,26 @@ export default function BoardHomePage() {
},
{
no: '03',
title: '绩效考核',
en: 'Performance',
tone: 'live' as const,
status: '已上线',
live: true,
href: '/performance-survey',
desc: '个人考核、公共看板与调研管理——任务进度和结果在同一处收敛。',
},
{
no: '04',
title: '作品过稿看板',
en: 'Work Review',
tone: 'live' as const,
status: '已上线',
live: true,
href: '/work-review',
desc: '作品提交、版本迭代与评审意见——让创意协作从上传后继续推进。',
},
{
no: '05',
title: '中心仪表盘',
en: 'Dashboard',
tone: 'building' as const,
@ -101,7 +122,7 @@ export default function BoardHomePage() {
desc: '将接入:核心指标 · 业务趋势 · 六品牌健康度 · 团队产能 · 风险预警',
},
{
no: '04',
no: '06',
title: '业务经营',
en: 'Operations',
tone: 'planned' as const,
@ -110,7 +131,7 @@ export default function BoardHomePage() {
desc: '将包含:新品上新 · 品牌塑造 · 创意生产 · 业绩转化 · 项目协同',
},
{
no: '05',
no: '07',
title: '治理与设置',
en: 'Governance',
tone: 'planned' as const,
@ -121,7 +142,16 @@ export default function BoardHomePage() {
]
useGSAP(() => {
if (typeof window.matchMedia !== 'function') return
if (sessionLoading || typeof window.matchMedia !== 'function') return
if (
!pageRef.current?.querySelector('[data-hero-title]') ||
!pageRef.current.querySelector('[data-row-line]') ||
!pageRef.current.querySelector('[data-row]') ||
!pageRef.current.querySelector('[data-card]') ||
!pageRef.current.querySelector('[data-foot]')
) {
return
}
const motion = gsap.matchMedia()
motion.add('(prefers-reduced-motion: no-preference)', () => {
@ -140,13 +170,6 @@ export default function BoardHomePage() {
clearProps: 'filter,opacity,letterSpacing',
},
)
// 柔光:同语言聚焦绽放
.fromTo(
'[data-orb]',
{ autoAlpha: 0, scale: 0.9, filter: 'blur(12px)' },
{ autoAlpha: 1, scale: 1, filter: 'blur(0px)', duration: 1.3, clearProps: 'filter' },
'-=0.8',
)
// 定位语
.fromTo(
'[data-hero]',
@ -187,12 +210,10 @@ export default function BoardHomePage() {
{ autoAlpha: 1, duration: 0.6, clearProps: 'opacity,visibility' },
'-=0.35',
)
// 柔光光晕的呼吸(极慢、纯 opacity)
gsap.to('[data-orb]', { opacity: 0.75, duration: 5, yoyo: true, repeat: -1, ease: 'sine.inOut' })
})
return () => motion.revert()
}, { scope: pageRef })
}, { scope: pageRef, dependencies: [sessionLoading] })
return (
// direction: zen-minimal × future-organic fusion (user-curated) — THESIS: 白皮书式的编号索引,悬停浮现
@ -204,18 +225,10 @@ export default function BoardHomePage() {
<main className="mx-auto max-w-[1280px] px-6">
{/* ===== 编辑页首:巨字 + 柔光 ===== */}
<section className="relative overflow-hidden pb-16 pt-20 md:pb-24 md:pt-28">
{/* 柔光渐变光晕:唯一的有色氛围(来自 Future Organic) */}
<div
data-orb
aria-hidden="true"
className="pointer-events-none absolute -top-10 right-[-60px] h-[340px] w-[340px] rounded-full opacity-90 md:h-[420px] md:w-[420px]"
style={{
background:
'radial-gradient(circle at 38% 40%, #eef0fa 0%, #dfe3f6 32%, rgba(224,228,248,0.55) 55%, rgba(250,250,250,0) 72%)',
}}
/>
<div className="relative">
<section className="relative isolate overflow-hidden pb-16 pt-20 md:pb-24 md:pt-28">
{/* 动态品牌色场:来自 Future Organic,只在首屏承担一次有色氛围 */}
<BrandFluidField />
<div className="relative z-10">
{/* 巨型标题:入场对焦揭幕(GSAP blur+tracking),悬停字距呼吸(CSS) */}
<h1
data-hero-title
@ -313,16 +326,19 @@ export default function BoardHomePage() {
</section>
)}
{/* ===== 两块已上线板块的入口白卡 ===== */}
{/* ===== 已上线板块的入口白卡 ===== */}
{sessionLoading ? (
<div className="grid gap-6 py-16 md:grid-cols-2 md:gap-8 md:py-24">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="h-[260px] animate-pulse rounded-xl border border-[#e7e8ea] bg-white/60" />
<div className="grid gap-6 py-16 md:grid-cols-12 md:gap-8 md:py-24">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className={`h-[260px] animate-pulse rounded-xl border border-[#e7e8ea] bg-white/60 ${i % 2 === 0 ? 'md:col-span-7' : 'md:col-span-5'}`}
/>
))}
</div>
) : (
<section className="grid gap-6 py-16 md:grid-cols-2 md:gap-8 md:py-24">
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:p-10">
<section className="grid gap-6 py-16 md:grid-cols-12 md:gap-8 md:py-24">
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:col-span-7 md:p-10">
<div className="flex items-center gap-2.5">
<StatusDot tone="live" />
<span className="text-[12px] text-[#6b7075]">运行中</span>
@ -341,7 +357,7 @@ export default function BoardHomePage() {
</Link>
</div>
</div>
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:p-10">
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:col-span-5 md:p-10">
<div className="flex items-center gap-2.5">
<StatusDot tone="live" />
<span className="text-[12px] text-[#6b7075]">已上线</span>
@ -351,7 +367,7 @@ export default function BoardHomePage() {
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
真实组织树、M/P 等级、汇报线与在职状态,数据源 HR 花名册。
</p>
<div className="mt-7 flex flex-wrap gap-3">
<div className="mt-7">
{isAdmin ? (
<Link
href="/organization"
@ -364,11 +380,43 @@ export default function BoardHomePage() {
需管理员权限
</span>
)}
</div>
</div>
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:col-span-5 md:p-10">
<div className="flex items-center gap-2.5">
<StatusDot tone="live" />
<span className="text-[12px] text-[#6b7075]">已上线</span>
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>03 / Performance</span>
</div>
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">绩效考核</h3>
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
个人考核、公共看板与调研管理——任务进度和结果在同一处收敛。
</p>
<div className="mt-7">
<Link
href="/performance-survey"
className={`inline-flex min-h-11 items-center rounded-lg border border-[#1a1c1d] bg-white px-6 text-[13px] font-medium transition-all duration-200 hover:bg-[#1a1c1d] hover:text-white active:scale-[0.98] ${focusRing}`}
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-all duration-200 hover:opacity-80 active:scale-[0.98] ${focusRing}`}
>
绩效考核
进入绩效考核
</Link>
</div>
</div>
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:col-span-7 md:p-10">
<div className="flex items-center gap-2.5">
<StatusDot tone="live" />
<span className="text-[12px] text-[#6b7075]">已上线</span>
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>04 / Review</span>
</div>
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">作品过稿看板</h3>
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
作品提交、版本迭代与评审意见——让创意协作从上传后继续推进。
</p>
<div className="mt-7">
<Link
href="/work-review"
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-all duration-200 hover:opacity-80 active:scale-[0.98] ${focusRing}`}
>
进入作品过稿看板
</Link>
</div>
</div>

View File

@ -19,6 +19,8 @@ import { groupVisualScoresByEvaluator } from '@/lib/performance-survey-result-gr
import { PerformanceSurveyAdminConfig } from '@/app/performance-survey/PerformanceSurveyAdminConfig'
import { PerformanceSurveyExportDialog } from '@/app/performance-survey/PerformanceSurveyExportDialog'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
import BrandLogo from '@/components/BrandLogo'
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
import {
choosePerformanceSurveyExportDestination,
sanitizePerformanceSurveyExportFileName,
@ -600,6 +602,10 @@ export default function PerformanceSurveyPage() {
setDingtalkSessionChecked(true)
}, [])
useEffect(() => {
document.title = '由壹点·品牌中心 · 绩效考核'
}, [])
useEffect(() => {
const handleExpiredSession = () => {
setHasDingtalkPerformanceSession(false)
@ -706,7 +712,15 @@ function PerformanceSurveyMobileHeader({ session }: { session: any }) {
return (
<header className="sticky top-0 z-30 border-b border-[#e7e8ea] bg-[#fafafa] text-[#1a1c1d]">
<div className="mx-auto flex h-14 w-full max-w-[1440px] items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<Link
href="/"
aria-label="返回品牌中心首页"
className="flex shrink-0 items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
>
<BrandLogo src={DEFAULT_LOGIN_LOGO_URL} fallbackSrc={DEFAULT_LOGIN_LOGO_URL} alt="" width={28} height={28} unoptimized className="h-7 w-7 rounded-md object-cover" />
</Link>
<span aria-hidden="true" className="text-[12px] text-[#d4d6da]">/</span>
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[12px] font-semibold text-[#1a1c1d]">
{avatar ? (
<Image
@ -721,14 +735,17 @@ function PerformanceSurveyMobileHeader({ session }: { session: any }) {
getSurveyInitials(name)
)}
</div>
<div className="truncate text-[16px] font-bold tracking-tight">绩效考核</div>
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold tracking-[-0.02em]">绩效考核</div>
<div className="font-en mt-0.5 truncate text-[10px] uppercase tracking-[0.14em] text-[#86898e]">Performance</div>
</div>
</div>
<Link
href="/materials"
className="shrink-0 text-[12px] font-semibold text-[#6b7075] transition-colors hover:text-[#1a1c1d]"
aria-label="返回创意资产库"
>
返回创意资产库
创意资产库
</Link>
</div>
</header>
@ -916,8 +933,16 @@ function PerformanceSurveyDashboardHeader({
}) {
return (
<header className="border-b border-[#e7e8ea] bg-[#fafafa] text-[#1a1c1d]">
<div className="container mx-auto grid min-h-[60px] max-w-7xl grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-3 py-3 sm:grid-cols-[minmax(220px,1fr)_auto_minmax(220px,1fr)] sm:px-4">
<div className="flex min-w-0 items-center gap-3">
<div className="container mx-auto grid min-h-[60px] max-w-7xl grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-3 py-3 sm:grid-cols-[minmax(280px,1fr)_auto_minmax(280px,1fr)] sm:px-4">
<div className="flex min-w-0 items-center gap-2.5">
<Link
href="/"
aria-label="返回品牌中心首页"
className="flex shrink-0 items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
>
<BrandLogo src={DEFAULT_LOGIN_LOGO_URL} fallbackSrc={DEFAULT_LOGIN_LOGO_URL} alt="" width={28} height={28} unoptimized className="h-7 w-7 rounded-md object-cover" />
</Link>
<span aria-hidden="true" className="text-[12px] text-[#d4d6da]">/</span>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[#1a1c1d]">
{avatar ? (
<Image
@ -933,7 +958,7 @@ function PerformanceSurveyDashboardHeader({
)}
</div>
<div className="min-w-0">
<div className="truncate text-[16px] font-bold">绩效考核</div>
<div className="truncate text-[15px] font-semibold tracking-[-0.02em]">绩效考核</div>
<div className="truncate text-[11px] font-medium text-[#6b7075]" title={displayName}>{displayName}</div>
</div>
</div>
@ -962,7 +987,7 @@ function PerformanceSurveyDashboardHeader({
className="hidden justify-self-end text-[13px] font-semibold text-[#6b7075] transition-colors hover:text-[#1a1c1d] sm:block"
aria-label="返回创意资产库"
>
返回创意资产库
创意资产库
</Link>
<Link
href="/materials"

View File

@ -3,11 +3,11 @@ import WorkReviewBoard from '@/components/work-review/WorkReviewBoard'
function WorkReviewFallback() {
return (
<div className="min-h-screen bg-[#f6f8fb]">
<div className="h-16 bg-[#1f2937]" />
<div className="min-h-screen bg-[#fafafa]">
<div className="h-16 border-b border-[#e7e8ea] bg-[#fafafa]" />
<main className="mx-auto max-w-[1180px] px-4 py-8 sm:px-6">
<div className="h-8 w-48 animate-pulse rounded bg-[#e6eaf0]" />
<div className="mt-3 h-4 w-72 animate-pulse rounded bg-[#edf0f4]" />
<div className="h-8 w-48 animate-pulse bg-[#eceef0]" />
<div className="mt-3 h-4 w-72 animate-pulse bg-[#f2f3f5]" />
</main>
</div>
)

View File

@ -0,0 +1,39 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { CalendarDateInput } from '@/components/admin/DataStatisticsTab'
describe('CalendarDateInput', () => {
test('稳定日期值不会触发重复渲染,并会在日期变化时同步月份', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
try {
const { rerender } = render(
<CalendarDateInput
id="stats-start-date"
label="开始日期"
value="2026-08-15"
onChange={jest.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '打开开始日期日历' }))
expect(screen.getByText('2026年08月')).toBeInTheDocument()
rerender(
<CalendarDateInput
id="stats-start-date"
label="开始日期"
value="2026-07-15"
onChange={jest.fn()}
/>,
)
await waitFor(() => {
expect(screen.getByText('2026年07月')).toBeInTheDocument()
})
expect(consoleErrorSpy.mock.calls.flat().join(' ')).not.toContain('Maximum update depth exceeded')
} finally {
consoleErrorSpy.mockRestore()
}
})
})

View File

@ -394,7 +394,7 @@ function SummaryCard({
)
}
function CalendarDateInput({
export function CalendarDateInput({
id,
label,
value,
@ -414,7 +414,7 @@ function CalendarDateInput({
onChange: (value: string) => void
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const selectedDate = parseDateInputValue(value)
const selectedDate = useMemo(() => parseDateInputValue(value), [value])
const minDate = parseDateInputValue(min)
const maxDate = parseDateInputValue(max)
const rangeStartDate = parseDateInputValue(rangeStart)
@ -423,10 +423,11 @@ function CalendarDateInput({
const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(selectedDate || new Date()))
useEffect(() => {
if (selectedDate) {
setVisibleMonth(startOfMonth(selectedDate))
}
}, [value, selectedDate])
if (!selectedDate) return
const nextMonth = startOfMonth(selectedDate)
setVisibleMonth((currentMonth) => (isSameMonth(currentMonth, nextMonth) ? currentMonth : nextMonth))
}, [selectedDate])
useEffect(() => {
if (!isOpen) {

View File

@ -0,0 +1,24 @@
import { render, waitFor } from '@testing-library/react'
import BrandFluidField from './BrandFluidField'
describe('BrandFluidField', () => {
const originalGetContext = HTMLCanvasElement.prototype.getContext
afterEach(() => {
HTMLCanvasElement.prototype.getContext = originalGetContext
})
it('在 WebGL 不可用时保留静态品牌光晕', async () => {
HTMLCanvasElement.prototype.getContext = jest.fn(() => null) as typeof originalGetContext
const { getByTestId, container } = render(<BrandFluidField />)
const field = getByTestId('brand-fluid-field')
const canvas = container.querySelector('canvas')
expect(field).toHaveAttribute('aria-hidden', 'true')
expect(field).toHaveStyle({
background: 'linear-gradient(90deg, #fafafa 0%, #fafafa 56%, #fff7fb 70%, #ffdceb 86%, #ffedf5 100%)',
})
await waitFor(() => expect(canvas).toHaveAttribute('data-webgl', 'unavailable'))
})
})

View File

@ -0,0 +1,408 @@
'use client'
import { useEffect, useRef } from 'react'
// 偏粉的参考稿版本:粉雾、玫瑰与奶白,避免再回到偏橙的材质感。
const BRAND_COLORS = ['#ffc7de', '#ff8fc7', '#fff1f7'] as const
const FLOW_TEXTURE_WIDTH = 512
const FLOW_TEXTURE_HEIGHT = 160
const VERTEX_SHADER = `
attribute vec2 a_position;
varying vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}
`
const SIMULATION_SHADER = `
precision highp float;
varying vec2 v_uv;
uniform float u_time;
uniform float u_seed;
float hash31(vec3 value) {
value = fract(value * vec3(0.1031, 0.1030, 0.0973));
value += dot(value, value.yzx + 33.33 + u_seed);
return fract((value.x + value.y) * value.z);
}
float valueNoise3(vec3 point) {
vec3 cell = floor(point);
vec3 local = fract(point);
local = local * local * (3.0 - 2.0 * local);
float x00 = mix(hash31(cell), hash31(cell + vec3(1.0, 0.0, 0.0)), local.x);
float x10 = mix(hash31(cell + vec3(0.0, 1.0, 0.0)), hash31(cell + vec3(1.0, 1.0, 0.0)), local.x);
float x01 = mix(hash31(cell + vec3(0.0, 0.0, 1.0)), hash31(cell + vec3(1.0, 0.0, 1.0)), local.x);
float x11 = mix(hash31(cell + vec3(0.0, 1.0, 1.0)), hash31(cell + vec3(1.0, 1.0, 1.0)), local.x);
float lower = mix(x00, x10, local.y);
float upper = mix(x01, x11, local.y);
return mix(lower, upper, local.z);
}
float fbm3(vec3 point) {
float value = 0.0;
float amplitude = 0.55;
for (int octave = 0; octave < 3; octave++) {
value += valueNoise3(point) * amplitude;
point = vec3(point.y + point.z, point.z + point.x, point.x + point.y) * 1.07
+ vec3(13.7, 9.2, 5.4);
amplitude *= 0.50;
}
return value;
}
float rollingDensity(vec2 point, vec2 warp, float channel, float time) {
float phase = channel * 7.91 + u_seed * 2.37;
vec2 channelOffset = vec2(
cos(phase) * 2.2 + channel * 1.1,
sin(phase) * 2.0 - channel * 0.8
);
float broadMass = fbm3(vec3(
point + warp * 1.24 + channelOffset,
time * 0.34 + phase
));
float foldingMass = valueNoise3(vec3(
point * 1.82 - warp * 0.52 + channelOffset * 1.53,
-time * 0.43 + phase * 1.71
));
float densityField = broadMass * 0.70 + foldingMass * 0.30;
float body = smoothstep(0.30, 0.76, densityField);
float volume = smoothstep(0.22, 0.82, broadMass * 0.60 + foldingMass * 0.40);
return body * mix(0.38, 0.98, volume);
}
void main() {
float time = u_time * 0.62;
vec2 point = vec2((v_uv.x - 0.24) * 3.30, v_uv.y * 1.35);
vec2 primaryWarp = vec2(
fbm3(vec3(point * 0.72 + u_seed, time * 0.29 + u_seed)),
fbm3(vec3(point * 0.76 + vec2(6.3, -4.7) - u_seed * 0.71, -time * 0.31 + u_seed))
) - 0.5;
vec3 dye = vec3(
rollingDensity(point, primaryWarp, 0.0, time),
rollingDensity(point, primaryWarp, 1.0, time),
rollingDensity(point, primaryWarp, 2.0, time)
);
// 色彩只聚集在右侧,并用倾斜的软边界进入白纸区域,避免一条直上直下的分界线。
float diagonalField = v_uv.x + (v_uv.y - 0.5) * 0.20;
float materialField = smoothstep(0.58, 0.93, diagonalField);
dye *= materialField;
gl_FragColor = vec4(clamp(dye, 0.0, 1.0), 1.0);
}
`
const DISPLAY_SHADER = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_dye;
uniform vec2 u_texel;
uniform vec3 u_color_a;
uniform vec3 u_color_b;
uniform vec3 u_color_c;
void main() {
vec3 center = texture2D(u_dye, v_uv).rgb;
vec3 cardinal = (
texture2D(u_dye, v_uv + vec2(u_texel.x, 0.0)).rgb +
texture2D(u_dye, v_uv - vec2(u_texel.x, 0.0)).rgb +
texture2D(u_dye, v_uv + vec2(0.0, u_texel.y)).rgb +
texture2D(u_dye, v_uv - vec2(0.0, u_texel.y)).rgb
) * 0.25;
vec3 diagonal = (
texture2D(u_dye, v_uv + u_texel).rgb +
texture2D(u_dye, v_uv - u_texel).rgb +
texture2D(u_dye, v_uv + vec2(u_texel.x, -u_texel.y)).rgb +
texture2D(u_dye, v_uv + vec2(-u_texel.x, u_texel.y)).rgb
) * 0.25;
vec3 nearby = mix(cardinal, diagonal, 0.42);
vec3 dye = mix(center, nearby, 0.24);
float baseWash = smoothstep(0.30, 0.64, v_uv.x);
dye += vec3(0.007, 0.006, 0.006) * baseWash;
float total = dye.r + dye.g + dye.b + 0.0001;
vec3 pigment = (u_color_a * dye.r + u_color_b * dye.g + u_color_c * dye.b) / total;
float opacity = 1.0 - exp(-total * 1.04);
vec3 paper = vec3(0.978);
vec3 color = mix(paper, pigment, clamp(opacity * 0.42, 0.0, 0.46));
float diagonalClearance = v_uv.x + (v_uv.y - 0.5) * 0.18;
float copyClearance = 1.0 - smoothstep(0.28, 0.88, diagonalClearance);
color = mix(color, paper, copyClearance * 0.99);
gl_FragColor = vec4(color, 1.0);
}
`
type ShaderProgram = {
program: WebGLProgram
fragmentShader: WebGLShader
}
type RenderTarget = {
texture: WebGLTexture
framebuffer: WebGLFramebuffer
}
function createShader(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type)
if (!shader) throw new Error('无法创建品牌动态色彩着色器')
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const message = gl.getShaderInfoLog(shader) || '品牌动态色彩着色器编译失败'
gl.deleteShader(shader)
throw new Error(message)
}
return shader
}
function createProgram(
gl: WebGLRenderingContext,
vertexShader: WebGLShader,
fragmentSource: string,
): ShaderProgram {
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
const program = gl.createProgram()
if (!program) {
gl.deleteShader(fragmentShader)
throw new Error('无法创建品牌动态色彩程序')
}
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const message = gl.getProgramInfoLog(program) || '品牌动态色彩程序链接失败'
gl.deleteProgram(program)
gl.deleteShader(fragmentShader)
throw new Error(message)
}
return { program, fragmentShader }
}
function createRenderTarget(gl: WebGLRenderingContext, width: number, height: number): RenderTarget {
const texture = gl.createTexture()
const framebuffer = gl.createFramebuffer()
if (!texture || !framebuffer) throw new Error('无法创建品牌动态色彩画布')
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
gl.deleteFramebuffer(framebuffer)
gl.deleteTexture(texture)
throw new Error('品牌动态色彩画布初始化失败')
}
return { texture, framebuffer }
}
function hexToRgb(hex: string) {
const value = Number.parseInt(hex.replace('#', ''), 16)
return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255]
}
export default function BrandFluidField() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const gl = canvas.getContext('webgl', {
alpha: false,
antialias: false,
powerPreference: 'high-performance',
preserveDrawingBuffer: false,
})
if (!gl) {
canvas.dataset.webgl = 'unavailable'
return
}
let vertexShader: WebGLShader | null = null
let simulation: ShaderProgram | null = null
let display: ShaderProgram | null = null
let positionBuffer: WebGLBuffer | null = null
let target: RenderTarget | null = null
let frame = 0
let lastFrameTime = 0
let isInViewport = true
let isDocumentVisible = !document.hidden
try {
vertexShader = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
simulation = createProgram(gl, vertexShader, SIMULATION_SHADER)
display = createProgram(gl, vertexShader, DISPLAY_SHADER)
positionBuffer = gl.createBuffer()
if (!positionBuffer) throw new Error('无法创建品牌动态色彩顶点缓冲')
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW)
target = createRenderTarget(gl, FLOW_TEXTURE_WIDTH, FLOW_TEXTURE_HEIGHT)
} catch {
canvas.dataset.webgl = 'unavailable'
return
}
const vertex = vertexShader
const simulationProgram = simulation
const displayProgram = display
const buffer = positionBuffer
const renderTarget = target
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const startedAt = performance.now()
const simulationUniforms = {
time: gl.getUniformLocation(simulationProgram.program, 'u_time'),
seed: gl.getUniformLocation(simulationProgram.program, 'u_seed'),
}
const displayUniforms = {
dye: gl.getUniformLocation(displayProgram.program, 'u_dye'),
texel: gl.getUniformLocation(displayProgram.program, 'u_texel'),
colorA: gl.getUniformLocation(displayProgram.program, 'u_color_a'),
colorB: gl.getUniformLocation(displayProgram.program, 'u_color_b'),
colorC: gl.getUniformLocation(displayProgram.program, 'u_color_c'),
}
const bindPlane = (program: WebGLProgram) => {
const position = gl.getAttribLocation(program, 'a_position')
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.enableVertexAttribArray(position)
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0)
}
const resize = () => {
const mobileRatio = window.matchMedia('(max-width: 767px)').matches ? 1.2 : 1.5
const ratio = Math.min(window.devicePixelRatio || 1, mobileRatio)
const width = Math.max(1, Math.round(canvas.clientWidth * ratio))
const height = Math.max(1, Math.round(canvas.clientHeight * ratio))
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
}
const renderFrame = (elapsed: number) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, renderTarget.framebuffer)
gl.viewport(0, 0, FLOW_TEXTURE_WIDTH, FLOW_TEXTURE_HEIGHT)
gl.useProgram(simulationProgram.program)
bindPlane(simulationProgram.program)
gl.uniform1f(simulationUniforms.time, elapsed)
// 与参考稿 ORIGINAL specimen 使用同一个确定性 seed,保证首帧纹理而非随机近似。
gl.uniform1f(simulationUniforms.seed, 597 / 97)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
resize()
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0.9804, 0.9804, 0.9804, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(displayProgram.program)
bindPlane(displayProgram.program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, renderTarget.texture)
gl.uniform1i(displayUniforms.dye, 0)
gl.uniform2f(displayUniforms.texel, 1 / FLOW_TEXTURE_WIDTH, 1 / FLOW_TEXTURE_HEIGHT)
gl.uniform3fv(displayUniforms.colorA, hexToRgb(BRAND_COLORS[0]))
gl.uniform3fv(displayUniforms.colorB, hexToRgb(BRAND_COLORS[1]))
gl.uniform3fv(displayUniforms.colorC, hexToRgb(BRAND_COLORS[2]))
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
canvas.dataset.webgl = 'ready'
}
const draw = (now: number) => {
frame = 0
if (!isInViewport || !isDocumentVisible) return
if (now - lastFrameTime >= 1000 / 30) {
lastFrameTime = now
renderFrame(7.2 + (now - startedAt) / 1000)
}
frame = window.requestAnimationFrame(draw)
}
const start = () => {
if (reduceMotion || frame || !isInViewport || !isDocumentVisible) return
frame = window.requestAnimationFrame(draw)
}
const stop = () => {
if (!frame) return
window.cancelAnimationFrame(frame)
frame = 0
}
const intersectionObserver = new IntersectionObserver(([entry]) => {
isInViewport = entry?.isIntersecting ?? true
if (isInViewport) start()
else stop()
}, { rootMargin: '80px' })
const resizeObserver = new ResizeObserver(() => {
if (reduceMotion) renderFrame(12)
})
const handleVisibilityChange = () => {
isDocumentVisible = !document.hidden
if (isDocumentVisible) start()
else stop()
}
intersectionObserver.observe(canvas)
resizeObserver.observe(canvas)
document.addEventListener('visibilitychange', handleVisibilityChange)
if (reduceMotion) renderFrame(12)
else start()
return () => {
stop()
intersectionObserver.disconnect()
resizeObserver.disconnect()
document.removeEventListener('visibilitychange', handleVisibilityChange)
gl.deleteFramebuffer(renderTarget.framebuffer)
gl.deleteTexture(renderTarget.texture)
gl.deleteBuffer(buffer)
gl.deleteProgram(simulationProgram.program)
gl.deleteProgram(displayProgram.program)
gl.deleteShader(simulationProgram.fragmentShader)
gl.deleteShader(displayProgram.fragmentShader)
gl.deleteShader(vertex)
}
}, [])
return (
<div
data-testid="brand-fluid-field"
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-0 h-[72px] overflow-hidden bg-[#f4f4f2] md:inset-0 md:h-auto"
style={{
background: 'linear-gradient(90deg, #fafafa 0%, #fafafa 56%, #fff7fb 70%, #ffdceb 86%, #ffedf5 100%)',
}}
>
<canvas
ref={canvasRef}
className="block h-full w-full [filter:saturate(0.82)]"
/>
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-7 bg-gradient-to-b from-transparent to-[#fafafa] md:hidden"
/>
</div>
)
}

View File

@ -17,8 +17,8 @@ import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
/*
* MaterialsHeader —— 创意资产库板块顶栏(看板语言 · Zen Minimal)
* 移植自 components/Header.tsx 的全部功能(淘宝搜索/内外网状态/管理员性能监控/用户菜单/更新日志),
* 视觉统一到品牌中心白皮书语言:浅底、发丝线、墨字;深色模式跟随素材页既有 dark: 支持。
* 旧 Header 仍服务 admin/profile/share/edit 等页面,待后续逐页统一。
* 视觉统一到品牌中心白皮书语言:浅底、发丝线、墨字。
* 用户菜单是“个人工作台”的统一入口;链接保留原有权限与业务行为,只迁移其入口体验。
*/
interface MaterialsHeaderProps {
@ -128,15 +128,22 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
}
}, [])
// 点击外部关闭下拉菜单
// 点击外部或按 Escape 关闭用户菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDropdown(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setShowDropdown(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [])
// 性能监控:根据 showPerfCard 控制轮询(2 秒)
@ -221,18 +228,20 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
}
const menuItemCls =
'w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2'
'group flex w-full items-start gap-3 rounded-md px-3 py-3 text-left text-[#1a1c1d] transition-colors hover:bg-[#f1f2f4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white'
const menuIconCls =
'mt-0.5 h-4 w-4 shrink-0 text-[#6b7075] transition-colors group-hover:text-[#1a1c1d]'
return (
<>
<MaintenanceBanner />
<header className="sticky top-0 z-50 border-b border-[#e7e8ea] bg-[#fafafa] dark:border-gray-700 dark:bg-gray-900">
<header className="sticky top-0 z-50 border-b border-[#e7e8ea] bg-[#fafafa]">
<div className="mx-auto flex h-14 max-w-[1280px] items-center gap-3 px-4 sm:px-6">
{/* 品牌 */}
<Link
href="/"
className="flex min-w-0 items-center gap-2.5 rounded-sm text-[14px] font-semibold tracking-tight text-[#1a1c1d] transition-opacity hover:opacity-70 dark:text-gray-100"
className="flex min-w-0 items-center gap-2.5 rounded-sm text-[14px] font-semibold tracking-tight text-[#1a1c1d] transition-opacity hover:opacity-70"
>
<BrandLogo
src={brandLogoUrl}
@ -250,8 +259,8 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
{sectionLabel ? (
/* 板块模式:面包屑 */
<>
<span aria-hidden="true" className="text-[12px] text-[#d4d6da] dark:text-gray-600">/</span>
<span className="text-[13px] font-medium text-[#6b7075] dark:text-gray-300">{sectionLabel}</span>
<span aria-hidden="true" className="text-[12px] text-[#d4d6da]">/</span>
<span className="text-[13px] font-medium text-[#6b7075]">{sectionLabel}</span>
</>
) : (
/* 首页模式:五大板块导航 */
@ -294,8 +303,8 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
<div
className={`flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-all ${
isInternalNetwork
? 'border-emerald-600/20 bg-emerald-600/10 text-emerald-700 dark:text-emerald-400'
: 'border-sky-600/20 bg-sky-600/10 text-sky-700 dark:text-sky-400'
? 'border-emerald-600/20 bg-emerald-600/10 text-emerald-700'
: 'border-sky-600/20 bg-sky-600/10 text-sky-700'
}`}
>
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
@ -483,9 +492,9 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDropdown(!showDropdown)}
aria-label="打开用户菜单"
aria-label={showDropdown ? '关闭个人工作台菜单' : '打开个人工作台菜单'}
aria-expanded={showDropdown}
className="flex items-center rounded-lg p-1.5 transition-all hover:bg-gray-200/60 dark:hover:bg-gray-800"
className="flex items-center rounded-md p-1.5 transition-colors hover:bg-[#f1f2f4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
>
{(session.user as any)?.avatar ? (
<Image
@ -493,48 +502,42 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
alt={session.user?.name || '用户'}
width={32}
height={32}
className="h-8 w-8 rounded-full border border-[#e7e8ea] object-cover transition-all hover:border-[#c9ccd1] dark:border-gray-600"
className="h-8 w-8 rounded-full border border-[#e7e8ea] object-cover transition-colors hover:border-[#9aa0a8]"
unoptimized
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#1a1c1d] text-base font-semibold text-white dark:bg-gray-200 dark:text-gray-900">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#1a1c1d] text-base font-semibold text-white">
{getInitials(session.user?.name || '')}
</div>
)}
</button>
{showDropdown && (
<div className="absolute right-0 z-50 mt-2 w-48 rounded-lg border border-gray-200 bg-white py-2 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<div className="border-b border-gray-100 px-4 py-2 dark:border-gray-700">
<p className="text-sm font-medium text-gray-900 dark:text-white">{session.user?.name}</p>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
<div
role="menu"
aria-label="个人工作台"
className="absolute right-0 z-50 mt-2 max-h-[calc(100dvh-4.5rem)] w-[min(21rem,calc(100vw-2rem))] overflow-y-auto border border-[#dfe1e4] bg-white p-2 [scrollbar-color:#c9ccd1_transparent] [scrollbar-width:thin]"
>
<div className="border-b border-[#e7e8ea] px-3 pb-4 pt-3">
<p className="font-display text-[17px] font-semibold tracking-[-0.02em] text-[#1a1c1d]">{session.user?.name}</p>
<p className="mt-1 truncate text-[12px] leading-relaxed text-[#6b7075]">
{employeeLine ?? session.user?.email}
</p>
<p className="mt-3 font-en text-[10px] uppercase tracking-[0.14em] text-[#86898e]">个人工作台</p>
</div>
{/* 档案与板块 */}
<Link href="/profile" onClick={() => setShowDropdown(false)} className={menuItemCls}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/* 个人档案 */}
<Link href="/profile" onClick={() => setShowDropdown(false)} className={menuItemCls} role="menuitem">
<svg className={menuIconCls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>我的档案</span>
<span className="min-w-0">
<span className="block text-[13px] font-semibold">我的档案</span>
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">账号资料、组织归属与我的素材</span>
</span>
</Link>
<Link href="/performance-survey" onClick={() => setShowDropdown(false)} className={menuItemCls}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-6m4 6V7m4 10v-3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>绩效考核</span>
</Link>
<Link href="/work-review" onClick={() => setShowDropdown(false)} className={menuItemCls}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a2 2 0 012-2h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm4 4h8M8 13h5m-5 4h3" />
</svg>
<span>作品过稿看板</span>
</Link>
<div aria-hidden="true" className="my-1.5 border-t border-gray-100 dark:border-gray-700" />
<div aria-hidden="true" className="my-1.5 border-t border-[#e7e8ea]" />
{/* 工具 */}
<button
@ -543,11 +546,15 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
showUpdateLog('all')
}}
className={menuItemCls}
role="menuitem"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className={menuIconCls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span>更新记录</span>
<span className="min-w-0">
<span className="block text-[13px] font-semibold">更新记录</span>
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">查看已发布的功能与体验改进</span>
</span>
</button>
<a
@ -556,18 +563,22 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
rel="noopener noreferrer"
onClick={() => setShowDropdown(false)}
className={menuItemCls}
role="menuitem"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className={menuIconCls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
<span>提交反馈</span>
<span className="min-w-0">
<span className="flex items-center gap-1 text-[13px] font-semibold">提交反馈 <span aria-hidden="true" className="text-[#86898e]">↗</span></span>
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">在钉钉文档中留下问题或建议</span>
</span>
</a>
{isAdmin && (
<>
<div aria-hidden="true" className="my-1.5 border-t border-gray-100 dark:border-gray-700" />
<Link href="/admin" onClick={() => setShowDropdown(false)} className={menuItemCls}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div aria-hidden="true" className="my-1.5 border-t border-[#e7e8ea]" />
<Link href="/admin" onClick={() => setShowDropdown(false)} className={menuItemCls} role="menuitem">
<svg className={menuIconCls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -576,12 +587,15 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>治理与设置</span>
<span className="min-w-0">
<span className="block text-[13px] font-semibold">治理与设置</span>
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">组织架构、规则与系统配置</span>
</span>
</Link>
</>
)}
<div aria-hidden="true" className="my-1.5 border-t border-gray-100 dark:border-gray-700" />
<div aria-hidden="true" className="my-1.5 border-t border-[#e7e8ea]" />
<button
onClick={async () => {
@ -597,9 +611,10 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
router.refresh()
}
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-all flex items-center space-x-2"
className="group flex w-full items-center gap-3 rounded-md px-3 py-3 text-left text-[13px] font-semibold text-[#6b7075] transition-colors hover:bg-[#f5f0f0] hover:text-[#a23c36] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
role="menuitem"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="h-4 w-4 shrink-0 transition-colors group-hover:text-[#a23c36]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"

View File

@ -45,11 +45,11 @@ function normalizePage(value: string | null) {
function LoadingScreen() {
return (
<div className="min-h-screen bg-[#f6f8fb]">
<div className="h-16 bg-[#1f2937]" />
<div className="min-h-screen bg-[#fafafa]">
<div className="h-16 border-b border-[#e7e8ea] bg-[#fafafa]" />
<main className="mx-auto max-w-[1180px] px-4 py-8 sm:px-6">
<div className="h-8 w-48 animate-pulse rounded bg-[#e6eaf0]" />
<div className="mt-3 h-4 w-72 animate-pulse rounded bg-[#edf0f4]" />
<div className="h-8 w-48 animate-pulse bg-[#eceef0]" />
<div className="mt-3 h-4 w-72 animate-pulse bg-[#f2f3f5]" />
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{[0, 1, 2].map((item) => <div key={item} className="h-20 animate-pulse rounded-xl bg-white" />)}
</div>
@ -291,7 +291,7 @@ export default function WorkReviewBoard() {
if (focusedProject) {
return (
<div className="min-h-screen bg-[#f6f8fb] text-[#101828]">
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<WorkReviewHeader
name={me?.user.name || session.user?.name || '当前用户'}
avatar={me?.user.avatar || (session.user as any)?.avatar || null}
@ -340,7 +340,7 @@ export default function WorkReviewBoard() {
}
return (
<div className="min-h-screen bg-[#f6f8fb] text-[#101828]">
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<WorkReviewHeader
name={me?.user.name || session.user?.name || '当前用户'}
avatar={me?.user.avatar || (session.user as any)?.avatar || null}

View File

@ -2,6 +2,9 @@
import Image from 'next/image'
import Link from 'next/link'
import { useEffect } from 'react'
import BrandLogo from '@/components/BrandLogo'
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
export function WorkReviewHeader({
name,
@ -26,29 +29,53 @@ export function WorkReviewHeader({
}) {
const initials = name.trim().slice(0, 2) || '我'
useEffect(() => {
document.title = '由壹点·品牌中心 · 作品过稿看板'
}, [])
return (
<header className="sticky top-0 z-30 border-b border-[#e7e8ea] bg-[#fafafa]">
<div className={`mx-auto flex min-h-[64px] w-full flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3 sm:flex-nowrap sm:px-6 ${wide ? 'max-w-[1600px]' : 'max-w-[1280px]'}`}>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[12px] font-bold text-[#1a1c1d]">
{avatar ? (
<Image
src={avatar}
alt={name}
width={36}
height={36}
unoptimized
className="h-full w-full object-cover"
/>
) : initials}
</div>
<div className="flex min-w-0 items-center gap-2.5">
<Link
href="/"
aria-label="返回品牌中心首页"
className="flex shrink-0 items-center gap-2 rounded-sm text-[13px] font-semibold tracking-tight text-[#1a1c1d] transition-opacity hover:opacity-70 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
>
<BrandLogo
src={DEFAULT_LOGIN_LOGO_URL}
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
alt=""
width={28}
height={28}
unoptimized
className="h-7 w-7 rounded-md object-cover"
/>
<span className="hidden lg:inline">由壹点·品牌中心</span>
</Link>
<span aria-hidden="true" className="text-[12px] text-[#d4d6da]">/</span>
<div className="min-w-0">
<div className="truncate text-[16px] font-bold text-[#1a1c1d]">作品过稿看板</div>
<div className="truncate text-[11px] font-medium text-[#6b7075]">{name}</div>
<div className="truncate text-[15px] font-semibold tracking-[-0.02em] text-[#1a1c1d]">作品过稿看板</div>
<div className="font-en mt-0.5 truncate text-[10px] uppercase tracking-[0.14em] text-[#86898e]">Review board</div>
</div>
</div>
<div className={`flex shrink-0 items-center gap-2 ${secondaryAction ? 'w-full sm:w-auto' : ''}`}>
<div className="hidden items-center gap-2 border-r border-[#e7e8ea] pr-3 sm:flex">
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[10px] font-bold text-[#1a1c1d]">
{avatar ? (
<Image
src={avatar}
alt={name}
width={28}
height={28}
unoptimized
className="h-full w-full object-cover"
/>
) : initials}
</div>
<span className="max-w-24 truncate text-[12px] text-[#6b7075]">{name}</span>
</div>
{secondaryAction && (
<button
type="button"
@ -81,7 +108,7 @@ export function WorkReviewHeader({
href="/materials"
className="inline-flex min-h-10 items-center justify-center rounded-lg px-2 text-[12px] font-semibold text-[#6b7075] transition-colors hover:bg-[#e9ebef] hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/60 sm:min-h-9 sm:px-3 sm:text-[13px]"
>
返回创意资产库
创意资产库
</Link>
</div>
</div>