feat: 创意资产库桌面改版,全站换上思源与 Space Grotesk

This commit is contained in:
chenpei 2026-08-17 20:55:15 +08:00
parent 2b5220fdd7
commit ed7c338537
13 changed files with 696 additions and 510 deletions

View File

@ -30,10 +30,11 @@
## Type ## Type
- 中文标题:Noto Sans SC(`font-display`),600 字重、`-0.03em` 紧字距;首页巨标 `clamp(60px, 9vw, 132px)` - 全站默认字栈:`Space Grotesk` → `Noto Sans SC`。拉丁字母和数字走 Space Grotesk,汉字走思源黑体,同一段字里自动切换。
- 工程标注/编号/英文:Space Grotesk(`font-en`),11–13px、大写、`0.14em` 字距 - 中文标题:Noto Sans SC(`font-sans` / `font-display`),600 字重、`-0.03em` 紧字距;首页巨标 `clamp(60px, 9vw, 132px)`
- 正文:系统无衬线 13–16px、行高 1.7 - 工程标注/编号/英文:Space Grotesk(字栈首位 + `font-en`),11–13px、大写、`0.14em` 字距
- 自托管:`@fontsource/noto-sans-sc`(700/900 备用)、`@fontsource/space-grotesk`(500/600/700) - 正文:同上字栈,13–16px、行高 1.7;不再用系统默认黑体
- 自托管:`@fontsource/noto-sans-sc`(400/500/600/700/900)、`@fontsource/space-grotesk`(400/500/600/700)
## Layout ## Layout
@ -59,5 +60,6 @@ GSAP(`useGSAP` + `matchMedia('(prefers-reduced-motion: no-preference)')`):
## Scope Notes ## Scope Notes
- 组织架构页(`/organization`)仍为旧「云采石场」语言(BoardShell),待后续按本世界对齐 - **2026-08-17 用户裁定:本次是比较重大的升级,视觉语言需要全面升级。** 后续页面不得再做「旧壳换色 / 只改角标和按钮」的局部换皮;信息架构可保留,版式、组件、密度、动效应按本世界重做。
- 素材库(`/materials`)与旧顶栏保持既有视觉不动 - 组织架构页(`/organization`)仍为旧「云采石场」语言(BoardShell),待按本世界全面对齐
- 素材库(`/materials`)顶栏已是看板语言;列表、筛选、卡片仍是旧素材库骨架,需按本世界全面升级,而不是只改色板

View File

@ -33,7 +33,7 @@ Next.js 14 (App Router) + React 18 + TypeScript + TailwindCSS + PostgreSQL(Prism
## Capabilities and Constraints ## Capabilities and Constraints
- 看板首页 `/`:五大板块编号索引(01 创意资产库全员可进 / 02 组织管理仅管理员 / 03-05 规划中如实标灰),入场"对焦"动效,悬停物理重量语言。 - 看板首页 `/`:五大板块编号索引(01 创意资产库全员可进 / 02 组织管理仅管理员 / 03-05 规划中如实标灰),入场"对焦"动效,悬停物理重量语言。
- 创意资产库 `/materials`:原素材库首页,业务功能不动;顶栏已统一为看板语言(MaterialsHeader,功能与旧顶栏等价)。 - 创意资产库 `/materials`:原素材库首页,**业务功能不动**;顶栏已统一为看板语言。2026-08-17 起按「重大升级」处理:视觉语言要全面对齐看板世界,禁止只改色板/圆角的局部换皮。
- 组织架构页 `/organization`:组织树 + 人员列表,展示 M/P 等级、人员归属(部门路径)、直属主管、在职状态。 - 组织架构页 `/organization`:组织树 + 人员列表,展示 M/P 等级、人员归属(部门路径)、直属主管、在职状态。
- 纯展示(只读),不改数据。 - 纯展示(只读),不改数据。
- 权限:系统管理员看全部;部门负责人看本部门。 - 权限:系统管理员看全部;部门负责人看本部门。

View File

@ -9,6 +9,13 @@
--background-end-rgb: 255, 255, 255; --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 { body {
color: rgb(var(--foreground-rgb)); color: rgb(var(--foreground-rgb));
background: linear-gradient( background: linear-gradient(

View File

@ -1,6 +1,10 @@
import type { Metadata, Viewport } from 'next' 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/700.css'
import '@fontsource/noto-sans-sc/900.css' import '@fontsource/noto-sans-sc/900.css'
import '@fontsource/space-grotesk/400.css'
import '@fontsource/space-grotesk/500.css' import '@fontsource/space-grotesk/500.css'
import '@fontsource/space-grotesk/600.css' import '@fontsource/space-grotesk/600.css'
import '@fontsource/space-grotesk/700.css' import '@fontsource/space-grotesk/700.css'
@ -62,7 +66,7 @@ export default function RootLayout({
{/* 注意:frame-ancestors 只能通过 HTTP 响应头设置,不能通过 meta 标签 */} {/* 注意:frame-ancestors 只能通过 HTTP 响应头设置,不能通过 meta 标签 */}
<meta httpEquiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: http: https: ws: wss:; img-src 'self' 'unsafe-inline' blob: data: http: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://g.alicdn.com; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data: http: https: ws: wss:; media-src 'self' blob: data: http: https:; font-src 'self' data: http: https:; object-src 'none'; base-uri 'self'; form-action 'self';" /> <meta httpEquiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: http: https: ws: wss:; img-src 'self' 'unsafe-inline' blob: data: http: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://g.alicdn.com; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data: http: https: ws: wss:; media-src 'self' blob: data: http: https:; font-src 'self' data: http: https:; object-src 'none'; base-uri 'self'; form-action 'self';" />
</head> </head>
<body className="font-sans antialiased"> <body className="antialiased">
<Providers> <Providers>
{children} {children}
</Providers> </Providers>

View File

@ -3,8 +3,11 @@
import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } from 'react' import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from 'next/navigation'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import UploadModal from '@/components/UploadModal' import UploadModal from '@/components/UploadModal'
import MaterialsHeader from '@/components/board/MaterialsHeader' import MaterialsHeader from '@/components/board/MaterialsHeader'
import BrandFluidField from '@/components/board/BrandFluidField'
import RankingPanel from '@/components/RankingPanel' import RankingPanel from '@/components/RankingPanel'
import AIChatWidget from '@/components/AIChatWidget' import AIChatWidget from '@/components/AIChatWidget'
import VideoPlayerModal from '@/components/VideoPlayerModal' import VideoPlayerModal from '@/components/VideoPlayerModal'
@ -23,6 +26,10 @@ import { normalizeMaterialPreviewUrl, pickFirstMaterialPreviewUrl } from '@/lib/
// 首页拆分组件 // 首页拆分组件
import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' 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 { interface Material {
id: string id: string
userId: string userId: string
@ -102,6 +109,7 @@ export default function Home() {
const { data: session, status } = useSession() const { data: session, status } = useSession()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const pageRef = useRef<HTMLDivElement>(null)
const [materials, setMaterials] = useState<Material[]>([]) const [materials, setMaterials] = useState<Material[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [facetsLoading, setFacetsLoading] = useState(true) const [facetsLoading, setFacetsLoading] = useState(true)
@ -170,7 +178,7 @@ export default function Home() {
const [shareUrl, setShareUrl] = useState<string | null>(null) const [shareUrl, setShareUrl] = useState<string | null>(null)
const [showShareModal, setShowShareModal] = useState(false) const [showShareModal, setShowShareModal] = useState(false)
const [showShareNotification, setShowShareNotification] = useState(false) const [showShareNotification, setShowShareNotification] = useState(false)
const [themeColor, setThemeColor] = useState('#1f2937') // 主题颜色 const themeColor = '#1a1c1d'
const [favoriteNotification, setFavoriteNotification] = useState<string | null>(null) // 收藏操作提示 const [favoriteNotification, setFavoriteNotification] = useState<string | null>(null) // 收藏操作提示
const { toast, showToast } = useToast() // 统一的Toast提示 const { toast, showToast } = useToast() // 统一的Toast提示
// 实时搜索:使用 useDeferredValue 实现非阻塞搜索 // 实时搜索:使用 useDeferredValue 实现非阻塞搜索
@ -231,11 +239,8 @@ export default function Home() {
}, []) }, [])
const handleCategoryChange = useCallback((value: string) => { const handleCategoryChange = useCallback((value: string) => {
if (value !== selectedCategory) {
setSelectedScene('')
}
setSelectedCategory(value) setSelectedCategory(value)
}, [selectedCategory]) }, [])
// 移除页面卸载时保存状态的逻辑(避免滚动问题) // 移除页面卸载时保存状态的逻辑(避免滚动问题)
@ -291,10 +296,6 @@ export default function Home() {
window.history.replaceState({}, '', newUrl) window.history.replaceState({}, '', newUrl)
}, [deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) }, [deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder])
useEffect(() => {
fetchThemeColor()
}, [])
// 加载当前用户可见的素材类型 // 加载当前用户可见的素材类型
useEffect(() => { useEffect(() => {
const loadViewableTypes = async () => { const loadViewableTypes = async () => {
@ -515,19 +516,6 @@ export default function Home() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [status, deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) }, [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]), ((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) { } catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return 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') { if (status === 'unauthenticated') {
return null return null
} }
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div ref={pageRef} className="materials-archive min-h-screen bg-[#fafafa] font-display text-[#1a1c1d] antialiased">
{/* 板块顶栏:品牌中心看板语言(含面包屑/淘宝搜索/内外网状态/性能监控/用户菜单),功能与旧顶栏等价 */} <style>{`.materials-archive ::selection { background: #eef0fa; color: #1a1c1d; }`}</style>
{/*
THESIS: 创意资产库是白皮书的下一章——编号索引落下之后,资产以图录而不是后台表格出现。
OWN-WORLD: 白纸 #fafafa、墨字、发丝线、平面无阴影、唯一柔紫光晕、墨色 8px 主按钮。
STORY: 打开即读「01 创意资产库」,搜索是可见的输入框,上传跟在后面,类型是文字分页。
FIRST VIEWPORT: 顶栏 → 01/巨标 → 搜索+上传 → 类型索引 → 三列图录。
FORM: Stitch 桌面稿 + 看板首页下限。FINISH: unreviewed and undocumented is unfinished.
*/}
<MaterialsHeader session={session ?? null} /> <MaterialsHeader session={session ?? null} />
{/* 维护通知横幅已在 Header 中显示 */}
{/* 收藏操作提示气泡 */} {/* 收藏操作提示气泡 */}
{favoriteNotification && ( {favoriteNotification && (
<div className="fixed top-20 right-4 z-50 px-6 py-3 bg-gray-900 dark:bg-gray-800 text-white rounded-lg shadow-lg animate-fade-in"> <div className="fixed top-20 right-4 z-50 px-6 py-3 bg-[#1a1c1d] text-white rounded-[8px] animate-fade-in">
{favoriteNotification} {favoriteNotification}
</div> </div>
)} )}
{/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
<RankingPanel /> <RankingPanel />
<main className="container mx-auto px-4 py-8 max-w-7xl"> <main className="relative mx-auto max-w-[1280px] px-6 pb-20">
{/* Header Section */} <section className="relative isolate overflow-hidden pb-8 pt-16 md:pb-10 md:pt-20">
<div className="mb-8"> <BrandFluidField />
<div className="flex justify-end items-center mb-6"> <div className="relative z-10">
<p className="font-en text-[11px] uppercase tracking-[0.14em] text-[#86898e]">
<span className="mr-3 tabular-nums">01</span>
Creative Assets
</p>
<h1 data-hero-title className="mt-3 font-display text-[clamp(36px,5vw,56px)] font-semibold leading-[1.08] tracking-[-0.03em]">
创意资产库
</h1>
<p data-hero className="mt-3 max-w-xl text-[14px] leading-relaxed text-[#6b7075] md:text-[15px]">
品牌中心的素材都在这里。浏览、上传、分享与下载照常可用。
</p>
</div>
</section>
<div data-toolbar>
<div className="mb-8 flex items-center gap-3">
<div className="min-w-0 flex-1">
<SearchBar
value={searchKeyword}
onChange={handleSearchChange}
placeholder="搜索素材 ID、SPU、任务号、店铺、场景或作者"
isSearching={isSearchPending}
/>
</div>
<button <button
onClick={() => setShowUploadModal(true)} onClick={() => setShowUploadModal(true)}
className="px-6 py-3 rounded-apple text-white font-medium transition-all hover:opacity-90 shadow-apple" className={`h-12 min-w-[148px] shrink-0 rounded-[8px] bg-[#1a1c1d] px-8 text-[14px] font-medium tracking-[-0.02em] text-white transition-colors hover:bg-[#2a2d2f] ${focusRing}`}
style={{ backgroundColor: themeColor }}
> >
<span className="mr-2">+</span>
上传素材 上传素材
</button> </button>
</div> </div>
{/* Search Bar - 实时搜索 */}
<SearchBar
value={searchKeyword}
onChange={handleSearchChange}
placeholder="搜索素材 ID、SPU、任务号、店铺、场景或作者"
isSearching={isSearchPending}
/>
{/* 筛选面板组件 */} {/* 筛选面板组件 */}
<FilterPanel <FilterPanel
selectedMaterialType={selectedMaterialType} selectedMaterialType={selectedMaterialType}
@ -1361,86 +1440,51 @@ export default function Home() {
facetsLoading={facetsLoading || status === 'loading'} facetsLoading={facetsLoading || status === 'loading'}
/> />
{/* Active Filters Display */}
{(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && ( {(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-apple p-3 mb-4"> <div className="mb-8 flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-[#e7e8ea] pb-4 text-[13px] text-[#6b7075]">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 flex-wrap"> <span className="text-[#86898e]">当前筛选</span>
<span className="font-medium text-blue-600 dark:text-blue-400">当前筛选:</span>
{searchKeyword && ( {searchKeyword && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full"> <span className="inline-flex items-center gap-1.5">
搜索: {searchKeyword} 搜索 {searchKeyword}
<button <button onClick={() => setSearchKeyword('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
onClick={() => setSearchKeyword('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span> </span>
)} )}
{selectedMaterialType && ( {selectedMaterialType && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full"> <span className="inline-flex items-center gap-1.5">
素材类型: {selectedMaterialType} {selectedMaterialType}
<button <button onClick={() => setSelectedMaterialType('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
onClick={() => setSelectedMaterialType('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span> </span>
)} )}
{selectedCategory && ( {selectedCategory && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full"> <span className="inline-flex items-center gap-1.5">
店铺: {selectedCategory} {selectedCategory}
<button <button onClick={() => handleCategoryChange('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
onClick={() => handleCategoryChange('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span> </span>
)} )}
{selectedScene && ( {selectedScene && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full"> <span className="inline-flex items-center gap-1.5">
场景类别: {selectedScene} {selectedScene}
<button <button onClick={() => setSelectedScene('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
onClick={() => setSelectedScene('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span> </span>
)} )}
{selectedPlatform && ( {selectedPlatform && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full"> <span className="inline-flex items-center gap-1.5">
平台分类: {selectedPlatform} {selectedPlatform}
<button <button onClick={() => setSelectedPlatform('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
onClick={() => setSelectedPlatform('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span> </span>
)} )}
{selectedProductCategory && ( {selectedProductCategory && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full"> <span className="inline-flex items-center gap-1.5">
产品类别: {selectedProductCategory} {selectedProductCategory}
<button <button onClick={() => setSelectedProductCategory('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
onClick={() => setSelectedProductCategory('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span> </span>
)} )}
{selectedMonth && ( {selectedMonth && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full"> <span className="inline-flex items-center gap-1.5">
月份: {months.find(m => m.value === selectedMonth)?.label || selectedMonth} {selectedMonth.length === 4
<button ? `${selectedMonth}年`
onClick={() => setSelectedMonth('')} : months.find(m => m.value === selectedMonth)?.label || selectedMonth}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" <button onClick={() => setSelectedMonth('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
>
×
</button>
</span> </span>
)} )}
<button <button
@ -1453,11 +1497,10 @@ export default function Home() {
setSelectedProductCategory('') setSelectedProductCategory('')
setSelectedMonth('') setSelectedMonth('')
}} }}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline ml-2 font-medium" className={`text-[#5a66b8] underline-offset-4 hover:underline ${focusRing} rounded-sm`}
> >
清除所有筛选 清除所有筛选
</button> </button>
</div>
</div> </div>
)} )}
</div> </div>
@ -1567,24 +1610,23 @@ export default function Home() {
onClick={() => setShowShareModal(false)} onClick={() => setShowShareModal(false)}
> >
<div <div
className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl" className="bg-white rounded-[12px] border border-[#e7e8ea] p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">分享链接</h3> <h3 className="text-lg font-semibold mb-4 text-[#1a1c1d]">分享链接</h3>
<div className="bg-gray-100 dark:bg-gray-700 p-3 rounded-lg mb-4 break-all text-sm text-gray-800 dark:text-gray-200"> <div className="bg-[#fafafa] border border-[#e7e8ea] p-3 rounded-[8px] mb-4 break-all text-sm text-[#1a1c1d]">
{shareUrl} {shareUrl}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={copyShareUrl} onClick={copyShareUrl}
className="flex-1 text-white px-4 py-2 rounded-lg transition-all hover:opacity-90" className="flex-1 bg-[#1a1c1d] text-white px-4 py-2 rounded-[8px] transition-colors hover:bg-[#2a2d2f] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
style={{ backgroundColor: themeColor }}
> >
复制链接 复制链接
</button> </button>
<button <button
onClick={() => setShowShareModal(false)} onClick={() => setShowShareModal(false)}
className="flex-1 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-800 dark:text-white px-4 py-2 rounded-lg transition-colors" className="flex-1 border border-[#1a1c1d] bg-white hover:bg-[#1a1c1d] hover:text-white text-[#1a1c1d] px-4 py-2 rounded-[8px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
> >
关闭 关闭
</button> </button>

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback } from 'react' import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
export interface CategoryItem { export interface CategoryItem {
id: string id: string
@ -66,7 +66,7 @@ function FilterPanel(props: FilterPanelProps) {
sortBy, setSortBy, sortOrder, setSortOrder, sortBy, setSortBy, sortOrder, setSortOrder,
materialTypes, categories, scenes, platforms, productCategories, months, materialTypes, categories, scenes, platforms, productCategories, months,
totalMaterialsCount, materialsCount, facetTotals, totalMaterialsCount, materialsCount, facetTotals,
isFilterCollapsed, setIsFilterCollapsed, themeColor, isFilterCollapsed, setIsFilterCollapsed,
facetsLoading = false, facetsLoading = false,
} = props } = props
@ -88,85 +88,287 @@ function FilterPanel(props: FilterPanelProps) {
month: facetTotals?.month ?? totalMaterialsCount, month: facetTotals?.month ?? totalMaterialsCount,
} }
const activeFilterCount = [selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth].filter(Boolean).length 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 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 btnActive = 'text-white' const tabActive = 'text-[#1a1c1d]'
const btnInactive = 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50' const tabInactive = 'text-[#86898e] hover:text-[#1a1c1d]'
const countText = (value: number) => (facetsLoading ? '…' : String(value)) 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(<T extends { count: number }>(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<string, number>()
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<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const moreWrapRef = useRef<HTMLDivElement>(null)
const [fitCount, setFitCount] = useState<number | null>(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) => (
<button onClick={onClick} className={`${tabBase} ${active ? tabActive : tabInactive}`}>
{label}
<span className={`ml-2 font-en text-[14px] tabular-nums ${countMuted(active)}`}>{countText(count)}</span>
{active && <span aria-hidden="true" className="absolute inset-x-0 -bottom-px h-px bg-[#1a1c1d]" />}
</button>
)
const moreActive = overflowTypes.some((type) => type.value === selectedMaterialType)
return ( return (
<> <>
<div className="mb-4"> <div className="mb-6">
<div className="flex items-center justify-between mb-3"> <div className="flex items-end justify-between gap-6 border-b border-[#e7e8ea]">
<h2 className="text-sm font-medium text-gray-700">类型分页</h2> <div ref={rowRef} className="relative min-w-0 flex-1">
{selectedMaterialType && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>} <div className="flex items-end gap-x-8" aria-busy={facetsLoading}>
</div> {renderTypeTab('全部', totals.materialType, selectedMaterialType === '', () => setSelectedMaterialType(''))}
<div className="flex flex-wrap gap-2" aria-busy={facetsLoading}> {visibleTypes.map((type) => (
<button onClick={() => setSelectedMaterialType('')} className={`${btnBase} ${selectedMaterialType === '' ? btnActive : btnInactive}`} style={selectedMaterialType === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMaterialType === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.materialType)})</span></button> <span key={type.value}>
{materialTypes.map((type) => ( {renderTypeTab(type.label, type.count || 0, selectedMaterialType === type.value, () => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value))}
<button key={type.value} onClick={() => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value)} className={`${btnBase} ${selectedMaterialType === type.value ? btnActive : btnInactive}`} style={selectedMaterialType === type.value ? { backgroundColor: themeColor } : {}}>{type.label}<span className={`ml-1.5 text-xs ${selectedMaterialType === type.value ? 'text-gray-300' : 'text-gray-500'}`}>({countText(type.count || 0)})</span></button> </span>
))} ))}
{facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => ( {overflowTypes.length > 0 && (
<span key={item} className="h-9 w-20 rounded-full bg-[#eceef0]" aria-hidden="true" /> <div ref={moreWrapRef} className="relative shrink-0">
))} <button
type="button"
onClick={() => setMoreOpen((open) => !open)}
aria-expanded={moreOpen}
aria-haspopup="listbox"
className={`${tabBase} ${moreActive || moreOpen ? tabActive : tabInactive}`}
>
更多
<span className={`ml-2 font-en text-[14px] tabular-nums ${countMuted(moreActive || moreOpen)}`}>{overflowTypes.length}</span>
{(moreActive || moreOpen) && <span aria-hidden="true" className="absolute inset-x-0 -bottom-px h-px bg-[#1a1c1d]" />}
</button>
{moreOpen && (
<div role="listbox" className="absolute right-0 top-full z-40 mt-2 max-h-[320px] min-w-[240px] overflow-y-auto border border-[#e7e8ea] bg-white py-1">
{overflowTypes.map((type) => {
const active = selectedMaterialType === type.value
return (
<button
key={type.value}
role="option"
aria-selected={active}
onClick={() => setSelectedMaterialType(active ? '' : type.value)}
className={`flex w-full items-baseline justify-between gap-6 px-4 py-2.5 text-left text-[14px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 ${active ? 'text-[#1a1c1d]' : 'text-[#6b7075] hover:bg-[#fafafa] hover:text-[#1a1c1d]'}`}
>
<span>{type.label}</span>
<span className={`font-en text-[13px] tabular-nums ${countMuted(active)}`}>{countText(type.count || 0)}</span>
</button>
)
})}
</div>
)}
</div>
)}
{facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => (
<span key={item} className="mb-2 h-4 w-16 bg-[#eceef0]" aria-hidden="true" />
))}
</div>
<div aria-hidden="true" className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden">
<div ref={measureRef} className="flex w-max items-end gap-x-8 whitespace-nowrap">
<span className={tabBase}>全部<span className="ml-2 font-en text-[14px] tabular-nums">{countText(totals.materialType)}</span></span>
{rankedTypes.map((type) => (
<span key={type.value} className={tabBase}>{type.label}<span className="ml-2 font-en text-[14px] tabular-nums">{countText(type.count || 0)}</span></span>
))}
<span className={tabBase}>更多<span className="ml-2 font-en text-[14px] tabular-nums">00</span></span>
</div>
</div>
</div>
<button
onClick={() => setIsFilterCollapsed(!isFilterCollapsed)}
aria-label={isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
className="mb-2 shrink-0 whitespace-nowrap bg-[#fafafa] pl-4 text-[13px] tracking-[-0.02em] text-[#6b7075] hover:text-[#1a1c1d] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
>
{isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1.5 font-en text-[11px] text-[#5a66b8]">{activeFilterCount}</span>}
</button>
</div> </div>
</div> </div>
<div className="mb-4 border-t border-gray-200 pt-4"> <div className="mb-8 flex flex-wrap items-baseline justify-between gap-3">
<button onClick={() => setIsFilterCollapsed(!isFilterCollapsed)} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"> <p className="text-[13px] text-[#86898e]">
<svg className={`w-4 h-4 transition-transform duration-200 ${isFilterCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg> 共 <span className="font-en tabular-nums text-[#6b7075]">{facetsLoading ? '…' : materialsCount.toLocaleString('zh-CN')}</span> 条
{isFilterCollapsed ? '展开更多筛选' : '收起筛选'} {selectedMaterialType && <span className="sr-only">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded text-xs">{activeFilterCount}</span>} </p>
</button> <div className="flex items-center gap-4 text-[13px]" role="group" aria-labelledby="materials-sort-label">
<h2 id="materials-sort-label" className="sr-only">排序方式</h2>
<button onClick={() => handleSortChange('createdAt')} className={`flex items-center gap-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${sortBy === 'createdAt' ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'}`}>
{sortBy === 'createdAt' && sortOrder === 'asc' ? '最早上传' : '最新上传'}
{sortBy === 'createdAt' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
</button>
<button onClick={() => handleSortChange('downloadCount')} className={`flex items-center gap-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${sortBy === 'downloadCount' ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'}`}>
{sortBy === 'downloadCount' && sortOrder === 'asc' ? '下载最少' : '下载最多'}
{sortBy === 'downloadCount' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
</button>
</div>
</div> </div>
<div className={`overflow-hidden transition-all duration-300 ${isFilterCollapsed ? 'max-h-0 opacity-0' : 'max-h-[2000px] opacity-100'}`}> <div className={`overflow-hidden transition-all duration-300 ${isFilterCollapsed ? 'max-h-0 opacity-0' : 'max-h-[2000px] opacity-100'}`}>
<div className="mb-4"> <div className="mb-6">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">使用店铺</h2>{selectedCategory && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div> <h2 className="mb-3 text-[13px] text-[#6b7075]">使用店铺</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedCategory('')} className={`${btnBase} ${selectedCategory === '' ? btnActive : btnInactive}`} style={selectedCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedCategory === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.category)})</span></button> <button onClick={() => setSelectedCategory('')} className={`${chipBase} ${selectedCategory === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedCategory === '')}`}>{countText(totals.category)}</span></button>
{categories.map((c) => <button key={c.id} onClick={() => setSelectedCategory(c.name === selectedCategory ? '' : c.name)} className={`${btnBase} ${selectedCategory === c.name ? btnActive : btnInactive}`} style={selectedCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedCategory === c.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(c.count || 0)})</span></button>)} {rankedCategories.map((item) => <button key={item.id} onClick={() => setSelectedCategory(item.name === selectedCategory ? '' : item.name)} className={`${chipBase} ${selectedCategory === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedCategory === item.name)}`}>{countText(item.count || 0)}</span></button>)}
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-6">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">场景类别</h2>{selectedScene && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div> <h2 className="mb-3 text-[13px] text-[#6b7075]">场景类别</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedScene('')} className={`${btnBase} ${selectedScene === '' ? btnActive : btnInactive}`} style={selectedScene === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedScene === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.scene)})</span></button> <button onClick={() => setSelectedScene('')} className={`${chipBase} ${selectedScene === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedScene === '')}`}>{countText(totals.scene)}</span></button>
{scenes.map((s) => <button key={s.id} onClick={() => setSelectedScene(s.name === selectedScene ? '' : s.name)} className={`${btnBase} ${selectedScene === s.name ? btnActive : btnInactive}`} style={selectedScene === s.name ? { backgroundColor: themeColor } : {}}>{s.name}<span className={`ml-1.5 text-xs ${selectedScene === s.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(s.count || 0)})</span></button>)} {rankedScenes.map((item) => <button key={item.id} onClick={() => setSelectedScene(item.name === selectedScene ? '' : item.name)} className={`${chipBase} ${selectedScene === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedScene === item.name)}`}>{countText(item.count || 0)}</span></button>)}
</div> </div>
</div> </div>
{(platforms.length > 0 || facetsLoading) && <div className="mb-4"> {(rankedPlatforms.length > 0 || facetsLoading) && <div className="mb-6">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">平台分类</h2>{selectedPlatform && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div> <h2 className="mb-3 text-[13px] text-[#6b7075]">平台分类</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedPlatform('')} className={`${btnBase} ${selectedPlatform === '' ? btnActive : btnInactive}`} style={selectedPlatform === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedPlatform === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.platform)})</span></button> <button onClick={() => setSelectedPlatform('')} className={`${chipBase} ${selectedPlatform === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedPlatform === '')}`}>{countText(totals.platform)}</span></button>
{platforms.map((p) => <button key={p.id} onClick={() => setSelectedPlatform(p.name === selectedPlatform ? '' : p.name)} className={`${btnBase} ${selectedPlatform === p.name ? btnActive : btnInactive}`} style={selectedPlatform === p.name ? { backgroundColor: themeColor } : {}}>{p.name}<span className={`ml-1.5 text-xs ${selectedPlatform === p.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(p.count || 0)})</span></button>)} {rankedPlatforms.map((item) => <button key={item.id} onClick={() => setSelectedPlatform(item.name === selectedPlatform ? '' : item.name)} className={`${chipBase} ${selectedPlatform === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedPlatform === item.name)}`}>{countText(item.count || 0)}</span></button>)}
</div> </div>
</div>} </div>}
{(productCategories.length > 0 || facetsLoading) && <div className="mb-4"> {(rankedProductCategories.length > 0 || facetsLoading) && <div className="mb-6">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">产品类别</h2>{selectedProductCategory && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div> <h2 className="mb-3 text-[13px] text-[#6b7075]">产品类别</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedProductCategory('')} className={`${btnBase} ${selectedProductCategory === '' ? btnActive : btnInactive}`} style={selectedProductCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedProductCategory === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.productCategory)})</span></button> <button onClick={() => setSelectedProductCategory('')} className={`${chipBase} ${selectedProductCategory === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedProductCategory === '')}`}>{countText(totals.productCategory)}</span></button>
{productCategories.map((c) => <button key={c.id} onClick={() => setSelectedProductCategory(c.name === selectedProductCategory ? '' : c.name)} className={`${btnBase} ${selectedProductCategory === c.name ? btnActive : btnInactive}`} style={selectedProductCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedProductCategory === c.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(c.count || 0)})</span></button>)} {rankedProductCategories.map((item) => <button key={item.id} onClick={() => setSelectedProductCategory(item.name === selectedProductCategory ? '' : item.name)} className={`${chipBase} ${selectedProductCategory === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedProductCategory === item.name)}`}>{countText(item.count || 0)}</span></button>)}
</div> </div>
</div>} </div>}
<div className="mb-4"> <div className="mb-6">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">上传月份</h2></div> <h2 className="mb-3 text-[13px] text-[#6b7075]">上传时间</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedMonth('')} className={`${btnBase} ${selectedMonth === '' ? btnActive : btnInactive}`} style={selectedMonth === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMonth === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.month)})</span></button> <button onClick={() => setSelectedMonth('')} className={`${chipBase} ${selectedMonth === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedMonth === '')}`}>{countText(totals.month)}</span></button>
{months.map((m) => <button key={m.value} onClick={() => setSelectedMonth(m.value === selectedMonth ? '' : m.value)} className={`${btnBase} ${selectedMonth === m.value ? btnActive : btnInactive}`} style={selectedMonth === m.value ? { backgroundColor: themeColor } : {}}>{m.label}<span className={`ml-1.5 text-xs ${selectedMonth === m.value ? 'text-gray-300' : 'text-gray-500'}`}>({countText(m.count)})</span></button>)} {yearFacets.map((item) => (
<button
key={item.year}
onClick={() => setSelectedMonth(selectedMonth === item.year ? '' : item.year)}
className={`${chipBase} ${selectedMonth === item.year || selectedMonth.startsWith(`${item.year}-`) ? chipActive : chipInactive}`}
>
{item.year}
<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedMonth === item.year || selectedMonth.startsWith(`${item.year}-`))}`}>{countText(item.count)}</span>
</button>
))}
</div> </div>
{monthsInFocusedYear.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-2">
{monthsInFocusedYear.map((item) => {
const monthNumber = Number.parseInt(item.value.slice(5), 10)
return (
<button
key={item.value}
onClick={() => setSelectedMonth(item.value === selectedMonth ? '' : item.value)}
className={`${chipBase} ${selectedMonth === item.value ? chipActive : chipInactive}`}
>
{monthNumber}月
<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedMonth === item.value)}`}>{countText(item.count)}</span>
</button>
)
})}
</div>
)}
</div> </div>
<div className="mb-4"> <p className="mb-6 text-xs text-[#86898e]">选了一个条件后,其它条件只保留还有结果的选项,数量会跟着变。</p>
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">排序方式</h2></div>
<div className="flex flex-wrap gap-2">
<button onClick={() => handleSortChange('createdAt')} className={`${btnBase} flex items-center gap-1 ${sortBy === 'createdAt' ? btnActive : btnInactive}`} style={sortBy === 'createdAt' ? { backgroundColor: themeColor } : {}}>
{sortBy === 'createdAt' && sortOrder === 'asc' ? '最早上传' : '最新上传'}
{sortBy === 'createdAt' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
</button>
<button onClick={() => handleSortChange('downloadCount')} className={`${btnBase} flex items-center gap-1 ${sortBy === 'downloadCount' ? btnActive : btnInactive}`} style={sortBy === 'downloadCount' ? { backgroundColor: themeColor } : {}}>
{sortBy === 'downloadCount' && sortOrder === 'asc' ? '下载最少' : '下载最多'}
{sortBy === 'downloadCount' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
</button>
</div>
<div className="text-xs text-gray-500 mt-2">可以组合使用多个筛选条件,找到更精确的素材</div>
</div>
</div> </div>
</> </>
) )

View File

@ -59,7 +59,7 @@ const LoadMoreTrigger = forwardRef<HTMLDivElement, LoadMoreTriggerProps>(functio
className="w-full h-20 flex items-center justify-center mt-8" className="w-full h-20 flex items-center justify-center mt-8"
> >
{isLoading ? ( {isLoading ? (
<div className="text-gray-500 dark:text-gray-400 text-sm flex items-center gap-2"> <div className="text-[#6b7075] text-sm flex items-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24"> <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
@ -67,7 +67,7 @@ const LoadMoreTrigger = forwardRef<HTMLDivElement, LoadMoreTriggerProps>(functio
加载中... 加载中...
</div> </div>
) : ( ) : (
<p className="text-gray-400 dark:text-gray-500 text-sm"> <p className="text-[#86898e] text-sm">
向下滚动加载更多 向下滚动加载更多
</p> </p>
)} )}
@ -78,7 +78,7 @@ const LoadMoreTrigger = forwardRef<HTMLDivElement, LoadMoreTriggerProps>(functio
// 显示到底提示 // 显示到底提示
if (showEndMessage && !hasMore && (displayedCount === undefined || displayedCount >= (totalCount || 0))) { if (showEndMessage && !hasMore && (displayedCount === undefined || displayedCount >= (totalCount || 0))) {
return ( return (
<div className="w-full py-6 text-center text-sm text-gray-500 dark:text-gray-400 mt-8"> <div className="w-full py-6 text-center text-sm text-[#86898e] mt-8">
已经到底啦~ 已经到底啦~
</div> </div>
) )

View File

@ -4,7 +4,7 @@ import React, { useRef, useEffect, useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { format } from 'date-fns' import { format } from 'date-fns'
import AudioVisualizer from '../AudioVisualizer' import AudioVisualizer from '../AudioVisualizer'
import { getTaskDetailSourceTypeLabel, normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type' import { getTaskDetailSourceTypeLabel } from '@/lib/task-detail-source-type'
import { getCertificateReportDisplayTitle } from '@/lib/certificate-report' import { getCertificateReportDisplayTitle } from '@/lib/certificate-report'
import { isVideoMaterialType } from '@/lib/video-material-types' import { isVideoMaterialType } from '@/lib/video-material-types'
import { import {
@ -92,49 +92,38 @@ interface MaterialCardProps {
showToast: (message: string, options?: { variant?: 'success' | 'error' }) => void showToast: (message: string, options?: { variant?: 'success' | 'error' }) => void
} }
// 根据素材类型获取角标背景颜色 const OVERLAY_CHIP_CLASS = 'bg-white/92 text-[#1a1c1d] border border-[#e7e8ea] px-2 py-1 rounded-[8px] text-xs font-medium'
const getMaterialTypeBadgeColor = (materialType: string): string => {
const isVideoType = isVideoMaterialType(materialType)
if (materialType === '3D场景' || materialType === '3DMAX') {
return 'bg-red-500'
} else if (isVideoType) {
return 'bg-blue-600'
} else if (materialType === '音乐') {
return 'bg-purple-600'
} else if (materialType === 'C4D模型') {
return 'bg-blue-500'
} else if (materialType === '详情模板' || materialType === '矢量文件') {
return 'bg-amber-500'
} else {
return 'bg-gray-700'
}
}
// 格式化文件大小 function formatFileSize(bytes: string | number): string {
const formatFileSize = (bytes: string | number): string => { const size = typeof bytes === 'string' ? Number.parseInt(bytes, 10) : bytes
const size = typeof bytes === 'string' ? parseInt(bytes) : bytes if (!size || Number.isNaN(size) || size <= 0) return ''
if (size === 0) return '未知'
const units = ['B', 'KB', 'MB', 'GB'] const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(size) / Math.log(1024)) const unitIndex = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1)
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}` return `${(size / Math.pow(1024, unitIndex)).toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`
} }
const getTaskDetailSourceBadgeClassName = (value?: string | null): string => { function DownloadGlyph() {
const normalized = normalizeTaskDetailSourceType(value) return (
if (normalized === '实拍') { <svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300' <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 4v11m0 0l-4-4m4 4l4-4M5 19h14" />
} </svg>
)
}
if (normalized === '渲染') { function ShareGlyph() {
return 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300' return (
} <svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M7 11l5-6 5 6M12 5v10M6 19h12" />
</svg>
)
}
if (normalized === 'AI') { function HeartGlyph({ filled }: { filled: boolean }) {
return 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300' return (
} <svg className="h-3.5 w-3.5" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 20s-7-4.4-7-9.2C5 8 6.8 6.4 9 6.4c1.3 0 2.4.6 3 1.6.6-1 1.7-1.6 3-1.6 2.2 0 4 1.6 4 4.4 0 4.8-7 9.2-7 9.2z" />
return 'bg-slate-100 dark:bg-slate-700/60 text-slate-700 dark:text-slate-300' </svg>
)
} }
export default function MaterialCard({ export default function MaterialCard({
@ -148,7 +137,6 @@ export default function MaterialCard({
audioRef, audioRef,
downloadingId, downloadingId,
downloadProgress, downloadProgress,
themeColor,
onAudioPlay, onAudioPlay,
onDownload, onDownload,
onLike, onLike,
@ -306,69 +294,80 @@ export default function MaterialCard({
progressValue = Math.max(28, Math.min(35 + 55 * Math.min(0.95, 0) + (attempts > 1 ? 5 : 0), 92)) progressValue = Math.max(28, Math.min(35 + 55 * Math.min(0.95, 0) + (attempts > 1 ? 5 : 0), 92))
label = `转码中${stageName ? ` · ${stageName}` : ''}${attempts > 1 ? ` · 第${attempts}次` : ''}${waitingCount > 0 ? ` · 队列余${waitingCount}` : ''} · ${processingCount}/${maxConcurrent}并发` label = `转码中${stageName ? ` · ${stageName}` : ''}${attempts > 1 ? ` · 第${attempts}次` : ''}${waitingCount > 0 ? ` · 队列余${waitingCount}` : ''} · ${processingCount}/${maxConcurrent}并发`
} }
bgColor = 'bg-blue-600' bgColor = 'bg-[#1a1c1d]'
} else if (status === 'processing') { } else if (status === 'processing') {
label = `转码中${attempts > 1 ? ` · 第${attempts}次` : ''}` label = `转码中${attempts > 1 ? ` · 第${attempts}次` : ''}`
progressValue = 15 progressValue = 15
bgColor = 'bg-blue-600' bgColor = 'bg-[#1a1c1d]'
} else if (status === 'pending') { } else if (status === 'pending') {
label = `等待中${attempts > 1 ? ` · 第${attempts}次` : ''}` label = `等待中${attempts > 1 ? ` · 第${attempts}次` : ''}`
progressValue = 5 progressValue = 5
bgColor = 'bg-gray-600' bgColor = 'bg-[#86898e]'
} else if (queueIndex !== null && queueIndex !== undefined) { } else if (queueIndex !== null && queueIndex !== undefined) {
const processingCount = processingIds.length const processingCount = processingIds.length
label = `排队中 · ${queueIndex}/${queueTotal} · ${processingCount}/${maxConcurrent}执行中` label = `排队中 · ${queueIndex}/${queueTotal} · ${processingCount}/${maxConcurrent}执行中`
progressValue = 8 progressValue = 8
bgColor = 'bg-amber-600' bgColor = 'bg-[#6e79c8]'
} else { } else {
label = status || '未知状态' label = status || '未知状态'
progressValue = 12 progressValue = 12
bgColor = 'bg-gray-600' bgColor = 'bg-[#86898e]'
} }
return ( return (
<div <>
className="absolute bottom-0 left-0 right-0 z-40 bg-black/85 p-2 text-xs text-white" {status !== 'failed' && (
role="status" <div className="pointer-events-none absolute inset-x-0 bottom-10 z-30 flex justify-center">
aria-live={status === 'failed' ? 'assertive' : 'polite'} <span className="rounded-[8px] border border-[#e7e8ea] bg-white px-3 py-1.5 text-[12px] text-[#1a1c1d]">
aria-atomic="true" {label}
> </span>
<div className="flex items-center justify-between mb-1"> </div>
<span className="truncate">{label}</span> )}
{status === 'failed' && !material.videoPreviewClipUrl && !material.videoPreview540Url && (
<button
onClick={(e) => {
e.stopPropagation()
onRetryTranscode(material.id)
}}
className="ml-2 bg-white/90 text-gray-900 px-2 py-0.5 rounded text-xs hover:bg-white transition-colors"
>
重试转码
</button>
)}
</div>
<div <div
className="h-1.5 w-full overflow-hidden rounded-full bg-gray-700" className="absolute bottom-0 left-0 right-0 z-40 border-t border-[#e7e8ea] bg-white/95 p-2 text-xs text-[#1a1c1d]"
role="progressbar" role="status"
aria-label={label} aria-live={status === 'failed' ? 'assertive' : 'polite'}
aria-valuemin={0} aria-atomic="true"
aria-valuemax={100}
aria-valuenow={progressValue}
> >
<div className="flex items-center justify-between mb-1">
<span className="truncate">{label}</span>
{status === 'failed' && !material.videoPreviewClipUrl && !material.videoPreview540Url && (
<button
onClick={(e) => {
e.stopPropagation()
onRetryTranscode(material.id)
}}
className="ml-2 bg-[#1a1c1d] text-white px-2 py-0.5 rounded-[8px] text-xs hover:bg-[#2a2d2f] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
>
重试转码
</button>
)}
</div>
<div <div
className={`h-full transition-all duration-300 ${bgColor}`} className="h-1.5 w-full overflow-hidden rounded-[8px] bg-[#e7e8ea]"
style={{ width: `${progressValue}%` }} role="progressbar"
/> aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progressValue}
>
<div
className={`h-full transition-all duration-300 ${bgColor}`}
style={{ width: `${progressValue}%` }}
/>
</div>
</div> </div>
</div> </>
) )
} }
const fileSizeLabel = formatFileSize(material.zipFileSize)
return ( return (
<div className="card-apple overflow-hidden dark:bg-gray-800 dark:border-gray-700"> <article data-card className="bg-transparent">
{/* Preview Image/Video */} {/* Preview Image/Video */}
<div <div
className="relative w-full h-[400px] bg-gray-200 dark:bg-gray-700 cursor-pointer group overflow-hidden" className="relative w-full aspect-[4/3] cursor-pointer overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-[#f3f4f5] group"
data-material-link="true" data-material-link="true"
onMouseEnter={() => isVideoType && videoPreviewUrl && handleVideoMouseEnter()} onMouseEnter={() => isVideoType && videoPreviewUrl && handleVideoMouseEnter()}
onMouseLeave={() => isVideoType && videoPreviewUrl && handleVideoMouseLeave()} onMouseLeave={() => isVideoType && videoPreviewUrl && handleVideoMouseLeave()}
@ -381,7 +380,7 @@ export default function MaterialCard({
ref={videoRef} ref={videoRef}
src={videoPreviewUrl} src={videoPreviewUrl}
poster={!posterLoadError && posterUrl ? posterUrl : undefined} poster={!posterLoadError && posterUrl ? posterUrl : undefined}
className="w-full h-full object-cover transition-transform group-hover:scale-105" className="h-full w-full object-cover"
muted muted
loop loop
playsInline playsInline
@ -399,8 +398,8 @@ export default function MaterialCard({
/> />
{/* 视频封面加载失败时的占位符 */} {/* 视频封面加载失败时的占位符 */}
{posterLoadError && ( {posterLoadError && (
<div className="absolute inset-0 w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600 z-10"> <div className="absolute inset-0 w-full h-full flex items-center justify-center bg-[#eceef0] z-10">
<span className="text-gray-500 dark:text-gray-400 text-sm">封面加载失败</span> <span className="text-[#86898e] text-sm">封面加载失败</span>
</div> </div>
)} )}
</> </>
@ -475,7 +474,7 @@ export default function MaterialCard({
)} )}
{/* 时长显示 */} {/* 时长显示 */}
{playingAudioId === material.id && audioDuration > 0 && ( {playingAudioId === material.id && audioDuration > 0 && (
<div className="absolute bottom-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium z-20"> <div className={`absolute bottom-2 left-2 z-20 ${OVERLAY_CHIP_CLASS}`}>
{Math.floor(audioCurrentTime / 60)}:{Math.floor(audioCurrentTime % 60).toString().padStart(2, '0')} / {Math.floor(audioDuration / 60)}:{Math.floor(audioDuration % 60).toString().padStart(2, '0')} {Math.floor(audioCurrentTime / 60)}:{Math.floor(audioCurrentTime % 60).toString().padStart(2, '0')} / {Math.floor(audioDuration / 60)}:{Math.floor(audioDuration % 60).toString().padStart(2, '0')}
</div> </div>
)} )}
@ -530,7 +529,7 @@ export default function MaterialCard({
alt={material.sceneName} alt={material.sceneName}
fill fill
unoptimized unoptimized
className="object-cover transition-transform group-hover:scale-105" className="object-contain"
sizes="(max-width: 768px) 100vw, 300px" sizes="(max-width: 768px) 100vw, 300px"
onError={(e) => { onError={(e) => {
// 图片加载失败时,隐藏图片并显示占位符 // 图片加载失败时,隐藏图片并显示占位符
@ -553,13 +552,13 @@ export default function MaterialCard({
} }
}} }}
/> />
<div className="image-placeholder hidden w-full h-full items-center justify-center bg-gray-300 dark:bg-gray-600"> <div className="image-placeholder hidden w-full h-full items-center justify-center bg-[#eceef0]">
<span className="text-gray-500 dark:text-gray-400 text-sm">图片加载失败</span> <span className="text-[#86898e] text-sm">图片加载失败</span>
</div> </div>
</> </>
) : ( ) : (
<div className="w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600 audio-placeholder"> <div className="w-full h-full flex items-center justify-center bg-[#eceef0] audio-placeholder">
<span className="text-gray-500 dark:text-gray-400"> <span className="text-[#86898e]">
{material.videoProcessingStatus === 'failed' {material.videoProcessingStatus === 'failed'
? '预览生成失败' ? '预览生成失败'
: material.videoProcessingStatus === 'pending' || material.videoProcessingStatus === 'processing' : material.videoProcessingStatus === 'pending' || material.videoProcessingStatus === 'processing'
@ -569,210 +568,50 @@ export default function MaterialCard({
</div> </div>
)} )}
{/* SPU标签 - 左上角 */}
<div className="absolute top-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium z-10">
{materialDisplayTitle}
</div>
{/* 素材类型角标 - 右上角 */}
<div className={`absolute top-2 right-2 text-white px-2 py-1 rounded text-xs font-medium z-10 ${getMaterialTypeBadgeColor(material.materialType)}`}>
{material.materialType}
</div>
{/* 放大提示 */}
{!isVideoType && material.materialType !== '音乐' && (
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-all flex items-center justify-center">
<svg className="w-12 h-12 text-white opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7" />
</svg>
</div>
)}
{/* 转码进度显示 */} {/* 转码进度显示 */}
{renderTranscodeProgress()} {renderTranscodeProgress()}
{/* 视频时长角标 */} {/* 视频时长角标 */}
{isVideoType && material.videoDuration && ( {isVideoType && material.videoDuration && (
<div className="absolute bottom-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium z-10"> <div className={`absolute bottom-2 left-2 z-10 ${OVERLAY_CHIP_CLASS}`}>
{Math.floor(material.videoDuration / 60)}:{(material.videoDuration % 60).toString().padStart(2, '0')} {Math.floor(material.videoDuration / 60)}:{(material.videoDuration % 60).toString().padStart(2, '0')}
</div> </div>
)} )}
{isPreviewWaiting && ( {isPreviewWaiting && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/55 px-4 text-center"> <div className="absolute inset-0 z-30 flex items-center justify-center bg-[#fafafa]/55 px-4 text-center">
<div className="rounded bg-white/95 px-3 py-2 text-xs font-medium text-gray-900 shadow"> <div className="rounded-[8px] border border-[#e7e8ea] bg-white px-3 py-2 text-xs font-medium text-[#1a1c1d]">
{material.videoPreviewRemainingDays || videoDownloadAvailability.remainingDays || VIDEO_DOWNLOAD_WAIT_DAYS}天后可查看详情 {material.videoPreviewRemainingDays || videoDownloadAvailability.remainingDays || VIDEO_DOWNLOAD_WAIT_DAYS}天后可查看详情
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Info */} <div className="pt-4">
<div className="p-4"> <h3 className="truncate font-display text-[16px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">
{/* 标签 */} {materialDisplayTitle}
<div className="flex items-center gap-2 mb-3 flex-wrap"> </h3>
{material.categoryName && ( <p className="mt-1.5 truncate text-[12px] leading-relaxed text-[#86898e]">
<span className="inline-flex items-center px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs font-medium"> {fileSizeLabel && <><span>{fileSizeLabel}</span> · </>}
{material.categoryName} {material.authorName && <><span>{material.authorName}</span> · </>}
</span> <span>{format(new Date(material.createdAt), 'yyyy.MM.dd')}</span>
)} {material.materialType && <> · <span>{material.materialType}</span></>}
{material.sceneName && ( {material.categoryName && <> · <span>{material.categoryName}</span></>}
<span className="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-xs font-medium"> {material.sceneName && <> · <span>{material.sceneName}</span></>}
{material.sceneName} {isTaskDetailMaterial && <> · <span>{getTaskDetailSourceTypeLabel(material.taskDetailSourceType)}</span></>}
</span> {!isTaskDetailMaterial && material.isAiGenerated && <> · <span>AI生成</span></>}
)} {material.materialType === '3D场景' && material.renderer && <> · <span>{material.renderer}</span></>}
{material.materialType && ( </p>
<span className="inline-flex items-center px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded text-xs font-medium"> <p
{material.materialType} className="mt-1 cursor-pointer truncate font-en text-[11px] tracking-[0.04em] text-[#86898e] hover:text-[#1a1c1d]"
</span> onClick={handleCopyId}
)} title="点击复制完整ID"
{isTaskDetailMaterial && ( >
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${getTaskDetailSourceBadgeClassName(material.taskDetailSourceType)}`}> ID {material.id}
{getTaskDetailSourceTypeLabel(material.taskDetailSourceType)} </p>
</span>
)}
{!isTaskDetailMaterial && material.isAiGenerated && (
<span className="inline-flex items-center px-2 py-1 bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300 rounded text-xs font-medium">
AI生成
</span>
)}
{material.materialType === '3D场景' && material.renderer && (
<span className="inline-flex items-center px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs font-medium">
{material.renderer}
</span>
)}
</div>
{/* 作者和时间 */} <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]">
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400 mb-3">
<p className="truncate">
<span className="font-medium">作者:</span>
{material.authorName}
</p>
<p
className="truncate text-xs cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
onClick={handleCopyId}
title="点击复制完整ID"
>
<span className="font-medium">ID:</span>
<span className="text-blue-600 dark:text-blue-400">{material.id}</span>
</p>
<p className="text-xs">
<span className="font-medium">大小:</span>
<span className="text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</span>
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
{/* 统计数据 */}
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-3 pb-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{material.downloadCount}
</span>
<button
onClick={(e) => {
e.stopPropagation()
onLike(material.id)
}}
className={`flex items-center gap-1 transition-colors ${
material.isLiked
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400'
}`}
title={material.isLiked ? '取消点赞' : '点赞'}
>
<svg
className="w-4 h-4"
fill={material.isLiked ? 'currentColor' : 'none'}
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={material.isLiked ? 0 : 2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{material.likeCount}
</button>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2 items-center">
{/* 编辑按钮 */}
{session?.user && (
material.userId === session.user.id || session.user.role === 'ADMIN'
) && (
<button
onClick={() => onEdit(material)}
className="flex-none h-10 w-10 flex items-center justify-center rounded-xl border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-all"
title="编辑"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)}
{/* 删除按钮 */}
{session?.user && session.user.role === 'ADMIN' && (
<button
onClick={(e) => {
e.stopPropagation()
onDelete(material.id)
}}
className="flex-none h-10 w-10 flex items-center justify-center rounded-xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/50 transition-all"
title="删除"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
{/* 收藏按钮 */}
{session?.user && (
<button
onClick={(e) => {
e.stopPropagation()
onFavorite(material.id)
}}
className={`flex-none h-10 w-10 flex items-center justify-center rounded-xl border transition-all ${
material.isFavorited
? 'border-pink-500 bg-pink-500 text-white hover:bg-pink-600'
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
title={material.isFavorited ? '取消收藏' : '收藏'}
>
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill={material.isFavorited ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={material.isFavorited ? 0 : 2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 4h12a1 1 0 011 1v15.382a.5.5 0 01-.777.416L12 17.5l-6.223 3.298A.5.5 0 015 20.382V5a1 1 0 011-1z"
/>
</svg>
</button>
)}
{/* 分享按钮 */}
<button <button
type="button" type="button"
onClick={() => onShare(material.id)}
aria-label="分享"
className="flex-none h-10 w-10 flex items-center justify-center rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
title="分享"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
{/* 下载按钮 */}
<button
onClick={() => { onClick={() => {
if (isDownloadWaiting) { if (isDownloadWaiting) {
showToast(getVideoDownloadUnavailableMessage(videoDownloadAvailability), { variant: 'error' }) showToast(getVideoDownloadUnavailableMessage(videoDownloadAvailability), { variant: 'error' })
@ -782,40 +621,89 @@ export default function MaterialCard({
}} }}
disabled={isDownloadDisabled} disabled={isDownloadDisabled}
aria-disabled={isDownloadWaiting} aria-disabled={isDownloadWaiting}
className={`flex-1 h-10 btn-primary text-sm font-medium rounded-xl transition-all disabled:opacity-50 flex items-center justify-center relative overflow-hidden whitespace-nowrap ${ className={`inline-flex items-center gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${
isDownloadWaiting ? 'opacity-50 cursor-not-allowed' : '' isDownloadWaiting || isDownloadDisabled ? 'cursor-not-allowed text-[#86898e]' : 'text-[#1a1c1d] hover:text-[#5a66b8]'
}`} }`}
style={{ backgroundColor: themeColor }}
title={ title={
videoDownloadAvailability.allowed videoDownloadAvailability.allowed
? '下载' ? '下载'
: getVideoDownloadUnavailableMessage(videoDownloadAvailability) : getVideoDownloadUnavailableMessage(videoDownloadAvailability)
} }
> >
{downloadingId === material.id ? ( <DownloadGlyph />
<> {downloadingId === material.id
<div ? `${downloadProgress}%`
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300" : isDownloadWaiting
style={{ width: `${downloadProgress}%` }} ? `${videoDownloadAvailability.remainingDays || VIDEO_DOWNLOAD_WAIT_DAYS}天后可下载`
/> : '下载'}
<span className="relative z-10 flex items-center justify-center gap-2"> {downloadingId !== material.id && !isDownloadWaiting && material.downloadCount > 0 && (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"> <span className="font-en text-[11px] tabular-nums text-[#86898e]">{material.downloadCount}</span>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{downloadProgress}%
</span>
</>
) : (
<span className="whitespace-nowrap">
{isDownloadWaiting
? `${videoDownloadAvailability.remainingDays || VIDEO_DOWNLOAD_WAIT_DAYS}天后可下载`
: '下载'}
</span>
)} )}
</button> </button>
<button
type="button"
onClick={() => onShare(material.id)}
aria-label="分享"
className="inline-flex items-center gap-1.5 text-[#1a1c1d] transition-colors hover:text-[#5a66b8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
title="分享"
>
<ShareGlyph />
分享
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onLike(material.id)
}}
className={`inline-flex items-center gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${
material.isLiked ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'
}`}
title={material.isLiked ? '取消点赞' : '点赞'}
>
点赞 <span>{material.likeCount}</span>
</button>
{session?.user && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onFavorite(material.id)
}}
className={`inline-flex items-center gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${
material.isFavorited ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'
}`}
title={material.isFavorited ? '取消收藏' : '收藏'}
>
<HeartGlyph filled={material.isFavorited} />
{material.isFavorited ? '已收藏' : '收藏'}
</button>
)}
{session?.user && (material.userId === session.user.id || session.user.role === 'ADMIN') && (
<button
type="button"
onClick={() => onEdit(material)}
className="text-[#86898e] transition-colors hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
title="编辑"
>
编辑
</button>
)}
{session?.user && session.user.role === 'ADMIN' && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(material.id)
}}
className="text-[#86898e] transition-colors hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
title="删除"
>
删除
</button>
)}
</div> </div>
</div> </div>
</div> </article>
) )
} }

View File

@ -47,17 +47,18 @@ interface MaterialGridProps {
function MaterialGridSkeleton() { function MaterialGridSkeleton() {
return ( return (
<div <div
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" className="grid grid-cols-1 gap-x-8 gap-y-12 sm:grid-cols-2 lg:grid-cols-3"
aria-busy="true" aria-busy="true"
aria-live="polite" aria-live="polite"
aria-label="素材列表加载中" aria-label="素材列表加载中"
> >
<span className="sr-only">加载中...</span>
{Array.from({ length: 8 }, (_, index) => ( {Array.from({ length: 8 }, (_, index) => (
<article <article
key={index} key={index}
className="overflow-hidden rounded-[14px] border border-[#e7e8ea] bg-white" className="overflow-hidden"
> >
<div className="h-[400px] animate-pulse bg-[#eceef0]" /> <div className="aspect-[4/3] animate-pulse rounded-[12px] bg-[#eceef0]" />
<div className="space-y-3 px-4 py-4"> <div className="space-y-3 px-4 py-4">
<div className="h-4 w-3/4 animate-pulse bg-[#eceef0]" /> <div className="h-4 w-3/4 animate-pulse bg-[#eceef0]" />
<div className="h-3 w-1/2 animate-pulse bg-[#f2f3f5]" /> <div className="h-3 w-1/2 animate-pulse bg-[#f2f3f5]" />
@ -104,7 +105,7 @@ export default function MaterialGrid({
<div className="relative"> <div className="relative">
{/* 过渡加载遮罩 */} {/* 过渡加载遮罩 */}
{isTransitionLoading && ( {isTransitionLoading && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/60 backdrop-blur-[1px]"> <div className="absolute inset-0 z-20 flex items-center justify-center bg-[#fafafa]/70 backdrop-blur-[1px]">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[#e7e8ea] border-t-[#1a1c1d]" /> <div className="h-6 w-6 animate-spin rounded-full border-2 border-[#e7e8ea] border-t-[#1a1c1d]" />
</div> </div>
)} )}
@ -112,13 +113,13 @@ export default function MaterialGrid({
{loading ? ( {loading ? (
<MaterialGridSkeleton /> <MaterialGridSkeleton />
) : materials.length === 0 ? ( ) : materials.length === 0 ? (
<div className="py-16 text-center"> <div className="border-t border-[#e7e8ea] py-24 text-center">
<p className="text-lg text-[#6b7075]"> <p className="text-[15px] tracking-[-0.02em] text-[#6b7075]">
{hasFilters ? '没有找到匹配的素材' : '还没有素材,快来上传第一个吧!'} {hasFilters ? '没有找到匹配的素材' : '还没有素材,快来上传第一个吧!'}
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-x-8 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{materials.slice(0, displayedCount).map((material) => ( {materials.slice(0, displayedCount).map((material) => (
<MaterialCard <MaterialCard
key={material.id} key={material.id}

View File

@ -196,9 +196,9 @@ function SearchBarInner({
// 渲染建议类型标签 // 渲染建议类型标签
const renderSuggestionType = (type: string) => { const renderSuggestionType = (type: string) => {
switch (type) { switch (type) {
case 'spu': return <span className="text-xs bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded">SPU</span> case 'spu': return <span className="text-xs border border-[#e7e8ea] bg-white text-[#6b7075] px-1.5 py-0.5 rounded-[8px]">SPU</span>
case 'author': return <span className="text-xs bg-green-100 text-green-600 px-1.5 py-0.5 rounded">作者</span> case 'author': return <span className="text-xs border border-[#e7e8ea] bg-white text-[#6b7075] px-1.5 py-0.5 rounded-[8px]">作者</span>
case 'scene': return <span className="text-xs bg-orange-100 text-orange-600 px-1.5 py-0.5 rounded">场景</span> case 'scene': return <span className="text-xs border border-[#e7e8ea] bg-white text-[#6b7075] px-1.5 py-0.5 rounded-[8px]">场景</span>
default: return null default: return null
} }
} }
@ -209,7 +209,7 @@ function SearchBarInner({
const shouldShowDropdown = showDropdown && (showSuggestionsList || showHistoryList) const shouldShowDropdown = showDropdown && (showSuggestionsList || showHistoryList)
return ( return (
<div ref={containerRef} className="relative mb-6 z-20"> <div ref={containerRef} className="relative z-20">
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
@ -221,17 +221,17 @@ function SearchBarInner({
onFocus={() => setShowDropdown(true)} onFocus={() => setShowDropdown(true)}
placeholder={placeholder} placeholder={placeholder}
autoComplete="off" autoComplete="off"
className="w-full pl-10 pr-10 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all shadow-sm" className="h-12 w-full rounded-[8px] border border-[#e7e8ea] bg-white py-3 pl-11 pr-10 font-display text-[15px] tracking-[-0.02em] text-[#1a1c1d] placeholder:text-[#86898e] outline-none transition-colors focus:border-[#1a1c1d] focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
/> />
{/* 搜索图标 / 加载动画 */} {/* 搜索图标 / 加载动画 */}
{isSearching ? ( {isSearching ? (
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5"> <div className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-[#e7e8ea] border-t-[#1a1c1d] rounded-full animate-spin" />
</div> </div>
) : ( ) : (
<svg <svg
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-[#6b7075]"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -249,7 +249,7 @@ function SearchBarInner({
{localValue && ( {localValue && (
<button <button
onClick={handleClear} onClick={handleClear}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[#86898e] hover:text-[#1a1c1d] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-[8px]"
aria-label="清除搜索" aria-label="清除搜索"
type="button" type="button"
> >
@ -266,7 +266,7 @@ function SearchBarInner({
{/* 下拉列表 */} {/* 下拉列表 */}
{shouldShowDropdown && ( {shouldShowDropdown && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden animate-fade-in z-30"> <div className="absolute top-full left-0 right-0 z-30 mt-2 overflow-hidden rounded-[8px] border border-[#e7e8ea] bg-white">
{/* 建议列表 */} {/* 建议列表 */}
{showSuggestionsList && ( {showSuggestionsList && (
@ -275,12 +275,12 @@ function SearchBarInner({
<li key={`${item.type}-${item.value}`}> <li key={`${item.type}-${item.value}`}>
<button <button
onClick={() => handleItemClick(item.value)} onClick={() => handleItemClick(item.value)}
className={`w-full text-left px-4 py-2.5 flex items-center justify-between group transition-colors text-sm text-gray-700 dark:text-gray-200 ${ className={`w-full text-left px-4 py-2.5 flex items-center justify-between group transition-colors text-sm text-[#1a1c1d] ${
index === selectedIndex ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700' index === selectedIndex ? 'bg-[#fafafa]' : 'hover:bg-[#fafafa]'
}`} }`}
> >
<div className="flex items-center gap-2 overflow-hidden"> <div className="flex items-center gap-2 overflow-hidden">
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-[#86898e] flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
<span className="truncate flex-1"> <span className="truncate flex-1">
@ -297,11 +297,11 @@ function SearchBarInner({
{/* 历史记录列表 */} {/* 历史记录列表 */}
{showHistoryList && ( {showHistoryList && (
<> <>
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 dark:bg-gray-700/50 text-xs text-gray-500 dark:text-gray-400"> <div className="flex items-center justify-between px-4 py-2 bg-[#fafafa] text-xs text-[#86898e]">
<span>最近搜索</span> <span>最近搜索</span>
<button <button
onClick={clearHistory} onClick={clearHistory}
className="hover:text-red-500 transition-colors" className="hover:text-[#1a1c1d] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-[8px]"
> >
清空历史 清空历史
</button> </button>
@ -311,19 +311,19 @@ function SearchBarInner({
<li key={item}> <li key={item}>
<button <button
onClick={() => handleItemClick(item)} onClick={() => handleItemClick(item)}
className={`w-full text-left px-4 py-2.5 flex items-center justify-between group transition-colors text-sm text-gray-700 dark:text-gray-200 ${ className={`w-full text-left px-4 py-2.5 flex items-center justify-between group transition-colors text-sm text-[#1a1c1d] ${
index === selectedIndex ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700' index === selectedIndex ? 'bg-[#fafafa]' : 'hover:bg-[#fafafa]'
}`} }`}
> >
<div className="flex items-center gap-2 overflow-hidden"> <div className="flex items-center gap-2 overflow-hidden">
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-[#86898e] flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<span className="truncate">{item}</span> <span className="truncate">{item}</span>
</div> </div>
<div <div
onClick={(e) => deleteHistory(e, item)} onClick={(e) => deleteHistory(e, item)}
className="p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-400 opacity-0 group-hover:opacity-100 transition-all" className="p-1 rounded-[8px] hover:bg-[#e7e8ea] text-[#86898e] opacity-0 group-hover:opacity-100 transition-all"
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />

View File

@ -206,6 +206,34 @@ describe('material search conditions', () => {
})).toBe(false) })).toBe(false)
}) })
test('month=2026 按整年筛选', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
const result = await buildMaterialWhereFromSearchParams(
new URLSearchParams('month=2026'),
{ id: 'super-admin', role: 'ADMIN' },
)
expect(result.where.createdAt).toEqual({
gte: new Date(2026, 0, 1),
lt: new Date(2027, 0, 1),
})
})
test('month=2026-08 仍按单月筛选', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
const result = await buildMaterialWhereFromSearchParams(
new URLSearchParams('month=2026-08'),
{ id: 'super-admin', role: 'ADMIN' },
)
expect(result.where.createdAt).toEqual({
gte: new Date(2026, 7, 1),
lt: new Date(2026, 8, 1),
})
})
test('回收站查询不应套用任务-页面空记录隐藏条件', async () => { test('回收站查询不应套用任务-页面空记录隐藏条件', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true) ;(isSuperAdmin as jest.Mock).mockResolvedValue(true)

View File

@ -292,9 +292,20 @@ export async function buildMaterialSearchOrConditions(search: string): Promise<a
function applyMonthFilter(where: any, month: string): void { function applyMonthFilter(where: any, month: string): void {
const [year, monthNum] = month.split('-') const [year, monthNum] = month.split('-')
const parsedYear = Number.parseInt(year, 10) const parsedYear = Number.parseInt(year, 10)
const parsedMonth = Number.parseInt(monthNum, 10) if (!Number.isFinite(parsedYear) || year.length !== 4) {
return
}
if (!Number.isFinite(parsedYear) || !Number.isFinite(parsedMonth)) { if (!monthNum) {
where.createdAt = {
gte: new Date(parsedYear, 0, 1),
lt: new Date(parsedYear + 1, 0, 1),
}
return
}
const parsedMonth = Number.parseInt(monthNum, 10)
if (!Number.isFinite(parsedMonth) || parsedMonth < 1 || parsedMonth > 12) {
return return
} }

View File

@ -24,7 +24,8 @@ const config: Config = {
}, },
}, },
fontFamily: { fontFamily: {
display: ['"Noto Sans SC"', 'ui-sans-serif', 'system-ui', 'sans-serif'], sans: ['"Space Grotesk"', '"Noto Sans SC"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
display: ['"Space Grotesk"', '"Noto Sans SC"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
en: ['"Space Grotesk"', 'ui-sans-serif', 'system-ui', 'sans-serif'], en: ['"Space Grotesk"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
}, },
borderRadius: { borderRadius: {