2 Commits
v2.3 ... 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
11 changed files with 1033 additions and 51 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),

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()
@@ -52,10 +52,23 @@ async def migrate_account_names():
async def migrate_add_real_email(): async def migrate_add_real_email():
async with engine.begin() as conn:
try:
await conn.execute(text(
"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: async with engine.begin() as conn:
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 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

@@ -2,28 +2,23 @@
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR" INSTALL_DIR="/opt/debt-manager"
echo "==========================================" echo "=========================================="
echo " 债务管理系统 v2.2 安装脚本" echo " 债务管理系统 v2.3 安装脚本"
echo "==========================================" echo "=========================================="
echo ""
# Detect architecture # 检测架构
ARCH=$(uname -m) ARCH=$(uname -m)
case "$ARCH" in case "$ARCH" in
x86_64|amd64) x86_64|amd64)
ARCH_NAME="x86" ARCH_NAME="x86"
COMPOSE_FILE="docker-compose.x86.yml"
BACKEND_IMAGE="debt-manager-backend:x86"
FRONTEND_IMAGE="debt-manager-frontend:x86"
BACKEND_TAR="debt-manager-backend-x86.tar.gz" BACKEND_TAR="debt-manager-backend-x86.tar.gz"
FRONTEND_TAR="debt-manager-frontend-x86.tar.gz" FRONTEND_TAR="debt-manager-frontend-x86.tar.gz"
;; ;;
aarch64|arm64) aarch64|arm64)
ARCH_NAME="arm64" ARCH_NAME="arm64"
COMPOSE_FILE="docker-compose.arm64.yml"
BACKEND_IMAGE="debt-manager-backend:latest"
FRONTEND_IMAGE="debt-manager-frontend:latest"
BACKEND_TAR="debt-manager-backend-arm64.tar.gz" BACKEND_TAR="debt-manager-backend-arm64.tar.gz"
FRONTEND_TAR="debt-manager-frontend-arm64.tar.gz" FRONTEND_TAR="debt-manager-frontend-arm64.tar.gz"
;; ;;
@@ -36,42 +31,62 @@ esac
echo "检测到架构: $ARCH_NAME ($ARCH)" echo "检测到架构: $ARCH_NAME ($ARCH)"
echo "" echo ""
# Load images # 检查 Docker
echo "加载 Docker 镜像..." if ! command -v docker &> /dev/null; then
docker load < "$BACKEND_TAR" echo "请先安装 Docker"
docker load < "$FRONTEND_TAR" exit 1
docker load < postgres-15-alpine.tar.gz fi
if ! docker compose version &> /dev/null; then
echo "请先安装 Docker Compose"
exit 1
fi
# Generate secure keys # 创建安装目录
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null || openssl rand -hex 32) mkdir -p "$INSTALL_DIR"
DB_PASSWORD=$(python3 -c "import secrets; print(secrets.token_hex(16))" 2>/dev/null || openssl rand -hex 16) cd "$INSTALL_DIR"
echo "" # 复制文件
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 "生成安全密钥..." echo "生成安全密钥..."
DB_PASSWORD=$(openssl rand -hex 16)
SECRET_KEY=$(openssl rand -hex 32)
# Create .env file # 创建 .env 文件
cat > .env << EOF cat > .env << EOF
POSTGRES_DB=debt_manager POSTGRES_DB=debt_manager
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=$DB_PASSWORD POSTGRES_PASSWORD=$DB_PASSWORD
DATABASE_URL=postgresql+asyncpg://postgres:$DB_PASSWORD@db:5432/debt_manager DATABASE_URL=postgresql+asyncpg://postgres:$DB_PASSWORD@db:5432/debt_manager
SECRET_KEY=$SECRET_KEY SECRET_KEY=$SECRET_KEY
ADMIN_PASSWORD=Admin123!
EOF EOF
echo "配置文件已创建" # 更新 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 "==========================================" echo "=========================================="
echo " 安装完成!" echo " 安装完成!"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "启动命令: docker compose -f $COMPOSE_FILE up -d" echo "启动命令: cd $INSTALL_DIR && docker compose up -d"
echo "" echo ""
echo "访问地址:" echo "访问地址:"
echo " 前台: http://localhost:806" echo " 前台: http://localhost:806"
echo " 后台: http://localhost:806/admin.html" echo " 后台: http://localhost:806/admin.html"
echo "" echo ""
echo "管理员账号: admin" echo "管理员账号: admin"
echo "管理员密码: Admin123!" echo "管理员密码: admin123"
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

@@ -92,6 +92,80 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
.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{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: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)} .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> </style>
</head> </head>
<body> <body>
@@ -100,8 +174,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
<h1>债务统计</h1> <h1>债务统计</h1>
<div class="header-center"> <div class="header-center">
<div class="nav-tabs"> <div class="nav-tabs">
<a href="/" class="nav-tab active">首页</a> <a href="/" class="nav-tab">首页</a>
<a href="/analysis.html" class="nav-tab">分析</a> <a href="/analysis.html" class="nav-tab active">分析</a>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View File

@@ -287,6 +287,91 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
.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{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: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)} .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>
@@ -465,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;
@@ -518,6 +603,7 @@ function showProfile(){
$('profileError').style.display='none'; $('profileError').style.display='none';
$('profileSuccess').style.display='none'; $('profileSuccess').style.display='none';
$('profileModal').classList.add('show'); $('profileModal').classList.add('show');
loadTwofaStatus();
} }
function closeProfile(){$('profileModal').classList.remove('show');} function closeProfile(){$('profileModal').classList.remove('show');}
@@ -567,6 +653,158 @@ async function saveProfile(){
setTimeout(()=>{closeProfile();},1000); 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');
@@ -600,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='请填写账号和密码';
@@ -636,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'});}
@@ -704,7 +937,108 @@ function exportHTML(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.leng
.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{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: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)} .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+=` </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-mask" id="profileModal">
<div class="modal" style="max-width:480px"> <div class="modal" style="max-width:480px">
<h3>个人中心</h3> <h3>个人中心</h3>
@@ -722,6 +1056,26 @@ function exportHTML(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.leng
<label>验证码</label> <label>验证码</label>
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6"> <input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
</div> </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="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div> <div id="profileSuccess" class="success-msg" style="display:none"></div>
<div class="btn-row"> <div class="btn-row">
@@ -804,6 +1158,22 @@ 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-mask" id="profileModal">
<div class="modal" style="max-width:480px"> <div class="modal" style="max-width:480px">
<h3>个人中心</h3> <h3>个人中心</h3>
@@ -821,6 +1191,26 @@ checkAuth().then(ok=>{if(ok)loadLoans();});
<label>验证码</label> <label>验证码</label>
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6"> <input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
</div> </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="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div> <div id="profileSuccess" class="success-msg" style="display:none"></div>
<div class="btn-row"> <div class="btn-row">

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>