v2.1: 新增通知提醒功能和安全增强
功能: - 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知 - 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章 - 定时任务: APScheduler 每小时检查即将到期的还款 - 邮件通知: SMTP 发送还款提醒和安全警告 - 异常登录通知: 3/5/10次失败触发网页和邮件通知 安全: - 登录安全告警: 异常登录次数达到阈值时发送通知 - 密码重置链接: 一次性使用, 5分钟有效期 - 登录频率限制: IP级和账户级限制 技术: - 后端: FastAPI + SQLAlchemy + PostgreSQL - 前端: 纯 HTML/CSS/JS - 部署: Docker Compose (新增 build 配置)
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
||||
# 数据库配置
|
||||
POSTGRES_DB=debt_manager
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
|
||||
# 后端配置
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/debt_manager
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
SECRET_KEY=change-this-in-production-please
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# 管理员账户
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# SMTP 邮件配置(留空则不发送邮件)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=your@email.com
|
||||
SMTP_PASSWORD=your-password
|
||||
SMTP_FROM=no-reply@example.com
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
# 通知配置
|
||||
NOTIFICATION_CHECK_INTERVAL_MINUTES=60
|
||||
REMINDER_DAYS_BEFORE=3
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
*.tar
|
||||
*.tar.gz
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
pgdata/
|
||||
76
DEPLOY.md
Normal file
76
DEPLOY.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 债务管理系统 - 部署指南
|
||||
|
||||
## 环境要求
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- 至少 1GB 可用内存
|
||||
|
||||
## 快速部署
|
||||
|
||||
```bash
|
||||
# 1. 解压项目
|
||||
tar -xzf debt-manager.tar.gz
|
||||
cd debt-manager
|
||||
|
||||
# 2. 启动所有服务
|
||||
docker compose up -d
|
||||
|
||||
# 3. 等待启动完成(约30秒)
|
||||
docker compose ps
|
||||
|
||||
# 4. 访问系统
|
||||
# 前台: http://localhost:806
|
||||
# 后台: http://localhost:806/admin.html
|
||||
# API文档: http://localhost:8000/docs
|
||||
```
|
||||
|
||||
## 默认账户
|
||||
|
||||
| 账号 | 密码 | 角色 |
|
||||
|------|------|------|
|
||||
| admin | admin123 | 管理员 |
|
||||
| test | test123 | 用户 |
|
||||
|
||||
## 端口说明
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| 前端 | 806 | 用户界面 |
|
||||
| 后端 | 8000 | API接口 |
|
||||
| 数据库 | 54326 | PostgreSQL |
|
||||
|
||||
如需修改端口,编辑 `docker-compose.yml` 中的 ports 映射。
|
||||
|
||||
## 数据备份
|
||||
|
||||
```bash
|
||||
# 备份数据库
|
||||
docker compose exec db pg_dump -U postgres debt_manager > backup.sql
|
||||
|
||||
# 恢复数据库
|
||||
docker compose exec -T db psql -U postgres debt_manager < backup.sql
|
||||
```
|
||||
|
||||
## 停止/重启
|
||||
|
||||
```bash
|
||||
# 停止
|
||||
docker compose down
|
||||
|
||||
# 重启
|
||||
docker compose restart
|
||||
|
||||
# 重建(代码更新后)
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 启动后前端打不开?**
|
||||
A: 等待30秒让后端启动完成,然后刷新页面。
|
||||
|
||||
**Q: 新用户注册后看不到?**
|
||||
A: 管理员需先登录前台,再访问后台管理页面审批。
|
||||
|
||||
**Q: 如何修改管理员密码?**
|
||||
A: 登录前台 → 右上角"修改密码"。
|
||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 债务管理系统
|
||||
|
||||
个人/小团队债务记录、跟踪和管理平台。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**: 纯 HTML/CSS/JS(无框架依赖)
|
||||
- **后端**: Python FastAPI + SQLAlchemy (async)
|
||||
- **数据库**: PostgreSQL 15
|
||||
- **部署**: Docker Compose
|
||||
|
||||
## 功能
|
||||
|
||||
### 前台
|
||||
- 用户注册/登录(JWT 双 token)
|
||||
- 借款管理:CRUD + 分组展示 + 一键还款
|
||||
- 月度还款计划:按月汇总 + 复选框标记已还
|
||||
- 支持:等额本息、等额本金、先息后本
|
||||
- 利率/总利息互算
|
||||
- 数据导入/导出(CSV)
|
||||
- 修改密码
|
||||
|
||||
### 后台
|
||||
- 用户管理(审批/禁用/启用/删除/重置密码)
|
||||
- 用户组管理(同组共享数据)
|
||||
- 系统数据总览
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 解压项目
|
||||
tar -xzf debt-manager.tar.gz
|
||||
cd debt-manager
|
||||
|
||||
# 启动所有服务
|
||||
docker compose up -d
|
||||
|
||||
# 访问
|
||||
# 前台: http://localhost:806
|
||||
# 后台: http://localhost:806/admin.html
|
||||
# API文档: http://localhost:8000/docs
|
||||
```
|
||||
|
||||
## 默认账户
|
||||
|
||||
| 账号 | 密码 | 角色 |
|
||||
|------|------|------|
|
||||
| admin | admin123 | 管理员 |
|
||||
| test | test123 | 用户 |
|
||||
|
||||
## 端口说明
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| 前端 | 806 | 用户界面 |
|
||||
| 后端 | 8000 | API接口 |
|
||||
| 数据库 | 54326 | PostgreSQL |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
debt-manager/
|
||||
├── backend/ # FastAPI 后端
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ ├── core/ # 配置、数据库、认证
|
||||
│ │ ├── models/ # SQLAlchemy 模型
|
||||
│ │ ├── schemas/ # Pydantic 模型
|
||||
│ │ └── services/ # 业务逻辑
|
||||
│ └── Dockerfile
|
||||
├── frontend/ # 前端页面
|
||||
│ ├── index.html # 主页面
|
||||
│ ├── admin.html # 管理后台
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .env
|
||||
└── DEPLOY.md # 部署指南
|
||||
```
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
429
backend/app/api/admin.py
Normal file
429
backend/app/api/admin.py
Normal file
@@ -0,0 +1,429 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.models.models import User, Debt, AuditLog, Group, SystemSetting
|
||||
from app.schemas.schemas import (
|
||||
AdminUserResponse, AdminDashboard, SystemSettingUpdate,
|
||||
GroupCreate, GroupResponse, GroupMemberAdd, EmailServiceConfig,
|
||||
)
|
||||
from app.core.deps import require_admin
|
||||
from app.core.security import hash_password
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["管理后台"])
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=AdminDashboard)
|
||||
async def admin_dashboard(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
users_count = await db.execute(select(func.count(User.id)))
|
||||
pending_count = await db.execute(select(func.count(User.id)).where(User.status == "pending"))
|
||||
active_count = await db.execute(select(func.count(User.id)).where(User.status == "active"))
|
||||
disabled_count = await db.execute(select(func.count(User.id)).where(User.status == "disabled"))
|
||||
|
||||
return AdminDashboard(
|
||||
total_users=users_count.scalar(),
|
||||
active_debts=0,
|
||||
total_amount=0,
|
||||
overdue_count=0,
|
||||
rate_distribution={},
|
||||
status_distribution={"pending": pending_count.scalar(), "active": active_count.scalar(), "disabled": disabled_count.scalar()},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserResponse])
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
search: str = "",
|
||||
status_filter: str = "",
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = select(User)
|
||||
if search:
|
||||
q = q.where(User.email.ilike(f"%{search}%") | User.nickname.ilike(f"%{search}%"))
|
||||
if status_filter:
|
||||
q = q.where(User.status == status_filter)
|
||||
q = q.order_by(User.created_at.desc()).offset((page - 1) * size).limit(size)
|
||||
result = await db.execute(q)
|
||||
users = result.scalars().all()
|
||||
|
||||
groups_result = await db.execute(select(Group))
|
||||
groups_map = {g.id: g.name for g in groups_result.scalars().all()}
|
||||
|
||||
out = []
|
||||
for u in users:
|
||||
dc = await db.execute(select(func.count(Debt.id)).where(Debt.user_id == u.id))
|
||||
ta = await db.execute(select(func.coalesce(func.sum(Debt.amount), 0)).where(Debt.user_id == u.id))
|
||||
ur = AdminUserResponse(
|
||||
id=u.id, account=u.email, nickname=u.nickname,
|
||||
real_email=u.real_email or "",
|
||||
role=u.role, status=u.status, group_id=u.group_id,
|
||||
created_at=u.created_at, debt_count=dc.scalar(),
|
||||
total_amount=ta.scalar(), group_name=groups_map.get(u.group_id, ""),
|
||||
)
|
||||
out.append(ur)
|
||||
return out
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/status")
|
||||
async def set_user_status(
|
||||
user_id: int,
|
||||
status: str = "",
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if status:
|
||||
user.status = status
|
||||
else:
|
||||
user.status = "active" if user.status != "active" else "disabled"
|
||||
await db.commit()
|
||||
return {"message": f"用户状态已更新为 {user.status}"}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if user.role == "admin":
|
||||
raise HTTPException(status_code=400, detail="不能删除管理员")
|
||||
debts_result = await db.execute(select(Debt).where(Debt.user_id == user_id))
|
||||
for d in debts_result.scalars().all():
|
||||
await db.delete(d)
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
return {"message": "用户已删除"}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/debts")
|
||||
async def view_user_debts(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.user_id == user_id))
|
||||
return [{"id": d.id, "creditor": d.creditor_name, "amount": d.amount, "status": d.status} for d in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def get_audit_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = select(AuditLog).order_by(AuditLog.created_at.desc()).offset((page - 1) * size).limit(size)
|
||||
result = await db.execute(q)
|
||||
return [{"id": l.id, "user_id": l.user_id, "action": l.action, "details": l.details, "created_at": l.created_at.isoformat()} for l in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/groups", response_model=list[GroupResponse])
|
||||
async def list_groups(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Group).order_by(Group.created_at.desc()))
|
||||
groups = result.scalars().all()
|
||||
out = []
|
||||
for g in groups:
|
||||
mc = await db.execute(select(func.count(User.id)).where(User.group_id == g.id))
|
||||
gr = GroupResponse.model_validate(g)
|
||||
gr.member_count = mc.scalar()
|
||||
out.append(gr)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/groups", response_model=GroupResponse, status_code=201)
|
||||
async def create_group(
|
||||
req: GroupCreate,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
group = Group(name=req.name, description=req.description)
|
||||
db.add(group)
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
gr = GroupResponse.model_validate(group)
|
||||
gr.member_count = 0
|
||||
return gr
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(
|
||||
group_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Group).where(Group.id == group_id))
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="组不存在")
|
||||
await db.execute(select(User).where(User.group_id == group_id).update({"group_id": None}))
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
return {"message": "组已删除"}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/members")
|
||||
async def add_group_member(
|
||||
group_id: int,
|
||||
req: GroupMemberAdd,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
group = await db.execute(select(Group).where(Group.id == group_id))
|
||||
if not group.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="组不存在")
|
||||
user = await db.execute(select(User).where(User.id == req.user_id))
|
||||
u = user.scalar_one_or_none()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
u.group_id = group_id
|
||||
await db.commit()
|
||||
return {"message": f"用户 {u.email} 已加入组"}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/members/{user_id}")
|
||||
async def remove_group_member(
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == user_id, User.group_id == group_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不在此组中")
|
||||
user.group_id = None
|
||||
await db.commit()
|
||||
return {"message": f"用户 {user.email} 已从组中移除"}
|
||||
|
||||
|
||||
class AdminPasswordChange(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class AdminUserCreate(BaseModel):
|
||||
account: str
|
||||
password: str
|
||||
nickname: str = ""
|
||||
real_email: str = ""
|
||||
role: str = "user"
|
||||
group_id: int | None = None
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
account: str | None = None
|
||||
nickname: str | None = None
|
||||
real_email: str | None = None
|
||||
role: str | None = None
|
||||
group_id: int | None = None
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserResponse, status_code=201)
|
||||
async def create_user(
|
||||
req: AdminUserCreate,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
account = req.account.strip().lower()
|
||||
existing = await db.execute(select(User).where(User.email == account))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="账号已存在")
|
||||
user = User(
|
||||
email=account, password_hash=hash_password(req.password),
|
||||
nickname=req.nickname, real_email=req.real_email, role=req.role, status="active",
|
||||
group_id=req.group_id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return AdminUserResponse(
|
||||
id=user.id, account=user.email, nickname=user.nickname,
|
||||
role=user.role, status=user.status, group_id=user.group_id,
|
||||
created_at=user.created_at, debt_count=0, total_amount=0, group_name="",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}")
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
req: AdminUserUpdate,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if req.account is not None:
|
||||
account = req.account.strip().lower()
|
||||
existing = await db.execute(select(User).where(User.email == account, User.id != user_id))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="账号已存在")
|
||||
user.email = account
|
||||
if req.nickname is not None:
|
||||
user.nickname = req.nickname
|
||||
if req.real_email is not None:
|
||||
user.real_email = req.real_email
|
||||
if req.role is not None:
|
||||
user.role = req.role
|
||||
if req.group_id is not None or req.group_id == "":
|
||||
user.group_id = req.group_id if req.group_id else None
|
||||
await db.commit()
|
||||
return {"message": "用户信息已更新"}
|
||||
|
||||
|
||||
RESET_TOKEN_TTL_HOURS = 24
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/password")
|
||||
async def admin_reset_password(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.models import SystemSetting
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if not user.real_email:
|
||||
raise HTTPException(status_code=400, detail="该用户未绑定邮箱,无法发送重置链接")
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = (datetime.utcnow() + timedelta(hours=RESET_TOKEN_TTL_HOURS)).isoformat()
|
||||
key = f"pwd_reset:{user.id}"
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
value = f"{token}|{expires_at}"
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.add(SystemSetting(key=key, value=value))
|
||||
await db.commit()
|
||||
from app.services.email_service import send_password_reset_link_email, get_site_url
|
||||
site_url = await get_site_url()
|
||||
reset_url = f"{site_url}/reset-password.html?token={token}&uid={user.id}"
|
||||
await send_password_reset_link_email(user, reset_url, RESET_TOKEN_TTL_HOURS, f"管理员 {admin.email}")
|
||||
return {"message": f"重置链接已发送至 {user.real_email}"}
|
||||
|
||||
|
||||
SMTP_KEYS = ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_use_tls"]
|
||||
|
||||
|
||||
@router.get("/email-config", response_model=EmailServiceConfig)
|
||||
async def get_email_config(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key.in_(SMTP_KEYS)))
|
||||
settings = {r.key: r.value for r in result.scalars().all()}
|
||||
return EmailServiceConfig(
|
||||
smtp_host=settings.get("smtp_host", ""),
|
||||
smtp_port=int(settings.get("smtp_port", "465")),
|
||||
smtp_user=settings.get("smtp_user", ""),
|
||||
smtp_password=settings.get("smtp_password", ""),
|
||||
smtp_from=settings.get("smtp_from", ""),
|
||||
smtp_use_tls=settings.get("smtp_use_tls", "true") == "true",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/email-config")
|
||||
async def update_email_config(
|
||||
req: EmailServiceConfig,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
values = {
|
||||
"smtp_host": req.smtp_host,
|
||||
"smtp_port": str(req.smtp_port),
|
||||
"smtp_user": req.smtp_user,
|
||||
"smtp_password": req.smtp_password,
|
||||
"smtp_from": req.smtp_from,
|
||||
"smtp_use_tls": "true" if req.smtp_use_tls else "false",
|
||||
}
|
||||
for key, value in values.items():
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.add(SystemSetting(key=key, value=value))
|
||||
await db.commit()
|
||||
return {"message": "邮件服务配置已保存"}
|
||||
|
||||
|
||||
class EmailTestRequest(BaseModel):
|
||||
to_email: str
|
||||
|
||||
|
||||
@router.post("/email-test")
|
||||
async def test_email(
|
||||
req: EmailTestRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
from app.services.email_service import send_email
|
||||
html = """
|
||||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||
<h2 style="color:#16a34a;margin-bottom:16px">邮件服务测试</h2>
|
||||
<p>您好,</p>
|
||||
<p>这是一封债务管理系统的邮件服务测试邮件。</p>
|
||||
<p>如果您收到此邮件,说明邮件服务器配置正确。</p>
|
||||
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0;text-align:center">
|
||||
<p style="font-size:24px;margin:0">✅</p>
|
||||
<p style="color:#16a34a;font-weight:600;margin:8px 0 0 0">邮件服务正常</p>
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
ok = await send_email(req.to_email, "【债务管理系统】邮件服务测试", html)
|
||||
if ok:
|
||||
return {"message": "发送成功,邮件服务器配置正确。"}
|
||||
raise HTTPException(status_code=500, detail="邮件发送失败,请检查 SMTP 配置")
|
||||
|
||||
|
||||
class SiteUrlUpdate(BaseModel):
|
||||
site_url: str
|
||||
|
||||
|
||||
@router.get("/site-url")
|
||||
async def get_site_url_config(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url"))
|
||||
setting = result.scalar_one_or_none()
|
||||
return {"site_url": setting.value if setting else ""}
|
||||
|
||||
|
||||
@router.put("/site-url")
|
||||
async def update_site_url(
|
||||
req: SiteUrlUpdate,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
url = req.site_url.strip().rstrip("/")
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url"))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = url
|
||||
else:
|
||||
db.add(SystemSetting(key="site_url", value=url))
|
||||
await db.commit()
|
||||
return {"message": "站点地址已保存"}
|
||||
337
backend/app/api/auth.py
Normal file
337
backend/app/api/auth.py
Normal file
@@ -0,0 +1,337 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
from app.core.database import get_db
|
||||
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||
from app.models.models import User, Group, SystemSetting
|
||||
from app.schemas.schemas import (
|
||||
RegisterRequest, LoginRequest, TokenResponse,
|
||||
RefreshRequest, UserProfile, ChangePasswordRequest,
|
||||
ForgotPasswordRequest, VerifyResetCodeRequest, ResetPasswordRequest,
|
||||
)
|
||||
from app.core.deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
account = req.account.strip().lower()
|
||||
result = await db.execute(select(User).where(User.email == account))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="账号已注册")
|
||||
user = User(
|
||||
email=account, password_hash=hash_password(req.password),
|
||||
nickname=req.nickname, status="pending", group_id=req.group_id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return RegisterResponse(message="注册成功,等待管理员审批")
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
req: LoginRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.core.rate_limit import record_login_failure, clear_login_failures, get_account_failure_count, get_unnotified_threshold
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
account = req.account.strip().lower()
|
||||
result = await db.execute(select(User).where(User.email == account))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
record_login_failure(ip, account)
|
||||
count = get_account_failure_count(account)
|
||||
threshold = get_unnotified_threshold(account)
|
||||
if threshold and user:
|
||||
from app.services.email_service import send_login_alert_email
|
||||
from app.services.notification_service import create_notification
|
||||
from app.models.models import NotificationType
|
||||
from app.core.database import async_session
|
||||
|
||||
level_map = {3: "注意", 5: "警告", 10: "严重"}
|
||||
level = level_map.get(threshold, "注意")
|
||||
title = f"【安全{level}】异常登录 {count} 次"
|
||||
message = f"您的账号在 5 分钟内出现 {count} 次异常登录尝试,来源 IP: {ip}。若非本人操作,请立即修改密码。"
|
||||
|
||||
async with async_session() as notif_db:
|
||||
await create_notification(
|
||||
notif_db, user.id, title, message,
|
||||
NotificationType.ACCOUNT.value,
|
||||
)
|
||||
await notif_db.commit()
|
||||
|
||||
if user.real_email:
|
||||
await send_login_alert_email(user, count, threshold, ip)
|
||||
raise HTTPException(status_code=401, detail="账号或密码错误")
|
||||
if user.status == "pending":
|
||||
raise HTTPException(status_code=403, detail="账户正在等待管理员审批")
|
||||
if user.status == "disabled":
|
||||
raise HTTPException(status_code=403, detail="账户已被禁用")
|
||||
clear_login_failures(ip, account)
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
payload = decode_token(req.refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
user_id = int(payload["sub"])
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
if user.status == "pending":
|
||||
raise HTTPException(status_code=403, detail="账户正在等待管理员审批")
|
||||
if user.status == "disabled":
|
||||
raise HTTPException(status_code=403, detail="账户已被禁用")
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/profile", response_model=UserProfile)
|
||||
async def get_profile(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
group_name = ""
|
||||
if user.group_id:
|
||||
result = await db.execute(select(Group.name).where(Group.id == user.group_id))
|
||||
row = result.first()
|
||||
if row:
|
||||
group_name = row[0]
|
||||
return UserProfile(
|
||||
id=user.id, account=user.email, nickname=user.nickname,
|
||||
real_email=user.real_email or "",
|
||||
role=user.role, status=user.status, group_id=user.group_id,
|
||||
group_name=group_name, created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_groups(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Group).where(Group.id.isnot(None)))
|
||||
return [{"id": g.id, "name": g.name} for g in result.scalars().all()]
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
nickname: str | None = None
|
||||
real_email: str | None = None
|
||||
|
||||
|
||||
@router.put("/profile")
|
||||
async def update_profile(
|
||||
req: ProfileUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if req.nickname is not None:
|
||||
user.nickname = req.nickname
|
||||
if req.real_email is not None:
|
||||
user.real_email = req.real_email
|
||||
await db.commit()
|
||||
return {"message": "资料更新成功"}
|
||||
|
||||
|
||||
@router.put("/password")
|
||||
async def change_password(
|
||||
req: ChangePasswordRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not verify_password(req.old_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
await db.commit()
|
||||
from app.services.email_service import send_password_changed_email
|
||||
await send_password_changed_email(user, "修改")
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
|
||||
RESET_CODE_TTL_MINUTES = 10
|
||||
|
||||
|
||||
async def _find_user_by_account_or_email(db: AsyncSession, account: str) -> User | None:
|
||||
account = account.strip().lower()
|
||||
result = await db.execute(select(User).where(User.email == account))
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
result = await db.execute(select(User).where(User.real_email == account))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _build_code_key(user_id: int) -> str:
|
||||
return f"reset_code:{user_id}"
|
||||
|
||||
|
||||
def _build_code_html(nickname: str, code: str) -> str:
|
||||
return f"""
|
||||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||
<h2 style="color:#0071e3;margin-bottom:16px">密码重置验证码</h2>
|
||||
<p>Hi {nickname},</p>
|
||||
<p>您正在重置密码,验证码如下:</p>
|
||||
<div style="background:#f5f5f7;border-radius:12px;padding:24px;margin:16px 0;text-align:center">
|
||||
<p style="font-size:36px;font-weight:700;letter-spacing:8px;color:#1d1d1f;margin:0">{code}</p>
|
||||
<p style="color:#6e6e73;font-size:12px;margin:8px 0 0 0">{RESET_CODE_TTL_MINUTES} 分钟内有效</p>
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件。</p>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(
|
||||
req: ForgotPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await _find_user_by_account_or_email(db, req.account)
|
||||
if not user or not user.real_email:
|
||||
return {"message": "如果该账号已绑定邮箱,验证码已发送"}
|
||||
code = f"{secrets.randbelow(1000000):06d}"
|
||||
key = _build_code_key(user.id)
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
value = f"{code}|{(datetime.utcnow() + timedelta(minutes=RESET_CODE_TTL_MINUTES)).isoformat()}"
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.add(SystemSetting(key=key, value=value))
|
||||
await db.commit()
|
||||
from app.services.email_service import send_email
|
||||
html = _build_code_html(user.nickname or user.email, code)
|
||||
await send_email(user.real_email, "【债务管理系统】密码重置验证码", html)
|
||||
return {"message": "如果该账号已绑定邮箱,验证码已发送"}
|
||||
|
||||
|
||||
@router.post("/verify-reset-code")
|
||||
async def verify_reset_code(
|
||||
req: VerifyResetCodeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await _find_user_by_account_or_email(db, req.account)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="账号不存在")
|
||||
key = _build_code_key(user.id)
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting:
|
||||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||||
parts = setting.value.split("|")
|
||||
if len(parts) != 2 or parts[0] != req.code:
|
||||
raise HTTPException(status_code=400, detail="验证码错误")
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(parts[1])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="验证码格式错误")
|
||||
if datetime.utcnow() > expires_at:
|
||||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||||
return {"message": "验证码正确"}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(
|
||||
req: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await _find_user_by_account_or_email(db, req.account)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="账号不存在")
|
||||
key = _build_code_key(user.id)
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting:
|
||||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||||
parts = setting.value.split("|")
|
||||
if len(parts) != 2 or parts[0] != req.code:
|
||||
raise HTTPException(status_code=400, detail="验证码错误")
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(parts[1])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="验证码格式错误")
|
||||
if datetime.utcnow() > expires_at:
|
||||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
await db.delete(setting)
|
||||
await db.commit()
|
||||
from app.services.email_service import send_password_changed_email
|
||||
await send_password_changed_email(user, "重置")
|
||||
return {"message": "密码重置成功"}
|
||||
|
||||
|
||||
class ResetByTokenRequest(BaseModel):
|
||||
token: str
|
||||
uid: int
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/reset-by-token")
|
||||
async def reset_by_token(
|
||||
req: ResetByTokenRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == req.uid))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="链接无效")
|
||||
key = f"pwd_reset:{req.uid}"
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting:
|
||||
raise HTTPException(status_code=400, detail="链接已失效,请联系管理员重新发送")
|
||||
parts = setting.value.split("|")
|
||||
if len(parts) != 2 or parts[0] != req.token:
|
||||
raise HTTPException(status_code=400, detail="链接无效")
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(parts[1])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="链接格式错误")
|
||||
if datetime.utcnow() > expires_at:
|
||||
await db.delete(setting)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=400, detail="链接已过期,请联系管理员重新发送")
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
await db.delete(setting)
|
||||
await db.commit()
|
||||
from app.services.email_service import send_password_changed_email
|
||||
await send_password_changed_email(user, "重置")
|
||||
return {"message": "密码重置成功"}
|
||||
|
||||
|
||||
@router.get("/check-reset-token")
|
||||
async def check_reset_token(
|
||||
token: str,
|
||||
uid: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == uid))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="链接无效")
|
||||
key = f"pwd_reset:{uid}"
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting:
|
||||
raise HTTPException(status_code=400, detail="链接已失效")
|
||||
parts = setting.value.split("|")
|
||||
if len(parts) != 2 or parts[0] != token:
|
||||
raise HTTPException(status_code=400, detail="链接无效")
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(parts[1])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="链接格式错误")
|
||||
if datetime.utcnow() > expires_at:
|
||||
raise HTTPException(status_code=400, detail="链接已过期")
|
||||
return {"valid": True, "nickname": user.nickname or user.email}
|
||||
172
backend/app/api/debts.py
Normal file
172
backend/app/api/debts.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from datetime import datetime
|
||||
from app.core.database import get_db
|
||||
from app.models.models import User, Debt, RepaymentPlan, RepaymentRecord, DebtStatus, PlanStatus
|
||||
from app.schemas.schemas import (
|
||||
DebtCreate, DebtUpdate, DebtResponse,
|
||||
RepaymentPlanResponse, RepaymentRecordCreate, RepaymentRecordResponse,
|
||||
)
|
||||
from app.core.deps import get_current_user
|
||||
from app.services.calculator import generate_repayment_plan
|
||||
|
||||
router = APIRouter(prefix="/debts", tags=["债务管理"])
|
||||
|
||||
|
||||
def _naive(dt: datetime) -> datetime:
|
||||
if dt and dt.tzinfo:
|
||||
return dt.replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
async def _enrich(db: AsyncSession, d: Debt) -> DebtResponse:
|
||||
dr = DebtResponse.model_validate(d)
|
||||
pr = await db.execute(select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == d.id))
|
||||
dr.total_paid = pr.scalar()
|
||||
ir = await db.execute(select(func.coalesce(func.sum(RepaymentPlan.interest_due), 0)).where(RepaymentPlan.debt_id == d.id))
|
||||
dr.total_interest = ir.scalar()
|
||||
owner = await db.execute(select(User.nickname, User.email).where(User.id == d.user_id))
|
||||
row = owner.first()
|
||||
dr.owner_name = row[0] or row[1] if row else ""
|
||||
return dr
|
||||
|
||||
|
||||
@router.get("", response_model=list[DebtResponse])
|
||||
async def list_debts(
|
||||
status: str | None = None,
|
||||
creditor: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = select(Debt)
|
||||
if status:
|
||||
q = q.where(Debt.status == status)
|
||||
if creditor:
|
||||
q = q.where(Debt.creditor_name.ilike(f"%{creditor}%"))
|
||||
q = q.order_by(Debt.created_at.desc()).offset((page - 1) * size).limit(size)
|
||||
result = await db.execute(q)
|
||||
debts = result.scalars().all()
|
||||
return [await _enrich(db, d) for d in debts]
|
||||
|
||||
|
||||
@router.post("", response_model=DebtResponse, status_code=201)
|
||||
async def create_debt(
|
||||
req: DebtCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
debt = Debt(
|
||||
user_id=user.id, creditor_name=req.creditor_name, amount=req.amount,
|
||||
borrow_date=_naive(req.borrow_date), interest_rate=req.interest_rate,
|
||||
rate_type=req.rate_type, repayment_method=req.repayment_method,
|
||||
period_count=req.period_count, period_unit=req.period_unit, notes=req.notes,
|
||||
)
|
||||
db.add(debt)
|
||||
await db.flush()
|
||||
|
||||
plans = generate_repayment_plan(
|
||||
req.amount, req.interest_rate, req.rate_type,
|
||||
req.repayment_method, req.period_count, req.period_unit,
|
||||
_naive(req.borrow_date),
|
||||
)
|
||||
for p in plans:
|
||||
db.add(RepaymentPlan(debt_id=debt.id, **p))
|
||||
await db.commit()
|
||||
await db.refresh(debt)
|
||||
return await _enrich(db, debt)
|
||||
|
||||
|
||||
@router.get("/{debt_id}", response_model=DebtResponse)
|
||||
async def get_debt(
|
||||
debt_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="债务不存在")
|
||||
return await _enrich(db, debt)
|
||||
|
||||
|
||||
@router.put("/{debt_id}", response_model=DebtResponse)
|
||||
async def update_debt(
|
||||
debt_id: int,
|
||||
req: DebtUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="债务不存在")
|
||||
if debt.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权修改此债务")
|
||||
if debt.status not in ("active",):
|
||||
raise HTTPException(status_code=400, detail="该债务不可修改")
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(debt, k, _naive(v) if k == "borrow_date" else v)
|
||||
if any(k in data for k in ["amount", "interest_rate", "rate_type", "repayment_method", "period_count", "period_unit", "borrow_date"]):
|
||||
await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt.id).delete())
|
||||
plans = generate_repayment_plan(
|
||||
debt.amount, debt.interest_rate, debt.rate_type,
|
||||
debt.repayment_method, debt.period_count, debt.period_unit,
|
||||
_naive(debt.borrow_date),
|
||||
)
|
||||
for p in plans:
|
||||
db.add(RepaymentPlan(debt_id=debt.id, **p))
|
||||
await db.commit()
|
||||
await db.refresh(debt)
|
||||
return await _enrich(db, debt)
|
||||
|
||||
|
||||
@router.delete("/{debt_id}")
|
||||
async def delete_debt(
|
||||
debt_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="债务不存在")
|
||||
if debt.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权删除此债务")
|
||||
has_records = await db.execute(select(func.count(RepaymentRecord.id)).where(RepaymentRecord.debt_id == debt.id))
|
||||
if has_records.scalar() > 0:
|
||||
debt.status = DebtStatus.CLOSED.value
|
||||
await db.commit()
|
||||
return {"message": "已有还款记录,债务已归档"}
|
||||
await db.delete(debt)
|
||||
await db.commit()
|
||||
return {"message": "债务已删除"}
|
||||
|
||||
|
||||
@router.get("/{debt_id}/plans", response_model=list[RepaymentPlanResponse])
|
||||
async def get_plans(
|
||||
debt_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="债务不存在")
|
||||
pr = await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt_id).order_by(RepaymentPlan.due_date))
|
||||
return pr.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{debt_id}/records", response_model=list[RepaymentRecordResponse])
|
||||
async def get_records(
|
||||
debt_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="债务不存在")
|
||||
rr = await db.execute(select(RepaymentRecord).where(RepaymentRecord.debt_id == debt_id).order_by(RepaymentRecord.paid_date.desc()))
|
||||
return rr.scalars().all()
|
||||
152
backend/app/api/loans.py
Normal file
152
backend/app/api/loans.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.models.models import User, Debt
|
||||
from app.schemas.schemas import LoanCreate, LoanResponse
|
||||
from app.core.deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/loans", tags=["借款管理"])
|
||||
|
||||
METHOD_MAP = {"e": "equal_installment", "p": "interest_first", "i": "lump_sum"}
|
||||
METHOD_REV = {"equal_installment": "e", "interest_first": "p", "lump_sum": "i"}
|
||||
|
||||
|
||||
class PaidUpdate(BaseModel):
|
||||
paid: list[int]
|
||||
|
||||
|
||||
def _debt_to_loan(d: Debt, owner_name: str = "") -> LoanResponse:
|
||||
return LoanResponse(
|
||||
id=d.id,
|
||||
name=d.creditor_name,
|
||||
date=d.borrow_date.strftime("%Y-%m-%d") if d.borrow_date else "",
|
||||
amount=d.amount / 100,
|
||||
rate=d.interest_rate,
|
||||
periods=d.period_count,
|
||||
method=METHOD_REV.get(d.repayment_method, "e"),
|
||||
purpose=d.purpose or "",
|
||||
schedule=d.schedule_data or [],
|
||||
paid=d.paid_periods or [],
|
||||
user_id=d.user_id,
|
||||
owner_name=owner_name,
|
||||
)
|
||||
|
||||
|
||||
async def _get_visible_user_ids(user: User, db: AsyncSession) -> list[int]:
|
||||
if user.role == "admin":
|
||||
result = await db.execute(select(User.id))
|
||||
return [r[0] for r in result.all()]
|
||||
if user.group_id:
|
||||
result = await db.execute(select(User.id).where(User.group_id == user.group_id))
|
||||
ids = [r[0] for r in result.all()]
|
||||
if user.id not in ids:
|
||||
ids.append(user.id)
|
||||
return ids
|
||||
return [user.id]
|
||||
|
||||
|
||||
@router.get("", response_model=list[LoanResponse])
|
||||
async def list_loans(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
visible_ids = await _get_visible_user_ids(user, db)
|
||||
result = await db.execute(select(Debt).where(Debt.user_id.in_(visible_ids)).order_by(Debt.created_at.desc()))
|
||||
debts = result.scalars().all()
|
||||
users_result = await db.execute(select(User.id, User.nickname))
|
||||
users_map = {uid: (nick or "") for uid, nick in users_result.all()}
|
||||
return [_debt_to_loan(d, users_map.get(d.user_id, "")) for d in debts]
|
||||
|
||||
|
||||
@router.post("", response_model=LoanResponse, status_code=201)
|
||||
async def create_loan(
|
||||
req: LoanCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
debt = Debt(
|
||||
user_id=user.id,
|
||||
creditor_name=req.name,
|
||||
amount=int(req.amount * 100),
|
||||
borrow_date=datetime.strptime(req.date, "%Y-%m-%d"),
|
||||
interest_rate=req.rate,
|
||||
rate_type="annual",
|
||||
repayment_method=METHOD_MAP.get(req.method, "equal_installment"),
|
||||
period_count=req.periods,
|
||||
period_unit="month",
|
||||
purpose=req.purpose,
|
||||
schedule_data=req.schedule,
|
||||
paid_periods=req.paid,
|
||||
)
|
||||
db.add(debt)
|
||||
await db.commit()
|
||||
await db.refresh(debt)
|
||||
return _debt_to_loan(debt, user.nickname or "")
|
||||
|
||||
|
||||
@router.put("/{loan_id}", response_model=LoanResponse)
|
||||
async def update_loan(
|
||||
loan_id: int,
|
||||
req: LoanCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == loan_id))
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="借款不存在")
|
||||
if debt.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权修改")
|
||||
|
||||
debt.creditor_name = req.name
|
||||
debt.amount = int(req.amount * 100)
|
||||
debt.borrow_date = datetime.strptime(req.date, "%Y-%m-%d")
|
||||
debt.interest_rate = req.rate
|
||||
debt.repayment_method = METHOD_MAP.get(req.method, "equal_installment")
|
||||
debt.period_count = req.periods
|
||||
debt.purpose = req.purpose
|
||||
debt.schedule_data = req.schedule
|
||||
debt.paid_periods = req.paid
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(debt)
|
||||
return _debt_to_loan(debt, user.nickname or "")
|
||||
|
||||
|
||||
@router.delete("/{loan_id}")
|
||||
async def delete_loan(
|
||||
loan_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == loan_id))
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="借款不存在")
|
||||
if debt.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权删除")
|
||||
await db.delete(debt)
|
||||
await db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.patch("/{loan_id}/toggle-paid")
|
||||
async def toggle_paid(
|
||||
loan_id: int,
|
||||
req: PaidUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == loan_id))
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="借款不存在")
|
||||
if debt.user_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="只能标记自己的还款")
|
||||
|
||||
debt.paid_periods = sorted(req.paid)
|
||||
await db.commit()
|
||||
return {"paid": debt.paid_periods}
|
||||
91
backend/app/api/notifications.py
Normal file
91
backend/app/api/notifications.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.models import User, Notification
|
||||
from app.schemas.schemas import NotificationResponse, NotificationListResponse
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["通知管理"])
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
unread_only: bool = False,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = select(Notification).where(Notification.user_id == user.id)
|
||||
if unread_only:
|
||||
q = q.where(Notification.is_read == 0)
|
||||
total_q = select(func.count(Notification.id)).where(Notification.user_id == user.id)
|
||||
total = (await db.execute(total_q)).scalar()
|
||||
q = q.order_by(Notification.created_at.desc()).offset((page - 1) * size).limit(size)
|
||||
items = (await db.execute(q)).scalars().all()
|
||||
unread = (await db.execute(
|
||||
select(func.count(Notification.id)).where(
|
||||
Notification.user_id == user.id, Notification.is_read == 0
|
||||
)
|
||||
)).scalar()
|
||||
return NotificationListResponse(
|
||||
items=[NotificationResponse.model_validate(n) for n in items],
|
||||
total=total,
|
||||
unread=unread,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read")
|
||||
async def mark_read(
|
||||
notification_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user.id
|
||||
)
|
||||
)
|
||||
n = result.scalar_one_or_none()
|
||||
if not n:
|
||||
raise HTTPException(status_code=404, detail="通知不存在")
|
||||
n.is_read = 1
|
||||
await db.commit()
|
||||
return {"message": "已标记已读"}
|
||||
|
||||
|
||||
@router.put("/read-all")
|
||||
async def mark_all_read(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(
|
||||
update(Notification).where(
|
||||
Notification.user_id == user.id,
|
||||
Notification.is_read == 0
|
||||
).values(is_read=1)
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "全部已读"}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
async def delete_notification(
|
||||
notification_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user.id
|
||||
)
|
||||
)
|
||||
n = result.scalar_one_or_none()
|
||||
if not n:
|
||||
raise HTTPException(status_code=404, detail="通知不存在")
|
||||
await db.delete(n)
|
||||
await db.commit()
|
||||
return {"message": "已删除"}
|
||||
101
backend/app/api/repayments.py
Normal file
101
backend/app/api/repayments.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.database import get_db
|
||||
from app.models.models import User, Debt, RepaymentPlan, RepaymentRecord, PlanStatus, DebtStatus
|
||||
from app.schemas.schemas import RepaymentRecordCreate, RepaymentRecordResponse, DashboardSummary
|
||||
from app.core.deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/repayments", tags=["还款管理"])
|
||||
|
||||
|
||||
def _naive(dt: datetime) -> datetime:
|
||||
if dt and dt.tzinfo:
|
||||
return dt.replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
@router.post("", response_model=RepaymentRecordResponse, status_code=201)
|
||||
async def create_repayment(
|
||||
req: RepaymentRecordCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == req.debt_id))
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="债务不存在")
|
||||
if debt.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权为此债务还款")
|
||||
|
||||
record = RepaymentRecord(
|
||||
debt_id=req.debt_id, plan_id=req.plan_id,
|
||||
amount=req.amount, paid_date=_naive(req.paid_date), notes=req.notes,
|
||||
)
|
||||
db.add(record)
|
||||
|
||||
if req.plan_id:
|
||||
plan_result = await db.execute(select(RepaymentPlan).where(RepaymentPlan.id == req.plan_id))
|
||||
plan = plan_result.scalar_one_or_none()
|
||||
if plan:
|
||||
plan.status = PlanStatus.PAID.value
|
||||
|
||||
total_paid_result = await db.execute(
|
||||
select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == req.debt_id)
|
||||
)
|
||||
total_paid = total_paid_result.scalar() + req.amount
|
||||
if total_paid >= debt.amount:
|
||||
debt.status = DebtStatus.PAID.value
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardSummary)
|
||||
async def get_dashboard(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
debts_result = await db.execute(select(Debt).where(Debt.status == DebtStatus.ACTIVE.value))
|
||||
debts = debts_result.scalars().all()
|
||||
|
||||
total_debt = sum(d.amount for d in debts)
|
||||
total_paid = 0
|
||||
for d in debts:
|
||||
pr = await db.execute(select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == d.id))
|
||||
total_paid += pr.scalar()
|
||||
|
||||
active_count = sum(1 for d in debts if d.status == DebtStatus.ACTIVE.value)
|
||||
overdue_count = sum(1 for d in debts if d.status == DebtStatus.OVERDUE.value)
|
||||
paid_all = await db.execute(select(Debt).where(Debt.status == DebtStatus.PAID.value))
|
||||
paid_count = len(paid_all.scalars().all())
|
||||
|
||||
now = datetime.utcnow()
|
||||
upcoming = []
|
||||
for d in debts:
|
||||
if d.status != DebtStatus.ACTIVE.value:
|
||||
continue
|
||||
plans_result = await db.execute(
|
||||
select(RepaymentPlan).where(
|
||||
RepaymentPlan.debt_id == d.id,
|
||||
RepaymentPlan.status == PlanStatus.PENDING.value,
|
||||
RepaymentPlan.due_date <= now + timedelta(days=7),
|
||||
).order_by(RepaymentPlan.due_date)
|
||||
)
|
||||
for p in plans_result.scalars().all():
|
||||
upcoming.append({
|
||||
"debt_id": d.id, "creditor": d.creditor_name,
|
||||
"due_date": p.due_date.isoformat(), "amount": p.total_due,
|
||||
"overdue": p.due_date < now,
|
||||
})
|
||||
|
||||
upcoming.sort(key=lambda x: x["due_date"])
|
||||
|
||||
return DashboardSummary(
|
||||
total_debt=total_debt, total_paid=total_paid,
|
||||
total_remaining=total_debt - total_paid,
|
||||
active_count=active_count, overdue_count=overdue_count,
|
||||
paid_count=paid_count, upcoming_payments=upcoming[:10],
|
||||
)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
32
backend/app/core/config.py
Normal file
32
backend/app/core/config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "债务管理系统"
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/debt_manager"
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
CORS_ORIGINS: list[str] = ["http://localhost", "http://localhost:80", "http://localhost:806"]
|
||||
ADMIN_EMAIL: str = "admin@example.com"
|
||||
ADMIN_PASSWORD: str = "admin123"
|
||||
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 465
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM: str = ""
|
||||
SMTP_USE_TLS: bool = True
|
||||
NOTIFICATION_CHECK_INTERVAL_MINUTES: int = 60
|
||||
REMINDER_DAYS_BEFORE: int = 7
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings():
|
||||
return Settings()
|
||||
14
backend/app/core/database.py
Normal file
14
backend/app/core/database.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
31
backend/app/core/deps.py
Normal file
31
backend/app/core/deps.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_token
|
||||
from app.models.models import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
user_id = int(payload["sub"])
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or user.status == "disabled":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or disabled")
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
|
||||
return user
|
||||
68
backend/app/core/rate_limit.py
Normal file
68
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
MAX_FAILURES = 10
|
||||
WINDOW_SECONDS = 300
|
||||
ALERT_THRESHOLDS = [3, 5, 10]
|
||||
|
||||
_login_attempts: dict[str, list[float]] = defaultdict(list)
|
||||
_account_failures: dict[str, list[float]] = defaultdict(list)
|
||||
_account_notified: dict[str, set[int]] = defaultdict(set)
|
||||
|
||||
|
||||
def record_login_failure(ip: str, account: str = ""):
|
||||
now = time.time()
|
||||
_login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS]
|
||||
_login_attempts[ip].append(now)
|
||||
if account:
|
||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||
_account_failures[account].append(now)
|
||||
|
||||
|
||||
def clear_login_failures(ip: str, account: str = ""):
|
||||
_login_attempts.pop(ip, None)
|
||||
if account:
|
||||
_account_failures.pop(account, None)
|
||||
_account_notified.pop(account, None)
|
||||
|
||||
|
||||
def get_remaining_seconds(ip: str) -> int:
|
||||
now = time.time()
|
||||
_login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS]
|
||||
if not _login_attempts[ip]:
|
||||
return 0
|
||||
oldest = _login_attempts[ip][0]
|
||||
remaining = int(WINDOW_SECONDS - (now - oldest))
|
||||
return max(remaining, 0)
|
||||
|
||||
|
||||
def get_account_failure_count(account: str) -> int:
|
||||
now = time.time()
|
||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||
return len(_account_failures[account])
|
||||
|
||||
|
||||
def get_unnotified_threshold(account: str) -> int | None:
|
||||
now = time.time()
|
||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||
count = len(_account_failures[account])
|
||||
notified = _account_notified[account]
|
||||
for threshold in ALERT_THRESHOLDS:
|
||||
if count >= threshold and threshold not in notified:
|
||||
notified.add(threshold)
|
||||
return threshold
|
||||
return None
|
||||
|
||||
|
||||
def is_ip_blocked(ip: str) -> bool:
|
||||
return get_remaining_seconds(ip) > 0 and len(_login_attempts[ip]) >= MAX_FAILURES
|
||||
|
||||
|
||||
async def ip_rate_limit_middleware(request: Request, call_next):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if is_ip_blocked(ip):
|
||||
remaining = get_remaining_seconds(ip)
|
||||
raise HTTPException(status_code=429, detail=f"登录失败次数过多,请 {remaining} 秒后再试")
|
||||
response = await call_next(request)
|
||||
return response
|
||||
39
backend/app/core/security.py
Normal file
39
backend/app/core/security.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_access_token(user_id: int) -> str:
|
||||
return create_token({"sub": str(user_id), "type": "access"}, timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
|
||||
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
return create_token({"sub": str(user_id), "type": "refresh"}, timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS))
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
151
backend/app/main.py
Normal file
151
backend/app/main.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import engine, async_session
|
||||
from app.models.models import Base, User, Debt, RepaymentPlan, RepaymentRecord, Group
|
||||
from app.core.security import hash_password
|
||||
from app.api import auth, debts, repayments, admin, loans, notifications
|
||||
from sqlalchemy import select, text
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
METHOD_REV = {"equal_installment": "e", "interest_first": "p", "lump_sum": "i"}
|
||||
|
||||
|
||||
async def seed_admin():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(User))
|
||||
existing = result.scalars().all()
|
||||
if not existing:
|
||||
admin_user = User(
|
||||
email="admin",
|
||||
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
||||
nickname="管理员",
|
||||
role="admin",
|
||||
)
|
||||
db.add(admin_user)
|
||||
test_user = User(
|
||||
email="test",
|
||||
password_hash=hash_password("test123"),
|
||||
nickname="测试用户",
|
||||
role="user",
|
||||
)
|
||||
db.add(test_user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def migrate_account_names():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
for u in users:
|
||||
if "@" in u.email:
|
||||
new_email = u.email.split("@")[0]
|
||||
existing = await db.execute(select(User).where(User.email == new_email, User.id != u.id))
|
||||
if not existing.scalar_one_or_none():
|
||||
u.email = new_email
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def migrate_add_real_email():
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''"
|
||||
))
|
||||
|
||||
|
||||
async def migrate_debt_data():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(Debt).where(Debt.schedule_data == None))
|
||||
debts = result.scalars().all()
|
||||
for debt in debts:
|
||||
plans_result = await db.execute(
|
||||
select(RepaymentPlan).where(RepaymentPlan.debt_id == debt.id).order_by(RepaymentPlan.due_date)
|
||||
)
|
||||
plans = plans_result.scalars().all()
|
||||
|
||||
schedule = []
|
||||
paid_periods = []
|
||||
for p in plans:
|
||||
item = {
|
||||
"p": p.principal_due / 100,
|
||||
"date": p.due_date.strftime("%Y-%m-%d") if p.due_date else "",
|
||||
"pay": p.total_due / 100,
|
||||
"pr": p.principal_due / 100,
|
||||
"it": p.interest_due / 100,
|
||||
"rp": 0,
|
||||
}
|
||||
schedule.append(item)
|
||||
if p.status == "paid":
|
||||
paid_periods.append(len(schedule))
|
||||
|
||||
remaining = debt.amount / 100
|
||||
for item in schedule:
|
||||
item["rp"] = round(remaining, 2)
|
||||
remaining -= item["pr"]
|
||||
|
||||
debt.schedule_data = schedule
|
||||
debt.paid_periods = paid_periods
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def migrate_account_names():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
for u in users:
|
||||
if "@" in u.email:
|
||||
u.email = u.email.split("@")[0]
|
||||
await db.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await seed_admin()
|
||||
await migrate_add_real_email()
|
||||
await migrate_account_names()
|
||||
await migrate_debt_data()
|
||||
from app.services.scheduler import start_scheduler, check_upcoming_repayments
|
||||
start_scheduler()
|
||||
await check_upcoming_repayments()
|
||||
yield
|
||||
from app.services.scheduler import scheduler
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.PROJECT_NAME, lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
from app.core.rate_limit import is_ip_blocked, get_remaining_seconds
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if is_ip_blocked(ip):
|
||||
remaining = get_remaining_seconds(ip)
|
||||
return JSONResponse(status_code=429, content={"detail": f"登录失败次数过多,请 {remaining} 秒后再试", "retry_after": remaining})
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(auth.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(loans.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(debts.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(repayments.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(admin.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(notifications.router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
171
backend/app/models/models.py
Normal file
171
backend/app/models/models.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum, Text, JSON
|
||||
from sqlalchemy.orm import relationship, DeclarativeBase
|
||||
import enum
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
USER = "user"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
PENDING = "pending"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class DebtStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
PAID = "paid"
|
||||
OVERDUE = "overdue"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class RateType(str, enum.Enum):
|
||||
ANNUAL = "annual"
|
||||
MONTHLY = "monthly"
|
||||
|
||||
|
||||
class RepaymentMethod(str, enum.Enum):
|
||||
EQUAL_INSTALLMENT = "equal_installment"
|
||||
INTEREST_FIRST = "interest_first"
|
||||
LUMP_SUM = "lump_sum"
|
||||
|
||||
|
||||
class PlanStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
PAID = "paid"
|
||||
OVERDUE = "overdue"
|
||||
|
||||
|
||||
class NotificationType(str, enum.Enum):
|
||||
REPAYMENT_DUE = "repayment_due"
|
||||
REPAYMENT_OVERDUE = "repayment_overdue"
|
||||
SYSTEM = "system"
|
||||
ACCOUNT = "account"
|
||||
|
||||
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
members = relationship("User", back_populates="group")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
real_email = Column(String(255), default="")
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
nickname = Column(String(100), default="")
|
||||
role = Column(String(20), default=UserRole.USER.value)
|
||||
status = Column(String(20), default=UserStatus.ACTIVE.value)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
group = relationship("Group", back_populates="members")
|
||||
debts = relationship("Debt", back_populates="user")
|
||||
audit_logs = relationship("AuditLog", back_populates="user")
|
||||
|
||||
|
||||
class Debt(Base):
|
||||
__tablename__ = "debts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
creditor_name = Column(String(255), nullable=False)
|
||||
amount = Column(Integer, nullable=False)
|
||||
borrow_date = Column(DateTime, nullable=False)
|
||||
interest_rate = Column(Float, nullable=False)
|
||||
rate_type = Column(String(20), default=RateType.ANNUAL.value)
|
||||
repayment_method = Column(String(30), nullable=False)
|
||||
period_count = Column(Integer, nullable=False)
|
||||
period_unit = Column(String(10), default="month")
|
||||
status = Column(String(20), default=DebtStatus.ACTIVE.value)
|
||||
purpose = Column(String(255), default="")
|
||||
notes = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
schedule_data = Column(JSON, default=list)
|
||||
paid_periods = Column(JSON, default=list)
|
||||
|
||||
user = relationship("User", back_populates="debts")
|
||||
plans = relationship("RepaymentPlan", back_populates="debt", cascade="all, delete-orphan")
|
||||
records = relationship("RepaymentRecord", back_populates="debt", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RepaymentPlan(Base):
|
||||
__tablename__ = "repayment_plans"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
debt_id = Column(Integer, ForeignKey("debts.id"), nullable=False)
|
||||
due_date = Column(DateTime, nullable=False)
|
||||
principal_due = Column(Integer, nullable=False)
|
||||
interest_due = Column(Integer, nullable=False)
|
||||
total_due = Column(Integer, nullable=False)
|
||||
status = Column(String(20), default=PlanStatus.PENDING.value)
|
||||
|
||||
debt = relationship("Debt", back_populates="plans")
|
||||
records = relationship("RepaymentRecord", back_populates="plan")
|
||||
|
||||
|
||||
class RepaymentRecord(Base):
|
||||
__tablename__ = "repayment_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
debt_id = Column(Integer, ForeignKey("debts.id"), nullable=False)
|
||||
plan_id = Column(Integer, ForeignKey("repayment_plans.id"), nullable=True)
|
||||
amount = Column(Integer, nullable=False)
|
||||
paid_date = Column(DateTime, nullable=False)
|
||||
notes = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
debt = relationship("Debt", back_populates="records")
|
||||
plan = relationship("RepaymentPlan", back_populates="records")
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
key = Column(String(100), primary_key=True)
|
||||
value = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
action = Column(String(50), nullable=False)
|
||||
target_type = Column(String(50), default="")
|
||||
target_id = Column(Integer, nullable=True)
|
||||
details = Column(JSON, default=dict)
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
user = relationship("User", back_populates="audit_logs")
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
type = Column(String(30), default=NotificationType.SYSTEM.value)
|
||||
is_read = Column(Integer, default=0)
|
||||
related_debt_id = Column(Integer, nullable=True)
|
||||
related_plan_id = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
user = relationship("User", backref="notifications")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
256
backend/app/schemas/schemas.py
Normal file
256
backend/app/schemas/schemas.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
account: str
|
||||
password: str
|
||||
nickname: str = ""
|
||||
group_id: int | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
account: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
id: int
|
||||
account: str
|
||||
nickname: str
|
||||
real_email: str = ""
|
||||
role: str
|
||||
status: str
|
||||
group_id: int | None = None
|
||||
group_name: str = ""
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class GroupResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
member_count: int = 0
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GroupMemberAdd(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
class LoanCreate(BaseModel):
|
||||
name: str
|
||||
date: str
|
||||
amount: float
|
||||
rate: float
|
||||
periods: int
|
||||
method: str
|
||||
purpose: str = ""
|
||||
schedule: list[dict] = []
|
||||
paid: list[int] = []
|
||||
|
||||
|
||||
class LoanResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
date: str
|
||||
amount: float
|
||||
rate: float
|
||||
periods: int
|
||||
method: str
|
||||
purpose: str
|
||||
schedule: list[dict]
|
||||
paid: list[int]
|
||||
user_id: int
|
||||
owner_name: str = ""
|
||||
|
||||
|
||||
class DebtCreate(BaseModel):
|
||||
creditor_name: str
|
||||
amount: int
|
||||
borrow_date: datetime
|
||||
interest_rate: float
|
||||
rate_type: str = "annual"
|
||||
repayment_method: str
|
||||
period_count: int
|
||||
period_unit: str = "month"
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class DebtUpdate(BaseModel):
|
||||
creditor_name: str | None = None
|
||||
amount: int | None = None
|
||||
borrow_date: datetime | None = None
|
||||
interest_rate: float | None = None
|
||||
rate_type: str | None = None
|
||||
repayment_method: str | None = None
|
||||
period_count: int | None = None
|
||||
period_unit: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class DebtResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
creditor_name: str
|
||||
amount: int
|
||||
borrow_date: datetime
|
||||
interest_rate: float
|
||||
rate_type: str
|
||||
repayment_method: str
|
||||
period_count: int
|
||||
period_unit: str
|
||||
status: str
|
||||
notes: str
|
||||
created_at: datetime
|
||||
total_paid: int = 0
|
||||
total_interest: int = 0
|
||||
owner_name: str = ""
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RepaymentPlanResponse(BaseModel):
|
||||
id: int
|
||||
debt_id: int
|
||||
due_date: datetime
|
||||
principal_due: int
|
||||
interest_due: int
|
||||
total_due: int
|
||||
status: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RepaymentRecordCreate(BaseModel):
|
||||
debt_id: int
|
||||
plan_id: int | None = None
|
||||
amount: int
|
||||
paid_date: datetime
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class RepaymentRecordResponse(BaseModel):
|
||||
id: int
|
||||
debt_id: int
|
||||
plan_id: int | None
|
||||
amount: int
|
||||
paid_date: datetime
|
||||
notes: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DashboardSummary(BaseModel):
|
||||
total_debt: int
|
||||
total_paid: int
|
||||
total_remaining: int
|
||||
active_count: int
|
||||
overdue_count: int
|
||||
paid_count: int
|
||||
upcoming_payments: list[dict]
|
||||
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
id: int
|
||||
account: str
|
||||
nickname: str
|
||||
real_email: str = ""
|
||||
role: str
|
||||
status: str
|
||||
group_id: int | None = None
|
||||
group_name: str = ""
|
||||
created_at: datetime
|
||||
debt_count: int = 0
|
||||
total_amount: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminDashboard(BaseModel):
|
||||
total_users: int
|
||||
active_debts: int
|
||||
total_amount: int
|
||||
overdue_count: int
|
||||
rate_distribution: dict
|
||||
status_distribution: dict
|
||||
|
||||
|
||||
class SystemSettingUpdate(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
title: str
|
||||
message: str
|
||||
type: str
|
||||
is_read: int
|
||||
related_debt_id: int | None = None
|
||||
related_plan_id: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationResponse]
|
||||
total: int
|
||||
unread: int
|
||||
|
||||
|
||||
class EmailServiceConfig(BaseModel):
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 465
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = ""
|
||||
smtp_use_tls: bool = True
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
account: str
|
||||
|
||||
|
||||
class VerifyResetCodeRequest(BaseModel):
|
||||
account: str
|
||||
code: str
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
account: str
|
||||
code: str
|
||||
new_password: str
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
79
backend/app/services/calculator.py
Normal file
79
backend/app/services/calculator.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
def calculate_monthly_rate(interest_rate: float, rate_type: str) -> float:
|
||||
if rate_type == "annual":
|
||||
return interest_rate / 100 / 12
|
||||
return interest_rate / 100
|
||||
|
||||
|
||||
def generate_equal_installment_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
period_count: int, start_date: datetime
|
||||
) -> list[dict]:
|
||||
mr = calculate_monthly_rate(interest_rate, rate_type)
|
||||
if mr == 0:
|
||||
principal_each = amount // period_count
|
||||
plans = []
|
||||
for i in range(period_count):
|
||||
due = start_date + relativedelta(months=i + 1)
|
||||
p = principal_each if i < period_count - 1 else amount - principal_each * (period_count - 1)
|
||||
plans.append({"due_date": due, "principal_due": p, "interest_due": 0, "total_due": p})
|
||||
return plans
|
||||
|
||||
mp = amount * mr * (1 + mr) ** period_count / ((1 + mr) ** period_count - 1)
|
||||
plans = []
|
||||
remaining = amount
|
||||
for i in range(period_count):
|
||||
due = start_date + relativedelta(months=i + 1)
|
||||
interest = int(remaining * mr)
|
||||
principal = int(mp) - interest
|
||||
if i == period_count - 1:
|
||||
principal = remaining
|
||||
interest = int(remaining * mr)
|
||||
remaining -= principal
|
||||
total = principal + interest
|
||||
plans.append({"due_date": due, "principal_due": principal, "interest_due": interest, "total_due": total})
|
||||
return plans
|
||||
|
||||
|
||||
def generate_interest_first_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
period_count: int, start_date: datetime
|
||||
) -> list[dict]:
|
||||
mr = calculate_monthly_rate(interest_rate, rate_type)
|
||||
plans = []
|
||||
for i in range(period_count):
|
||||
due = start_date + relativedelta(months=i + 1)
|
||||
interest = int(amount * mr)
|
||||
principal = amount if i == period_count - 1 else 0
|
||||
total = principal + interest
|
||||
plans.append({"due_date": due, "principal_due": principal, "interest_due": interest, "total_due": total})
|
||||
return plans
|
||||
|
||||
|
||||
def generate_lump_sum_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
period_count: int, period_unit: str, start_date: datetime
|
||||
) -> list[dict]:
|
||||
if period_unit == "year":
|
||||
total_interest = int(amount * interest_rate / 100 * period_count)
|
||||
else:
|
||||
total_interest = int(amount * interest_rate / 100 / 12 * period_count)
|
||||
due = start_date + relativedelta(months=period_count) if period_unit == "month" else start_date + relativedelta(years=period_count)
|
||||
return [{"due_date": due, "principal_due": amount, "interest_due": total_interest, "total_due": amount + total_interest}]
|
||||
|
||||
|
||||
def generate_repayment_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
repayment_method: str, period_count: int, period_unit: str,
|
||||
start_date: datetime
|
||||
) -> list[dict]:
|
||||
if repayment_method == "equal_installment":
|
||||
return generate_equal_installment_plan(amount, interest_rate, rate_type, period_count, start_date)
|
||||
elif repayment_method == "interest_first":
|
||||
return generate_interest_first_plan(amount, interest_rate, rate_type, period_count, start_date)
|
||||
elif repayment_method == "lump_sum":
|
||||
return generate_lump_sum_plan(amount, interest_rate, rate_type, period_count, period_unit, start_date)
|
||||
return []
|
||||
233
backend/app/services/email_service.py
Normal file
233
backend/app/services/email_service.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from sqlalchemy import select
|
||||
from app.core.database import async_session
|
||||
from app.core.config import get_settings
|
||||
from app.models.models import SystemSetting
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
async def get_site_url() -> str:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url"))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting and setting.value.strip():
|
||||
return setting.value.strip().rstrip("/")
|
||||
return "http://localhost:806"
|
||||
|
||||
|
||||
async def get_smtp_config() -> dict:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(
|
||||
SystemSetting.key.in_([
|
||||
"smtp_host", "smtp_port", "smtp_user",
|
||||
"smtp_password", "smtp_from", "smtp_use_tls",
|
||||
])
|
||||
)
|
||||
)
|
||||
rows = {r.key: r.value for r in result.scalars().all()}
|
||||
|
||||
if rows.get("smtp_host") and rows.get("smtp_user"):
|
||||
return {
|
||||
"host": rows.get("smtp_host", ""),
|
||||
"port": int(rows.get("smtp_port", "465")),
|
||||
"user": rows.get("smtp_user", ""),
|
||||
"password": rows.get("smtp_password", ""),
|
||||
"from_addr": rows.get("smtp_from") or rows.get("smtp_user", ""),
|
||||
"use_tls": rows.get("smtp_use_tls", "true") == "true",
|
||||
}
|
||||
return {
|
||||
"host": settings.SMTP_HOST,
|
||||
"port": settings.SMTP_PORT,
|
||||
"user": settings.SMTP_USER,
|
||||
"password": settings.SMTP_PASSWORD,
|
||||
"from_addr": settings.SMTP_FROM or settings.SMTP_USER,
|
||||
"use_tls": settings.SMTP_USE_TLS,
|
||||
}
|
||||
|
||||
|
||||
async def send_email(to_email: str, subject: str, body: str) -> bool:
|
||||
smtp = await get_smtp_config()
|
||||
if not smtp["host"] or not smtp["user"]:
|
||||
return False
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = smtp["from_addr"]
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body, "html", "utf-8"))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=smtp["host"],
|
||||
port=smtp["port"],
|
||||
username=smtp["user"],
|
||||
password=smtp["password"],
|
||||
use_tls=smtp["use_tls"],
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def build_repayment_reminder_html(
|
||||
nickname: str, creditor: str, amount_yuan: float,
|
||||
due_date: str, days_left: int, is_overdue: bool = False
|
||||
) -> str:
|
||||
status_text = f"已逾期 {abs(days_left)} 天" if is_overdue else f"还有 {days_left} 天到期"
|
||||
color = "#ff3b30" if is_overdue else "#0071e3"
|
||||
title = "逾期提醒" if is_overdue else "还款提醒"
|
||||
return f"""
|
||||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||
<h2 style="color:{color};margin-bottom:16px">{title}</h2>
|
||||
<p>Hi {nickname},</p>
|
||||
<p>您有一笔借款即将到期:</p>
|
||||
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
|
||||
<p><strong>债权人:</strong>{creditor}</p>
|
||||
<p><strong>金额:</strong>¥{amount_yuan:,.2f}</p>
|
||||
<p><strong>到期日:</strong>{due_date}</p>
|
||||
<p style="color:{color};font-weight:600">{status_text}</p>
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
_LOGIN_ALERT_CONFIG = {
|
||||
3: {
|
||||
"level": "注意",
|
||||
"color": "#ff9500",
|
||||
"bg": "rgba(255,149,0,.06)",
|
||||
"border": "rgba(255,149,0,.15)",
|
||||
"icon": "⚠️",
|
||||
"desc": "您的账号出现了异常登录尝试。",
|
||||
},
|
||||
5: {
|
||||
"level": "警告",
|
||||
"color": "#ff6b35",
|
||||
"bg": "rgba(255,107,53,.06)",
|
||||
"border": "rgba(255,107,53,.15)",
|
||||
"icon": "🔶",
|
||||
"desc": "您的账号正遭受多次暴力破解尝试,若非本人操作请立即修改密码。",
|
||||
},
|
||||
10: {
|
||||
"level": "严重",
|
||||
"color": "#ff3b30",
|
||||
"bg": "rgba(255,59,48,.08)",
|
||||
"border": "rgba(255,59,48,.18)",
|
||||
"icon": "🔴",
|
||||
"desc": "您的账号已被暴力破解锁定 5 分钟。若非本人操作,请立即修改密码并检查账户安全。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_login_alert_html(nickname: str, count: int, threshold: int, ip: str, site_url: str, token: str, uid: int, ttl_minutes: int = 5) -> str:
|
||||
cfg = _LOGIN_ALERT_CONFIG[threshold]
|
||||
change_pwd_url = f"{site_url}/reset-password.html?token={token}&uid={uid}"
|
||||
return f"""
|
||||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||
<h2 style="color:{cfg['color']};margin-bottom:16px">{cfg['icon']} 登录安全{cfg['level']}</h2>
|
||||
<p>Hi {nickname},</p>
|
||||
<p>{cfg['desc']}</p>
|
||||
<div style="background:{cfg['bg']};border:1px solid {cfg['border']};border-radius:12px;padding:20px;margin:16px 0">
|
||||
<p><strong>异常登录次数:</strong><span style="color:{cfg['color']};font-weight:700">{count} 次</span></p>
|
||||
<p><strong>触发来源 IP:</strong>{ip}</p>
|
||||
<p><strong>时间:</strong>{__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
</div>
|
||||
<div style="text-align:center;margin:24px 0">
|
||||
<a href="{change_pwd_url}" style="display:inline-block;padding:12px 32px;background:#0071e3;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">修改密码</a>
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:12px">此链接 {ttl_minutes} 分钟内有效,仅可使用一次。</p>
|
||||
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件并尽快检查账户安全。</p>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
async def send_login_alert_email(user, count: int, threshold: int, ip: str):
|
||||
if not user.real_email:
|
||||
return
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
ttl_minutes = 5
|
||||
expires_at = (datetime.utcnow() + timedelta(minutes=ttl_minutes)).isoformat()
|
||||
key = f"pwd_reset:{user.id}"
|
||||
value = f"{token}|{expires_at}"
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.add(SystemSetting(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
cfg = _LOGIN_ALERT_CONFIG[threshold]
|
||||
site_url = await get_site_url()
|
||||
html = _build_login_alert_html(user.nickname or user.email, count, threshold, ip, site_url, token, user.id, ttl_minutes)
|
||||
subject = f"【{cfg['level']}】账号安全提醒 - 异常登录 {count} 次"
|
||||
await send_email(user.real_email, subject, html)
|
||||
|
||||
|
||||
def _build_password_changed_html(nickname: str, action: str, operator: str = "") -> str:
|
||||
from datetime import datetime
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
return f"""
|
||||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||
<h2 style="color:#0071e3;margin-bottom:16px">🔐 密码{action}通知</h2>
|
||||
<p>Hi {nickname},</p>
|
||||
<p>您的账户密码已被{action}。</p>
|
||||
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
|
||||
<p><strong>操作类型:</strong>密码{action}</p>
|
||||
<p><strong>操作时间:</strong>{now}</p>
|
||||
{"<p><strong>操作人:</strong>" + operator + "</p>" if operator else ""}
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:13px">如非本人操作,请立即联系管理员并修改密码。</p>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
async def send_password_changed_email(user, action: str, operator: str = ""):
|
||||
if not user.real_email:
|
||||
return
|
||||
html = _build_password_changed_html(user.nickname or user.email, action, operator)
|
||||
subject = f"【安全通知】您的密码已被{action}"
|
||||
await send_email(user.real_email, subject, html)
|
||||
|
||||
|
||||
def _build_password_reset_link_html(nickname: str, reset_url: str, ttl_hours: int, operator: str) -> str:
|
||||
from datetime import datetime
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
return f"""
|
||||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||
<h2 style="color:#0071e3;margin-bottom:16px">🔐 密码重置链接</h2>
|
||||
<p>Hi {nickname},</p>
|
||||
<p>管理员已为您发起密码重置,请点击下方按钮设置新密码。</p>
|
||||
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
|
||||
<p><strong>操作人:</strong>{operator}</p>
|
||||
<p><strong>发送时间:</strong>{now}</p>
|
||||
<p><strong>有效期:</strong>{ttl_hours} 小时内有效</p>
|
||||
</div>
|
||||
<div style="text-align:center;margin:24px 0">
|
||||
<a href="{reset_url}" style="display:inline-block;padding:14px 36px;background:#0071e3;color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px">重置密码</a>
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:12px">如非本人操作,请忽略此邮件。</p>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
async def send_password_reset_link_email(user, reset_url: str, ttl_hours: int, operator: str = ""):
|
||||
if not user.real_email:
|
||||
return
|
||||
html = _build_password_reset_link_html(user.nickname or user.email, reset_url, ttl_hours, operator)
|
||||
subject = "【密码重置】管理员为您重置了密码"
|
||||
await send_email(user.real_email, subject, html)
|
||||
41
backend/app/services/notification_service.py
Normal file
41
backend/app/services/notification_service.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.models import Notification, NotificationType, User
|
||||
|
||||
|
||||
async def create_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message: str,
|
||||
notification_type: str = NotificationType.SYSTEM.value,
|
||||
related_debt_id: int | None = None,
|
||||
related_plan_id: int | None = None,
|
||||
) -> Notification:
|
||||
n = Notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
type=notification_type,
|
||||
related_debt_id=related_debt_id,
|
||||
related_plan_id=related_plan_id,
|
||||
)
|
||||
db.add(n)
|
||||
return n
|
||||
|
||||
|
||||
async def create_system_broadcast(
|
||||
db: AsyncSession,
|
||||
title: str,
|
||||
message: str,
|
||||
) -> int:
|
||||
users = await db.execute(select(User).where(User.status == "active"))
|
||||
count = 0
|
||||
for user in users.scalars().all():
|
||||
db.add(Notification(
|
||||
user_id=user.id, title=title, message=message,
|
||||
type=NotificationType.SYSTEM.value,
|
||||
))
|
||||
count += 1
|
||||
await db.commit()
|
||||
return count
|
||||
105
backend/app/services/scheduler.py
Normal file
105
backend/app/services/scheduler.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.database import async_session
|
||||
from app.core.config import get_settings
|
||||
from app.models.models import (
|
||||
Debt, Notification, NotificationType, User, DebtStatus
|
||||
)
|
||||
from app.services.notification_service import create_notification
|
||||
from app.services.email_service import send_email, build_repayment_reminder_html
|
||||
|
||||
settings = get_settings()
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def check_upcoming_repayments():
|
||||
async with async_session() as db:
|
||||
now = datetime.utcnow()
|
||||
reminder_days = settings.REMINDER_DAYS_BEFORE
|
||||
check_window = now + timedelta(days=reminder_days)
|
||||
|
||||
result = await db.execute(
|
||||
select(Debt).where(
|
||||
and_(
|
||||
Debt.status == DebtStatus.ACTIVE.value,
|
||||
Debt.schedule_data.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
debts = result.scalars().all()
|
||||
|
||||
for debt in debts:
|
||||
schedule = debt.schedule_data or []
|
||||
paid_periods = debt.paid_periods or []
|
||||
user_result = await db.execute(select(User).where(User.id == debt.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
continue
|
||||
|
||||
for item in schedule:
|
||||
p = item.get("p")
|
||||
if p in paid_periods:
|
||||
continue
|
||||
|
||||
try:
|
||||
due_date = datetime.strptime(item["date"], "%Y-%m-%d")
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
if due_date > check_window:
|
||||
continue
|
||||
|
||||
is_overdue = due_date < now
|
||||
amount_yuan = item.get("pay", 0)
|
||||
days_left = (due_date - now).days
|
||||
|
||||
if is_overdue:
|
||||
ntype = NotificationType.REPAYMENT_OVERDUE.value
|
||||
title = f"逾期提醒:{debt.creditor_name} 第{p}期还款已逾期"
|
||||
msg = f"您在 {debt.creditor_name} 的第{p}期 ¥{amount_yuan:,.2f} 还款已于 {item['date']} 逾期,请尽快处理。"
|
||||
else:
|
||||
ntype = NotificationType.REPAYMENT_DUE.value
|
||||
title = f"还款提醒:{debt.creditor_name} 第{p}期将在 {days_left} 天后到期"
|
||||
msg = f"您在 {debt.creditor_name} 的第{p}期 ¥{amount_yuan:,.2f} 将于 {item['date']} 到期,请按时还款。"
|
||||
|
||||
existing = await db.execute(
|
||||
select(Notification).where(
|
||||
and_(
|
||||
Notification.user_id == user.id,
|
||||
Notification.related_debt_id == debt.id,
|
||||
Notification.title == title,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
await create_notification(
|
||||
db, user.id, title, msg, ntype,
|
||||
related_debt_id=debt.id,
|
||||
)
|
||||
|
||||
if user.email and "@" in user.email:
|
||||
html = build_repayment_reminder_html(
|
||||
user.nickname or user.email,
|
||||
debt.creditor_name,
|
||||
amount_yuan,
|
||||
item["date"],
|
||||
days_left,
|
||||
is_overdue,
|
||||
)
|
||||
await send_email(user.email, title, html)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
scheduler.add_job(
|
||||
check_upcoming_repayments,
|
||||
"interval",
|
||||
minutes=settings.NOTIFICATION_CHECK_INTERVAL_MINUTES,
|
||||
id="repayment_check",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
16
backend/requirements.txt
Normal file
16
backend/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
fastapi==0.109.2
|
||||
uvicorn[standard]==0.27.1
|
||||
sqlalchemy==2.0.27
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.3
|
||||
python-multipart==0.0.9
|
||||
pydantic==2.6.1
|
||||
pydantic-settings==2.1.0
|
||||
redis==5.0.1
|
||||
httpx==0.26.0
|
||||
python-dateutil==2.8.2
|
||||
apscheduler==3.10.4
|
||||
aiosmtplib==3.0.1
|
||||
603
data.sql
Normal file
603
data.sql
Normal file
@@ -0,0 +1,603 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict RdcQlBPEqYuP88EyiWHtXP8VF7CTE1R3DOjX7UMme8jFrcKXvNbaZeFWWNkzt7J
|
||||
|
||||
-- Dumped from database version 15.18
|
||||
-- Dumped by pg_dump version 15.18
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: audit_logs; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.audit_logs (
|
||||
id integer NOT NULL,
|
||||
user_id integer,
|
||||
action character varying(50) NOT NULL,
|
||||
target_type character varying(50),
|
||||
target_id integer,
|
||||
details json,
|
||||
created_at timestamp without time zone
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.audit_logs OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: audit_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.audit_logs_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.audit_logs_id_seq OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: audit_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.audit_logs_id_seq OWNED BY public.audit_logs.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: debts; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.debts (
|
||||
id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
creditor_name character varying(255) NOT NULL,
|
||||
amount integer NOT NULL,
|
||||
borrow_date timestamp without time zone NOT NULL,
|
||||
interest_rate double precision NOT NULL,
|
||||
rate_type character varying(20),
|
||||
repayment_method character varying(30) NOT NULL,
|
||||
period_count integer NOT NULL,
|
||||
period_unit character varying(10),
|
||||
status character varying(20),
|
||||
purpose character varying(255),
|
||||
notes text,
|
||||
created_at timestamp without time zone,
|
||||
schedule_data json,
|
||||
paid_periods json
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.debts OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: debts_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.debts_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.debts_id_seq OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: debts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.debts_id_seq OWNED BY public.debts.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: groups; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.groups (
|
||||
id integer NOT NULL,
|
||||
name character varying(100) NOT NULL,
|
||||
description text,
|
||||
created_at timestamp without time zone
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.groups OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: groups_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.groups_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.groups_id_seq OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: groups_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.groups_id_seq OWNED BY public.groups.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_plans; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.repayment_plans (
|
||||
id integer NOT NULL,
|
||||
debt_id integer NOT NULL,
|
||||
due_date timestamp without time zone NOT NULL,
|
||||
principal_due integer NOT NULL,
|
||||
interest_due integer NOT NULL,
|
||||
total_due integer NOT NULL,
|
||||
status character varying(20)
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.repayment_plans OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: repayment_plans_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.repayment_plans_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.repayment_plans_id_seq OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: repayment_plans_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.repayment_plans_id_seq OWNED BY public.repayment_plans.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_records; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.repayment_records (
|
||||
id integer NOT NULL,
|
||||
debt_id integer NOT NULL,
|
||||
plan_id integer,
|
||||
amount integer NOT NULL,
|
||||
paid_date timestamp without time zone NOT NULL,
|
||||
notes text,
|
||||
created_at timestamp without time zone
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.repayment_records OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: repayment_records_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.repayment_records_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.repayment_records_id_seq OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: repayment_records_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.repayment_records_id_seq OWNED BY public.repayment_records.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: system_settings; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.system_settings (
|
||||
key character varying(100) NOT NULL,
|
||||
value text NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.system_settings OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: users; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.users (
|
||||
id integer NOT NULL,
|
||||
email character varying(255) NOT NULL,
|
||||
password_hash character varying(255) NOT NULL,
|
||||
nickname character varying(100),
|
||||
role character varying(20),
|
||||
status character varying(20),
|
||||
group_id integer,
|
||||
created_at timestamp without time zone
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.users OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.users_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.users_id_seq OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: audit_logs id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.audit_logs ALTER COLUMN id SET DEFAULT nextval('public.audit_logs_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: debts id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.debts ALTER COLUMN id SET DEFAULT nextval('public.debts_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: groups id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.groups ALTER COLUMN id SET DEFAULT nextval('public.groups_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_plans id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.repayment_plans ALTER COLUMN id SET DEFAULT nextval('public.repayment_plans_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_records id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.repayment_records ALTER COLUMN id SET DEFAULT nextval('public.repayment_records_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: audit_logs; Type: TABLE DATA; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
COPY public.audit_logs (id, user_id, action, target_type, target_id, details, created_at) FROM stdin;
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: debts; Type: TABLE DATA; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
COPY public.debts (id, user_id, creditor_name, amount, borrow_date, interest_rate, rate_type, repayment_method, period_count, period_unit, status, purpose, notes, created_at, schedule_data, paid_periods) FROM stdin;
|
||||
61 1 管理员的单 5000000 2026-01-01 00:00:00 8 annual equal_installment 6 month active 2026-06-25 15:11:21.412465 [] []
|
||||
54 5 借呗 1000000 2026-04-14 00:00:00 14.4 annual equal_installment 24 month active 2026-06-24 14:59:49.57332 [{"p": 1, "date": "2026-05-14", "pay": 482.02, "pr": 362.02, "it": 120, "rp": 9637.98}, {"p": 2, "date": "2026-06-14", "pay": 482.02, "pr": 366.36, "it": 115.66, "rp": 9271.61}, {"p": 3, "date": "2026-07-14", "pay": 482.02, "pr": 370.76, "it": 111.26, "rp": 8900.85}, {"p": 4, "date": "2026-08-14", "pay": 482.02, "pr": 375.21, "it": 106.81, "rp": 8525.64}, {"p": 5, "date": "2026-09-14", "pay": 482.02, "pr": 379.71, "it": 102.31, "rp": 8145.93}, {"p": 6, "date": "2026-10-14", "pay": 482.02, "pr": 384.27, "it": 97.75, "rp": 7761.66}, {"p": 7, "date": "2026-11-14", "pay": 482.02, "pr": 388.88, "it": 93.14, "rp": 7372.78}, {"p": 8, "date": "2026-12-14", "pay": 482.02, "pr": 393.55, "it": 88.47, "rp": 6979.23}, {"p": 9, "date": "2027-01-14", "pay": 482.02, "pr": 398.27, "it": 83.75, "rp": 6580.96}, {"p": 10, "date": "2027-02-14", "pay": 482.02, "pr": 403.05, "it": 78.97, "rp": 6177.91}, {"p": 11, "date": "2027-03-14", "pay": 482.02, "pr": 407.89, "it": 74.13, "rp": 5770.03}, {"p": 12, "date": "2027-04-14", "pay": 482.02, "pr": 412.78, "it": 69.24, "rp": 5357.25}, {"p": 13, "date": "2027-05-14", "pay": 482.02, "pr": 417.73, "it": 64.29, "rp": 4939.51}, {"p": 14, "date": "2027-06-14", "pay": 482.02, "pr": 422.75, "it": 59.27, "rp": 4516.77}, {"p": 15, "date": "2027-07-14", "pay": 482.02, "pr": 427.82, "it": 54.2, "rp": 4088.95}, {"p": 16, "date": "2027-08-14", "pay": 482.02, "pr": 432.95, "it": 49.07, "rp": 3655.99}, {"p": 17, "date": "2027-09-14", "pay": 482.02, "pr": 438.15, "it": 43.87, "rp": 3217.85}, {"p": 18, "date": "2027-10-14", "pay": 482.02, "pr": 443.41, "it": 38.61, "rp": 2774.44}, {"p": 19, "date": "2027-11-14", "pay": 482.02, "pr": 448.73, "it": 33.29, "rp": 2325.71}, {"p": 20, "date": "2027-12-14", "pay": 482.02, "pr": 454.11, "it": 27.91, "rp": 1871.6}, {"p": 21, "date": "2028-01-14", "pay": 482.02, "pr": 459.56, "it": 22.46, "rp": 1412.04}, {"p": 22, "date": "2028-02-14", "pay": 482.02, "pr": 465.08, "it": 16.94, "rp": 946.96}, {"p": 23, "date": "2028-03-14", "pay": 482.02, "pr": 470.66, "it": 11.36, "rp": 476.3}, {"p": 24, "date": "2028-04-14", "pay": 482.02, "pr": 476.3, "it": 5.72, "rp": 0}] [1, 2, 7]
|
||||
36 4 国庆 8000000 2025-12-22 00:00:00 14.45 annual equal_installment 12 month active 2026-06-24 13:50:38.299868 [{"p": 1, "date": "2026-01-22", "pay": 7199.92, "pr": 6236.59, "it": 963.33, "rp": 73763.41}, {"p": 2, "date": "2026-02-22", "pay": 7199.92, "pr": 6311.68, "it": 888.23, "rp": 67451.73}, {"p": 3, "date": "2026-03-22", "pay": 7199.92, "pr": 6387.69, "it": 812.23, "rp": 61064.04}, {"p": 4, "date": "2026-04-22", "pay": 7199.92, "pr": 6464.61, "it": 735.31, "rp": 54599.44}, {"p": 5, "date": "2026-05-22", "pay": 7199.92, "pr": 6542.45, "it": 657.47, "rp": 48056.98}, {"p": 6, "date": "2026-06-22", "pay": 7199.92, "pr": 6621.23, "it": 578.69, "rp": 41435.75}, {"p": 7, "date": "2026-07-22", "pay": 7199.92, "pr": 6700.96, "it": 498.96, "rp": 34734.79}, {"p": 8, "date": "2026-08-22", "pay": 7199.92, "pr": 6781.65, "it": 418.26, "rp": 27953.13}, {"p": 9, "date": "2026-09-22", "pay": 7199.92, "pr": 6863.32, "it": 336.6, "rp": 21089.82}, {"p": 10, "date": "2026-10-22", "pay": 7199.92, "pr": 6945.96, "it": 253.96, "rp": 14143.86}, {"p": 11, "date": "2026-11-22", "pay": 7199.92, "pr": 7029.6, "it": 170.32, "rp": 7114.25}, {"p": 12, "date": "2026-12-22", "pay": 7199.92, "pr": 7114.25, "it": 85.67, "rp": 0}] [1, 2, 3, 4, 5]
|
||||
39 4 国庆 3000000 2026-02-14 00:00:00 4 annual lump_sum 12 month active 2026-06-24 13:50:38.336453 [{"p": 1, "date": "2026-03-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 2, "date": "2026-04-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 3, "date": "2026-05-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 4, "date": "2026-06-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 5, "date": "2026-07-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 6, "date": "2026-08-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 7, "date": "2026-09-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 8, "date": "2026-10-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 9, "date": "2026-11-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 10, "date": "2026-12-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 11, "date": "2027-01-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 12, "date": "2027-02-14", "pay": 30100, "pr": 30000, "it": 100, "rp": 0}] [1, 2, 3]
|
||||
33 4 广发信用卡 3600000 2025-10-19 00:00:00 9.2 annual equal_installment 24 month active 2026-06-24 13:50:38.210885 [{"p": 1, "date": "2025-11-19", "pay": 1647.96, "pr": 1371.96, "it": 276, "rp": 34628.04}, {"p": 2, "date": "2025-12-19", "pay": 1647.96, "pr": 1382.47, "it": 265.48, "rp": 33245.57}, {"p": 3, "date": "2026-01-19", "pay": 1647.96, "pr": 1393.07, "it": 254.88, "rp": 31852.5}, {"p": 4, "date": "2026-02-19", "pay": 1647.96, "pr": 1403.75, "it": 244.2, "rp": 30448.74}, {"p": 5, "date": "2026-03-19", "pay": 1647.96, "pr": 1414.52, "it": 233.44, "rp": 29034.23}, {"p": 6, "date": "2026-04-19", "pay": 1647.96, "pr": 1425.36, "it": 222.6, "rp": 27608.87}, {"p": 7, "date": "2026-05-19", "pay": 1647.96, "pr": 1436.29, "it": 211.67, "rp": 26172.58}, {"p": 8, "date": "2026-06-19", "pay": 1647.96, "pr": 1447.3, "it": 200.66, "rp": 24725.28}, {"p": 9, "date": "2026-07-19", "pay": 1647.96, "pr": 1458.4, "it": 189.56, "rp": 23266.88}, {"p": 10, "date": "2026-08-19", "pay": 1647.96, "pr": 1469.58, "it": 178.38, "rp": 21797.31}, {"p": 11, "date": "2026-09-19", "pay": 1647.96, "pr": 1480.84, "it": 167.11, "rp": 20316.46}, {"p": 12, "date": "2026-10-19", "pay": 1647.96, "pr": 1492.2, "it": 155.76, "rp": 18824.27}, {"p": 13, "date": "2026-11-19", "pay": 1647.96, "pr": 1503.64, "it": 144.32, "rp": 17320.63}, {"p": 14, "date": "2026-12-19", "pay": 1647.96, "pr": 1515.16, "it": 132.79, "rp": 15805.46}, {"p": 15, "date": "2027-01-19", "pay": 1647.96, "pr": 1526.78, "it": 121.18, "rp": 14278.68}, {"p": 16, "date": "2027-02-19", "pay": 1647.96, "pr": 1538.49, "it": 109.47, "rp": 12740.2}, {"p": 17, "date": "2027-03-19", "pay": 1647.96, "pr": 1550.28, "it": 97.67, "rp": 11189.92}, {"p": 18, "date": "2027-04-19", "pay": 1647.96, "pr": 1562.17, "it": 85.79, "rp": 9627.75}, {"p": 19, "date": "2027-05-19", "pay": 1647.96, "pr": 1574.14, "it": 73.81, "rp": 8053.6}, {"p": 20, "date": "2027-06-19", "pay": 1647.96, "pr": 1586.21, "it": 61.74, "rp": 6467.39}, {"p": 21, "date": "2027-07-19", "pay": 1647.96, "pr": 1598.37, "it": 49.58, "rp": 4869.02}, {"p": 22, "date": "2027-08-19", "pay": 1647.96, "pr": 1610.63, "it": 37.33, "rp": 3258.39}, {"p": 23, "date": "2027-09-19", "pay": 1647.96, "pr": 1622.98, "it": 24.98, "rp": 1635.42}, {"p": 24, "date": "2027-10-19", "pay": 1647.96, "pr": 1635.42, "it": 12.54, "rp": 0}] [1, 2, 3, 4, 5, 6, 7]
|
||||
40 4 GK 2400000 2026-03-15 00:00:00 0 annual lump_sum 1 month active 2026-06-24 13:50:38.350761 [{"p": 1, "date": "2026-04-15", "pay": 24000, "pr": 24000, "it": 0, "rp": 0}] [1]
|
||||
31 4 GK 8600000 2025-06-14 00:00:00 5.7 annual equal_installment 24 month active 2026-06-24 13:50:38.130577 [{"p": 1, "date": "2025-07-14", "pay": 3799.96, "pr": 3391.46, "it": 408.5, "rp": 82608.54}, {"p": 2, "date": "2025-08-14", "pay": 3799.96, "pr": 3407.57, "it": 392.39, "rp": 79200.98}, {"p": 3, "date": "2025-09-14", "pay": 3799.96, "pr": 3423.75, "it": 376.2, "rp": 75777.22}, {"p": 4, "date": "2025-10-14", "pay": 3799.96, "pr": 3440.02, "it": 359.94, "rp": 72337.21}, {"p": 5, "date": "2025-11-14", "pay": 3799.96, "pr": 3456.36, "it": 343.6, "rp": 68880.85}, {"p": 6, "date": "2025-12-14", "pay": 3799.96, "pr": 3472.77, "it": 327.18, "rp": 65408.08}, {"p": 7, "date": "2026-01-14", "pay": 3799.96, "pr": 3489.27, "it": 310.69, "rp": 61918.81}, {"p": 8, "date": "2026-02-14", "pay": 3799.96, "pr": 3505.84, "it": 294.11, "rp": 58412.96}, {"p": 9, "date": "2026-03-14", "pay": 3799.96, "pr": 3522.5, "it": 277.46, "rp": 54890.47}, {"p": 10, "date": "2026-04-14", "pay": 3799.96, "pr": 3539.23, "it": 260.73, "rp": 51351.24}, {"p": 11, "date": "2026-05-14", "pay": 3799.96, "pr": 3556.04, "it": 243.92, "rp": 47795.2}, {"p": 12, "date": "2026-06-14", "pay": 3799.96, "pr": 3572.93, "it": 227.03, "rp": 44222.27}, {"p": 13, "date": "2026-07-14", "pay": 3799.96, "pr": 3589.9, "it": 210.06, "rp": 40632.37}, {"p": 14, "date": "2026-08-14", "pay": 3799.96, "pr": 3606.95, "it": 193, "rp": 37025.41}, {"p": 15, "date": "2026-09-14", "pay": 3799.96, "pr": 3624.09, "it": 175.87, "rp": 33401.33}, {"p": 16, "date": "2026-10-14", "pay": 3799.96, "pr": 3641.3, "it": 158.66, "rp": 29760.02}, {"p": 17, "date": "2026-11-14", "pay": 3799.96, "pr": 3658.6, "it": 141.36, "rp": 26101.43}, {"p": 18, "date": "2026-12-14", "pay": 3799.96, "pr": 3675.98, "it": 123.98, "rp": 22425.45}, {"p": 19, "date": "2027-01-14", "pay": 3799.96, "pr": 3693.44, "it": 106.52, "rp": 18732.01}, {"p": 20, "date": "2027-02-14", "pay": 3799.96, "pr": 3710.98, "it": 88.98, "rp": 15021.03}, {"p": 21, "date": "2027-03-14", "pay": 3799.96, "pr": 3728.61, "it": 71.35, "rp": 11292.43}, {"p": 22, "date": "2027-04-14", "pay": 3799.96, "pr": 3746.32, "it": 53.64, "rp": 7546.11}, {"p": 23, "date": "2027-05-14", "pay": 3799.96, "pr": 3764.11, "it": 35.84, "rp": 3781.99}, {"p": 24, "date": "2027-06-14", "pay": 3799.96, "pr": 3781.99, "it": 17.96, "rp": 0}] [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
||||
34 4 广发信用卡 3500000 2025-11-03 00:00:00 16.43 annual equal_installment 24 month active 2026-06-24 13:50:38.248821 [{"p": 1, "date": "2025-12-03", "pay": 1720.91, "pr": 1241.7, "it": 479.21, "rp": 33758.3}, {"p": 2, "date": "2026-01-03", "pay": 1720.91, "pr": 1258.7, "it": 462.21, "rp": 32499.6}, {"p": 3, "date": "2026-02-03", "pay": 1720.91, "pr": 1275.93, "it": 444.97, "rp": 31223.66}, {"p": 4, "date": "2026-03-03", "pay": 1720.91, "pr": 1293.4, "it": 427.5, "rp": 29930.26}, {"p": 5, "date": "2026-04-03", "pay": 1720.91, "pr": 1311.11, "it": 409.8, "rp": 28619.15}, {"p": 6, "date": "2026-05-03", "pay": 1720.91, "pr": 1329.06, "it": 391.84, "rp": 27290.08}, {"p": 7, "date": "2026-06-03", "pay": 1720.91, "pr": 1347.26, "it": 373.65, "rp": 25942.82}, {"p": 8, "date": "2026-07-03", "pay": 1720.91, "pr": 1365.71, "it": 355.2, "rp": 24577.11}, {"p": 9, "date": "2026-08-03", "pay": 1720.91, "pr": 1384.41, "it": 336.5, "rp": 23192.7}, {"p": 10, "date": "2026-09-03", "pay": 1720.91, "pr": 1403.36, "it": 317.55, "rp": 21789.34}, {"p": 11, "date": "2026-10-03", "pay": 1720.91, "pr": 1422.58, "it": 298.33, "rp": 20366.77}, {"p": 12, "date": "2026-11-03", "pay": 1720.91, "pr": 1442.05, "it": 278.85, "rp": 18924.71}, {"p": 13, "date": "2026-12-03", "pay": 1720.91, "pr": 1461.8, "it": 259.11, "rp": 17462.91}, {"p": 14, "date": "2027-01-03", "pay": 1720.91, "pr": 1481.81, "it": 239.1, "rp": 15981.1}, {"p": 15, "date": "2027-02-03", "pay": 1720.91, "pr": 1502.1, "it": 218.81, "rp": 14479}, {"p": 16, "date": "2027-03-03", "pay": 1720.91, "pr": 1522.67, "it": 198.24, "rp": 12956.33}, {"p": 17, "date": "2027-04-03", "pay": 1720.91, "pr": 1543.51, "it": 177.39, "rp": 11412.82}, {"p": 18, "date": "2027-05-03", "pay": 1720.91, "pr": 1564.65, "it": 156.26, "rp": 9848.17}, {"p": 19, "date": "2027-06-03", "pay": 1720.91, "pr": 1586.07, "it": 134.84, "rp": 8262.1}, {"p": 20, "date": "2027-07-03", "pay": 1720.91, "pr": 1607.79, "it": 113.12, "rp": 6654.31}, {"p": 21, "date": "2027-08-03", "pay": 1720.91, "pr": 1629.8, "it": 91.11, "rp": 5024.51}, {"p": 22, "date": "2027-09-03", "pay": 1720.91, "pr": 1652.11, "it": 68.79, "rp": 3372.4}, {"p": 23, "date": "2027-10-03", "pay": 1720.91, "pr": 1674.73, "it": 46.17, "rp": 1697.66}, {"p": 24, "date": "2027-11-03", "pay": 1720.91, "pr": 1697.66, "it": 23.24, "rp": 0}] [1, 2, 3, 4, 5, 6]
|
||||
37 4 国庆 3000000 2025-11-02 00:00:00 28.33 annual equal_installment 12 month active 2026-06-24 13:50:38.312429 [{"p": 1, "date": "2025-12-02", "pay": 2900.03, "pr": 2191.78, "it": 708.25, "rp": 27808.22}, {"p": 2, "date": "2026-01-02", "pay": 2900.03, "pr": 2243.52, "it": 656.51, "rp": 25564.71}, {"p": 3, "date": "2026-02-02", "pay": 2900.03, "pr": 2296.49, "it": 603.54, "rp": 23268.22}, {"p": 4, "date": "2026-03-02", "pay": 2900.03, "pr": 2350.7, "it": 549.32, "rp": 20917.52}, {"p": 5, "date": "2026-04-02", "pay": 2900.03, "pr": 2406.2, "it": 493.83, "rp": 18511.32}, {"p": 6, "date": "2026-05-02", "pay": 2900.03, "pr": 2463, "it": 437.02, "rp": 16048.32}, {"p": 7, "date": "2026-06-02", "pay": 2900.03, "pr": 2521.15, "it": 378.87, "rp": 13527.17}, {"p": 8, "date": "2026-07-02", "pay": 2900.03, "pr": 2580.67, "it": 319.35, "rp": 10946.49}, {"p": 9, "date": "2026-08-02", "pay": 2900.03, "pr": 2641.6, "it": 258.43, "rp": 8304.9}, {"p": 10, "date": "2026-09-02", "pay": 2900.03, "pr": 2703.96, "it": 196.06, "rp": 5600.94}, {"p": 11, "date": "2026-10-02", "pay": 2900.03, "pr": 2767.8, "it": 132.23, "rp": 2833.14}, {"p": 12, "date": "2026-11-02", "pay": 2900.03, "pr": 2833.14, "it": 66.89, "rp": 0}] [1, 2, 3, 4, 5, 6]
|
||||
32 4 信用卡 6000000 2025-09-25 00:00:00 17.45 annual equal_installment 6 month active 2026-06-24 13:50:38.182766 [{"p": 1, "date": "2025-10-25", "pay": 10515.08, "pr": 9642.58, "it": 872.5, "rp": 50357.42}, {"p": 2, "date": "2025-11-25", "pay": 10515.08, "pr": 9782.8, "it": 732.28, "rp": 40574.62}, {"p": 3, "date": "2025-12-25", "pay": 10515.08, "pr": 9925.06, "it": 590.02, "rp": 30649.56}, {"p": 4, "date": "2026-01-25", "pay": 10515.08, "pr": 10069.39, "it": 445.7, "rp": 20580.18}, {"p": 5, "date": "2026-02-25", "pay": 10515.08, "pr": 10215.81, "it": 299.27, "rp": 10364.37}, {"p": 6, "date": "2026-03-25", "pay": 10515.08, "pr": 10364.37, "it": 150.72, "rp": 0}] [1, 2, 3, 4, 5, 6]
|
||||
53 5 借呗 2000000 2026-05-14 00:00:00 12.24 annual equal_installment 24 month active 2026-06-24 14:59:49.562775 [{"p": 1, "date": "2026-06-14", "pay": 943.71, "pr": 739.71, "it": 204, "rp": 19260.29}, {"p": 2, "date": "2026-07-14", "pay": 943.71, "pr": 747.26, "it": 196.45, "rp": 18513.03}, {"p": 3, "date": "2026-08-14", "pay": 943.71, "pr": 754.88, "it": 188.83, "rp": 17758.15}, {"p": 4, "date": "2026-09-14", "pay": 943.71, "pr": 762.58, "it": 181.13, "rp": 16995.57}, {"p": 5, "date": "2026-10-14", "pay": 943.71, "pr": 770.36, "it": 173.35, "rp": 16225.21}, {"p": 6, "date": "2026-11-14", "pay": 943.71, "pr": 778.22, "it": 165.5, "rp": 15447}, {"p": 7, "date": "2026-12-14", "pay": 943.71, "pr": 786.15, "it": 157.56, "rp": 14660.84}, {"p": 8, "date": "2027-01-14", "pay": 943.71, "pr": 794.17, "it": 149.54, "rp": 13866.67}, {"p": 9, "date": "2027-02-14", "pay": 943.71, "pr": 802.27, "it": 141.44, "rp": 13064.4}, {"p": 10, "date": "2027-03-14", "pay": 943.71, "pr": 810.46, "it": 133.26, "rp": 12253.94}, {"p": 11, "date": "2027-04-14", "pay": 943.71, "pr": 818.72, "it": 124.99, "rp": 11435.22}, {"p": 12, "date": "2027-05-14", "pay": 943.71, "pr": 827.07, "it": 116.64, "rp": 10608.15}, {"p": 13, "date": "2027-06-14", "pay": 943.71, "pr": 835.51, "it": 108.2, "rp": 9772.64}, {"p": 14, "date": "2027-07-14", "pay": 943.71, "pr": 844.03, "it": 99.68, "rp": 8928.61}, {"p": 15, "date": "2027-08-14", "pay": 943.71, "pr": 852.64, "it": 91.07, "rp": 8075.97}, {"p": 16, "date": "2027-09-14", "pay": 943.71, "pr": 861.34, "it": 82.37, "rp": 7214.63}, {"p": 17, "date": "2027-10-14", "pay": 943.71, "pr": 870.12, "it": 73.59, "rp": 6344.51}, {"p": 18, "date": "2027-11-14", "pay": 943.71, "pr": 879, "it": 64.71, "rp": 5465.51}, {"p": 19, "date": "2027-12-14", "pay": 943.71, "pr": 887.96, "it": 55.75, "rp": 4577.54}, {"p": 20, "date": "2028-01-14", "pay": 943.71, "pr": 897.02, "it": 46.69, "rp": 3680.52}, {"p": 21, "date": "2028-02-14", "pay": 943.71, "pr": 906.17, "it": 37.54, "rp": 2774.35}, {"p": 22, "date": "2028-03-14", "pay": 943.71, "pr": 915.41, "it": 28.3, "rp": 1858.94}, {"p": 23, "date": "2028-04-14", "pay": 943.71, "pr": 924.75, "it": 18.96, "rp": 934.18}, {"p": 24, "date": "2028-05-14", "pay": 943.71, "pr": 934.18, "it": 9.53, "rp": 0}] [1, 6]
|
||||
38 4 中信信用卡 3700000 2026-02-05 00:00:00 16.64 annual equal_installment 12 month active 2026-06-24 13:50:38.325601 [{"p": 1, "date": "2026-03-05", "pay": 3368.26, "pr": 2855.19, "it": 513.07, "rp": 34144.81}, {"p": 2, "date": "2026-04-05", "pay": 3368.26, "pr": 2894.78, "it": 473.47, "rp": 31250.03}, {"p": 3, "date": "2026-05-05", "pay": 3368.26, "pr": 2934.92, "it": 433.33, "rp": 28315.1}, {"p": 4, "date": "2026-06-05", "pay": 3368.26, "pr": 2975.62, "it": 392.64, "rp": 25339.48}, {"p": 5, "date": "2026-07-05", "pay": 3368.26, "pr": 3016.88, "it": 351.37, "rp": 22322.6}, {"p": 6, "date": "2026-08-05", "pay": 3368.26, "pr": 3058.72, "it": 309.54, "rp": 19263.88}, {"p": 7, "date": "2026-09-05", "pay": 3368.26, "pr": 3101.13, "it": 267.13, "rp": 16162.75}, {"p": 8, "date": "2026-10-05", "pay": 3368.26, "pr": 3144.13, "it": 224.12, "rp": 13018.61}, {"p": 9, "date": "2026-11-05", "pay": 3368.26, "pr": 3187.73, "it": 180.52, "rp": 9830.88}, {"p": 10, "date": "2026-12-05", "pay": 3368.26, "pr": 3231.94, "it": 136.32, "rp": 6598.94}, {"p": 11, "date": "2027-01-05", "pay": 3368.26, "pr": 3276.75, "it": 91.51, "rp": 3322.19}, {"p": 12, "date": "2027-02-05", "pay": 3368.26, "pr": 3322.19, "it": 46.07, "rp": 0}] [1, 2, 3]
|
||||
35 4 mm 3500000 2026-01-15 00:00:00 17.1429 annual equal_installment 1 month active 2026-06-24 13:50:38.273985 [{"p": 1, "date": "2026-02-15", "pay": 35500, "pr": 35000, "it": 500, "rp": 0}] []
|
||||
47 5 招商 1000000 2026-03-22 00:00:00 9.81 annual equal_installment 24 month active 2026-06-24 14:59:49.469152 [{"p": 1, "date": "2026-04-22", "pay": 460.57, "pr": 378.82, "it": 81.75, "rp": 9621.18}, {"p": 2, "date": "2026-05-22", "pay": 460.57, "pr": 381.92, "it": 78.65, "rp": 9239.26}, {"p": 3, "date": "2026-06-22", "pay": 460.57, "pr": 385.04, "it": 75.53, "rp": 8854.22}, {"p": 4, "date": "2026-07-22", "pay": 460.57, "pr": 388.19, "it": 72.38, "rp": 8466.03}, {"p": 5, "date": "2026-08-22", "pay": 460.57, "pr": 391.36, "it": 69.21, "rp": 8074.66}, {"p": 6, "date": "2026-09-22", "pay": 460.57, "pr": 394.56, "it": 66.01, "rp": 7680.1}, {"p": 7, "date": "2026-10-22", "pay": 460.57, "pr": 397.79, "it": 62.78, "rp": 7282.31}, {"p": 8, "date": "2026-11-22", "pay": 460.57, "pr": 401.04, "it": 59.53, "rp": 6881.27}, {"p": 9, "date": "2026-12-22", "pay": 460.57, "pr": 404.32, "it": 56.25, "rp": 6476.95}, {"p": 10, "date": "2027-01-22", "pay": 460.57, "pr": 407.62, "it": 52.95, "rp": 6069.33}, {"p": 11, "date": "2027-02-22", "pay": 460.57, "pr": 410.96, "it": 49.62, "rp": 5658.37}, {"p": 12, "date": "2027-03-22", "pay": 460.57, "pr": 414.32, "it": 46.26, "rp": 5244.06}, {"p": 13, "date": "2027-04-22", "pay": 460.57, "pr": 417.7, "it": 42.87, "rp": 4826.36}, {"p": 14, "date": "2027-05-22", "pay": 460.57, "pr": 421.12, "it": 39.46, "rp": 4405.24}, {"p": 15, "date": "2027-06-22", "pay": 460.57, "pr": 424.56, "it": 36.01, "rp": 3980.68}, {"p": 16, "date": "2027-07-22", "pay": 460.57, "pr": 428.03, "it": 32.54, "rp": 3552.65}, {"p": 17, "date": "2027-08-22", "pay": 460.57, "pr": 431.53, "it": 29.04, "rp": 3121.12}, {"p": 18, "date": "2027-09-22", "pay": 460.57, "pr": 435.06, "it": 25.52, "rp": 2686.06}, {"p": 19, "date": "2027-10-22", "pay": 460.57, "pr": 438.61, "it": 21.96, "rp": 2247.45}, {"p": 20, "date": "2027-11-22", "pay": 460.57, "pr": 442.2, "it": 18.37, "rp": 1805.25}, {"p": 21, "date": "2027-12-22", "pay": 460.57, "pr": 445.81, "it": 14.76, "rp": 1359.43}, {"p": 22, "date": "2028-01-22", "pay": 460.57, "pr": 449.46, "it": 11.11, "rp": 909.97}, {"p": 23, "date": "2028-02-22", "pay": 460.57, "pr": 453.13, "it": 7.44, "rp": 456.84}, {"p": 24, "date": "2028-03-22", "pay": 460.57, "pr": 456.84, "it": 3.73, "rp": 0}] [1, 2, 3, 8]
|
||||
41 4 招行信用卡 2800000 2026-06-20 00:00:00 9.7 annual equal_installment 12 month active 周转 2026-06-24 13:50:38.363134 [{"p": 1, "date": "2026-07-20", "pay": 2457.74, "pr": 2231.41, "it": 226.33, "rp": 25768.59}, {"p": 2, "date": "2026-08-20", "pay": 2457.74, "pr": 2249.44, "it": 208.3, "rp": 23519.15}, {"p": 3, "date": "2026-09-20", "pay": 2457.74, "pr": 2267.63, "it": 190.11, "rp": 21251.52}, {"p": 4, "date": "2026-10-20", "pay": 2457.74, "pr": 2285.96, "it": 171.78, "rp": 18965.57}, {"p": 5, "date": "2026-11-20", "pay": 2457.74, "pr": 2304.43, "it": 153.3, "rp": 16661.13}, {"p": 6, "date": "2026-12-20", "pay": 2457.74, "pr": 2323.06, "it": 134.68, "rp": 14338.07}, {"p": 7, "date": "2027-01-20", "pay": 2457.74, "pr": 2341.84, "it": 115.9, "rp": 11996.23}, {"p": 8, "date": "2027-02-20", "pay": 2457.74, "pr": 2360.77, "it": 96.97, "rp": 9635.46}, {"p": 9, "date": "2027-03-20", "pay": 2457.74, "pr": 2379.85, "it": 77.89, "rp": 7255.61}, {"p": 10, "date": "2027-04-20", "pay": 2457.74, "pr": 2399.09, "it": 58.65, "rp": 4856.52}, {"p": 11, "date": "2027-05-20", "pay": 2457.74, "pr": 2418.48, "it": 39.26, "rp": 2438.03}, {"p": 12, "date": "2027-06-20", "pay": 2457.74, "pr": 2438.03, "it": 19.71, "rp": 0}] []
|
||||
49 5 抖音 2000000 2025-10-09 00:00:00 4.63 annual equal_installment 12 month active 2026-06-24 14:59:49.499084 [{"p": 1, "date": "2025-11-09", "pay": 1708.76, "pr": 1631.59, "it": 77.17, "rp": 18368.41}, {"p": 2, "date": "2025-12-09", "pay": 1708.76, "pr": 1637.89, "it": 70.87, "rp": 16730.52}, {"p": 3, "date": "2026-01-09", "pay": 1708.76, "pr": 1644.21, "it": 64.55, "rp": 15086.31}, {"p": 4, "date": "2026-02-09", "pay": 1708.76, "pr": 1650.55, "it": 58.21, "rp": 13435.76}, {"p": 5, "date": "2026-03-09", "pay": 1708.76, "pr": 1656.92, "it": 51.84, "rp": 11778.84}, {"p": 6, "date": "2026-04-09", "pay": 1708.76, "pr": 1663.31, "it": 45.45, "rp": 10115.52}, {"p": 7, "date": "2026-05-09", "pay": 1708.76, "pr": 1669.73, "it": 39.03, "rp": 8445.79}, {"p": 8, "date": "2026-06-09", "pay": 1708.76, "pr": 1676.17, "it": 32.59, "rp": 6769.62}, {"p": 9, "date": "2026-07-09", "pay": 1708.76, "pr": 1682.64, "it": 26.12, "rp": 5086.98}, {"p": 10, "date": "2026-08-09", "pay": 1708.76, "pr": 1689.13, "it": 19.63, "rp": 3397.84}, {"p": 11, "date": "2026-09-09", "pay": 1708.76, "pr": 1695.65, "it": 13.11, "rp": 1702.19}, {"p": 12, "date": "2026-10-09", "pay": 1708.76, "pr": 1702.19, "it": 6.57, "rp": 0}] [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
46 5 招商 859900 2025-10-20 00:00:00 0 annual equal_installment 12 month active 手机 2026-06-24 14:59:49.45153 [{"p": 1, "date": "2025-11-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 7882.42}, {"p": 2, "date": "2025-12-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 7165.83}, {"p": 3, "date": "2026-01-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 6449.25}, {"p": 4, "date": "2026-02-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 5732.67}, {"p": 5, "date": "2026-03-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 5016.08}, {"p": 6, "date": "2026-04-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 4299.5}, {"p": 7, "date": "2026-05-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 3582.92}, {"p": 8, "date": "2026-06-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 2866.33}, {"p": 9, "date": "2026-07-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 2149.75}, {"p": 10, "date": "2026-08-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 1433.17}, {"p": 11, "date": "2026-09-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 716.58}, {"p": 12, "date": "2026-10-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 0}] [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
48 5 平安 2900000 2025-11-19 00:00:00 3.65 annual equal_installment 24 month active 2026-06-24 14:59:49.483587 [{"p": 1, "date": "2025-12-19", "pay": 1254.81, "pr": 1166.6, "it": 88.21, "rp": 27833.4}, {"p": 2, "date": "2026-01-19", "pay": 1254.81, "pr": 1170.15, "it": 84.66, "rp": 26663.25}, {"p": 3, "date": "2026-02-19", "pay": 1254.81, "pr": 1173.71, "it": 81.1, "rp": 25489.54}, {"p": 4, "date": "2026-03-19", "pay": 1254.81, "pr": 1177.28, "it": 77.53, "rp": 24312.26}, {"p": 5, "date": "2026-04-19", "pay": 1254.81, "pr": 1180.86, "it": 73.95, "rp": 23131.4}, {"p": 6, "date": "2026-05-19", "pay": 1254.81, "pr": 1184.45, "it": 70.36, "rp": 21946.95}, {"p": 7, "date": "2026-06-19", "pay": 1254.81, "pr": 1188.05, "it": 66.76, "rp": 20758.89}, {"p": 8, "date": "2026-07-19", "pay": 1254.81, "pr": 1191.67, "it": 63.14, "rp": 19567.22}, {"p": 9, "date": "2026-08-19", "pay": 1254.81, "pr": 1195.29, "it": 59.52, "rp": 18371.93}, {"p": 10, "date": "2026-09-19", "pay": 1254.81, "pr": 1198.93, "it": 55.88, "rp": 17173}, {"p": 11, "date": "2026-10-19", "pay": 1254.81, "pr": 1202.58, "it": 52.23, "rp": 15970.43}, {"p": 12, "date": "2026-11-19", "pay": 1254.81, "pr": 1206.23, "it": 48.58, "rp": 14764.19}, {"p": 13, "date": "2026-12-19", "pay": 1254.81, "pr": 1209.9, "it": 44.91, "rp": 13554.29}, {"p": 14, "date": "2027-01-19", "pay": 1254.81, "pr": 1213.58, "it": 41.23, "rp": 12340.71}, {"p": 15, "date": "2027-02-19", "pay": 1254.81, "pr": 1217.27, "it": 37.54, "rp": 11123.44}, {"p": 16, "date": "2027-03-19", "pay": 1254.81, "pr": 1220.98, "it": 33.83, "rp": 9902.46}, {"p": 17, "date": "2027-04-19", "pay": 1254.81, "pr": 1224.69, "it": 30.12, "rp": 8677.77}, {"p": 18, "date": "2027-05-19", "pay": 1254.81, "pr": 1228.42, "it": 26.39, "rp": 7449.35}, {"p": 19, "date": "2027-06-19", "pay": 1254.81, "pr": 1232.15, "it": 22.66, "rp": 6217.2}, {"p": 20, "date": "2027-07-19", "pay": 1254.81, "pr": 1235.9, "it": 18.91, "rp": 4981.3}, {"p": 21, "date": "2027-08-19", "pay": 1254.81, "pr": 1239.66, "it": 15.15, "rp": 3741.65}, {"p": 22, "date": "2027-09-19", "pay": 1254.81, "pr": 1243.43, "it": 11.38, "rp": 2498.22}, {"p": 23, "date": "2027-10-19", "pay": 1254.81, "pr": 1247.21, "it": 7.6, "rp": 1251}, {"p": 24, "date": "2027-11-19", "pay": 1254.81, "pr": 1251, "it": 3.81, "rp": 0}] [1, 2, 3, 4, 5, 6, 7, 12]
|
||||
51 5 借呗 1000000 2026-01-10 00:00:00 10.4 annual equal_installment 24 month active 2026-06-24 14:59:49.53963 [{"p": 1, "date": "2026-02-10", "pay": 463.3, "pr": 376.63, "it": 86.67, "rp": 9623.37}, {"p": 2, "date": "2026-03-10", "pay": 463.3, "pr": 379.9, "it": 83.4, "rp": 9243.47}, {"p": 3, "date": "2026-04-10", "pay": 463.3, "pr": 383.19, "it": 80.11, "rp": 8860.29}, {"p": 4, "date": "2026-05-10", "pay": 463.3, "pr": 386.51, "it": 76.79, "rp": 8473.78}, {"p": 5, "date": "2026-06-10", "pay": 463.3, "pr": 389.86, "it": 73.44, "rp": 8083.92}, {"p": 6, "date": "2026-07-10", "pay": 463.3, "pr": 393.24, "it": 70.06, "rp": 7690.68}, {"p": 7, "date": "2026-08-10", "pay": 463.3, "pr": 396.65, "it": 66.65, "rp": 7294.04}, {"p": 8, "date": "2026-09-10", "pay": 463.3, "pr": 400.08, "it": 63.21, "rp": 6893.95}, {"p": 9, "date": "2026-10-10", "pay": 463.3, "pr": 403.55, "it": 59.75, "rp": 6490.4}, {"p": 10, "date": "2026-11-10", "pay": 463.3, "pr": 407.05, "it": 56.25, "rp": 6083.36}, {"p": 11, "date": "2026-12-10", "pay": 463.3, "pr": 410.58, "it": 52.72, "rp": 5672.78}, {"p": 12, "date": "2027-01-10", "pay": 463.3, "pr": 414.13, "it": 49.16, "rp": 5258.65}, {"p": 13, "date": "2027-02-10", "pay": 463.3, "pr": 417.72, "it": 45.57, "rp": 4840.93}, {"p": 14, "date": "2027-03-10", "pay": 463.3, "pr": 421.34, "it": 41.95, "rp": 4419.58}, {"p": 15, "date": "2027-04-10", "pay": 463.3, "pr": 424.99, "it": 38.3, "rp": 3994.59}, {"p": 16, "date": "2027-05-10", "pay": 463.3, "pr": 428.68, "it": 34.62, "rp": 3565.91}, {"p": 17, "date": "2027-06-10", "pay": 463.3, "pr": 432.39, "it": 30.9, "rp": 3133.52}, {"p": 18, "date": "2027-07-10", "pay": 463.3, "pr": 436.14, "it": 27.16, "rp": 2697.38}, {"p": 19, "date": "2027-08-10", "pay": 463.3, "pr": 439.92, "it": 23.38, "rp": 2257.46}, {"p": 20, "date": "2027-09-10", "pay": 463.3, "pr": 443.73, "it": 19.56, "rp": 1813.72}, {"p": 21, "date": "2027-10-10", "pay": 463.3, "pr": 447.58, "it": 15.72, "rp": 1366.14}, {"p": 22, "date": "2027-11-10", "pay": 463.3, "pr": 451.46, "it": 11.84, "rp": 914.69}, {"p": 23, "date": "2027-12-10", "pay": 463.3, "pr": 455.37, "it": 7.93, "rp": 459.32}, {"p": 24, "date": "2028-01-10", "pay": 463.3, "pr": 459.32, "it": 3.98, "rp": 0}] [1, 2, 3, 4, 5, 10]
|
||||
52 5 借呗 1000000 2026-02-18 00:00:00 10.8 annual lump_sum 12 month active 2026-06-24 14:59:49.549909 [{"p": 1, "date": "2026-03-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 2, "date": "2026-04-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 3, "date": "2026-05-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 4, "date": "2026-06-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 5, "date": "2026-07-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 6, "date": "2026-08-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 7, "date": "2026-09-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 8, "date": "2026-10-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 9, "date": "2026-11-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 10, "date": "2026-12-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 11, "date": "2027-01-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 12, "date": "2027-02-18", "pay": 10090, "pr": 10000, "it": 90, "rp": 0}] [1, 2, 3, 4, 9]
|
||||
45 5 招商 2100000 2026-06-23 00:00:00 6.57 annual equal_installment 24 month active 2026-06-24 14:59:49.41078 [{"p": 1, "date": "2026-07-23", "pay": 936.14, "pr": 821.16, "it": 114.98, "rp": 20178.84}, {"p": 2, "date": "2026-08-23", "pay": 936.14, "pr": 825.66, "it": 110.48, "rp": 19353.18}, {"p": 3, "date": "2026-09-23", "pay": 936.14, "pr": 830.18, "it": 105.96, "rp": 18523.01}, {"p": 4, "date": "2026-10-23", "pay": 936.14, "pr": 834.72, "it": 101.41, "rp": 17688.28}, {"p": 5, "date": "2026-11-23", "pay": 936.14, "pr": 839.29, "it": 96.84, "rp": 16848.99}, {"p": 6, "date": "2026-12-23", "pay": 936.14, "pr": 843.89, "it": 92.25, "rp": 16005.1}, {"p": 7, "date": "2027-01-23", "pay": 936.14, "pr": 848.51, "it": 87.63, "rp": 15156.6}, {"p": 8, "date": "2027-02-23", "pay": 936.14, "pr": 853.15, "it": 82.98, "rp": 14303.44}, {"p": 9, "date": "2027-03-23", "pay": 936.14, "pr": 857.82, "it": 78.31, "rp": 13445.62}, {"p": 10, "date": "2027-04-23", "pay": 936.14, "pr": 862.52, "it": 73.61, "rp": 12583.1}, {"p": 11, "date": "2027-05-23", "pay": 936.14, "pr": 867.24, "it": 68.89, "rp": 11715.85}, {"p": 12, "date": "2027-06-23", "pay": 936.14, "pr": 871.99, "it": 64.14, "rp": 10843.86}, {"p": 13, "date": "2027-07-23", "pay": 936.14, "pr": 876.77, "it": 59.37, "rp": 9967.1}, {"p": 14, "date": "2027-08-23", "pay": 936.14, "pr": 881.57, "it": 54.57, "rp": 9085.53}, {"p": 15, "date": "2027-09-23", "pay": 936.14, "pr": 886.39, "it": 49.74, "rp": 8199.14}, {"p": 16, "date": "2027-10-23", "pay": 936.14, "pr": 891.25, "it": 44.89, "rp": 7307.89}, {"p": 17, "date": "2027-11-23", "pay": 936.14, "pr": 896.13, "it": 40.01, "rp": 6411.77}, {"p": 18, "date": "2027-12-23", "pay": 936.14, "pr": 901.03, "it": 35.1, "rp": 5510.73}, {"p": 19, "date": "2028-01-23", "pay": 936.14, "pr": 905.96, "it": 30.17, "rp": 4604.77}, {"p": 20, "date": "2028-02-23", "pay": 936.14, "pr": 910.92, "it": 25.21, "rp": 3693.85}, {"p": 21, "date": "2028-03-23", "pay": 936.14, "pr": 915.91, "it": 20.22, "rp": 2777.93}, {"p": 22, "date": "2028-04-23", "pay": 936.14, "pr": 920.93, "it": 15.21, "rp": 1857.01}, {"p": 23, "date": "2028-05-23", "pay": 936.14, "pr": 925.97, "it": 10.17, "rp": 931.04}, {"p": 24, "date": "2028-06-23", "pay": 936.14, "pr": 931.04, "it": 5.1, "rp": 0}] []
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: groups; Type: TABLE DATA; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
COPY public.groups (id, name, description, created_at) FROM stdin;
|
||||
1 A组 测试 2026-06-24 11:03:50.691376
|
||||
2 nnwqkj 2026-06-24 11:10:49.680329
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: repayment_plans; Type: TABLE DATA; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
COPY public.repayment_plans (id, debt_id, due_date, principal_due, interest_due, total_due, status) FROM stdin;
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: repayment_records; Type: TABLE DATA; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
COPY public.repayment_records (id, debt_id, plan_id, amount, paid_date, notes, created_at) FROM stdin;
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: system_settings; Type: TABLE DATA; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
COPY public.system_settings (key, value) FROM stdin;
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
COPY public.users (id, email, password_hash, nickname, role, status, group_id, created_at) FROM stdin;
|
||||
5 mo $2b$12$xGBqDkS0UXRgCwl37Pf/ius6JFZAZ2uqJ/3CcYuI9HS2cIBIPCa9O 莫 user active 2 2026-06-24 14:58:20.107655
|
||||
4 w $2b$12$08TQRMaHOZxatKL4wxPTG./Hpdr6i9Kr63we0tq0eJJvBzV5Mfczq 国玮 user active 2 2026-06-24 13:07:27.778884
|
||||
2 test $2b$12$RHko0eF2q8kGB.aM.I82VuBh5IJ3ZafHyigFIHxR73r/FIixp3Gwi 测试用户新昵称 user active \N 2026-06-24 11:03:36.561415
|
||||
1 admin $2b$12$j/.oSg76c3GNiALEgOAWqehT/vh1dektBmQ7gPMFlEOtdQF7sCrC2 admin admin active \N 2026-06-24 11:03:36.561411
|
||||
\.
|
||||
|
||||
|
||||
--
|
||||
-- Name: audit_logs_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.audit_logs_id_seq', 1, false);
|
||||
|
||||
|
||||
--
|
||||
-- Name: debts_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.debts_id_seq', 61, true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: groups_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.groups_id_seq', 2, true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_plans_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.repayment_plans_id_seq', 1, false);
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_records_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.repayment_records_id_seq', 1, false);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.users_id_seq', 6, true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: audit_logs audit_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.audit_logs
|
||||
ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: debts debts_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.debts
|
||||
ADD CONSTRAINT debts_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: groups groups_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.groups
|
||||
ADD CONSTRAINT groups_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_plans repayment_plans_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.repayment_plans
|
||||
ADD CONSTRAINT repayment_plans_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_records repayment_records_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.repayment_records
|
||||
ADD CONSTRAINT repayment_records_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: system_settings system_settings_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.system_settings
|
||||
ADD CONSTRAINT system_settings_pkey PRIMARY KEY (key);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ix_audit_logs_id; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE INDEX ix_audit_logs_id ON public.audit_logs USING btree (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ix_debts_id; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE INDEX ix_debts_id ON public.debts USING btree (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ix_groups_id; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE INDEX ix_groups_id ON public.groups USING btree (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ix_repayment_plans_id; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE INDEX ix_repayment_plans_id ON public.repayment_plans USING btree (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ix_repayment_records_id; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE INDEX ix_repayment_records_id ON public.repayment_records USING btree (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ix_users_email; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX ix_users_email ON public.users USING btree (email);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ix_users_id; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE INDEX ix_users_id ON public.users USING btree (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: audit_logs audit_logs_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.audit_logs
|
||||
ADD CONSTRAINT audit_logs_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: debts debts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.debts
|
||||
ADD CONSTRAINT debts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_plans repayment_plans_debt_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.repayment_plans
|
||||
ADD CONSTRAINT repayment_plans_debt_id_fkey FOREIGN KEY (debt_id) REFERENCES public.debts(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_records repayment_records_debt_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.repayment_records
|
||||
ADD CONSTRAINT repayment_records_debt_id_fkey FOREIGN KEY (debt_id) REFERENCES public.debts(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: repayment_records repayment_records_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.repayment_records
|
||||
ADD CONSTRAINT repayment_records_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.repayment_plans(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_group_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_group_id_fkey FOREIGN KEY (group_id) REFERENCES public.groups(id);
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict RdcQlBPEqYuP88EyiWHtXP8VF7CTE1R3DOjX7UMme8jFrcKXvNbaZeFWWNkzt7J
|
||||
|
||||
40
deploy/docker-compose.yml
Normal file
40
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: debt_manager
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "54326:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
image: debt-manager-backend:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/debt_manager
|
||||
SECRET_KEY: change-this-in-production-please
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: debt-manager-frontend:latest
|
||||
ports:
|
||||
- "806:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
39
deploy/install.sh
Executable file
39
deploy/install.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo " 债务管理系统 - 一键安装"
|
||||
echo "========================================="
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo ""
|
||||
echo "[1/3] 加载 Docker 镜像..."
|
||||
docker load -i "$SCRIPT_DIR/debt-manager-backend.tar.gz"
|
||||
docker load -i "$SCRIPT_DIR/debt-manager-frontend.tar.gz"
|
||||
docker load -i "$SCRIPT_DIR/postgres-15-alpine.tar.gz"
|
||||
|
||||
echo ""
|
||||
echo "[2/3] 启动服务..."
|
||||
cd "$SCRIPT_DIR"
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo "[3/3] 等待服务就绪..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " 安装完成!"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo " 前台地址: http://localhost:806"
|
||||
echo " 后台地址: http://localhost:806/admin.html"
|
||||
echo " API 文档: http://localhost:8000/docs"
|
||||
echo ""
|
||||
echo " 默认账号: admin / admin123"
|
||||
echo " 默认账号: test / test123"
|
||||
echo ""
|
||||
echo " 停止服务: docker compose -f $SCRIPT_DIR/docker-compose.yml down"
|
||||
echo " 查看日志: docker compose -f $SCRIPT_DIR/docker-compose.yml logs -f"
|
||||
echo "========================================="
|
||||
22
deploy/uninstall.sh
Executable file
22
deploy/uninstall.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo " 债务管理系统 - 卸载"
|
||||
echo "========================================="
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo ""
|
||||
echo "[1/2] 停止并删除容器..."
|
||||
cd "$SCRIPT_DIR"
|
||||
docker compose down -v
|
||||
|
||||
echo ""
|
||||
echo "[2/2] 删除 Docker 镜像..."
|
||||
docker rmi debt-manager-backend:latest debt-manager-frontend:latest postgres:15-alpine 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " 卸载完成"
|
||||
echo "========================================="
|
||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: debt_manager
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "54326:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
image: debt-manager-backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/debt_manager
|
||||
SECRET_KEY: change-this-in-production-please
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
image: debt-manager-frontend
|
||||
ports:
|
||||
- "806:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
3
frontend/Dockerfile
Normal file
3
frontend/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM nginx:alpine
|
||||
COPY index.html admin.html reset-password.html /usr/share/nginx/html/
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
520
frontend/admin.html
Normal file
520
frontend/admin.html
Normal file
@@ -0,0 +1,520 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<title>后台管理</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;background:#eef2f7;color:#1a2332;min-height:100vh;font-size:14px}
|
||||
.header{background:linear-gradient(135deg,#1e3a5f 0%,#2563eb 100%);padding:14px 0;color:#fff}
|
||||
.header-inner{max-width:1200px;margin:0 auto;padding:0 24px;display:flex;align-items:center;justify-content:space-between}
|
||||
.header h1{font-size:18px;font-weight:600}
|
||||
.header-right{display:flex;align-items:center;gap:12px}
|
||||
.header-right a{color:rgba(255,255,255,.8);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.3);border-radius:4px}
|
||||
.header-right a:hover{background:rgba(255,255,255,.15);color:#fff}
|
||||
.container{max-width:1200px;margin:0 auto;padding:20px 24px}
|
||||
.stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
|
||||
.stat-card{background:#fff;border-radius:6px;padding:16px;text-align:center;border:1px solid #e2e8f0}
|
||||
.stat-card .lbl{font-size:12px;color:#64748b;margin-bottom:4px}
|
||||
.stat-card .val{font-size:22px;font-weight:700}
|
||||
.stat-card .val.blue{color:#2563eb}.stat-card .val.green{color:#16a34a}.stat-card .val.orange{color:#ea580c}.stat-card .val.red{color:#dc2626}
|
||||
.card{background:#fff;border-radius:6px;border:1px solid #d0d7e2;overflow:hidden;margin-bottom:16px}
|
||||
.card-header{padding:12px 20px;border-bottom:1px solid #e5eaf0;display:flex;justify-content:space-between;align-items:center}
|
||||
.card-header h3{font-size:15px;color:#1e3a5f}
|
||||
.card-body{padding:0}
|
||||
.search-bar{display:flex;gap:8px;align-items:center}
|
||||
.search-bar input,.search-bar select{padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none}
|
||||
.search-bar input:focus{border-color:#2563eb}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{background:#f8fafc;padding:10px 16px;text-align:left;font-weight:600;color:#64748b;border-bottom:1px solid #e5eaf0}
|
||||
td{padding:10px 16px;border-bottom:1px solid #f0f3f7}
|
||||
tr:hover{background:#f8fafc}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600}
|
||||
.badge-pending{background:#fef3c7;color:#b45309}
|
||||
.badge-active{background:#dcfce7;color:#16a34a}
|
||||
.badge-disabled{background:#fee2e2;color:#dc2626}
|
||||
.badge-admin{background:#ede9fe;color:#7c3aed}
|
||||
.badge-group{background:#dbeafe;color:#1d4ed8}
|
||||
.btn-sm{padding:4px 10px;border:none;border-radius:4px;font-size:12px;cursor:pointer;font-weight:500}
|
||||
.btn-approve{background:#dcfce7;color:#16a34a}.btn-approve:hover{background:#bbf7d0}
|
||||
.btn-enable{background:#dbeafe;color:#2563eb}.btn-enable:hover{background:#bfdbfe}
|
||||
.btn-disable{background:#fee2e2;color:#dc2626}.btn-disable:hover{background:#fecaca}
|
||||
.btn-restore{background:#f1f5f9;color:#64748b}.btn-restore:hover{background:#e2e8f0}
|
||||
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8}
|
||||
.btn-danger{background:#fee2e2;color:#dc2626}.btn-danger:hover{background:#fecaca}
|
||||
.empty{text-align:center;padding:40px;color:#94a3b8}
|
||||
.form-inline{display:flex;gap:8px;align-items:center}
|
||||
.form-inline input{padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none}
|
||||
.modal-mask{display:none;position:fixed;inset:0;background:rgba(15,23,42,.4);z-index:100;align-items:center;justify-content:center}
|
||||
.modal-mask.show{display:flex}
|
||||
.modal{background:#fff;border-radius:6px;padding:20px;max-width:400px;width:90%}
|
||||
.modal h3{margin-bottom:12px;font-size:15px;color:#1e3a5f}
|
||||
.modal .form-row{margin-bottom:10px}
|
||||
.modal .form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:3px}
|
||||
.modal .form-row input,.modal .form-row select{width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none}
|
||||
.modal .btn-row{display:flex;gap:8px;margin-top:14px;justify-content:flex-end}
|
||||
@media(max-width:768px){.stats-row{grid-template-columns:repeat(2,1fr)}}
|
||||
|
||||
/* NOTIFICATION CENTER */
|
||||
.notif-bell{position:relative;cursor:pointer;padding:6px 10px;border-radius:4px;transition:all .2s}
|
||||
.notif-bell:hover{background:rgba(255,255,255,.15)}
|
||||
.notif-bell svg{width:18px;height:18px;fill:rgba(255,255,255,.8);transition:fill .2s}
|
||||
.notif-bell:hover svg{fill:#fff}
|
||||
.notif-badge{position:absolute;top:4px;right:4px;min-width:16px;height:16px;background:#dc2626;color:#fff;border-radius:8px;font-size:10px;font-weight:600;display:flex;align-items:center;justify-content:center;padding:0 4px;border:2px solid #2563eb;animation:badgePop .3s ease-out}
|
||||
@keyframes badgePop{from{transform:scale(0)}to{transform:scale(1)}}
|
||||
|
||||
.notif-dropdown{display:none;position:absolute;right:0;top:calc(100% + 8px);background:#fff;border:1px solid #e2e8f0;border-radius:6px;box-shadow:0 12px 48px rgba(0,0,0,.15);width:360px;max-height:480px;overflow:hidden;z-index:60}
|
||||
.notif-dropdown.show{display:block}
|
||||
.notif-header{padding:14px 16px;border-bottom:1px solid #e5eaf0;display:flex;justify-content:space-between;align-items:center}
|
||||
.notif-header h4{font-size:14px;font-weight:600;color:#1e3a5f}
|
||||
.notif-header .mark-all{font-size:12px;color:#2563eb;cursor:pointer;background:none;border:none;font-family:inherit}
|
||||
.notif-header .mark-all:hover{text-decoration:underline}
|
||||
.notif-list{overflow-y:auto;max-height:380px}
|
||||
.notif-item{padding:12px 16px;border-bottom:1px solid #f0f3f7;cursor:pointer;transition:background .15s;display:flex;gap:12px;align-items:flex-start}
|
||||
.notif-item:hover{background:#f8fafc}
|
||||
.notif-item.unread{background:rgba(37,99,235,.03)}
|
||||
.notif-item .notif-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
|
||||
.notif-icon.repayment_due{background:rgba(37,99,235,.08)}
|
||||
.notif-icon.repayment_overdue{background:rgba(220,38,38,.08)}
|
||||
.notif-icon.system{background:rgba(124,58,237,.08)}
|
||||
.notif-icon.account{background:rgba(22,163,74,.08)}
|
||||
.notif-item .notif-content{flex:1;min-width:0}
|
||||
.notif-item .notif-title{font-size:13px;font-weight:500;color:#1a2332;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.notif-item .notif-msg{font-size:12px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.notif-item .notif-time{font-size:11px;color:#94a3b8;white-space:nowrap}
|
||||
.notif-empty{text-align:center;padding:32px;color:#94a3b8;font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="header-inner">
|
||||
<h1>🔧 后台管理</h1>
|
||||
<div class="header-right">
|
||||
<div style="position:relative">
|
||||
<div class="notif-bell" onclick="toggleNotifDropdown(event)">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>
|
||||
<span class="notif-badge" id="notifBadge" style="display:none">0</span>
|
||||
</div>
|
||||
<div class="notif-dropdown" id="notifDropdown">
|
||||
<div class="notif-header">
|
||||
<h4>通知</h4>
|
||||
<button class="mark-all" onclick="markAllNotifRead()">全部已读</button>
|
||||
</div>
|
||||
<div class="notif-list" id="notifList">
|
||||
<div class="notif-empty">暂无通知</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/">返回前台</a>
|
||||
<a href="#" onclick="adminLogout()">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="stats-row" id="statsRow"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>用户组管理</h3>
|
||||
<button class="btn-sm btn-primary" onclick="showCreateGroup()">新增组</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table>
|
||||
<thead><tr><th>组名</th><th>描述</th><th>成员数</th><th>操作</th></tr></thead>
|
||||
<tbody id="groupTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>用户管理</h3>
|
||||
<div class="search-bar">
|
||||
<button class="btn-sm btn-primary" onclick="showCreateUser()">新增用户</button>
|
||||
<input type="text" id="searchInput" placeholder="搜索账号/昵称" onkeyup="if(event.key==='Enter')loadUsers()">
|
||||
<select id="statusFilter" onchange="loadUsers()">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待审批</option>
|
||||
<option value="active">已激活</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
</select>
|
||||
<button class="btn-sm btn-enable" onclick="loadUsers()">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table>
|
||||
<thead><tr><th>账号</th><th>昵称</th><th>邮箱</th><th>角色</th><th>组</th><th>债务</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody id="userTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>站点配置</h3>
|
||||
<button class="btn-sm btn-primary" onclick="saveSiteUrl()">保存</button>
|
||||
</div>
|
||||
<div class="card-body" style="padding:16px 20px">
|
||||
<div style="display:grid;grid-template-columns:1fr;gap:12px">
|
||||
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">站点地址(用于邮件中的链接)</label><input type="text" id="siteUrl" placeholder="http://your-domain:806" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
|
||||
</div>
|
||||
<div id="siteUrlMsg" style="margin-top:10px;font-size:12px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>邮件服务配置</h3>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-sm btn-enable" onclick="showEmailTestModal()">邮件测试</button>
|
||||
<button class="btn-sm btn-primary" onclick="saveEmailConfig()">保存配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" style="padding:16px 20px">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
|
||||
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">SMTP 服务器</label><input type="text" id="smtpHost" placeholder="smtp.example.com" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
|
||||
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">端口</label><input type="number" id="smtpPort" value="465" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
|
||||
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">用户名</label><input type="text" id="smtpUser" placeholder="发件账号" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
|
||||
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">密码/授权码</label><input type="password" id="smtpPassword" placeholder="SMTP 密码" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
|
||||
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">发件人地址</label><input type="text" id="smtpFrom" placeholder="留空则使用用户名" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
|
||||
<div style="display:flex;align-items:end"><label style="font-size:13px;cursor:pointer"><input type="checkbox" id="smtpTls" checked style="margin-right:6px">启用 TLS/SSL</label></div>
|
||||
</div>
|
||||
<div id="emailConfigMsg" style="margin-top:10px;font-size:12px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="groupModal">
|
||||
<div class="modal">
|
||||
<h3>新增组</h3>
|
||||
<div class="form-row"><label>组名</label><input type="text" id="groupName" placeholder="输入组名"></div>
|
||||
<div class="form-row"><label>描述</label><input type="text" id="groupDesc" placeholder="可选"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-sm btn-restore" onclick="closeGroupModal()">取消</button>
|
||||
<button class="btn-sm btn-primary" onclick="createGroup()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="memberModal">
|
||||
<div class="modal">
|
||||
<h3>管理组成员 - <span id="memberGroupName"></span></h3>
|
||||
<div class="form-row">
|
||||
<label>添加成员</label>
|
||||
<select id="memberSelect"></select>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-sm btn-primary" onclick="addMember()">添加</button>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<label style="font-size:12px;color:#64748b">当前成员:</label>
|
||||
<div id="memberList" style="margin-top:6px"></div>
|
||||
</div>
|
||||
<div class="btn-row" style="margin-top:14px">
|
||||
<button class="btn-sm btn-restore" onclick="closeMemberModal()">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="createUserModal">
|
||||
<div class="modal">
|
||||
<h3>新增用户</h3>
|
||||
<div class="form-row"><label>账号</label><input type="text" id="cuAccount" placeholder="登录账号"></div>
|
||||
<div class="form-row"><label>密码</label><input type="password" id="cuPassword" placeholder="登录密码"></div>
|
||||
<div class="form-row"><label>昵称</label><input type="text" id="cuNickname" placeholder="显示名称"></div>
|
||||
<div class="form-row"><label>邮箱(可选)</label><input type="email" id="cuEmail" placeholder="用于接收通知"></div>
|
||||
<div class="form-row"><label>角色</label><select id="cuRole"><option value="user">用户</option><option value="admin">管理员</option></select></div>
|
||||
<div id="cuError" style="color:#dc2626;font-size:12px;margin-top:8px;display:none"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-sm btn-restore" onclick="closeCreateUser()">取消</button>
|
||||
<button class="btn-sm btn-primary" onclick="doCreateUser()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="editUserModal">
|
||||
<div class="modal">
|
||||
<h3>编辑用户</h3>
|
||||
<div class="form-row"><label>账号</label><input type="text" id="euAccount"></div>
|
||||
<div class="form-row"><label>昵称</label><input type="text" id="euNickname"></div>
|
||||
<div class="form-row"><label>邮箱</label><input type="email" id="euEmail"></div>
|
||||
<div class="form-row"><label>角色</label><select id="euRole"><option value="user">用户</option><option value="admin">管理员</option></select></div>
|
||||
<div id="euError" style="color:#dc2626;font-size:12px;margin-top:8px;display:none"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-sm btn-restore" onclick="closeEditUser()">取消</button>
|
||||
<button class="btn-sm btn-primary" onclick="doEditUser()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="emailTestModal">
|
||||
<div class="modal">
|
||||
<h3>邮件测试</h3>
|
||||
<div class="form-row"><label>接收邮箱</label><input type="email" id="emailTestTo" placeholder="输入测试接收邮箱"></div>
|
||||
<div id="emailTestMsg" style="font-size:12px;margin-top:8px;display:none"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-sm btn-restore" onclick="closeEmailTestModal()">取消</button>
|
||||
<button class="btn-sm btn-primary" id="emailTestBtn" onclick="doEmailTest()">发送测试</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API='/api/v1';
|
||||
let token=localStorage.getItem('access_token')||'';
|
||||
let refreshToken=localStorage.getItem('refresh_token')||'';
|
||||
let allGroups=[];
|
||||
let allUsers=[];
|
||||
let currentGroupId=null;
|
||||
|
||||
const $=id=>document.getElementById(id);
|
||||
|
||||
async function apiFetch(path,opts={}){
|
||||
const headers={'Content-Type':'application/json',...opts.headers};
|
||||
if(token)headers['Authorization']='Bearer '+token;
|
||||
let res=await fetch(API+path,{...opts,headers});
|
||||
if(res.status===401&&refreshToken){
|
||||
const r=await fetch(API+'/auth/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refresh_token:refreshToken})});
|
||||
if(r.ok){const d=await r.json();token=d.access_token;refreshToken=d.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);headers['Authorization']='Bearer '+token;res=await fetch(API+path,{...opts,headers});}
|
||||
else{adminLogout();return null;}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function init(){
|
||||
if(!token){window.location.href='/';return;}
|
||||
const res=await apiFetch('/auth/profile');
|
||||
if(!res||!res.ok){window.location.href='/';return;}
|
||||
const user=await res.json();
|
||||
if(user.role!=='admin'){window.location.href='/';return;}
|
||||
loadStats();loadGroups();loadUsers();loadEmailConfig();loadSiteUrl();
|
||||
}
|
||||
|
||||
async function loadStats(){
|
||||
const res=await apiFetch('/admin/dashboard');
|
||||
if(!res||!res.ok)return;
|
||||
const d=await res.json();
|
||||
const sd=d.status_distribution||{};
|
||||
$('statsRow').innerHTML=`
|
||||
<div class="stat-card"><div class="lbl">总用户</div><div class="val blue">${d.total_users}</div></div>
|
||||
<div class="stat-card"><div class="lbl">待审批</div><div class="val orange">${sd.pending||0}</div></div>
|
||||
<div class="stat-card"><div class="lbl">已激活</div><div class="val green">${sd.active||0}</div></div>
|
||||
<div class="stat-card"><div class="lbl">已禁用</div><div class="val red">${sd.disabled||0}</div></div>`;
|
||||
}
|
||||
|
||||
async function loadGroups(){
|
||||
const res=await apiFetch('/admin/groups');
|
||||
if(!res||!res.ok)return;
|
||||
allGroups=await res.json();
|
||||
const tb=$('groupTable');
|
||||
if(!allGroups.length){tb.innerHTML='<tr><td colspan="4" class="empty">暂无组</td></tr>';return;}
|
||||
tb.innerHTML=allGroups.map(g=>`<tr>
|
||||
<td><strong>${g.name}</strong></td>
|
||||
<td>${g.description||'-'}</td>
|
||||
<td>${g.member_count}人</td>
|
||||
<td>
|
||||
<button class="btn-sm btn-enable" onclick="showMembers(${g.id},'${g.name}')">管理成员</button>
|
||||
<button class="btn-sm btn-danger" onclick="deleteGroup(${g.id})">删除</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
async function loadUsers(){
|
||||
const search=$('searchInput').value;
|
||||
const status=$('statusFilter').value;
|
||||
const params=new URLSearchParams();
|
||||
if(search)params.set('search',search);
|
||||
if(status)params.set('status_filter',status);
|
||||
const res=await apiFetch('/admin/users?'+params);
|
||||
if(!res||!res.ok)return;
|
||||
allUsers=await res.json();
|
||||
const tb=$('userTable');
|
||||
if(!allUsers.length){tb.innerHTML='<tr><td colspan="7" class="empty">暂无用户</td></tr>';return;}
|
||||
tb.innerHTML=allUsers.map(u=>{
|
||||
const roleBadge=u.role==='admin'?'<span class="badge badge-admin">管理员</span>':'用户';
|
||||
const statusBadge=u.status==='pending'?'<span class="badge badge-pending">待审批</span>':u.status==='active'?'<span class="badge badge-active">已激活</span>':'<span class="badge badge-disabled">已禁用</span>';
|
||||
const groupBadge=u.group_name?`<span class="badge badge-group">${u.group_name}</span>`:'<span style="color:#94a3b8">未分组</span>';
|
||||
const emailDisplay=u.real_email||'<span style="color:#94a3b8">未绑定</span>';
|
||||
let actions='';
|
||||
if(u.role!=='admin'){
|
||||
if(u.status==='pending')actions=`<button class="btn-sm btn-approve" onclick="approveUser(${u.id})">通过</button>`;
|
||||
if(u.status==='active')actions=`<button class="btn-sm btn-disable" onclick="setStatus(${u.id},'disabled')">禁用</button>`;
|
||||
if(u.status==='disabled')actions+=` <button class="btn-sm btn-enable" onclick="setStatus(${u.id},'active')">启用</button> <button class="btn-sm btn-restore" onclick="setStatus(${u.id},'pending')">恢复待审</button>`;
|
||||
actions+=` <button class="btn-sm btn-enable" onclick="showEditUser(${u.id},'${u.account}','${u.nickname||''}','${u.real_email||''}','${u.role}')">编辑</button>`;
|
||||
actions+=` <button class="btn-sm btn-enable" onclick="sendResetLink(${u.id},'${u.account}')">改密</button>`;
|
||||
actions+=` <button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${u.account}')">删除</button>`;
|
||||
}
|
||||
return`<tr><td>${u.account}</td><td>${u.nickname}</td><td>${emailDisplay}</td><td>${roleBadge}</td><td>${groupBadge}</td><td>${u.debt_count}笔</td><td>${statusBadge}</td><td>${actions}</td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function approveUser(id){
|
||||
await apiFetch('/admin/users/'+id+'/status?status=active',{method:'PUT'});
|
||||
loadStats();loadUsers();
|
||||
}
|
||||
|
||||
async function setStatus(id,status){
|
||||
await apiFetch('/admin/users/'+id+'/status?status='+status,{method:'PUT'});
|
||||
loadStats();loadUsers();
|
||||
}
|
||||
|
||||
function showCreateUser(){$('cuAccount').value='';$('cuPassword').value='';$('cuNickname').value='';$('cuEmail').value='';$('cuRole').value='user';$('cuError').style.display='none';$('createUserModal').classList.add('show');}
|
||||
function closeCreateUser(){$('createUserModal').classList.remove('show');}
|
||||
async function doCreateUser(){
|
||||
const account=$('cuAccount').value.trim(),pwd=$('cuPassword').value,nick=$('cuNickname').value.trim(),realEmail=$('cuEmail').value.trim(),role=$('cuRole').value;
|
||||
if(!account||!pwd){$('cuError').textContent='账号和密码必填';$('cuError').style.display='';return;}
|
||||
$('cuError').style.display='none';
|
||||
const res=await apiFetch('/admin/users',{method:'POST',body:JSON.stringify({account,password:pwd,nickname:nick,real_email:realEmail,role})});
|
||||
if(res&&res.ok){closeCreateUser();loadStats();loadUsers();}
|
||||
else{const d=await res.json();$('cuError').textContent=d.detail||'创建失败';$('cuError').style.display='';}
|
||||
}
|
||||
|
||||
let editUserId=null;
|
||||
function showEditUser(id,account,nickname,realEmail,role){editUserId=id;$('euAccount').value=account;$('euNickname').value=nickname;$('euEmail').value=realEmail;$('euRole').value=role;$('euError').style.display='none';$('editUserModal').classList.add('show');}
|
||||
function closeEditUser(){$('editUserModal').classList.remove('show');editUserId=null;}
|
||||
async function doEditUser(){
|
||||
if(!editUserId)return;
|
||||
const account=$('euAccount').value.trim(),nick=$('euNickname').value.trim(),realEmail=$('euEmail').value.trim(),role=$('euRole').value;
|
||||
if(!account){$('euError').textContent='账号必填';$('euError').style.display='';return;}
|
||||
$('euError').style.display='none';
|
||||
const res=await apiFetch('/admin/users/'+editUserId,{method:'PUT',body:JSON.stringify({account,nickname:nick,real_email:realEmail,role})});
|
||||
if(res&&res.ok){closeEditUser();loadStats();loadUsers();}
|
||||
else{const d=await res.json();$('euError').textContent=d.detail||'保存失败';$('euError').style.display='';}
|
||||
}
|
||||
|
||||
async function deleteUser(id,email){
|
||||
if(!confirm(`确定删除用户 ${email}?此操作不可恢复!`))return;
|
||||
await apiFetch('/admin/users/'+id,{method:'DELETE'});
|
||||
loadStats();loadGroups();loadUsers();
|
||||
}
|
||||
|
||||
let pwdUserId=null;
|
||||
async function sendResetLink(uid,uname){
|
||||
if(!confirm(`确定向 ${uname} 发送密码重置链接?`))return;
|
||||
const res=await apiFetch('/admin/users/'+uid+'/password',{method:'PUT'});
|
||||
if(res&&res.ok){const d=await res.json();alert(d.message);}
|
||||
else{const d=await res.json().catch(()=>({}));alert(d.detail||'发送失败,请确认用户已绑定邮箱');}
|
||||
}
|
||||
|
||||
function showCreateGroup(){$('groupModal').classList.add('show');$('groupName').value='';$('groupDesc').value='';}
|
||||
function closeGroupModal(){$('groupModal').classList.remove('show');}
|
||||
async function createGroup(){
|
||||
const name=$('groupName').value.trim();if(!name)return;
|
||||
await apiFetch('/admin/groups',{method:'POST',body:JSON.stringify({name,description:$('groupDesc').value.trim()})});
|
||||
closeGroupModal();loadGroups();
|
||||
}
|
||||
async function deleteGroup(id){
|
||||
if(!confirm('确定删除该组?组内成员将被移出。'))return;
|
||||
await apiFetch('/admin/groups/'+id,{method:'DELETE'});
|
||||
loadGroups();loadUsers();
|
||||
}
|
||||
|
||||
async function showMembers(gid,gname){
|
||||
currentGroupId=gid;
|
||||
$('memberGroupName').textContent=gname;
|
||||
const members=allUsers.filter(u=>u.group_id===gid);
|
||||
$('memberList').innerHTML=members.length?members.map(u=>`<span style="display:inline-block;padding:3px 8px;margin:2px;background:#eef2f7;border-radius:4px;font-size:12px">${u.nickname||u.account} <a href="#" onclick="removeMember(${u.id});return false" style="color:#dc2626;margin-left:4px">×</a></span>`).join(''):'<span style="font-size:12px;color:#94a3b8">暂无成员</span>';
|
||||
const unassigned=allUsers.filter(u=>!u.group_id&&u.role!=='admin');
|
||||
$('memberSelect').innerHTML=unassigned.map(u=>`<option value="${u.id}">${u.nickname||u.account} (${u.account})</option>`).join('');
|
||||
$('memberModal').classList.add('show');
|
||||
}
|
||||
function closeMemberModal(){$('memberModal').classList.remove('show');currentGroupId=null;}
|
||||
async function addMember(){
|
||||
const uid=+$('memberSelect').value;if(!uid||!currentGroupId)return;
|
||||
await apiFetch('/admin/groups/'+currentGroupId+'/members',{method:'POST',body:JSON.stringify({user_id:uid})});
|
||||
loadGroups();loadUsers();showMembers(currentGroupId,$('memberGroupName').textContent);
|
||||
}
|
||||
async function removeMember(uid){
|
||||
if(!currentGroupId)return;
|
||||
await apiFetch('/admin/groups/'+currentGroupId+'/members/'+uid,{method:'DELETE'});
|
||||
loadGroups();loadUsers();showMembers(currentGroupId,$('memberGroupName').textContent);
|
||||
}
|
||||
|
||||
function adminLogout(){
|
||||
localStorage.removeItem('access_token');localStorage.removeItem('refresh_token');
|
||||
window.location.href='/';
|
||||
}
|
||||
|
||||
function showEmailTestModal(){$('emailTestTo').value='';$('emailTestMsg').style.display='none';$('emailTestModal').classList.add('show');}
|
||||
function closeEmailTestModal(){$('emailTestModal').classList.remove('show');}
|
||||
async function doEmailTest(){
|
||||
const to=$('emailTestTo').value.trim();
|
||||
if(!to||!to.includes('@'))return;
|
||||
const msg=$('emailTestMsg');
|
||||
const btn=$('emailTestBtn');
|
||||
btn.disabled=true;btn.textContent='发送中...';msg.style.display='none';
|
||||
const res=await apiFetch('/admin/email-test',{method:'POST',body:JSON.stringify({to_email:to})});
|
||||
if(res&&res.ok){
|
||||
const d=await res.json();
|
||||
msg.style.display='';msg.style.color='#16a34a';msg.textContent=d.message;
|
||||
}else{
|
||||
const d=await res.json().catch(()=>({}));
|
||||
msg.style.display='';msg.style.color='#dc2626';msg.textContent=d.detail||'发送失败,请检查 SMTP 配置';
|
||||
}
|
||||
btn.disabled=false;btn.textContent='发送测试';
|
||||
}
|
||||
|
||||
async function loadEmailConfig(){
|
||||
const res=await apiFetch('/admin/email-config');
|
||||
if(!res||!res.ok)return;
|
||||
const d=await res.json();
|
||||
$('smtpHost').value=d.smtp_host||'';
|
||||
$('smtpPort').value=d.smtp_port||465;
|
||||
$('smtpUser').value=d.smtp_user||'';
|
||||
$('smtpPassword').value=d.smtp_password||'';
|
||||
$('smtpFrom').value=d.smtp_from||'';
|
||||
$('smtpTls').checked=d.smtp_use_tls!==false;
|
||||
}
|
||||
async function saveEmailConfig(){
|
||||
const data={
|
||||
smtp_host:$('smtpHost').value.trim(),
|
||||
smtp_port:parseInt($('smtpPort').value)||465,
|
||||
smtp_user:$('smtpUser').value.trim(),
|
||||
smtp_password:$('smtpPassword').value,
|
||||
smtp_from:$('smtpFrom').value.trim(),
|
||||
smtp_use_tls:$('smtpTls').checked,
|
||||
};
|
||||
const res=await apiFetch('/admin/email-config',{method:'PUT',body:JSON.stringify(data)});
|
||||
const msg=$('emailConfigMsg');
|
||||
if(res&&res.ok){msg.style.display='';msg.style.color='#16a34a';msg.textContent='配置已保存';}
|
||||
else{msg.style.display='';msg.style.color='#dc2626';msg.textContent='保存失败';}
|
||||
setTimeout(()=>{msg.style.display='none';},3000);
|
||||
}
|
||||
|
||||
async function loadSiteUrl(){
|
||||
const res=await apiFetch('/admin/site-url');
|
||||
if(!res||!res.ok)return;
|
||||
const d=await res.json();
|
||||
$('siteUrl').value=d.site_url||'';
|
||||
}
|
||||
async function saveSiteUrl(){
|
||||
const url=$('siteUrl').value.trim();
|
||||
const res=await apiFetch('/admin/site-url',{method:'PUT',body:JSON.stringify({site_url:url})});
|
||||
const msg=$('siteUrlMsg');
|
||||
if(res&&res.ok){msg.style.display='';msg.style.color='#16a34a';msg.textContent='站点地址已保存';}
|
||||
else{msg.style.display='';msg.style.color='#dc2626';msg.textContent='保存失败';}
|
||||
setTimeout(()=>{msg.style.display='none';},3000);
|
||||
}
|
||||
|
||||
let notifPage=1;
|
||||
function notifTypeIcon(type){const map={repayment_due:"📅",repayment_overdue:"⚠️",system:"📢",account:"👤"};return map[type]||"📩";}
|
||||
function timeAgo(dt){const diff=(Date.now()-new Date(dt).getTime())/1000;if(diff<60)return "刚刚";if(diff<3600)return Math.floor(diff/60)+"分钟前";if(diff<86400)return Math.floor(diff/3600)+"小时前";return Math.floor(diff/86400)+"天前";}
|
||||
|
||||
function toggleNotifDropdown(e){e.stopPropagation();const dd=$("notifDropdown");dd.classList.toggle("show");if(dd.classList.contains("show"))loadNotifications();}
|
||||
document.addEventListener("click",function(){$("notifDropdown")?.classList.remove("show");});
|
||||
|
||||
async function loadNotifications(){const res=await apiFetch("/notifications?size=20");if(!res||!res.ok)return;const data=await res.json();renderNotifBadge(data.unread);renderNotifList(data.items);}
|
||||
function renderNotifBadge(unread){const badge=$("notifBadge");if(unread>0){badge.style.display="";badge.textContent=unread>99?"99+":unread;}else{badge.style.display="none";}}
|
||||
function renderNotifList(items){const el=$("notifList");if(!items.length){el.innerHTML="<div class="notif-empty">暂无通知</div>";return;}el.innerHTML=items.map(n=>`<div class="notif-item${n.is_read===0?" unread":""}" onclick="readNotif(${n.id})"><div class="notif-icon ${n.type}">${notifTypeIcon(n.type)}</div><div class="notif-content"><div class="notif-title">${n.title}</div><div class="notif-msg">${n.message}</div></div><div class="notif-time">${timeAgo(n.created_at)}</div></div>`).join("");}
|
||||
|
||||
async function readNotif(id){await apiFetch("/notifications/"+id+"/read",{method:"PUT"});loadNotifications();}
|
||||
async function markAllNotifRead(){await apiFetch("/notifications/read-all",{method:"PUT"});loadNotifications();}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
668
frontend/index.html
Normal file
668
frontend/index.html
Normal file
@@ -0,0 +1,668 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache">
|
||||
<title>债务统计</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--card: rgba(255,255,255,.8);
|
||||
--glass: rgba(255,255,255,.72);
|
||||
--border: rgba(0,0,0,.06);
|
||||
--text: #1d1d1f;
|
||||
--text2: #6e6e73;
|
||||
--text3: #aeaeb2;
|
||||
--blue: #0071e3;
|
||||
--green: #34c759;
|
||||
--red: #ff3b30;
|
||||
--orange: #ff9500;
|
||||
--purple: #af52de;
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--radius-xs: 8px;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text","PingFang SC",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:14px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
|
||||
|
||||
/* HEADER */
|
||||
.header{background:rgba(255,255,255,.72);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:40;padding:0}
|
||||
.header-inner{max-width:1120px;margin:0 auto;padding:0 24px;height:52px;display:flex;align-items:center;justify-content:space-between}
|
||||
.header h1{font-size:17px;font-weight:600;color:var(--text);letter-spacing:-.2px}
|
||||
.header-right{display:flex;align-items:center;gap:4px}
|
||||
.header-right a,.hdr-btn{color:var(--text2);text-decoration:none;font-size:12px;padding:6px 12px;border-radius:var(--radius-xs);transition:all .2s;background:transparent;border:none;cursor:pointer;font-family:inherit}
|
||||
.header-right a:hover,.hdr-btn:hover{background:rgba(0,0,0,.04);color:var(--text)}
|
||||
.header-right .admin-link{color:var(--blue)}
|
||||
.header-right .logout-link{color:var(--red)}
|
||||
.user-info{color:var(--text);font-size:13px;font-weight:500;cursor:pointer;padding:6px 12px;border-radius:var(--radius-xs);transition:all .2s}
|
||||
.user-info:hover{background:rgba(0,0,0,.04)}
|
||||
|
||||
/* MAIN */
|
||||
.main{max-width:1120px;margin:0 auto;padding:24px;display:grid;grid-template-columns:320px 1fr;gap:20px}
|
||||
@media(max-width:900px){.main{grid-template-columns:1fr}}
|
||||
|
||||
/* PANEL */
|
||||
.panel{background:var(--card);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-radius:var(--radius);border:1px solid var(--border);overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.panel-title{padding:16px 20px;border-bottom:1px solid var(--border);font-size:14px;font-weight:600;color:var(--text);display:flex;align-items:center;justify-content:space-between;background:linear-gradient(180deg,rgba(245,245,247,.8) 0%,rgba(255,255,255,0) 100%);position:relative}
|
||||
.panel-title::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:18px;background:var(--blue);border-radius:2px}
|
||||
.panel-title .title-text{display:flex;align-items:center;gap:8px;padding-left:8px}
|
||||
.panel-title .title-badge{font-size:10px;padding:2px 8px;border-radius:4px;background:rgba(0,113,227,.08);color:var(--blue);font-weight:500}
|
||||
.panel-body{padding:16px 20px}
|
||||
.panel+.panel{margin-top:16px}
|
||||
|
||||
/* FORM */
|
||||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px;padding:4px 0}
|
||||
.form-grid .form-row{margin-bottom:0;min-width:0}
|
||||
.form-row label{display:block;font-size:11px;color:var(--text2);margin-bottom:4px;font-weight:500}
|
||||
.form-row input,.form-row select{width:100%;min-width:0;padding:8px 12px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:13px;outline:none;transition:all .2s;background:#fff;color:var(--text);-webkit-appearance:none}
|
||||
.form-row input:focus,.form-row select:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
|
||||
.calc-hint{font-size:11px;color:var(--blue);margin-top:3px;min-height:16px}
|
||||
|
||||
/* BUTTONS */
|
||||
.btn-row{display:flex;gap:10px;margin-top:16px}
|
||||
.btn{flex:1;padding:10px 0;border:none;border-radius:var(--radius-xs);font-size:14px;font-weight:500;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||
.btn-main{background:var(--blue);color:#fff}
|
||||
.btn-main:hover{background:#0077ed}
|
||||
.btn-ghost{background:#fff;color:var(--text2);border:1px solid #d2d2d7}
|
||||
.btn-ghost:hover{background:#f5f5f7;border-color:#bbb}
|
||||
|
||||
/* LOAN LIST */
|
||||
.loan-list{overflow-y:auto}
|
||||
.loan-group{border-bottom:1px solid var(--border)}
|
||||
.loan-group:last-child{border-bottom:none}
|
||||
.loan-group-header{padding:12px 20px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;user-select:none;transition:background .2s}
|
||||
.loan-group-header:hover{background:rgba(0,0,0,.02)}
|
||||
.loan-group-header .arrow{font-size:10px;color:var(--text3);transition:transform .3s cubic-bezier(.4,0,.2,1);margin-right:8px;display:inline-block}
|
||||
.loan-group-header .arrow.open{transform:rotate(90deg)}
|
||||
.loan-group-header .gname{font-size:15px;font-weight:600;color:var(--text)}
|
||||
.loan-group-header .gsummary{font-size:12px;color:var(--text3)}
|
||||
.loan-group-body{display:none}
|
||||
.loan-group-body.open{display:block}
|
||||
.loan-item{padding:10px 20px 10px 36px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .15s;display:flex;justify-content:space-between;align-items:center}
|
||||
.loan-item:last-child{border-bottom:none}
|
||||
.loan-item:hover{background:rgba(0,0,0,.02)}
|
||||
.loan-item.active{background:rgba(0,113,227,.04);border-left:3px solid var(--blue)}
|
||||
.loan-item .iname{font-size:13px;font-weight:500;color:var(--text)}
|
||||
.loan-item .isub{font-size:11px;color:var(--text3);margin-top:2px}
|
||||
.loan-item .iamt{font-size:13px;font-weight:600;color:var(--text)}
|
||||
.loan-item.done-item{background:rgba(52,199,89,.04)}
|
||||
.loan-item.done-item .iname,.loan-item.done-item .isub{text-decoration:line-through;color:var(--text3)}
|
||||
.loan-item.done-item .iamt{text-decoration:line-through;color:var(--text3)}
|
||||
.loan-actions-popup{display:flex;gap:6px;padding:8px 20px;border-top:1px solid var(--border);background:rgba(245,245,247,.5)}
|
||||
.loan-actions-popup .act-btn{padding:5px 14px;border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;border:none;transition:all .2s;font-family:inherit}
|
||||
.act-btn-edit{background:rgba(0,113,227,.08);color:var(--blue)}
|
||||
.act-btn-edit:hover{background:rgba(0,113,227,.15)}
|
||||
.act-btn-pay{background:rgba(52,199,89,.08);color:var(--green)}
|
||||
.act-btn-pay:hover{background:rgba(52,199,89,.15)}
|
||||
.act-btn-del{background:rgba(255,59,48,.08);color:var(--red)}
|
||||
.act-btn-del:hover{background:rgba(255,59,48,.15)}
|
||||
.quick-panel{padding:8px 20px 12px;border-top:1px solid var(--border);background:rgba(245,245,247,.5)}
|
||||
.quick-panel .qp-title{font-size:11px;color:var(--text3);margin-bottom:6px;font-weight:500}
|
||||
.quick-panel .qp-row{display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:12px}
|
||||
.quick-panel .qp-row .qp-info{color:var(--text2)}
|
||||
.quick-panel .qp-row .qp-amt{color:var(--text);font-weight:500;font-variant-numeric:tabular-nums}
|
||||
.quick-panel .qp-row .qp-btn{padding:3px 10px;border:none;border-radius:6px;font-size:11px;cursor:pointer;font-weight:500;transition:all .2s;font-family:inherit}
|
||||
.qp-btn-done{background:#34c759!important;color:#fff!important}
|
||||
.qp-btn-pay{background:var(--blue);color:#fff}
|
||||
.qp-btn-pay:hover{background:#0077ed}
|
||||
.badge-done{display:inline-block;font-size:9px;padding:2px 6px;border-radius:4px;background:var(--green);color:#fff;font-weight:600;margin-left:6px}
|
||||
.empty-tip{padding:32px;text-align:center;color:var(--text3);font-size:13px}
|
||||
|
||||
/* STATS */
|
||||
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||||
.stat-block{margin-bottom:0}
|
||||
.stat-card{background:var(--card);border-radius:var(--radius-sm);padding:16px;text-align:center;border:1px solid var(--border);transition:all .2s;position:relative}
|
||||
.stat-card .lbl{font-size:11px;color:var(--text3);margin-bottom:4px;font-weight:500;letter-spacing:.3px}
|
||||
.stat-card .val{font-size:18px;font-weight:700;color:var(--text)}
|
||||
.stat-card .val.blue{color:var(--blue)}
|
||||
.stat-card .val.red{color:var(--red)}
|
||||
.stat-card .val.green{color:var(--green)}
|
||||
.stat-card.clickable{cursor:pointer}
|
||||
.stat-card.clickable:hover{box-shadow:0 2px 12px rgba(0,0,0,.06);transform:translateY(-1px)}
|
||||
.stat-arrow{position:absolute;top:8px;right:10px;font-size:8px;color:var(--text3);transition:transform .3s cubic-bezier(.4,0,.2,1)}
|
||||
.stat-arrow.open{transform:rotate(90deg)}
|
||||
.stats-detail-wrap{background:rgba(245,245,247,.5);border:1px solid var(--border);border-top:none;border-radius:0 0 var(--radius-sm) var(--radius-sm);padding:10px 16px}
|
||||
.stat-sub{display:flex;justify-content:space-between;font-size:12px;padding:5px 0;border-bottom:1px solid var(--border)}
|
||||
.stat-sub:last-child{border-bottom:none}
|
||||
.stat-sub span:first-child{color:var(--text2)}
|
||||
.stat-sub .stat-sub-val{font-weight:600;color:var(--text)}
|
||||
@media(max-width:600px){.stats{grid-template-columns:repeat(2,1fr)}}
|
||||
|
||||
/* TAGS */
|
||||
.tag{display:inline-block;padding:2px 7px;border-radius:6px;font-size:10px;font-weight:600}
|
||||
.tag-e{background:rgba(0,113,227,.08);color:var(--blue)}
|
||||
.tag-p{background:rgba(255,149,0,.08);color:var(--orange)}
|
||||
.tag-i{background:rgba(175,82,222,.08);color:var(--purple)}
|
||||
.tag-0{background:rgba(0,0,0,.04);color:var(--text3)}
|
||||
|
||||
/* RATE */
|
||||
.rate-low{color:var(--green);font-weight:600}
|
||||
.rate-mid{color:var(--orange);font-weight:600}
|
||||
.rate-high{color:var(--red);font-weight:700}
|
||||
|
||||
/* MONTH PLAN */
|
||||
.month-group{border-bottom:1px solid var(--border)}
|
||||
.month-group:last-child{border-bottom:none}
|
||||
.month-header{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;cursor:pointer;user-select:none;transition:background .2s}
|
||||
.month-header:hover{background:rgba(0,0,0,.02)}
|
||||
.month-header .ym{font-size:14px;font-weight:600;color:var(--text)}
|
||||
.month-header .total{font-size:15px;font-weight:700;color:var(--blue)}
|
||||
.month-header .arrow{font-size:10px;color:var(--text3);transition:transform .3s cubic-bezier(.4,0,.2,1);display:inline-block}
|
||||
.month-header .arrow.open{transform:rotate(90deg)}
|
||||
.month-detail{display:none;padding:0 20px 12px}
|
||||
.month-detail.show{display:block}
|
||||
.loan-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);cursor:pointer;font-size:13px;transition:all .15s}
|
||||
.loan-row:last-child{border-bottom:none}
|
||||
.loan-row:hover{background:rgba(0,0,0,.02);margin:0 -12px;padding:8px 12px;border-radius:var(--radius-xs)}
|
||||
.loan-row .left{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.loan-row .borrower{font-size:13px;font-weight:500;color:var(--text)}
|
||||
.loan-row .detail{font-size:11px;color:var(--text3);margin-top:2px}
|
||||
.loan-row .right{flex-shrink:0;width:110px;text-align:right}
|
||||
.loan-row .pay-amt{font-size:14px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums}
|
||||
.loan-row .pay-date{font-size:11px;color:var(--text3);font-variant-numeric:tabular-nums}
|
||||
.loan-row.paid{opacity:.45}
|
||||
.loan-row.paid .borrower{text-decoration:line-through}
|
||||
.paid-badge{display:inline-block;font-size:9px;padding:2px 6px;border-radius:4px;background:var(--green);color:#fff;margin-left:4px}
|
||||
.loan-row .pay-check{width:22px;height:22px;border:2px solid #d2d2d7;border-radius:6px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:all .2s;margin-left:12px}
|
||||
.loan-row .pay-check:hover{border-color:var(--blue);background:rgba(0,113,227,.06)}
|
||||
.loan-row .pay-check.checked{background:var(--blue);border-color:var(--blue)}
|
||||
.loan-row .pay-check.checked::after{content:"✓";color:#fff;font-size:12px;font-weight:700}
|
||||
.btn-pay-all{padding:6px 16px;border:1px solid var(--blue);background:transparent;color:var(--blue);border-radius:var(--radius-xs);font-size:12px;cursor:pointer;transition:all .2s;white-space:nowrap;font-weight:500;font-family:inherit}
|
||||
.btn-pay-all:hover{background:var(--blue);color:#fff}
|
||||
|
||||
/* MODALS */
|
||||
.modal-mask{display:none;position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:100;align-items:center;justify-content:center;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
|
||||
.modal-mask.show{display:flex}
|
||||
.modal{background:#fff;border-radius:var(--radius);padding:28px;max-width:420px;width:90%;box-shadow:0 24px 80px rgba(0,0,0,.15);animation:fadeIn .25s ease-out}
|
||||
@keyframes fadeIn{from{opacity:0;transform:scale(.96) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}
|
||||
.modal h3{margin-bottom:18px;font-size:17px;color:var(--text);font-weight:600}
|
||||
.modal .btn-row{margin-top:18px}
|
||||
|
||||
/* AUTH */
|
||||
.auth-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)}
|
||||
.auth-card{background:rgba(255,255,255,.72);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-radius:20px;padding:48px 44px;width:400px;border:1px solid var(--border);box-shadow:0 16px 48px rgba(0,0,0,.08)}
|
||||
.auth-card h2{text-align:center;margin-bottom:32px;color:var(--text);font-size:22px;font-weight:700;letter-spacing:-.3px}
|
||||
.auth-card .form-row{margin-bottom:18px}
|
||||
.auth-card .form-row label{font-size:13px;font-weight:500}
|
||||
.auth-card .form-row input{padding:12px 16px;font-size:15px}
|
||||
.auth-card .links{text-align:center;margin-top:18px}
|
||||
.auth-card .links a{color:var(--blue);text-decoration:none;font-size:13px;font-weight:500}
|
||||
.auth-card .links a:hover{text-decoration:underline}
|
||||
.auth-card .error-msg{display:none;font-size:13px;margin-top:0;text-align:center;padding:12px 18px;border-radius:var(--radius-sm);background:rgba(255,59,48,.08);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);color:var(--red);border:1px solid rgba(255,59,48,.15);line-height:1.5;align-items:center;justify-content:center;gap:8px;box-shadow:0 8px 32px rgba(255,59,48,.1);animation:fadeSlideIn .3s ease-out}
|
||||
.auth-card .error-msg.show{display:flex}
|
||||
@keyframes fadeSlideIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
|
||||
.auth-card .success-msg{color:var(--green);font-size:13px;margin-top:12px;text-align:center;background:rgba(52,199,89,.06);padding:12px;border-radius:var(--radius-xs)}
|
||||
|
||||
/* DROPDOWN */
|
||||
.export-wrap{position:relative;display:inline-block}
|
||||
.export-menu{display:none;position:absolute;right:0;top:calc(100% + 4px);background:rgba(255,255,255,.95);backdrop-filter:blur(20px);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:0 8px 32px rgba(0,0,0,.12);min-width:180px;z-index:50;overflow:hidden}
|
||||
.export-menu.show{display:block}
|
||||
.export-menu a{display:block;padding:10px 18px;font-size:13px;color:var(--text);text-decoration:none;transition:background .15s}
|
||||
.export-menu a:hover{background:rgba(0,0,0,.04)}
|
||||
|
||||
#loginPage,#registerPage{display:none}
|
||||
|
||||
/* TOAST */
|
||||
.toast-container{position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none}
|
||||
.toast{padding:12px 24px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;box-shadow:0 8px 24px rgba(0,0,0,.12);animation:toastIn .3s ease-out;pointer-events:auto;max-width:400px;text-align:center}
|
||||
.toast.info{background:#1d1d1f;color:#fff}
|
||||
.toast.success{background:#fff;color:var(--green);border:1px solid rgba(52,199,89,.2)}
|
||||
.toast.warning{background:#fff;color:var(--orange);border:1px solid rgba(255,149,0,.2)}
|
||||
.toast.error{background:#fff;color:var(--red);border:1px solid rgba(255,59,48,.2)}
|
||||
@keyframes toastIn{from{opacity:0;transform:translateY(-12px)}to{opacity:1;transform:translateY(0)}}
|
||||
|
||||
/* NOTIFICATION BANNER */
|
||||
.notif-banner{background:linear-gradient(135deg,#ff9500,#ff3b30);color:#fff;padding:10px 24px;text-align:center;font-size:13px;font-weight:500;cursor:pointer;animation:slideDown .3s ease-out}
|
||||
.notif-banner:hover{filter:brightness(1.05)}
|
||||
@keyframes slideDown{from{max-height:0;opacity:0}to{max-height:60px;opacity:1}}
|
||||
|
||||
/* NOTIFICATION CENTER */
|
||||
.notif-bell{position:relative;cursor:pointer;padding:6px 10px;border-radius:var(--radius-xs);transition:all .2s}
|
||||
.notif-bell:hover{background:rgba(0,0,0,.04)}
|
||||
.notif-bell svg{width:18px;height:18px;fill:var(--text2);transition:fill .2s}
|
||||
.notif-bell:hover svg{fill:var(--text)}
|
||||
.notif-badge{position:absolute;top:4px;right:4px;min-width:16px;height:16px;background:var(--red);color:#fff;border-radius:8px;font-size:10px;font-weight:600;display:flex;align-items:center;justify-content:center;padding:0 4px;border:2px solid #fff;animation:badgePop .3s ease-out}
|
||||
@keyframes badgePop{from{transform:scale(0)}to{transform:scale(1)}}
|
||||
|
||||
.notif-dropdown{display:none;position:absolute;right:0;top:calc(100% + 8px);background:rgba(255,255,255,.96);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 12px 48px rgba(0,0,0,.15);width:360px;max-height:480px;overflow:hidden;z-index:60}
|
||||
.notif-dropdown.show{display:block}
|
||||
.notif-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||
.notif-header h4{font-size:14px;font-weight:600;color:var(--text)}
|
||||
.notif-header .mark-all{font-size:12px;color:var(--blue);cursor:pointer;background:none;border:none;font-family:inherit}
|
||||
.notif-header .mark-all:hover{text-decoration:underline}
|
||||
.notif-list{overflow-y:auto;max-height:380px}
|
||||
.notif-item{padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .15s;display:flex;gap:12px;align-items:flex-start}
|
||||
.notif-item:hover{background:rgba(0,0,0,.02)}
|
||||
.notif-item.unread{background:rgba(0,113,227,.03)}
|
||||
.notif-item .notif-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
|
||||
.notif-icon.repayment_due{background:rgba(0,113,227,.08)}
|
||||
.notif-icon.repayment_overdue{background:rgba(255,59,48,.08)}
|
||||
.notif-icon.system{background:rgba(175,82,222,.08)}
|
||||
.notif-icon.account{background:rgba(52,199,89,.08)}
|
||||
.notif-item .notif-content{flex:1;min-width:0}
|
||||
.notif-item .notif-title{font-size:13px;font-weight:500;color:var(--text);margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.notif-item .notif-msg{font-size:12px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.notif-item .notif-time{font-size:11px;color:var(--text3);white-space:nowrap}
|
||||
.notif-empty{text-align:center;padding:32px;color:var(--text3);font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
<div id="notifBanner" style="display:none"></div>
|
||||
|
||||
<div id="loginPage" class="auth-page">
|
||||
<div class="auth-card">
|
||||
<h2>债务管理系统</h2>
|
||||
<div class="form-row"><label>账号</label><input type="text" id="loginEmail" placeholder="请输入账号"></div>
|
||||
<div class="form-row"><label>密码</label><input type="password" id="loginPwd" placeholder="请输入密码" onkeydown="if(event.key==='Enter')doLogin()"></div>
|
||||
<div id="loginError" class="error-msg"><span>⚠</span><span id="loginErrorText"></span></div>
|
||||
<div class="btn-row"><button class="btn btn-main" onclick="doLogin()">登录</button></div>
|
||||
<div class="links"><a href="#" onclick="showRegister()">注册新账户</a> <span style="color:var(--text3);margin:0 6px">|</span> <a href="#" onclick="showForgotStep1()">找回密码</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="forgotStep1">
|
||||
<div class="modal">
|
||||
<h3>找回密码</h3>
|
||||
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">请输入账号或绑定的邮箱,我们将发送验证码。</p>
|
||||
<div class="form-row"><label>账号 / 邮箱</label><input type="text" id="fpAccount" placeholder="请输入账号或邮箱"></div>
|
||||
<div id="fpStep1Error" class="error-msg" style="display:none"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-ghost" onclick="closeForgotModals()">取消</button>
|
||||
<button class="btn btn-main" onclick="doForgotStep1()">发送验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="forgotStep2">
|
||||
<div class="modal">
|
||||
<h3>输入验证码</h3>
|
||||
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">验证码已发送至绑定邮箱,请输入 6 位验证码。</p>
|
||||
<div class="form-row"><label>验证码</label><input type="text" id="fpCode" placeholder="6 位数字验证码" maxlength="6" style="letter-spacing:4px;text-align:center;font-size:18px"></div>
|
||||
<div id="fpStep2Error" class="error-msg" style="display:none"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-ghost" onclick="closeForgotModals()">取消</button>
|
||||
<button class="btn btn-main" onclick="doForgotStep2()">验证</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="forgotStep3">
|
||||
<div class="modal">
|
||||
<h3>重置密码</h3>
|
||||
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">验证码通过,请设置新密码。</p>
|
||||
<div class="form-row"><label>新密码</label><input type="password" id="fpNewPwd" placeholder="请输入新密码"></div>
|
||||
<div class="form-row"><label>确认新密码</label><input type="password" id="fpNewPwd2" placeholder="请再次输入新密码"></div>
|
||||
<div id="fpStep3Error" class="error-msg" style="display:none"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-ghost" onclick="closeForgotModals()">取消</button>
|
||||
<button class="btn btn-main" onclick="doForgotStep3()">确认重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="registerPage" class="auth-page">
|
||||
<div class="auth-card">
|
||||
<h2>注册账户</h2>
|
||||
<div id="regSuccess" class="success-msg" style="display:none"></div>
|
||||
<div id="regForm">
|
||||
<div class="form-row"><label>账号</label><input type="text" id="regEmail" placeholder="请输入账号"></div>
|
||||
<div class="form-row"><label>昵称</label><input type="text" id="regNick" placeholder="请输入昵称"></div>
|
||||
<div class="form-row"><label>密码</label><input type="password" id="regPwd" placeholder="请输入密码"></div>
|
||||
<div class="form-row"><label>确认密码</label><input type="password" id="regPwd2" placeholder="请再次输入密码"></div>
|
||||
<div id="regError" class="error-msg"></div>
|
||||
<div class="btn-row"><button class="btn btn-main" onclick="doRegister()">注册</button></div>
|
||||
</div>
|
||||
<div class="links"><a href="#" onclick="showLogin()">已有账户?去登录</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="appPage">
|
||||
<div class="header">
|
||||
<div class="header-inner">
|
||||
<h1>债务统计</h1>
|
||||
<div class="header-right">
|
||||
<div class="export-wrap" style="position:relative">
|
||||
<div class="notif-bell" onclick="toggleNotifDropdown(event)">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>
|
||||
<span class="notif-badge" id="notifBadge" style="display:none">0</span>
|
||||
</div>
|
||||
<div class="notif-dropdown" id="notifDropdown">
|
||||
<div class="notif-header">
|
||||
<h4>通知</h4>
|
||||
<button class="mark-all" onclick="markAllNotifRead()">全部已读</button>
|
||||
</div>
|
||||
<div class="notif-list" id="notifList">
|
||||
<div class="notif-empty">暂无通知</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="export-wrap">
|
||||
<span class="user-info" id="userInfo" onclick="toggleUserMenu(event)"></span>
|
||||
<div class="export-menu" id="userMenu">
|
||||
<a href="#" onclick="showChangeNick(event)">修改昵称</a>
|
||||
<a href="#" onclick="showChangePwd(event)">修改密码</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin.html" class="admin-link" id="adminLink" style="display:none">管理</a>
|
||||
<div class="export-wrap">
|
||||
<button class="hdr-btn" onclick="toggleExport(event)">导出</button>
|
||||
<div class="export-menu" id="exportMenu">
|
||||
<a href="#" onclick="exportCSV(event)">导出 CSV</a>
|
||||
<a href="#" onclick="exportJSON(event)">导出 JSON</a>
|
||||
<a href="#" onclick="exportHTML(event)">导出 HTML</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="export-wrap">
|
||||
<button class="hdr-btn" onclick="toggleImport(event)">导入</button>
|
||||
<div class="export-menu" id="importMenu">
|
||||
<a href="#" onclick="downloadTemplate(event)">下载模板</a>
|
||||
<a href="#" onclick="showImportModal(event)">导入 CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="logout-link" onclick="doLogout()">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="title-text">📝 新增借款</span><span class="title-badge" id="formTitle">编辑中</span></div>
|
||||
<div class="panel-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-row"><label>借款日期</label><input type="date" id="fDate"></div>
|
||||
<div class="form-row"><label>债权人</label><input type="text" id="fName" placeholder="债权人名称"></div>
|
||||
<div class="form-row"><label>借款金额(元)</label><input type="number" id="fAmt" placeholder="0.00" step="0.01" min="0"></div>
|
||||
<div class="form-row"><label>用途</label><input type="text" id="fPurpose" placeholder="如:装修、教育"></div>
|
||||
<div class="form-row"><label>还款方式</label><select id="fMethod"><option value="e">等额本息</option><option value="p">等额本金</option><option value="i">先息后本</option></select></div>
|
||||
<div class="form-row"><label>分期数(月)</label><input type="number" id="fN" placeholder="12" min="1" max="360"></div>
|
||||
<div class="form-row"><label>年化率(%)</label><input type="number" id="fRate" placeholder="利率" step="0.01" min="0"><div class="calc-hint" id="rateHint"></div></div>
|
||||
<div class="form-row"><label>总利息(元)</label><input type="number" id="fTotalInt" placeholder="总利息" step="0.01" min="0"><div class="calc-hint" id="intHint"></div></div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-main" id="btnSubmit" onclick="submitLoan()">添加</button>
|
||||
<button class="btn btn-ghost" onclick="clearForm()">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="title-text">📋 借款列表</span> <span style="font-size:11px;color:var(--text3);font-weight:400;cursor:pointer;display:flex;gap:12px"><span onclick="toggleHidePaid()"><span id="hidePaidIcon">🙈</span> 显示已还清</span><span onclick="toggleHideOthers()"><span id="hideOthersIcon">🙈</span> 显示他人</span></span></div>
|
||||
<div class="loan-list" id="loanList"><div class="empty-tip">暂无记录</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rightPanel" style="display:none">
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-title"><span class="title-text">📊 汇总概览</span></div>
|
||||
<div class="panel-body"><div class="stats" id="statsGrid"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="title-text">📅 月度还款计划</span> <span style="font-size:11px;color:var(--text3);font-weight:400;margin-left:auto;display:flex;gap:14px;align-items:center"><span style="cursor:pointer" onclick="toggleHidePaidMonths()"><span id="hidePaidMonthsIcon">🙈</span> 显示已还完</span><span style="cursor:pointer" onclick="toggleHideOthers()"><span id="hideOthersIcon2">🙈</span> 显示他人</span><span>点击标记已还</span></span></div>
|
||||
<div id="monthList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="delModal"><div class="modal"><h3>确认删除</h3><p style="font-size:13px;color:var(--text2);line-height:1.6">此操作不可撤销,删除后该借款记录将永久移除。</p><div class="btn-row"><button class="btn btn-ghost" onclick="closeDel()">取消</button><button class="btn btn-main" style="background:var(--red)" onclick="confirmDel()">确认删除</button></div></div></div>
|
||||
<div class="modal-mask" id="importModal"><div class="modal" style="max-width:500px"><h3>导入 CSV 数据</h3><p style="font-size:12px;color:var(--text2);margin-bottom:14px">选择 CSV 文件导入借款数据,可先下载模板再修改。</p><div class="form-row"><input type="file" id="importFile" accept=".csv" style="font-size:13px"></div><div id="importError" class="error-msg" style="display:none"></div><div id="importSuccess" class="success-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-ghost" onclick="closeImportModal()">取消</button><button class="btn btn-main" onclick="doImport()">导入</button></div></div></div>
|
||||
<div class="modal-mask" id="changeNickModal"><div class="modal"><h3>修改昵称</h3><div class="form-row"><label>新昵称</label><input type="text" id="newNick" placeholder="请输入新昵称"></div><div id="nickError" class="error-msg" style="display:none"></div><div id="nickSuccess" class="success-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-ghost" onclick="closeChangeNick()">取消</button><button class="btn btn-main" onclick="doChangeNick()">确认修改</button></div></div></div>
|
||||
<div class="modal-mask" id="changePwdModal"><div class="modal"><h3>修改密码</h3><div class="form-row"><label>原密码</label><input type="password" id="oldPwd" placeholder="请输入原密码"></div><div class="form-row"><label>新密码</label><input type="password" id="newPwd" placeholder="请输入新密码"></div><div class="form-row"><label>确认新密码</label><input type="password" id="newPwd2" placeholder="请再次输入新密码"></div><div id="pwdError" class="error-msg" style="display:none"></div><div id="pwdSuccess" class="success-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-ghost" onclick="closeChangePwd()">取消</button><button class="btn btn-main" onclick="doChangePwd()">确认修改</button></div></div></div>
|
||||
<div class="modal-mask" id="emailModal"><div class="modal"><h3>绑定邮箱</h3><p style="font-size:13px;color:var(--text2);line-height:1.6;margin-bottom:14px">首次使用请绑定邮箱,用于接收还款提醒和密码找回。</p><div class="form-row"><label>邮箱地址</label><input type="email" id="bindEmail" placeholder="请输入有效邮箱"></div><div id="bindEmailError" class="error-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-main" onclick="doBindEmail()">确认绑定</button></div></div></div>
|
||||
|
||||
<script>
|
||||
const API='/api/v1';
|
||||
let token=localStorage.getItem('access_token')||'';
|
||||
let refreshToken=localStorage.getItem('refresh_token')||'';
|
||||
let currentUser=null;
|
||||
let loans=[];
|
||||
let delTarget=null;
|
||||
let openMonths=new Set();
|
||||
let openGroups=new Set();
|
||||
let editId=null;
|
||||
let hidePaidItems=true;
|
||||
let hidePaidMonths=true;
|
||||
let hideOthers=true;
|
||||
let quickPayLoanId=null;
|
||||
let actionLoanId=null;
|
||||
|
||||
const $=id=>document.getElementById(id);
|
||||
const fmt=n=>n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const today=()=>new Date().toISOString().slice(0,10);
|
||||
const ymOf=d=>d.slice(0,7);
|
||||
const methodLabel=m=>m==='e'?'等额本息':m==='p'?'等额本金':'先息后本';
|
||||
const methodTag=m=>m==='e'?'tag-e':m==='p'?'tag-p':'tag-i';
|
||||
function showToast(msg,type='info',dur=3000){const c=$('toastContainer');const t=document.createElement('div');t.className='toast '+type;t.textContent=msg;c.appendChild(t);setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),300);},dur);}
|
||||
|
||||
let notifPage=1;
|
||||
function notifTypeIcon(type){const map={repayment_due:'📅',repayment_overdue:'⚠️',system:'📢',account:'👤'};return map[type]||'📩';}
|
||||
function notifTypeLabel(type){const map={repayment_due:'还款提醒',repayment_overdue:'逾期提醒',system:'系统消息',account:'账户通知'};return map[type]||'通知';}
|
||||
function timeAgo(dt){const diff=(Date.now()-new Date(dt).getTime())/1000;if(diff<60)return '刚刚';if(diff<3600)return Math.floor(diff/60)+'分钟前';if(diff<86400)return Math.floor(diff/3600)+'小时前';return Math.floor(diff/86400)+'天前';}
|
||||
|
||||
function toggleNotifDropdown(e){e.stopPropagation();const dd=$('notifDropdown');dd.classList.toggle('show');if(dd.classList.contains('show'))loadNotifications();}
|
||||
document.addEventListener('click',function(){$('notifDropdown')?.classList.remove('show');});
|
||||
|
||||
async function loadNotifications(){const res=await apiFetch('/notifications?size=20');if(!res||!res.ok)return;const data=await res.json();renderNotifBadge(data.unread);renderNotifList(data.items);}
|
||||
function renderNotifBadge(unread){const badge=$('notifBadge');if(unread>0){badge.style.display='';badge.textContent=unread>99?'99+':unread;}else{badge.style.display='none';}}
|
||||
function renderNotifList(items){const el=$('notifList');if(!items.length){el.innerHTML='<div class="notif-empty">暂无通知</div>';return;}el.innerHTML=items.map(n=>`<div class="notif-item${n.is_read===0?' unread':''}" onclick="readNotif(${n.id},'${n.type}',${n.related_debt_id||0})"><div class="notif-icon ${n.type}">${notifTypeIcon(n.type)}</div><div class="notif-content"><div class="notif-title">${n.title}</div><div class="notif-msg">${n.message}</div></div><div class="notif-time">${timeAgo(n.created_at)}</div></div>`).join('');}
|
||||
|
||||
async function readNotif(id,type,debtId){await apiFetch('/notifications/'+id+'/read',{method:'PUT'});loadNotifications();if(debtId&&(type==='repayment_due'||type==='repayment_overdue')){$('notifDropdown').classList.remove('show');}}
|
||||
async function markAllNotifRead(){await apiFetch('/notifications/read-all',{method:'PUT'});loadNotifications();}
|
||||
|
||||
async function loadNotifBanner(){const res=await apiFetch('/notifications?unread_only=true&size=5');if(!res||!res.ok)return;const data=await res.json();const overdue=data.items.find(n=>n.type==='repayment_overdue');const due=data.items.find(n=>n.type==='repayment_due');const banner=overdue||due;if(banner){$('notifBanner').style.display='';$('notifBanner').className='notif-banner';$('notifBanner').textContent=banner.title;$('notifBanner').onclick=()=>{$('notifBanner').style.display='none';toggleNotifDropdown(new Event('click'));};}}
|
||||
|
||||
async function apiFetch(path,opts={}){
|
||||
const headers={'Content-Type':'application/json',...opts.headers};
|
||||
if(token)headers['Authorization']='Bearer '+token;
|
||||
let res=await fetch(API+path,{...opts,headers});
|
||||
if(res.status===401&&refreshToken){
|
||||
const r=await fetch(API+'/auth/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refresh_token:refreshToken})});
|
||||
if(r.ok){const d=await r.json();token=d.access_token;refreshToken=d.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);headers['Authorization']='Bearer '+token;res=await fetch(API+path,{...opts,headers});}
|
||||
else{doLogout();return null;}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function checkAuth(){
|
||||
if(!token){showLogin();return false;}
|
||||
const res=await apiFetch('/auth/profile');
|
||||
if(!res||!res.ok){showLogin();return false;}
|
||||
currentUser=await res.json();
|
||||
$('userInfo').textContent=currentUser.nickname||currentUser.account;
|
||||
if(currentUser.role==='admin')$('adminLink').style.display='';
|
||||
loadNotifBanner();
|
||||
showApp();
|
||||
if(!currentUser.real_email)$('emailModal').classList.add('show');
|
||||
return true;
|
||||
}
|
||||
function showLogin(){$('loginPage').style.display='flex';$('registerPage').style.display='none';$('appPage').style.display='none';}
|
||||
function showRegister(){$('loginPage').style.display='none';$('registerPage').style.display='flex';$('appPage').style.display='none';}
|
||||
function showApp(){$('loginPage').style.display='none';$('registerPage').style.display='none';$('appPage').style.display='';}
|
||||
|
||||
function showLoginError(msg){$('loginErrorText').textContent=msg;$('loginError').classList.add('show');}
|
||||
function hideLoginError(){$('loginError').classList.remove('show');$('loginErrorText').textContent='';}
|
||||
let loginCountdown=null;
|
||||
function startLoginCountdown(seconds){
|
||||
if(loginCountdown)clearInterval(loginCountdown);
|
||||
let remaining=seconds;
|
||||
const btn=document.querySelector('#loginPage .btn-main');
|
||||
btn.disabled=true;btn.style.opacity='.5';
|
||||
function tick(){
|
||||
const m=Math.floor(remaining/60),s=remaining%60;
|
||||
showLoginError(`登录失败次数过多,请 ${m}:${String(s).padStart(2,'0')} 后再试`);
|
||||
if(remaining<=0){clearInterval(loginCountdown);loginCountdown=null;btn.disabled=false;btn.style.opacity='';hideLoginError();}
|
||||
remaining--;
|
||||
}
|
||||
tick();
|
||||
loginCountdown=setInterval(tick,1000);
|
||||
}
|
||||
async function doLogin(){
|
||||
const account=$('loginEmail').value.trim(),pwd=$('loginPwd').value;
|
||||
if(!account||!pwd)return showLoginError('请输入账号和密码');
|
||||
hideLoginError();
|
||||
try{const res=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account,password:pwd})});const data=await res.json();if(!res.ok){if(res.status===429&&data.retry_after){startLoginCountdown(data.retry_after);}else{showLoginError(data.detail||'登录失败');}return;}token=data.access_token;refreshToken=data.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);await checkAuth();await loadLoans();}catch(e){showLoginError('网络连接异常,请稍后重试');}
|
||||
}
|
||||
async function doRegister(){
|
||||
const account=$('regEmail').value.trim(),nick=$('regNick').value.trim(),pwd=$('regPwd').value,pwd2=$('regPwd2').value;
|
||||
if(!account||!pwd)return $('regError').textContent='请填写账号和密码';
|
||||
if(pwd!==pwd2)return $('regError').textContent='两次输入的密码不一致';
|
||||
$('regError').textContent='';
|
||||
try{const res=await fetch(API+'/auth/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account,password:pwd,nickname:nick})});const data=await res.json();if(!res.ok){$('regError').textContent=data.detail||'注册失败';return;}$('regForm').style.display='none';$('regSuccess').style.display='';$('regSuccess').textContent=data.message||'注册成功,等待管理员审批';}catch(e){$('regError').textContent='网络连接异常,请稍后重试';}
|
||||
}
|
||||
function doLogout(){token='';refreshToken='';currentUser=null;localStorage.removeItem('access_token');localStorage.removeItem('refresh_token');showLogin();}
|
||||
|
||||
async function loadLoans(){const res=await apiFetch('/loans');if(!res||!res.ok)return;loans=await res.json();renderAll();}
|
||||
async function saveLoanToServer(loan){if(loan.id){const res=await apiFetch('/loans/'+loan.id,{method:'PUT',body:JSON.stringify(loan)});if(res&&res.ok)return await res.json();}else{const res=await apiFetch('/loans',{method:'POST',body:JSON.stringify(loan)});if(res&&res.ok)return await res.json();}return null;}
|
||||
async function deleteLoanFromServer(id){const res=await apiFetch('/loans/'+id,{method:'DELETE'});return res&&res.ok;}
|
||||
async function togglePaidOnServer(id,paid){const res=await apiFetch('/loans/'+id+'/toggle-paid',{method:'PATCH',body:JSON.stringify({paid})});if(res&&res.ok){const data=await res.json();return data.paid;}return null;}
|
||||
|
||||
$('fDate').value=today();
|
||||
let syncingFields=false;
|
||||
function calcTotalIntFromRate(a,r,n,m){if(!a||!r||!n)return 0;const mr=r/100/12;if(m==='e'){const mp=a*mr*Math.pow(1+mr,n)/(Math.pow(1+mr,n)-1);return mp*n-a;}if(m==='p')return a*mr*(n+1)/2;if(m==='i')return a*mr*n;return 0;}
|
||||
function calcRateFromTotalInt(a,ti,n,m){if(!a||!ti||!n)return 0;if(m==='i')return ti*1200/(a*n);if(m==='p')return ti*2400/(a*(n+1));let lo=0,hi=1;for(let i=0;i<50;i++){if(calcTotalIntFromRate(a,hi,n,'e')>=ti)break;hi*=2;if(hi>100)return 0;}for(let i=0;i<100;i++){const mid=(lo+hi)/2;if(calcTotalIntFromRate(a,mid,n,'e')<ti)lo=mid;else hi=mid;}return(lo+hi)/2;}
|
||||
function syncFromRate(){if(syncingFields)return;syncingFields=true;const a=+$('fAmt').value,r=+$('fRate').value,n=+$('fN').value,m=$('fMethod').value;if(a&&!isNaN(r)&&$('fRate').value!==''&&n){$('fTotalInt').value=calcTotalIntFromRate(a,r,n,m).toFixed(2);$('intHint').textContent='← 自动计算';$('rateHint').textContent='';}else{$('fTotalInt').value='';$('intHint').textContent='';}syncingFields=false;}
|
||||
function syncFromTotalInt(){if(syncingFields)return;syncingFields=true;const a=+$('fAmt').value,ti=+$('fTotalInt').value,n=+$('fN').value,m=$('fMethod').value;if(a&&!isNaN(ti)&&$('fTotalInt').value!==''&&n){if(ti===0){$('fRate').value='0';$('rateHint').textContent='← 无息';$('intHint').textContent='';}else{const r=calcRateFromTotalInt(a,ti,n,m);$('fRate').value=r.toFixed(4);$('rateHint').textContent='← 自动计算';$('intHint').textContent='';}}else{$('fRate').value='';$('rateHint').textContent='';}syncingFields=false;}
|
||||
let rateDebounce=null,intDebounce=null;
|
||||
$('fRate').addEventListener('input',function(){clearTimeout(rateDebounce);clearTimeout(intDebounce);rateDebounce=setTimeout(syncFromRate,300);});
|
||||
$('fRate').addEventListener('focus',function(){$('rateHint').textContent='输入后自动计算总利息';$('intHint').textContent='';});
|
||||
$('fTotalInt').addEventListener('input',function(){clearTimeout(rateDebounce);clearTimeout(intDebounce);intDebounce=setTimeout(syncFromTotalInt,300);});
|
||||
$('fTotalInt').addEventListener('focus',function(){$('intHint').textContent='输入后自动计算年利率';$('rateHint').textContent='';});
|
||||
['fAmt','fN','fMethod'].forEach(id=>{$(id).addEventListener('change',function(){clearTimeout(rateDebounce);clearTimeout(intDebounce);const a=+$('fAmt').value,r=+$('fRate').value,ti=+$('fTotalInt').value,n=+$('fN').value,m=$('fMethod').value;if(a&&n&&$('fTotalInt').value!==''&&!isNaN(ti))syncFromTotalInt();else if(a&&n&&$('fRate').value!==''&&!isNaN(r))syncFromRate();});});
|
||||
|
||||
function calcSchedule(a,r,n,m,start){const mr=r/100/12,sd=new Date(start),list=[];if(m==='e'){const mp=mr===0?a/n:a*mr*Math.pow(1+mr,n)/(Math.pow(1+mr,n)-1);let rp=a;for(let i=0;i<n;i++){const it=rp*mr,pr=mp-it;rp-=pr;const dd=new Date(sd);dd.setMonth(dd.getMonth()+i+1);list.push({p:i+1,date:dd.toISOString().slice(0,10),pay:Math.round(mp*100)/100,pr:Math.round(pr*100)/100,it:Math.round(it*100)/100,rp:Math.round(Math.max(0,rp)*100)/100});}}else if(m==='p'){const pp=a/n;let rp=a;for(let i=0;i<n;i++){const it=rp*mr,pay=pp+it;rp-=pp;const dd=new Date(sd);dd.setMonth(dd.getMonth()+i+1);list.push({p:i+1,date:dd.toISOString().slice(0,10),pay:Math.round(pay*100)/100,pr:Math.round(pp*100)/100,it:Math.round(it*100)/100,rp:Math.round(Math.max(0,rp)*100)/100});}}else{const mi=a*mr;let rp=a;for(let i=0;i<n;i++){const isLast=i===n-1;const pr=isLast?a:0;const pay=mi+pr;const dd=new Date(sd);dd.setMonth(dd.getMonth()+i+1);list.push({p:i+1,date:dd.toISOString().slice(0,10),pay:Math.round(pay*100)/100,pr:Math.round(pr*100)/100,it:Math.round(mi*100)/100,rp:Math.round(Math.max(0,rp-pr)*100)/100});if(isLast)rp=0;}}return list;}
|
||||
|
||||
function renderAll(){renderList();renderRight();}
|
||||
function renderList(){const el=$('loanList');if(!loans.length){el.innerHTML='<div class="empty-tip">暂无记录</div>';return;}let visibleLoans=loans;if(hideOthers)visibleLoans=loans.filter(l=>l.user_id===currentUser.id);const groups=new Map();visibleLoans.forEach(l=>{if(!groups.has(l.name))groups.set(l.name,[]);groups.get(l.name).push(l);});const sortedGroups=[...groups.entries()].sort(([,a],[,b])=>{const ea=a.reduce((m,l)=>l.date<m?l.date:m,'9999-99-99');const eb=b.reduce((m,l)=>l.date<m?l.date:m,'9999-99-99');return eb.localeCompare(ea);});let html='';sortedGroups.forEach(([name,items])=>{items.sort((a,b)=>b.date.localeCompare(a.date));let visibleItems=items;if(hidePaidItems)visibleItems=items.filter(l=>l.paid.length<l.periods);if(hidePaidItems&&!visibleItems.length)return;const isOpen=openGroups.has(name);const totalAmt=visibleItems.reduce((s,l)=>s+l.amount,0);const doneCount=items.filter(l=>l.paid.length>=l.periods).length;html+=`<div class="loan-group"><div class="loan-group-header" onclick="toggleGroup('${name}')"><span><span class="arrow${isOpen?' open':''}">▶</span><span class="gname">${name}</span></span><span class="gsummary">${visibleItems.length}笔 · ¥${fmt(totalAmt)}${doneCount===items.length?' · <span style="color:var(--green)">全部还清</span>':''}</span></div><div class="loan-group-body${isOpen?' open':''}">`;visibleItems.forEach(l=>{const mt=methodLabel(l.method),tc=methodTag(l.method);const allDone=l.paid.length>=l.periods;const noInt=l.rate===0;const rc=l.rate===0?'':l.rate<12?'rate-low':l.rate<=24?'rate-mid':'rate-high';const hasPopup=actionLoanId===l.id;const isOwner=l.user_id===currentUser.id;html+=`<div class="loan-item${l.id===editId?' active':''}${allDone?' done-item':''}" onclick="toggleLoanAction(${l.id})"><div style="flex:1"><div class="iname">${l.purpose||''}${allDone?'<span class="badge-done">已还清</span>':''} <span class="tag ${tc}">${mt}</span>${noInt?' <span class="tag tag-0">无息</span>':''}${l.owner_name?` <span class="tag" style="background:rgba(0,0,0,.04);color:var(--text2)">${l.owner_name}</span>`:''}</div><div class="isub">${l.paid.length}/${l.periods}期 · <span class="${rc}">${l.rate}%</span> · ${l.date}</div></div><div style="text-align:right"><div class="iamt">¥${fmt(l.amount)}</div></div></div>${hasPopup?`<div class="loan-actions-popup"><button class="act-btn act-btn-edit" onclick="event.stopPropagation();doEditLoan(${l.id})">✏️ 修改</button><button class="act-btn act-btn-pay" onclick="event.stopPropagation();toggleQuickPay(${l.id})">💰 快速还款</button>${isOwner?`<button class="act-btn act-btn-del" onclick="event.stopPropagation();showDel(${l.id})">🗑 删除</button>`:''}</div>`:''}${quickPayLoanId===l.id?renderQuickPay(l):''}`;});html+=`</div></div>`;});el.innerHTML=html;}
|
||||
function toggleGroup(name){if(openGroups.has(name))openGroups.delete(name);else openGroups.add(name);renderList();}
|
||||
function showLoanDetail(id){const l=loans.find(x=>x.id===id);if(!l)return;editId=id;$('fDate').value=l.date;$('fName').value=l.name;$('fPurpose').value=l.purpose||'';$('fAmt').value=l.amount;$('fRate').value=l.rate;$('fTotalInt').value='';$('fN').value=l.periods;$('fMethod').value=l.method;$('rateHint').textContent='';$('intHint').textContent='';$('formTitle').textContent='编辑中';$('btnSubmit').textContent='修改';document.querySelector('.main').scrollIntoView({behavior:'smooth',block:'start'});}
|
||||
|
||||
async function submitLoan(){clearTimeout(rateDebounce);clearTimeout(intDebounce);const a=+$('fAmt').value,ti=+$('fTotalInt').value,n=+$('fN').value,m=$('fMethod').value;let r=+$('fRate').value;if(a&&n&&!r&&ti){r=calcRateFromTotalInt(a,ti,n,m);$('fRate').value=r.toFixed(4);}const d=$('fDate').value,nm=$('fName').value.trim(),p=+$('fN').value;r=+$('fRate').value;if(!d||!nm||!a||isNaN(r)||r===''||!p)return showToast('请完善借款信息,年化率和总利息至少填写一项','warning');const schedule=calcSchedule(a,r,p,m,d);const loan={name:nm,date:d,amount:a,rate:Math.round(r*10000)/10000,periods:p,method:m,purpose:($('fPurpose').value||'').trim(),schedule,paid:[]};if(editId){const existing=loans.find(x=>x.id===editId);if(existing)loan.paid=existing.paid.filter(x=>x<=p);loan.id=editId;}const saved=await saveLoanToServer(loan);if(saved){if(editId){const idx=loans.findIndex(x=>x.id===editId);if(idx>=0)loans[idx]=saved;}else loans.push(saved);renderAll();clearForm();}}
|
||||
|
||||
let toggleLock=false;
|
||||
async function togglePaid(id,p){if(toggleLock)return;const l=loans.find(x=>x.id===id);if(!l)return;if(l.user_id!==currentUser.id){showToast('仅支持本人操作还款','warning');return;}toggleLock=true;const wasPaid=l.paid.includes(p);if(wasPaid){l.paid=l.paid.filter(x=>x!==p);}else{l.paid=[...l.paid,p];}renderMonthPlan();renderStats();renderList();try{const serverPaid=await togglePaidOnServer(id,l.paid);if(serverPaid!==null){l.paid=serverPaid;}}catch(e){}toggleLock=false;renderMonthPlan();renderStats();renderList();}
|
||||
|
||||
async function payAllMonth(ym){if(toggleLock)return;toggleLock=true;const map=buildMonthMap();const items=map[ym];if(!items){toggleLock=false;return;}const unpaidItems=items.filter(r=>{const l=loans.find(x=>x.id===r.loanId);return l&&l.user_id===currentUser.id&&!l.paid.includes(r.p);});if(!unpaidItems.length){toggleLock=false;return;}for(const r of unpaidItems){const l=loans.find(x=>x.id===r.loanId);if(l&&!l.paid.includes(r.p)){l.paid=[...l.paid,r.p];}}renderMonthPlan();renderStats();renderList();for(const r of unpaidItems){const l=loans.find(x=>x.id===r.loanId);if(l){await togglePaidOnServer(r.loanId,l.paid);}}toggleLock=false;renderMonthPlan();renderStats();renderList();}
|
||||
|
||||
function clearForm(){$('fName').value='';$('fPurpose').value='';$('fAmt').value='';$('fRate').value='';$('fTotalInt').value='';$('fN').value='';$('fDate').value=today();$('rateHint').textContent='';$('intHint').textContent='';editId=null;$('formTitle').textContent='新增';$('btnSubmit').textContent='添加';}
|
||||
function toggleLoanAction(id){if(actionLoanId===id){actionLoanId=null;quickPayLoanId=null;}else{actionLoanId=id;quickPayLoanId=null;}renderList();}
|
||||
function doEditLoan(id){
|
||||
const l=loans.find(x=>x.id===id);
|
||||
if(!l)return;
|
||||
if(l.user_id!==currentUser.id){showToast('仅支持修改本人的借款记录','warning');return;}
|
||||
actionLoanId=null;quickPayLoanId=null;showLoanDetail(id);
|
||||
}
|
||||
|
||||
function toggleQuickPay(id){quickPayLoanId=quickPayLoanId===id?null:id;renderList();}
|
||||
|
||||
function renderQuickPay(l){
|
||||
const isOwner=l.user_id===currentUser.id;
|
||||
let html=`<div class="quick-panel"><div class="qp-title">还款节点 · ${l.name} ${l.purpose?'· '+l.purpose:''}</div>`;
|
||||
l.schedule.forEach(r=>{
|
||||
const isPaid=l.paid.includes(r.p);
|
||||
const btnClass=isPaid?'qp-btn-done':'qp-btn-pay';
|
||||
const btnText=isPaid?'✓ 已还':'还款';
|
||||
const disabled=!isOwner?(isPaid?'':`disabled title="只允许借款人还款"`):'';
|
||||
const onclick=isOwner?`onclick="event.stopPropagation();togglePaid(${l.id},${r.p})"`:(isPaid?`onclick="event.stopPropagation();togglePaid(${l.id},${r.p})"`:'');
|
||||
html+=`<div class="qp-row"><span class="qp-info">第${r.p}期 · ${r.date}</span><span class="qp-amt">¥${r.pay.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g,',')}</span><button class="qp-btn ${btnClass}" ${onclick}>${btnText}</button></div>`;
|
||||
});
|
||||
html+=`</div>`;
|
||||
return html;
|
||||
}
|
||||
function showDel(id){const l=loans.find(x=>x.id===id);if(l&&l.user_id!==currentUser.id){showToast('仅支持删除本人的借款记录','warning');return;}delTarget=id;$('delModal').classList.add('show');}
|
||||
function closeDel(){$('delModal').classList.remove('show');delTarget=null;}
|
||||
async function confirmDel(){if(!delTarget)return;const ok=await deleteLoanFromServer(delTarget);if(ok){loans=loans.filter(l=>l.id!==delTarget);renderAll();}closeDel();}
|
||||
|
||||
function renderRight(){if(!loans.length){$('rightPanel').style.display='none';return;}$('rightPanel').style.display='';renderStats();renderMonthPlan();}
|
||||
const statsExpanded=new Set();
|
||||
function renderStats(){let totalAmt=0,totalInt=0,totalPaid=0,paidN=0,totalN=0;loans.forEach(l=>{totalAmt+=l.amount;totalN+=l.periods;const s=l.schedule;totalInt+=s.reduce((a,r)=>a+r.it,0);paidN+=l.paid.length;totalPaid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const totalPay=totalAmt+totalInt;const memberMap=new Map();loans.forEach(l=>{const owner=l.owner_name||'未知';if(!memberMap.has(owner))memberMap.set(owner,{amt:0,int:0,paid:0,paidN:0,totalN:0});const m=memberMap.get(owner);m.amt+=l.amount;m.totalN+=l.periods;const s=l.schedule;m.int+=s.reduce((a,r)=>a+r.it,0);m.paidN+=l.paid.length;m.paid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const cards=[{lbl:'借款总额',val:`¥${fmt(totalAmt)}`,cls:'blue',key:'amt'},{lbl:'总利息',val:`¥${fmt(totalInt)}`,cls:'red',key:'int'},{lbl:'总还款',val:`¥${fmt(totalPay)}`,cls:'',key:'pay'},{lbl:'已还',val:`¥${fmt(totalPaid)}`,cls:'green',key:'paid'},{lbl:'待还',val:`¥${fmt(totalPay-totalPaid)}`,cls:'red',key:'remain'},{lbl:'已还期数',val:`${paidN}/${totalN}`,cls:'green',key:'periods'}];let html='';cards.forEach(c=>{const isOpen=statsExpanded.has(c.key);html+=`<div class="stat-block"><div class="stat-card clickable" onclick="toggleStat('${c.key}')"><div class="lbl">${c.lbl}</div><div class="val ${c.cls}">${c.val}</div><div class="stat-arrow${isOpen?' open':''}">▶</div></div>`;if(isOpen){html+=`<div class="stats-detail-wrap">`;memberMap.forEach((m,owner)=>{const vals={amt:fmt(m.amt),int:fmt(m.int),pay:fmt(m.amt+m.int),paid:fmt(m.paid),remain:fmt(m.amt+m.int-m.paid),periods:`${m.paidN}/${m.totalN}`};html+=`<div class="stat-sub"><span>${owner}</span><span class="stat-sub-val">${vals[c.key]}</span></div>`;});html+=`</div>`;}html+=`</div>`;});$('statsGrid').innerHTML=html;}
|
||||
function toggleStat(key){if(statsExpanded.has(key))statsExpanded.delete(key);else statsExpanded.add(key);renderStats();}
|
||||
|
||||
function buildMonthMap(){const map={};const visibleLoans=hideOthers?loans.filter(l=>l.user_id===currentUser.id):loans;visibleLoans.forEach(l=>{const displayName=l.purpose?`${l.name} · ${l.purpose}`:l.name;const ownerTag=l.owner_name?` [${l.owner_name}]`:'';l.schedule.forEach(r=>{const ym=ymOf(r.date);if(!map[ym])map[ym]=[];map[ym].push({loanId:l.id,loanName:displayName+ownerTag,method:l.method,...r,paid:l.paid.includes(r.p)});});});return map;}
|
||||
|
||||
function renderMonthPlan(){const map=buildMonthMap();let months=Object.keys(map).sort();if(!months.length){$('monthList').innerHTML='<div class="empty-tip">暂无还款计划</div>';return;}if(hidePaidMonths)months=months.filter(ym=>{const items=map[ym];return items.some(r=>!r.paid);});if(!months.length){$('monthList').innerHTML='<div class="empty-tip">所有月份已还清</div>';return;}const now=today(),nowYm=now.slice(0,7);$('monthList').innerHTML=months.map(ym=>{const items=map[ym];const monthTotal=items.reduce((a,r)=>a+r.pay,0);const paidCount=items.filter(r=>r.paid).length;const allPaid=paidCount===items.length;const isPast=ym<nowYm;const isNow=ym===nowYm;const isOpen=openMonths.has(ym);const payAllBtn=allPaid?'':`<button class="btn-pay-all" onclick="event.stopPropagation();payAllMonth('${ym}')">一键还款</button>`;return`<div class="month-group"><div class="month-header" onclick="toggleMonth('${ym}')"><span class="ym">${ym.replace('-','年')}月${isNow?' (本月)':''}${isPast&&!allPaid?' ⚠️':''}</span><span style="display:flex;align-items:center;gap:12px"><span style="font-size:11px;color:var(--text3)">${paidCount}/${items.length}笔</span>${payAllBtn}<span class="total"${allPaid?' style="color:var(--green)"':''}>${allPaid?'✓ ':''}¥${fmt(monthTotal)}</span><span class="arrow${isOpen?' open':''}">▶</span></span></div><div class="month-detail${isOpen?' show':''}">${items.map(r=>{const ml=methodLabel(r.method);const ds=r.method==='i'?`利息 ¥${fmt(r.it)}${r.pr>0?' + 本金 ¥'+fmt(r.pr):''}`:`本金 ¥${fmt(r.pr)} + 利息 ¥${fmt(r.it)}`;const isChecked=r.paid;const cls='pay-check'+(isChecked?' checked':'')+(toggleLock?' disabled':'');return`<div class="loan-row${isChecked?' paid':''}" data-loan="${r.loanId}" data-period="${r.p}"><div class="left"><span class="borrower">${r.loanName}${isChecked?' <span class="paid-badge">✓ 已还</span>':''}</span><span class="detail">${ml} · 第${r.p}期 · ${ds}</span></div><div class="right"><div class="pay-amt">¥${r.pay.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g,',')}</div><div class="pay-date">${r.date}</div></div><div class="${cls}" onclick="event.stopPropagation();togglePaid(${r.loanId},${r.p})"></div></div>`;}).join('')}</div></div>`;}).join('');}
|
||||
|
||||
function toggleHidePaid(){hidePaidItems=!hidePaidItems;$('hidePaidIcon').textContent=hidePaidItems?'🙈':'👁';$('hidePaidIcon').nextSibling.textContent=hidePaidItems?' 显示已还清':' 隐藏已还清';renderList();}
|
||||
function toggleHidePaidMonths(){hidePaidMonths=!hidePaidMonths;$('hidePaidMonthsIcon').textContent=hidePaidMonths?'🙈':'👁';$('hidePaidMonthsIcon').nextSibling.textContent=hidePaidMonths?' 显示已还完':' 隐藏已还完';renderMonthPlan();}
|
||||
function toggleHideOthers(){hideOthers=!hideOthers;$('hideOthersIcon').textContent=hideOthers?'🙈':'👁';$('hideOthersIcon').nextSibling.textContent=hideOthers?' 显示他人':' 隐藏他人';$('hideOthersIcon2').textContent=hideOthers?'🙈':'👁';$('hideOthersIcon2').nextSibling.textContent=hideOthers?' 显示他人':' 隐藏他人';renderList();renderMonthPlan();}
|
||||
function toggleMonth(ym){if(openMonths.has(ym))openMonths.delete(ym);else openMonths.add(ym);renderMonthPlan();}
|
||||
|
||||
function toggleExport(e){e.stopPropagation();$('exportMenu').classList.toggle('show');}
|
||||
document.addEventListener('click',function(){$('exportMenu').classList.remove('show');});
|
||||
function download(filename,content,mime){const blob=new Blob(['\uFEFF'+content],{type:mime});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=filename;a.click();}
|
||||
function exportCSV(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let csv='借款人,借款日期,借款金额,年利率(%),总利息,分期数,还款方式,已还期数,状态\n';loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const paid=l.paid.length+'/'+l.periods;const status=l.paid.length>=l.periods?'已还清':'还款中';csv+=`${l.name},${l.date},${l.amount},${l.rate},${ti.toFixed(2)},${l.periods},${methodLabel(l.method)},${paid},${status}\n`;});download('债务统计_'+today()+'.csv',csv,'text/csv;charset=utf-8');}
|
||||
function exportJSON(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');const data=loans.map(l=>({借款人:l.name,借款日期:l.date,借款金额:l.amount,年利率:l.rate,分期数:l.periods,还款方式:methodLabel(l.method),已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',还款计划:l.schedule.map(r=>({期数:r.p,日期:r.date,还款额:r.pay,本金:r.pr,利息:r.it}))}));download('债务统计_'+today()+'.json',JSON.stringify(data,null,2),'application/json;charset=utf-8');}
|
||||
function exportHTML(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let h=`<html><head><meta charset="utf-8"><title>债务统计</title><style>body{font-family:"Microsoft YaHei",sans-serif;padding:20px}h2{color:#1d1d1f}table{border-collapse:collapse;width:100%;font-size:13px;margin-bottom:20px}th{background:#0071e3;color:#fff;padding:8px 12px;text-align:left}td{padding:6px 12px;border-bottom:1px solid #d2d2d7}tr:nth-child(even){background:#f5f5f7}.done{color:#34c759;font-weight:600}</style></head><body><h2>债务统计报表 - ${today()}</h2>`;loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`<h3>${l.name} - ${l.date} - ¥${fmt(l.amount)}${st?' <span class="done">已还清</span>':''}</h3><p>年利率 ${l.rate}% · ${methodLabel(l.method)} · ${l.periods}期 · 总利息 ¥${ti.toFixed(2)}</p><table><tr><th>期数</th><th>日期</th><th>还款额</th><th>本金</th><th>利息</th><th>剩余本金</th><th>状态</th></tr>`;l.schedule.forEach(r=>{h+=`<tr><td>${r.p}</td><td>${r.date}</td><td>¥${fmt(r.pay)}</td><td>¥${fmt(r.pr)}</td><td>¥${fmt(r.it)}</td><td>¥${fmt(r.rp)}</td><td>${l.paid.includes(r.p)?'✓ 已还':'待还'}</td></tr>`;});h+=`</table>`;});h+=`</body></html>`;download('债务统计_'+today()+'.html',h,'text/html;charset=utf-8');}
|
||||
function toggleImport(e){e.stopPropagation();$('importMenu').classList.toggle('show');}
|
||||
document.addEventListener('click',function(){$('importMenu').classList.remove('show');});
|
||||
function showImportModal(e){e.preventDefault();$('importMenu').classList.remove('show');$('importFile').value='';$('importError').style.display='none';$('importSuccess').style.display='none';$('importModal').classList.add('show');}
|
||||
function closeImportModal(){$('importModal').classList.remove('show');}
|
||||
function downloadTemplate(e){e.preventDefault();$('importMenu').classList.remove('show');let csv='借款人,借款日期,借款金额,年利率(%),总利息,分期数,还款方式,已还期数,状态\n';csv+='张三,2026-01-01,100000,5.7,5198.99,24,等额本息,0/24,还款中\n';csv+='李四,2026-03-15,50000,8,1200.00,12,先息后本,0/12,还款中\n';csv+='王五,2026-06-01,30000,0,0.00,6,等额本息,0/6,还款中\n';download('导入模板.csv',csv,'text/csv;charset=utf-8');}
|
||||
async function doImport(){const file=$('importFile').files[0];if(!file){$('importError').textContent='请选择文件';$('importError').style.display='';return;}$('importError').style.display='none';$('importSuccess').style.display='none';const text=await file.text();const lines=text.trim().split('\n');if(lines.length<2){$('importError').textContent='文件为空或格式错误';$('importError').style.display='';return;}const header=lines[0].split(',').map(s=>s.trim());const nameIdx=header.indexOf('借款人'),dateIdx=header.indexOf('借款日期'),amtIdx=header.indexOf('借款金额'),rateIdx=header.indexOf('年利率(%)'),periodsIdx=header.indexOf('分期数'),methodIdx=header.indexOf('还款方式'),paidIdx=header.indexOf('已还期数');if(nameIdx===-1||dateIdx===-1||amtIdx===-1||rateIdx===-1||periodsIdx===-1||methodIdx===-1){$('importError').textContent='缺少必需列:借款人,借款日期,借款金额,年利率(%),分期数,还款方式';$('importError').style.display='';return;}const methodMap={'等额本息':'e','等额本金':'p','先息后本':'i','e':'e','p':'p','i':'i'};let imported=0,failed=0;for(let i=1;i<lines.length;i++){const line=lines[i].trim();if(!line)continue;const cols=line.split(',').map(s=>s.trim());if(cols.length<6)continue;const name=cols[nameIdx],date=cols[dateIdx],amount=parseFloat(cols[amtIdx]),rate=parseFloat(cols[rateIdx]),periods=parseInt(cols[periodsIdx]),method=methodMap[cols[methodIdx]];if(!name||!date||isNaN(amount)||isNaN(rate)||isNaN(periods)||!method){failed++;continue;}const schedule=calcSchedule(amount,rate,periods,method,date);let paid=[];if(paidIdx>=0&&cols[paidIdx]){const paidStr=cols[paidIdx].trim();const m=paidStr.match(/^(\d+)\//);if(m){const paidCount=parseInt(m[1]);for(let p=1;p<=Math.min(paidCount,periods);p++)paid.push(p);}}const loan={name,date,amount,rate,periods,method,purpose:'',schedule,paid};const saved=await saveLoanToServer(loan);if(saved)loans.push(saved);else failed++;imported++;}if(imported>0){$('importSuccess').style.display='';$('importSuccess').textContent=`成功导入 ${imported} 条${failed?`,失败 ${failed} 条`:''}`;renderAll();}else{$('importError').textContent='导入失败,请检查文件格式';$('importError').style.display='';}}
|
||||
function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('show');}
|
||||
document.addEventListener('click',function(){$('userMenu').classList.remove('show');});
|
||||
function showChangeNick(e){if(e)e.preventDefault();$('userMenu').classList.remove('show');$('newNick').value='';$('nickError').style.display='none';$('nickSuccess').style.display='none';$('changeNickModal').classList.add('show');}
|
||||
function closeChangeNick(){$('changeNickModal').classList.remove('show');}
|
||||
async function doChangeNick(){const nick=$('newNick').value.trim();if(!nick)return $('nickError').textContent='请输入昵称';$('nickError').style.display='';$('nickError').style.display='none';const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({nickname:nick})});if(res&&res.ok){$('nickSuccess').style.display='';$('nickSuccess').textContent='昵称修改成功';currentUser.nickname=nick;$('userInfo').textContent=nick||currentUser.account;}else{const d=await res.json();$('nickError').textContent=d.detail||'修改失败';$('nickError').style.display='';}}
|
||||
function showChangePwd(e){if(e)e.preventDefault();$('userMenu').classList.remove('show');$('oldPwd').value='';$('newPwd').value='';$('newPwd2').value='';$('pwdError').style.display='none';$('pwdSuccess').style.display='none';$('changePwdModal').classList.add('show');}
|
||||
function closeChangePwd(){$('changePwdModal').classList.remove('show');}
|
||||
async function doChangePwd(){const old=$('oldPwd').value,nw=$('newPwd').value,nw2=$('newPwd2').value;if(!old||!nw)return $('pwdError').textContent='请填写所有字段';$('pwdError').style.display='';if(nw!==nw2){$('pwdError').textContent='两次新密码不一致';$('pwdError').style.display='';return;}$('pwdError').style.display='none';const res=await apiFetch('/auth/password',{method:'PUT',body:JSON.stringify({old_password:old,new_password:nw})});if(res&&res.ok){$('pwdSuccess').style.display='';$('pwdSuccess').textContent='密码修改成功';$('oldPwd').value='';$('newPwd').value='';$('newPwd2').value='';}else{const d=await res.json();$('pwdError').textContent=d.detail||'修改失败';$('pwdError').style.display='';}}
|
||||
|
||||
async function doBindEmail(){
|
||||
const email=$('bindEmail').value.trim();
|
||||
if(!email||!email.includes('@'))return $('bindEmailError').textContent='请输入有效邮箱地址';$('bindEmailError').style.display='';
|
||||
$('bindEmailError').style.display='none';
|
||||
const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({real_email:email})});
|
||||
if(res&&res.ok){currentUser.real_email=email;$('emailModal').classList.remove('show');showToast('邮箱绑定成功','success');}
|
||||
else{const d=await res.json();$('bindEmailError').textContent=d.detail||'绑定失败';$('bindEmailError').style.display='';}
|
||||
}
|
||||
|
||||
let fpAccount='';
|
||||
function showForgotStep1(){$('fpAccount').value='';$('fpStep1Error').style.display='none';$('forgotStep1').classList.add('show');}
|
||||
function closeForgotModals(){$('forgotStep1').classList.remove('show');$('forgotStep2').classList.remove('show');$('forgotStep3').classList.remove('show');}
|
||||
async function doForgotStep1(){
|
||||
const account=$('fpAccount').value.trim();
|
||||
if(!account){$('fpStep1Error').textContent='请输入账号或邮箱';$('fpStep1Error').style.display='';return;}
|
||||
$('fpStep1Error').style.display='none';
|
||||
const res=await fetch(API+'/auth/forgot-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account})});
|
||||
const data=await res.json();
|
||||
fpAccount=account;
|
||||
$('forgotStep1').classList.remove('show');
|
||||
$('fpCode').value='';$('fpStep2Error').style.display='none';
|
||||
$('forgotStep2').classList.add('show');
|
||||
showToast(data.message||'验证码已发送','success');
|
||||
}
|
||||
async function doForgotStep2(){
|
||||
const code=$('fpCode').value.trim();
|
||||
if(!code||code.length!==6){$('fpStep2Error').textContent='请输入 6 位验证码';$('fpStep2Error').style.display='';return;}
|
||||
$('fpStep2Error').style.display='none';
|
||||
const res=await fetch(API+'/auth/verify-reset-code',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:fpAccount,code})});
|
||||
if(res.ok){
|
||||
$('forgotStep2').classList.remove('show');
|
||||
$('fpNewPwd').value='';$('fpNewPwd2').value='';$('fpStep3Error').style.display='none';
|
||||
$('forgotStep3').classList.add('show');
|
||||
}else{
|
||||
const d=await res.json();
|
||||
$('fpStep2Error').textContent=d.detail||'验证码错误';$('fpStep2Error').style.display='';
|
||||
}
|
||||
}
|
||||
async function doForgotStep3(){
|
||||
const nw=$('fpNewPwd').value,nw2=$('fpNewPwd2').value;
|
||||
if(!nw){$('fpStep3Error').textContent='请输入新密码';$('fpStep3Error').style.display='';return;}
|
||||
if(nw!==nw2){$('fpStep3Error').textContent='两次密码不一致';$('fpStep3Error').style.display='';return;}
|
||||
$('fpStep3Error').style.display='none';
|
||||
const code=$('fpCode').value.trim();
|
||||
const res=await fetch(API+'/auth/reset-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:fpAccount,code,new_password:nw})});
|
||||
if(res.ok){
|
||||
closeForgotModals();
|
||||
showToast('密码重置成功,请重新登录','success');
|
||||
}else{
|
||||
const d=await res.json();
|
||||
$('fpStep3Error').textContent=d.detail||'重置失败';$('fpStep3Error').style.display='';
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth().then(ok=>{if(ok)loadLoans();});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
113
frontend/reset-password.html
Normal file
113
frontend/reset-password.html
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>重置密码</title>
|
||||
<style>
|
||||
:root{--bg:#f5f5f7;--card:rgba(255,255,255,.72);--border:rgba(0,0,0,.06);--text:#1d1d1f;--text2:#6e6e73;--blue:#0071e3;--green:#34c759;--red:#ff3b30;--radius:20px;--radius-sm:10px;--radius-xs:8px}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;-webkit-font-smoothing:antialiased}
|
||||
.auth-card{background:var(--card);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-radius:var(--radius);padding:48px 44px;width:400px;border:1px solid var(--border);box-shadow:0 16px 48px rgba(0,0,0,.08);animation:fadeIn .3s ease-out}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||||
.auth-card h2{text-align:center;margin-bottom:32px;color:var(--text);font-size:22px;font-weight:700;letter-spacing:-.3px}
|
||||
.auth-card .form-row{margin-bottom:18px}
|
||||
.auth-card .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px}
|
||||
.auth-card .form-row input{width:100%;padding:12px 16px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:15px;outline:none;transition:all .2s;background:#fff;color:var(--text)}
|
||||
.auth-card .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
|
||||
.btn{width:100%;padding:12px 0;border:none;border-radius:var(--radius-xs);font-size:15px;font-weight:600;cursor:pointer;transition:all .2s;font-family:inherit;background:var(--blue);color:#fff}
|
||||
.btn:hover{background:#0077ed}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.error-msg{display:none;font-size:12px;margin-top:12px;text-align:center;padding:10px 14px;border-radius:var(--radius-xs);background:rgba(255,59,48,.06);backdrop-filter:blur(20px);color:var(--red);border:1px solid rgba(255,59,48,.12);line-height:1.5}
|
||||
.error-msg.show{display:flex;align-items:center;justify-content:center;gap:6px}
|
||||
.success-msg{display:none;font-size:13px;margin-top:12px;text-align:center;padding:12px;border-radius:var(--radius-xs);background:rgba(52,199,89,.06);color:var(--green)}
|
||||
.success-msg.show{display:block}
|
||||
.loading{display:none;text-align:center;padding:40px;color:var(--text2);font-size:14px}
|
||||
.loading.show{display:block}
|
||||
.invalid{display:none;text-align:center;padding:40px;color:var(--text2);font-size:14px}
|
||||
.invalid.show{display:block}
|
||||
.links{text-align:center;margin-top:18px}
|
||||
.links a{color:var(--blue);text-decoration:none;font-size:13px;font-weight:500}
|
||||
.links a:hover{text-decoration:underline}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-card" id="card">
|
||||
<h2>重置密码</h2>
|
||||
<div class="loading" id="loading">验证链接中...</div>
|
||||
<div class="invalid" id="invalid" style="display:none">
|
||||
<p style="font-size:24px;margin-bottom:12px">❌</p>
|
||||
<p id="invalidMsg">链接无效或已过期</p>
|
||||
<div class="links" style="margin-top:20px"><a href="/">返回登录</a></div>
|
||||
</div>
|
||||
<div id="resetForm" style="display:none">
|
||||
<p style="font-size:13px;color:var(--text2);margin-bottom:18px;text-align:center">Hi <strong id="userName"></strong>,请设置新密码。</p>
|
||||
<div class="form-row"><label>新密码</label><input type="password" id="newPwd" placeholder="请输入新密码"></div>
|
||||
<div class="form-row"><label>确认新密码</label><input type="password" id="newPwd2" placeholder="请再次输入新密码" onkeydown="if(event.key==='Enter')doReset()"></div>
|
||||
<div id="resetError" class="error-msg"><span>⚠</span><span id="resetErrorText"></span></div>
|
||||
<div id="resetSuccess" class="success-msg"></div>
|
||||
<button class="btn" id="resetBtn" onclick="doReset()">确认重置</button>
|
||||
<div class="links"><a href="/">返回登录</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const API='/api/v1';
|
||||
const $=id=>document.getElementById(id);
|
||||
const params=new URLSearchParams(window.location.search);
|
||||
const token=params.get('token');
|
||||
const uid=params.get('uid');
|
||||
|
||||
function showError(msg){$('resetErrorText').textContent=msg;$('resetError').classList.add('show');}
|
||||
function hideError(){$('resetError').classList.remove('show');$('resetErrorText').textContent='';}
|
||||
|
||||
async function init(){
|
||||
if(!token||!uid){$('loading').style.display='none';$('invalid').style.display='';$('invalidMsg').textContent='链接参数缺失';return;}
|
||||
try{
|
||||
const res=await fetch(API+'/auth/check-reset-token?token='+encodeURIComponent(token)+'&uid='+uid);
|
||||
if(res.ok){
|
||||
const data=await res.json();
|
||||
$('userName').textContent=data.nickname;
|
||||
$('loading').style.display='none';
|
||||
$('resetForm').style.display='';
|
||||
}else{
|
||||
const data=await res.json().catch(()=>({}));
|
||||
$('loading').style.display='none';
|
||||
$('invalid').style.display='';
|
||||
$('invalidMsg').textContent=data.detail||'链接无效';
|
||||
}
|
||||
}catch(e){
|
||||
$('loading').style.display='none';
|
||||
$('invalid').style.display='';
|
||||
$('invalidMsg').textContent='网络异常,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
async function doReset(){
|
||||
const nw=$('newPwd').value,nw2=$('newPwd2').value;
|
||||
if(!nw){showError('请输入新密码');return;}
|
||||
if(nw.length<6){showError('密码至少6位');return;}
|
||||
if(nw!==nw2){showError('两次密码不一致');return;}
|
||||
hideError();
|
||||
$('resetBtn').disabled=true;$('resetBtn').textContent='重置中...';
|
||||
try{
|
||||
const res=await fetch(API+'/auth/reset-by-token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,uid:+uid,new_password:nw})});
|
||||
if(res.ok){
|
||||
$('resetForm').style.display='none';
|
||||
$('resetSuccess').style.display='';
|
||||
$('resetSuccess').textContent='密码重置成功!';
|
||||
setTimeout(()=>{window.location.href='/';},2000);
|
||||
}else{
|
||||
const data=await res.json().catch(()=>({}));
|
||||
showError(data.detail||'重置失败');
|
||||
$('resetBtn').disabled=false;$('resetBtn').textContent='确认重置';
|
||||
}
|
||||
}catch(e){
|
||||
showError('网络异常,请稍后重试');
|
||||
$('resetBtn').disabled=false;$('resetBtn').textContent='确认重置';
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
47
restore.sh
Executable file
47
restore.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# 债务管理系统 - 一键部署脚本
|
||||
# 用法: bash restore.sh
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=============================="
|
||||
echo " 债务管理系统 - 离线部署"
|
||||
echo "=============================="
|
||||
echo ""
|
||||
|
||||
echo "[1/4] 加载 Docker 镜像..."
|
||||
for img in postgres-15-alpine.tar debt-manager-backend.tar debt-manager-frontend.tar; do
|
||||
if [ -f "$img" ]; then
|
||||
echo " 加载 $img ..."
|
||||
docker load -i "$img"
|
||||
else
|
||||
echo " 跳过 $img (文件不存在)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[2/4] 启动数据库..."
|
||||
docker compose up -d db
|
||||
echo " 等待数据库就绪..."
|
||||
sleep 15
|
||||
|
||||
echo "[3/4] 恢复数据..."
|
||||
if [ -f "data.sql" ]; then
|
||||
docker compose exec -T db psql -U postgres debt_manager < data.sql
|
||||
echo " 数据恢复完成"
|
||||
else
|
||||
echo " 跳过数据恢复 (无 data.sql)"
|
||||
fi
|
||||
|
||||
echo "[4/4] 启动所有服务..."
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo "=============================="
|
||||
echo " 部署完成!"
|
||||
echo "=============================="
|
||||
echo ""
|
||||
echo " 前台: http://localhost:806"
|
||||
echo " 后台: http://localhost:806/admin.html"
|
||||
echo " 管理员: admin / admin123"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user