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

@@ -16,12 +16,15 @@ ALLOWED_COMPONENTS = {
"metric-progress",
"activity-heatmap",
"task-settings",
"schedule-board",
"review-charts",
"review-notes",
}
# 每个组件允许出现的页面与区域。组件在各自区域内必须单例,避免重复 ID
# 与重复事件绑定;跨页/跨区错放(例如把 today-record 放进首页)视为非法布局。
COMPONENT_SCOPES: dict[str, tuple[tuple[str, ...], str]] = {
"task-list": (("home", "task-detail"), "sidebar"),
"task-list": (("home", "task-detail", "review"), "sidebar"),
"overview-summary": (("home",), "main"),
"task-progress-grid": (("home",), "main"),
"task-gantt": (("home",), "main"),
@@ -30,6 +33,9 @@ COMPONENT_SCOPES: dict[str, tuple[tuple[str, ...], str]] = {
"metric-progress": (("task-detail",), "main"),
"activity-heatmap": (("task-detail",), "main"),
"task-settings": (("task-detail",), "main"),
"schedule-board": (("home",), "main"),
"review-charts": (("review",), "main"),
"review-notes": (("review",), "main"),
}
FALLBACKS = {
@@ -42,6 +48,7 @@ FALLBACKS = {
{"component": "overview-summary"},
{"component": "task-progress-grid"},
{"component": "task-gantt"},
{"component": "schedule-board"},
],
},
},
@@ -59,6 +66,14 @@ FALLBACKS = {
],
},
},
"review": {
"schemaVersion": 1,
"page": "review",
"regions": {
"sidebar": [{"component": "task-list"}],
"main": [{"component": "review-charts"}, {"component": "review-notes"}],
},
},
}
@@ -102,4 +117,3 @@ def load_layout(layout_dir: Path, page: str) -> dict[str, Any]:
except (OSError, json.JSONDecodeError):
pass
return deepcopy(FALLBACKS[page])

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,

View File

@@ -121,3 +121,59 @@ class PurgeInput(BaseModel):
model_config = ConfigDict(extra="forbid")
confirmation: str
class ScheduleRecurrence(str, Enum):
none = "none"
daily = "daily"
weekly = "weekly"
monthly = "monthly"
class ScheduleInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: Annotated[str, Field(min_length=1, max_length=120)]
description: Annotated[str, Field(max_length=2000)] = ""
date: date
time: Annotated[str | None, Field(max_length=5)] = None
recurrence: ScheduleRecurrence = ScheduleRecurrence.none
priority: Annotated[int, Field(ge=1, le=5)] = 3
state: Literal["active", "archived"] = "active"
@field_validator("time")
@classmethod
def validate_time(cls, value: str | None) -> str | None:
if value is None or value == "":
return None
if len(value) != 5 or value[2] != ":" or not value[:2].isdigit() or not value[3:].isdigit():
raise ValueError("时间必须是 HH:MM 格式")
hour, minute = int(value[:2]), int(value[3:])
if hour > 23 or minute > 59:
raise ValueError("时间必须是合法的时分")
return value
class ScheduleUpdate(ScheduleInput):
expectedVersion: Annotated[int, Field(ge=1)]
class ScheduleCompleteInput(BaseModel):
model_config = ConfigDict(extra="forbid")
date: date
completed: bool = True
expectedVersion: Annotated[int, Field(ge=1)]
class ChartInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: Annotated[str, Field(min_length=1, max_length=80)]
taskId: str | None = None
metricId: str | None = None
days: Annotated[int, Field(ge=7, le=365)] = 30
class ChartUpdate(ChartInput):
expectedVersion: Annotated[int, Field(ge=1)]

View File

@@ -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)

View File

@@ -1,7 +1,10 @@
const state = {
view: "home",
tasks: [], version: 1, selectedId: null, selectedTask: null,
daily: null, history: null, summary: null, csrf: "", authEnabled: false,
charts: [], notes: null, editingChartId: null,
filter: "", editingId: null, dragId: null, selectedDate: null, ganttScale: "month",
schedules: { calendar: null, today: null }, scheduleMonth: new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()).slice(0, 7), editingScheduleId: null,
};
const $ = (selector, root = document) => root.querySelector(selector);
@@ -47,7 +50,7 @@ async function bootstrap() {
} catch (error) { toast(error.message); }
}
const layoutCache = { home: null, "task-detail": null };
const layoutCache = { home: null, "task-detail": null, review: null };
async function ensureLayout(page) { if (!layoutCache[page]) layoutCache[page] = await api(`/api/layouts/${page}`); return layoutCache[page]; }
async function loadTasks() {
const result = await api("/api/tasks"); state.tasks = result.items; state.version = result.version;
@@ -55,6 +58,15 @@ async function loadTasks() {
await render();
}
async function loadSchedules() {
const day = state.selectedDate || today();
const [calendar, todayResult] = await Promise.all([
api(`/api/schedules/calendar?month=${encodeURIComponent(state.scheduleMonth)}`),
api(`/api/schedules/today?date=${encodeURIComponent(day)}`),
]);
state.schedules = { calendar, today: todayResult };
}
function renderTaskList() {
const filtered = state.tasks.filter((task) => !state.filter || task.name.toLowerCase().includes(state.filter.toLowerCase()));
const items = filtered.map((task) => `<div class="task-item ${task.taskId === state.selectedId ? "selected" : ""}" draggable="true" data-task-id="${task.taskId}">
@@ -78,7 +90,7 @@ function rerenderSidebarList() {
function bindSidebarItems() {
$$(".task-item").forEach((node) => {
node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); });
node.addEventListener("click", () => { state.view = "home"; state.selectedId = node.dataset.taskId; render(); });
node.addEventListener("dragstart", () => { state.dragId = node.dataset.taskId; node.classList.add("dragging"); });
node.addEventListener("dragend", () => { node.classList.remove("dragging"); state.dragId = null; });
node.addEventListener("dragover", (event) => event.preventDefault());
@@ -97,6 +109,53 @@ function periodText(task) { const metrics = task.metrics || []; return [...new S
function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; }
function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; }
function renderChartSvg(chart) {
const points = chart.data?.points || [];
if (!points.length || points.every((point) => Number(point.value) === 0)) return `<div class="chart-empty">暂无数据</div>`;
const width = 600, height = 200, pad = { left: 34, right: 12, top: 14, bottom: 30 };
const max = Math.max(...points.map((point) => Number(point.value) || 0), 1);
const x = (index) => pad.left + (points.length === 1 ? 0 : index * (width - pad.left - pad.right) / (points.length - 1));
const y = (value) => pad.top + (height - pad.top - pad.bottom) * (1 - value / max);
const coords = points.map((point, index) => `${x(index).toFixed(1)},${y(Number(point.value) || 0).toFixed(1)}`).join(" ");
const areaPath = `M ${pad.left.toFixed(1)} ${y(0).toFixed(1)} L ${coords.replace(/ /g, " L ")} L ${x(points.length - 1).toFixed(1)} ${y(0).toFixed(1)} Z`;
const labels = [points[0], points[Math.floor((points.length - 1) / 2)], points[points.length - 1]].filter((point, index, list) => point && list.findIndex((item) => item.date === point.date) === index).map((point) => {
const index = points.indexOf(point); return `<text x="${x(index)}" y="${height - 7}" text-anchor="middle">${escapeHtml(point.date.slice(5))}</text>`;
}).join("");
const circles = points.map((point, index) => `<circle cx="${x(index).toFixed(1)}" cy="${y(Number(point.value) || 0).toFixed(1)}" r="2.2"/>`).join("");
return `<svg class="chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(chart.title)}"><line class="chart-guide" x1="${pad.left}" y1="${y(0)}" x2="${width - pad.right}" y2="${y(0)}"/><line class="chart-guide" x1="${pad.left}" y1="${y(max)}" x2="${width - pad.right}" y2="${y(max)}"/><text class="chart-max" x="4" y="${y(max) + 4}">${max}</text><path class="chart-area" d="${areaPath}"/><polyline points="${coords}"/><g class="chart-points">${circles}</g><g class="chart-labels">${labels}</g></svg>`;
}
function renderReviewCharts() {
const cards = state.charts.map((chart) => `<article class="review-chart-card"><div class="review-chart-head"><h3>${escapeHtml(chart.title)}</h3><span><button class="icon-button chart-edit" data-chart-id="${chart.chartId}" title="编辑图表" aria-label="编辑图表">✎</button><button class="icon-button chart-delete" data-chart-id="${chart.chartId}" title="删除图表" aria-label="删除图表">×</button></span></div>${renderChartSvg(chart)}</article>`).join("");
return `<section class="section review-charts"><div class="page-heading review-heading"><div><p class="eyebrow">REVIEW</p><h1>复盘</h1><p>观察完成趋势,回看每日心得</p></div><button class="button primary" id="addChartButton">+ 添加图表</button></div><div class="review-chart-grid">${cards || `<p class="empty-list">还没有图表,添加一张开始复盘</p>`}</div></section>`;
}
function renderReviewNotes() {
const days = state.notes?.days || [];
const content = days.map((day) => `<details class="review-day" open><summary><span class="review-date-badge">${escapeHtml(day.date.slice(5))}</span><strong>${escapeHtml(day.date)}</strong><span class="muted">${day.records.length} 条记录</span></summary><div class="review-day-records">${day.records.map((record) => `<div class="review-note-record"><strong>${escapeHtml(record.taskName)}</strong>${record.completedAt ? `<time>${escapeHtml(record.completedAt.replace("T", " "))}</time>` : ""}${record.note ? `<p>${escapeHtml(record.note)}</p>` : ""}</div>`).join("")}</div></details>`).join("");
return `<section class="section review-notes"><div class="section-heading"><div><p class="eyebrow">REVIEW</p><h2>每日心得</h2></div><span>最近 30 天</span></div>${content || `<p class="empty-list">最近没有心得记录</p>`}</section>`;
}
function fillChartMetrics(taskId, metricId = "") {
const select = $("#chartMetricSelect");
const task = state.tasks.find((item) => item.taskId === taskId);
const options = [`<option value="">完成数</option>`].concat(task ? task.metrics.map((metric) => `<option value="${escapeHtml(metric.metricId)}" ${metric.metricId === metricId ? "selected" : ""}>${escapeHtml(metric.name)}</option>`) : []);
select.innerHTML = options.join("");
}
function openChartDialog(chart = null) {
state.editingChartId = chart?.chartId || null;
const form = $("#chartForm"); form.reset(); $("#chartDialogTitle").textContent = chart ? "编辑图表" : "添加图表";
$("#chartTaskSelect").innerHTML = `<option value="">全部任务</option>${state.tasks.map((task) => `<option value="${escapeHtml(task.taskId)}">${escapeHtml(task.name)}</option>`).join("")}`;
form.title.value = chart?.title || ""; form.days.value = chart?.days || 30; $("#chartTaskSelect").value = chart?.taskId || ""; fillChartMetrics($("#chartTaskSelect").value, chart?.metricId || ""); $("#chartDialog").showModal();
}
function bindReviewCharts() {
$("#addChartButton")?.addEventListener("click", () => openChartDialog());
$(".chart-edit") && $$(".chart-edit").forEach((button) => button.addEventListener("click", () => openChartDialog(state.charts.find((chart) => chart.chartId === button.dataset.chartId))));
$$(".chart-delete").forEach((button) => button.addEventListener("click", async () => { if (!confirm("确认删除这张图表?")) return; try { await api(`/api/charts/${button.dataset.chartId}`, { method: "DELETE" }); toast("图表已删除"); await render(); } catch (error) { toast(error.message); } }));
}
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
// 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认),
// 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。
@@ -110,6 +169,9 @@ const components = {
"metric-progress": { render: renderMetricProgress },
"activity-heatmap": { render: renderActivityHeatmap },
"task-settings": { render: renderTaskSettings, bind: bindSettings },
"schedule-board": { render: renderScheduleBoard, bind: bindScheduleBoard },
"review-charts": { render: renderReviewCharts, bind: bindReviewCharts },
"review-notes": { render: renderReviewNotes },
};
function componentHtml(item) { const entry = components[item.component]; return entry ? entry.render(item.options || {}) : ""; }
@@ -132,8 +194,17 @@ async function renderLayout(page) {
}
async function render() {
if (state.view === "review") {
try {
const [charts, notes] = await Promise.all([api("/api/charts"), api(`/api/review/notes?start=${encodeURIComponent(shanghaiDate(-29))}&end=${encodeURIComponent(today())}`)]);
state.notes = notes;
state.charts = await Promise.all((charts.items || []).map(async (chart) => ({ ...chart, data: await api(`/api/charts/${chart.chartId}/data`) })));
} catch (error) { toast(error.message); return; }
await renderLayout("review");
return;
}
if (!state.selectedId) {
try { state.summary = await api("/api/summary"); } catch (error) { toast(error.message); }
try { state.summary = await api("/api/summary"); await loadSchedules(); } catch (error) { toast(error.message); }
await renderLayout("home");
return;
}
@@ -153,6 +224,70 @@ async function render() {
await renderLayout("task-detail");
}
function scheduleMonthOffset(offset) {
const [year, month] = state.scheduleMonth.split("-").map(Number);
const value = new Date(Date.UTC(year, month - 1 + offset, 1));
state.scheduleMonth = `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, "0")}`;
}
function scheduleForSelectedDate() {
const selected = state.selectedDate || today();
if (state.schedules.today?.date === selected) return state.schedules.today.items || [];
return state.schedules.calendar?.days?.find((item) => item.date === selected)?.schedules || [];
}
function renderScheduleBoard() {
const calendar = state.schedules.calendar;
if (!calendar) return `<section class="section"><div class="section-heading"><h2>日程</h2></div><p class="muted">正在加载日程…</p></section>`;
const selected = state.selectedDate || today();
const days = calendar.days || [];
const offset = days.length ? (new Date(`${days[0].date}T00:00:00Z`).getUTCDay() + 6) % 7 : 0;
const cells = Array.from({ length: offset }, () => `<span class="schedule-day blank"></span>`).join("") + days.map((item) => {
const schedules = item.schedules || [];
const priorities = [...new Set(schedules.map((schedule) => Number(schedule.priority)).filter((priority) => priority >= 1 && priority <= 5))].sort((a, b) => a - b);
const visiblePriorities = priorities.slice(0, 3);
const remainingPriorities = Math.max(0, priorities.length - visiblePriorities.length);
const dots = visiblePriorities.map((priority) => `<i class="dot priority-${priority}" aria-hidden="true"></i>`).join("");
const more = remainingPriorities ? `<span class="schedule-dots-more">+${remainingPriorities}</span>` : "";
const tooltip = schedules.map((schedule) => `${schedule.time ? escapeHtml(schedule.time) : "全天"} ${escapeHtml(schedule.title)} · 优先级${Number(schedule.priority) || 3}`).join("\n").replace(/\n/g, "&#10;");
const tooltipAttribute = tooltip ? ` data-tooltip="${tooltip}"` : "";
return `<button type="button" class="schedule-day ${item.date === selected ? "selected" : ""} ${item.date === today() ? "today" : ""}" data-schedule-date="${item.date}"${tooltipAttribute}><span class="schedule-date">${Number(item.date.slice(-2))}</span><span class="schedule-dots" aria-label="${priorities.length} 种优先级">${dots}${more}</span></button>`;
}).join("");
const items = scheduleForSelectedDate().map((schedule) => `<div class="schedule-item ${schedule.completed ? "completed" : ""}"><span class="priority-bar priority-${schedule.priority}"></span><div class="schedule-copy"><strong>${escapeHtml(schedule.title)}</strong><small>${schedule.time ? escapeHtml(schedule.time) : "全天"}${schedule.recurrence !== "none" ? ` · ${escapeHtml(schedule.recurrence)}` : ""}</small></div><button type="button" class="button subtle schedule-complete" data-schedule-id="${schedule.scheduleId}" data-schedule-version="${schedule.version}" data-completed="${schedule.completed ? "false" : "true"}">${schedule.completed ? "取消完成" : "完成"}</button><button type="button" class="icon-button schedule-edit" data-schedule-id="${schedule.scheduleId}" title="编辑" aria-label="编辑">✎</button></div>`).join("");
const legend = [[1, "紧急"], [2, "高"], [3, "正常"], [4, "低"], [5, "挂起候选"]].map(([priority, label]) => `<span class="schedule-legend-item"><i class="dot priority-${priority}" aria-hidden="true"></i><span>${label}</span></span>`).join("");
return `<section class="section schedule-board"><div class="section-heading"><div><p class="eyebrow">SCHEDULE</p><h2>日程安排</h2><span>${selected} · ${scheduleForSelectedDate().length} 项</span></div><div class="schedule-actions"><button type="button" class="icon-button" id="schedulePrev" title="上月" aria-label="上月">‹</button><strong>${calendar.year}-${String(calendar.month).padStart(2, "0")}</strong><button type="button" class="icon-button" id="scheduleNext" title="下月" aria-label="下月">›</button><button type="button" class="button primary" id="addScheduleButton">+ 新建日程</button></div></div><div class="schedule-weekdays"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div><div class="schedule-calendar">${cells}</div><div class="schedule-legend" aria-label="优先级图例"><span class="schedule-legend-label">优先级图例</span>${legend}</div><div class="schedule-list">${items || `<p class="muted">这一天没有日程</p>`}</div></section>`;
}
async function refreshSchedules() { try { await loadSchedules(); await renderLayout("home"); } catch (error) { toast(error.message); } }
function bindScheduleBoard() {
$("#schedulePrev")?.addEventListener("click", async () => { scheduleMonthOffset(-1); await refreshSchedules(); });
$("#scheduleNext")?.addEventListener("click", async () => { scheduleMonthOffset(1); await refreshSchedules(); });
$("#addScheduleButton")?.addEventListener("click", () => openScheduleDialog());
$$('[data-schedule-date]').forEach((node) => node.addEventListener("click", async () => { state.selectedDate = node.dataset.scheduleDate; await refreshSchedules(); }));
$$(".schedule-edit").forEach((node) => node.addEventListener("click", () => {
const schedule = scheduleForSelectedDate().find((item) => item.scheduleId === node.dataset.scheduleId); if (schedule) openScheduleDialog(schedule);
}));
$$(".schedule-complete").forEach((node) => node.addEventListener("click", async () => {
try { await api(`/api/schedules/${node.dataset.scheduleId}/complete`, { method: "POST", body: JSON.stringify({ date: state.selectedDate || today(), completed: node.dataset.completed === "true", expectedVersion: Number(node.dataset.scheduleVersion) }) }); toast("日程状态已更新"); await refreshSchedules(); } catch (error) { toast(error.message); }
}));
}
function openScheduleDialog(schedule = null) {
state.editingScheduleId = schedule?.scheduleId || null;
const form = $("#scheduleForm"); form.reset(); $("#scheduleDialogTitle").textContent = schedule ? "编辑日程" : "新建日程";
form.title.value = schedule?.title || ""; form.description.value = schedule?.description || ""; form.date.value = schedule?.date || state.selectedDate || today(); form.time.value = schedule?.time || ""; form.recurrence.value = schedule?.recurrence || "none"; form.priority.value = schedule?.priority || 3; form.state.value = schedule?.state || "active";
$("#scheduleDialog").showModal();
}
$("#scheduleForm")?.addEventListener("submit", async (event) => {
event.preventDefault(); const form = event.currentTarget;
const payload = { title: form.title.value, description: form.description.value, date: form.date.value || null, time: form.time.value || null, recurrence: form.recurrence.value, priority: Number(form.priority.value), state: form.state.value };
try { const editing = state.editingScheduleId; if (editing) { payload.expectedVersion = scheduleForSelectedDate().find((item) => item.scheduleId === editing)?.version || 1; }
const schedule = await api(editing ? `/api/schedules/${editing}` : "/api/schedules", { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#scheduleDialog").close(); state.selectedDate = schedule.date; state.scheduleMonth = schedule.date.slice(0, 7); toast(editing ? "日程已更新" : "日程已创建"); await refreshSchedules();
} catch (error) { toast(error.message); }
});
$("#cancelScheduleButton")?.addEventListener("click", () => $("#scheduleDialog").close()); $("#closeScheduleButton")?.addEventListener("click", () => $("#scheduleDialog").close());
function renderOverviewSummary() {
const active = state.tasks.filter((task) => !task.deletedAt); const activePlans = active.filter((task) => task.state === "active").length;
return `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${activePlans}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div>`;
@@ -419,9 +554,25 @@ function renderMetricEditor(metrics) {
$("#addMetricButton").addEventListener("click", () => { const metrics = $$(".metric-row").map((row) => Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value]))); metrics.push({ name: "新指标", kind: "quantity", period: "day", target: "", unit: "次" }); renderMetricEditor(metrics); });
$("#taskForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const metrics = $$(".metric-row").map((row) => { const result = Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value])); result.metricId = row.dataset.metricId || null; result.target = result.target ? Number(result.target) : null; if (result.kind === "duration") { result.unit = ""; result.durationUnit = result.durationUnit || "minute"; if (result.target != null) result.target = Math.round(result.target * (DURATION_SECONDS[result.durationUnit] || 60)); } else { delete result.durationUnit; } return result; }); const payload = { name: form.name.value, description: form.description.value, startDate: form.startDate.value || null, endDate: form.endDate.value || null, priority: Number(form.priority.value), state: form.state.value, metrics }; try { const endpoint = state.editingId ? `/api/tasks/${state.editingId}` : "/api/tasks"; const method = state.editingId ? "PUT" : "POST"; if (state.editingId) payload.expectedVersion = state.selectedTask.version; const task = await api(endpoint, { method, body: JSON.stringify(payload) }); $("#taskDialog").close(); state.selectedId = task.taskId; toast(state.editingId ? "任务属性已更新" : "任务已创建"); await loadTasks(); } catch (error) { toast(error.message); } });
$("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); });
$("#addTaskButton").addEventListener("click", () => openTaskDialog());
$("#reviewButton").addEventListener("click", () => { state.view = "review"; render(); });
$("#homeButton").addEventListener("click", () => { state.view = "home"; state.selectedId = null; render(); });
$("#cancelTaskButton").addEventListener("click", () => $("#taskDialog").close());
$("#closeTaskButton").addEventListener("click", () => $("#taskDialog").close());
$("#cancelChartButton").addEventListener("click", () => $("#chartDialog").close());
$("#closeChartButton").addEventListener("click", () => $("#chartDialog").close());
$("#chartTaskSelect").addEventListener("change", (event) => fillChartMetrics(event.target.value));
$("#chartForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const payload = { title: form.title.value, taskId: form.taskId.value || null, metricId: form.metricId.value || null, days: Number(form.days.value) };
try {
const editing = state.editingChartId ? state.charts.find((chart) => chart.chartId === state.editingChartId) : null;
if (editing) payload.expectedVersion = editing.version;
await api(editing ? `/api/charts/${editing.chartId}` : "/api/charts", { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) });
$("#chartDialog").close(); toast(editing ? "图表已更新" : "图表已创建"); await render();
} catch (error) { toast(error.message); }
});
function renderAdminStats(stats) {
const cells = [

View File

@@ -15,6 +15,7 @@
<span><strong>进度台</strong><small id="logicalDate"></small></span>
</button>
<div class="top-actions">
<button class="button" id="reviewButton" type="button">复盘</button>
<button class="button primary" id="addTaskButton" type="button">+ 新建任务</button>
<button class="icon-button" id="adminButton" type="button" title="数据管理" aria-label="数据管理">⚙</button>
</div>
@@ -45,6 +46,35 @@
</form>
</dialog>
<dialog id="scheduleDialog" class="dialog schedule-dialog">
<form id="scheduleForm">
<div class="dialog-heading"><div><p class="eyebrow">日程定义</p><h2 id="scheduleDialogTitle">新建日程</h2></div><button class="icon-button" id="closeScheduleButton" type="button" title="关闭" aria-label="关闭">×</button></div>
<div class="form-grid">
<label class="wide">标题<input name="title" required maxlength="120"></label>
<label>日期<input name="date" type="date"></label>
<label>时间<input name="time" type="time" maxlength="5"></label>
<label>重复<select name="recurrence"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option></select></label>
<label>优先级<select name="priority"><option value="1">1 · 紧急</option><option value="2">2 · 高</option><option value="3" selected>3 · 正常</option><option value="4">4 · 低</option><option value="5">5 · 低优先</option></select></label>
<label>状态<select name="state"><option value="active">进行中</option><option value="archived">已归档</option></select></label>
<label class="wide">说明<textarea name="description" maxlength="2000" rows="3"></textarea></label>
</div>
<div class="dialog-actions"><button class="button" id="cancelScheduleButton" type="button">取消</button><button class="button primary" type="submit">保存日程</button></div>
</form>
</dialog>
<dialog id="chartDialog" class="dialog chart-dialog">
<form id="chartForm">
<div class="dialog-heading"><div><p class="eyebrow">REVIEW</p><h2 id="chartDialogTitle">添加图表</h2></div><button class="icon-button" id="closeChartButton" type="button" title="关闭" aria-label="关闭">×</button></div>
<div class="form-grid">
<label class="wide">标题<input name="title" required maxlength="80"></label>
<label>任务<select name="taskId" id="chartTaskSelect"><option value="">全部任务</option></select></label>
<label>指标<select name="metricId" id="chartMetricSelect"><option value="">完成数</option></select></label>
<label>天数<input name="days" type="number" min="7" max="365" value="30" required></label>
</div>
<div class="dialog-actions"><button class="button" id="cancelChartButton" type="button">取消</button><button class="button primary" type="submit">保存图表</button></div>
</form>
</dialog>
<dialog id="adminDialog" class="dialog admin-dialog">
<div class="dialog-heading"><div><p class="eyebrow">后台</p><h2>数据管理</h2></div><button class="icon-button" id="closeAdminButton" title="关闭" aria-label="关闭">×</button></div>
<div class="admin-actions"><button class="button" id="exportButton">导出 JSON</button><button class="button" id="importButton">导入还原</button><input type="file" id="importFile" accept="application/json,.json" hidden><button class="button" id="archiveButton">执行补归档</button><button class="button" id="logoutButton">退出登录</button></div>

View File

@@ -194,12 +194,70 @@ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #4551
.login-overlay { position: fixed; inset: 0; z-index: 100; background: #eef2ee; display: grid; place-items: center; padding: 20px; }
.login-overlay[hidden] { display: none; }.login-panel { width: min(380px, 100%); background: white; border: 1px solid var(--line); padding: 38px; border-radius: 8px; }.login-panel h1 { margin: 6px 0 28px; font-size: 25px; }.wide-button { width: 100%; margin-top: 16px; }.form-error { color: var(--red); min-height: 18px; font-size: 12px; }
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 200; max-width: min(360px, calc(100vw - 44px)); background: var(--ink); color: white; border-radius: 6px; padding: 11px 15px; transform: translateY(100px); opacity: 0; transition: .2s ease; }.toast.show { transform: translateY(0); opacity: 1; }
@media (max-width: 820px) {
.review-heading { margin-bottom: 20px; }
.review-chart-grid { display: grid; gap: 14px; }
.review-chart-card { border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 16px; }
.review-chart-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 10px; }
.review-chart-head h3 { margin: 0; font-size: 15px; }
.review-chart-head > span { display: flex; gap: 5px; }
.review-chart-head .icon-button { width: 30px; min-height: 30px; font-size: 16px; }
.chart-svg { display: block; width: 100%; height: auto; min-height: 180px; overflow: visible; }
.chart-svg polyline { fill: none; stroke: var(--green); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chart-svg circle { fill: var(--surface); stroke: var(--green); stroke-width: 1.5; }
.chart-area { fill: rgba(23,107,75,.10); }
.chart-guide { stroke: #e6ebe7; stroke-width: 1; }
.chart-svg text { fill: var(--muted); font-size: 11px; }
.chart-empty { min-height: 180px; display: grid; place-items: center; color: var(--muted); font-size: 13px; }
.review-day { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); margin-bottom: 9px; overflow: hidden; }
.review-day summary { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; }
.review-day summary::-webkit-details-marker { display: none; }
.review-date-badge { display: grid; place-items: center; min-width: 48px; min-height: 30px; border-radius: 4px; background: var(--green-soft); color: var(--green); font-size: 12px; font-weight: 700; }
.review-day-records { border-top: 1px solid var(--line); padding: 2px 14px 8px 72px; }
.review-note-record { padding: 10px 0; border-bottom: 1px solid #eef2ee; }
.review-note-record:last-child { border-bottom: 0; }
.review-note-record strong { display: block; font-size: 13px; }.review-note-record time { display: block; color: var(--muted); font-size: 11px; margin-top: 3px; }.review-note-record p { margin: 7px 0 0; white-space: pre-wrap; font-size: 13px; line-height: 1.6; }
.chart-dialog { width: min(560px, calc(100vw - 28px)); }
.schedule-actions { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.schedule-actions strong { min-width: 76px; text-align: center; font-size: 14px; }
.schedule-weekdays, .schedule-calendar { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 5px; }
.schedule-weekdays { color: var(--muted); font-size: 11px; text-align: center; margin-bottom: 5px; }
.schedule-date { font-size: 13px; font-weight: 600; }
.schedule-day { min-height: 52px; position: relative; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); color: var(--ink); display: flex; align-items: flex-start; justify-content: flex-start; padding: 7px; transition: .15s ease; }
.schedule-day:hover, .schedule-day.selected { border-color: var(--green); background: var(--green-soft); }
.schedule-day:hover, .schedule-day:focus-visible { transform: translateY(-1px); box-shadow: 0 3px 8px rgba(23,33,26,.08); z-index: 4; }
.schedule-day.today { border-color: var(--green); box-shadow: 0 0 0 1px var(--green); }
.schedule-day.today .schedule-date { font-weight: 800; color: var(--green); }
.schedule-dots { position: absolute; right: 7px; bottom: 7px; display: flex; align-items: flex-end; justify-content: flex-end; gap: 4px; min-height: 9px; max-width: calc(100% - 14px); }
.schedule-dots .dot, .schedule-legend .dot { width: 7px; height: 7px; flex: 0 0 7px; display: block; border-radius: 50%; box-shadow: 0 0 0 1.5px #fff; }
.schedule-dots .dot.priority-1, .schedule-legend .dot.priority-1 { background: #e5484d; }
.schedule-dots .dot.priority-2, .schedule-legend .dot.priority-2 { background: #f76b15; }
.schedule-dots .dot.priority-3, .schedule-legend .dot.priority-3 { background: var(--green); }
.schedule-dots .dot.priority-4, .schedule-legend .dot.priority-4 { background: #8a93a6; }
.schedule-dots .dot.priority-5, .schedule-legend .dot.priority-5 { background: #c6ccd6; }
.schedule-dots-more { color: var(--muted); font-size: 10px; line-height: 9px; white-space: nowrap; }
.schedule-day[data-tooltip]::after { content: attr(data-tooltip); position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; display: none; width: max-content; max-width: min(260px, calc(100vw - 28px)); padding: 8px 10px; border-radius: 5px; background: var(--ink); color: #fff; box-shadow: 0 5px 16px rgba(23,33,26,.2); font-size: 11px; font-weight: 400; line-height: 1.5; text-align: left; white-space: pre-line; pointer-events: none; }
.schedule-day[data-tooltip]:hover::after, .schedule-day[data-tooltip]:focus-visible::after { display: block; }
.schedule-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 13px; margin-top: 12px; color: var(--muted); font-size: 11px; }
.schedule-legend-label { margin-right: 2px; font-weight: 600; }
.schedule-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.schedule-list { display: grid; gap: 7px; margin-top: 15px; }
.schedule-item { display: grid; grid-template-columns: 4px 1fr auto auto; gap: 10px; align-items: center; border-top: 1px solid var(--line); padding: 11px 0; }
.schedule-item .priority-bar { height: 36px; }
.schedule-copy { min-width: 0; }
.schedule-copy strong, .schedule-copy small { display: block; }
.schedule-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.schedule-copy small { margin-top: 4px; color: var(--muted); font-size: 11px; }
.schedule-item.completed .schedule-copy strong { text-decoration: line-through; color: var(--muted); }
.schedule-item .icon-button { width: 32px; min-height: 32px; font-size: 16px; }
.schedule-dialog { width: min(620px, calc(100vw - 28px)); }
.schedule-day.blank { border-color: transparent; background: transparent; pointer-events: none; }
@media (max-width: 820px) {
.topbar { padding: 0 12px; }.button.primary { padding-inline: 10px; }
.workspace { display: block; }.sidebar { border-right: 0; border-bottom: 1px solid var(--line); max-height: 41vh; }.main-content { padding: 22px 16px 50px; }
.summary-strip { grid-template-columns: repeat(3, 1fr); }.summary-cell { padding: 15px 10px; }.summary-cell strong { font-size: 20px; }
.overview-row { grid-template-columns: 1fr auto; }.overview-row .progress-track { grid-column: 1 / -1; grid-row: 2; }
.metric-row { grid-template-columns: repeat(2, 1fr); }.remove-metric { grid-column: 2; justify-self: end; }
.review-day-records { padding-left: 14px; }
}
@media (max-width: 520px) {
.brand-button small { display: none; }.top-actions .primary { font-size: 0; width: 42px; }.top-actions .primary::first-letter { font-size: 18px; }