feat: 移动端UI精致化优化
- 添加页面过渡动画(淡入+滑动) - 优化统计卡片视觉,添加渐变色和微动效 - 实现列表项左滑删除手势(编辑+删除按钮) - 优化底部导航,缩小高度并添加点击波纹效果 - 改进空状态,添加插画和引导按钮 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Icon } from './icons';
|
||||
|
||||
export default function EmptyState({
|
||||
icon = 'info',
|
||||
title = '暂无数据',
|
||||
description = '这里还没有内容',
|
||||
action,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state-enhanced">
|
||||
<div className="empty-state-illustration">
|
||||
<div className="empty-state-bg">
|
||||
<Icon name={icon} size={32} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="empty-state-title">{title}</h3>
|
||||
{description && <p className="empty-state-description">{description}</p>}
|
||||
{action && (
|
||||
<button type="button" className="btn btn-primary empty-state-action" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function PageTransition({ children }) {
|
||||
const router = useRouter();
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [displayChildren, setDisplayChildren] = useState(children);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChangeStart = () => {
|
||||
setIsAnimating(true);
|
||||
};
|
||||
|
||||
const handleRouteChangeComplete = () => {
|
||||
setDisplayChildren(children);
|
||||
setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 50);
|
||||
};
|
||||
|
||||
router.events.on('routeChangeStart', handleRouteChangeStart);
|
||||
router.events.on('routeChangeComplete', handleRouteChangeComplete);
|
||||
|
||||
return () => {
|
||||
router.events.off('routeChangeStart', handleRouteChangeStart);
|
||||
router.events.off('routeChangeComplete', handleRouteChangeComplete);
|
||||
};
|
||||
}, [router, children]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnimating) {
|
||||
setDisplayChildren(children);
|
||||
}
|
||||
}, [children, isAnimating]);
|
||||
|
||||
return (
|
||||
<div className={`page-transition ${isAnimating ? 'page-transition--entering' : 'page-transition--active'}`}>
|
||||
{displayChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
export default function SwipeableItem({
|
||||
children,
|
||||
onSwipeLeft,
|
||||
renderRightActions,
|
||||
swipeThreshold = 80,
|
||||
}) {
|
||||
const [isSwiping, setIsSwiping] = useState(false);
|
||||
const [translateX, setTranslateX] = useState(0);
|
||||
const [isRevealed, setIsRevealed] = useState(false);
|
||||
const startXRef = useRef(0);
|
||||
const currentXRef = useRef(0);
|
||||
const itemRef = useRef(null);
|
||||
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
startXRef.current = e.touches[0].clientX;
|
||||
currentXRef.current = startXRef.current;
|
||||
setIsSwiping(true);
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (!isSwiping) return;
|
||||
|
||||
currentXRef.current = e.touches[0].clientX;
|
||||
const diff = currentXRef.current - startXRef.current;
|
||||
|
||||
// Only allow swiping left (negative diff)
|
||||
if (diff < 0) {
|
||||
// Limit the swipe distance
|
||||
const newTranslateX = Math.max(diff, -swipeThreshold - 20);
|
||||
setTranslateX(newTranslateX);
|
||||
} else if (isRevealed) {
|
||||
// If already revealed, allow swiping right to close
|
||||
const newTranslateX = Math.min(0, -swipeThreshold + diff);
|
||||
setTranslateX(newTranslateX);
|
||||
}
|
||||
}, [isSwiping, isRevealed, swipeThreshold]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setIsSwiping(false);
|
||||
const diff = currentXRef.current - startXRef.current;
|
||||
|
||||
if (diff < -swipeThreshold / 2 || (isRevealed && diff > -swipeThreshold / 2)) {
|
||||
// Reveal actions
|
||||
setTranslateX(-swipeThreshold);
|
||||
setIsRevealed(true);
|
||||
} else {
|
||||
// Snap back
|
||||
setTranslateX(0);
|
||||
setIsRevealed(false);
|
||||
}
|
||||
}, [isRevealed, swipeThreshold]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setTranslateX(0);
|
||||
setIsRevealed(false);
|
||||
}, []);
|
||||
|
||||
const handleActionClick = useCallback((callback) => {
|
||||
callback?.();
|
||||
handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
return (
|
||||
<div className="swipeable-item" ref={itemRef}>
|
||||
<div
|
||||
className={`swipeable-content ${isSwiping ? 'is-swiping' : ''}`}
|
||||
style={{
|
||||
transform: `translateX(${translateX}px)`,
|
||||
transition: isSwiping ? 'none' : 'transform 0.2s ease-out',
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{renderRightActions && (
|
||||
<div
|
||||
className="swipeable-actions"
|
||||
style={{
|
||||
opacity: isRevealed || translateX < 0 ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{renderRightActions(handleActionClick)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user