feat: 新增日程面板、复盘页面与 Agent 读写接口
- 日程:单次/每日/每周/每月重复,JSON 存储,月历按优先级着色圆点 + 悬停提示 - 复盘页:可自定义折线图卡片(SVG) + 每日心得总览(按日期/任务展开) - Agent 接口:GET /api/agent/overview 读日程+进度,POST /api/agent/schedules 建日程(PPM_API_TOKEN Bearer 鉴权) - 导出/导入/统计纳入日程与图表;47 项测试全绿
This commit is contained in:
@@ -12,7 +12,7 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .models import DailyRecordInput, TaskInput
|
||||
from .models import ChartInput, DailyRecordInput, ScheduleInput, TaskInput
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
@@ -85,9 +85,12 @@ class JsonRepository:
|
||||
self.revisions_dir = self.root / "revisions"
|
||||
self.daily_dir = self.root / "daily"
|
||||
self.archive_dir = self.root / "archive"
|
||||
self.schedules_dir = self.root / "schedules"
|
||||
self.schedule_revisions_dir = self.root / "schedule-revisions"
|
||||
self.charts_dir = self.root / "charts"
|
||||
self.meta_path = self.root / "meta.json"
|
||||
self._lock = threading.RLock()
|
||||
for folder in (self.root, self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir):
|
||||
for folder in (self.root, self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir, self.schedules_dir, self.schedule_revisions_dir, self.charts_dir):
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
if not self.meta_path.exists():
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
@@ -130,6 +133,280 @@ class JsonRepository:
|
||||
def _task_path(self, task_id: str) -> Path:
|
||||
return self.tasks_dir / f"{task_id}.json"
|
||||
|
||||
def _schedule_path(self, schedule_id: str) -> Path:
|
||||
return self.schedules_dir / f"{schedule_id}.json"
|
||||
|
||||
def _chart_path(self, chart_id: str) -> Path:
|
||||
return self.charts_dir / f"{chart_id}.json"
|
||||
|
||||
def _load_chart(self, chart_id: str) -> dict[str, Any]:
|
||||
chart = self._read(self._chart_path(chart_id))
|
||||
if chart is None:
|
||||
raise NotFoundError("图表不存在")
|
||||
return chart
|
||||
|
||||
def create_chart(self, payload: ChartInput) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
chart_id = str(uuid4())
|
||||
timestamp = now_iso()
|
||||
chart = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"chartId": chart_id,
|
||||
"version": 1,
|
||||
**payload.model_dump(mode="json"),
|
||||
"createdAt": timestamp,
|
||||
"updatedAt": timestamp,
|
||||
}
|
||||
self._atomic_write(self._chart_path(chart_id), chart)
|
||||
return chart
|
||||
|
||||
def list_charts(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
items = [self._read(path) for path in self.charts_dir.glob("*.json")]
|
||||
items = [item for item in items if item]
|
||||
items.sort(key=lambda item: item.get("createdAt") or "")
|
||||
return {"items": items}
|
||||
|
||||
def get_chart(self, chart_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return self._load_chart(chart_id)
|
||||
|
||||
def update_chart(self, chart_id: str, payload: ChartInput, expected_version: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
old = self._load_chart(chart_id)
|
||||
if old["version"] != expected_version:
|
||||
raise ConflictError("图表已被更新,请刷新后重试")
|
||||
chart = {**old, **payload.model_dump(mode="json"), "version": old["version"] + 1, "updatedAt": now_iso()}
|
||||
self._atomic_write(self._chart_path(chart_id), chart)
|
||||
return chart
|
||||
|
||||
def delete_chart(self, chart_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._load_chart(chart_id)
|
||||
self._chart_path(chart_id).unlink()
|
||||
return {"deleted": True, "chartId": chart_id}
|
||||
|
||||
def chart_data(self, chart_id: str, ref_date: date | None = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
chart = self._load_chart(chart_id)
|
||||
ref = ref_date or datetime.now(SHANGHAI).date()
|
||||
days = int(chart["days"])
|
||||
start = ref - timedelta(days=days - 1)
|
||||
points = []
|
||||
cursor = start
|
||||
while cursor <= ref:
|
||||
daily = self._read(self._daily_path(cursor), {}) or {}
|
||||
value = 0
|
||||
records = daily.get("records", {})
|
||||
if chart.get("metricId"):
|
||||
for task_id, record in records.items():
|
||||
if chart.get("taskId") and task_id != chart["taskId"]:
|
||||
continue
|
||||
value += int(record.get("values", {}).get(chart["metricId"], 0) or 0)
|
||||
else:
|
||||
seen = set()
|
||||
for task_id, record in records.items():
|
||||
if chart.get("taskId") and task_id != chart["taskId"]:
|
||||
continue
|
||||
if any(int(item or 0) > 0 for item in record.get("values", {}).values()):
|
||||
seen.add(task_id)
|
||||
value = len(seen)
|
||||
points.append({"date": cursor.isoformat(), "value": value})
|
||||
cursor += timedelta(days=1)
|
||||
return {"chartId": chart["chartId"], "title": chart["title"], "days": days, "points": points}
|
||||
|
||||
def review_notes(self, start: date, end: date) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if end < start or (end - start).days > 730:
|
||||
raise ValidationError("心得查询范围无效或超过 730 天")
|
||||
task_names = {}
|
||||
for path in self.tasks_dir.glob("*.json"):
|
||||
task = self._read(path)
|
||||
if task and not task.get("deletedAt"):
|
||||
task_names[task.get("taskId")] = task.get("name", "")
|
||||
days = []
|
||||
cursor = end
|
||||
while cursor >= start:
|
||||
daily = self._read(self._daily_path(cursor), {}) or {}
|
||||
records = []
|
||||
for task_id, record in daily.get("records", {}).items():
|
||||
if not (record.get("note") or record.get("values") or record.get("completedAt")):
|
||||
continue
|
||||
records.append({
|
||||
"taskId": task_id,
|
||||
"taskName": task_names.get(task_id, "已删除任务"),
|
||||
"note": record.get("note", ""),
|
||||
"completedAt": record.get("completedAt"),
|
||||
"values": record.get("values", {}),
|
||||
})
|
||||
if records:
|
||||
days.append({"date": cursor.isoformat(), "records": records})
|
||||
cursor -= timedelta(days=1)
|
||||
return {"start": start.isoformat(), "end": end.isoformat(), "days": days}
|
||||
|
||||
def _load_schedule(self, schedule_id: str) -> dict[str, Any]:
|
||||
schedule = self._read(self._schedule_path(schedule_id))
|
||||
if schedule is None:
|
||||
raise NotFoundError("日程不存在")
|
||||
return schedule
|
||||
|
||||
def _write_schedule_revision(self, schedule: dict[str, Any]) -> None:
|
||||
path = self.schedule_revisions_dir / schedule["scheduleId"] / f"{schedule['revision']}.json"
|
||||
self._atomic_write(path, schedule)
|
||||
|
||||
def create_schedule(self, payload: ScheduleInput) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
schedule_id = str(uuid4())
|
||||
timestamp = now_iso()
|
||||
schedule = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"scheduleId": schedule_id,
|
||||
"version": 1,
|
||||
"revision": 1,
|
||||
**payload.model_dump(mode="json"),
|
||||
"completions": {},
|
||||
"createdAt": timestamp,
|
||||
"updatedAt": timestamp,
|
||||
"deletedAt": None,
|
||||
}
|
||||
self._atomic_write(self._schedule_path(schedule_id), schedule)
|
||||
self._write_schedule_revision(schedule)
|
||||
return schedule
|
||||
|
||||
def list_schedules(self, include_deleted: bool = False) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
schedules = [self._read(path) for path in self.schedules_dir.glob("*.json")]
|
||||
schedules = [item for item in schedules if item and (include_deleted or not item.get("deletedAt"))]
|
||||
schedules.sort(key=lambda item: (item.get("date") or "9999-12-31", item.get("time") or "", item.get("createdAt") or ""))
|
||||
return {"items": schedules}
|
||||
|
||||
def get_schedule(self, schedule_id: str, include_deleted: bool = False) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
schedule = self._load_schedule(schedule_id)
|
||||
if schedule.get("deletedAt") and not include_deleted:
|
||||
raise NotFoundError("日程不存在")
|
||||
return schedule
|
||||
|
||||
def update_schedule(self, schedule_id: str, payload: ScheduleInput, expected_version: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
old = self._load_schedule(schedule_id)
|
||||
if old.get("deletedAt"):
|
||||
raise NotFoundError("日程已删除")
|
||||
if old["version"] != expected_version:
|
||||
raise ConflictError("日程已被更新,请刷新后重试")
|
||||
schedule = {
|
||||
**old,
|
||||
**payload.model_dump(mode="json"),
|
||||
"version": old["version"] + 1,
|
||||
"revision": old["revision"] + 1,
|
||||
"updatedAt": now_iso(),
|
||||
}
|
||||
self._atomic_write(self._schedule_path(schedule_id), schedule)
|
||||
self._write_schedule_revision(schedule)
|
||||
return schedule
|
||||
|
||||
def _change_schedule_deleted(self, schedule_id: str, expected_version: int, restore: bool) -> dict[str, Any]:
|
||||
schedule = self._load_schedule(schedule_id)
|
||||
if schedule["version"] != expected_version:
|
||||
raise ConflictError("日程已被更新,请刷新后重试")
|
||||
if restore and not schedule.get("deletedAt"):
|
||||
return schedule
|
||||
if not restore and schedule.get("deletedAt"):
|
||||
return schedule
|
||||
schedule["deletedAt"] = None if restore else now_iso()
|
||||
schedule["version"] += 1
|
||||
schedule["revision"] += 1
|
||||
schedule["updatedAt"] = now_iso()
|
||||
self._atomic_write(self._schedule_path(schedule_id), schedule)
|
||||
self._write_schedule_revision(schedule)
|
||||
return schedule
|
||||
|
||||
def soft_delete_schedule(self, schedule_id: str, expected_version: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return self._change_schedule_deleted(schedule_id, expected_version, False)
|
||||
|
||||
def restore_schedule(self, schedule_id: str, expected_version: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return self._change_schedule_deleted(schedule_id, expected_version, True)
|
||||
|
||||
def purge_schedule(self, schedule_id: str, confirmation: str) -> None:
|
||||
with self._lock:
|
||||
schedule = self._load_schedule(schedule_id)
|
||||
if not schedule.get("deletedAt"):
|
||||
raise ValidationError("永久删除前必须先放入回收站")
|
||||
if confirmation != schedule_id:
|
||||
raise ValidationError("确认内容必须是 scheduleId")
|
||||
self._schedule_path(schedule_id).unlink()
|
||||
revision_folder = self.schedule_revisions_dir / schedule_id
|
||||
for path in revision_folder.glob("*.json"):
|
||||
path.unlink()
|
||||
if revision_folder.exists():
|
||||
revision_folder.rmdir()
|
||||
|
||||
def complete_schedule(self, schedule_id: str, logical_date: date, completed: bool, expected_version: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
schedule = self._load_schedule(schedule_id)
|
||||
if schedule.get("deletedAt"):
|
||||
raise NotFoundError("日程已删除")
|
||||
if schedule["version"] != expected_version:
|
||||
raise ConflictError("日程已被更新,请刷新后重试")
|
||||
key = logical_date.isoformat()
|
||||
if completed:
|
||||
schedule.setdefault("completions", {})[key] = now_iso()
|
||||
else:
|
||||
schedule.setdefault("completions", {}).pop(key, None)
|
||||
schedule["version"] += 1
|
||||
schedule["updatedAt"] = now_iso()
|
||||
self._atomic_write(self._schedule_path(schedule_id), schedule)
|
||||
return schedule
|
||||
|
||||
def schedules_for_date(self, day: date) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
result = []
|
||||
for schedule in self.list_schedules(include_deleted=False)["items"]:
|
||||
if schedule.get("state") != "active":
|
||||
continue
|
||||
start = date.fromisoformat(schedule["date"])
|
||||
recurrence = schedule.get("recurrence", "none")
|
||||
occurs = (
|
||||
recurrence == "none" and start == day
|
||||
or recurrence == "daily" and start <= day
|
||||
or recurrence == "weekly" and start <= day and start.weekday() == day.weekday()
|
||||
or recurrence == "monthly" and start <= day and start.day == day.day
|
||||
)
|
||||
if not occurs:
|
||||
continue
|
||||
completion_time = schedule.get("completions", {}).get(day.isoformat())
|
||||
result.append({**schedule, "completed": bool(completion_time), "completionTime": completion_time})
|
||||
result.sort(key=lambda item: (item.get("time") or "", item.get("createdAt") or ""))
|
||||
return result
|
||||
|
||||
def calendar_month(self, year: int, month: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
days = []
|
||||
for day_number in range(1, monthrange(year, month)[1] + 1):
|
||||
current = date(year, month, day_number)
|
||||
schedules = [item for item in self.schedules_for_date(current) if item.get("state") == "active"]
|
||||
days.append({"date": current.isoformat(), "schedules": schedules})
|
||||
return {"year": year, "month": month, "days": days}
|
||||
|
||||
def agent_overview(self, day: date) -> dict[str, Any]:
|
||||
schedules = [
|
||||
{key: item.get(key) for key in ("scheduleId", "title", "description", "date", "time", "recurrence", "priority", "state", "completed", "completionTime")}
|
||||
for item in self.schedules_for_date(day)
|
||||
if item.get("state") == "active"
|
||||
]
|
||||
progress = self.summarize(day)
|
||||
for task_id, info in progress.get("tasks", {}).items():
|
||||
task = self._read(self._task_path(task_id))
|
||||
if task:
|
||||
info["name"] = task.get("name", "")
|
||||
info["priority"] = task.get("priority")
|
||||
info["state"] = task.get("state", "")
|
||||
else:
|
||||
info["name"] = ""
|
||||
return {"date": day.isoformat(), "schedules": schedules, "taskProgress": progress}
|
||||
|
||||
def _daily_path(self, logical_date: date) -> Path:
|
||||
return self.daily_dir / str(logical_date.year) / f"{logical_date.isoformat()}.json"
|
||||
|
||||
@@ -521,6 +798,9 @@ class JsonRepository:
|
||||
revisions = [self._read(path) for path in self.revisions_dir.glob("*/*.json")]
|
||||
daily = [self._read(path) for path in self.daily_dir.glob("*/*.json")]
|
||||
archives = [self._read(path) for path in self.archive_dir.glob("*/*.json")]
|
||||
schedules = [self._read(path) for path in self.schedules_dir.glob("*.json")]
|
||||
schedule_revisions = [self._read(path) for path in self.schedule_revisions_dir.glob("*/*.json")]
|
||||
charts = [self._read(path) for path in self.charts_dir.glob("*.json")]
|
||||
return {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"exportedAt": now_iso(),
|
||||
@@ -529,6 +809,9 @@ class JsonRepository:
|
||||
"revisions": revisions,
|
||||
"daily": daily,
|
||||
"archives": archives,
|
||||
"schedules": schedules,
|
||||
"scheduleRevisions": schedule_revisions,
|
||||
"charts": charts,
|
||||
}
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
@@ -537,6 +820,9 @@ class JsonRepository:
|
||||
daily_files = [self._read(path) for path in self.daily_dir.glob("*/*.json")]
|
||||
revisions = [path for path in self.revisions_dir.glob("*/*.json")]
|
||||
archives = [path for path in self.archive_dir.glob("*/*.json")]
|
||||
schedules = [path for path in self.schedules_dir.glob("*.json")]
|
||||
schedule_revisions = [path for path in self.schedule_revisions_dir.glob("*/*.json")]
|
||||
charts = [path for path in self.charts_dir.glob("*.json")]
|
||||
|
||||
def bytes_of(paths):
|
||||
total = 0
|
||||
@@ -555,11 +841,17 @@ class JsonRepository:
|
||||
"dailyFactCount": len(daily_files),
|
||||
"revisionCount": len(revisions),
|
||||
"archiveCount": len(archives),
|
||||
"scheduleCount": len(schedules),
|
||||
"scheduleRevisionCount": len(schedule_revisions),
|
||||
"chartCount": len(charts),
|
||||
"storageBytes": bytes_of(
|
||||
list(self.tasks_dir.glob("*.json"))
|
||||
+ list(self.daily_dir.glob("*/*.json"))
|
||||
+ list(self.revisions_dir.glob("*/*.json"))
|
||||
+ list(self.archive_dir.glob("*/*.json"))
|
||||
+ list(self.schedules_dir.glob("*.json"))
|
||||
+ list(self.schedule_revisions_dir.glob("*/*.json"))
|
||||
+ charts
|
||||
),
|
||||
}
|
||||
|
||||
@@ -583,6 +875,9 @@ class JsonRepository:
|
||||
tasks = payload.get("tasks")
|
||||
revisions = payload.get("revisions") or []
|
||||
daily = payload.get("daily") or []
|
||||
schedules = payload.get("schedules") or []
|
||||
schedule_revisions = payload.get("scheduleRevisions") or []
|
||||
charts = payload.get("charts") or []
|
||||
if not isinstance(meta, dict) or not isinstance(tasks, list):
|
||||
raise ValidationError("备份数据缺少 meta/tasks")
|
||||
|
||||
@@ -618,8 +913,36 @@ class JsonRepository:
|
||||
daily_dates.add(day)
|
||||
staged.append((self._daily_path(day), fact))
|
||||
|
||||
seen_schedules: set[str] = set()
|
||||
for schedule in schedules:
|
||||
if not isinstance(schedule, dict):
|
||||
raise ValidationError("备份日程数据无效")
|
||||
schedule_id = self._validate_identifier(schedule.get("scheduleId"), "scheduleId")
|
||||
if schedule_id in seen_schedules:
|
||||
raise ValidationError("备份包含重复日程")
|
||||
seen_schedules.add(schedule_id)
|
||||
staged.append((self._schedule_path(schedule_id), schedule))
|
||||
|
||||
for schedule_revision in schedule_revisions:
|
||||
if not isinstance(schedule_revision, dict):
|
||||
continue
|
||||
schedule_id = self._validate_identifier(schedule_revision.get("scheduleId"), "scheduleId")
|
||||
rev = self._validate_identifier(str(schedule_revision.get("revision")), "revision")
|
||||
folder = self.schedule_revisions_dir / schedule_id
|
||||
staged.append((folder / f"{rev}.json", schedule_revision))
|
||||
|
||||
seen_charts: set[str] = set()
|
||||
for chart in charts:
|
||||
if not isinstance(chart, dict):
|
||||
raise ValidationError("备份图表数据无效")
|
||||
chart_id = self._validate_identifier(chart.get("chartId"), "chartId")
|
||||
if chart_id in seen_charts:
|
||||
raise ValidationError("备份包含重复图表")
|
||||
seen_charts.add(chart_id)
|
||||
staged.append((self._chart_path(chart_id), chart))
|
||||
|
||||
# 原子替换整个数据集:先清空旧数据,再整体写入。
|
||||
for folder in (self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir):
|
||||
for folder in (self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir, self.schedules_dir, self.schedule_revisions_dir, self.charts_dir):
|
||||
for path in folder.glob("*"):
|
||||
if path.is_dir():
|
||||
for child in path.iterdir():
|
||||
@@ -635,4 +958,3 @@ class JsonRepository:
|
||||
for day in sorted(daily_dates):
|
||||
self._rebuild_month(day.year, day.month)
|
||||
return len(seen_tasks)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user