feat: 新增日程面板、复盘页面与 Agent 读写接口

- 日程:单次/每日/每周/每月重复,JSON 存储,月历按优先级着色圆点 + 悬停提示
- 复盘页:可自定义折线图卡片(SVG) + 每日心得总览(按日期/任务展开)
- Agent 接口:GET /api/agent/overview 读日程+进度,POST /api/agent/schedules 建日程(PPM_API_TOKEN Bearer 鉴权)
- 导出/导入/统计纳入日程与图表;47 项测试全绿
This commit is contained in:
MobKBK
2026-09-04 14:46:41 +08:00
parent 5f50eb54ed
commit 8340d1dbbd
13 changed files with 1027 additions and 19 deletions

View File

@@ -8,13 +8,13 @@ from datetime import date, datetime, time, timedelta
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, Query, Request
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .auth import SessionManager
from .layouts import load_layout
from .models import DailyRecordInput, LoginInput, PurgeInput, ReorderInput, TaskInput, TaskUpdate
from .models import ChartInput, ChartUpdate, DailyRecordInput, LoginInput, PurgeInput, ReorderInput, ScheduleCompleteInput, ScheduleInput, ScheduleUpdate, TaskInput, TaskUpdate
from .repository import ConflictError, JsonRepository, NotFoundError, SHANGHAI, ValidationError
@@ -22,6 +22,15 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
def require_agent_token(request: Request):
token = os.getenv("PPM_API_TOKEN", "").strip()
if not token:
raise HTTPException(status_code=503, detail="agent API 未启用")
auth = request.headers.get("Authorization", "")
if not secrets.compare_digest(auth, f"Bearer {token}"):
raise HTTPException(status_code=401, detail="token 无效")
def _cookie_secure() -> bool:
return os.getenv("PPM_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
@@ -97,11 +106,11 @@ def create_app(
@app.middleware("http")
async def security_middleware(request: Request, call_next):
path = request.url.path
public = path in {"/api/health", "/api/auth/status", "/api/auth/login"}
public = path in {"/api/health", "/api/auth/status", "/api/auth/login"} or path.startswith("/api/agent/")
if path.startswith("/api/") and not public:
if session_manager.enabled and not session_manager.get(request.cookies.get("ppm_session")):
return JSONResponse({"detail": "需要登录"}, status_code=401)
if path.startswith("/api/") and request.method not in SAFE_METHODS and path != "/api/auth/login":
if path.startswith("/api/") and request.method not in SAFE_METHODS and path != "/api/auth/login" and not path.startswith("/api/agent/"):
submitted = request.headers.get("X-CSRF-Token", "")
if session_manager.enabled:
session = session_manager.get(request.cookies.get("ppm_session"))
@@ -155,7 +164,7 @@ def create_app(
@app.get("/api/layouts/{page}")
async def get_layout(page: str) -> dict:
if page not in {"home", "task-detail"}:
if page not in {"home", "task-detail", "review"}:
raise NotFoundError("布局不存在")
return load_layout(layouts, page)
@@ -189,6 +198,67 @@ def create_app(
repository.purge_task(task_id, payload.confirmation)
return {"purged": True, "taskId": task_id}
@app.get("/api/schedules")
async def list_schedules(includeDeleted: bool = False) -> dict:
return repository.list_schedules(includeDeleted)
@app.post("/api/schedules", status_code=201)
async def create_schedule(payload: ScheduleInput) -> dict:
return repository.create_schedule(payload)
@app.get("/api/schedules/calendar")
async def schedule_calendar(month: Annotated[str | None, Query()] = None) -> dict:
value = month or datetime.now(SHANGHAI).strftime("%Y-%m")
try:
year, month_number = (int(part) for part in value.split("-"))
if len(value) != 7 or value[4] != "-" or not 1 <= month_number <= 12:
raise ValueError
except ValueError:
raise ValidationError("月份必须是 YYYY-MM 格式")
return repository.calendar_month(year, month_number)
@app.get("/api/schedules/today")
async def schedules_today(date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
day = date_ or datetime.now(SHANGHAI).date()
return {"date": day.isoformat(), "items": repository.schedules_for_date(day)}
@app.get("/api/schedules/{schedule_id}")
async def get_schedule(schedule_id: str, includeDeleted: bool = False) -> dict:
return repository.get_schedule(schedule_id, includeDeleted)
@app.put("/api/schedules/{schedule_id}")
async def update_schedule(schedule_id: str, payload: ScheduleUpdate) -> dict:
schedule_input = ScheduleInput.model_validate(payload.model_dump(exclude={"expectedVersion"}))
return repository.update_schedule(schedule_id, schedule_input, payload.expectedVersion)
@app.delete("/api/schedules/{schedule_id}")
async def delete_schedule(schedule_id: str, expectedVersion: int = Query(ge=1)) -> dict:
return repository.soft_delete_schedule(schedule_id, expectedVersion)
@app.post("/api/schedules/{schedule_id}/restore")
async def restore_schedule(schedule_id: str, expectedVersion: int = Query(ge=1)) -> dict:
return repository.restore_schedule(schedule_id, expectedVersion)
@app.post("/api/schedules/{schedule_id}/purge")
async def purge_schedule(schedule_id: str, payload: PurgeInput) -> dict:
repository.purge_schedule(schedule_id, payload.confirmation)
return {"purged": True, "scheduleId": schedule_id}
@app.post("/api/schedules/{schedule_id}/complete")
async def complete_schedule(schedule_id: str, payload: ScheduleCompleteInput) -> dict:
return repository.complete_schedule(schedule_id, payload.date, payload.completed, payload.expectedVersion)
@app.get("/api/agent/overview")
async def agent_overview(
date_: Annotated[date | None, Query(alias="date")] = None,
_: None = Depends(require_agent_token),
) -> dict:
return repository.agent_overview(date_ or datetime.now(SHANGHAI).date())
@app.post("/api/agent/schedules", status_code=201)
async def agent_create_schedule(payload: ScheduleInput, _: None = Depends(require_agent_token)) -> dict:
return repository.create_schedule(payload)
@app.put("/api/tasks/reorder/all")
async def reorder_tasks(payload: ReorderInput) -> dict:
return repository.reorder(payload.taskIds, payload.expectedVersion)
@@ -209,6 +279,41 @@ def create_app(
async def task_summary(task_id: str, date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
return repository.summarize(date_, task_id)
@app.get("/api/charts")
async def list_charts() -> dict:
return repository.list_charts()
@app.post("/api/charts", status_code=201)
async def create_chart(payload: ChartInput) -> dict:
return repository.create_chart(payload)
@app.get("/api/charts/{chart_id}/data")
async def chart_data(chart_id: str, ref: Annotated[date | None, Query()] = None) -> dict:
return repository.chart_data(chart_id, ref)
@app.get("/api/charts/{chart_id}")
async def get_chart(chart_id: str) -> dict:
return repository.get_chart(chart_id)
@app.put("/api/charts/{chart_id}")
async def update_chart(chart_id: str, payload: ChartUpdate) -> dict:
chart_input = ChartInput.model_validate(payload.model_dump(exclude={"expectedVersion"}))
return repository.update_chart(chart_id, chart_input, payload.expectedVersion)
@app.delete("/api/charts/{chart_id}")
async def delete_chart(chart_id: str) -> dict:
return repository.delete_chart(chart_id)
@app.get("/api/review/notes")
async def review_notes(
start: Annotated[date | None, Query()] = None,
end: Annotated[date | None, Query()] = None,
) -> dict:
logical_today = datetime.now(SHANGHAI).date()
review_end = end or logical_today
review_start = start or review_end - timedelta(days=29)
return repository.review_notes(review_start, review_end)
@app.get("/api/tasks/{task_id}/history")
async def history(
task_id: str,