feat: 统一更新记录与品牌元数据

This commit is contained in:
chenpei 2026-08-16 11:33:42 +08:00
parent 904535d7d9
commit 17e23c26ae
3 changed files with 170 additions and 70 deletions

View File

@ -31,10 +31,13 @@ async function getSystemSettings() {
export async function generateMetadata(): Promise<Metadata> {
const settings = await getSystemSettings()
const siteTitle = !settings?.siteName || settings.siteName === '由壹点3D素材'
? '由壹点·品牌中心'
: settings.siteName
return {
title: settings?.siteName || '由壹点3D素材',
description: '3D素材分享平台',
title: siteTitle,
description: '品牌中心部门看板与创意资产协作平台',
icons: {
icon: '/api/favicon',
shortcut: '/api/favicon',

View File

@ -0,0 +1,60 @@
import { fireEvent, render, screen } from '@testing-library/react'
import UpdateLogModal from '@/components/UpdateLogModal'
const updateLog = {
version: '2026.08.15',
date: '2026-08-15',
title: '系统更新',
items: [
{
type: 'feature' as const,
title: '品牌中心看板上线',
description: '看板首页已经切换到全新的视觉标准。',
},
{
type: 'fix' as const,
title: '修复展示问题',
description: '修复部分页面的展示异常。',
},
],
}
describe('UpdateLogModal', () => {
test('使用可访问的弹窗结构展示更新清单', () => {
render(<UpdateLogModal isOpen onClose={jest.fn()} updateLog={updateLog} />)
expect(screen.getByRole('dialog', { name: '系统更新' })).toHaveAttribute('aria-modal', 'true')
expect(screen.getByText('Version 2026.08.15 · 2026-08-15')).toBeInTheDocument()
expect(screen.getByText('01')).toBeInTheDocument()
expect(screen.getByText('02')).toBeInTheDocument()
expect(screen.getByText('新增')).toBeInTheDocument()
expect(screen.getByText('修复')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '关闭' })).toHaveFocus()
})
test('支持 Escape、确认按钮和焦点循环', () => {
const onClose = jest.fn()
const opener = document.createElement('button')
document.body.appendChild(opener)
opener.focus()
const { unmount } = render(<UpdateLogModal isOpen onClose={onClose} updateLog={updateLog} />)
const closeButton = screen.getByRole('button', { name: '关闭' })
const confirmButton = screen.getByRole('button', { name: '知道了' })
confirmButton.focus()
fireEvent.keyDown(document, { key: 'Tab' })
expect(closeButton).toHaveFocus()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
fireEvent.click(confirmButton)
expect(onClose).toHaveBeenCalledTimes(2)
unmount()
expect(opener).toHaveFocus()
opener.remove()
})
})

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
// 更新日志配置 - 每次有新功能或优化时更新这个版本号
const CURRENT_VERSION = '2026.07.04'
@ -1022,17 +1022,47 @@ interface UpdateLogModalProps {
}
function UpdateLogModal({ isOpen, onClose, updateLog, showAllHistory = false }: UpdateLogModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (isOpen) {
// 阻止背景滚动
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
if (!isOpen) return
const previousOverflow = document.body.style.overflow
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
return
}
if (event.key !== 'Tab' || !dialogRef.current) return
const focusableElements = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement?.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement?.focus()
}
}
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleKeyDown)
closeButtonRef.current?.focus()
return () => {
document.body.style.overflow = 'unset'
document.body.style.overflow = previousOverflow
document.removeEventListener('keydown', handleKeyDown)
previousFocus?.focus()
}
}, [isOpen])
}, [isOpen, onClose])
if (!isOpen) return null
@ -1042,122 +1072,129 @@ function UpdateLogModal({ isOpen, onClose, updateLog, showAllHistory = false }:
// 如果没有可显示的内容,不渲染弹窗
if (logsToShow.length === 0) return null
const getTypeIcon = (type: string) => {
const getTypeLabel = (type: UpdateItem['type']) => {
switch (type) {
case 'feature':
return '✨'
return '新增'
case 'improvement':
return '🚀'
return '优化'
case 'fix':
return '🔧'
default:
return '📝'
return '修复'
}
}
const getTypeColor = (type: string) => {
const getTypeDotColor = (type: UpdateItem['type']) => {
switch (type) {
case 'feature':
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
return 'bg-[#6e79c8]'
case 'improvement':
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
return 'bg-[#157a4b]'
case 'fix':
return 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800'
default:
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700'
return 'bg-[#1a1c1d]'
}
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="fixed inset-0 z-[100] flex items-center justify-center p-3 sm:p-6">
{/* 背景遮罩 */}
<div
className="absolute inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm transition-opacity"
<div
className="absolute inset-0 bg-[#1a1c1d]/40 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* 弹窗内容 */}
<div className="relative w-full max-w-2xl max-h-[85vh] bg-white dark:bg-gray-800 rounded-2xl shadow-apple-lg overflow-hidden transform transition-all flex flex-col">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="update-log-title"
aria-describedby="update-log-summary"
className="relative flex max-h-[88dvh] w-full max-w-[760px] flex-col overflow-hidden rounded-xl border border-[#e7e8ea] bg-[#fafafa] text-[#1a1c1d] selection:bg-[#eef0fa]"
>
{/* 头部 */}
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-white dark:from-gray-800 dark:to-gray-900 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{showAllHistory ? '更新记录' : updateLog?.title || '系统更新'}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{showAllHistory ? '查看所有历史更新' : `版本 ${updateLog?.version || ''} · ${updateLog?.date || ''}`}
</p>
</div>
<header className="relative flex flex-shrink-0 items-start justify-between gap-6 overflow-hidden border-b border-[#e7e8ea] px-5 py-5 sm:px-8 sm:py-7">
<div aria-hidden="true" className="pointer-events-none absolute -right-12 -top-16 h-40 w-40 rounded-full bg-[#6e79c8]/15 blur-3xl" />
<div className="relative min-w-0">
<h2 id="update-log-title" className="font-display text-[clamp(1.5rem,4vw,2.25rem)] font-semibold leading-tight tracking-[-0.03em]">
{showAllHistory ? '更新记录' : updateLog?.title || '系统更新'}
</h2>
<p id="update-log-summary" className="font-en mt-2 text-[11px] uppercase tracking-[0.14em] text-[#86898e] sm:text-xs">
{showAllHistory ? 'Product changelog · 全部历史版本' : `Version ${updateLog?.version || ''} · ${updateLog?.date || ''}`}
</p>
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
className="relative inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border border-[#e7e8ea] bg-white text-[#6b7075] transition-colors hover:border-[#1a1c1d] hover:bg-[#1a1c1d] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
aria-label="关闭"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</header>
{/* 内容区域 */}
<div className="px-6 py-6 overflow-y-auto flex-1 min-h-0">
<div className="space-y-6">
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-2 [scrollbar-color:#c9ccd1_transparent] [scrollbar-width:thin] sm:px-8">
<div>
{logsToShow.map((log, logIndex) => (
<div key={logIndex} className="space-y-4">
<section key={`${log.version}-${log.date}`} aria-label={`版本 ${log.version}`} className={logIndex > 0 ? 'pt-10' : ''}>
{/* 版本标题(仅在显示所有历史时显示) */}
{showAllHistory && (
<div className="sticky top-0 bg-white dark:bg-gray-800 pb-2 border-b border-gray-200 dark:border-gray-700 z-10 mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
版本 {log.version}
<div className="sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b border-[#e7e8ea] bg-[#fafafa] py-4">
<h3 className="font-display text-base font-semibold tracking-[-0.02em] sm:text-lg">
{log.version}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="font-en text-[11px] uppercase tracking-[0.14em] text-[#86898e]">
{log.date}
</p>
</div>
)}
{/* 更新项列表 */}
<div className="space-y-4">
<div className={showAllHistory ? '' : 'border-t border-[#e7e8ea]'}>
{log.items.map((item, itemIndex) => (
<div
key={itemIndex}
className={`p-4 rounded-xl border ${getTypeColor(item.type)} transition-all hover:shadow-md`}
<article
key={`${item.type}-${item.title}`}
className="grid grid-cols-[42px_minmax(0,1fr)] gap-x-2 border-b border-[#e7e8ea] py-6 sm:grid-cols-[58px_minmax(0,1fr)_80px] sm:gap-x-4 sm:py-7"
>
<div className="flex items-start gap-3">
<span className="text-2xl flex-shrink-0">{getTypeIcon(item.type)}</span>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1.5">
<span className="font-en pt-0.5 text-xs tabular-nums text-[#86898e]">
{String(itemIndex + 1).padStart(2, '0')}
</span>
<div className="min-w-0">
<div className="mb-2">
<h3 className="font-display text-base font-semibold leading-snug tracking-[-0.02em] sm:text-lg">
{item.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{item.description}
</p>
</div>
<p className="max-w-[70ch] text-sm leading-[1.8] text-[#6b7075] sm:text-[15px]">
{item.description}
</p>
</div>
</div>
<span className="col-start-2 mt-3 inline-flex items-center gap-2 text-[11px] text-[#6b7075] sm:col-start-3 sm:row-start-1 sm:mt-0 sm:justify-end sm:pt-1 sm:text-xs">
<span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${getTypeDotColor(item.type)}`} />
{getTypeLabel(item.type)}
</span>
</article>
))}
</div>
</div>
</section>
))}
</div>
</div>
{/* 底部按钮 */}
<div className="px-6 py-5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
<footer className="flex flex-shrink-0 border-t border-[#e7e8ea] bg-[#fafafa] px-5 py-4 sm:px-8 sm:py-5">
<button
type="button"
onClick={onClose}
className="w-full py-3 px-6 bg-gray-900 dark:bg-gray-700 text-white rounded-xl font-medium hover:bg-gray-800 dark:hover:bg-gray-600 active:scale-[0.98] transition-all duration-200 shadow-apple hover:shadow-apple-lg"
className="min-h-11 w-full rounded-lg bg-[#1a1c1d] px-8 text-sm font-medium text-white transition-opacity hover:opacity-80 active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa] sm:ml-auto sm:w-auto sm:min-w-40"
>
知道了
</button>
</div>
</footer>
</div>
</div>
)