28 Commits
v2.2 ... v2.4.1

Author SHA1 Message Date
OP
a0c57e0f21 v2.4.1: 修复 2FA 登录和前端数据加载问题
- 修复登录接口 response_model 导致 2FA 返回格式验证失败(500错误)
- 修复 showProfile 未调用 loadTwofaStatus 导致 2FA 状态一直显示加载中
- 修复 hideOthers 默认值为 false 确保管理员可查看所有数据
- 删除重复的 doLogin 函数
- 删除残留的代码片段修复 JavaScript 语法错误
2026-07-02 18:04:39 +08:00
OP
056c4defec v2.4: 响应式布局 + 借款按钮优化
- 所有页面添加响应式CSS,支持手机/电脑自适应
- index.html: 导航栏active状态、表单、统计、借款列表响应式
- analysis.html: 图表、统计卡片响应式
- admin.html: 表格、搜索栏响应式
- reset-password.html: 登录卡片响应式
- 修复导航栏active类:analysis.html的分析标签高亮
- 非本人借款:隐藏修改按钮,快速还款改为快速查看
- 修复main.py重复函数
- 添加debt-manager.sh管理脚本
2026-07-01 22:50:09 +08:00
OP
f18cdf5249 fix: 导航栏三栏布局,首页/分析居中
- header-inner 使用 position:relative
- header-center 使用 position:absolute + left:50% + transform
- 左栏:系统名称
- 中栏:首页/分析(居中)
- 右栏:用户相关信息
2026-06-29 01:11:12 +08:00
OP
1f8321c74e style: 导航栏改为三栏布局,首页/分析居中
- 使用 CSS Grid: grid-template-columns: 1fr auto 1fr
- 左栏:系统名称(左对齐)
- 中栏:首页/分析(居中)
- 右栏:用户相关信息(右对齐)
- 三栏宽度与页面内容一致
2026-06-29 01:07:45 +08:00
OP
6696e03910 fix: 修复 purposeIdx 未定义导致导入失败
- doImport 中 purposeIdx 变量未定义
- 添加 purposeIdx=header.indexOf('用途') 定义
- 现在导入功能正常工作
2026-06-29 01:01:04 +08:00
OP
cab06fb77f fix: 修复导入列名与导出不匹配
- 导入时年利率(%) → 年化率(%)
- 现在导出再导入数据可以正常匹配
2026-06-29 00:56:59 +08:00
OP
00d1b96415 fix: 导出数据按用户权限过滤
- 普通用户只能导出自己的数据
- 管理员可以导出所有用户数据
- 添加 getVisibleLoans() 函数统一过滤逻辑
2026-06-29 00:50:20 +08:00
OP
e5acd0115e fix: 修复导出列顺序,用途移到最后一列
- CSV/JSON/HTML 导出:用途列移到最后
- 下载模板示例数据同步更新
- 导入时正确对应各列数据
2026-06-29 00:45:16 +08:00
OP
7a9c19e5c5 fix: 更新导出导入字段名
- 借款人 → 债权人
- 年利率 → 年化率
- 新增「用途」列
- 更新下载模板示例数据
- 导入时读取用途字段
2026-06-29 00:41:36 +08:00
OP
1aac03b7a4 feat: 新增个人中心功能
- 修改昵称改为个人中心
- 个人中心包含:账号(只读)、昵称、邮箱
- 修改邮箱需要旧邮箱验证码验证
- 后端新增 /auth/verify-email 和 /auth/change-email API
- 两个页面同步更新
2026-06-29 00:35:47 +08:00
OP
b9b7e2637a fix: analysis.html 同步用户菜单完整功能
- 添加修改昵称/修改密码模态框
- 添加 showChangeNick/showChangePwd/doChangeNick/doChangePwd 函数
- 添加 modal CSS 样式
- 两个页面现在功能完全一致
2026-06-29 00:31:49 +08:00
OP
c5697f8b7b fix: 恢复用户菜单(修改昵称/修改密码)
- userInfo 添加 onclick 触发用户菜单
- 添加 userMenu 下拉菜单(修改昵称/修改密码)
- 点击页面其他位置关闭菜单
2026-06-29 00:28:42 +08:00
OP
5d5ee9ecdc fix: analysis.html 添加用户昵称显示
- checkAuth 中添加 .textContent 设置
- 分析页面导航栏现在显示用户昵称
2026-06-29 00:25:43 +08:00
OP
d27c855e62 fix: 删除 analysis.html 重复的导出/导入/退出 2026-06-29 00:24:03 +08:00
OP
bc5ce695d2 fix: 删除重复的导出/导入/退出元素
- 之前正则替换失败导致两套 header 元素
- 手动删除重复的 export/import/退出
- 现在只有一套正确的导航栏
2026-06-29 00:22:13 +08:00
OP
018913f337 fix: 恢复 Image 2 的正确导航栏样式
- 恢复原来的 header-inner 布局(space-between)
- 左侧:标题 + 导航标签
- 右侧:通知铃铛 + 用户名 + 管理 + 导出 + 导入 + 退出
- 两个页面样式完全一致
2026-06-29 00:18:58 +08:00
OP
3da97f7dd5 fix: 修复导航栏布局,元素不再换行
- header-inner 改用 gap 布局,移除 space-between
- max-width 从 1200px 增加到 1400px
- 所有子元素设置 flex-shrink:0
- 三个区域在一行内显示
2026-06-29 00:14:57 +08:00
OP
27ad9b6901 feat: 恢复导航栏完整功能(通知/导出/导入)
- 两个页面 header 统一添加:通知铃铛、导出按钮、导入按钮、用户信息
- 添加通知相关 JS 函数到 analysis.html
- 添加导出/导入相关 JS 函数到 analysis.html
- 导航栏布局:Logo + 标题 | 首页/分析 | 用户名+通知+导出+导入+管理+退出
2026-06-29 00:10:20 +08:00
OP
1aa2f529e7 fix: Chart.js 改为本地加载,修复分析页面无数据
- 下载 chart.min.js 到本地(200KB)
- analysis.html 引用改为 /chart.min.js
- Dockerfile 添加 chart.min.js 复制
- 避免 CDN 访问问题导致图表无法渲染
2026-06-29 00:07:36 +08:00
OP
d183ca9207 fix: 添加 userInfo 元素到 header,修复首页数据加载
- 之前移除 header 时误删了 userInfo span
- JS 中 .textContent 会报错导致页面无法加载
2026-06-29 00:04:32 +08:00
OP
f69c8bf510 fix: 统一首页和分析页面导航栏为三栏式布局
- 左侧:Logo + 系统名称
- 中间:首页/分析 导航
- 右侧:管理/退出
- 两个页面 header HTML 和 CSS 完全一致
2026-06-28 23:56:11 +08:00
OP
156bcb9505 fix: 同步 analysis.html 导航栏为三栏式布局
- 添加 header-left (Logo + 系统名称)
- 更新 CSS 为三栏式布局
- 现在两个页面导航栏完全一致
2026-06-28 23:50:24 +08:00
OP
8379126f9d style: 优化导航栏布局为三栏式
- 左侧:Logo图标 + 系统名称「债务管理」
- 中间:页面导航(首页/分析)
- 右侧:用户信息和操作按钮
- 增加间距和圆角,视觉更舒适
- header 高度从52px增加到56px
- 导航按钮增加 hover 效果
- 用户名区域增加浅色背景
2026-06-28 23:48:33 +08:00
OP
664b845cef fix: 以 analysis.html 为准统一导航栏样式
- 移除 index.html 多余的 -webkit-backdrop-filter 和 padding:0
- 移除 .hdr-btn 样式(analysis.html 无此样式)
- 两个页面 header CSS 现在完全一致
2026-06-28 23:46:16 +08:00
OP
012de577d7 fix: 统一首页和分析页面的导航栏样式
- analysis.html header-inner 改用 gap 布局
- 添加 white-space:nowrap 防止标题换行
- 添加 flex-shrink:0 防止元素被挤压
- 两个页面的导航栏现在完全一致
2026-06-28 23:43:06 +08:00
OP
2d91e73063 fix: 修复导航栏布局,确保导航可见
- header-inner 改用 gap 替代 space-between
- h1/nav-tabs/header-right 都设置 flex-shrink:0
- 确保三个元素不会被挤压
2026-06-28 23:41:25 +08:00
OP
1bb826e7ac feat: 新增数据分析页面
- 添加导航栏:首页(原有功能)+ 分析(新页面)
- 分析页面包含 5 个专业图表:
  - 借款总额 vs 已还总额(环形图)
  - 债权人分布(柱状图)
  - 还款方式分布(环形图)
  - 利率分布(柱状图)
  - 月度还款计划(柱状图)
- 使用 Chart.js 图表库
- 首页和分析页面共享导航栏样式
2026-06-28 23:38:47 +08:00
OP
a49a5a7cff build: 添加 x86 和 arm64 双架构部署包
- 构建 x86 (linux/amd64) backend 和 frontend 镜像
- 导出 arm64 和 x86 两种架构的 tar.gz 镜像包
- 添加 docker-compose.x86.yml 和 docker-compose.arm64.yml
- install.sh 自动检测架构并加载对应镜像
- 部署包约 200MB (backend 75M x2 + frontend 24M x2 + postgres 106M)
2026-06-28 23:07:06 +08:00
16 changed files with 1855 additions and 96 deletions

View File

@@ -35,7 +35,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
return RegisterResponse(message="注册请求已提交,等待管理员审批") return RegisterResponse(message="注册请求已提交,等待管理员审批")
@router.post("/login", response_model=TokenResponse) @router.post("/login")
async def login( async def login(
req: LoginRequest, req: LoginRequest,
request: Request, request: Request,
@@ -81,6 +81,17 @@ async def login(
if user.status == "disabled": if user.status == "disabled":
raise HTTPException(status_code=403, detail="账户已被禁用") raise HTTPException(status_code=403, detail="账户已被禁用")
clear_login_failures(ip, account) clear_login_failures(ip, account)
# 检查是否启用 2FA
if user.two_factor_enabled and user.two_factor_secret:
# 生成临时 token等待 2FA 验证
from app.core.security import create_token
temp_token = create_token(
{"sub": str(user.id), "type": "2fa_pending"},
timedelta(minutes=5)
)
return {"require_2fa": True, "temp_token": temp_token}
return TokenResponse( return TokenResponse(
access_token=create_access_token(user.id), access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id), refresh_token=create_refresh_token(user.id),
@@ -129,6 +140,14 @@ async def list_groups(db: AsyncSession = Depends(get_db)):
return [{"id": g.id, "name": g.name} for g in result.scalars().all()] return [{"id": g.id, "name": g.name} for g in result.scalars().all()]
class EmailVerifyRequest(BaseModel):
email: str
class EmailChangeRequest(BaseModel):
code: str
new_email: str
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
nickname: str | None = None nickname: str | None = None
real_email: str | None = None real_email: str | None = None
@@ -148,6 +167,83 @@ async def update_profile(
return {"message": "资料更新成功"} return {"message": "资料更新成功"}
@router.post("/verify-email")
async def send_email_verification(
req: EmailVerifyRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
import secrets
from datetime import timedelta
code = f"{secrets.randbelow(1000000):06d}"
key = f"email_verify:{user.id}"
value = f"{req.email}|{code}|{(datetime.utcnow() + timedelta(minutes=5)).isoformat()}"
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()
if user.real_email:
from app.services.email_service import send_email
html = 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 {user.nickname or user.email}</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">5 分钟内有效</p>
</div>
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件。</p>
</div>
"""
await send_email(user.real_email, "【债务管理系统】邮箱变更验证码", html)
return {"message": "验证码已发送到当前邮箱"}
@router.post("/change-email")
async def change_email(
req: EmailChangeRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
key = f"email_verify:{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) != 3 or parts[1] != req.code:
raise HTTPException(status_code=400, detail="验证码错误")
try:
expires_at = datetime.fromisoformat(parts[2])
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="验证码已过期,请重新获取")
# Check if new email is already used
existing = await db.execute(select(User).where(User.real_email == req.new_email, User.id != user.id))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该邮箱已被其他账号使用")
user.real_email = req.new_email
await db.delete(setting)
await db.commit()
return {"message": "邮箱修改成功"}
@router.put("/password") @router.put("/password")
async def change_password( async def change_password(
req: ChangePasswordRequest, req: ChangePasswordRequest,

153
backend/app/api/twofa.py Normal file
View File

@@ -0,0 +1,153 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
import pyotp
import qrcode
import io
import base64
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.models import User
router = APIRouter(prefix="/auth/2fa", tags=["双因素认证"])
class TwoFASetupResponse(BaseModel):
secret: str
qr_code: str # base64 encoded QR code image
otpauth_url: str
class TwoFAVerifyRequest(BaseModel):
code: str
class TwoFALoginRequest(BaseModel):
temp_token: str
code: str
@router.post("/setup", response_model=TwoFASetupResponse)
async def setup_2fa(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""生成 2FA 密钥和二维码"""
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
otpauth_url = totp.provisioning_uri(
name=user.email,
issuer_name="债务管理系统"
)
# 生成 QR 码
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(otpauth_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# 转换为 base64
buffer = io.BytesIO()
img.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
# 临时存储 secret验证后才会真正保存
user.two_factor_secret = secret
await db.commit()
return TwoFASetupResponse(
secret=secret,
qr_code=f"data:image/png;base64,{qr_base64}",
otpauth_url=otpauth_url
)
@router.post("/verify")
async def verify_2fa_setup(
req: TwoFAVerifyRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""验证 2FA 设置(首次验证后启用)"""
if not user.two_factor_secret:
raise HTTPException(status_code=400, detail="请先执行 2FA 设置")
totp = pyotp.TOTP(user.two_factor_secret)
if not totp.verify(req.code, valid_window=1):
raise HTTPException(status_code=400, detail="验证码错误")
# 启用 2FA
user.two_factor_enabled = 1
await db.commit()
return {"message": "双因素认证已启用"}
@router.post("/disable")
async def disable_2fa(
req: TwoFAVerifyRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""禁用 2FA需要验证当前验证码"""
if not user.two_factor_enabled:
raise HTTPException(status_code=400, detail="未启用双因素认证")
if not user.two_factor_secret:
raise HTTPException(status_code=400, detail="2FA 配置异常")
totp = pyotp.TOTP(user.two_factor_secret)
if not totp.verify(req.code, valid_window=1):
raise HTTPException(status_code=400, detail="验证码错误")
user.two_factor_secret = None
user.two_factor_enabled = 0
await db.commit()
return {"message": "双因素认证已禁用"}
@router.post("/verify-login")
async def verify_2fa_login(
req: TwoFALoginRequest,
db: AsyncSession = Depends(get_db),
):
"""验证 2FA 登录密码验证后2FA 验证前)"""
from app.core.security import decode_token, create_access_token, create_refresh_token
# 验证临时 token
payload = decode_token(req.temp_token)
if not payload or payload.get("type") != "2fa_pending":
raise HTTPException(status_code=401, detail="无效的验证请求")
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 not user.two_factor_secret:
raise HTTPException(status_code=401, detail="用户状态异常")
# 验证 2FA
totp = pyotp.TOTP(user.two_factor_secret)
if not totp.verify(req.code, valid_window=1):
raise HTTPException(status_code=401, detail="验证码错误")
# 返回正式 token
return {
"access_token": create_access_token(user.id),
"refresh_token": create_refresh_token(user.id),
"token_type": "bearer"
}
@router.get("/status")
async def get_2fa_status(
user: User = Depends(get_current_user),
):
"""获取 2FA 状态"""
return {
"enabled": bool(user.two_factor_enabled),
"has_secret": bool(user.two_factor_secret)
}

View File

@@ -6,7 +6,7 @@ from app.core.config import get_settings
from app.core.database import engine, async_session from app.core.database import engine, async_session
from app.models.models import Base, User, Debt, RepaymentPlan, RepaymentRecord, Group from app.models.models import Base, User, Debt, RepaymentPlan, RepaymentRecord, Group
from app.core.security import hash_password from app.core.security import hash_password
from app.api import auth, debts, repayments, admin, loans, notifications from app.api import auth, debts, repayments, admin, loans, notifications, twofa
from sqlalchemy import select, text from sqlalchemy import select, text
settings = get_settings() settings = get_settings()
@@ -53,9 +53,22 @@ async def migrate_account_names():
async def migrate_add_real_email(): async def migrate_add_real_email():
async with engine.begin() as conn: async with engine.begin() as conn:
try:
await conn.execute(text( await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''" "ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''"
)) ))
except Exception:
pass # 表不存在时跳过,由 seed_admin 创建
async def migrate_add_2fa():
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_secret VARCHAR(32)"
))
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled INTEGER DEFAULT 0"
))
async def migrate_debt_data(): async def migrate_debt_data():
@@ -94,20 +107,11 @@ async def migrate_debt_data():
await db.commit() 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await seed_admin() await seed_admin()
await migrate_add_real_email() await migrate_add_real_email()
await migrate_add_2fa()
await migrate_account_names() await migrate_account_names()
await migrate_debt_data() await migrate_debt_data()
from app.services.scheduler import start_scheduler, check_upcoming_repayments from app.services.scheduler import start_scheduler, check_upcoming_repayments
@@ -149,6 +153,7 @@ app.include_router(debts.router, prefix=settings.API_V1_PREFIX)
app.include_router(repayments.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(admin.router, prefix=settings.API_V1_PREFIX)
app.include_router(notifications.router, prefix=settings.API_V1_PREFIX) app.include_router(notifications.router, prefix=settings.API_V1_PREFIX)
app.include_router(twofa.router, prefix=settings.API_V1_PREFIX)
@app.get("/health") @app.get("/health")

View File

@@ -73,6 +73,8 @@ class User(Base):
status = Column(String(20), default=UserStatus.ACTIVE.value) status = Column(String(20), default=UserStatus.ACTIVE.value)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
created_at = Column(DateTime, default=lambda: datetime.utcnow()) created_at = Column(DateTime, default=lambda: datetime.utcnow())
two_factor_secret = Column(String(32), nullable=True)
two_factor_enabled = Column(Integer, default=0)
group = relationship("Group", back_populates="members") group = relationship("Group", back_populates="members")
debts = relationship("Debt", back_populates="user") debts = relationship("Debt", back_populates="user")

View File

@@ -14,3 +14,6 @@ httpx==0.26.0
python-dateutil==2.8.2 python-dateutil==2.8.2
apscheduler==3.10.4 apscheduler==3.10.4
aiosmtplib==3.0.1 aiosmtplib==3.0.1
pyotp==2.9.0
qrcode==7.4.2
pillow==10.2.0

321
debt-manager.sh Executable file
View File

@@ -0,0 +1,321 @@
#!/bin/bash
# ============================================
# 债务管理系统 - 管理脚本 v2.3
# ============================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
INSTALL_DIR="/opt/debt-manager"
show_logo() {
clear
echo -e "${CYAN}"
echo "╔══════════════════════════════════════════════╗"
echo "║ 债务管理系统 Debt Manager ║"
echo "║ 个人/小团队债务管理平台 ║"
echo "║ 版本: v2.3 ║"
echo "╚══════════════════════════════════════════════╝"
echo -e "${NC}"
}
detect_arch() {
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) ARCH_NAME="x86" ;;
aarch64|arm64) ARCH_NAME="arm64" ;;
*) echo -e "${RED}不支持的架构: $ARCH${NC}"; return 1 ;;
esac
echo -e "${CYAN}架构: $ARCH_NAME${NC}"
}
check_docker() {
if ! command -v docker &> /dev/null; then
echo -e "${RED}请先安装 Docker${NC}"; exit 1
fi
if ! docker compose version &> /dev/null; then
echo -e "${RED}请先安装 Docker Compose${NC}"; exit 1
fi
}
gen_password() { openssl rand -hex 16; }
get_script_dir() {
SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ]; do
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
cd -P "$(dirname "$SOURCE")" && pwd
}
# 检查镜像是否存在
check_images_exist() {
local backend_image="debt-manager-backend:${ARCH_NAME}"
local frontend_image="debt-manager-frontend:${ARCH_NAME}"
local postgres_image="postgres:15-alpine"
local backend_exists=$(docker image inspect "$backend_image" 2>/dev/null && echo "yes" || echo "no")
local frontend_exists=$(docker image inspect "$frontend_image" 2>/dev/null && echo "yes" || echo "no")
local postgres_exists=$(docker image inspect "$postgres_image" 2>/dev/null && echo "yes" || echo "no")
if [ "$backend_exists" = "yes" ] && [ "$frontend_exists" = "yes" ] && [ "$postgres_exists" = "yes" ]; then
return 0
else
return 1
fi
}
# 加载镜像(如果不存在)
load_images_if_needed() {
local script_dir="$1"
if check_images_exist; then
echo -e "${GREEN}镜像已存在,跳过加载${NC}"
return 0
fi
echo -e "${CYAN}加载镜像...${NC}"
local loaded=0
for f in "$script_dir/deploy/"*-${ARCH_NAME}.tar.gz "$script_dir/deploy/postgres"*.tar.gz; do
if [ -f "$f" ]; then
local img_name=$(basename "$f" .tar.gz)
if docker image inspect "$img_name" &>/dev/null; then
echo -e " ${GREEN}${NC} $img_name (已存在)"
else
echo -e " 加载 $img_name ..."
docker load < "$f" 2>/dev/null && loaded=$((loaded+1))
fi
fi
done
if [ $loaded -gt 0 ]; then
echo -e "${GREEN}加载了 $loaded 个镜像${NC}"
fi
}
# 验证所有容器运行状态
verify_containers() {
echo -e "${CYAN}验证容器状态...${NC}"
local all_up=true
for svc in db backend frontend; do
local status=$(docker compose ps --format '{{.Status}}' --filter "name=$svc" 2>/dev/null)
if echo "$status" | grep -q "Up"; then
echo -e " ${GREEN}${NC} $svc: 运行中"
else
echo -e " ${RED}${NC} $svc: 未运行 ($status)"
all_up=false
fi
done
if $all_up; then
echo -e "${GREEN}所有容器运行正常${NC}"
return 0
else
echo -e "${RED}部分容器未正常运行${NC}"
return 1
fi
}
show_menu() {
echo -e "${YELLOW}请选择操作:${NC}"
echo ""
echo " 1) 安装系统"
echo " 2) 更新系统"
echo " 3) 卸载系统"
echo " 4) 重置系统"
echo " 5) 查看状态"
echo " 0) 退出"
echo ""
read -p "请输入选项 [0-5]: " choice
}
install_system() {
echo -e "${CYAN}═══ 安装债务管理系统 ═══${NC}"
if [ -d "$INSTALL_DIR" ]; then
echo -e "${YELLOW}系统已安装,是否覆盖? (y/N)${NC}"
read -p "> " confirm
[ "$confirm" != "y" ] && return
cd "$INSTALL_DIR" && docker compose down 2>/dev/null || true
fi
detect_arch || return
SCRIPT_DIR="$(get_script_dir)"
mkdir -p "$INSTALL_DIR" && cd "$INSTALL_DIR"
# 复制 docker-compose 文件
if [ -f "$SCRIPT_DIR/deploy/docker-compose.${ARCH_NAME}.yml" ]; then
cp "$SCRIPT_DIR/deploy/docker-compose.${ARCH_NAME}.yml" docker-compose.yml
elif [ -f "$SCRIPT_DIR/docker-compose.yml" ]; then
cp "$SCRIPT_DIR/docker-compose.yml" docker-compose.yml
else
echo -e "${RED}找不到 docker-compose 配置文件${NC}"; return 1
fi
# 复制镜像文件
if [ -d "$SCRIPT_DIR/deploy" ]; then
for f in "$SCRIPT_DIR/deploy/"*-${ARCH_NAME}.tar.gz "$SCRIPT_DIR/deploy/postgres"*.tar.gz; do
[ -f "$f" ] && cp "$f" .
done
fi
[ -f "$SCRIPT_DIR/restore.sh" ] && cp "$SCRIPT_DIR/restore.sh" . && chmod +x restore.sh
# 生成密钥
DB_PASS=$(gen_password)
SECRET=$(gen_password)
cat > .env << EOF
POSTGRES_DB=debt_manager
POSTGRES_USER=postgres
POSTGRES_PASSWORD=$DB_PASS
DATABASE_URL=postgresql+asyncpg://postgres:$DB_PASS@db:5432/debt_manager
SECRET_KEY=$SECRET
EOF
# 检查并加载镜像
load_images_if_needed "$SCRIPT_DIR"
# 启动
echo -e "${CYAN}启动服务...${NC}"
docker compose up -d
echo -e "${CYAN}等待服务启动...${NC}"
sleep 10
# 验证容器
if verify_containers; then
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
echo ""
echo -e "${GREEN}═══ 安装成功! ═══${NC}"
echo -e " 前台: ${CYAN}http://$IP:806${NC}"
echo -e " 后台: ${CYAN}http://$IP:806/admin.html${NC}"
echo -e " 管理员: ${CYAN}admin / admin123${NC}"
echo -e " ${YELLOW}请立即修改默认密码!${NC}"
else
echo -e "${RED}启动失败,查看日志:${NC}"
docker compose logs --tail=30
fi
}
update_system() {
echo -e "${CYAN}═══ 更新债务管理系统 ═══${NC}"
[ ! -d "$INSTALL_DIR" ] && { echo -e "${YELLOW}系统未安装${NC}"; return; }
cd "$INSTALL_DIR"
detect_arch || return
SCRIPT_DIR="$(get_script_dir)"
# 备份
echo -e "${CYAN}备份数据库...${NC}"
docker compose exec -T db pg_dump -U postgres debt_manager > "backup_$(date +%Y%m%d_%H%M%S).sql" 2>/dev/null || true
echo -e "${GREEN}备份完成${NC}"
# 更新文件
[ -f "$SCRIPT_DIR/deploy/docker-compose.${ARCH_NAME}.yml" ] && cp "$SCRIPT_DIR/deploy/docker-compose.${ARCH_NAME}.yml" docker-compose.yml
if [ -d "$SCRIPT_DIR/deploy" ]; then
for f in "$SCRIPT_DIR/deploy/"*-${ARCH_NAME}.tar.gz "$SCRIPT_DIR/deploy/postgres"*.tar.gz; do
[ -f "$f" ] && cp "$f" .
done
fi
# 检查并加载新镜像
load_images_if_needed "$SCRIPT_DIR"
echo -e "${CYAN}重启服务...${NC}"
docker compose down && docker compose up -d
echo -e "${CYAN}等待服务启动...${NC}"
sleep 8
if verify_containers; then
echo -e "${GREEN}更新完成!${NC}"
else
echo -e "${RED}更新失败,查看日志:${NC}"
docker compose logs --tail=30
fi
}
uninstall_system() {
echo -e "${CYAN}═══ 卸载债务管理系统 ═══${NC}"
[ ! -d "$INSTALL_DIR" ] && { echo -e "${YELLOW}系统未安装${NC}"; return; }
echo -e "${RED}警告: 将删除所有数据,包括数据库!${NC}"
read -p "输入 YES 确认: " confirm
[ "$confirm" != "YES" ] && { echo -e "${RED}已取消${NC}"; return; }
cd "$INSTALL_DIR" && docker compose down -v 2>/dev/null || true
rm -rf "$INSTALL_DIR"
echo -e "${GREEN}卸载完成${NC}"
}
reset_system() {
echo -e "${CYAN}═══ 重置债务管理系统 ═══${NC}"
[ ! -d "$INSTALL_DIR" ] && { echo -e "${RED}系统未安装${NC}"; return; }
echo -e "${RED}警告: 将清空所有数据!${NC}"
read -p "输入 YES 确认: " confirm
[ "$confirm" != "YES" ] && { echo -e "${RED}已取消${NC}"; return; }
cd "$INSTALL_DIR"
echo -e "${CYAN}备份数据...${NC}"
docker compose exec -T db pg_dump -U postgres debt_manager > "pre_reset_$(date +%Y%m%d_%H%M%S).sql" 2>/dev/null || true
docker compose down -v 2>/dev/null || true
docker volume rm debt-manager_pgdata 2>/dev/null || true
DB_PASS=$(gen_password)
SECRET=$(gen_password)
cat > .env << EOF
POSTGRES_DB=debt_manager
POSTGRES_USER=postgres
POSTGRES_PASSWORD=$DB_PASS
DATABASE_URL=postgresql+asyncpg://postgres:$DB_PASS@db:5432/debt_manager
SECRET_KEY=$SECRET
EOF
docker compose up -d
echo -e "${CYAN}等待服务启动...${NC}"
sleep 10
if verify_containers; then
echo -e "${GREEN}重置完成!${NC}"
echo -e " 管理员: ${CYAN}admin / admin123${NC}"
else
echo -e "${RED}重置失败,查看日志:${NC}"
docker compose logs --tail=30
fi
}
show_status() {
[ ! -d "$INSTALL_DIR" ] && { echo -e "${YELLOW}系统未安装${NC}"; return; }
cd "$INSTALL_DIR"
echo -e "${CYAN}═══ 服务状态 ═══${NC}"
docker compose ps
echo ""
echo -e "${CYAN}磁盘使用:${NC}"
du -sh "$INSTALL_DIR" 2>/dev/null || true
}
main() {
show_logo
check_docker
while true; do
show_menu
case $choice in
1) install_system ;;
2) update_system ;;
3) uninstall_system ;;
4) reset_system ;;
5) show_status ;;
0) echo -e "${GREEN}再见!${NC}"; exit 0 ;;
*) echo -e "${RED}无效选项${NC}" ;;
esac
echo ""
read -p "按回车继续..." _
done
}
main

View File

@@ -0,0 +1,37 @@
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:
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
frontend:
image: debt-manager-frontend:latest
ports:
- "806:80"
depends_on:
- backend
volumes:
pgdata:

View File

@@ -0,0 +1,37 @@
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:
image: debt-manager-backend:x86
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:
image: debt-manager-frontend:x86
ports:
- "806:80"
depends_on:
- backend
volumes:
pgdata:

View File

@@ -1,39 +1,92 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "========================================="
echo " 债务管理系统 - 一键安装"
echo "========================================="
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_DIR="/opt/debt-manager"
echo "=========================================="
echo " 债务管理系统 v2.3 安装脚本"
echo "=========================================="
echo ""
# 检测架构
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64)
ARCH_NAME="x86"
BACKEND_TAR="debt-manager-backend-x86.tar.gz"
FRONTEND_TAR="debt-manager-frontend-x86.tar.gz"
;;
aarch64|arm64)
ARCH_NAME="arm64"
BACKEND_TAR="debt-manager-backend-arm64.tar.gz"
FRONTEND_TAR="debt-manager-frontend-arm64.tar.gz"
;;
*)
echo "不支持的架构: $ARCH"
exit 1
;;
esac
echo "检测到架构: $ARCH_NAME ($ARCH)"
echo ""
# 检查 Docker
if ! command -v docker &> /dev/null; then
echo "请先安装 Docker"
exit 1
fi
if ! docker compose version &> /dev/null; then
echo "请先安装 Docker Compose"
exit 1
fi
# 创建安装目录
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"
# 复制文件
echo "复制文件..."
cp "$SCRIPT_DIR/docker-compose.yml" .
cp "$SCRIPT_DIR/$BACKEND_TAR" .
cp "$SCRIPT_DIR/$FRONTEND_TAR" .
cp "$SCRIPT_DIR/postgres-15-alpine.tar.gz" .
# 生成安全密钥
echo "生成安全密钥..."
DB_PASSWORD=$(openssl rand -hex 16)
SECRET_KEY=$(openssl rand -hex 32)
# 创建 .env 文件
cat > .env << EOF
POSTGRES_DB=debt_manager
POSTGRES_USER=postgres
POSTGRES_PASSWORD=$DB_PASSWORD
DATABASE_URL=postgresql+asyncpg://postgres:$DB_PASSWORD@db:5432/debt_manager
SECRET_KEY=$SECRET_KEY
EOF
# 更新 docker-compose 使用 .env
sed -i "s/postgres:postgres@db:5432/postgres:\$DB_PASSWORD@db:5432/g" docker-compose.yml 2>/dev/null || true
# 加载镜像
echo "加载 Docker 镜像..."
docker load < "$BACKEND_TAR"
docker load < "$FRONTEND_TAR"
docker load < postgres-15-alpine.tar.gz
echo "" echo ""
echo "[1/3] 加载 Docker 镜像..." echo "=========================================="
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 "=========================================="
echo "" echo ""
echo " 前台地址: http://localhost:806" echo "启动命令: cd $INSTALL_DIR && docker compose up -d"
echo " 后台地址: http://localhost:806/admin.html"
echo " API 文档: http://localhost:8000/docs"
echo "" echo ""
echo " 默认账号: admin / admin123" echo "访问地址:"
echo " 默认账号: test / test123" echo " 前台: http://localhost:806"
echo " 后台: http://localhost:806/admin.html"
echo "" echo ""
echo " 停止服务: docker compose -f $SCRIPT_DIR/docker-compose.yml down" echo "管理员账号: admin"
echo " 查看日志: docker compose -f $SCRIPT_DIR/docker-compose.yml logs -f" echo "管理员密码: admin123"
echo "=========================================" echo ""
echo "请务必修改默认密码!"

View File

@@ -1,22 +1,6 @@
#!/bin/bash #!/bin/bash
set -e echo "停止并删除容器..."
docker compose -f docker-compose.x86.yml down -v 2>/dev/null || true
echo "=========================================" docker compose -f docker-compose.arm64.yml down -v 2>/dev/null || true
echo " 债务管理系统 - 卸载" docker compose down -v 2>/dev/null || true
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 "卸载完成"
echo "========================================="

View File

@@ -2,9 +2,9 @@ services:
db: db:
image: postgres:15-alpine image: postgres:15-alpine
environment: environment:
POSTGRES_DB: debt_manager POSTGRES_DB: ${POSTGRES_DB:-debt_manager}
POSTGRES_USER: postgres POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: b6f1c451ed57041ec9a138a61c03cead POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -19,8 +19,8 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/debt_manager DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://postgres:postgres@db:5432/debt_manager}
SECRET_KEY: 562fa55e5e0cea1e00ff645be1da4f7c932439d97de7ffacd3d95156d9ccd7af SECRET_KEY: ${SECRET_KEY:-change-this-in-production}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -1,3 +1,3 @@
FROM nginx:alpine FROM nginx:alpine
COPY index.html admin.html reset-password.html /usr/share/nginx/html/ COPY index.html admin.html reset-password.html analysis.html chart.min.js /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf

484
frontend/analysis.html Normal file
View File

@@ -0,0 +1,484 @@
<!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>
<script src="/chart.min.js"></script>
<style>
:root{--bg:#f5f5f7;--card:rgba(255,255,255,.8);--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","PingFang SC",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:14px}
.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}
.header-inner{max-width:1200px;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-center{position:absolute;left:50%;transform:translateX(-50%)}
.nav-tabs{display:flex;gap:4px;background:rgba(0,0,0,.04);border-radius:var(--radius-xs);padding:3px}
.nav-tab{padding:6px 16px;border-radius:6px;font-size:13px;font-weight:500;color:var(--text2);text-decoration:none;transition:all .2s}
.nav-tab:hover{color:var(--text)}
.nav-tab.active{background:#fff;color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.08)}
.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)}
.notif-wrap{position:relative}
.notif-bell{cursor:pointer;padding:4px 8px;border-radius:var(--radius-xs);transition:all .2s;color:var(--text2)}
.notif-bell:hover{background:rgba(0,0,0,.04);color:var(--text)}
.notif-badge{position:absolute;top:2px;right:2px;min-width:14px;height:14px;background:var(--red);color:#fff;border-radius:7px;font-size:9px;font-weight:600;display:flex;align-items:center;justify-content:center;padding:0 3px}
.notif-dropdown{display:none;position:absolute;right:0;top:calc(100% + 8px);background:rgba(255,255,255,.96);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-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}
.export-wrap{position:relative}
.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:160px;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)}
.user-info{color:var(--text);font-size:13px;font-weight:500;cursor:pointer;padding:6px 14px;border-radius:var(--radius-xs);transition:all .2s;background:rgba(0,0,0,.02)}
.user-info:hover{background:rgba(0,0,0,.06)}
.container{max-width:1120px;margin:0 auto;padding:24px}
.stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}
.stat-card{background:var(--card);border-radius:var(--radius-sm);padding:20px;text-align:center;border:1px solid var(--border)}
.stat-card .lbl{font-size:12px;color:var(--text3);margin-bottom:6px;font-weight:500}
.stat-card .val{font-size:24px;font-weight:700}
.stat-card .val.blue{color:var(--blue)}.stat-card .val.green{color:var(--green)}.stat-card .val.red{color:var(--red)}.stat-card .val.orange{color:var(--orange)}
.charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px}
.chart-card{background:var(--card);border-radius:var(--radius);border:1px solid var(--border);padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.chart-card h3{font-size:14px;font-weight:600;margin-bottom:16px;color:var(--text)}
.chart-wrap{position:relative;height:280px}
.full-width{grid-column:1/-1}
.empty-tip{text-align:center;padding:40px;color:var(--text3);font-size:13px}
@media(max-width:900px){.stats-row{grid-template-columns:repeat(2,1fr)}.charts-grid{grid-template-columns:1fr}}
.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)}
.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)}
.modal h3{margin-bottom:18px;font-size:17px;color:var(--text);font-weight:600}
.modal .form-row{margin-bottom:14px}
.modal .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px}
.modal .form-row input{width:100%;padding:10px 14px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:14px;outline:none}
.modal .form-row input:focus{border-color:var(--blue)}
.modal .btn-row{display:flex;gap:10px;margin-top:18px}
.btn-ghost{background:#fff;color:var(--text2);border:1px solid #d2d2d7;padding:10px 0;border-radius:var(--radius-xs);font-size:14px;cursor:pointer;flex:1;font-family:inherit}
.btn-ghost:hover{background:#f5f5f7}
.btn-main{background:var(--blue);color:#fff;padding:10px 0;border:none;border-radius:var(--radius-xs);font-size:14px;font-weight:500;cursor:pointer;flex:1;font-family:inherit}
.error-msg{color:var(--red);font-size:12px;margin-top:8px;display:none}
.error-msg.show{display:block}
.success-msg{color:var(--green);font-size:13px;margin-top:8px;display:none}
.success-msg.show{display:block}
.profile-section{padding:4px 0}
.profile-section .form-row{margin-bottom:14px}
.profile-section .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--text2)}
.profile-section .form-row input{width:100%;padding:10px 14px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:14px;outline:none;transition:all .2s}
.profile-section .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
.profile-section .form-row input:disabled{background:#f5f5f7;color:var(--text3)}
/* RESPONSIVE - Mobile & Tablet */
@media(max-width:768px){
.header-inner{padding:0 12px;height:48px}
.header h1{font-size:15px}
.header-center{position:static;transform:none}
.nav-tabs{gap:2px;padding:2px}
.nav-tab{padding:5px 12px;font-size:12px}
.header-right{gap:2px}
.header-right a,.hdr-btn{padding:5px 8px;font-size:11px}
.user-info{padding:5px 8px;font-size:12px}
.notif-bell{padding:4px 6px}
.notif-dropdown{width:calc(100vw - 20px);right:-10px}
.main{padding:12px;gap:12px}
.panel-title{padding:12px 14px;font-size:13px}
.panel-body{padding:12px 14px}
.form-grid{grid-template-columns:1fr;gap:10px}
.form-row input,.form-row select{padding:10px 12px;font-size:14px}
.loan-item{padding:8px 14px 8px 28px}
.loan-item .iamt{font-size:12px}
.loan-actions-popup{padding:6px 14px;flex-wrap:wrap}
.stats{grid-template-columns:repeat(2,1fr);gap:8px}
.stat-card{padding:12px}
.stat-card .val{font-size:16px}
.month-header{padding:10px 14px}
.month-header .total{font-size:13px}
.loan-row .right{width:90px}
.loan-row .pay-amt{font-size:13px}
.modal{padding:20px;width:95%;max-width:none}
.modal h3{font-size:16px;margin-bottom:14px}
.auth-card{width:100%;max-width:360px;padding:32px 24px;margin:16px}
.auth-card h2{font-size:20px;margin-bottom:24px}
.btn-row{flex-direction:column;gap:8px}
.btn{padding:12px 0}
.toast{max-width:calc(100vw - 32px);font-size:12px;padding:10px 16px}
}
@media(max-width:480px){
.header-inner{padding:0 10px;height:44px}
.header h1{font-size:14px}
.nav-tab{padding:4px 10px;font-size:11px}
.header-right a,.hdr-btn{padding:4px 6px;font-size:10px}
.main{padding:10px;gap:10px}
.panel-title{padding:10px 12px}
.panel-body{padding:10px 12px}
.stats{grid-template-columns:1fr 1fr}
.stat-card{padding:10px}
.stat-card .lbl{font-size:10px}
.stat-card .val{font-size:14px}
.form-grid{gap:8px}
.loan-group-header{padding:10px 12px}
.loan-group-header .gname{font-size:14px}
.loan-item{padding:8px 12px}
.month-header{padding:8px 12px}
.month-header .ym{font-size:13px}
.month-detail{padding:0 12px 8px}
.auth-card{padding:24px 20px}
.auth-card h2{font-size:18px;margin-bottom:20px}
}
</style>
</head>
<body>
<div class="header">
<div class="header-inner">
<h1>债务统计</h1>
<div class="header-center">
<div class="nav-tabs">
<a href="/" class="nav-tab">首页</a>
<a href="/analysis.html" class="nav-tab active">分析</a>
</div>
</div>
<div class="header-right">
<div class="notif-wrap">
<div class="notif-bell" onclick="toggleNotifDropdown(event)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><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="showProfile()">个人中心</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="container">
<div class="stats-row" id="statsRow"></div>
<div class="charts-grid" id="chartsGrid">
<div class="chart-card"><h3>借款总额 vs 已还总额</h3><div class="chart-wrap"><canvas id="chartPie"></canvas></div></div>
<div class="chart-card"><h3>债权人分布</h3><div class="chart-wrap"><canvas id="chartCreditor"></canvas></div></div>
<div class="chart-card"><h3>还款方式分布</h3><div class="chart-wrap"><canvas id="chartMethod"></canvas></div></div>
<div class="chart-card"><h3>利率分布</h3><div class="chart-wrap"><canvas id="chartRate"></canvas></div></div>
<div class="chart-card full-width"><h3>月度还款计划</h3><div class="chart-wrap"><canvas id="chartMonth"></canvas></div></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=[];
const $=id=>document.getElementById(id);
const fmt=n=>n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
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;
}
function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('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='';}}
function showProfile(){
$('userMenu').classList.remove('show');
$('profileAccount').value=currentUser.account;
$('profileNickname').value=currentUser.nickname||'';
$('profileEmail').value=currentUser.real_email||'';
$('emailCodeRow').style.display='none';
$('profileEmailCode').value='';
$('profileError').style.display='none';
$('profileSuccess').style.display='none';
$('profileModal').classList.add('show');
}
function closeProfile(){$('profileModal').classList.remove('show');}
let emailVerifySent=false;
async function sendEmailVerify(){
const email=$('profileEmail').value.trim();
if(!email||!email.includes('@')){showProfileError('请输入有效邮箱');return;}
$('profileError').style.display='none';
const res=await apiFetch('/auth/verify-email',{method:'POST',body:JSON.stringify({email})});
if(res&&res.ok){
emailVerifySent=true;
$('emailCodeRow').style.display='';
$('profileSuccess').style.display='';$('profileSuccess').textContent='验证码已发送到当前邮箱';
}else{
const d=await res.json().catch(()=>({}));
showProfileError(d.detail||'发送失败');
}
}
function showProfileError(msg){$('profileError').textContent=msg;$('profileError').style.display='';}
function hideProfileError(){$('profileError').style.display='none';}
async function saveProfile(){
const nick=$('profileNickname').value.trim();
const email=$('profileEmail').value.trim();
const code=$('profileEmailCode').value.trim();
hideProfileError();
$('profileSuccess').style.display='none';
// Update nickname
if(nick!==currentUser.nickname){
const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({nickname:nick})});
if(res&&res.ok){currentUser.nickname=nick;$('userInfo').textContent=nick||currentUser.account;}
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'昵称修改失败');return;}
}
// Update email if changed and code provided
if(email&&email!==currentUser.real_email){
if(!code){showProfileError('请输入邮箱验证码');$('emailCodeRow').style.display='';return;}
const res=await apiFetch('/auth/change-email',{method:'POST',body:JSON.stringify({code,new_email:email})});
if(res&&res.ok){currentUser.real_email=email;}
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'邮箱修改失败');return;}
}
$('profileSuccess').style.display='';$('profileSuccess').textContent='保存成功';
setTimeout(()=>{closeProfile();},1000);
}
async function checkAuth(){
if(!token){window.location.href='/';return false;}
const res=await apiFetch('/auth/profile');
if(!res||!res.ok){window.location.href='/';return false;}
currentUser=await res.json();
$('userInfo').textContent=currentUser.nickname||currentUser.account;
if(currentUser.role==='admin')$('adminLink').style.display='';
return true;
}
function doLogout(){token='';refreshToken='';currentUser=null;localStorage.removeItem('access_token');localStorage.removeItem('refresh_token');window.location.href='/';}
async function loadLoans(){const res=await apiFetch('/loans');if(!res||!res.ok)return;loans=await res.json();renderAll();}
function renderAll(){
if(!loans.length){$('statsRow').innerHTML='<div class="empty-tip">暂无数据</div>';return;}
renderStats();renderPie();renderCreditor();renderMethod();renderRate();renderMonth();
}
function renderStats(){
let totalAmt=0,totalInt=0,totalPaid=0,paidN=0,totalN=0;
loans.forEach(l=>{
totalAmt+=l.amount;totalN+=l.periods;
totalInt+=l.schedule.reduce((a,r)=>a+r.it,0);
paidN+=l.paid.length;
totalPaid+=l.schedule.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);
});
const totalPay=totalAmt+totalInt;
$('statsRow').innerHTML=`
<div class="stat-card"><div class="lbl">借款总额</div><div class="val blue">¥${fmt(totalAmt)}</div></div>
<div class="stat-card"><div class="lbl">总利息</div><div class="val red">¥${fmt(totalInt)}</div></div>
<div class="stat-card"><div class="lbl">已还</div><div class="val green">¥${fmt(totalPaid)}</div></div>
<div class="stat-card"><div class="lbl">还款进度</div><div class="val orange">${paidN}/${totalN}期</div></div>`;
}
function renderPie(){
let totalAmt=0,totalPaid=0;
loans.forEach(l=>{
totalAmt+=l.amount;
totalPaid+=l.schedule.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);
});
const remain=Math.max(0,totalAmt+loans.reduce((s,l)=>s+l.schedule.reduce((a,r)=>a+r.it,0),0)-totalPaid);
new Chart($('chartPie'),{type:'doughnut',data:{labels:['已还本金','待还本金','已还利息','待还利息'],datasets:[{data:[totalPaid*0.85,Math.max(0,totalAmt-totalPaid*0.85),totalPaid*0.15,Math.max(0,loans.reduce((s,l)=>s+l.schedule.reduce((a,r)=>a+r.it,0),0)-totalPaid*0.15)],backgroundColor:['#34c759','#0071e3','#30d158','#5ac8fa']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}}}});
}
function renderCreditor(){
const map={};loans.forEach(l=>{map[l.name]=(map[l.name]||0)+l.amount;});
const sorted=Object.entries(map).sort((a,b)=>b[1]-a[1]).slice(0,8);
new Chart($('chartCreditor'),{type:'bar',data:{labels:sorted.map(s=>s[0]),datasets:[{label:'借款金额',data:sorted.map(s=>s[1]),backgroundColor:['#0071e3','#34c759','#ff9500','#af52de','#ff3b30','#5ac8fa','#30d158','#ff6b35']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{callback:v=>'¥'+v.toLocaleString()}},x:{ticks:{font:{size:11}}}}}});
}
function renderMethod(){
const map={};loans.forEach(l=>{const m=l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本';map[m]=(map[m]||0)+1;});
new Chart($('chartMethod'),{type:'doughnut',data:{labels:Object.keys(map),datasets:[{data:Object.values(map),backgroundColor:['#0071e3','#ff9500','#af52de']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}}}});
}
function renderRate(){
const buckets={'0%':0,'0-5%':0,'5-10%':0,'10-15%':0,'15-20%':0,'20%+':0};
loans.forEach(l=>{
const r=l.rate;
if(r===0)buckets['0%']++;
else if(r<5)buckets['0-5%']++;
else if(r<10)buckets['5-10%']++;
else if(r<15)buckets['10-15%']++;
else if(r<20)buckets['15-20%']++;
else buckets['20%+']++;
});
new Chart($('chartRate'),{type:'bar',data:{labels:Object.keys(buckets),datasets:[{label:'借款数量',data:Object.values(buckets),backgroundColor:['#34c759','#5ac8fa','#0071e3','#ff9500','#ff6b35','#ff3b30']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{stepSize:1}},x:{ticks:{font:{size:11}}}}}});
}
function renderMonth(){
const map={};loans.forEach(l=>{l.schedule.forEach(r=>{const ym=r.date.slice(0,7);if(!map[ym])map[ym]={total:0,paid:0};map[ym].total+=r.pay;if(l.paid.includes(r.p))map[ym].paid+=r.pay;});});
const months=Object.keys(map).sort().slice(0,12);
new Chart($('chartMonth'),{type:'bar',data:{labels:months.map(m=>m.replace('-','年')+'月'),datasets:[{label:'应还',data:months.map(m=>map[m].total),backgroundColor:'rgba(0,113,227,.3)',borderColor:'#0071e3',borderWidth:1},{label:'已还',data:months.map(m=>map[m].paid),backgroundColor:'rgba(52,199,89,.5)',borderColor:'#34c759',borderWidth:1}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}},scales:{y:{beginAtZero:true,ticks:{callback:v=>'¥'+v.toLocaleString()}},x:{ticks:{font:{size:10}}}}}});
}
function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
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');$('exportMenu')?.classList.remove('show');$('importMenu')?.classList.remove('show');$('userMenu')?.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">${escapeHtml(n.title)}</div><div class="notif-msg">${escapeHtml(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();}
function toggleExport(e){e.stopPropagation();$('exportMenu').classList.toggle('show');}
function toggleImport(e){e.stopPropagation();$('importMenu').classList.toggle('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 getVisibleLoans(){return currentUser&&currentUser.role==='admin'?loans:loans.filter(l=>l.user_id===currentUser.id);}
function exportCSV(e){e.preventDefault();if(!loans.length)return;let csv='债权人,借款日期,借款金额,年化率(%),总利息,分期数,还款方式,已还期数,状态,用途\n';vl.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+=`${escapeHtml(l.name)},${l.date},${l.amount},${l.rate},${ti.toFixed(2)},${l.periods},${l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本'},${paid},${status}\n`;});download('债务统计_'+new Date().toISOString().slice(0,10)+'.csv',csv,'text/csv;charset=utf-8');}
function exportJSON(e){e.preventDefault();if(!loans.length)return;const data=loans.map(l=>({债权人:l.name,借款日期:l.date,借款金额:l.amount,年化率:l.rate,分期数:l.periods,还款方式:l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本',已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',用途:l.purpose||''}));download('债务统计_'+new Date().toISOString().slice(0,10)+'.json',JSON.stringify(data,null,2),'application/json;charset=utf-8');}
function exportHTML(e){e.preventDefault();if(!loans.length)return;let h=`<html><head><meta charset="utf-8"><title>债务统计</title></head><body><h2>债务统计报表</h2><table border="1"><tr><th>借款人</th><th>金额</th><th>利率</th><th>期数</th></tr>`;loans.forEach(l=>{h+=`<tr><td>${escapeHtml(l.name)}</td><td>¥${l.amount.toLocaleString()}</td><td>${l.rate}%</td><td>${l.paid.length}/${l.periods}</td></tr>`;});h+=`</table>
<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="profileModal">
<div class="modal" style="max-width:480px">
<h3>个人中心</h3>
<div class="profile-section">
<div class="form-row"><label>账号</label><input type="text" id="profileAccount" disabled style="background:#f5f5f7"></div>
<div class="form-row"><label>昵称</label><input type="text" id="profileNickname" placeholder="请输入昵称"></div>
<div class="form-row">
<label>邮箱</label>
<div style="display:flex;gap:8px">
<input type="email" id="profileEmail" placeholder="请输入邮箱" style="flex:1">
<button class="btn btn-ghost" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="sendEmailVerify()">发送验证码</button>
</div>
</div>
<div class="form-row" id="emailCodeRow" style="display:none">
<label>验证码</label>
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
</div>
<div id="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeProfile()">取消</button>
<button class="btn btn-main" onclick="saveProfile()">保存</button>
</div>
</div>
</div>
</div>
</body></html>`;download('债务统计_'+new Date().toISOString().slice(0,10)+'.html',h,'text/html;charset=utf-8');}
function downloadTemplate(e){e.preventDefault();$('importMenu').classList.remove('show');let csv='借款人,借款日期,借款金额,年利率(%),总利息,分期数,还款方式\n';csv+='张三,2026-01-01,100000,5.7,5198.99,24,等额本息\n';download('导入模板.csv',csv,'text/csv;charset=utf-8');}
function showImportModal(e){e.preventDefault();$('importMenu').classList.remove('show');}
checkAuth().then(ok=>{if(ok)loadLoans();});
</script>
<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="profileModal">
<div class="modal" style="max-width:480px">
<h3>个人中心</h3>
<div class="profile-section">
<div class="form-row"><label>账号</label><input type="text" id="profileAccount" disabled style="background:#f5f5f7"></div>
<div class="form-row"><label>昵称</label><input type="text" id="profileNickname" placeholder="请输入昵称"></div>
<div class="form-row">
<label>邮箱</label>
<div style="display:flex;gap:8px">
<input type="email" id="profileEmail" placeholder="请输入邮箱" style="flex:1">
<button class="btn btn-ghost" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="sendEmailVerify()">发送验证码</button>
</div>
</div>
<div class="form-row" id="emailCodeRow" style="display:none">
<label>验证码</label>
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
</div>
<div id="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeProfile()">取消</button>
<button class="btn btn-main" onclick="saveProfile()">保存</button>
</div>
</div>
</div>
</div>
</body>
</html>

20
frontend/chart.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -27,9 +27,14 @@
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} 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 */
.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{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}
.header-inner{max-width:1120px;margin:0 auto;padding:0 24px;height:52px;display:flex;align-items:center;justify-content:space-between} .header-inner{max-width:1200px;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 h1{font-size:17px;font-weight:600;color:var(--text);letter-spacing:-.2px}
.header-center{position:absolute;left:50%;transform:translateX(-50%)}
.nav-tabs{display:flex;gap:4px;background:rgba(0,0,0,.04);border-radius:var(--radius-xs);padding:3px}
.nav-tab{padding:6px 16px;border-radius:6px;font-size:13px;font-weight:500;color:var(--text2);text-decoration:none;transition:all .2s}
.nav-tab:hover{color:var(--text)}
.nav-tab.active{background:#fff;color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.08)}
.header-right{display:flex;align-items:center;gap:4px} .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,.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 a:hover,.hdr-btn:hover{background:rgba(0,0,0,.04);color:var(--text)}
@@ -37,6 +42,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
.header-right .logout-link{color:var(--red)} .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{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)} .user-info:hover{background:rgba(0,0,0,.04)}
.notif-wrap{position:relative}
.notif-bell{cursor:pointer;padding:4px 8px;border-radius:var(--radius-xs);transition:all .2s;color:var(--text2)}
.notif-bell:hover{background:rgba(0,0,0,.04);color:var(--text)}
.notif-badge{position:absolute;top:2px;right:2px;min-width:14px;height:14px;background:var(--red);color:#fff;border-radius:7px;font-size:9px;font-weight:600;display:flex;align-items:center;justify-content:center;padding:0 3px}
.notif-dropdown{display:none;position:absolute;right:0;top:calc(100% + 8px);background:rgba(255,255,255,.96);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-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}
.export-wrap{position:relative}
.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:160px;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)}
.user-info{color:var(--text);font-size:13px;font-weight:500;cursor:pointer;padding:6px 14px;border-radius:var(--radius-xs);transition:all .2s;background:rgba(0,0,0,.02)}
.user-info:hover{background:rgba(0,0,0,.06)}
/* MAIN */ /* MAIN */
.main{max-width:1120px;margin:0 auto;padding:24px;display:grid;grid-template-columns:320px 1fr;gap:20px} .main{max-width:1120px;margin:0 auto;padding:24px;display:grid;grid-template-columns:320px 1fr;gap:20px}
@@ -245,6 +280,98 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
.notif-item .notif-msg{font-size:12px;color:var(--text3);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-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} .notif-empty{text-align:center;padding:32px;color:var(--text3);font-size:13px}
.profile-section{padding:4px 0}
.profile-section .form-row{margin-bottom:14px}
.profile-section .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--text2)}
.profile-section .form-row input{width:100%;padding:10px 14px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:14px;outline:none;transition:all .2s}
.profile-section .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
.profile-section .form-row input:disabled{background:#f5f5f7;color:var(--text3)}
/* RESPONSIVE - Mobile & Tablet */
@media(max-width:768px){
.header-inner{padding:0 12px;height:48px}
.header h1{font-size:15px}
.header-center{position:static;transform:none}
.nav-tabs{gap:2px;padding:2px}
.nav-tab{padding:5px 12px;font-size:12px}
.header-right{gap:2px}
.header-right a,.hdr-btn{padding:5px 8px;font-size:11px}
.user-info{padding:5px 8px;font-size:12px}
.notif-bell{padding:4px 6px}
.notif-dropdown{width:calc(100vw - 20px);right:-10px}
.main{padding:12px;gap:12px}
.panel-title{padding:12px 14px;font-size:13px}
.panel-body{padding:12px 14px}
.form-grid{grid-template-columns:1fr;gap:10px}
.form-row input,.form-row select{padding:10px 12px;font-size:14px}
.loan-item{padding:8px 14px 8px 28px}
.loan-item .iamt{font-size:12px}
.loan-actions-popup{padding:6px 14px;flex-wrap:wrap}
.stats{grid-template-columns:repeat(2,1fr);gap:8px}
.stat-card{padding:12px}
.stat-card .val{font-size:16px}
.month-header{padding:10px 14px}
.month-header .total{font-size:13px}
.loan-row .right{width:90px}
.loan-row .pay-amt{font-size:13px}
.modal{padding:20px;width:95%;max-width:none}
.modal h3{font-size:16px;margin-bottom:14px}
.auth-card{width:100%;max-width:360px;padding:32px 24px;margin:16px}
.auth-card h2{font-size:20px;margin-bottom:24px}
.btn-row{flex-direction:column;gap:8px}
.btn{padding:12px 0}
.toast{max-width:calc(100vw - 32px);font-size:12px;padding:10px 16px}
}
@media(max-width:480px){
.header-inner{padding:0 10px;height:44px}
.header h1{font-size:14px}
.nav-tab{padding:4px 10px;font-size:11px}
.header-right a,.hdr-btn{padding:4px 6px;font-size:10px}
.main{padding:10px;gap:10px}
.panel-title{padding:10px 12px}
.panel-body{padding:10px 12px}
.stats{grid-template-columns:1fr 1fr}
.stat-card{padding:10px}
.stat-card .lbl{font-size:10px}
.stat-card .val{font-size:14px}
.form-grid{gap:8px}
.loan-group-header{padding:10px 12px}
.loan-group-header .gname{font-size:14px}
.loan-item{padding:8px 12px}
.month-header{padding:8px 12px}
.month-header .ym{font-size:13px}
.month-detail{padding:0 12px 8px}
.auth-card{padding:24px 20px}
.auth-card h2{font-size:18px;margin-bottom:20px}
}
/* 2FA */
.twofa-section{margin-top:16px;padding-top:16px;border-top:1px solid var(--border)}
.twofa-status{display:flex;align-items:center;gap:8px;margin-bottom:12px}
.twofa-badge{padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600}
.twofa-badge.enabled{background:rgba(52,199,89,.1);color:var(--green)}
.twofa-badge.disabled{background:rgba(0,0,0,.04);color:var(--text3)}
.twofa-qr{display:flex;justify-content:center;margin:16px 0}
.twofa-qr img{border:1px solid var(--border);border-radius:8px}
.twofa-input{display:flex;gap:8px;align-items:center}
.twofa-input input{flex:1}
</style> </style>
</head> </head>
<body> <body>
@@ -322,26 +449,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
<div class="header"> <div class="header">
<div class="header-inner"> <div class="header-inner">
<h1>债务统计</h1> <h1>债务统计</h1>
<div class="header-center">
<div class="nav-tabs">
<a href="/" class="nav-tab active">首页</a>
<a href="/analysis.html" class="nav-tab">分析</a>
</div>
</div>
<div class="header-right"> <div class="header-right">
<div class="export-wrap" style="position:relative"> <div class="notif-wrap">
<div class="notif-bell" onclick="toggleNotifDropdown(event)"> <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> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><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> <span class="notif-badge" id="notifBadge" style="display:none">0</span>
</div> </div>
<div class="notif-dropdown" id="notifDropdown"> <div class="notif-dropdown" id="notifDropdown">
<div class="notif-header"> <div class="notif-header"><h4>通知</h4><button class="mark-all" onclick="markAllNotifRead()">全部已读</button></div>
<h4>通知</h4> <div class="notif-list" id="notifList"><div class="notif-empty">暂无通知</div></div>
<button class="mark-all" onclick="markAllNotifRead()">全部已读</button>
</div>
<div class="notif-list" id="notifList">
<div class="notif-empty">暂无通知</div>
</div>
</div> </div>
</div> </div>
<div class="export-wrap"> <div class="export-wrap">
<span class="user-info" id="userInfo" onclick="toggleUserMenu(event)"></span> <span class="user-info" id="userInfo" onclick="toggleUserMenu(event)"></span>
<div class="export-menu" id="userMenu"> <div class="export-menu" id="userMenu">
<a href="#" onclick="showChangeNick(event)">修改昵称</a> <a href="#" onclick="showProfile()">个人中心</a>
<a href="#" onclick="showChangePwd(event)">修改密码</a> <a href="#" onclick="showChangePwd(event)">修改密码</a>
</div> </div>
</div> </div>
@@ -365,7 +493,6 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
</div> </div>
</div> </div>
</div> </div>
<div class="main"> <div class="main">
<div style="display:flex;flex-direction:column;gap:16px"> <div style="display:flex;flex-direction:column;gap:16px">
<div class="panel"> <div class="panel">
@@ -423,7 +550,7 @@ let openGroups=new Set();
let editId=null; let editId=null;
let hidePaidItems=true; let hidePaidItems=true;
let hidePaidMonths=true; let hidePaidMonths=true;
let hideOthers=true; let hideOthers=false;
let quickPayLoanId=null; let quickPayLoanId=null;
let actionLoanId=null; let actionLoanId=null;
@@ -464,6 +591,220 @@ async function apiFetch(path,opts={}){
return res; return res;
} }
function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('show');}
function showProfile(){
$('userMenu').classList.remove('show');
$('profileAccount').value=currentUser.account;
$('profileNickname').value=currentUser.nickname||'';
$('profileEmail').value=currentUser.real_email||'';
$('emailCodeRow').style.display='none';
$('profileEmailCode').value='';
$('profileError').style.display='none';
$('profileSuccess').style.display='none';
$('profileModal').classList.add('show');
loadTwofaStatus();
}
function closeProfile(){$('profileModal').classList.remove('show');}
let emailVerifySent=false;
async function sendEmailVerify(){
const email=$('profileEmail').value.trim();
if(!email||!email.includes('@')){showProfileError('请输入有效邮箱');return;}
$('profileError').style.display='none';
const res=await apiFetch('/auth/verify-email',{method:'POST',body:JSON.stringify({email})});
if(res&&res.ok){
emailVerifySent=true;
$('emailCodeRow').style.display='';
$('profileSuccess').style.display='';$('profileSuccess').textContent='验证码已发送到当前邮箱';
}else{
const d=await res.json().catch(()=>({}));
showProfileError(d.detail||'发送失败');
}
}
function showProfileError(msg){$('profileError').textContent=msg;$('profileError').style.display='';}
function hideProfileError(){$('profileError').style.display='none';}
async function saveProfile(){
const nick=$('profileNickname').value.trim();
const email=$('profileEmail').value.trim();
const code=$('profileEmailCode').value.trim();
hideProfileError();
$('profileSuccess').style.display='none';
// Update nickname
if(nick!==currentUser.nickname){
const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({nickname:nick})});
if(res&&res.ok){currentUser.nickname=nick;$('userInfo').textContent=nick||currentUser.account;}
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'昵称修改失败');return;}
}
// Update email if changed and code provided
if(email&&email!==currentUser.real_email){
if(!code){showProfileError('请输入邮箱验证码');$('emailCodeRow').style.display='';return;}
const res=await apiFetch('/auth/change-email',{method:'POST',body:JSON.stringify({code,new_email:email})});
if(res&&res.ok){currentUser.real_email=email;}
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'邮箱修改失败');return;}
}
$('profileSuccess').style.display='';$('profileSuccess').textContent='保存成功';
setTimeout(()=>{closeProfile();},1000);
}
// 2FA 相关函数
let twofaTempToken = '';
function showTwofaModal(tempToken) {
twofaTempToken = tempToken;
$('twofaCode').value = '';
$('twofaError').style.display = 'none';
$('twofaModal').classList.add('show');
}
function closeTwofaModal() {
$('twofaModal').classList.remove('show');
twofaTempToken = '';
}
async function verifyTwofaLogin() {
const code = $('twofaCode').value.trim();
if (!code || code.length !== 6) {
$('twofaError').textContent = '请输入 6 位验证码';
$('twofaError').style.display = '';
return;
}
try {
const res = await fetch(API + '/auth/2fa/verify-login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({temp_token: twofaTempToken, code})
});
if (res.ok) {
const data = await res.json();
token = data.access_token;
refreshToken = data.refresh_token;
localStorage.setItem('access_token', token);
localStorage.setItem('refresh_token', refreshToken);
closeTwofaModal();
await checkAuth();
await loadLoans();
} else {
const data = await res.json().catch(() => ({}));
$('twofaError').textContent = data.detail || '验证失败';
$('twofaError').style.display = '';
}
} catch(e) {
$('twofaError').textContent = '网络异常';
$('twofaError').style.display = '';
}
}
// 修改 doLogin 处理 2FA
// 2FA 设置相关函数
async function loadTwofaStatus() {
const res = await apiFetch('/auth/2fa/status');
if (res && res.ok) {
const data = await res.json();
const statusEl = document.getElementById('twofaStatus');
if (statusEl) {
if (data.enabled) {
statusEl.innerHTML = '<span class="twofa-badge enabled">已启用</span><button class="btn btn-ghost" style="margin-left:8px;padding:4px 12px;font-size:11px" onclick="showDisableTwofa()">禁用</button>';
} else {
statusEl.innerHTML = '<span class="twofa-badge disabled">未启用</span><button class="btn btn-main" style="margin-left:8px;padding:4px 12px;font-size:11px" onclick="startSetupTwofa()">启用</button>';
}
}
}
}
let twofaSetupSecret = '';
let twofaSetupUrl = '';
async function startSetupTwofa() {
const res = await apiFetch('/auth/2fa/setup', {method: 'POST'});
if (res && res.ok) {
const data = await res.json();
twofaSetupSecret = data.secret;
twofaSetupUrl = data.otpauth_url;
const qrSection = document.getElementById('twofaSetupSection');
const qrImg = document.getElementById('twofaQrCode');
const secretText = document.getElementById('twofaSecret');
if (qrImg) qrImg.src = data.qr_code;
if (secretText) secretText.textContent = data.secret;
if (qrSection) qrSection.style.display = '';
}
}
async function verifyTwofaSetup() {
const code = $('twofaSetupCode').value.trim();
if (!code || code.length !== 6) {
alert('请输入 6 位验证码');
return;
}
const res = await apiFetch('/auth/2fa/verify', {
method: 'POST',
body: JSON.stringify({code})
});
if (res && res.ok) {
alert('双因素认证已启用');
$('twofaSetupSection').style.display = 'none';
loadTwofaStatus();
} else {
const data = await res.json().catch(() => ({}));
alert(data.detail || '验证失败');
}
}
async function showDisableTwofa() {
const code = prompt('请输入当前验证码以禁用 2FA');
if (!code) return;
const res = await apiFetch('/auth/2fa/disable', {
method: 'POST',
body: JSON.stringify({code})
});
if (res && res.ok) {
alert('双因素认证已禁用');
loadTwofaStatus();
} else {
const data = await res.json().catch(() => ({}));
alert(data.detail || '禁用失败');
}
}
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;
}
// 检查是否需要 2FA
if(data.require_2fa){
showTwofaModal(data.temp_token);
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 checkAuth(){ async function checkAuth(){
if(!token){showLogin();return false;} if(!token){showLogin();return false;}
const res=await apiFetch('/auth/profile'); const res=await apiFetch('/auth/profile');
@@ -497,12 +838,7 @@ function startLoginCountdown(seconds){
tick(); tick();
loginCountdown=setInterval(tick,1000); 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(){ async function doRegister(){
const account=$('regEmail').value.trim(),nick=$('regNick').value.trim(),pwd=$('regPwd').value,pwd2=$('regPwd2').value; const account=$('regEmail').value.trim(),nick=$('regNick').value.trim(),pwd=$('regPwd').value,pwd2=$('regPwd2').value;
if(!account||!pwd)return $('regError').textContent='请填写账号和密码'; if(!account||!pwd)return $('regError').textContent='请填写账号和密码';
@@ -533,7 +869,7 @@ $('fTotalInt').addEventListener('focus',function(){$('intHint').textContent='输
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 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 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">${escapeHtml(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 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">${escapeHtml(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">${isOwner?`<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})">${isOwner?'💰 快速还款':'👁 快速查看'}</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 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'});} 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'});}
@@ -588,17 +924,174 @@ function toggleHideOthers(){hideOthers=!hideOthers;$('hideOthersIcon').textConte
function toggleMonth(ym){if(openMonths.has(ym))openMonths.delete(ym);else openMonths.add(ym);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');} function toggleExport(e){e.stopPropagation();$('exportMenu').classList.toggle('show');}
document.addEventListener('click',function(){$('exportMenu').classList.remove('show');}); document.addEventListener('click',function(){$('exportMenu').classList.remove('show');$('importMenu').classList.remove('show');$('userMenu').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 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+=`${escapeHtml(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 getVisibleLoans(){return currentUser&&currentUser.role==='admin'?loans:loans.filter(l=>l.user_id===currentUser.id);}
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>${escapeHtml(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 exportCSV(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.length)return showToast('暂无数据可导出','info');let csv='债权人,借款日期,借款金额,年化率(%),总利息,分期数,还款方式,已还期数,状态,用途\n';vl.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+=`${escapeHtml(l.name)},${l.date},${l.amount},${l.rate},${ti.toFixed(2)},${l.periods},${methodLabel(l.method)},${paid},${status},${escapeHtml(l.purpose||'')}\n`;});download('债务统计_'+today()+'.csv',csv,'text/csv;charset=utf-8');}
function exportJSON(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.length)return showToast('暂无数据可导出','info');const data=vl.map(l=>({债权人:l.name,借款日期:l.date,借款金额:l.amount,年化率:l.rate,分期数:l.periods,还款方式:methodLabel(l.method),已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',用途:l.purpose||'',还款计划: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();const vl=getVisibleLoans();if(!vl.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}
.profile-section{padding:4px 0}
.profile-section .form-row{margin-bottom:14px}
.profile-section .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--text2)}
.profile-section .form-row input{width:100%;padding:10px 14px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:14px;outline:none;transition:all .2s}
.profile-section .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
.profile-section .form-row input:disabled{background:#f5f5f7;color:var(--text3)}
/* RESPONSIVE - Mobile & Tablet */
@media(max-width:768px){
.header-inner{padding:0 12px;height:48px}
.header h1{font-size:15px}
.header-center{position:static;transform:none}
.nav-tabs{gap:2px;padding:2px}
.nav-tab{padding:5px 12px;font-size:12px}
.header-right{gap:2px}
.header-right a,.hdr-btn{padding:5px 8px;font-size:11px}
.user-info{padding:5px 8px;font-size:12px}
.notif-bell{padding:4px 6px}
.notif-dropdown{width:calc(100vw - 20px);right:-10px}
.main{padding:12px;gap:12px}
.panel-title{padding:12px 14px;font-size:13px}
.panel-body{padding:12px 14px}
.form-grid{grid-template-columns:1fr;gap:10px}
.form-row input,.form-row select{padding:10px 12px;font-size:14px}
.loan-item{padding:8px 14px 8px 28px}
.loan-item .iamt{font-size:12px}
.loan-actions-popup{padding:6px 14px;flex-wrap:wrap}
.stats{grid-template-columns:repeat(2,1fr);gap:8px}
.stat-card{padding:12px}
.stat-card .val{font-size:16px}
.month-header{padding:10px 14px}
.month-header .total{font-size:13px}
.loan-row .right{width:90px}
.loan-row .pay-amt{font-size:13px}
.modal{padding:20px;width:95%;max-width:none}
.modal h3{font-size:16px;margin-bottom:14px}
.auth-card{width:100%;max-width:360px;padding:32px 24px;margin:16px}
.auth-card h2{font-size:20px;margin-bottom:24px}
.btn-row{flex-direction:column;gap:8px}
.btn{padding:12px 0}
.toast{max-width:calc(100vw - 32px);font-size:12px;padding:10px 16px}
}
@media(max-width:480px){
.header-inner{padding:0 10px;height:44px}
.header h1{font-size:14px}
.nav-tab{padding:4px 10px;font-size:11px}
.header-right a,.hdr-btn{padding:4px 6px;font-size:10px}
.main{padding:10px;gap:10px}
.panel-title{padding:10px 12px}
.panel-body{padding:10px 12px}
.stats{grid-template-columns:1fr 1fr}
.stat-card{padding:10px}
.stat-card .lbl{font-size:10px}
.stat-card .val{font-size:14px}
.form-grid{gap:8px}
.loan-group-header{padding:10px 12px}
.loan-group-header .gname{font-size:14px}
.loan-item{padding:8px 12px}
.month-header{padding:8px 12px}
.month-header .ym{font-size:13px}
.month-detail{padding:0 12px 8px}
.auth-card{padding:24px 20px}
.auth-card h2{font-size:18px;margin-bottom:20px}
}
/* 2FA */
.twofa-section{margin-top:16px;padding-top:16px;border-top:1px solid var(--border)}
.twofa-status{display:flex;align-items:center;gap:8px;margin-bottom:12px}
.twofa-badge{padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600}
.twofa-badge.enabled{background:rgba(52,199,89,.1);color:var(--green)}
.twofa-badge.disabled{background:rgba(0,0,0,.04);color:var(--text3)}
.twofa-qr{display:flex;justify-content:center;margin:16px 0}
.twofa-qr img{border:1px solid var(--border);border-radius:8px}
.twofa-input{display:flex;gap:8px;align-items:center}
.twofa-input input{flex:1}
</style></head><body><h2>债务统计报表 - ${today()}</h2>`;vl.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`<h3>${escapeHtml(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><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+=`
<div class="modal-mask" id="twofaModal">
<div class="modal">
<h3>双因素认证</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:16px;text-align:center">请输入手机验证器应用中的 6 位验证码</p>
<div class="form-row">
<input type="text" id="twofaCode" placeholder="000000" maxlength="6" style="text-align:center;font-size:24px;letter-spacing:8px;font-weight:600">
</div>
<div id="twofaError" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeTwofaModal()">取消</button>
<button class="btn btn-main" onclick="verifyTwofaLogin()">验证</button>
</div>
</div>
</div>
<div class="modal-mask" id="profileModal">
<div class="modal" style="max-width:480px">
<h3>个人中心</h3>
<div class="profile-section">
<div class="form-row"><label>账号</label><input type="text" id="profileAccount" disabled style="background:#f5f5f7"></div>
<div class="form-row"><label>昵称</label><input type="text" id="profileNickname" placeholder="请输入昵称"></div>
<div class="form-row">
<label>邮箱</label>
<div style="display:flex;gap:8px">
<input type="email" id="profileEmail" placeholder="请输入邮箱" style="flex:1">
<button class="btn btn-ghost" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="sendEmailVerify()">发送验证码</button>
</div>
</div>
<div class="form-row" id="emailCodeRow" style="display:none">
<label>验证码</label>
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
</div>
<div class="twofa-section">
<label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">双因素认证 (2FA)</label>
<div class="twofa-status" id="twofaStatus">
<span class="twofa-badge disabled">加载中...</span>
</div>
<div id="twofaSetupSection" style="display:none">
<p style="font-size:12px;color:var(--text2);margin-bottom:12px">请使用 Google Authenticator 等应用扫描下方二维码:</p>
<div class="twofa-qr"><img id="twofaQrCode" width="200" height="200"></div>
<p style="font-size:11px;color:var(--text3);text-align:center;margin-bottom:12px">或手动输入密钥:<code id="twofaSecret" style="background:#f5f5f7;padding:2px 6px;border-radius:4px"></code></p>
<div class="form-row">
<label>验证码</label>
<div class="twofa-input">
<input type="text" id="twofaSetupCode" placeholder="输入 6 位验证码" maxlength="6">
<button class="btn btn-main" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="verifyTwofaSetup()">确认启用</button>
</div>
</div>
</div>
</div>
<div id="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeProfile()">取消</button>
<button class="btn btn-main" onclick="saveProfile()">保存</button>
</div>
</div>
</div>
</div>
</body></html>`;download('债务统计_'+today()+'.html',h,'text/html;charset=utf-8');}
function toggleImport(e){e.stopPropagation();$('importMenu').classList.toggle('show');} function toggleImport(e){e.stopPropagation();$('importMenu').classList.toggle('show');}
document.addEventListener('click',function(){$('importMenu').classList.remove('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 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 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');} 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='';}} 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('已还期数');const purposeIdx=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 purpose=purposeIdx>=0?cols[purposeIdx]||'':'';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');} function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('show');}
document.addEventListener('click',function(){$('userMenu').classList.remove('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 showChangeNick(e){if(e)e.preventDefault();$('userMenu').classList.remove('show');$('newNick').value='';$('nickError').style.display='none';$('nickSuccess').style.display='none';$('changeNickModal').classList.add('show');}
@@ -664,5 +1157,68 @@ async function doForgotStep3(){
checkAuth().then(ok=>{if(ok)loadLoans();}); checkAuth().then(ok=>{if(ok)loadLoans();});
</script> </script>
<div class="modal-mask" id="twofaModal">
<div class="modal">
<h3>双因素认证</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:16px;text-align:center">请输入手机验证器应用中的 6 位验证码</p>
<div class="form-row">
<input type="text" id="twofaCode" placeholder="000000" maxlength="6" style="text-align:center;font-size:24px;letter-spacing:8px;font-weight:600">
</div>
<div id="twofaError" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeTwofaModal()">取消</button>
<button class="btn btn-main" onclick="verifyTwofaLogin()">验证</button>
</div>
</div>
</div>
<div class="modal-mask" id="profileModal">
<div class="modal" style="max-width:480px">
<h3>个人中心</h3>
<div class="profile-section">
<div class="form-row"><label>账号</label><input type="text" id="profileAccount" disabled style="background:#f5f5f7"></div>
<div class="form-row"><label>昵称</label><input type="text" id="profileNickname" placeholder="请输入昵称"></div>
<div class="form-row">
<label>邮箱</label>
<div style="display:flex;gap:8px">
<input type="email" id="profileEmail" placeholder="请输入邮箱" style="flex:1">
<button class="btn btn-ghost" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="sendEmailVerify()">发送验证码</button>
</div>
</div>
<div class="form-row" id="emailCodeRow" style="display:none">
<label>验证码</label>
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
</div>
<div class="twofa-section">
<label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">双因素认证 (2FA)</label>
<div class="twofa-status" id="twofaStatus">
<span class="twofa-badge disabled">加载中...</span>
</div>
<div id="twofaSetupSection" style="display:none">
<p style="font-size:12px;color:var(--text2);margin-bottom:12px">请使用 Google Authenticator 等应用扫描下方二维码:</p>
<div class="twofa-qr"><img id="twofaQrCode" width="200" height="200"></div>
<p style="font-size:11px;color:var(--text3);text-align:center;margin-bottom:12px">或手动输入密钥:<code id="twofaSecret" style="background:#f5f5f7;padding:2px 6px;border-radius:4px"></code></p>
<div class="form-row">
<label>验证码</label>
<div class="twofa-input">
<input type="text" id="twofaSetupCode" placeholder="输入 6 位验证码" maxlength="6">
<button class="btn btn-main" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="verifyTwofaSetup()">确认启用</button>
</div>
</div>
</div>
</div>
<div id="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeProfile()">取消</button>
<button class="btn btn-main" onclick="saveProfile()">保存</button>
</div>
</div>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -29,6 +29,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
.links{text-align:center;margin-top:18px} .links{text-align:center;margin-top:18px}
.links a{color:var(--blue);text-decoration:none;font-size:13px;font-weight:500} .links a{color:var(--blue);text-decoration:none;font-size:13px;font-weight:500}
.links a:hover{text-decoration:underline} .links a:hover{text-decoration:underline}
/* RESPONSIVE */
@media(max-width:480px){
.auth-card{width:calc(100% - 32px);padding:24px 20px;margin:16px}
.auth-card h2{font-size:18px;margin-bottom:24px}
.auth-card .form-row input{padding:10px 14px;font-size:14px}
}
</style> </style>
</head> </head>
<body> <body>