refactor(wall): 优化留言墙API和组件样式

- 修改 getCateWallListAPI 函数,移除不必要的参数以简化调用。
- 更新 WallMasonry 组件,重构样式和背景处理,提升视觉效果和用户体验。
- 替换 AddWallInfo 组件中的按钮样式,增强可访问性和交互性。
- 移除不再使用的样式文件,简化项目结构。
This commit is contained in:
刘宇阳
2026-07-07 10:07:10 +08:00
parent 6b3002c31c
commit dcafd462ed
10 changed files with 246 additions and 240 deletions

View File

@@ -12,6 +12,6 @@ export const getCateListAPI = async () => {
}
// 获取当前分类中所有留言
export const getCateWallListAPI = async (cateId: number, params?: Page) => {
return await Request<Paginate<Wall[]>>('GET', `/wall/cate/${cateId}`, { params: params ?? {} });
export const getCateWallListAPI = async (cateId: number) => {
return await Request<Paginate<Wall[]>>('GET', `/wall/cate/${cateId}`);
}

View File

@@ -17,7 +17,7 @@ const Copyright = () => {
>
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary dark:bg-blue-500/80 rounded-l-xl" />
<FiShield className="absolute right-2.5 top-5 text-7xl text-blue-500/[0.04] dark:text-gray-700 pointer-events-none rotate-12" />
<FiShield className="absolute right-2.5 top-5 text-7xl text-blue-500/4 dark:text-gray-700 pointer-events-none rotate-12" />
<div className="relative z-10 flex flex-col gap-3.5">
<div className="flex items-center gap-3 text-sm sm:text-base text-gray-700 dark:text-gray-300">

View File

@@ -7,7 +7,7 @@ import RecordCommentPanel from './Comment';
import useDebouncedLike from '@/hooks/useDebouncedLike';
import { likeRecordAPI } from '@/api/record';
import { getRecordCommentListAPI } from '@/api/recordComment';
import dayjs from 'dayjs';
import { getRelativeTimeLabel } from '@/utils';
import { User } from '@/types/app/user';
interface RecordItemProps {
@@ -43,30 +43,6 @@ function CommentIcon() {
);
}
function getRelativeTimeLabel(ts: string | number | Date | undefined): string {
if (ts == null) return '';
const now = dayjs();
const then = dayjs(+ts);
const diffDays = now.startOf('day').diff(then.startOf('day'), 'day');
const diffMonths = now.diff(then, 'month', true);
const diffYears = now.diff(then, 'year', true);
if (diffDays === 0) return `今天 ${then.format('HH:mm')}`;
if (diffDays === 1) return `昨天 ${then.format('HH:mm')}`;
if (diffDays >= 2 && diffDays <= 6) return `${diffDays}天前`;
if (diffDays >= 7 && diffDays <= 13) return '一周前';
if (diffDays >= 14 && diffDays <= 20) return '两周前';
if (diffDays >= 21 && diffDays <= 27) return '三周前';
if (diffMonths >= 1 && diffMonths < 2) return '一月前';
if (diffMonths >= 2 && diffMonths < 3) return '两月前';
if (diffMonths >= 3 && diffMonths < 6) return '三月前';
if (diffMonths >= 6 && diffMonths < 12) return '半年前';
if (diffYears >= 1 && diffYears < 2) return '一年前';
if (diffYears >= 2 && diffYears < 3) return '两年前';
if (diffYears >= 3) return `${Math.floor(diffYears)}年前`;
return '';
}
export default function RecordCard({ id, content, images, likeCount, mood, location, createTime, user }: RecordItemProps) {
const [showComments, setShowComments] = useState(false);
const [commentCount, setCommentCount] = useState(0);

View File

@@ -1,13 +1,25 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ZCOOL_KuaiLe } from 'next/font/google';
import AddWallInfo from '../components/AddWallInfo';
import WallMasonry from '../components/WallMasonry';
import Loading from '@/components/Loading';
import { getCateListAPI, getCateWallListAPI } from '@/api/wall';
import { Cate, Wall } from '@/types/app/wall';
import '../wall.css';
const zcoolKuaiLe = ZCOOL_KuaiLe({ weight: '400', subsets: ['latin'] });
const floatDots = [
{ size: 'w-2 h-2', color: 'bg-rose-400/70', delay: '0s' },
{ size: 'w-1.5 h-1.5', color: 'bg-amber-400/70', delay: '0.5s' },
{ size: 'w-2.5 h-2.5', color: 'bg-violet-400/70', delay: '1s' },
{ size: 'w-1.5 h-1.5', color: 'bg-cyan-400/70', delay: '1.5s' },
{ size: 'w-2 h-2', color: 'bg-emerald-400/70', delay: '2s' },
];
export default () => {
const params = useParams();
@@ -15,13 +27,8 @@ export default () => {
const [cateList, setCateList] = useState<Cate[]>([]);
const [walls, setWalls] = useState<Wall[]>([]);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [initialLoading, setInitialLoading] = useState(true);
const currentPageRef = useRef(1);
const [loading, setLoading] = useState(true);
// 获取分类列表
useEffect(() => {
const fetchCateList = async () => {
const { data } = await getCateListAPI();
@@ -31,157 +38,107 @@ export default () => {
fetchCateList();
}, []);
// 获取留言列表
const getWallList = useCallback(
async (params: Page, append: boolean = false) => {
const id = cateList.find((item) => item.mark === cate)?.id ?? 0;
if (!id) return;
const getWallList = useCallback(async () => {
const id = cateList.find((item) => item.mark === cate)?.id ?? 0;
if (!id) return;
setLoading(true);
try {
const { data: tallList } = await getCateWallListAPI(id, params);
setLoading(true);
try {
const { data } = await getCateWallListAPI(id);
setWalls(data?.result ?? []);
} catch (error) {
console.error('获取留言列表失败:', error);
setWalls([]);
} finally {
setLoading(false);
}
}, [cate, cateList]);
if (tallList?.result && tallList?.result?.length > 0) {
if (append) {
setWalls((prev) => [...prev, ...tallList.result]);
} else {
setWalls(tallList?.result);
}
setTotalPages(tallList.pages || 1);
setHasMore((params.pageNum ?? 1) < (tallList.pages ?? 1));
currentPageRef.current = params.pageSize ?? 1;
} else {
setHasMore(false);
}
} catch (error) {
console.error('获取留言列表失败:', error);
setHasMore(false);
} finally {
setLoading(false);
setInitialLoading(false);
}
},
[cate, cateList]
);
// 初始加载和分类切换时重新加载
useEffect(() => {
if (cateList.length > 0 && cate) {
setWalls([]);
setHasMore(true);
setInitialLoading(true);
currentPageRef.current = 1;
getWallList({ pageNum: 1, pageSize: 8 }, false);
getWallList();
}
}, [cate, cateList, getWallList]);
// 滚动监听
useEffect(() => {
const handleScroll = () => {
// 如果正在加载或没有更多数据,则不处理
if (loading || !hasMore) return;
// 检查是否滚动到底部距离底部100px时触发
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
if (scrollTop + windowHeight >= documentHeight - 100) {
const nextPage = currentPageRef.current + 1;
if (nextPage <= totalPages) {
getWallList({ pageNum: nextPage, pageSize: 8 }, true);
}
}
};
// 使用防抖优化滚动事件
let timeoutId: NodeJS.Timeout;
const debouncedHandleScroll = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(handleScroll, 200);
};
window.addEventListener('scroll', debouncedHandleScroll);
return () => {
window.removeEventListener('scroll', debouncedHandleScroll);
clearTimeout(timeoutId);
};
}, [hasMore, loading, totalPages, getWallList]);
return (
<>
<div className="relative min-h-screen overflow-x-hidden bg-[#f4f4f8] text-gray-900 antialiased dark:bg-[#08080d] dark:text-white">
<title>💌 </title>
<meta name="description" content="💌 留言墙" />
{/* 背景装饰 */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute inset-0 bg-[linear-gradient(rgba(148,163,184,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.06)_1px,transparent_1px)] bg-size-[64px_64px]" />
<div className="absolute -top-1/2 left-1/2 -translate-x-1/2 w-[800px] h-[800px] rounded-full bg-primary/6 blur-[120px]" />
<div className="absolute top-1/4 right-0 w-96 h-96 rounded-full bg-violet-400/8 blur-[80px]" />
<div className="absolute bottom-1/4 left-0 w-80 h-80 rounded-full bg-cyan-400/8 blur-[80px]" />
{/* 颗粒纹理 */}
<div className="wall-grain-overlay pointer-events-none fixed inset-0 z-50 opacity-[0.025] dark:opacity-[0.03]" aria-hidden="true" />
{/* 背景渐变光斑 */}
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
<div className="absolute -top-24 left-[8%] w-[550px] h-[550px] rounded-full bg-violet-500/8 blur-[140px] dark:bg-violet-600/6" />
<div className="absolute top-[28%] right-[4%] w-[460px] h-[460px] rounded-full bg-rose-400/8 blur-[120px] dark:bg-rose-500/5" />
<div className="absolute bottom-[5%] left-[30%] w-[400px] h-[400px] rounded-full bg-cyan-400/6 blur-[100px] dark:bg-cyan-500/4" />
<div className="absolute top-[58%] left-[2%] w-[320px] h-[320px] rounded-full bg-amber-400/6 blur-[90px] dark:bg-amber-500/4" />
</div>
<div className="py-16 border-b dark:border-[#4e5969]">
<div className="relative z-10">
{/* 头部区域 */}
<div className="flex flex-col items-center px-4 pt-12 md:pt-16 pb-8">
<div className="relative text-center mb-10">
<h2 className="text-5xl mb-3"></h2>
<p className="text-sm text-gray-600 mb-4"></p>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0 w-24 h-1 bg-linear-to-r from-transparent via-blue-400 to-transparent rounded-full"></div>
</div>
<div className="mb-8">
<AddWallInfo />
</div>
</div>
{/* 分类标签 */}
<div className="flex flex-wrap justify-center gap-2 md:gap-3 px-4 mb-8">
{cateList?.map((item) => (
<Link
key={item.id}
href={`/wall/${item.mark}`}
className={`
relative px-5 py-2.5 text-sm font-medium rounded-full
transition-transform
${item.mark === cate ? 'text-white bg-primary scale-105' : 'text-gray-700 dark:text-gray-300 bg-white/80 dark:bg-gray-700/80 backdrop-blur-sm border dark:border-gray-700/50 hover:text-primary hover:scale-105'}
`}
>
{item.name}
</Link>
))}
</div>
{/* 留言卡片瀑布流 */}
{initialLoading ? (
<div className="flex justify-center items-center py-20">
<Loading />
</div>
) : (
<div className="w-[90%] xl:w-[1200px] mx-auto mt-8 pb-12">
{walls && walls.length > 0 ? (
<>
<WallMasonry walls={walls} />
{/* 加载更多指示器 */}
{loading && (
<div className="flex justify-center items-center py-8">
<div className="text-gray-500 dark:text-gray-400 text-sm">...</div>
</div>
)}
{!hasMore && walls.length > 0 && (
<div className="flex justify-center items-center py-8">
<div className="text-gray-500 dark:text-gray-400 text-sm"></div>
</div>
)}
</>
) : (
<div className="text-center py-12 text-gray-500 dark:text-gray-400"></div>
)}
</div>
)}
{/* 头部 */}
<header className="relative z-10 pt-20 pb-4 mt-10 text-center">
<div className="flex justify-center gap-3 mb-6">
{floatDots.map((dot, i) => (
<span
key={i}
className={`animate-[wall-float-dot_3s_ease-in-out_infinite] rounded-full ${dot.size} ${dot.color}`}
style={{ animationDelay: dot.delay }}
/>
))}
</div>
<h1 className={`text-6xl sm:text-7xl font-bold tracking-tight mb-5 ${zcoolKuaiLe.className}`}>
<span className="bg-size-[200%_auto] bg-linear-to-r from-amber-500 via-rose-500 to-violet-500 bg-clip-text text-transparent animate-[wall-shimmer_5s_ease_infinite] dark:from-amber-300 dark:via-rose-300 dark:to-violet-400">
</span>
</h1>
<p className="text-neutral-500 dark:text-neutral-400 text-base sm:text-lg tracking-[0.2em] font-light"></p>
<div className="mt-8 flex items-center justify-center gap-3">
<div className="w-20 h-px bg-linear-to-r from-transparent to-neutral-300 dark:to-neutral-700" />
<div className="w-2 h-2 rounded-full bg-amber-400/80 shadow-lg shadow-amber-400/20" />
<div className="w-20 h-px bg-linear-to-l from-transparent to-neutral-300 dark:to-neutral-700" />
</div>
</header>
{/* 分类标签 */}
<div className="relative z-10 flex flex-wrap justify-center gap-2 md:gap-3 px-4 mb-8">
{cateList?.map((item) => (
<Link
key={item.id}
href={`/wall/${item.mark}`}
className={`
relative px-5 py-2.5 text-sm font-medium rounded-full
transition-transform cursor-pointer
${item.mark === cate ? 'text-white bg-primary scale-105' : 'text-gray-700 dark:text-gray-300 bg-white/80 dark:bg-gray-700/80 backdrop-blur-sm border dark:border-gray-700/50 hover:text-primary hover:scale-105'}
`}
>
{item.name}
</Link>
))}
</div>
</>
{/* 留言卡片瀑布流 */}
<main className="relative z-10 max-w-7xl mx-auto px-6 sm:px-10 pb-24">
{loading ? (
<div className="flex justify-center items-center py-20">
<Loading />
</div>
) : walls.length > 0 ? (
<div className="py-6">
<WallMasonry walls={walls} />
</div>
) : (
<div className="text-center py-12 text-neutral-500 dark:text-neutral-400"></div>
)}
</main>
<AddWallInfo />
<footer className="relative z-10 text-center pb-16 pt-4">
<p className="text-neutral-400 dark:text-neutral-600 text-sm tracking-[0.3em] font-light"> </p>
</footer>
</div>
);
};

View File

@@ -96,9 +96,14 @@ export default () => {
return (
<>
<Button color="primary" onPress={onOpen}>
</Button>
<button
type="button"
onClick={onOpen}
aria-label="写留言"
className="fixed bottom-8 right-8 z-50 w-14 h-14 rounded-full bg-linear-to-br from-amber-400 to-rose-500 text-white shadow-xl shadow-rose-500/25 flex items-center justify-center text-3xl font-light transition-all duration-300 hover:scale-110 hover:shadow-2xl hover:shadow-rose-500/40 cursor-pointer leading-none"
>
+
</button>
<Modal
open={isOpen}

View File

@@ -1,20 +0,0 @@
// 留言墙瀑布流样式优化
.wall-masonry-grid {
margin-left: -20px; // 调整左侧间距
@media (min-width: 640px) {
margin-left: -15px;
}
@media (min-width: 768px) {
margin-left: -20px;
}
@media (min-width: 1024px) {
margin-left: -24px;
}
}
.wall-masonry-grid .masonry-grid_column {
padding-left: 12px; // 列间距
}

View File

@@ -1,21 +1,41 @@
'use client';
import Masonry from 'react-masonry-css';
import dayjs from 'dayjs';
import { Wall } from '@/types/app/wall';
import { getRelativeTimeLabel } from '@/utils';
import '@/components/ArticleLayout/Waterfall/index.scss';
import './index.scss';
// 颜色映射表,将颜色值映射到对应的 Tailwind 类名
const colorMap: Record<string, string> = {
'#fcafa24d': 'bg-[#fcafa24d]',
'#a8ed8a4d': 'bg-[#a8ed8a4d]',
'#caa7f74d': 'bg-[#caa7f74d]',
'#ffe3944d': 'bg-[#ffe3944d]',
'#92e6f54d': 'bg-[#92e6f54d]',
const colorThemes: Record<string, { bg: string; pushpin: string; badge: string }> = {
'#fcafa24d': {
bg: 'linear-gradient(145deg,#FFE4E6,#FECDD3)',
pushpin: 'linear-gradient(135deg,#fb7185,#e11d48)',
badge: 'bg-rose-400/25 text-rose-800',
},
'#a8ed8a4d': {
bg: 'linear-gradient(145deg,#D1FAE5,#A7F3D0)',
pushpin: 'linear-gradient(135deg,#34d399,#059669)',
badge: 'bg-emerald-400/25 text-emerald-800',
},
'#caa7f74d': {
bg: 'linear-gradient(145deg,#EDE9FE,#DDD6FE)',
pushpin: 'linear-gradient(135deg,#a78bfa,#7c3aed)',
badge: 'bg-violet-400/25 text-violet-800',
},
'#ffe3944d': {
bg: 'linear-gradient(145deg,#FEF3C7,#FDE68A)',
pushpin: 'linear-gradient(135deg,#fbbf24,#d97706)',
badge: 'bg-amber-400/25 text-amber-800',
},
'#92e6f54d': {
bg: 'linear-gradient(145deg,#DBEAFE,#BFDBFE)',
pushpin: 'linear-gradient(135deg,#60a5fa,#2563eb)',
badge: 'bg-blue-400/25 text-blue-800',
},
};
// 瀑布流断点配置
const defaultTheme = colorThemes['#ffe3944d'];
const rotations = [-3, 2.5, -1.8, 4, -2.8, 1.5, -3.5, 3, -2, 1.8, -3.2, 2.5];
const breakpointColumnsObj = {
default: 4,
1024: 3,
@@ -23,53 +43,51 @@ const breakpointColumnsObj = {
640: 1,
};
const msgCardClass =
'wall-paper-texture relative mb-14 cursor-default rounded-2xl p-6 ' +
'shadow-[0_2px_8px_-2px_rgba(0,0,0,0.06)] dark:shadow-[0_2px_8px_-2px_rgba(0,0,0,0.18)] ' +
'transition-[rotate,scale,translate,box-shadow,opacity] duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] ' +
'animate-[wall-card-enter_0.65s_ease_forwards] ' +
'hover:rotate-0! hover:scale-[1.07] hover:-translate-y-2.5 hover:z-30 ' +
'hover:shadow-[0_10px_24px_-6px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_10px_24px_-6px_rgba(0,0,0,0.28)]';
const pushpinClass =
'absolute -top-2 left-1/2 z-[5] size-4 -translate-x-1/2 rounded-full ' +
'shadow-[0_2px_6px_rgba(0,0,0,0.35),inset_0_-3px_6px_rgba(0,0,0,0.15),inset_0_3px_6px_rgba(255,255,255,0.4)] ' +
"after:absolute after:top-[3px] after:left-1 after:size-[5px] after:rounded-full after:bg-white/45 after:content-['']";
interface WallMasonryProps {
walls: Wall[];
}
export default ({ walls }: WallMasonryProps) => {
return (
<Masonry breakpointCols={breakpointColumnsObj} className="masonry-grid wall-masonry-grid" columnClassName="masonry-grid_column">
<Masonry
breakpointCols={breakpointColumnsObj}
className="masonry-grid -ml-5 sm:ml-[-15px] md:-ml-5 lg:-ml-6"
columnClassName="masonry-grid_column pl-3"
>
{walls.map((item, index) => {
const bgColor = colorMap[item.color] || 'bg-[#ffe3944d]';
const theme = colorThemes[item.color] || defaultTheme;
const rotate = rotations[index % rotations.length];
return (
<div
key={item.id}
className={`
group relative flex flex-col p-5 rounded-2xl
${bgColor}
backdrop-blur-sm
border border-white/30 dark:border-gray-700/30
cursor-pointer overflow-hidden mb-3 animate-fade-in-up
hover:scale-105 hover:-translate-y-1 transition-transform
transform-gpu
`}
className={msgCardClass}
style={{
animationDelay: `${index * 50}ms`,
rotate: `${rotate}deg`,
background: theme.bg,
animationDelay: `${index * 90 + 300}ms`,
}}
>
{/* 卡片装饰边框 */}
<div className="absolute inset-0 rounded-2xl border-2 border-white/20 dark:border-gray-600/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
{/* 顶部信息 */}
<div className="flex justify-between items-center mb-3 pb-3 border-b border-white/20 dark:border-gray-700/30">
<span className="text-xs text-gray-600 dark:text-gray-400 font-medium">{dayjs(+item.createTime!).format('YYYY-MM-DD HH:mm')}</span>
<span className="px-2.5 py-1 text-xs font-semibold backdrop-blur-sm text-gray-700 dark:text-white bg-white/60 dark:bg-gray-800/60 rounded-full transition-transform group-hover:scale-105">{item.cate.name}</span>
<div className={pushpinClass} style={{ background: theme.pushpin }} />
<span className={`mb-3 inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${theme.badge}`}>{item.cate.name}</span>
<p className="hide_sliding mb-5 min-h-16 overflow-auto text-sm leading-relaxed text-stone-800">{item.content}</p>
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-stone-600 dark:text-black">{item.name ? item.name : '匿名'}</span>
<span className="text-stone-400 dark:text-stone-600">{getRelativeTimeLabel(item.createTime)}</span>
</div>
{/* 留言内容 */}
<div className="flex-1 hide_sliding overflow-auto min-h-20 text-sm md:text-base leading-relaxed text-gray-800 dark:text-gray-200 my-3 px-1">{item.content}</div>
{/* 底部署名 */}
<div className="flex justify-end items-center mt-4 pt-3 border-t border-white/20 dark:border-gray-700/30">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400"></span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 transition-transform group-hover:translate-x-[-2px]">{item.name ? item.name : '匿名'}</span>
</div>
</div>
{/* Hover 时的光效 */}
<div className="absolute inset-0 bg-linear-to-br from-white/0 via-white/0 to-white/20 dark:from-transparent dark:via-transparent dark:to-white/5 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
</div>
);
})}

46
src/app/wall/wall.css Normal file
View File

@@ -0,0 +1,46 @@
@keyframes wall-shimmer {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@keyframes wall-float-dot {
0%,
100% {
transform: translateY(0);
opacity: 0.6;
}
50% {
transform: translateY(-14px);
opacity: 1;
}
}
@keyframes wall-card-enter {
from {
opacity: 0;
translate: 0 55px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.wall-grain-overlay {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.wall-paper-texture::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0.035;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23p)' opacity='0.5'/%3E%3C/svg%3E");
}

View File

@@ -26,7 +26,7 @@ const toneStyles: Record<ActionCardTone, { card: string; divider: string }> = {
card: cn(
'border-rose-100/90 bg-linear-to-br from-rose-50/80 via-white to-orange-50/40',
'shadow-[0_4px_20px_-4px_rgba(244,63,94,0.15)]',
'dark:border-rose-500/15 dark:from-rose-500/8 dark:via-white/[0.02] dark:to-orange-500/5 dark:shadow-none',
'dark:border-rose-500/15 dark:from-rose-500/8 dark:via-white/2 dark:to-orange-500/5 dark:shadow-none',
),
divider: 'via-rose-200/80 dark:via-rose-500/25',
},
@@ -35,7 +35,7 @@ const toneStyles: Record<ActionCardTone, { card: string; divider: string }> = {
'border-slate-200/90 bg-linear-to-br from-slate-50/90 via-white to-blue-50/40',
'shadow-[0_4px_20px_-4px_rgba(83,157,253,0.12)]',
'hover:border-primary/30 hover:shadow-[0_4px_24px_-4px_rgba(83,157,253,0.22)]',
'dark:border-white/10 dark:from-white/[0.03] dark:via-transparent dark:to-primary/5 dark:shadow-none',
'dark:border-white/10 dark:from-white/3 dark:via-transparent dark:to-primary/5 dark:shadow-none',
),
divider: 'via-blue-200/70 dark:via-primary/25',
},

View File

@@ -39,4 +39,28 @@ export function dayFormat(timestamp: number | string) {
} else {
return target.fromNow();
}
}
export function getRelativeTimeLabel(ts: string | number | Date | undefined): string {
if (ts == null) return '';
const now = dayjs();
const then = dayjs(+ts);
const diffDays = now.startOf('day').diff(then.startOf('day'), 'day');
const diffMonths = now.diff(then, 'month', true);
const diffYears = now.diff(then, 'year', true);
if (diffDays === 0) return `今天 ${then.format('HH:mm')}`;
if (diffDays === 1) return `昨天 ${then.format('HH:mm')}`;
if (diffDays >= 2 && diffDays <= 6) return `${diffDays}天前`;
if (diffDays >= 7 && diffDays <= 13) return '一周前';
if (diffDays >= 14 && diffDays <= 20) return '两周前';
if (diffDays >= 21 && diffDays <= 27) return '三周前';
if (diffMonths >= 1 && diffMonths < 2) return '一月前';
if (diffMonths >= 2 && diffMonths < 3) return '两月前';
if (diffMonths >= 3 && diffMonths < 6) return '三月前';
if (diffMonths >= 6 && diffMonths < 12) return '半年前';
if (diffYears >= 1 && diffYears < 2) return '一年前';
if (diffYears >= 2 && diffYears < 3) return '两年前';
if (diffYears >= 3) return `${Math.floor(diffYears)}年前`;
return '';
}