Add login
This commit is contained in:
parent
39f9c02065
commit
42b3c0950e
6
.eslintrc.json
Normal file
6
.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
238
README.md
Normal file
238
README.md
Normal 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]
|
||||
|
||||
---
|
||||
|
||||
**享受您的理财计算之旅!** 💰📊
|
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
44
app/api/products/[id]/route.ts
Normal file
44
app/api/products/[id]/route.ts
Normal 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
158
app/api/products/route.ts
Normal 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
58
app/auth/error/page.tsx
Normal 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
113
app/auth/signin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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
64
auth.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
62
components/auth/user-nav.tsx
Normal file
62
components/auth/user-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
11
components/providers/session-provider.tsx
Normal file
11
components/providers/session-provider.tsx
Normal 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
17
env.example
Normal 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
13
lib/db.ts
Normal 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
|
13
package.json
13
package.json
@ -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
2653
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
79
prisma/schema.prisma
Normal file
79
prisma/schema.prisma
Normal 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
121
scripts/setup.sh
Normal 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
12
types/next-auth.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user