From ed7c338537283088d0ebe648da94f9021471ca11 Mon Sep 17 00:00:00 2001 From: chenpei Date: Mon, 17 Aug 2026 20:55:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9B=E6=84=8F=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E5=BA=93=E6=A1=8C=E9=9D=A2=E6=94=B9=E7=89=88=EF=BC=8C=E5=85=A8?= =?UTF-8?q?=E7=AB=99=E6=8D=A2=E4=B8=8A=E6=80=9D=E6=BA=90=E4=B8=8E=20Space?= =?UTF-8?q?=20Grotesk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DESIGN.md | 14 +- PRODUCT.md | 2 +- app/globals.css | 7 + app/layout.tsx | 6 +- app/materials/page.tsx | 264 ++++++++------- components/home/FilterPanel.tsx | 330 ++++++++++++++---- components/home/LoadMoreTrigger.tsx | 6 +- components/home/MaterialCard.tsx | 478 ++++++++++----------------- components/home/MaterialGrid.tsx | 15 +- components/home/SearchBar.tsx | 38 +-- lib/__tests__/material-query.test.ts | 28 ++ lib/material-query.ts | 15 +- tailwind.config.ts | 3 +- 13 files changed, 696 insertions(+), 510 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index d5ee8d0..a3e16d6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -30,10 +30,11 @@ ## Type -- 中文标题:Noto Sans SC(`font-display`),600 字重、`-0.03em` 紧字距;首页巨标 `clamp(60px, 9vw, 132px)` -- 工程标注/编号/英文:Space Grotesk(`font-en`),11–13px、大写、`0.14em` 字距 -- 正文:系统无衬线 13–16px、行高 1.7 -- 自托管:`@fontsource/noto-sans-sc`(700/900 备用)、`@fontsource/space-grotesk`(500/600/700) +- 全站默认字栈:`Space Grotesk` → `Noto Sans SC`。拉丁字母和数字走 Space Grotesk,汉字走思源黑体,同一段字里自动切换。 +- 中文标题:Noto Sans SC(`font-sans` / `font-display`),600 字重、`-0.03em` 紧字距;首页巨标 `clamp(60px, 9vw, 132px)` +- 工程标注/编号/英文:Space Grotesk(字栈首位 + `font-en`),11–13px、大写、`0.14em` 字距 +- 正文:同上字栈,13–16px、行高 1.7;不再用系统默认黑体 +- 自托管:`@fontsource/noto-sans-sc`(400/500/600/700/900)、`@fontsource/space-grotesk`(400/500/600/700) ## Layout @@ -59,5 +60,6 @@ GSAP(`useGSAP` + `matchMedia('(prefers-reduced-motion: no-preference)')`): ## Scope Notes -- 组织架构页(`/organization`)仍为旧「云采石场」语言(BoardShell),待后续按本世界对齐 -- 素材库(`/materials`)与旧顶栏保持既有视觉不动 +- **2026-08-17 用户裁定:本次是比较重大的升级,视觉语言需要全面升级。** 后续页面不得再做「旧壳换色 / 只改角标和按钮」的局部换皮;信息架构可保留,版式、组件、密度、动效应按本世界重做。 +- 组织架构页(`/organization`)仍为旧「云采石场」语言(BoardShell),待按本世界全面对齐 +- 素材库(`/materials`)顶栏已是看板语言;列表、筛选、卡片仍是旧素材库骨架,需按本世界全面升级,而不是只改色板 diff --git a/PRODUCT.md b/PRODUCT.md index 0c212e2..68087fb 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -33,7 +33,7 @@ Next.js 14 (App Router) + React 18 + TypeScript + TailwindCSS + PostgreSQL(Prism ## Capabilities and Constraints - 看板首页 `/`:五大板块编号索引(01 创意资产库全员可进 / 02 组织管理仅管理员 / 03-05 规划中如实标灰),入场"对焦"动效,悬停物理重量语言。 -- 创意资产库 `/materials`:原素材库首页,业务功能不动;顶栏已统一为看板语言(MaterialsHeader,功能与旧顶栏等价)。 +- 创意资产库 `/materials`:原素材库首页,**业务功能不动**;顶栏已统一为看板语言。2026-08-17 起按「重大升级」处理:视觉语言要全面对齐看板世界,禁止只改色板/圆角的局部换皮。 - 组织架构页 `/organization`:组织树 + 人员列表,展示 M/P 等级、人员归属(部门路径)、直属主管、在职状态。 - 纯展示(只读),不改数据。 - 权限:系统管理员看全部;部门负责人看本部门。 diff --git a/app/globals.css b/app/globals.css index f8d72fe..a5d779d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -9,6 +9,13 @@ --background-end-rgb: 255, 255, 255; } +@layer base { + html, + body { + font-family: "Space Grotesk", "Noto Sans SC", ui-sans-serif, system-ui, sans-serif; + } +} + body { color: rgb(var(--foreground-rgb)); background: linear-gradient( diff --git a/app/layout.tsx b/app/layout.tsx index 7bbe426..ab459cd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,10 @@ import type { Metadata, Viewport } from 'next' +import '@fontsource/noto-sans-sc/400.css' +import '@fontsource/noto-sans-sc/500.css' +import '@fontsource/noto-sans-sc/600.css' import '@fontsource/noto-sans-sc/700.css' import '@fontsource/noto-sans-sc/900.css' +import '@fontsource/space-grotesk/400.css' import '@fontsource/space-grotesk/500.css' import '@fontsource/space-grotesk/600.css' import '@fontsource/space-grotesk/700.css' @@ -62,7 +66,7 @@ export default function RootLayout({ {/* 注意:frame-ancestors 只能通过 HTTP 响应头设置,不能通过 meta 标签 */} - + {children} diff --git a/app/materials/page.tsx b/app/materials/page.tsx index c08d7d8..5a7e051 100644 --- a/app/materials/page.tsx +++ b/app/materials/page.tsx @@ -3,8 +3,11 @@ import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } from 'react' import { useSession } from 'next-auth/react' import { useRouter, usePathname } from 'next/navigation' +import gsap from 'gsap' +import { useGSAP } from '@gsap/react' import UploadModal from '@/components/UploadModal' import MaterialsHeader from '@/components/board/MaterialsHeader' +import BrandFluidField from '@/components/board/BrandFluidField' import RankingPanel from '@/components/RankingPanel' import AIChatWidget from '@/components/AIChatWidget' import VideoPlayerModal from '@/components/VideoPlayerModal' @@ -23,6 +26,10 @@ import { normalizeMaterialPreviewUrl, pickFirstMaterialPreviewUrl } from '@/lib/ // 首页拆分组件 import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' +gsap.registerPlugin(useGSAP) + +const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]' + interface Material { id: string userId: string @@ -102,6 +109,7 @@ export default function Home() { const { data: session, status } = useSession() const router = useRouter() const pathname = usePathname() + const pageRef = useRef(null) const [materials, setMaterials] = useState([]) const [loading, setLoading] = useState(true) const [facetsLoading, setFacetsLoading] = useState(true) @@ -170,7 +178,7 @@ export default function Home() { const [shareUrl, setShareUrl] = useState(null) const [showShareModal, setShowShareModal] = useState(false) const [showShareNotification, setShowShareNotification] = useState(false) - const [themeColor, setThemeColor] = useState('#1f2937') // 主题颜色 + const themeColor = '#1a1c1d' const [favoriteNotification, setFavoriteNotification] = useState(null) // 收藏操作提示 const { toast, showToast } = useToast() // 统一的Toast提示 // 实时搜索:使用 useDeferredValue 实现非阻塞搜索 @@ -231,11 +239,8 @@ export default function Home() { }, []) const handleCategoryChange = useCallback((value: string) => { - if (value !== selectedCategory) { - setSelectedScene('') - } setSelectedCategory(value) - }, [selectedCategory]) + }, []) // 移除页面卸载时保存状态的逻辑(避免滚动问题) @@ -291,10 +296,6 @@ export default function Home() { window.history.replaceState({}, '', newUrl) }, [deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) - useEffect(() => { - fetchThemeColor() - }, []) - // 加载当前用户可见的素材类型 useEffect(() => { const loadViewableTypes = async () => { @@ -515,19 +516,6 @@ export default function Home() { return () => clearTimeout(timer) }, [status, deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) - // 获取主题颜色 - const fetchThemeColor = async () => { - try { - const response = await fetch('/api/system-settings') - if (response.ok) { - const data = await response.json() - setThemeColor(data.headerBgColor || '#1f2937') - } - } catch (error) { - console.error('获取主题颜色失败:', error) - } - } - // 移除筛选条件保存逻辑(避免滚动问题) // 获取素材列表(支持分页和追加模式) @@ -1004,6 +992,29 @@ export default function Home() { ((data.materialTypes || []) as FacetItem[]).map((item) => [item.name, item.count]), ), ) + const nextCategories = (data.categories || []) as FacetItem[] + const nextScenes = (data.scenes || []) as FacetItem[] + const nextPlatforms = (data.platforms || []) as FacetItem[] + const nextProductCategories = (data.productCategories || []) as FacetItem[] + const nextMonths = (data.months || []) as MonthFacetItem[] + if (selectedCategory && !nextCategories.some((item) => item.name === selectedCategory)) { + setSelectedCategory('') + } + if (selectedScene && !nextScenes.some((item) => item.name === selectedScene)) { + setSelectedScene('') + } + if (selectedPlatform && !nextPlatforms.some((item) => item.name === selectedPlatform)) { + setSelectedPlatform('') + } + if (selectedProductCategory && !nextProductCategories.some((item) => item.name === selectedProductCategory)) { + setSelectedProductCategory('') + } + if (selectedMonth) { + const monthStillValid = nextMonths.some((item) => + selectedMonth.length === 4 ? item.value.startsWith(`${selectedMonth}-`) : item.value === selectedMonth, + ) + if (!monthStillValid) setSelectedMonth('') + } } } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return @@ -1285,49 +1296,117 @@ export default function Home() { } } + useGSAP(() => { + if (status === 'loading' || typeof window.matchMedia !== 'function') return + if (!pageRef.current?.querySelector('[data-hero-title]')) return + + const motion = gsap.matchMedia() + motion.add('(prefers-reduced-motion: no-preference)', () => { + const tl = gsap.timeline({ defaults: { ease: 'expo.out' } }) + tl + .fromTo( + '[data-hero-title]', + { y: 18, autoAlpha: 0, filter: 'blur(10px)' }, + { y: 0, autoAlpha: 1, filter: 'blur(0px)', duration: 0.85, ease: 'power4.out', clearProps: 'filter,opacity,visibility,transform' }, + ) + .fromTo( + '[data-hero]', + { y: 10, autoAlpha: 0 }, + { y: 0, autoAlpha: 1, duration: 0.5, clearProps: 'opacity,visibility,transform' }, + '-=0.6', + ) + .fromTo( + '[data-toolbar]', + { y: 12, autoAlpha: 0 }, + { y: 0, autoAlpha: 1, duration: 0.5, clearProps: 'opacity,visibility,transform' }, + '-=0.35', + ) + }) + return () => motion.revert() + }, { scope: pageRef, dependencies: [status] }) + + useGSAP(() => { + if (loading || status === 'loading' || typeof window.matchMedia !== 'function') return + if (!pageRef.current?.querySelector('[data-card]')) return + + const motion = gsap.matchMedia() + motion.add('(prefers-reduced-motion: no-preference)', () => { + gsap.fromTo( + '[data-card]', + { y: 16, autoAlpha: 0 }, + { + y: 0, + autoAlpha: 1, + duration: 0.45, + stagger: 0.06, + ease: 'power3.out', + clearProps: 'opacity,visibility,transform', + }, + ) + }) + return () => motion.revert() + }, { scope: pageRef, dependencies: [loading, status] }) + if (status === 'unauthenticated') { return null } return ( -
- {/* 板块顶栏:品牌中心看板语言(含面包屑/淘宝搜索/内外网状态/性能监控/用户菜单),功能与旧顶栏等价 */} +
+ + {/* + THESIS: 创意资产库是白皮书的下一章——编号索引落下之后,资产以图录而不是后台表格出现。 + OWN-WORLD: 白纸 #fafafa、墨字、发丝线、平面无阴影、唯一柔紫光晕、墨色 8px 主按钮。 + STORY: 打开即读「01 创意资产库」,搜索是可见的输入框,上传跟在后面,类型是文字分页。 + FIRST VIEWPORT: 顶栏 → 01/巨标 → 搜索+上传 → 类型索引 → 三列图录。 + FORM: Stitch 桌面稿 + 看板首页下限。FINISH: unreviewed and undocumented is unfinished. + */} - {/* 维护通知横幅已在 Header 中显示 */} {/* 收藏操作提示气泡 */} {favoriteNotification && ( -
+
{favoriteNotification}
)} - {/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */} - -
- {/* Header Section */} -
-
+
+
+ +
+

+ 01 + Creative Assets +

+

+ 创意资产库 +

+

+ 品牌中心的素材都在这里。浏览、上传、分享与下载照常可用。 +

+
+
+ +
+
+
+ +
- {/* Search Bar - 实时搜索 */} - - {/* 筛选面板组件 */} - {/* Active Filters Display */} {(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && ( -
-
- 当前筛选: +
+ 当前筛选 {searchKeyword && ( - - 搜索: {searchKeyword} - + + 搜索 {searchKeyword} + )} {selectedMaterialType && ( - - 素材类型: {selectedMaterialType} - + + {selectedMaterialType} + )} {selectedCategory && ( - - 店铺: {selectedCategory} - + + {selectedCategory} + )} {selectedScene && ( - - 场景类别: {selectedScene} - + + {selectedScene} + )} {selectedPlatform && ( - - 平台分类: {selectedPlatform} - + + {selectedPlatform} + )} {selectedProductCategory && ( - - 产品类别: {selectedProductCategory} - + + {selectedProductCategory} + )} {selectedMonth && ( - - 月份: {months.find(m => m.value === selectedMonth)?.label || selectedMonth} - + + {selectedMonth.length === 4 + ? `${selectedMonth}年` + : months.find(m => m.value === selectedMonth)?.label || selectedMonth} + )} -
)}
@@ -1567,24 +1610,23 @@ export default function Home() { onClick={() => setShowShareModal(false)} >
e.stopPropagation()} > -

分享链接

-
+

分享链接

+
{shareUrl}
diff --git a/components/home/FilterPanel.tsx b/components/home/FilterPanel.tsx index d0f4e82..7ddd5f5 100644 --- a/components/home/FilterPanel.tsx +++ b/components/home/FilterPanel.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useCallback } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' export interface CategoryItem { id: string @@ -66,7 +66,7 @@ function FilterPanel(props: FilterPanelProps) { sortBy, setSortBy, sortOrder, setSortOrder, materialTypes, categories, scenes, platforms, productCategories, months, totalMaterialsCount, materialsCount, facetTotals, - isFilterCollapsed, setIsFilterCollapsed, themeColor, + isFilterCollapsed, setIsFilterCollapsed, facetsLoading = false, } = props @@ -88,85 +88,287 @@ function FilterPanel(props: FilterPanelProps) { month: facetTotals?.month ?? totalMaterialsCount, } const activeFilterCount = [selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth].filter(Boolean).length - const btnBase = 'px-4 py-2 rounded-full text-sm font-medium transition-all' - const btnActive = 'text-white' - const btnInactive = 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50' - const countText = (value: number) => (facetsLoading ? '…' : String(value)) + const tabBase = 'relative shrink-0 whitespace-nowrap pb-3 text-[15px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm' + const tabActive = 'text-[#1a1c1d]' + const tabInactive = 'text-[#86898e] hover:text-[#1a1c1d]' + const chipBase = 'text-[14px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm' + const chipActive = 'text-[#1a1c1d]' + const chipInactive = 'text-[#86898e] hover:text-[#1a1c1d]' + const countMuted = (active: boolean) => (active ? 'text-[#6b7075]' : 'text-[#86898e]') + const countText = (value: number) => (facetsLoading ? '…' : value.toLocaleString('zh-CN')) + const rankedTypes = useMemo(() => { + const hasCounts = materialTypes.some((type) => (type.count || 0) > 0) + if (!hasCounts) return materialTypes + return [...materialTypes].sort((left, right) => (right.count || 0) - (left.count || 0)) + }, [materialTypes]) + const rankByCount = useCallback((items: T[]) => { + if (!items.some((item) => (item.count || 0) > 0)) return items + return [...items].sort((left, right) => (right.count || 0) - (left.count || 0)) + }, []) + const rankedCategories = useMemo(() => rankByCount(categories), [categories, rankByCount]) + const rankedScenes = useMemo(() => rankByCount(scenes), [scenes, rankByCount]) + const rankedPlatforms = useMemo(() => rankByCount(platforms), [platforms, rankByCount]) + const rankedProductCategories = useMemo(() => rankByCount(productCategories), [productCategories, rankByCount]) + const yearFacets = useMemo(() => { + const yearMap = new Map() + for (const item of months) { + const year = item.value.slice(0, 4) + if (!/^\d{4}$/.test(year)) continue + yearMap.set(year, (yearMap.get(year) || 0) + item.count) + } + return Array.from(yearMap.entries()) + .map(([year, count]) => ({ year, count })) + .sort((left, right) => right.year.localeCompare(left.year)) + }, [months]) + const focusedYear = selectedMonth.slice(0, 4) || yearFacets[0]?.year || '' + const monthsInFocusedYear = useMemo( + () => months.filter((item) => item.value.startsWith(`${focusedYear}-`)).sort((left, right) => right.value.localeCompare(left.value)), + [months, focusedYear], + ) + const rowRef = useRef(null) + const measureRef = useRef(null) + const moreWrapRef = useRef(null) + const [fitCount, setFitCount] = useState(null) + const [moreOpen, setMoreOpen] = useState(false) + + const measureFit = useCallback(() => { + const row = rowRef.current + const measure = measureRef.current + if (!row || !measure) return + const available = row.clientWidth + if (available < 8) { + setFitCount(null) + return + } + const kids = Array.from(measure.children) as HTMLElement[] + if (kids.length < 2) { + setFitCount(rankedTypes.length) + return + } + const moreWidth = kids[kids.length - 1].offsetWidth + const typeEls = kids.slice(1, -1) + const gap = 32 + let used = kids[0].offsetWidth + let count = 0 + for (let index = 0; index < typeEls.length; index += 1) { + const remaining = typeEls.length - (index + 1) + const extra = remaining > 0 ? gap + moreWidth : 0 + if (used + gap + typeEls[index].offsetWidth + extra <= available) { + used += gap + typeEls[index].offsetWidth + count += 1 + } else { + break + } + } + setFitCount(count) + }, [rankedTypes.length]) + + useLayoutEffect(() => { + measureFit() + const row = rowRef.current + if (!row || typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(() => measureFit()) + observer.observe(row) + return () => observer.disconnect() + }, [measureFit, rankedTypes, totals.materialType, facetsLoading]) + + const { visibleTypes, overflowTypes } = useMemo(() => { + if (fitCount === null || fitCount >= rankedTypes.length) { + return { visibleTypes: rankedTypes, overflowTypes: [] as typeof rankedTypes } + } + let visible = rankedTypes.slice(0, fitCount) + let overflow = rankedTypes.slice(fitCount) + if (selectedMaterialType) { + const hiddenSelected = overflow.find((type) => type.value === selectedMaterialType) + if (hiddenSelected && visible.length > 0) { + const displaced = visible[visible.length - 1] + visible = [...visible.slice(0, -1), hiddenSelected] + overflow = [displaced, ...overflow.filter((type) => type.value !== selectedMaterialType)] + } + } + return { visibleTypes: visible, overflowTypes: overflow } + }, [fitCount, rankedTypes, selectedMaterialType]) + + useEffect(() => { + if (!moreOpen) return + const onPointer = (event: MouseEvent) => { + if (!moreWrapRef.current?.contains(event.target as Node)) setMoreOpen(false) + } + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') setMoreOpen(false) + } + document.addEventListener('mousedown', onPointer) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onPointer) + document.removeEventListener('keydown', onKey) + } + }, [moreOpen]) + + useEffect(() => { + setMoreOpen(false) + }, [selectedMaterialType]) + + const renderTypeTab = (label: string, count: number, active: boolean, onClick: () => void) => ( + + ) + + const moreActive = overflowTypes.some((type) => type.value === selectedMaterialType) return ( <> -
-
-

类型分页

- {selectedMaterialType && {facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}} -
-
- - {materialTypes.map((type) => ( - - ))} - {facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => ( -