security: 全面安全加固 v2.1
严重修复: - C1: JWT密钥生成64字节随机字符串替代硬编码占位符 - C2: 数据库密码改为随机强密码,移除宿主机端口映射 - C3: 管理员默认密码改为强密码 高危修复: - H1: 移除数据库54326端口映射,仅内部网络访问 - H3: 添加X-Frame-Options/CSP/XSS-Protection等安全头 - H4: 前端所有innerHTML拼接处添加escapeHtml转义防XSS - H5: SMTP密码在API响应中脱敏显示 - H6: 债务列表接口添加用户隔离,普通用户只能看自己的数据 - H7: 债务详情/计划/记录接口添加归属校验 - H8: 账户级暴力破解锁定(5次失败锁定5分钟) - H9: 速率限制数据增加过期清理机制防内存泄漏 中危修复: - M1: 注册/重置密码接口添加后端密码强度校验(最少8位) - M4: 添加全局异常处理器,500错误不再暴露堆栈 - M5: Docker容器改为非root用户运行 - M6: 添加.dockerignore排除pyc和敏感文件 - M7: 管理员操作写入审计日志 - M8: CORS限制为实际使用的HTTP方法和头 其他: - Nginx隐藏版本号(server_tokens off) - 管理员角色修改添加枚举校验 - 密码重置验证码接口返回统一错误信息
This commit is contained in:
@@ -1,18 +1,18 @@
|
|||||||
# 数据库配置
|
# 数据库配置
|
||||||
POSTGRES_DB=debt_manager
|
POSTGRES_DB=debt_manager
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=CHANGE_ME_TO_RANDOM_32_CHAR_STRING
|
||||||
|
|
||||||
# 后端配置
|
# 后端配置
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/debt_manager
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/debt_manager
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
SECRET_KEY=change-this-in-production-please
|
SECRET_KEY=CHANGE_ME_TO_RANDOM_64_CHAR_HEX_STRING
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
# 管理员账户
|
# 管理员账户
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=Adm1n!2026#Str0ng
|
||||||
|
|
||||||
# SMTP 邮件配置(留空则不发送邮件)
|
# SMTP 邮件配置(留空则不发送邮件)
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
|
|||||||
8
backend/.dockerignore
Normal file
8
backend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
tests/
|
||||||
|
*.md
|
||||||
@@ -7,4 +7,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ async def update_user(
|
|||||||
if req.real_email is not None:
|
if req.real_email is not None:
|
||||||
user.real_email = req.real_email
|
user.real_email = req.real_email
|
||||||
if req.role is not None:
|
if req.role is not None:
|
||||||
|
if req.role not in ("user", "admin"):
|
||||||
|
raise HTTPException(status_code=400, detail="角色值无效")
|
||||||
user.role = req.role
|
user.role = req.role
|
||||||
if req.group_id is not None or req.group_id == "":
|
if req.group_id is not None or req.group_id == "":
|
||||||
user.group_id = req.group_id if req.group_id else None
|
user.group_id = req.group_id if req.group_id else None
|
||||||
@@ -338,7 +340,7 @@ async def get_email_config(
|
|||||||
smtp_host=settings.get("smtp_host", ""),
|
smtp_host=settings.get("smtp_host", ""),
|
||||||
smtp_port=int(settings.get("smtp_port", "465")),
|
smtp_port=int(settings.get("smtp_port", "465")),
|
||||||
smtp_user=settings.get("smtp_user", ""),
|
smtp_user=settings.get("smtp_user", ""),
|
||||||
smtp_password=settings.get("smtp_password", ""),
|
smtp_password="***" if settings.get("smtp_password") else "",
|
||||||
smtp_from=settings.get("smtp_from", ""),
|
smtp_from=settings.get("smtp_from", ""),
|
||||||
smtp_use_tls=settings.get("smtp_use_tls", "true") == "true",
|
smtp_use_tls=settings.get("smtp_use_tls", "true") == "true",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,9 +41,14 @@ async def login(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
from app.core.rate_limit import record_login_failure, clear_login_failures, get_account_failure_count, get_unnotified_threshold
|
from app.core.rate_limit import record_login_failure, clear_login_failures, get_account_failure_count, get_unnotified_threshold, is_account_blocked, get_account_remaining_seconds
|
||||||
ip = request.client.host if request.client else "unknown"
|
ip = request.client.host if request.client else "unknown"
|
||||||
account = req.account.strip().lower()
|
account = req.account.strip().lower()
|
||||||
|
|
||||||
|
if is_account_blocked(account):
|
||||||
|
remaining = get_account_remaining_seconds(account)
|
||||||
|
raise HTTPException(status_code=429, detail=f"该账户已被锁定,请 {remaining} 秒后再试")
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.email == account))
|
result = await db.execute(select(User).where(User.email == account))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user or not verify_password(req.password, user.password_hash):
|
if not user or not verify_password(req.password, user.password_hash):
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ async def list_debts(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
q = select(Debt)
|
q = select(Debt)
|
||||||
|
if user.role != "admin":
|
||||||
|
q = q.where(Debt.user_id == user.id)
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Debt.status == status)
|
q = q.where(Debt.status == status)
|
||||||
if creditor:
|
if creditor:
|
||||||
@@ -89,6 +91,8 @@ async def get_debt(
|
|||||||
debt = result.scalar_one_or_none()
|
debt = result.scalar_one_or_none()
|
||||||
if not debt:
|
if not debt:
|
||||||
raise HTTPException(status_code=404, detail="债务不存在")
|
raise HTTPException(status_code=404, detail="债务不存在")
|
||||||
|
if debt.user_id != user.id and user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="无权访问此债务")
|
||||||
return await _enrich(db, debt)
|
return await _enrich(db, debt)
|
||||||
|
|
||||||
|
|
||||||
@@ -153,8 +157,11 @@ async def get_plans(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
||||||
if not result.scalar_one_or_none():
|
debt = result.scalar_one_or_none()
|
||||||
|
if not debt:
|
||||||
raise HTTPException(status_code=404, detail="债务不存在")
|
raise HTTPException(status_code=404, detail="债务不存在")
|
||||||
|
if debt.user_id != user.id and user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="无权访问此债务")
|
||||||
pr = await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt_id).order_by(RepaymentPlan.due_date))
|
pr = await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt_id).order_by(RepaymentPlan.due_date))
|
||||||
return pr.scalars().all()
|
return pr.scalars().all()
|
||||||
|
|
||||||
@@ -166,7 +173,10 @@ async def get_records(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
||||||
if not result.scalar_one_or_none():
|
debt = result.scalar_one_or_none()
|
||||||
|
if not debt:
|
||||||
raise HTTPException(status_code=404, detail="债务不存在")
|
raise HTTPException(status_code=404, detail="债务不存在")
|
||||||
|
if debt.user_id != user.id and user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="无权访问此债务")
|
||||||
rr = await db.execute(select(RepaymentRecord).where(RepaymentRecord.debt_id == debt_id).order_by(RepaymentRecord.paid_date.desc()))
|
rr = await db.execute(select(RepaymentRecord).where(RepaymentRecord.debt_id == debt_id).order_by(RepaymentRecord.paid_date.desc()))
|
||||||
return rr.scalars().all()
|
return rr.scalars().all()
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ class Settings(BaseSettings):
|
|||||||
API_V1_PREFIX: str = "/api/v1"
|
API_V1_PREFIX: str = "/api/v1"
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/debt_manager"
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/debt_manager"
|
||||||
REDIS_URL: str = "redis://redis:6379/0"
|
REDIS_URL: str = "redis://redis:6379/0"
|
||||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
SECRET_KEY: str = "562fa55e5e0cea1e00ff645be1da4f7c932439d97de7ffacd3d95156d9ccd7af"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
CORS_ORIGINS: list[str] = ["http://localhost", "http://localhost:80", "http://localhost:806"]
|
CORS_ORIGINS: list[str] = ["http://localhost", "http://localhost:80", "http://localhost:806"]
|
||||||
ADMIN_EMAIL: str = "admin@example.com"
|
ADMIN_EMAIL: str = "admin@example.com"
|
||||||
ADMIN_PASSWORD: str = "admin123"
|
ADMIN_PASSWORD: str = "Adm1n!2026#Str0ng"
|
||||||
|
|
||||||
SMTP_HOST: str = ""
|
SMTP_HOST: str = ""
|
||||||
SMTP_PORT: int = 465
|
SMTP_PORT: int = 465
|
||||||
|
|||||||
@@ -3,21 +3,41 @@ from collections import defaultdict
|
|||||||
from fastapi import Request, HTTPException
|
from fastapi import Request, HTTPException
|
||||||
|
|
||||||
MAX_FAILURES = 10
|
MAX_FAILURES = 10
|
||||||
|
ACCOUNT_MAX_FAILURES = 5
|
||||||
WINDOW_SECONDS = 300
|
WINDOW_SECONDS = 300
|
||||||
ALERT_THRESHOLDS = [3, 5, 10]
|
ALERT_THRESHOLDS = [3, 5, 10]
|
||||||
|
|
||||||
_login_attempts: dict[str, list[float]] = defaultdict(list)
|
_login_attempts: dict[str, list[float]] = defaultdict(list)
|
||||||
_account_failures: dict[str, list[float]] = defaultdict(list)
|
_account_failures: dict[str, list[float]] = defaultdict(list)
|
||||||
_account_notified: dict[str, set[int]] = defaultdict(set)
|
_account_notified: dict[str, set[int]] = defaultdict(set)
|
||||||
|
_account_blocked: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup():
|
||||||
|
now = time.time()
|
||||||
|
expired_ips = [ip for ip, times in _login_attempts.items() if not times or now - times[-1] > WINDOW_SECONDS]
|
||||||
|
for ip in expired_ips:
|
||||||
|
del _login_attempts[ip]
|
||||||
|
expired_accounts = [acc for acc, times in _account_failures.items() if not times or now - times[-1] > WINDOW_SECONDS]
|
||||||
|
for acc in expired_accounts:
|
||||||
|
_account_failures.pop(acc, None)
|
||||||
|
_account_notified.pop(acc, None)
|
||||||
|
expired_blocked = [acc for acc, until in _account_blocked.items() if now > until]
|
||||||
|
for acc in expired_blocked:
|
||||||
|
del _account_blocked[acc]
|
||||||
|
|
||||||
|
|
||||||
def record_login_failure(ip: str, account: str = ""):
|
def record_login_failure(ip: str, account: str = ""):
|
||||||
|
_cleanup()
|
||||||
now = time.time()
|
now = time.time()
|
||||||
_login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS]
|
_login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS]
|
||||||
_login_attempts[ip].append(now)
|
_login_attempts[ip].append(now)
|
||||||
if account:
|
if account:
|
||||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||||
_account_failures[account].append(now)
|
_account_failures[account].append(now)
|
||||||
|
count = len(_account_failures[account])
|
||||||
|
if count >= ACCOUNT_MAX_FAILURES:
|
||||||
|
_account_blocked[account] = now + WINDOW_SECONDS
|
||||||
|
|
||||||
|
|
||||||
def clear_login_failures(ip: str, account: str = ""):
|
def clear_login_failures(ip: str, account: str = ""):
|
||||||
@@ -25,6 +45,7 @@ def clear_login_failures(ip: str, account: str = ""):
|
|||||||
if account:
|
if account:
|
||||||
_account_failures.pop(account, None)
|
_account_failures.pop(account, None)
|
||||||
_account_notified.pop(account, None)
|
_account_notified.pop(account, None)
|
||||||
|
_account_blocked.pop(account, None)
|
||||||
|
|
||||||
|
|
||||||
def get_remaining_seconds(ip: str) -> int:
|
def get_remaining_seconds(ip: str) -> int:
|
||||||
@@ -37,6 +58,16 @@ def get_remaining_seconds(ip: str) -> int:
|
|||||||
return max(remaining, 0)
|
return max(remaining, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_remaining_seconds(account: str) -> int:
|
||||||
|
now = time.time()
|
||||||
|
if account in _account_blocked:
|
||||||
|
remaining = int(_account_blocked[account] - now)
|
||||||
|
if remaining > 0:
|
||||||
|
return remaining
|
||||||
|
del _account_blocked[account]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def get_account_failure_count(account: str) -> int:
|
def get_account_failure_count(account: str) -> int:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||||
@@ -59,6 +90,10 @@ def is_ip_blocked(ip: str) -> bool:
|
|||||||
return get_remaining_seconds(ip) > 0 and len(_login_attempts[ip]) >= MAX_FAILURES
|
return get_remaining_seconds(ip) > 0 and len(_login_attempts[ip]) >= MAX_FAILURES
|
||||||
|
|
||||||
|
|
||||||
|
def is_account_blocked(account: str) -> bool:
|
||||||
|
return get_account_remaining_seconds(account) > 0
|
||||||
|
|
||||||
|
|
||||||
async def ip_rate_limit_middleware(request: Request, call_next):
|
async def ip_rate_limit_middleware(request: Request, call_next):
|
||||||
ip = request.client.host if request.client else "unknown"
|
ip = request.client.host if request.client else "unknown"
|
||||||
if is_ip_blocked(ip):
|
if is_ip_blocked(ip):
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.CORS_ORIGINS,
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -138,6 +138,11 @@ async def rate_limit_middleware(request: Request, call_next):
|
|||||||
return JSONResponse(status_code=429, content={"detail": f"登录失败次数过多,请 {remaining} 秒后再试", "retry_after": remaining})
|
return JSONResponse(status_code=429, content={"detail": f"登录失败次数过多,请 {remaining} 秒后再试", "retry_after": remaining})
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
return JSONResponse(status_code=500, content={"detail": "服务器内部错误"})
|
||||||
|
|
||||||
app.include_router(auth.router, prefix=settings.API_V1_PREFIX)
|
app.include_router(auth.router, prefix=settings.API_V1_PREFIX)
|
||||||
app.include_router(loans.router, prefix=settings.API_V1_PREFIX)
|
app.include_router(loans.router, prefix=settings.API_V1_PREFIX)
|
||||||
app.include_router(debts.router, prefix=settings.API_V1_PREFIX)
|
app.include_router(debts.router, prefix=settings.API_V1_PREFIX)
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: debt_manager
|
POSTGRES_DB: debt_manager
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: b6f1c451ed57041ec9a138a61c03cead
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- "54326:5432"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -22,7 +20,7 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/debt_manager
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/debt_manager
|
||||||
SECRET_KEY: change-this-in-production-please
|
SECRET_KEY: 562fa55e5e0cea1e00ff645be1da4f7c932439d97de7ffacd3d95156d9ccd7af
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ let allGroups=[];
|
|||||||
let allUsers=[];
|
let allUsers=[];
|
||||||
let currentGroupId=null;
|
let currentGroupId=null;
|
||||||
|
|
||||||
const $=id=>document.getElementById(id);
|
const $=id=>document.getElementById(id);function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
||||||
|
|
||||||
async function apiFetch(path,opts={}){
|
async function apiFetch(path,opts={}){
|
||||||
const headers={'Content-Type':'application/json',...opts.headers};
|
const headers={'Content-Type':'application/json',...opts.headers};
|
||||||
@@ -313,11 +313,11 @@ async function loadGroups(){
|
|||||||
const tb=$('groupTable');
|
const tb=$('groupTable');
|
||||||
if(!allGroups.length){tb.innerHTML='<tr><td colspan="4" class="empty">暂无组</td></tr>';return;}
|
if(!allGroups.length){tb.innerHTML='<tr><td colspan="4" class="empty">暂无组</td></tr>';return;}
|
||||||
tb.innerHTML=allGroups.map(g=>`<tr>
|
tb.innerHTML=allGroups.map(g=>`<tr>
|
||||||
<td><strong>${g.name}</strong></td>
|
<td><strong>${escapeHtml(g.name)}</strong></td>
|
||||||
<td>${g.description||'-'}</td>
|
<td>${escapeHtml(g.description||'-')}</td>
|
||||||
<td>${g.member_count}人</td>
|
<td>${g.member_count}人</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-sm btn-enable" onclick="showMembers(${g.id},'${g.name}')">管理成员</button>
|
<button class="btn-sm btn-enable" onclick="showMembers(${g.id},'${escapeHtml(g.name)}')">管理成员</button>
|
||||||
<button class="btn-sm btn-danger" onclick="deleteGroup(${g.id})">删除</button>
|
<button class="btn-sm btn-danger" onclick="deleteGroup(${g.id})">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
@@ -344,11 +344,11 @@ async function loadUsers(){
|
|||||||
if(u.status==='pending')actions=`<button class="btn-sm btn-approve" onclick="approveUser(${u.id})">通过</button>`;
|
if(u.status==='pending')actions=`<button class="btn-sm btn-approve" onclick="approveUser(${u.id})">通过</button>`;
|
||||||
if(u.status==='active')actions=`<button class="btn-sm btn-disable" onclick="setStatus(${u.id},'disabled')">禁用</button>`;
|
if(u.status==='active')actions=`<button class="btn-sm btn-disable" onclick="setStatus(${u.id},'disabled')">禁用</button>`;
|
||||||
if(u.status==='disabled')actions+=` <button class="btn-sm btn-enable" onclick="setStatus(${u.id},'active')">启用</button> <button class="btn-sm btn-restore" onclick="setStatus(${u.id},'pending')">恢复待审</button>`;
|
if(u.status==='disabled')actions+=` <button class="btn-sm btn-enable" onclick="setStatus(${u.id},'active')">启用</button> <button class="btn-sm btn-restore" onclick="setStatus(${u.id},'pending')">恢复待审</button>`;
|
||||||
actions+=` <button class="btn-sm btn-enable" onclick="showEditUser(${u.id},'${u.account}','${u.nickname||''}','${u.real_email||''}','${u.role}')">编辑</button>`;
|
actions+=` <button class="btn-sm btn-enable" onclick="showEditUser(${u.id},'${escapeHtml(u.account)}','${u.nickname||''}','${u.real_email||''}','${u.role}')">编辑</button>`;
|
||||||
actions+=` <button class="btn-sm btn-enable" onclick="sendResetLink(${u.id},'${u.account}')">改密</button>`;
|
actions+=` <button class="btn-sm btn-enable" onclick="sendResetLink(${u.id},'${escapeHtml(u.account)}')">改密</button>`;
|
||||||
actions+=` <button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${u.account}')">删除</button>`;
|
actions+=` <button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${escapeHtml(u.account)}')">删除</button>`;
|
||||||
}
|
}
|
||||||
return`<tr><td>${u.account}</td><td>${u.nickname}</td><td>${emailDisplay}</td><td>${roleBadge}</td><td>${groupBadge}</td><td>${u.debt_count}笔</td><td>${statusBadge}</td><td>${actions}</td></tr>`;
|
return`<tr><td>${escapeHtml(u.account)}</td><td>${escapeHtml(u.nickname)}</td><td>${emailDisplay}</td><td>${roleBadge}</td><td>${groupBadge}</td><td>${u.debt_count}笔</td><td>${statusBadge}</td><td>${actions}</td></tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +419,7 @@ async function showMembers(gid,gname){
|
|||||||
const members=allUsers.filter(u=>u.group_id===gid);
|
const members=allUsers.filter(u=>u.group_id===gid);
|
||||||
$('memberList').innerHTML=members.length?members.map(u=>`<span style="display:inline-block;padding:3px 8px;margin:2px;background:#eef2f7;border-radius:4px;font-size:12px">${u.nickname||u.account} <a href="#" onclick="removeMember(${u.id});return false" style="color:#dc2626;margin-left:4px">×</a></span>`).join(''):'<span style="font-size:12px;color:#94a3b8">暂无成员</span>';
|
$('memberList').innerHTML=members.length?members.map(u=>`<span style="display:inline-block;padding:3px 8px;margin:2px;background:#eef2f7;border-radius:4px;font-size:12px">${u.nickname||u.account} <a href="#" onclick="removeMember(${u.id});return false" style="color:#dc2626;margin-left:4px">×</a></span>`).join(''):'<span style="font-size:12px;color:#94a3b8">暂无成员</span>';
|
||||||
const unassigned=allUsers.filter(u=>!u.group_id&&u.role!=='admin');
|
const unassigned=allUsers.filter(u=>!u.group_id&&u.role!=='admin');
|
||||||
$('memberSelect').innerHTML=unassigned.map(u=>`<option value="${u.id}">${u.nickname||u.account} (${u.account})</option>`).join('');
|
$('memberSelect').innerHTML=unassigned.map(u=>`<option value="${u.id}">${u.nickname||u.account} (${escapeHtml(u.account)})</option>`).join('');
|
||||||
$('memberModal').classList.add('show');
|
$('memberModal').classList.add('show');
|
||||||
}
|
}
|
||||||
function closeMemberModal(){$('memberModal').classList.remove('show');currentGroupId=null;}
|
function closeMemberModal(){$('memberModal').classList.remove('show');currentGroupId=null;}
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ let hideOthers=true;
|
|||||||
let quickPayLoanId=null;
|
let quickPayLoanId=null;
|
||||||
let actionLoanId=null;
|
let actionLoanId=null;
|
||||||
|
|
||||||
const $=id=>document.getElementById(id);
|
const $=id=>document.getElementById(id);function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
||||||
const fmt=n=>n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
|
const fmt=n=>n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||||
const today=()=>new Date().toISOString().slice(0,10);
|
const today=()=>new Date().toISOString().slice(0,10);
|
||||||
const ymOf=d=>d.slice(0,7);
|
const ymOf=d=>d.slice(0,7);
|
||||||
@@ -445,7 +445,7 @@ document.addEventListener('click',function(){$('notifDropdown')?.classList.remov
|
|||||||
|
|
||||||
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);}
|
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 renderNotifBadge(unread){const badge=$('notifBadge');if(unread>0){badge.style.display='';badge.textContent=unread>99?'99+':unread;}else{badge.style.display='none';}}
|
||||||
function renderNotifList(items){const el=$('notifList');if(!items.length){el.innerHTML='<div class="notif-empty">暂无通知</div>';return;}el.innerHTML=items.map(n=>`<div class="notif-item${n.is_read===0?' unread':''}" onclick="readNotif(${n.id},'${n.type}',${n.related_debt_id||0})"><div class="notif-icon ${n.type}">${notifTypeIcon(n.type)}</div><div class="notif-content"><div class="notif-title">${n.title}</div><div class="notif-msg">${n.message}</div></div><div class="notif-time">${timeAgo(n.created_at)}</div></div>`).join('');}
|
function renderNotifList(items){const el=$('notifList');if(!items.length){el.innerHTML='<div class="notif-empty">暂无通知</div>';return;}el.innerHTML=items.map(n=>`<div class="notif-item${n.is_read===0?' unread':''}" onclick="readNotif(${n.id},'${n.type}',${n.related_debt_id||0})"><div class="notif-icon ${n.type}">${notifTypeIcon(n.type)}</div><div class="notif-content"><div class="notif-title">${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,type,debtId){await apiFetch('/notifications/'+id+'/read',{method:'PUT'});loadNotifications();if(debtId&&(type==='repayment_due'||type==='repayment_overdue')){$('notifDropdown').classList.remove('show');}}
|
async function readNotif(id,type,debtId){await apiFetch('/notifications/'+id+'/read',{method:'PUT'});loadNotifications();if(debtId&&(type==='repayment_due'||type==='repayment_overdue')){$('notifDropdown').classList.remove('show');}}
|
||||||
async function markAllNotifRead(){await apiFetch('/notifications/read-all',{method:'PUT'});loadNotifications();}
|
async function markAllNotifRead(){await apiFetch('/notifications/read-all',{method:'PUT'});loadNotifications();}
|
||||||
@@ -533,7 +533,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">${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"><button class="act-btn act-btn-edit" onclick="event.stopPropagation();doEditLoan(${l.id})">✏️ 修改</button><button class="act-btn act-btn-pay" onclick="event.stopPropagation();toggleQuickPay(${l.id})">💰 快速还款</button>${isOwner?`<button class="act-btn act-btn-del" onclick="event.stopPropagation();showDel(${l.id})">🗑 删除</button>`:''}</div>`:''}${quickPayLoanId===l.id?renderQuickPay(l):''}`;});html+=`</div></div>`;});el.innerHTML=html;}
|
||||||
function toggleGroup(name){if(openGroups.has(name))openGroups.delete(name);else openGroups.add(name);renderList();}
|
function 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'});}
|
||||||
|
|
||||||
@@ -557,7 +557,7 @@ function toggleQuickPay(id){quickPayLoanId=quickPayLoanId===id?null:id;renderLis
|
|||||||
|
|
||||||
function renderQuickPay(l){
|
function renderQuickPay(l){
|
||||||
const isOwner=l.user_id===currentUser.id;
|
const isOwner=l.user_id===currentUser.id;
|
||||||
let html=`<div class="quick-panel"><div class="qp-title">还款节点 · ${l.name} ${l.purpose?'· '+l.purpose:''}</div>`;
|
let html=`<div class="quick-panel"><div class="qp-title">还款节点 · ${escapeHtml(l.name)} ${l.purpose?'· '+l.purpose:''}</div>`;
|
||||||
l.schedule.forEach(r=>{
|
l.schedule.forEach(r=>{
|
||||||
const isPaid=l.paid.includes(r.p);
|
const isPaid=l.paid.includes(r.p);
|
||||||
const btnClass=isPaid?'qp-btn-done':'qp-btn-pay';
|
const btnClass=isPaid?'qp-btn-done':'qp-btn-pay';
|
||||||
@@ -578,7 +578,7 @@ const statsExpanded=new Set();
|
|||||||
function renderStats(){let totalAmt=0,totalInt=0,totalPaid=0,paidN=0,totalN=0;loans.forEach(l=>{totalAmt+=l.amount;totalN+=l.periods;const s=l.schedule;totalInt+=s.reduce((a,r)=>a+r.it,0);paidN+=l.paid.length;totalPaid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const totalPay=totalAmt+totalInt;const memberMap=new Map();loans.forEach(l=>{const owner=l.owner_name||'未知';if(!memberMap.has(owner))memberMap.set(owner,{amt:0,int:0,paid:0,paidN:0,totalN:0});const m=memberMap.get(owner);m.amt+=l.amount;m.totalN+=l.periods;const s=l.schedule;m.int+=s.reduce((a,r)=>a+r.it,0);m.paidN+=l.paid.length;m.paid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const cards=[{lbl:'借款总额',val:`¥${fmt(totalAmt)}`,cls:'blue',key:'amt'},{lbl:'总利息',val:`¥${fmt(totalInt)}`,cls:'red',key:'int'},{lbl:'总还款',val:`¥${fmt(totalPay)}`,cls:'',key:'pay'},{lbl:'已还',val:`¥${fmt(totalPaid)}`,cls:'green',key:'paid'},{lbl:'待还',val:`¥${fmt(totalPay-totalPaid)}`,cls:'red',key:'remain'},{lbl:'已还期数',val:`${paidN}/${totalN}`,cls:'green',key:'periods'}];let html='';cards.forEach(c=>{const isOpen=statsExpanded.has(c.key);html+=`<div class="stat-block"><div class="stat-card clickable" onclick="toggleStat('${c.key}')"><div class="lbl">${c.lbl}</div><div class="val ${c.cls}">${c.val}</div><div class="stat-arrow${isOpen?' open':''}">▶</div></div>`;if(isOpen){html+=`<div class="stats-detail-wrap">`;memberMap.forEach((m,owner)=>{const vals={amt:fmt(m.amt),int:fmt(m.int),pay:fmt(m.amt+m.int),paid:fmt(m.paid),remain:fmt(m.amt+m.int-m.paid),periods:`${m.paidN}/${m.totalN}`};html+=`<div class="stat-sub"><span>${owner}</span><span class="stat-sub-val">${vals[c.key]}</span></div>`;});html+=`</div>`;}html+=`</div>`;});$('statsGrid').innerHTML=html;}
|
function renderStats(){let totalAmt=0,totalInt=0,totalPaid=0,paidN=0,totalN=0;loans.forEach(l=>{totalAmt+=l.amount;totalN+=l.periods;const s=l.schedule;totalInt+=s.reduce((a,r)=>a+r.it,0);paidN+=l.paid.length;totalPaid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const totalPay=totalAmt+totalInt;const memberMap=new Map();loans.forEach(l=>{const owner=l.owner_name||'未知';if(!memberMap.has(owner))memberMap.set(owner,{amt:0,int:0,paid:0,paidN:0,totalN:0});const m=memberMap.get(owner);m.amt+=l.amount;m.totalN+=l.periods;const s=l.schedule;m.int+=s.reduce((a,r)=>a+r.it,0);m.paidN+=l.paid.length;m.paid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const cards=[{lbl:'借款总额',val:`¥${fmt(totalAmt)}`,cls:'blue',key:'amt'},{lbl:'总利息',val:`¥${fmt(totalInt)}`,cls:'red',key:'int'},{lbl:'总还款',val:`¥${fmt(totalPay)}`,cls:'',key:'pay'},{lbl:'已还',val:`¥${fmt(totalPaid)}`,cls:'green',key:'paid'},{lbl:'待还',val:`¥${fmt(totalPay-totalPaid)}`,cls:'red',key:'remain'},{lbl:'已还期数',val:`${paidN}/${totalN}`,cls:'green',key:'periods'}];let html='';cards.forEach(c=>{const isOpen=statsExpanded.has(c.key);html+=`<div class="stat-block"><div class="stat-card clickable" onclick="toggleStat('${c.key}')"><div class="lbl">${c.lbl}</div><div class="val ${c.cls}">${c.val}</div><div class="stat-arrow${isOpen?' open':''}">▶</div></div>`;if(isOpen){html+=`<div class="stats-detail-wrap">`;memberMap.forEach((m,owner)=>{const vals={amt:fmt(m.amt),int:fmt(m.int),pay:fmt(m.amt+m.int),paid:fmt(m.paid),remain:fmt(m.amt+m.int-m.paid),periods:`${m.paidN}/${m.totalN}`};html+=`<div class="stat-sub"><span>${owner}</span><span class="stat-sub-val">${vals[c.key]}</span></div>`;});html+=`</div>`;}html+=`</div>`;});$('statsGrid').innerHTML=html;}
|
||||||
function toggleStat(key){if(statsExpanded.has(key))statsExpanded.delete(key);else statsExpanded.add(key);renderStats();}
|
function toggleStat(key){if(statsExpanded.has(key))statsExpanded.delete(key);else statsExpanded.add(key);renderStats();}
|
||||||
|
|
||||||
function buildMonthMap(){const map={};const visibleLoans=hideOthers?loans.filter(l=>l.user_id===currentUser.id):loans;visibleLoans.forEach(l=>{const displayName=l.purpose?`${l.name} · ${l.purpose}`:l.name;const ownerTag=l.owner_name?` [${l.owner_name}]`:'';l.schedule.forEach(r=>{const ym=ymOf(r.date);if(!map[ym])map[ym]=[];map[ym].push({loanId:l.id,loanName:displayName+ownerTag,method:l.method,...r,paid:l.paid.includes(r.p)});});});return map;}
|
function buildMonthMap(){const map={};const visibleLoans=hideOthers?loans.filter(l=>l.user_id===currentUser.id):loans;visibleLoans.forEach(l=>{const displayName=l.purpose?`${escapeHtml(l.name)} · ${l.purpose}`:l.name;const ownerTag=l.owner_name?` [${l.owner_name}]`:'';l.schedule.forEach(r=>{const ym=ymOf(r.date);if(!map[ym])map[ym]=[];map[ym].push({loanId:l.id,loanName:displayName+ownerTag,method:l.method,...r,paid:l.paid.includes(r.p)});});});return map;}
|
||||||
|
|
||||||
function renderMonthPlan(){const map=buildMonthMap();let months=Object.keys(map).sort();if(!months.length){$('monthList').innerHTML='<div class="empty-tip">暂无还款计划</div>';return;}if(hidePaidMonths)months=months.filter(ym=>{const items=map[ym];return items.some(r=>!r.paid);});if(!months.length){$('monthList').innerHTML='<div class="empty-tip">所有月份已还清</div>';return;}const now=today(),nowYm=now.slice(0,7);$('monthList').innerHTML=months.map(ym=>{const items=map[ym];const monthTotal=items.reduce((a,r)=>a+r.pay,0);const paidCount=items.filter(r=>r.paid).length;const allPaid=paidCount===items.length;const isPast=ym<nowYm;const isNow=ym===nowYm;const isOpen=openMonths.has(ym);const payAllBtn=allPaid?'':`<button class="btn-pay-all" onclick="event.stopPropagation();payAllMonth('${ym}')">一键还款</button>`;return`<div class="month-group"><div class="month-header" onclick="toggleMonth('${ym}')"><span class="ym">${ym.replace('-','年')}月${isNow?' (本月)':''}${isPast&&!allPaid?' ⚠️':''}</span><span style="display:flex;align-items:center;gap:12px"><span style="font-size:11px;color:var(--text3)">${paidCount}/${items.length}笔</span>${payAllBtn}<span class="total"${allPaid?' style="color:var(--green)"':''}>${allPaid?'✓ ':''}¥${fmt(monthTotal)}</span><span class="arrow${isOpen?' open':''}">▶</span></span></div><div class="month-detail${isOpen?' show':''}">${items.map(r=>{const ml=methodLabel(r.method);const ds=r.method==='i'?`利息 ¥${fmt(r.it)}${r.pr>0?' + 本金 ¥'+fmt(r.pr):''}`:`本金 ¥${fmt(r.pr)} + 利息 ¥${fmt(r.it)}`;const isChecked=r.paid;const cls='pay-check'+(isChecked?' checked':'')+(toggleLock?' disabled':'');return`<div class="loan-row${isChecked?' paid':''}" data-loan="${r.loanId}" data-period="${r.p}"><div class="left"><span class="borrower">${r.loanName}${isChecked?' <span class="paid-badge">✓ 已还</span>':''}</span><span class="detail">${ml} · 第${r.p}期 · ${ds}</span></div><div class="right"><div class="pay-amt">¥${r.pay.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g,',')}</div><div class="pay-date">${r.date}</div></div><div class="${cls}" onclick="event.stopPropagation();togglePaid(${r.loanId},${r.p})"></div></div>`;}).join('')}</div></div>`;}).join('');}
|
function renderMonthPlan(){const map=buildMonthMap();let months=Object.keys(map).sort();if(!months.length){$('monthList').innerHTML='<div class="empty-tip">暂无还款计划</div>';return;}if(hidePaidMonths)months=months.filter(ym=>{const items=map[ym];return items.some(r=>!r.paid);});if(!months.length){$('monthList').innerHTML='<div class="empty-tip">所有月份已还清</div>';return;}const now=today(),nowYm=now.slice(0,7);$('monthList').innerHTML=months.map(ym=>{const items=map[ym];const monthTotal=items.reduce((a,r)=>a+r.pay,0);const paidCount=items.filter(r=>r.paid).length;const allPaid=paidCount===items.length;const isPast=ym<nowYm;const isNow=ym===nowYm;const isOpen=openMonths.has(ym);const payAllBtn=allPaid?'':`<button class="btn-pay-all" onclick="event.stopPropagation();payAllMonth('${ym}')">一键还款</button>`;return`<div class="month-group"><div class="month-header" onclick="toggleMonth('${ym}')"><span class="ym">${ym.replace('-','年')}月${isNow?' (本月)':''}${isPast&&!allPaid?' ⚠️':''}</span><span style="display:flex;align-items:center;gap:12px"><span style="font-size:11px;color:var(--text3)">${paidCount}/${items.length}笔</span>${payAllBtn}<span class="total"${allPaid?' style="color:var(--green)"':''}>${allPaid?'✓ ':''}¥${fmt(monthTotal)}</span><span class="arrow${isOpen?' open':''}">▶</span></span></div><div class="month-detail${isOpen?' show':''}">${items.map(r=>{const ml=methodLabel(r.method);const ds=r.method==='i'?`利息 ¥${fmt(r.it)}${r.pr>0?' + 本金 ¥'+fmt(r.pr):''}`:`本金 ¥${fmt(r.pr)} + 利息 ¥${fmt(r.it)}`;const isChecked=r.paid;const cls='pay-check'+(isChecked?' checked':'')+(toggleLock?' disabled':'');return`<div class="loan-row${isChecked?' paid':''}" data-loan="${r.loanId}" data-period="${r.p}"><div class="left"><span class="borrower">${r.loanName}${isChecked?' <span class="paid-badge">✓ 已还</span>':''}</span><span class="detail">${ml} · 第${r.p}期 · ${ds}</span></div><div class="right"><div class="pay-amt">¥${r.pay.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g,',')}</div><div class="pay-date">${r.date}</div></div><div class="${cls}" onclick="event.stopPropagation();togglePaid(${r.loanId},${r.p})"></div></div>`;}).join('')}</div></div>`;}).join('');}
|
||||||
|
|
||||||
@@ -590,9 +590,9 @@ function toggleMonth(ym){if(openMonths.has(ym))openMonths.delete(ym);else openMo
|
|||||||
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');});
|
||||||
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+=`${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 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 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 exportJSON(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');const data=loans.map(l=>({借款人:l.name,借款日期:l.date,借款金额:l.amount,年利率:l.rate,分期数:l.periods,还款方式:methodLabel(l.method),已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',还款计划:l.schedule.map(r=>({期数:r.p,日期:r.date,还款额:r.pay,本金:r.pr,利息:r.it}))}));download('债务统计_'+today()+'.json',JSON.stringify(data,null,2),'application/json;charset=utf-8');}
|
||||||
function exportHTML(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let h=`<html><head><meta charset="utf-8"><title>债务统计</title><style>body{font-family:"Microsoft YaHei",sans-serif;padding:20px}h2{color:#1d1d1f}table{border-collapse:collapse;width:100%;font-size:13px;margin-bottom:20px}th{background:#0071e3;color:#fff;padding:8px 12px;text-align:left}td{padding:6px 12px;border-bottom:1px solid #d2d2d7}tr:nth-child(even){background:#f5f5f7}.done{color:#34c759;font-weight:600}</style></head><body><h2>债务统计报表 - ${today()}</h2>`;loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`<h3>${l.name} - ${l.date} - ¥${fmt(l.amount)}${st?' <span class="done">已还清</span>':''}</h3><p>年利率 ${l.rate}% · ${methodLabel(l.method)} · ${l.periods}期 · 总利息 ¥${ti.toFixed(2)}</p><table><tr><th>期数</th><th>日期</th><th>还款额</th><th>本金</th><th>利息</th><th>剩余本金</th><th>状态</th></tr>`;l.schedule.forEach(r=>{h+=`<tr><td>${r.p}</td><td>${r.date}</td><td>¥${fmt(r.pay)}</td><td>¥${fmt(r.pr)}</td><td>¥${fmt(r.it)}</td><td>¥${fmt(r.rp)}</td><td>${l.paid.includes(r.p)?'✓ 已还':'待还'}</td></tr>`;});h+=`</table>`;});h+=`</body></html>`;download('债务统计_'+today()+'.html',h,'text/html;charset=utf-8');}
|
function 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 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');}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" always;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
Reference in New Issue
Block a user