feat: 创意资产库桌面改版,全站换上思源与 Space Grotesk
This commit is contained in:
parent
2b5220fdd7
commit
ed7c338537
14
DESIGN.md
14
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`)顶栏已是看板语言;列表、筛选、卡片仍是旧素材库骨架,需按本世界全面升级,而不是只改色板
|
||||
|
||||
@ -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 等级、人员归属(部门路径)、直属主管、在职状态。
|
||||
- 纯展示(只读),不改数据。
|
||||
- 权限:系统管理员看全部;部门负责人看本部门。
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 标签 */}
|
||||
<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>
|
||||
<body className="font-sans antialiased">
|
||||
<body className="antialiased">
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
|
||||
@ -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<HTMLDivElement>(null)
|
||||
const [materials, setMaterials] = useState<Material[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [facetsLoading, setFacetsLoading] = useState(true)
|
||||
@ -170,7 +178,7 @@ export default function Home() {
|
||||
const [shareUrl, setShareUrl] = useState<string | null>(null)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showShareNotification, setShowShareNotification] = useState(false)
|
||||
const [themeColor, setThemeColor] = useState('#1f2937') // 主题颜色
|
||||
const themeColor = '#1a1c1d'
|
||||
const [favoriteNotification, setFavoriteNotification] = useState<string | null>(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 (
|
||||
<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} />
|
||||
{/* 维护通知横幅已在 Header 中显示 */}
|
||||
|
||||
{/* 收藏操作提示气泡 */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
|
||||
|
||||
<RankingPanel />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Header Section */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-end items-center mb-6">
|
||||
<main className="relative mx-auto max-w-[1280px] px-6 pb-20">
|
||||
<section className="relative isolate overflow-hidden pb-8 pt-16 md:pb-10 md:pt-20">
|
||||
<BrandFluidField />
|
||||
<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
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 rounded-apple text-white font-medium transition-all hover:opacity-90 shadow-apple"
|
||||
style={{ backgroundColor: themeColor }}
|
||||
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}`}
|
||||
>
|
||||
<span className="mr-2">+</span>
|
||||
上传素材
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar - 实时搜索 */}
|
||||
<SearchBar
|
||||
value={searchKeyword}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="搜索素材 ID、SPU、任务号、店铺、场景或作者"
|
||||
isSearching={isSearchPending}
|
||||
/>
|
||||
|
||||
{/* 筛选面板组件 */}
|
||||
<FilterPanel
|
||||
selectedMaterialType={selectedMaterialType}
|
||||
@ -1361,86 +1440,51 @@ export default function Home() {
|
||||
facetsLoading={facetsLoading || status === 'loading'}
|
||||
/>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
{(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="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 flex-wrap">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">当前筛选:</span>
|
||||
<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]">
|
||||
<span className="text-[#86898e]">当前筛选</span>
|
||||
{searchKeyword && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||
搜索: {searchKeyword}
|
||||
<button
|
||||
onClick={() => setSearchKeyword('')}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
搜索 {searchKeyword}
|
||||
<button onClick={() => setSearchKeyword('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedMaterialType && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||
素材类型: {selectedMaterialType}
|
||||
<button
|
||||
onClick={() => setSelectedMaterialType('')}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{selectedMaterialType}
|
||||
<button onClick={() => setSelectedMaterialType('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedCategory && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||
店铺: {selectedCategory}
|
||||
<button
|
||||
onClick={() => handleCategoryChange('')}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{selectedCategory}
|
||||
<button onClick={() => handleCategoryChange('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedScene && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||
场景类别: {selectedScene}
|
||||
<button
|
||||
onClick={() => setSelectedScene('')}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{selectedScene}
|
||||
<button onClick={() => setSelectedScene('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedPlatform && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||
平台分类: {selectedPlatform}
|
||||
<button
|
||||
onClick={() => setSelectedPlatform('')}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{selectedPlatform}
|
||||
<button onClick={() => setSelectedPlatform('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedProductCategory && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||
产品类别: {selectedProductCategory}
|
||||
<button
|
||||
onClick={() => setSelectedProductCategory('')}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{selectedProductCategory}
|
||||
<button onClick={() => setSelectedProductCategory('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedMonth && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||
月份: {months.find(m => m.value === selectedMonth)?.label || selectedMonth}
|
||||
<button
|
||||
onClick={() => setSelectedMonth('')}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{selectedMonth.length === 4
|
||||
? `${selectedMonth}年`
|
||||
: months.find(m => m.value === selectedMonth)?.label || selectedMonth}
|
||||
<button onClick={() => setSelectedMonth('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@ -1453,11 +1497,10 @@ export default function Home() {
|
||||
setSelectedProductCategory('')
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -1567,24 +1610,23 @@ export default function Home() {
|
||||
onClick={() => setShowShareModal(false)}
|
||||
>
|
||||
<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()}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">分享链接</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">
|
||||
<h3 className="text-lg font-semibold mb-4 text-[#1a1c1d]">分享链接</h3>
|
||||
<div className="bg-[#fafafa] border border-[#e7e8ea] p-3 rounded-[8px] mb-4 break-all text-sm text-[#1a1c1d]">
|
||||
{shareUrl}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyShareUrl}
|
||||
className="flex-1 text-white px-4 py-2 rounded-lg transition-all hover:opacity-90"
|
||||
style={{ backgroundColor: themeColor }}
|
||||
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"
|
||||
>
|
||||
复制链接
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
|
||||
@ -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(<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 (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-gray-700">类型分页</h2>
|
||||
{selectedMaterialType && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2" aria-busy={facetsLoading}>
|
||||
<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>
|
||||
{materialTypes.map((type) => (
|
||||
<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>
|
||||
))}
|
||||
{facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => (
|
||||
<span key={item} className="h-9 w-20 rounded-full bg-[#eceef0]" aria-hidden="true" />
|
||||
))}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-end justify-between gap-6 border-b border-[#e7e8ea]">
|
||||
<div ref={rowRef} className="relative min-w-0 flex-1">
|
||||
<div className="flex items-end gap-x-8" aria-busy={facetsLoading}>
|
||||
{renderTypeTab('全部', totals.materialType, selectedMaterialType === '', () => setSelectedMaterialType(''))}
|
||||
{visibleTypes.map((type) => (
|
||||
<span key={type.value}>
|
||||
{renderTypeTab(type.label, type.count || 0, selectedMaterialType === type.value, () => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value))}
|
||||
</span>
|
||||
))}
|
||||
{overflowTypes.length > 0 && (
|
||||
<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 className="mb-4 border-t border-gray-200 pt-4">
|
||||
<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">
|
||||
<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>
|
||||
{isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
|
||||
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded text-xs">{activeFilterCount}</span>}
|
||||
</button>
|
||||
<div className="mb-8 flex flex-wrap items-baseline justify-between gap-3">
|
||||
<p className="text-[13px] text-[#86898e]">
|
||||
共 <span className="font-en tabular-nums text-[#6b7075]">{facetsLoading ? '…' : materialsCount.toLocaleString('zh-CN')}</span> 条
|
||||
{selectedMaterialType && <span className="sr-only">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}
|
||||
</p>
|
||||
<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 className={`overflow-hidden transition-all duration-300 ${isFilterCollapsed ? 'max-h-0 opacity-0' : 'max-h-[2000px] opacity-100'}`}>
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-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>
|
||||
{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>)}
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-3 text-[13px] text-[#6b7075]">使用店铺</h2>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<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>
|
||||
{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 className="mb-4">
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-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>
|
||||
{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>)}
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-3 text-[13px] text-[#6b7075]">场景类别</h2>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<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>
|
||||
{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>
|
||||
{(platforms.length > 0 || facetsLoading) && <div className="mb-4">
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-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>
|
||||
{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.length > 0 || facetsLoading) && <div className="mb-6">
|
||||
<h2 className="mb-3 text-[13px] text-[#6b7075]">平台分类</h2>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<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>
|
||||
{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>}
|
||||
{(productCategories.length > 0 || facetsLoading) && <div className="mb-4">
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-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>
|
||||
{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.length > 0 || facetsLoading) && <div className="mb-6">
|
||||
<h2 className="mb-3 text-[13px] text-[#6b7075]">产品类别</h2>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<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>
|
||||
{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 className="mb-4">
|
||||
<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={() => 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>
|
||||
{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>)}
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-3 text-[13px] text-[#6b7075]">上传时间</h2>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<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>
|
||||
{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>
|
||||
{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 className="mb-4">
|
||||
<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>
|
||||
<p className="mb-6 text-xs text-[#86898e]">选了一个条件后,其它条件只保留还有结果的选项,数量会跟着变。</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -59,7 +59,7 @@ const LoadMoreTrigger = forwardRef<HTMLDivElement, LoadMoreTriggerProps>(functio
|
||||
className="w-full h-20 flex items-center justify-center mt-8"
|
||||
>
|
||||
{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">
|
||||
<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" />
|
||||
@ -67,7 +67,7 @@ const LoadMoreTrigger = forwardRef<HTMLDivElement, LoadMoreTriggerProps>(functio
|
||||
加载中...
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm">
|
||||
<p className="text-[#86898e] text-sm">
|
||||
向下滚动加载更多
|
||||
</p>
|
||||
)}
|
||||
@ -78,7 +78,7 @@ const LoadMoreTrigger = forwardRef<HTMLDivElement, LoadMoreTriggerProps>(functio
|
||||
// 显示到底提示
|
||||
if (showEndMessage && !hasMore && (displayedCount === undefined || displayedCount >= (totalCount || 0))) {
|
||||
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>
|
||||
)
|
||||
|
||||
@ -4,7 +4,7 @@ import React, { useRef, useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { format } from 'date-fns'
|
||||
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 { isVideoMaterialType } from '@/lib/video-material-types'
|
||||
import {
|
||||
@ -92,49 +92,38 @@ interface MaterialCardProps {
|
||||
showToast: (message: string, options?: { variant?: 'success' | 'error' }) => void
|
||||
}
|
||||
|
||||
// 根据素材类型获取角标背景颜色
|
||||
const getMaterialTypeBadgeColor = (materialType: string): string => {
|
||||
const isVideoType = isVideoMaterialType(materialType)
|
||||
const OVERLAY_CHIP_CLASS = 'bg-white/92 text-[#1a1c1d] border border-[#e7e8ea] px-2 py-1 rounded-[8px] text-xs font-medium'
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: string | number): string => {
|
||||
const size = typeof bytes === 'string' ? parseInt(bytes) : bytes
|
||||
if (size === 0) return '未知'
|
||||
function formatFileSize(bytes: string | number): string {
|
||||
const size = typeof bytes === 'string' ? Number.parseInt(bytes, 10) : bytes
|
||||
if (!size || Number.isNaN(size) || size <= 0) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(size) / Math.log(1024))
|
||||
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
|
||||
const unitIndex = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1)
|
||||
return `${(size / Math.pow(1024, unitIndex)).toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const getTaskDetailSourceBadgeClassName = (value?: string | null): string => {
|
||||
const normalized = normalizeTaskDetailSourceType(value)
|
||||
if (normalized === '实拍') {
|
||||
return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
}
|
||||
function DownloadGlyph() {
|
||||
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="M12 4v11m0 0l-4-4m4 4l4-4M5 19h14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (normalized === '渲染') {
|
||||
return 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300'
|
||||
}
|
||||
function ShareGlyph() {
|
||||
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') {
|
||||
return 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300'
|
||||
}
|
||||
|
||||
return 'bg-slate-100 dark:bg-slate-700/60 text-slate-700 dark:text-slate-300'
|
||||
function HeartGlyph({ filled }: { filled: boolean }) {
|
||||
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" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MaterialCard({
|
||||
@ -148,7 +137,6 @@ export default function MaterialCard({
|
||||
audioRef,
|
||||
downloadingId,
|
||||
downloadProgress,
|
||||
themeColor,
|
||||
onAudioPlay,
|
||||
onDownload,
|
||||
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))
|
||||
label = `转码中${stageName ? ` · ${stageName}` : ''}${attempts > 1 ? ` · 第${attempts}次` : ''}${waitingCount > 0 ? ` · 队列余${waitingCount}` : ''} · ${processingCount}/${maxConcurrent}并发`
|
||||
}
|
||||
bgColor = 'bg-blue-600'
|
||||
bgColor = 'bg-[#1a1c1d]'
|
||||
} else if (status === 'processing') {
|
||||
label = `转码中${attempts > 1 ? ` · 第${attempts}次` : ''}`
|
||||
progressValue = 15
|
||||
bgColor = 'bg-blue-600'
|
||||
bgColor = 'bg-[#1a1c1d]'
|
||||
} else if (status === 'pending') {
|
||||
label = `等待中${attempts > 1 ? ` · 第${attempts}次` : ''}`
|
||||
progressValue = 5
|
||||
bgColor = 'bg-gray-600'
|
||||
bgColor = 'bg-[#86898e]'
|
||||
} else if (queueIndex !== null && queueIndex !== undefined) {
|
||||
const processingCount = processingIds.length
|
||||
label = `排队中 · ${queueIndex}/${queueTotal} · ${processingCount}/${maxConcurrent}执行中`
|
||||
progressValue = 8
|
||||
bgColor = 'bg-amber-600'
|
||||
bgColor = 'bg-[#6e79c8]'
|
||||
} else {
|
||||
label = status || '未知状态'
|
||||
progressValue = 12
|
||||
bgColor = 'bg-gray-600'
|
||||
bgColor = 'bg-[#86898e]'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 z-40 bg-black/85 p-2 text-xs text-white"
|
||||
role="status"
|
||||
aria-live={status === 'failed' ? 'assertive' : 'polite'}
|
||||
aria-atomic="true"
|
||||
>
|
||||
<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-white/90 text-gray-900 px-2 py-0.5 rounded text-xs hover:bg-white transition-colors"
|
||||
>
|
||||
重试转码
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
{status !== 'failed' && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-10 z-30 flex justify-center">
|
||||
<span className="rounded-[8px] border border-[#e7e8ea] bg-white px-3 py-1.5 text-[12px] text-[#1a1c1d]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="h-1.5 w-full overflow-hidden rounded-full bg-gray-700"
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progressValue}
|
||||
className="absolute bottom-0 left-0 right-0 z-40 border-t border-[#e7e8ea] bg-white/95 p-2 text-xs text-[#1a1c1d]"
|
||||
role="status"
|
||||
aria-live={status === 'failed' ? 'assertive' : 'polite'}
|
||||
aria-atomic="true"
|
||||
>
|
||||
<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
|
||||
className={`h-full transition-all duration-300 ${bgColor}`}
|
||||
style={{ width: `${progressValue}%` }}
|
||||
/>
|
||||
className="h-1.5 w-full overflow-hidden rounded-[8px] bg-[#e7e8ea]"
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const fileSizeLabel = formatFileSize(material.zipFileSize)
|
||||
|
||||
return (
|
||||
<div className="card-apple overflow-hidden dark:bg-gray-800 dark:border-gray-700">
|
||||
<article data-card className="bg-transparent">
|
||||
{/* Preview Image/Video */}
|
||||
<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"
|
||||
onMouseEnter={() => isVideoType && videoPreviewUrl && handleVideoMouseEnter()}
|
||||
onMouseLeave={() => isVideoType && videoPreviewUrl && handleVideoMouseLeave()}
|
||||
@ -381,7 +380,7 @@ export default function MaterialCard({
|
||||
ref={videoRef}
|
||||
src={videoPreviewUrl}
|
||||
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
|
||||
loop
|
||||
playsInline
|
||||
@ -399,8 +398,8 @@ export default function MaterialCard({
|
||||
/>
|
||||
{/* 视频封面加载失败时的占位符 */}
|
||||
{posterLoadError && (
|
||||
<div className="absolute inset-0 w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600 z-10">
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">封面加载失败</span>
|
||||
<div className="absolute inset-0 w-full h-full flex items-center justify-center bg-[#eceef0] z-10">
|
||||
<span className="text-[#86898e] text-sm">封面加载失败</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -475,7 +474,7 @@ export default function MaterialCard({
|
||||
)}
|
||||
{/* 时长显示 */}
|
||||
{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')}
|
||||
</div>
|
||||
)}
|
||||
@ -530,7 +529,7 @@ export default function MaterialCard({
|
||||
alt={material.sceneName}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover transition-transform group-hover:scale-105"
|
||||
className="object-contain"
|
||||
sizes="(max-width: 768px) 100vw, 300px"
|
||||
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">
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">图片加载失败</span>
|
||||
<div className="image-placeholder hidden w-full h-full items-center justify-center bg-[#eceef0]">
|
||||
<span className="text-[#86898e] text-sm">图片加载失败</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600 audio-placeholder">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<div className="w-full h-full flex items-center justify-center bg-[#eceef0] audio-placeholder">
|
||||
<span className="text-[#86898e]">
|
||||
{material.videoProcessingStatus === 'failed'
|
||||
? '预览生成失败'
|
||||
: material.videoProcessingStatus === 'pending' || material.videoProcessingStatus === 'processing'
|
||||
@ -569,210 +568,50 @@ export default function MaterialCard({
|
||||
</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()}
|
||||
|
||||
{/* 视频时长角标 */}
|
||||
{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')}
|
||||
</div>
|
||||
)}
|
||||
{isPreviewWaiting && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/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="absolute inset-0 z-30 flex items-center justify-center bg-[#fafafa]/55 px-4 text-center">
|
||||
<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}天后可查看详情
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
{/* 标签 */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{material.categoryName && (
|
||||
<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">
|
||||
{material.categoryName}
|
||||
</span>
|
||||
)}
|
||||
{material.sceneName && (
|
||||
<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.materialType && (
|
||||
<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">
|
||||
{material.materialType}
|
||||
</span>
|
||||
)}
|
||||
{isTaskDetailMaterial && (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${getTaskDetailSourceBadgeClassName(material.taskDetailSourceType)}`}>
|
||||
{getTaskDetailSourceTypeLabel(material.taskDetailSourceType)}
|
||||
</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="pt-4">
|
||||
<h3 className="truncate font-display text-[16px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">
|
||||
{materialDisplayTitle}
|
||||
</h3>
|
||||
<p className="mt-1.5 truncate text-[12px] leading-relaxed text-[#86898e]">
|
||||
{fileSizeLabel && <><span>{fileSizeLabel}</span> · </>}
|
||||
{material.authorName && <><span>{material.authorName}</span> · </>}
|
||||
<span>{format(new Date(material.createdAt), 'yyyy.MM.dd')}</span>
|
||||
{material.materialType && <> · <span>{material.materialType}</span></>}
|
||||
{material.categoryName && <> · <span>{material.categoryName}</span></>}
|
||||
{material.sceneName && <> · <span>{material.sceneName}</span></>}
|
||||
{isTaskDetailMaterial && <> · <span>{getTaskDetailSourceTypeLabel(material.taskDetailSourceType)}</span></>}
|
||||
{!isTaskDetailMaterial && material.isAiGenerated && <> · <span>AI生成</span></>}
|
||||
{material.materialType === '3D场景' && material.renderer && <> · <span>{material.renderer}</span></>}
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 cursor-pointer truncate font-en text-[11px] tracking-[0.04em] text-[#86898e] hover:text-[#1a1c1d]"
|
||||
onClick={handleCopyId}
|
||||
title="点击复制完整ID"
|
||||
>
|
||||
ID {material.id}
|
||||
</p>
|
||||
|
||||
{/* 作者和时间 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 分享按钮 */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]">
|
||||
<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={() => {
|
||||
if (isDownloadWaiting) {
|
||||
showToast(getVideoDownloadUnavailableMessage(videoDownloadAvailability), { variant: 'error' })
|
||||
@ -782,40 +621,89 @@ export default function MaterialCard({
|
||||
}}
|
||||
disabled={isDownloadDisabled}
|
||||
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 ${
|
||||
isDownloadWaiting ? 'opacity-50 cursor-not-allowed' : ''
|
||||
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 || isDownloadDisabled ? 'cursor-not-allowed text-[#86898e]' : 'text-[#1a1c1d] hover:text-[#5a66b8]'
|
||||
}`}
|
||||
style={{ backgroundColor: themeColor }}
|
||||
title={
|
||||
videoDownloadAvailability.allowed
|
||||
? '下载'
|
||||
: getVideoDownloadUnavailableMessage(videoDownloadAvailability)
|
||||
}
|
||||
>
|
||||
{downloadingId === material.id ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<DownloadGlyph />
|
||||
{downloadingId === material.id
|
||||
? `${downloadProgress}%`
|
||||
: isDownloadWaiting
|
||||
? `${videoDownloadAvailability.remainingDays || VIDEO_DOWNLOAD_WAIT_DAYS}天后可下载`
|
||||
: '下载'}
|
||||
{downloadingId !== material.id && !isDownloadWaiting && material.downloadCount > 0 && (
|
||||
<span className="font-en text-[11px] tabular-nums text-[#86898e]">{material.downloadCount}</span>
|
||||
)}
|
||||
</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>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@ -47,17 +47,18 @@ interface MaterialGridProps {
|
||||
function MaterialGridSkeleton() {
|
||||
return (
|
||||
<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-live="polite"
|
||||
aria-label="素材列表加载中"
|
||||
>
|
||||
<span className="sr-only">加载中...</span>
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<article
|
||||
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="h-4 w-3/4 animate-pulse bg-[#eceef0]" />
|
||||
<div className="h-3 w-1/2 animate-pulse bg-[#f2f3f5]" />
|
||||
@ -104,7 +105,7 @@ export default function MaterialGrid({
|
||||
<div className="relative">
|
||||
{/* 过渡加载遮罩 */}
|
||||
{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>
|
||||
)}
|
||||
@ -112,13 +113,13 @@ export default function MaterialGrid({
|
||||
{loading ? (
|
||||
<MaterialGridSkeleton />
|
||||
) : materials.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-lg text-[#6b7075]">
|
||||
<div className="border-t border-[#e7e8ea] py-24 text-center">
|
||||
<p className="text-[15px] tracking-[-0.02em] text-[#6b7075]">
|
||||
{hasFilters ? '没有找到匹配的素材' : '还没有素材,快来上传第一个吧!'}
|
||||
</p>
|
||||
</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) => (
|
||||
<MaterialCard
|
||||
key={material.id}
|
||||
|
||||
@ -196,9 +196,9 @@ function SearchBarInner({
|
||||
// 渲染建议类型标签
|
||||
const renderSuggestionType = (type: string) => {
|
||||
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 'author': return <span className="text-xs bg-green-100 text-green-600 px-1.5 py-0.5 rounded">作者</span>
|
||||
case 'scene': return <span className="text-xs bg-orange-100 text-orange-600 px-1.5 py-0.5 rounded">场景</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 border border-[#e7e8ea] bg-white text-[#6b7075] px-1.5 py-0.5 rounded-[8px]">作者</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
|
||||
}
|
||||
}
|
||||
@ -209,7 +209,7 @@ function SearchBarInner({
|
||||
const shouldShowDropdown = showDropdown && (showSuggestionsList || showHistoryList)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative mb-6 z-20">
|
||||
<div ref={containerRef} className="relative z-20">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@ -221,17 +221,17 @@ function SearchBarInner({
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
placeholder={placeholder}
|
||||
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 ? (
|
||||
<div className="absolute left-3 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="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5">
|
||||
<div className="w-4 h-4 border-2 border-[#e7e8ea] border-t-[#1a1c1d] rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@ -249,7 +249,7 @@ function SearchBarInner({
|
||||
{localValue && (
|
||||
<button
|
||||
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="清除搜索"
|
||||
type="button"
|
||||
>
|
||||
@ -266,7 +266,7 @@ function SearchBarInner({
|
||||
|
||||
{/* 下拉列表 */}
|
||||
{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 && (
|
||||
@ -275,12 +275,12 @@ function SearchBarInner({
|
||||
<li key={`${item.type}-${item.value}`}>
|
||||
<button
|
||||
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 ${
|
||||
index === selectedIndex ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
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-[#fafafa]' : 'hover:bg-[#fafafa]'
|
||||
}`}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
<span className="truncate flex-1">
|
||||
@ -297,11 +297,11 @@ function SearchBarInner({
|
||||
{/* 历史记录列表 */}
|
||||
{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>
|
||||
<button
|
||||
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>
|
||||
@ -311,19 +311,19 @@ function SearchBarInner({
|
||||
<li key={item}>
|
||||
<button
|
||||
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 ${
|
||||
index === selectedIndex ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
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-[#fafafa]' : 'hover:bg-[#fafafa]'
|
||||
}`}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
<span className="truncate">{item}</span>
|
||||
</div>
|
||||
<div
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
|
||||
@ -206,6 +206,34 @@ describe('material search conditions', () => {
|
||||
})).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 () => {
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
|
||||
|
||||
|
||||
@ -292,9 +292,20 @@ export async function buildMaterialSearchOrConditions(search: string): Promise<a
|
||||
function applyMonthFilter(where: any, month: string): void {
|
||||
const [year, monthNum] = month.split('-')
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,8 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
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'],
|
||||
},
|
||||
borderRadius: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user