From 349760b60c2dfd1d476a2fb65bc265d0e92ed370 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 18 Mar 2026 16:48:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=BB=E5=8A=A8=E7=AB=AFUI=E7=B2=BE?= =?UTF-8?q?=E8=87=B4=E5=8C=96=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加页面过渡动画(淡入+滑动) - 优化统计卡片视觉,添加渐变色和微动效 - 实现列表项左滑删除手势(编辑+删除按钮) - 优化底部导航,缩小高度并添加点击波纹效果 - 改进空状态,添加插画和引导按钮 Co-Authored-By: Claude --- frontend/components/EmptyState.js | 27 +++ frontend/components/PageTransition.js | 41 ++++ frontend/components/SwipeableItem.js | 93 ++++++++++ frontend/pages/_app.js | 5 +- frontend/pages/index.js | 82 ++++++-- frontend/styles/globals.css | 258 +++++++++++++++++++++++--- 6 files changed, 466 insertions(+), 40 deletions(-) create mode 100644 frontend/components/EmptyState.js create mode 100644 frontend/components/PageTransition.js create mode 100644 frontend/components/SwipeableItem.js 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 ( - + + + )} + > +
-
- {record.type === 'income' ? '+' : '-'} - {formatMoney(record.amount)} -
- +
+ {record.type === 'income' ? '+' : '-'} + {formatMoney(record.amount)} +
+ + ); })} diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index 7926c48..6f8a6eb 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -564,24 +564,77 @@ textarea:focus-visible { text-align: left; border: 1px solid rgba(148, 163, 184, 0.12); background: rgba(255, 255, 255, 0.82); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45), 0 2px 8px rgba(15, 23, 42, 0.04); cursor: pointer; transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition), background var(--transition); } .stat-card:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-xs); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.stat-card:active { + transform: translateY(0) scale(0.98); } .stat-card.active { border-color: rgba(37, 99, 235, 0.3); background: rgba(240, 246, 255, 0.96); - box-shadow: 0 10px 24px rgba(37, 99, 235, 0.08); + box-shadow: 0 10px 28px rgba(37, 99, 235, 0.12); +} + +/* Income stat card with gradient */ +.stat-card:has(.income) { + background: linear-gradient(135deg, rgba(5, 150, 105, 0.08) 0%, rgba(5, 150, 105, 0.02) 100%); + border-color: rgba(5, 150, 105, 0.15); +} + +.stat-card:has(.income):hover { + background: linear-gradient(135deg, rgba(5, 150, 105, 0.12) 0%, rgba(5, 150, 105, 0.04) 100%); + border-color: rgba(5, 150, 105, 0.22); + box-shadow: 0 8px 24px rgba(5, 150, 105, 0.1); +} + +.stat-card:has(.income).active { + background: linear-gradient(135deg, rgba(5, 150, 105, 0.16) 0%, rgba(5, 150, 105, 0.06) 100%); + border-color: rgba(5, 150, 105, 0.3); + box-shadow: 0 10px 28px rgba(5, 150, 105, 0.14); +} + +/* Expense stat card with gradient */ +.stat-card:has(.expense) { + background: linear-gradient(135deg, rgba(220, 38, 38, 0.08) 0%, rgba(220, 38, 38, 0.02) 100%); + border-color: rgba(220, 38, 38, 0.15); +} + +.stat-card:has(.expense):hover { + background: linear-gradient(135deg, rgba(220, 38, 38, 0.12) 0%, rgba(220, 38, 38, 0.04) 100%); + border-color: rgba(220, 38, 38, 0.22); + box-shadow: 0 8px 24px rgba(220, 38, 38, 0.1); +} + +.stat-card:has(.expense).active { + background: linear-gradient(135deg, rgba(220, 38, 38, 0.16) 0%, rgba(220, 38, 38, 0.06) 100%); + border-color: rgba(220, 38, 38, 0.3); + box-shadow: 0 10px 28px rgba(220, 38, 38, 0.14); } .stat-card--balance { - background: rgba(255, 255, 255, 0.82); + background: linear-gradient(135deg, rgba(37, 99, 235, 0.08) 0%, rgba(37, 99, 235, 0.02) 100%); + border-color: rgba(37, 99, 235, 0.15); +} + +.stat-card--balance:hover { + background: linear-gradient(135deg, rgba(37, 99, 235, 0.12) 0%, rgba(37, 99, 235, 0.04) 100%); + border-color: rgba(37, 99, 235, 0.22); + box-shadow: 0 8px 24px rgba(37, 99, 235, 0.1); +} + +.stat-card--balance.active { + background: linear-gradient(135deg, rgba(37, 99, 235, 0.16) 0%, rgba(37, 99, 235, 0.06) 100%); + border-color: rgba(37, 99, 235, 0.3); + box-shadow: 0 10px 28px rgba(37, 99, 235, 0.14); } .label { @@ -1817,31 +1870,53 @@ textarea:focus-visible { .bottom-nav__inner { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 6px; - padding: 10px 10px 11px; - border-radius: 22px 22px 0 0; - background: rgba(255, 255, 255, 0.84); - border: 1px solid rgba(255, 255, 255, 0.66); - box-shadow: 0 -12px 30px rgba(15, 23, 42, 0.06); + gap: 4px; + padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); + border-radius: 20px 20px 0 0; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(255, 255, 255, 0.7); + box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.05); backdrop-filter: blur(24px); } .bottom-nav__item { - min-height: 54px; - display: grid; - justify-items: center; - align-content: center; - gap: 4px; - padding: 7px 4px; - border-radius: 16px; + min-height: 48px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + padding: 5px 4px; + border-radius: 14px; color: var(--text-tertiary); cursor: pointer; transition: background var(--transition), color var(--transition), transform var(--transition); + position: relative; + overflow: hidden; +} + +.bottom-nav__item::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(37, 99, 235, 0.1); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: width 0.3s ease, height 0.3s ease; +} + +.bottom-nav__item:active::before { + width: 100%; + height: 100%; } .bottom-nav__item span { - font-size: 0.74rem; - font-weight: 700; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; } .bottom-nav__item.is-active { @@ -1849,6 +1924,18 @@ textarea:focus-visible { color: var(--primary); } +.bottom-nav__item.is-active::before { + display: none; +} + +.bottom-nav__item .icon { + transition: transform 0.2s ease; +} + +.bottom-nav__item:active .icon { + transform: scale(0.9); +} + .checkbox-label { display: inline-flex; align-items: center; @@ -2191,6 +2278,133 @@ textarea:focus-visible { to { transform: rotate(360deg); } } -.is-spinning { - animation: spin 1s linear infinite; +/* Swipeable Item */ +.swipeable-item { + position: relative; + overflow: hidden; +} + +.swipeable-content { + position: relative; + z-index: 1; + background: var(--surface); + cursor: grab; +} + +.swipeable-content.is-swiping { + cursor: grabbing; +} + +.swipeable-actions { + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 0 12px; + background: transparent; + z-index: 0; +} + +.swipeable-action { + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 14px; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.swipeable-action:active { + transform: scale(0.92); +} + +.swipeable-action--edit { + background: var(--primary-soft); + color: var(--primary); +} + +.swipeable-action--delete { + background: var(--expense-soft); + color: var(--expense); +} + +/* Empty State Enhanced */ +.empty-state-enhanced { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 24px; + text-align: center; +} + +.empty-state-illustration { + position: relative; + margin-bottom: 20px; +} + +.empty-state-bg { + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 24px; + background: linear-gradient(135deg, rgba(37, 99, 235, 0.1) 0%, rgba(37, 99, 235, 0.04) 100%); + color: var(--primary); + position: relative; +} + +.empty-state-bg::before { + content: ''; + position: absolute; + inset: -8px; + background: radial-gradient(circle, rgba(37, 99, 235, 0.08) 0%, transparent 70%); + border-radius: 50%; + z-index: -1; +} + +.empty-state-title { + font-size: 1.1rem; + font-weight: 700; + color: var(--text); + margin-bottom: 8px; +} + +.empty-state-description { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 20px; + max-width: 240px; +} + +.empty-state-action { + min-width: 140px; + min-height: 44px; +} + +.empty-state-action .icon { + margin-right: 6px; +} +.page-transition { + opacity: 1; + transform: translateY(0); + transition: opacity 250ms ease-out, transform 250ms ease-out; +} + +.page-transition--entering { + opacity: 0; + transform: translateY(12px); +} + +.page-transition--active { + opacity: 1; + transform: translateY(0); }