diff --git a/frontend/components/EmptyState.js b/frontend/components/EmptyState.js
new file mode 100644
index 0000000..0153da4
--- /dev/null
+++ b/frontend/components/EmptyState.js
@@ -0,0 +1,27 @@
+import { Icon } from './icons';
+
+export default function EmptyState({
+ icon = 'info',
+ title = '暂无数据',
+ description = '这里还没有内容',
+ action,
+ actionLabel,
+ onAction,
+}) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+ {action && (
+
+ )}
+
+ );
+}
diff --git a/frontend/components/PageTransition.js b/frontend/components/PageTransition.js
new file mode 100644
index 0000000..e5daa37
--- /dev/null
+++ b/frontend/components/PageTransition.js
@@ -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 (
+
+ {displayChildren}
+
+ );
+}
diff --git a/frontend/components/SwipeableItem.js b/frontend/components/SwipeableItem.js
new file mode 100644
index 0000000..eed4897
--- /dev/null
+++ b/frontend/components/SwipeableItem.js
@@ -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 (
+
+
+ {children}
+
+ {renderRightActions && (
+
+ {renderRightActions(handleActionClick)}
+
+ )}
+
+ );
+}
diff --git a/frontend/pages/_app.js b/frontend/pages/_app.js
index 798f429..9046097 100644
--- a/frontend/pages/_app.js
+++ b/frontend/pages/_app.js
@@ -2,6 +2,7 @@ import { SWRConfig } from 'swr';
import ErrorBoundary from '../components/ErrorBoundary';
import { ToastProvider } from '../components/ToastProvider';
import { ConfirmProvider } from '../components/ConfirmProvider';
+import PageTransition from '../components/PageTransition';
import { swrConfig } from '../lib/swr-config';
import '../styles/globals.css';
@@ -11,7 +12,9 @@ export default function App({ Component, pageProps }) {
-
+
+
+
diff --git a/frontend/pages/index.js b/frontend/pages/index.js
index 8d4da8f..6e1de03 100644
--- a/frontend/pages/index.js
+++ b/frontend/pages/index.js
@@ -4,10 +4,12 @@ import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRec
import AppShell from '../components/AppShell';
import { Icon } from '../components/icons';
import RecordModal from '../components/RecordModal';
+import SwipeableItem from '../components/SwipeableItem';
+import EmptyState from '../components/EmptyState';
import { useToast } from '../components/ToastProvider';
import { useConfirm } from '../components/ConfirmProvider';
import { useCategories } from '../hooks/useCategories';
-import { getMonthStr, formatMoney, formatMonth, parseDateTime, getDatePart } from '../lib/utils';
+import { getMonthStr, formatMoney, formatMonth, parseDateTime } from '../lib/utils';
export default function Home() {
const router = useRouter();
@@ -191,6 +193,23 @@ export default function Home() {
}
};
+ const handleQuickDelete = async (record) => {
+ const shouldDelete = await confirm({
+ title: '删除这条记录?',
+ description: `${record.category} ${record.type === 'income' ? '+' : '-'}${formatMoney(record.amount)}`,
+ confirmText: '删除',
+ });
+ if (!shouldDelete) return;
+
+ try {
+ await deleteRecord(record.id);
+ setRecords(prev => prev.filter(r => r.id !== record.id));
+ showToast('记录已删除', { tone: 'success' });
+ } catch (err) {
+ showToast(err.message, { tone: 'error' });
+ }
+ };
+
const groupedRecords = useMemo(() => {
return records.reduce((groups, record) => {
const date = getDatePart(record.date);
@@ -286,10 +305,14 @@ export default function Home() {
{loading ? (
加载中...
) : records.length === 0 ? (
-
+
) : (
{sortedDates.map((date) => (
@@ -301,19 +324,44 @@ export default function Home() {
{groupedRecords[date].map((record) => {
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
return (
-