@@ -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() {
{/* ===== 编辑页首:巨字 + 柔光 ===== */}
-
- {/* 柔光渐变光晕:唯一的有色氛围(来自 Future Organic) */}
-
-
+
+ {/* 动态品牌色场:来自 Future Organic,只在首屏承担一次有色氛围 */}
+
+
{/* 巨型标题:入场对焦揭幕(GSAP blur+tracking),悬停字距呼吸(CSS) */}
)}
- {/* ===== 两块已上线板块的入口白卡 ===== */}
+ {/* ===== 已上线板块的入口白卡 ===== */}
{sessionLoading ? (
-
- {Array.from({ length: 2 }).map((_, i) => (
-
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
))}
) : (
-
-
+
+
运行中
@@ -341,7 +357,7 @@ export default function BoardHomePage() {
-
+
已上线
@@ -351,7 +367,7 @@ export default function BoardHomePage() {
真实组织树、M/P 等级、汇报线与在职状态,数据源 HR 花名册。
-
+
+
+
+ 已上线
+ 03 / Performance
+
+
绩效考核
+
+ 个人考核、公共看板与调研管理——任务进度和结果在同一处收敛。
+
+
- 绩效考核
+ 进入绩效考核
+
+
+
+
+
+
+ 已上线
+ 04 / Review
+
+
作品过稿看板
+
+ 作品提交、版本迭代与评审意见——让创意协作从上传后继续推进。
+
+
+
+ 进入作品过稿看板
diff --git a/app/performance-survey/[[...path]]/page.tsx b/app/performance-survey/[[...path]]/page.tsx
index 08036e3..770f8e0 100644
--- a/app/performance-survey/[[...path]]/page.tsx
+++ b/app/performance-survey/[[...path]]/page.tsx
@@ -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 (
-
+
+
+
+
+
/
- 返回创意资产库
+ 创意资产库
@@ -916,8 +933,16 @@ function PerformanceSurveyDashboardHeader({
}) {
return (
-
-
+
+
+
+
+
+
/
{avatar ? (
-
绩效考核
+
绩效考核
{displayName}
@@ -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="返回创意资产库"
>
- 返回创意资产库
+ 创意资产库
-
+
)
diff --git a/components/admin/DataStatisticsTab.test.tsx b/components/admin/DataStatisticsTab.test.tsx
new file mode 100644
index 0000000..b028fda
--- /dev/null
+++ b/components/admin/DataStatisticsTab.test.tsx
@@ -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(
+
,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: '打开开始日期日历' }))
+ expect(screen.getByText('2026年08月')).toBeInTheDocument()
+
+ rerender(
+
,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByText('2026年07月')).toBeInTheDocument()
+ })
+
+ expect(consoleErrorSpy.mock.calls.flat().join(' ')).not.toContain('Maximum update depth exceeded')
+ } finally {
+ consoleErrorSpy.mockRestore()
+ }
+ })
+})
diff --git a/components/admin/DataStatisticsTab.tsx b/components/admin/DataStatisticsTab.tsx
index d537303..98fc1bb 100644
--- a/components/admin/DataStatisticsTab.tsx
+++ b/components/admin/DataStatisticsTab.tsx
@@ -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
(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) {
diff --git a/components/board/BrandFluidField.test.tsx b/components/board/BrandFluidField.test.tsx
new file mode 100644
index 0000000..e32d56b
--- /dev/null
+++ b/components/board/BrandFluidField.test.tsx
@@ -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()
+ 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'))
+ })
+})
diff --git a/components/board/BrandFluidField.tsx b/components/board/BrandFluidField.tsx
new file mode 100644
index 0000000..826ad61
--- /dev/null
+++ b/components/board/BrandFluidField.tsx
@@ -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(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 (
+
+
+
+
+ )
+}
diff --git a/components/board/MaterialsHeader.tsx b/components/board/MaterialsHeader.tsx
index dd739d1..3b6eb9d 100644
--- a/components/board/MaterialsHeader.tsx
+++ b/components/board/MaterialsHeader.tsx
@@ -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 (
<>
-
+
{/* 品牌 */}
- /
- {sectionLabel}
+ /
+ {sectionLabel}
>
) : (
/* 首页模式:五大板块导航 */
@@ -294,8 +303,8 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead