support local storage

This commit is contained in:
songtianlun 2025-06-14 20:52:15 +08:00
parent aacc770d59
commit 2a60e35fc5
6 changed files with 767 additions and 294 deletions

View File

@ -1,5 +1,6 @@
import FinanceCalculator from "@/components/finance-calculator" import FinanceCalculator from "@/components/finance-calculator"
import { UserNav } from "@/components/auth/user-nav" import { UserNav } from "@/components/auth/user-nav"
import { LoginSuggestion } from "@/components/login-suggestion"
import { Metadata } from "next" import { Metadata } from "next"
export const metadata: Metadata = { export const metadata: Metadata = {
@ -49,6 +50,9 @@ export default function Home() {
</div> </div>
</div> </div>
</div> </div>
{/* 登录建议组件 */}
<LoginSuggestion />
</div> </div>
<FinanceCalculator /> <FinanceCalculator />

View File

@ -0,0 +1,150 @@
"use client"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Upload, Download, AlertCircle, Loader2 } from "lucide-react"
import { LocalFinanceProduct } from "@/hooks/use-local-storage"
interface DataSyncDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
localProducts: LocalFinanceProduct[]
onSyncConfirm: () => Promise<void>
onSyncCancel: () => void
isSyncing: boolean
}
export function DataSyncDialog({
open,
onOpenChange,
localProducts,
onSyncConfirm,
onSyncCancel,
isSyncing
}: DataSyncDialogProps) {
const [selectedAction, setSelectedAction] = useState<'sync' | 'skip' | null>(null)
const handleConfirm = async () => {
if (selectedAction === 'sync') {
await onSyncConfirm()
} else {
onSyncCancel()
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-amber-500" />
</DialogTitle>
<DialogDescription>
{localProducts.length}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Alert className="border-blue-200 bg-blue-50">
<Upload className="h-4 w-4" />
<AlertDescription>
<strong></strong>
<ul className="mt-2 text-sm space-y-1">
<li> {localProducts.length} </li>
<li> : ¥{localProducts.reduce((sum, p) => sum + p.principal, 0).toLocaleString()}</li>
<li> : {Array.from(new Set(localProducts.map(p => p.currency))).join(', ')}</li>
</ul>
</AlertDescription>
</Alert>
<div className="space-y-3">
<div
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedAction === 'sync'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-blue-300'
}`}
onClick={() => setSelectedAction('sync')}
>
<div className="flex items-start space-x-3">
<input
type="radio"
name="syncAction"
checked={selectedAction === 'sync'}
onChange={() => setSelectedAction('sync')}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Upload className="h-4 w-4 text-blue-600" />
<h4 className="font-medium text-blue-800"></h4>
</div>
<p className="text-sm text-gray-600">
</p>
</div>
</div>
</div>
<div
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedAction === 'skip'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-blue-300'
}`}
onClick={() => setSelectedAction('skip')}
>
<div className="flex items-start space-x-3">
<input
type="radio"
name="syncAction"
checked={selectedAction === 'skip'}
onChange={() => setSelectedAction('skip')}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Download className="h-4 w-4 text-gray-600" />
<h4 className="font-medium text-gray-800">使</h4>
</div>
<p className="text-sm text-gray-600">
</p>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="flex justify-between">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSyncing}
>
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedAction || isSyncing}
>
{isSyncing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSyncing ? '处理中...' : '确认'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { CalendarIcon, PlusCircle, Trash2, LogIn, Edit, Check, X } from "lucide-react" import { CalendarIcon, PlusCircle, Trash2, Edit, Check, X } from "lucide-react"
import { Calendar } from "@/components/ui/calendar" import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@ -17,6 +17,9 @@ import { zhCN } from "date-fns/locale"
import { Alert, AlertDescription } from "@/components/ui/alert" import { Alert, AlertDescription } from "@/components/ui/alert"
import { toast } from "sonner" import { toast } from "sonner"
import AdBanner from "@/components/AdBanner" import AdBanner from "@/components/AdBanner"
import { useLocalStorage, LocalFinanceProduct } from "@/hooks/use-local-storage"
import { useDataSync } from "@/hooks/use-data-sync"
import { DataSyncDialog } from "@/components/data-sync-dialog"
interface FinanceProduct { interface FinanceProduct {
id: string id: string
@ -48,19 +51,36 @@ interface NewProduct {
export default function FinanceCalculator() { export default function FinanceCalculator() {
const { data: session, status } = useSession() const { data: session, status } = useSession()
const [products, setProducts] = useState<FinanceProduct[]>([])
const [newProduct, setNewProduct] = useState<NewProduct>(createEmptyProduct())
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
// 编辑相关状态 // 服务器数据状态
const [editingProductId, setEditingProductId] = useState<string | null>(null) const [serverProducts, setServerProducts] = useState<FinanceProduct[]>([])
const [editingProduct, setEditingProduct] = useState<NewProduct>(createEmptyProduct()) const [isServerLoading, setIsServerLoading] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
// 本地数据hooks
const {
products: localProducts,
isLoading: isLocalLoading,
addProduct: addLocalProduct,
updateProduct: updateLocalProduct,
removeProduct: removeLocalProduct,
hasLocalData,
getAllLocalData
} = useLocalStorage()
// 数据同步hooks
const { isSyncing, syncLocalToServer, fetchServerData } = useDataSync()
// 同步对话框状态
const [showSyncDialog, setShowSyncDialog] = useState(false)
const [hasCheckedSync, setHasCheckedSync] = useState(false)
// 获取当前使用的产品列表
const currentProducts = status === 'authenticated' ? serverProducts : localProducts
function createEmptyProduct(): NewProduct { // 创建空产品的函数
const lastProduct = products.length > 0 ? products[products.length - 1] : null function createEmptyProduct(products?: FinanceProduct[]): NewProduct {
const productList = products || currentProducts
const lastProduct = productList.length > 0 ? productList[productList.length - 1] : null
return { return {
principal: lastProduct ? lastProduct.principal : 0, principal: lastProduct ? lastProduct.principal : 0,
depositDate: null, depositDate: null,
@ -71,56 +91,71 @@ export default function FinanceCalculator() {
notes: null, notes: null,
} }
} }
// 表单状态
const [newProduct, setNewProduct] = useState<NewProduct>(() => createEmptyProduct([]))
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// 编辑相关状态
const [editingProductId, setEditingProductId] = useState<string | null>(null)
const [editingProduct, setEditingProduct] = useState<NewProduct>(() => createEmptyProduct([]))
const [isUpdating, setIsUpdating] = useState(false)
// 从数据库加载产品 // 从服务器加载产品
async function loadProducts() { async function loadServerProducts() {
if (!session?.user?.id) return if (!session?.user?.id) return
setIsLoading(true) setIsServerLoading(true)
try { try {
const response = await fetch('/api/products') const data = await fetchServerData()
if (response.ok) { setServerProducts(data)
const data = await response.json()
// 转换日期字符串为Date对象
const productsWithDates = data.map((product: any) => ({
...product,
depositDate: product.depositDate ? new Date(product.depositDate) : null,
endDate: product.endDate ? new Date(product.endDate) : null,
createdAt: product.createdAt ? new Date(product.createdAt) : undefined,
updatedAt: product.updatedAt ? new Date(product.updatedAt) : undefined,
}))
setProducts(productsWithDates)
} else {
toast.error('加载产品数据失败')
}
} catch (error) { } catch (error) {
toast.error('加载产品数据失败') console.error('Failed to load server products:', error)
} finally { } finally {
setIsLoading(false) setIsServerLoading(false)
} }
} }
// 当用户登录状态改变时加载产品 // 当用户登录状态改变时处理数据加载和同步
useEffect(() => { useEffect(() => {
if (status === 'authenticated') { if (status === 'authenticated') {
loadProducts() loadServerProducts()
// 检查是否有本地数据需要同步
if (!hasCheckedSync && hasLocalData()) {
setShowSyncDialog(true)
setHasCheckedSync(true)
}
} else if (status === 'unauthenticated') { } else if (status === 'unauthenticated') {
setProducts([]) setServerProducts([])
setHasCheckedSync(false)
} }
}, [status, session?.user?.id]) }, [status, session?.user?.id])
async function handleAddProduct() { // 数据同步处理
if (!session?.user?.id) { const handleSyncConfirm = async () => {
toast.error('请先登录') const localData = getAllLocalData()
return const success = await syncLocalToServer(localData)
if (success) {
// 重新加载服务器数据
await loadServerProducts()
} }
}
const handleSyncCancel = async () => {
// 直接从服务器加载数据,不同步本地数据
await loadServerProducts()
}
// 添加产品
async function handleAddProduct() {
if (newProduct.principal <= 0) { if (newProduct.principal <= 0) {
setError("本金必须大于0") setError("本金必须大于0")
return return
} }
// 更新验证逻辑:如果有完整的时间和净值信息,则不需要年化利率 // 更新验证逻辑
const hasCompleteInfo = newProduct.depositDate && newProduct.endDate && newProduct.principal && newProduct.currentNetValue const hasCompleteInfo = newProduct.depositDate && newProduct.endDate && newProduct.principal && newProduct.currentNetValue
const hasEitherValue = newProduct.currentNetValue !== null || newProduct.annualRate !== null const hasEitherValue = newProduct.currentNetValue !== null || newProduct.annualRate !== null
@ -133,65 +168,59 @@ export default function FinanceCalculator() {
setError(null) setError(null)
try { try {
const requestData = { if (status === 'authenticated') {
principal: newProduct.principal, // 已登录:保存到服务器
depositDate: newProduct.depositDate?.toISOString() || null, const requestData = {
endDate: newProduct.endDate?.toISOString() || null, principal: newProduct.principal,
currentNetValue: newProduct.currentNetValue, depositDate: newProduct.depositDate?.toISOString() || null,
annualRate: newProduct.annualRate, endDate: newProduct.endDate?.toISOString() || null,
currency: newProduct.currency, currentNetValue: newProduct.currentNetValue,
notes: newProduct.notes, annualRate: newProduct.annualRate,
} currency: newProduct.currency,
notes: newProduct.notes,
if (process.env.NODE_ENV === 'development') {
console.log('Sending data to API:', requestData)
}
const response = await fetch('/api/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
})
const responseData = await response.json()
if (process.env.NODE_ENV === 'development') {
console.log('API response:', responseData)
}
if (response.ok) {
// 转换日期字符串为Date对象
const productWithDates = {
...responseData,
depositDate: responseData.depositDate ? new Date(responseData.depositDate) : null,
endDate: responseData.endDate ? new Date(responseData.endDate) : null,
createdAt: responseData.createdAt ? new Date(responseData.createdAt) : undefined,
updatedAt: responseData.updatedAt ? new Date(responseData.updatedAt) : undefined,
} }
setProducts([productWithDates, ...products])
setNewProduct(createEmptyProduct()) const response = await fetch('/api/products', {
toast.success('产品添加成功') method: 'POST',
} else { headers: {
if (process.env.NODE_ENV === 'development') { 'Content-Type': 'application/json',
console.error('API error response:', responseData) },
} body: JSON.stringify(requestData),
})
if (responseData.details && Array.isArray(responseData.details)) {
// 显示详细的验证错误 if (response.ok) {
const errorMessages = responseData.details.map((detail: any) => const responseData = await response.json()
`${detail.field}: ${detail.message}` const productWithDates = {
).join(', ') ...responseData,
setError(`数据验证失败: ${errorMessages}`) depositDate: responseData.depositDate ? new Date(responseData.depositDate) : null,
endDate: responseData.endDate ? new Date(responseData.endDate) : null,
createdAt: responseData.createdAt ? new Date(responseData.createdAt) : undefined,
updatedAt: responseData.updatedAt ? new Date(responseData.updatedAt) : undefined,
}
setServerProducts([productWithDates, ...serverProducts])
toast.success('产品添加成功')
} else { } else {
const responseData = await response.json()
setError(responseData.error || '添加产品失败') setError(responseData.error || '添加产品失败')
toast.error('添加产品失败')
} }
toast.error('添加产品失败') } else {
// 未登录:保存到本地存储
addLocalProduct({
principal: newProduct.principal,
depositDate: newProduct.depositDate,
endDate: newProduct.endDate,
currentNetValue: newProduct.currentNetValue,
annualRate: newProduct.annualRate,
currency: newProduct.currency,
notes: newProduct.notes,
})
toast.success('产品已保存到本地')
} }
setNewProduct(createEmptyProduct([]))
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { console.error('Request error:', error)
console.error('Request error:', error)
}
setError('网络请求失败,请重试') setError('网络请求失败,请重试')
toast.error('网络请求失败,请重试') toast.error('网络请求失败,请重试')
} finally { } finally {
@ -199,7 +228,32 @@ export default function FinanceCalculator() {
} }
} }
// 开始编辑产品 // 删除产品
async function handleRemoveProduct(id: string) {
if (status === 'authenticated') {
// 从服务器删除
try {
const response = await fetch(`/api/products/${id}`, {
method: 'DELETE',
})
if (response.ok) {
setServerProducts(serverProducts.filter(product => product.id !== id))
toast.success('产品删除成功')
} else {
toast.error('删除产品失败')
}
} catch (error) {
toast.error('网络请求失败')
}
} else {
// 从本地存储删除
removeLocalProduct(id)
toast.success('产品已从本地删除')
}
}
// 编辑产品
function handleEditProduct(product: FinanceProduct) { function handleEditProduct(product: FinanceProduct) {
setEditingProductId(product.id) setEditingProductId(product.id)
setEditingProduct({ setEditingProduct({
@ -213,189 +267,88 @@ export default function FinanceCalculator() {
}) })
} }
// 取消编辑
function handleCancelEdit() { function handleCancelEdit() {
setEditingProductId(null) setEditingProductId(null)
setEditingProduct(createEmptyProduct()) setEditingProduct(createEmptyProduct([]))
} }
// 保存编辑
async function handleSaveEdit() { async function handleSaveEdit() {
if (!session?.user?.id || !editingProductId) { if (!editingProductId) return
toast.error('请先登录')
return
}
if (editingProduct.principal <= 0) {
toast.error("本金必须大于0")
return
}
setIsUpdating(true) setIsUpdating(true)
try { try {
const requestData = { if (status === 'authenticated') {
principal: editingProduct.principal, // 更新服务器数据
depositDate: editingProduct.depositDate?.toISOString() || null, const requestData = {
endDate: editingProduct.endDate?.toISOString() || null, principal: editingProduct.principal,
currentNetValue: editingProduct.currentNetValue, depositDate: editingProduct.depositDate?.toISOString() || null,
annualRate: editingProduct.annualRate, endDate: editingProduct.endDate?.toISOString() || null,
currency: editingProduct.currency, currentNetValue: editingProduct.currentNetValue,
notes: editingProduct.notes, annualRate: editingProduct.annualRate,
} currency: editingProduct.currency,
notes: editingProduct.notes,
if (process.env.NODE_ENV === 'development') {
console.log('Updating product with data:', requestData)
}
const response = await fetch(`/api/products/${editingProductId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
})
const responseData = await response.json()
if (process.env.NODE_ENV === 'development') {
console.log('Update API response:', responseData)
}
if (response.ok) {
// 转换日期字符串为Date对象
const updatedProductWithDates = {
...responseData,
depositDate: responseData.depositDate ? new Date(responseData.depositDate) : null,
endDate: responseData.endDate ? new Date(responseData.endDate) : null,
createdAt: responseData.createdAt ? new Date(responseData.createdAt) : undefined,
updatedAt: responseData.updatedAt ? new Date(responseData.updatedAt) : undefined,
} }
// 更新产品列表 const response = await fetch(`/api/products/${editingProductId}`, {
setProducts(products.map(p => method: 'PUT',
p.id === editingProductId ? updatedProductWithDates : p headers: {
)) 'Content-Type': 'application/json',
},
// 清除编辑状态 body: JSON.stringify(requestData),
setEditingProductId(null) })
setEditingProduct(createEmptyProduct())
if (response.ok) {
// 显示智能计算状态toast const responseData = await response.json()
if (responseData.smartCalculation) { const updatedProduct = {
const { hasSmartCalculation, calculationType, message, recalculatedFields } = responseData.smartCalculation ...responseData,
depositDate: responseData.depositDate ? new Date(responseData.depositDate) : null,
if (hasSmartCalculation) { endDate: responseData.endDate ? new Date(responseData.endDate) : null,
if (calculationType === 'time_profit_changed') { createdAt: responseData.createdAt ? new Date(responseData.createdAt) : undefined,
toast.success( updatedAt: responseData.updatedAt ? new Date(responseData.updatedAt) : undefined,
`🤖 智能计算完成!检测到时间或利润相关字段变化,已自动重新计算年化利率和相关收益。已更新:${recalculatedFields.join('、')}`,
{ duration: 4000 }
)
} else if (calculationType === 'annual_rate_changed') {
toast.success(
`🤖 智能计算完成!检测到年化利率变化,已自动重新计算当前净值和利润。已更新:${recalculatedFields.join('、')}`,
{ duration: 4000 }
)
}
} else {
toast.info(
` ${message}`,
{ duration: 3000 }
)
} }
} else {
setServerProducts(serverProducts.map(product =>
product.id === editingProductId ? updatedProduct : product
))
toast.success('产品更新成功') toast.success('产品更新成功')
} else {
toast.error('更新产品失败')
} }
} else { } else {
if (process.env.NODE_ENV === 'development') { // 更新本地数据
console.error('Update API error response:', responseData) updateLocalProduct(editingProductId, editingProduct)
} toast.success('产品已在本地更新')
if (responseData.details && Array.isArray(responseData.details)) {
// 显示详细的验证错误
const errorMessages = responseData.details.map((detail: any) =>
`${detail.field}: ${detail.message}`
).join(', ')
toast.error(`数据验证失败: ${errorMessages}`)
} else {
toast.error(responseData.error || '更新产品失败')
}
} }
setEditingProductId(null)
setEditingProduct(createEmptyProduct([]))
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { toast.error('网络请求失败')
console.error('Update request error:', error)
}
toast.error('网络请求失败,请重试')
} finally { } finally {
setIsUpdating(false) setIsUpdating(false)
} }
} }
async function handleRemoveProduct(id: string) {
if (!session?.user?.id) {
toast.error('请先登录')
return
}
try {
const response = await fetch(`/api/products/${id}`, {
method: 'DELETE',
})
if (response.ok) {
setProducts(products.filter((product) => product.id !== id))
toast.success('产品删除成功')
} else {
toast.error('删除产品失败')
}
} catch (error) {
toast.error('删除产品失败')
}
}
function handleUseCurrentTime() { function handleUseCurrentTime() {
setNewProduct({ setNewProduct({ ...newProduct, endDate: new Date() })
...newProduct,
endDate: new Date(),
})
} }
function handleUseCurrentTimeForEdit() { function handleUseCurrentTimeForEdit() {
setEditingProduct({ setEditingProduct({ ...editingProduct, endDate: new Date() })
...editingProduct,
endDate: new Date(),
})
} }
// 当产品列表变化时,更新新产品的默认本金 // 更新空产品状态
useEffect(() => { useEffect(() => {
if (products.length > 0) { if (currentProducts.length > 0) {
setNewProduct((prev) => ({ setNewProduct((prev) => ({
...prev, ...prev,
principal: products[0].principal, principal: currentProducts[0].principal,
})) }))
} }
}, [products.length]) }, [currentProducts.length])
// 如果用户未登录,显示登录提示
if (status === 'unauthenticated') {
return (
<div className="space-y-8">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<LogIn className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-muted-foreground text-center mb-4">
使
</p>
</CardContent>
</Card>
<AdBanner />
</div>
)
}
// 加载中状态 // 加载中状态
if (status === 'loading' || isLoading) { if (status === 'loading' || isLocalLoading || isServerLoading) {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<Card> <Card>
@ -412,9 +365,26 @@ export default function FinanceCalculator() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* 数据同步对话框 */}
<DataSyncDialog
open={showSyncDialog}
onOpenChange={setShowSyncDialog}
localProducts={getAllLocalData()}
onSyncConfirm={handleSyncConfirm}
onSyncCancel={handleSyncCancel}
isSyncing={isSyncing}
/>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle className="flex items-center justify-between">
<span></span>
{status === 'unauthenticated' && (
<span className="text-sm font-normal text-orange-600 bg-orange-50 px-2 py-1 rounded">
</span>
)}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@ -565,7 +535,14 @@ export default function FinanceCalculator() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle className="flex items-center justify-between">
<span></span>
{currentProducts.length > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{status === 'authenticated' ? '云端数据' : '本地数据'} · {currentProducts.length}
</span>
)}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -587,14 +564,14 @@ export default function FinanceCalculator() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{products.length === 0 ? ( {currentProducts.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={12} className="text-center"> <TableCell colSpan={12} className="text-center">
{status === 'authenticated' ? '暂无云端数据' : '暂无本地数据'}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
products.map((product) => ( currentProducts.map((product) => (
<TableRow key={product.id}> <TableRow key={product.id}>
{editingProductId === product.id ? ( {editingProductId === product.id ? (
// 编辑模式的行 // 编辑模式的行
@ -650,11 +627,7 @@ export default function FinanceCalculator() {
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button <Button variant="secondary" size="sm" onClick={handleUseCurrentTimeForEdit}>
variant="ghost"
size="sm"
onClick={handleUseCurrentTimeForEdit}
>
</Button> </Button>
</div> </div>
@ -667,7 +640,7 @@ export default function FinanceCalculator() {
...editingProduct, ...editingProduct,
currentNetValue: e.target.value ? Number.parseFloat(e.target.value) : null currentNetValue: e.target.value ? Number.parseFloat(e.target.value) : null
})} })}
className="w-24" className="w-20"
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -679,12 +652,17 @@ export default function FinanceCalculator() {
annualRate: e.target.value ? Number.parseFloat(e.target.value) : null annualRate: e.target.value ? Number.parseFloat(e.target.value) : null
})} })}
className="w-20" className="w-20"
placeholder="自动计算"
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Select value={editingProduct.currency} onValueChange={(value) => setEditingProduct({ ...editingProduct, currency: value })}> <Select
<SelectTrigger className="w-24"> value={editingProduct.currency}
onValueChange={(value) => setEditingProduct({
...editingProduct,
currency: value
})}
>
<SelectTrigger className="w-20">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -706,27 +684,22 @@ export default function FinanceCalculator() {
...editingProduct, ...editingProduct,
notes: e.target.value || null notes: e.target.value || null
})} })}
className="w-32" className="w-20"
placeholder="备注" placeholder="备注"
/> />
</TableCell> </TableCell>
<TableCell>-</TableCell> <TableCell colSpan={4}>
<TableCell>-</TableCell> <div className="flex gap-2">
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>
<div className="flex gap-1">
<Button <Button
variant="ghost" size="sm"
size="icon"
onClick={handleSaveEdit} onClick={handleSaveEdit}
disabled={isUpdating} disabled={isUpdating}
> >
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" size="sm"
size="icon" variant="outline"
onClick={handleCancelEdit} onClick={handleCancelEdit}
disabled={isUpdating} disabled={isUpdating}
> >
@ -736,9 +709,9 @@ export default function FinanceCalculator() {
</TableCell> </TableCell>
</> </>
) : ( ) : (
// 普通显示模式的行 // 显示模式的行
<> <>
<TableCell>{product.principal.toFixed(2)}</TableCell> <TableCell>{product.principal.toLocaleString()}</TableCell>
<TableCell> <TableCell>
{product.depositDate ? format(product.depositDate, "yyyy-MM-dd", { locale: zhCN }) : "-"} {product.depositDate ? format(product.depositDate, "yyyy-MM-dd", { locale: zhCN }) : "-"}
</TableCell> </TableCell>
@ -746,37 +719,41 @@ export default function FinanceCalculator() {
{product.endDate ? format(product.endDate, "yyyy-MM-dd", { locale: zhCN }) : "-"} {product.endDate ? format(product.endDate, "yyyy-MM-dd", { locale: zhCN }) : "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
{product.currentNetValue !== null ? product.currentNetValue.toFixed(2) : "-"} {product.currentNetValue !== null ? product.currentNetValue.toLocaleString() : "-"}
</TableCell> </TableCell>
<TableCell>{product.annualRate !== null ? product.annualRate.toFixed(2) : "-"}</TableCell>
<TableCell>{product.currency}</TableCell>
<TableCell>{product.notes}</TableCell>
<TableCell
className={cn(
product.profit && product.profit > 0
? "text-green-600"
: product.profit && product.profit < 0
? "text-red-600"
: "",
)}
>
{product.profit !== null ? product.profit.toFixed(2) : "-"}
</TableCell>
<TableCell>{product.dailyProfit !== null ? product.dailyProfit.toFixed(2) : "-"}</TableCell>
<TableCell>{product.monthlyProfit !== null ? product.monthlyProfit.toFixed(2) : "-"}</TableCell>
<TableCell>{product.averageAnnualProfit !== null ? product.averageAnnualProfit.toFixed(2) : "-"}</TableCell>
<TableCell> <TableCell>
<div className="flex gap-1"> {product.calculatedAnnualRate !== null
? `${product.calculatedAnnualRate.toFixed(2)}%`
: product.annualRate !== null
? `${product.annualRate.toFixed(2)}%`
: "-"}
</TableCell>
<TableCell>{product.currency}</TableCell>
<TableCell>{product.notes || "-"}</TableCell>
<TableCell className={product.profit && product.profit > 0 ? "text-green-600" : product.profit && product.profit < 0 ? "text-red-600" : ""}>
{product.profit !== null ? product.profit.toLocaleString() : "-"}
</TableCell>
<TableCell>
{product.dailyProfit !== null ? product.dailyProfit.toFixed(2) : "-"}
</TableCell>
<TableCell>
{product.monthlyProfit !== null ? product.monthlyProfit.toFixed(2) : "-"}
</TableCell>
<TableCell>
{product.averageAnnualProfit !== null ? product.averageAnnualProfit.toFixed(2) : "-"}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button <Button
variant="ghost" size="sm"
size="icon" variant="outline"
onClick={() => handleEditProduct(product)} onClick={() => handleEditProduct(product)}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" size="sm"
size="icon" variant="outline"
onClick={() => handleRemoveProduct(product.id)} onClick={() => handleRemoveProduct(product.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@ -793,6 +770,7 @@ export default function FinanceCalculator() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<AdBanner /> <AdBanner />
</div> </div>
) )

View File

@ -0,0 +1,90 @@
"use client"
import { useSession, signIn } from "next-auth/react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { LogIn, AlertTriangle, Cloud, Shield, RefreshCw } from "lucide-react"
export function LoginSuggestion() {
const { data: session, status } = useSession()
// 如果已登录,不显示此组件
if (status === "authenticated" || status === "loading") {
return null
}
const loginFeatures = [
{
icon: <Cloud className="h-4 w-4" />,
title: "云端数据存储",
description: "数据安全存储在云端,永不丢失"
},
{
icon: <RefreshCw className="h-4 w-4" />,
title: "多设备同步",
description: "在不同设备间同步您的理财数据"
},
{
icon: <Shield className="h-4 w-4" />,
title: "数据备份保护",
description: "专业的数据备份和恢复机制"
}
]
return (
<div className="space-y-4">
{/* 警告提示 */}
<Alert className="border-orange-200 bg-orange-50 text-orange-800">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong></strong>
</AlertDescription>
</Alert>
{/* 登录建议卡片 */}
<Card className="border-blue-200 bg-blue-50/50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-blue-800">
<LogIn className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-blue-700 mb-4">
便
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{loginFeatures.map((feature, index) => (
<div key={index} className="flex items-start space-x-2 p-3 bg-white rounded-lg border border-blue-100">
<div className="text-blue-600 mt-0.5">
{feature.icon}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-blue-800 mb-1">
{feature.title}
</h4>
<p className="text-xs text-blue-600">
{feature.description}
</p>
</div>
</div>
))}
</div>
<div className="pt-2 flex justify-center">
<Button
onClick={() => signIn()}
className="bg-blue-600 hover:bg-blue-700 text-white px-6"
>
<LogIn className="h-4 w-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

92
hooks/use-data-sync.ts Normal file
View File

@ -0,0 +1,92 @@
"use client"
import { useState } from "react"
import { useSession } from "next-auth/react"
import { toast } from "sonner"
import { LocalFinanceProduct } from "./use-local-storage"
export function useDataSync() {
const { data: session } = useSession()
const [isSyncing, setIsSyncing] = useState(false)
// 将本地数据同步到服务器
const syncLocalToServer = async (localProducts: LocalFinanceProduct[]): Promise<boolean> => {
if (!session?.user?.id) {
throw new Error('用户未登录')
}
setIsSyncing(true)
try {
const syncPromises = localProducts.map(async (product) => {
const requestData = {
principal: product.principal,
depositDate: product.depositDate?.toISOString() || null,
endDate: product.endDate?.toISOString() || null,
currentNetValue: product.currentNetValue,
annualRate: product.annualRate,
currency: product.currency,
notes: product.notes,
}
const response = await fetch('/api/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(`同步产品失败: ${errorData.error || '未知错误'}`)
}
return response.json()
})
await Promise.all(syncPromises)
toast.success(`成功同步 ${localProducts.length} 条本地数据到服务器`)
return true
} catch (error) {
console.error('数据同步失败:', error)
toast.error('数据同步失败,请重试')
return false
} finally {
setIsSyncing(false)
}
}
// 从服务器获取数据
const fetchServerData = async () => {
if (!session?.user?.id) {
throw new Error('用户未登录')
}
try {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error('获取服务器数据失败')
}
const data = await response.json()
return data.map((product: any) => ({
...product,
depositDate: product.depositDate ? new Date(product.depositDate) : null,
endDate: product.endDate ? new Date(product.endDate) : null,
createdAt: product.createdAt ? new Date(product.createdAt) : undefined,
updatedAt: product.updatedAt ? new Date(product.updatedAt) : undefined,
}))
} catch (error) {
console.error('获取服务器数据失败:', error)
toast.error('获取服务器数据失败')
throw error
}
}
return {
isSyncing,
syncLocalToServer,
fetchServerData,
}
}

159
hooks/use-local-storage.ts Normal file
View File

@ -0,0 +1,159 @@
"use client"
import { useState, useEffect } from "react"
export interface LocalFinanceProduct {
id: string
principal: number
depositDate: Date | null
endDate: Date | null
currentNetValue: number | null
annualRate: number | null
profit: number | null
dailyProfit: number | null
monthlyProfit: number | null
calculatedAnnualRate: number | null
averageAnnualProfit: number | null
currency: string
notes: string | null
createdAt: Date
updatedAt: Date
}
const STORAGE_KEY = 'finance-calculator-products'
export function useLocalStorage() {
const [products, setProducts] = useState<LocalFinanceProduct[]>([])
const [isLoading, setIsLoading] = useState(true)
// 从本地存储加载数据
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsedProducts = JSON.parse(stored).map((product: any) => ({
...product,
depositDate: product.depositDate ? new Date(product.depositDate) : null,
endDate: product.endDate ? new Date(product.endDate) : null,
createdAt: new Date(product.createdAt),
updatedAt: new Date(product.updatedAt),
}))
setProducts(parsedProducts)
}
} catch (error) {
console.error('Failed to load local storage data:', error)
} finally {
setIsLoading(false)
}
}, [])
// 保存数据到本地存储
const saveToLocal = (newProducts: LocalFinanceProduct[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newProducts))
setProducts(newProducts)
} catch (error) {
console.error('Failed to save to local storage:', error)
throw error
}
}
// 添加产品到本地存储
const addProduct = (productData: Omit<LocalFinanceProduct, 'id' | 'createdAt' | 'updatedAt' | 'profit' | 'dailyProfit' | 'monthlyProfit' | 'calculatedAnnualRate' | 'averageAnnualProfit'>) => {
const now = new Date()
const newProduct: LocalFinanceProduct = {
...productData,
id: `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
profit: null,
dailyProfit: null,
monthlyProfit: null,
calculatedAnnualRate: null,
averageAnnualProfit: null,
createdAt: now,
updatedAt: now,
}
// 计算收益相关数据
const calculatedProduct = calculateProductStats(newProduct)
const newProducts = [calculatedProduct, ...products]
saveToLocal(newProducts)
return calculatedProduct
}
// 更新产品
const updateProduct = (id: string, updates: Partial<LocalFinanceProduct>) => {
const updatedProducts = products.map(product => {
if (product.id === id) {
const updatedProduct = { ...product, ...updates, updatedAt: new Date() }
return calculateProductStats(updatedProduct)
}
return product
})
saveToLocal(updatedProducts)
}
// 删除产品
const removeProduct = (id: string) => {
const newProducts = products.filter(product => product.id !== id)
saveToLocal(newProducts)
}
// 清空本地存储
const clearLocal = () => {
localStorage.removeItem(STORAGE_KEY)
setProducts([])
}
// 获取所有本地数据(用于同步)
const getAllLocalData = () => {
return products
}
// 检查是否有本地数据
const hasLocalData = () => {
return products.length > 0
}
return {
products,
isLoading,
addProduct,
updateProduct,
removeProduct,
clearLocal,
getAllLocalData,
hasLocalData,
}
}
// 计算产品统计数据的辅助函数
function calculateProductStats(product: LocalFinanceProduct): LocalFinanceProduct {
let profit = null
let dailyProfit = null
let monthlyProfit = null
let calculatedAnnualRate = null
let averageAnnualProfit = null
if (product.currentNetValue && product.principal) {
profit = product.currentNetValue - product.principal
if (product.depositDate && product.endDate) {
const days = Math.ceil((product.endDate.getTime() - product.depositDate.getTime()) / (1000 * 60 * 60 * 24))
if (days > 0) {
dailyProfit = profit / days
monthlyProfit = dailyProfit * 30
calculatedAnnualRate = ((product.currentNetValue / product.principal) ** (365 / days) - 1) * 100
averageAnnualProfit = profit * (365 / days)
}
}
}
return {
...product,
profit,
dailyProfit,
monthlyProfit,
calculatedAnnualRate,
averageAnnualProfit,
}
}