feat: 统一更新记录与品牌元数据
This commit is contained in:
parent
904535d7d9
commit
17e23c26ae
@ -31,10 +31,13 @@ async function getSystemSettings() {
|
|||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const settings = await getSystemSettings()
|
const settings = await getSystemSettings()
|
||||||
|
const siteTitle = !settings?.siteName || settings.siteName === '由壹点3D素材'
|
||||||
|
? '由壹点·品牌中心'
|
||||||
|
: settings.siteName
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: settings?.siteName || '由壹点3D素材',
|
title: siteTitle,
|
||||||
description: '3D素材分享平台',
|
description: '品牌中心部门看板与创意资产协作平台',
|
||||||
icons: {
|
icons: {
|
||||||
icon: '/api/favicon',
|
icon: '/api/favicon',
|
||||||
shortcut: '/api/favicon',
|
shortcut: '/api/favicon',
|
||||||
|
|||||||
60
components/UpdateLogModal.test.tsx
Normal file
60
components/UpdateLogModal.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
// 更新日志配置 - 每次有新功能或优化时更新这个版本号
|
// 更新日志配置 - 每次有新功能或优化时更新这个版本号
|
||||||
const CURRENT_VERSION = '2026.07.04'
|
const CURRENT_VERSION = '2026.07.04'
|
||||||
@ -1022,17 +1022,47 @@ interface UpdateLogModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UpdateLogModal({ isOpen, onClose, updateLog, showAllHistory = false }: UpdateLogModalProps) {
|
function UpdateLogModal({ isOpen, onClose, updateLog, showAllHistory = false }: UpdateLogModalProps) {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (!isOpen) return
|
||||||
// 阻止背景滚动
|
|
||||||
document.body.style.overflow = 'hidden'
|
const previousOverflow = document.body.style.overflow
|
||||||
} else {
|
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
document.body.style.overflow = 'unset'
|
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 () => {
|
return () => {
|
||||||
document.body.style.overflow = 'unset'
|
document.body.style.overflow = previousOverflow
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
previousFocus?.focus()
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
@ -1042,122 +1072,129 @@ function UpdateLogModal({ isOpen, onClose, updateLog, showAllHistory = false }:
|
|||||||
// 如果没有可显示的内容,不渲染弹窗
|
// 如果没有可显示的内容,不渲染弹窗
|
||||||
if (logsToShow.length === 0) return null
|
if (logsToShow.length === 0) return null
|
||||||
|
|
||||||
const getTypeIcon = (type: string) => {
|
const getTypeLabel = (type: UpdateItem['type']) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'feature':
|
case 'feature':
|
||||||
return '✨'
|
return '新增'
|
||||||
case 'improvement':
|
case 'improvement':
|
||||||
return '🚀'
|
return '优化'
|
||||||
case 'fix':
|
case 'fix':
|
||||||
return '🔧'
|
return '修复'
|
||||||
default:
|
|
||||||
return '📝'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
const getTypeDotColor = (type: UpdateItem['type']) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'feature':
|
case 'feature':
|
||||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
return 'bg-[#6e79c8]'
|
||||||
case 'improvement':
|
case 'improvement':
|
||||||
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
return 'bg-[#157a4b]'
|
||||||
case 'fix':
|
case 'fix':
|
||||||
return 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800'
|
return 'bg-[#1a1c1d]'
|
||||||
default:
|
|
||||||
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className="absolute inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm transition-opacity"
|
className="absolute inset-0 bg-[#1a1c1d]/40 transition-opacity"
|
||||||
onClick={onClose}
|
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">
|
<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 className="flex items-center gap-3">
|
<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="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
<div className="relative min-w-0">
|
||||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<h2 id="update-log-title" className="font-display text-[clamp(1.5rem,4vw,2.25rem)] font-semibold leading-tight tracking-[-0.03em]">
|
||||||
<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" />
|
{showAllHistory ? '更新记录' : updateLog?.title || '系统更新'}
|
||||||
</svg>
|
</h2>
|
||||||
</div>
|
<p id="update-log-summary" className="font-en mt-2 text-[11px] uppercase tracking-[0.14em] text-[#86898e] sm:text-xs">
|
||||||
<div>
|
{showAllHistory ? 'Product changelog · 全部历史版本' : `Version ${updateLog?.version || ''} · ${updateLog?.date || ''}`}
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
</p>
|
||||||
{showAllHistory ? '更新记录' : updateLog?.title || '系统更新'}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{showAllHistory ? '查看所有历史更新' : `版本 ${updateLog?.version || ''} · ${updateLog?.date || ''}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
ref={closeButtonRef}
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
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="关闭"
|
aria-label="关闭"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
<div className="px-6 py-6 overflow-y-auto flex-1 min-h-0">
|
<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 className="space-y-6">
|
<div>
|
||||||
{logsToShow.map((log, logIndex) => (
|
{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 && (
|
{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">
|
<div className="sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b border-[#e7e8ea] bg-[#fafafa] py-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="font-display text-base font-semibold tracking-[-0.02em] sm:text-lg">
|
||||||
版本 {log.version}
|
{log.version}
|
||||||
</h3>
|
</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}
|
{log.date}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 更新项列表 */}
|
{/* 更新项列表 */}
|
||||||
<div className="space-y-4">
|
<div className={showAllHistory ? '' : 'border-t border-[#e7e8ea]'}>
|
||||||
{log.items.map((item, itemIndex) => (
|
{log.items.map((item, itemIndex) => (
|
||||||
<div
|
<article
|
||||||
key={itemIndex}
|
key={`${item.type}-${item.title}`}
|
||||||
className={`p-4 rounded-xl border ${getTypeColor(item.type)} transition-all hover:shadow-md`}
|
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="font-en pt-0.5 text-xs tabular-nums text-[#86898e]">
|
||||||
<span className="text-2xl flex-shrink-0">{getTypeIcon(item.type)}</span>
|
{String(itemIndex + 1).padStart(2, '0')}
|
||||||
<div className="flex-1 min-w-0">
|
</span>
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1.5">
|
<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}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="max-w-[70ch] text-sm leading-[1.8] text-[#6b7075] sm:text-[15px]">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
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>
|
</button>
|
||||||
</div>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user