feat: 统一品牌中心入口与治理页体验
This commit is contained in:
parent
cc6f6578be
commit
5e99c74e79
@ -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')}
|
||||
|
||||
@ -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">
|
||||
|
||||
130
app/page.tsx
130
app/page.tsx
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
39
components/admin/DataStatisticsTab.test.tsx
Normal file
39
components/admin/DataStatisticsTab.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
24
components/board/BrandFluidField.test.tsx
Normal file
24
components/board/BrandFluidField.test.tsx
Normal 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'))
|
||||
})
|
||||
})
|
||||
408
components/board/BrandFluidField.tsx
Normal file
408
components/board/BrandFluidField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user