Add vue-router, login/setup flow and backend logging

Refactor frontend to route-based navigation with vue-router, add
system setup and login pages with API integration. Add structured
logging, access-log middleware and startup lifecycle to FastAPI
backend.
This commit is contained in:
2026-05-06 22:23:42 +08:00
parent 83d7da3d62
commit ae63766c91
35 changed files with 3762 additions and 404 deletions

View File

@@ -44,7 +44,13 @@ class Settings(BaseSettings):
redis_url: str | None = Field(default=None, alias="REDIS_URL")
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
vite_api_base_url: str = Field(default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL")
vite_api_base_url: str = Field(
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
)
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_dir: str = Field(default="logs", alias="LOG_DIR")
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
@property
def resolved_database_url(self) -> str:

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
_LEVEL_COLORS: dict[int, str] = {
logging.DEBUG: "\033[36m",
logging.INFO: "\033[32m",
logging.WARNING: "\033[33m",
logging.ERROR: "\033[31m",
logging.CRITICAL: "\033[1;31m",
}
_RESET = "\033[0m"
class _ColorFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
color = _LEVEL_COLORS.get(record.levelno, "")
record.levelname = f"{color}{record.levelname:<8}{_RESET}"
return super().format(record)
def _build_console_handler(level: int) -> logging.StreamHandler:
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(level)
handler.setFormatter(_ColorFormatter(LOG_FORMAT, datefmt=DATE_FORMAT))
return handler
def _build_file_handler(log_dir: Path, level: int) -> RotatingFileHandler:
log_dir.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_dir / "app.log",
maxBytes=10 * 1024 * 1024,
backupCount=10,
encoding="utf-8",
)
handler.setLevel(level)
handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT))
return handler
def setup_logging(
*,
level: str = "INFO",
log_dir: str = "logs",
enable_file: bool = True,
) -> None:
numeric_level = getattr(logging, level.upper(), logging.INFO)
root = logging.getLogger()
root.setLevel(numeric_level)
root.handlers.clear()
root.addHandler(_build_console_handler(numeric_level))
if enable_file:
from app.core.config import SERVER_DIR
file_path = SERVER_DIR / log_dir
root.addHandler(_build_file_handler(file_path, numeric_level))
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)

View File

@@ -5,17 +5,32 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.middleware.logging import AccessLogMiddleware
def create_app() -> FastAPI:
settings = get_settings()
setup_logging(
level=settings.log_level,
log_dir=settings.log_dir,
enable_file=settings.log_file_enabled,
)
logger = get_logger("app.main")
logger.info(
"Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug
)
app = FastAPI(
title=settings.app_name,
debug=settings.app_debug,
version="0.1.0",
)
app.add_middleware(AccessLogMiddleware)
if settings.cors_origins:
app.add_middleware(
CORSMiddleware,
@@ -31,6 +46,15 @@ def create_app() -> FastAPI:
def root() -> dict[str, str]:
return {"message": f"{settings.app_name} is running"}
@app.on_event("startup")
def _on_startup() -> None:
logger.info(
"Server ready — host=%s port=%s prefix=%s",
settings.app_host,
settings.app_port,
settings.api_v1_prefix,
)
return app

View File

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from app.core.logging import get_logger
logger = get_logger("app.middleware.access")
_SKIP_PATHS: frozenset[str] = frozenset({"/", "/docs", "/openapi.json", "/redoc"})
class AccessLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.url.path in _SKIP_PATHS:
return await call_next(request)
request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex[:12])
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
level = logging.WARNING if response.status_code >= 500 else logging.INFO
logger.log(
level,
"%s %s %s %.1fms request_id=%s",
request.method,
request.url.path,
response.status_code,
duration_ms,
request_id,
)
response.headers["X-Request-ID"] = request_id
return response

View File

@@ -1,20 +1,34 @@
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.models.employee import Employee
from app.repositories.employee import EmployeeRepository
from app.schemas.employee import EmployeeCreate
logger = get_logger("app.services.employee")
class EmployeeService:
def __init__(self, db: Session) -> None:
self.repository = EmployeeRepository(db)
def list_employees(self) -> list[Employee]:
return self.repository.list()
employees = self.repository.list()
logger.info("Listed employees (count=%d)", len(employees))
return employees
def get_employee(self, employee_id: str) -> Employee | None:
return self.repository.get(employee_id)
employee = self.repository.get(employee_id)
if employee:
logger.info("Fetched employee id=%s name=%s", employee_id, employee.name)
else:
logger.warning("Employee not found id=%s", employee_id)
return employee
def create_employee(self, payload: EmployeeCreate) -> Employee:
employee = Employee(**payload.model_dump())
return self.repository.create(employee)
created = self.repository.create(employee)
logger.info(
"Created employee id=%s no=%s name=%s", created.id, created.employee_no, created.name
)
return created

View File

@@ -1,20 +1,37 @@
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.models.reimbursement import ReimbursementRequest
from app.repositories.reimbursement import ReimbursementRepository
from app.schemas.reimbursement import ReimbursementCreate
logger = get_logger("app.services.reimbursement")
class ReimbursementService:
def __init__(self, db: Session) -> None:
self.repository = ReimbursementRepository(db)
def list_reimbursements(self) -> list[ReimbursementRequest]:
return self.repository.list()
items = self.repository.list()
logger.info("Listed reimbursements (count=%d)", len(items))
return items
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
return self.repository.get(request_id)
request = self.repository.get(request_id)
if request:
logger.info("Fetched reimbursement id=%s no=%s", request_id, request.request_no)
else:
logger.warning("Reimbursement not found id=%s", request_id)
return request
def create_reimbursement(self, payload: ReimbursementCreate) -> ReimbursementRequest:
request = ReimbursementRequest(**payload.model_dump(), status="draft")
return self.repository.create(request)
created = self.repository.create(request)
logger.info(
"Created reimbursement id=%s no=%s amount=%s",
created.id,
created.request_no,
created.amount,
)
return created