Add login

This commit is contained in:
songtianlun 2025-06-14 01:33:52 +08:00
parent 39f9c02065
commit 42b3c0950e
20 changed files with 3858 additions and 64 deletions

6
.eslintrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}

238
README.md Normal file
View File

@ -0,0 +1,238 @@
# 个人理财计算器
一个现代化的理财产品管理和收益计算应用,支持用户认证、数据持久化和云端同步。
## 🌟 功能特性
- **用户认证**: 支持 Google OAuth 和邮箱链接登录
- **理财产品管理**: 添加、编辑、删除理财产品
- **自动计算**:
- 收益计算
- 日均收益
- 月均收益
- 实际年化利率
- **数据持久化**: 使用 PostgreSQL 数据库存储用户数据
- **响应式设计**: 支持桌面和移动设备
- **现代化UI**: 基于 Tailwind CSS 和 shadcn/ui 组件库
## 🛠️ 技术栈
- **前端**: Next.js 15, React 19, TypeScript
- **认证**: NextAuth.js v5
- **数据库**: PostgreSQL + Prisma ORM
- **样式**: Tailwind CSS
- **UI组件**: shadcn/ui
- **图标**: Lucide React
- **通知**: Sonner
## 📦 安装配置
### 1. 克隆项目
```bash
git clone <your-repo-url>
cd finance-calculator
```
### 2. 安装依赖
```bash
pnpm install
```
### 3. 环境变量配置
复制环境变量模板文件:
```bash
cp env.example .env.local
```
编辑 `.env.local` 文件,填入以下配置:
```env
# 数据库连接
DATABASE_URL="postgresql://username:password@localhost:5432/finance_calculator"
# NextAuth 配置
NEXTAUTH_SECRET="your-nextauth-secret-here"
NEXTAUTH_URL="http://localhost:3000"
# Google OAuth 配置(可选)
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# SMTP 邮件配置
SMTP_HOST="smtp.gmail.com"
SMTP_PORT=587
SMTP_USER="your-email@gmail.com"
SMTP_PASS="your-app-password"
SMTP_FROM="your-email@gmail.com"
```
### 4. 数据库设置
#### PostgreSQL 安装
使用 Docker推荐
```bash
docker run --name postgres-finance -e POSTGRES_PASSWORD=password -e POSTGRES_DB=finance_calculator -p 5432:5432 -d postgres:15
```
或手动安装 PostgreSQL。
#### 数据库迁移
```bash
# 生成 Prisma 客户端
pnpm db:generate
# 推送数据库结构
pnpm db:push
# 或者使用迁移(生产环境推荐)
pnpm db:migrate
```
### 5. Google OAuth 配置(可选)
1. 访问 [Google Cloud Console](https://console.cloud.google.com/)
2. 创建新项目或选择现有项目
3. 启用 Google+ API
4. 创建 OAuth 2.0 客户端 ID
5. 添加授权的重定向 URI`http://localhost:3000/api/auth/callback/google`
### 6. SMTP 邮件配置
#### Gmail 配置示例:
1. 启用 2FA
2. 生成应用专用密码
3. 使用应用密码作为 `SMTP_PASS`
## 🚀 运行项目
### 开发模式
```bash
pnpm dev
```
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
### 生产模式
```bash
pnpm build
pnpm start
```
## 📱 使用说明
### 登录
1. 访问应用首页
2. 点击右上角"登录"按钮
3. 选择登录方式:
- **Google 登录**: 直接使用 Google 账号
- **邮箱登录**: 输入邮箱接收登录链接
### 管理理财产品
1. **添加产品**
- 输入本金
- 选择存入时间和截止时间
- 输入当前净值或年化利率(至少输入一个)
- 点击"添加产品"
2. **查看数据**
- 产品列表显示所有理财产品
- 自动计算收益、日均收益、月均收益
- 显示实际年化利率
3. **删除产品**
- 点击产品行最右侧的删除按钮
## 🔧 开发命令
```bash
# 开发模式
pnpm dev
# 构建
pnpm build
# 启动生产服务器
pnpm start
# 类型检查
pnpm lint
# 数据库相关
pnpm db:generate # 生成 Prisma 客户端
pnpm db:push # 推送数据库结构
pnpm db:migrate # 运行数据库迁移
```
## 📁 项目结构
```
finance-calculator/
├── app/ # Next.js App Router
│ ├── api/ # API 路由
│ ├── auth/ # 认证页面
│ ├── globals.css # 全局样式
│ ├── layout.tsx # 根布局
│ └── page.tsx # 首页
├── components/ # React 组件
│ ├── auth/ # 认证相关组件
│ ├── providers/ # Context Providers
│ ├── ui/ # UI 组件库
│ └── finance-calculator.tsx
├── lib/ # 工具库
│ ├── db.ts # 数据库连接
│ └── utils.ts # 工具函数
├── prisma/ # Prisma 配置
│ └── schema.prisma # 数据库模式
├── types/ # TypeScript 类型定义
├── auth.ts # NextAuth 配置
└── package.json
```
## 🤝 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 📄 许可证
本项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
## 🙋‍♂️ 常见问题
### Q: 登录后看不到数据?
A: 请检查数据库连接是否正常,确保已执行数据库迁移。
### Q: 邮箱登录收不到邮件?
A: 请检查 SMTP 配置是否正确,确认邮箱和应用密码设置。
### Q: Google 登录失败?
A: 请检查 Google OAuth 配置,确认客户端 ID 和密钥正确。
### Q: 计算结果不准确?
A: 计算基于365天年化如有特殊需求可修改计算逻辑。
## 📞 支持
如有问题或建议,请通过以下方式联系:
- 创建 [Issue](../../issues)
- 发送邮件至 [your-email@example.com]
---
**享受您的理财计算之旅!** 💰📊

View File

@ -0,0 +1,3 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/auth"
import { db } from "@/lib/db"
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const product = await db.financeProduct.findUnique({
where: {
id: params.id,
},
})
if (!product) {
return NextResponse.json({ error: "Product not found" }, { status: 404 })
}
if (product.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await db.financeProduct.delete({
where: {
id: params.id,
},
})
return NextResponse.json({ message: "Product deleted successfully" })
} catch (error) {
console.error("Error deleting product:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

158
app/api/products/route.ts Normal file
View File

@ -0,0 +1,158 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/auth"
import { db } from "@/lib/db"
import { z } from "zod"
const createProductSchema = z.object({
principal: z.number().positive("本金必须大于0"),
depositDate: z.string().nullable().optional(),
endDate: z.string().nullable().optional(),
currentNetValue: z.number().nullable().optional(),
annualRate: z.number().nullable().optional(),
}).refine((data) => {
// 至少有一个:当前净值或年化利率
return data.currentNetValue !== null || data.annualRate !== null
}, {
message: "当前净值和年化利率不能同时为空",
path: ["currentNetValue", "annualRate"]
})
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const products = await db.financeProduct.findMany({
where: {
userId: session.user.id,
},
orderBy: {
createdAt: "desc",
},
})
return NextResponse.json(products)
} catch (error) {
console.error("Error fetching products:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await request.json()
console.log("Received request body:", body)
// 预处理数据将空字符串转换为null
const processedBody = {
...body,
currentNetValue: body.currentNetValue === "" || body.currentNetValue === null ? null : Number(body.currentNetValue),
annualRate: body.annualRate === "" || body.annualRate === null ? null : Number(body.annualRate),
principal: Number(body.principal),
depositDate: body.depositDate || null,
endDate: body.endDate || null,
}
console.log("Processed request body:", processedBody)
const validatedData = createProductSchema.parse(processedBody)
console.log("Validated data:", validatedData)
// 计算相关字段
let calculatedValues = {
profit: null as number | null,
dailyProfit: null as number | null,
monthlyProfit: null as number | null,
calculatedAnnualRate: null as number | null,
}
// 如果有当前净值,计算收益
if (validatedData.currentNetValue !== null && validatedData.currentNetValue !== undefined) {
calculatedValues.profit = validatedData.currentNetValue - validatedData.principal
}
// 如果有年化利率和日期,计算当前净值
if (validatedData.annualRate && validatedData.depositDate && validatedData.endDate) {
const depositDate = new Date(validatedData.depositDate)
const endDate = new Date(validatedData.endDate)
// 验证日期是否有效
if (isNaN(depositDate.getTime()) || isNaN(endDate.getTime())) {
return NextResponse.json(
{ error: "无效的日期格式" },
{ status: 400 }
)
}
const days = Math.max(1, Math.floor((endDate.getTime() - depositDate.getTime()) / (1000 * 60 * 60 * 24)))
if (!validatedData.currentNetValue) {
const calculatedNetValue = validatedData.principal * (1 + ((validatedData.annualRate / 100) * days) / 365)
validatedData.currentNetValue = calculatedNetValue
calculatedValues.profit = calculatedNetValue - validatedData.principal
}
}
// 计算日收益和月收益
if (calculatedValues.profit !== null && validatedData.depositDate && validatedData.endDate) {
const depositDate = new Date(validatedData.depositDate)
const endDate = new Date(validatedData.endDate)
const days = Math.max(1, Math.floor((endDate.getTime() - depositDate.getTime()) / (1000 * 60 * 60 * 24)))
calculatedValues.dailyProfit = calculatedValues.profit / days
calculatedValues.monthlyProfit = calculatedValues.dailyProfit * 30
calculatedValues.calculatedAnnualRate = ((calculatedValues.dailyProfit * 365) / validatedData.principal) * 100
}
console.log("Calculated values:", calculatedValues)
const product = await db.financeProduct.create({
data: {
userId: session.user.id,
principal: validatedData.principal,
depositDate: validatedData.depositDate ? new Date(validatedData.depositDate) : null,
endDate: validatedData.endDate ? new Date(validatedData.endDate) : null,
currentNetValue: validatedData.currentNetValue || null,
annualRate: validatedData.annualRate || null,
...calculatedValues,
},
})
console.log("Created product:", product)
return NextResponse.json(product)
} catch (error) {
console.error("Error in POST /api/products:", error)
if (error instanceof z.ZodError) {
console.error("Zod validation errors:", error.errors)
return NextResponse.json(
{
error: "数据验证失败",
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}))
},
{ status: 400 }
)
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

58
app/auth/error/page.tsx Normal file
View File

@ -0,0 +1,58 @@
"use client"
import { useSearchParams } from "next/navigation"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { AlertTriangle, ArrowLeft } from "lucide-react"
import Link from "next/link"
const errorMessages: Record<string, string> = {
Configuration: "服务器配置错误",
AccessDenied: "访问被拒绝",
Verification: "验证失败,请重试",
Default: "登录时发生错误",
}
export default function AuthErrorPage() {
const searchParams = useSearchParams()
const error = searchParams.get("error")
const errorMessage = error && errorMessages[error] ? errorMessages[error] : errorMessages.Default
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<div className="flex justify-center mb-4">
<AlertTriangle className="h-12 w-12 text-red-500" />
</div>
<CardTitle className="text-2xl"></CardTitle>
<CardDescription>
{errorMessage}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground text-center">
<p></p>
{error && (
<p className="mt-2">: {error}</p>
)}
</div>
<div className="flex gap-2">
<Button asChild variant="outline" className="flex-1">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button asChild className="flex-1">
<Link href="/auth/signin">
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

113
app/auth/signin/page.tsx Normal file
View File

@ -0,0 +1,113 @@
"use client"
import { useState } from "react"
import { signIn, getProviders } from "next-auth/react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Mail, Chrome } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
export default function SignInPage() {
const [email, setEmail] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [message, setMessage] = useState("")
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setMessage("")
try {
const result = await signIn("nodemailer", {
email,
redirect: false,
})
if (result?.error) {
setMessage("登录失败,请重试")
} else {
setMessage("登录链接已发送到您的邮箱,请查收")
}
} catch (error) {
setMessage("登录失败,请重试")
} finally {
setIsLoading(false)
}
}
const handleGoogleSignIn = async () => {
setIsLoading(true)
try {
await signIn("google", { callbackUrl: "/" })
} catch (error) {
setMessage("Google登录失败请重试")
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center"></CardTitle>
<CardDescription className="text-center">
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
onClick={handleGoogleSignIn}
variant="outline"
className="w-full"
disabled={isLoading}
>
<Chrome className="mr-2 h-4 w-4" />
使 Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
</span>
</div>
</div>
<form onSubmit={handleEmailSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
placeholder="输入您的邮箱地址"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !email}
>
<Mail className="mr-2 h-4 w-4" />
{isLoading ? "发送中..." : "发送登录链接"}
</Button>
</form>
{message && (
<Alert>
<AlertDescription>{message}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -1,10 +1,12 @@
import type { Metadata } from 'next'
import './globals.css'
import { ThemeProvider } from '@/components/theme-provider'
import NextAuthSessionProvider from '@/components/providers/session-provider'
export const metadata: Metadata = {
title: 'v0 App',
description: 'Created with v0',
generator: 'v0.dev',
title: '个人理财计算器',
description: '帮助您管理和计算理财产品收益',
generator: 'Next.js',
}
export default function RootLayout({
@ -13,8 +15,19 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="en">
<body>{children}</body>
<html lang="zh-CN">
<body>
<NextAuthSessionProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</NextAuthSessionProvider>
</body>
</html>
)
}

View File

@ -1,9 +1,13 @@
import FinanceCalculator from "@/components/finance-calculator"
import { UserNav } from "@/components/auth/user-nav"
export default function Home() {
return (
<main className="container mx-auto py-8 px-4">
<h1 className="text-3xl font-bold mb-6 text-center"></h1>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold"></h1>
<UserNav />
</div>
<FinanceCalculator />
</main>
)

64
auth.ts Normal file
View File

@ -0,0 +1,64 @@
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { db } from "@/lib/db"
import type { Adapter } from "next-auth/adapters"
import Nodemailer from "next-auth/providers/nodemailer"
export const { auth, handlers, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db) as Adapter,
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Nodemailer({
server: {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
from: process.env.SMTP_FROM,
}),
],
pages: {
signIn: "/auth/signin",
error: "/auth/error",
},
callbacks: {
async session({ token, session }) {
if (token) {
session.user.id = token.id
session.user.name = token.name
session.user.email = token.email
session.user.image = token.picture
}
return session
},
async jwt({ user, token }) {
const dbUser = await db.user.findFirst({
where: {
email: token.email,
},
})
if (!dbUser) {
if (user) {
token.id = user?.id
}
return token
}
return {
id: dbUser.id,
name: dbUser.name,
email: dbUser.email,
picture: dbUser.image,
}
},
},
})

View File

@ -0,0 +1,62 @@
"use client"
import { useSession, signIn, signOut } from "next-auth/react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { User, LogOut } from "lucide-react"
export function UserNav() {
const { data: session, status } = useSession()
if (status === "loading") {
return <div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
}
if (!session) {
return (
<Button onClick={() => signIn()} variant="outline">
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={session.user?.image || ""} alt={session.user?.name || ""} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{session.user?.name}
</p>
<p className="text-xs leading-none text-muted-foreground">
{session.user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut()}>
<LogOut className="mr-2 h-4 w-4" />
<span>退</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,18 +1,20 @@
"use client"
import { useState, useEffect } from "react"
import { useSession } from "next-auth/react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Label } from "@/components/ui/label"
import { CalendarIcon, PlusCircle, Trash2 } from "lucide-react"
import { CalendarIcon, PlusCircle, Trash2, LogIn } from "lucide-react"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { toast } from "sonner"
interface FinanceProduct {
id: string
@ -25,88 +27,171 @@ interface FinanceProduct {
dailyProfit: number | null
monthlyProfit: number | null
calculatedAnnualRate: number | null
error: string | null
createdAt?: Date
updatedAt?: Date
}
interface NewProduct {
principal: number
depositDate: Date | null
endDate: Date | null
currentNetValue: number | null
annualRate: number | null
}
export default function FinanceCalculator() {
const { data: session, status } = useSession()
const [products, setProducts] = useState<FinanceProduct[]>([])
const [newProduct, setNewProduct] = useState<FinanceProduct>(createEmptyProduct())
const [newProduct, setNewProduct] = useState<NewProduct>(createEmptyProduct())
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
function createEmptyProduct(): FinanceProduct {
function createEmptyProduct(): NewProduct {
const lastProduct = products.length > 0 ? products[products.length - 1] : null
return {
id: Date.now().toString(),
principal: lastProduct ? lastProduct.principal : 0,
depositDate: null,
endDate: null,
currentNetValue: null,
annualRate: null,
profit: null,
dailyProfit: null,
monthlyProfit: null,
calculatedAnnualRate: null,
error: null,
}
}
function calculateValues(product: FinanceProduct): FinanceProduct {
const result = { ...product }
result.error = null
// 从数据库加载产品
async function loadProducts() {
if (!session?.user?.id) return
// 验证输入
if (result.currentNetValue === null && result.annualRate === null) {
result.error = "当前净值和年化利率不能同时为空"
return result
setIsLoading(true)
try {
const response = await fetch('/api/products')
if (response.ok) {
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) {
toast.error('加载产品数据失败')
} finally {
setIsLoading(false)
}
// 计算当前净值(如果有年化利率和日期)
if (result.annualRate !== null && result.depositDate && result.endDate) {
const days = Math.max(
1,
Math.floor((result.endDate.getTime() - result.depositDate.getTime()) / (1000 * 60 * 60 * 24)),
)
result.currentNetValue = result.principal * (1 + ((result.annualRate / 100) * days) / 365)
}
// 计算收益
if (result.currentNetValue !== null) {
result.profit = result.currentNetValue - result.principal
}
// 计算平均日收益
if (result.depositDate && result.endDate && result.profit !== null) {
const days = Math.max(
1,
Math.floor((result.endDate.getTime() - result.depositDate.getTime()) / (1000 * 60 * 60 * 24)),
)
result.dailyProfit = result.profit / days
result.monthlyProfit = result.dailyProfit * 30
result.calculatedAnnualRate = ((result.dailyProfit * 365) / result.principal) * 100
}
return result
}
function handleAddProduct() {
// 当用户登录状态改变时加载产品
useEffect(() => {
if (status === 'authenticated') {
loadProducts()
} else if (status === 'unauthenticated') {
setProducts([])
}
}, [status, session?.user?.id])
async function handleAddProduct() {
if (!session?.user?.id) {
toast.error('请先登录')
return
}
if (newProduct.principal <= 0) {
setError("本金必须大于0")
return
}
const calculatedProduct = calculateValues(newProduct)
if (calculatedProduct.error) {
setError(calculatedProduct.error)
// 验证输入
if (newProduct.currentNetValue === null && newProduct.annualRate === null) {
setError("当前净值和年化利率不能同时为空")
return
}
setProducts([...products, calculatedProduct])
setNewProduct(createEmptyProduct())
setIsSubmitting(true)
setError(null)
try {
const requestData = {
principal: newProduct.principal,
depositDate: newProduct.depositDate?.toISOString() || null,
endDate: newProduct.endDate?.toISOString() || null,
currentNetValue: newProduct.currentNetValue,
annualRate: newProduct.annualRate,
}
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()
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())
toast.success('产品添加成功')
} else {
console.error('API error response:', responseData)
if (responseData.details && Array.isArray(responseData.details)) {
// 显示详细的验证错误
const errorMessages = responseData.details.map((detail: any) =>
`${detail.field}: ${detail.message}`
).join(', ')
setError(`数据验证失败: ${errorMessages}`)
} else {
setError(responseData.error || '添加产品失败')
}
toast.error('添加产品失败')
}
} catch (error) {
console.error('Request error:', error)
setError('网络请求失败,请重试')
toast.error('网络请求失败,请重试')
} finally {
setIsSubmitting(false)
}
}
function handleRemoveProduct(id: string) {
setProducts(products.filter((product) => product.id !== id))
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() {
@ -121,11 +206,44 @@ export default function FinanceCalculator() {
if (products.length > 0) {
setNewProduct((prev) => ({
...prev,
principal: products[products.length - 1].principal,
principal: products[0].principal,
}))
}
}, [products.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>
</div>
)
}
// 加载中状态
if (status === 'loading' || isLoading) {
return (
<div className="space-y-8">
<Card>
<CardContent className="py-12">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="ml-2">...</span>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-8">
<Card>
@ -235,9 +353,9 @@ export default function FinanceCalculator() {
</div>
<div className="flex items-end">
<Button onClick={handleAddProduct} className="w-full">
<Button onClick={handleAddProduct} className="w-full" disabled={isSubmitting}>
<PlusCircle className="mr-2 h-4 w-4" />
{isSubmitting ? "添加中..." : "添加产品"}
</Button>
</div>
</div>

View File

@ -0,0 +1,11 @@
"use client"
import { SessionProvider } from "next-auth/react"
export default function NextAuthSessionProvider({
children,
}: {
children: React.ReactNode
}) {
return <SessionProvider>{children}</SessionProvider>
}

17
env.example Normal file
View File

@ -0,0 +1,17 @@
# 数据库连接
DATABASE_URL="postgresql://username:password@localhost:5432/finance_calculator"
# NextAuth 配置
NEXTAUTH_SECRET="your-nextauth-secret-here"
NEXTAUTH_URL="http://localhost:3000"
# Google OAuth 配置
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# SMTP 邮件配置
SMTP_HOST="smtp.gmail.com"
SMTP_PORT=587
SMTP_USER="your-email@gmail.com"
SMTP_PASS="your-app-password"
SMTP_FROM="your-email@gmail.com"

13
lib/db.ts Normal file
View File

@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db

View File

@ -6,10 +6,15 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.2",
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^5.22.0",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
@ -46,7 +51,9 @@
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"next": "15.2.4",
"next-auth": "^5.0.0-beta.24",
"next-themes": "^0.4.4",
"nodemailer": "^6.9.14",
"react": "^19",
"react-day-picker": "8.10.1",
"react-dom": "^19",
@ -61,9 +68,13 @@
},
"devDependencies": {
"@types/node": "^22",
"@types/nodemailer": "^6.4.15",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.28.0",
"eslint-config-next": "15.3.3",
"postcss": "^8.5",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}

2653
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

79
prisma/schema.prisma Normal file
View File

@ -0,0 +1,79 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
products FinanceProduct[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model FinanceProduct {
id String @id @default(cuid())
userId String
principal Float
depositDate DateTime?
endDate DateTime?
currentNetValue Float?
annualRate Float?
profit Float?
dailyProfit Float?
monthlyProfit Float?
calculatedAnnualRate Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}

121
scripts/setup.sh Normal file
View File

@ -0,0 +1,121 @@
#!/bin/bash
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 打印带颜色的消息
print_info() {
echo -e "${BLUE} $1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# 检查命令是否存在
command_exists() {
command -v "$1" >/dev/null 2>&1
}
print_info "🚀 开始设置个人理财计算器..."
# 检查必要的命令
print_info "检查必要的依赖..."
if ! command_exists node; then
print_error "Node.js 未安装,请先安装 Node.js"
exit 1
fi
if ! command_exists pnpm; then
print_warning "pnpm 未安装,将使用 npm 代替"
PACKAGE_MANAGER="npm"
else
PACKAGE_MANAGER="pnpm"
print_success "发现 pnpm"
fi
# 安装依赖
print_info "安装项目依赖..."
if [ "$PACKAGE_MANAGER" = "pnpm" ]; then
pnpm install
else
npm install
fi
if [ $? -eq 0 ]; then
print_success "依赖安装完成"
else
print_error "依赖安装失败"
exit 1
fi
# 创建环境变量文件
print_info "设置环境变量..."
if [ ! -f .env.local ]; then
if [ -f env.example ]; then
cp env.example .env.local
print_success "已创建 .env.local 文件"
print_warning "请编辑 .env.local 文件,填入您的配置信息"
else
print_error "找不到 env.example 文件"
fi
else
print_info ".env.local 文件已存在,跳过创建"
fi
# 生成 NextAuth 密钥
print_info "生成 NextAuth 密钥..."
if command_exists openssl; then
NEXTAUTH_SECRET=$(openssl rand -base64 32)
if [ -f .env.local ]; then
if grep -q "NEXTAUTH_SECRET=" .env.local; then
sed -i.bak "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=\"$NEXTAUTH_SECRET\"/" .env.local
else
echo "NEXTAUTH_SECRET=\"$NEXTAUTH_SECRET\"" >> .env.local
fi
print_success "NextAuth 密钥已生成"
fi
else
print_warning "openssl 未找到,请手动设置 NEXTAUTH_SECRET"
fi
# Prisma 设置
print_info "设置 Prisma..."
if [ "$PACKAGE_MANAGER" = "pnpm" ]; then
pnpm db:generate
else
npm run db:generate
fi
if [ $? -eq 0 ]; then
print_success "Prisma 客户端已生成"
else
print_warning "Prisma 客户端生成失败,请检查数据库配置"
fi
# 完成提示
echo ""
print_success "🎉 设置完成!"
echo ""
print_info "接下来的步骤:"
echo "1. 编辑 .env.local 文件,配置数据库和其他服务"
echo "2. 启动 PostgreSQL 数据库"
echo "3. 运行数据库迁移: ${PACKAGE_MANAGER} run db:push"
echo "4. 启动开发服务器: ${PACKAGE_MANAGER} run dev"
echo ""
print_info "访问 http://localhost:3000 查看应用"
echo ""
print_info "更多信息请查看 README.md 文件"

12
types/next-auth.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
import NextAuth from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
name?: string | null
email?: string | null
image?: string | null
}
}
}