feat: recurring tasks, habits, attachments, import/export, audit and security
This commit is contained in:
+23
-3
@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError
|
||||
from fastapi import Cookie, Depends, HTTPException, Response, status
|
||||
from fastapi import Cookie, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -14,6 +14,7 @@ from .models import Session, User
|
||||
|
||||
password_hasher = PasswordHasher()
|
||||
COOKIE_NAME = "dodo_session"
|
||||
CSRF_COOKIE_NAME = "dodo_csrf"
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
@@ -31,15 +32,24 @@ def verify_password(password_hash: str, password: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def issue_session(db: AsyncSession, response: Response, user: User) -> None:
|
||||
async def issue_session(db: AsyncSession, response: Response, user: User, request: Request | None = None) -> None:
|
||||
token = secrets.token_urlsafe(32)
|
||||
csrf = secrets.token_urlsafe(24)
|
||||
expires = datetime.now(UTC) + timedelta(days=get_settings().session_days)
|
||||
db.add(Session(token_hash=hash_token(token), user_id=user.id, expires_at=expires))
|
||||
db.add(Session(
|
||||
token_hash=hash_token(token), user_id=user.id, expires_at=expires,
|
||||
ip_address=request.client.host if request and request.client else None,
|
||||
user_agent=request.headers.get("user-agent", "")[:500] if request else None,
|
||||
))
|
||||
await db.commit()
|
||||
response.set_cookie(
|
||||
COOKIE_NAME, token, max_age=get_settings().session_days * 86400,
|
||||
httponly=True, secure=get_settings().cookie_secure, samesite="lax", path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE_NAME, csrf, max_age=get_settings().session_days * 86400,
|
||||
httponly=False, secure=get_settings().cookie_secure, samesite="lax", path="/",
|
||||
)
|
||||
|
||||
|
||||
async def session_token(
|
||||
@@ -63,3 +73,13 @@ async def current_user(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已失效,请重新登录")
|
||||
return user
|
||||
|
||||
|
||||
async def current_session(
|
||||
token: str = Depends(session_token),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Session:
|
||||
row = await db.scalar(select(Session).where(Session.token_hash == hash_token(token), Session.expires_at > datetime.now(UTC)))
|
||||
if row is None:
|
||||
raise HTTPException(status_code=401, detail="会话已失效,请重新登录")
|
||||
return row
|
||||
|
||||
@@ -10,6 +10,10 @@ class Settings(BaseSettings):
|
||||
cookie_secure: bool = False
|
||||
trusted_proxies: str = ""
|
||||
auto_create_schema: bool = False
|
||||
attachment_dir: str = "./data/attachments"
|
||||
attachment_max_mb: int = 20
|
||||
login_attempts: int = 5
|
||||
login_window_seconds: int = 300
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="DODO_", env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
+103
-7
@@ -1,12 +1,15 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import delete, exists, func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -14,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .auth import (
|
||||
COOKIE_NAME,
|
||||
CSRF_COOKIE_NAME,
|
||||
current_user,
|
||||
hash_password,
|
||||
hash_token,
|
||||
@@ -23,6 +27,8 @@ from .auth import (
|
||||
)
|
||||
from .db import create_schema, get_db
|
||||
from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow
|
||||
from .mvp import audit
|
||||
from .mvp import router as mvp_router
|
||||
from .schemas import (
|
||||
BatchResult,
|
||||
BatchTaskUpdate,
|
||||
@@ -55,13 +61,44 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="dodo",
|
||||
version="0.1.0",
|
||||
version="0.2.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
openapi_url="/api/openapi.json",
|
||||
docs_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security(request: Request, call_next):
|
||||
if request.method not in {"GET", "HEAD", "OPTIONS"} and request.cookies.get(COOKIE_NAME):
|
||||
csrf_cookie = request.cookies.get(CSRF_COOKIE_NAME)
|
||||
csrf_header = request.headers.get("x-csrf-token")
|
||||
# SameSite cookies plus same-origin validation; API clients may explicitly double-submit.
|
||||
origin = request.headers.get("origin")
|
||||
if origin and (not csrf_cookie or csrf_header != csrf_cookie):
|
||||
return JSONResponse({"detail": "CSRF 校验失败"}, status_code=403)
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "same-origin"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/docs", include_in_schema=False)
|
||||
async def docs(_: User = Depends(current_user)):
|
||||
return get_swagger_ui_html(openapi_url="/api/openapi.json", title="dodo API")
|
||||
|
||||
|
||||
@app.get("/api/openapi.json", include_in_schema=False)
|
||||
async def openapi(_: User = Depends(current_user)):
|
||||
return app.openapi()
|
||||
|
||||
|
||||
app.include_router(mvp_router)
|
||||
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
@app.get("/health/live")
|
||||
async def live():
|
||||
return {"status": "ok"}
|
||||
@@ -83,6 +120,7 @@ async def setup_status(db: AsyncSession = Depends(get_db)):
|
||||
async def initialize(
|
||||
payload: InitializeRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
count = await db.scalar(select(func.count()).select_from(User))
|
||||
@@ -99,7 +137,7 @@ async def initialize(
|
||||
db.add(TaskList(user_id=user.id, name="收集箱", is_inbox=True))
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await issue_session(db, response, user)
|
||||
await issue_session(db, response, user, request)
|
||||
return user
|
||||
|
||||
|
||||
@@ -107,12 +145,26 @@ async def initialize(
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from .config import get_settings
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
key = (payload.username.casefold(), ip)
|
||||
now = time.monotonic()
|
||||
attempts = _login_attempts[key]
|
||||
window = get_settings().login_window_seconds
|
||||
while attempts and attempts[0] < now - window:
|
||||
attempts.popleft()
|
||||
if len(attempts) >= get_settings().login_attempts:
|
||||
raise HTTPException(status_code=429, detail="登录尝试过多,请稍后再试", headers={"Retry-After": str(window)})
|
||||
user = await db.scalar(select(User).where(User.username == payload.username))
|
||||
if user is None or not verify_password(user.password_hash, payload.password):
|
||||
attempts.append(now)
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
await issue_session(db, response, user)
|
||||
attempts.clear()
|
||||
await issue_session(db, response, user, request)
|
||||
return user
|
||||
|
||||
|
||||
@@ -132,6 +184,35 @@ async def logout(
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
response.delete_cookie(CSRF_COOKIE_NAME, path="/")
|
||||
return Response(status_code=204, headers=response.headers)
|
||||
|
||||
|
||||
@app.get("/api/v1/sessions")
|
||||
async def list_sessions(
|
||||
token: str = Depends(session_token),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
current_hash = hash_token(token)
|
||||
rows = (await db.scalars(select(Session).where(Session.user_id == user.id).order_by(Session.created_at.desc()))).all()
|
||||
return [{"id": row.id, "current": row.token_hash == current_hash, "created_at": row.created_at, "last_seen_at": row.last_seen_at, "expires_at": row.expires_at, "ip_address": row.ip_address, "user_agent": row.user_agent} for row in rows]
|
||||
|
||||
|
||||
@app.delete("/api/v1/sessions/{session_id}", status_code=204)
|
||||
async def revoke_session(
|
||||
session_id: UUID,
|
||||
response: Response,
|
||||
token: str = Depends(session_token),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
row = await db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id))
|
||||
if not row: raise HTTPException(404, "会话不存在")
|
||||
current = row.token_hash == hash_token(token)
|
||||
await db.delete(row); await db.commit()
|
||||
if current:
|
||||
response.delete_cookie(COOKIE_NAME, path="/"); response.delete_cookie(CSRF_COOKIE_NAME, path="/")
|
||||
return Response(status_code=204, headers=response.headers)
|
||||
|
||||
|
||||
@@ -169,6 +250,8 @@ async def rename_folder(
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
folder.name = payload.name
|
||||
await db.flush()
|
||||
audit(db, user.id, "update", "folder", folder.id)
|
||||
await db.commit()
|
||||
return folder
|
||||
|
||||
@@ -187,6 +270,7 @@ async def delete_folder(
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
folder.deleted_at = utcnow()
|
||||
audit(db, user.id, "delete", "folder", folder.id)
|
||||
await db.execute(
|
||||
update(TaskList)
|
||||
.where(TaskList.user_id == user.id, TaskList.folder_id == folder.id)
|
||||
@@ -212,6 +296,8 @@ async def create_list(
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name)
|
||||
db.add(item)
|
||||
await db.flush()
|
||||
audit(db, user.id, "create", "list", item.id)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
@@ -248,7 +334,10 @@ async def rename_list(
|
||||
if item.is_inbox:
|
||||
raise HTTPException(status_code=409, detail="系统收集箱不能重命名")
|
||||
item.name = payload.name
|
||||
await db.flush()
|
||||
audit(db, user.id, "update", "list", item.id)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@@ -364,6 +453,7 @@ async def create_task(
|
||||
task = Task(user_id=user.id, **data)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
audit(db, user.id, "create", "task", task.id)
|
||||
if tag_ids:
|
||||
db.add_all(TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tag_ids)
|
||||
await db.commit()
|
||||
@@ -499,6 +589,10 @@ async def update_task(
|
||||
.where(Task.parent_id == task.id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
.values(list_id=task.list_id, version=Task.version + 1, updated_at=utcnow())
|
||||
)
|
||||
changed = {k for k in data if k not in {"version", "updated_at"}}
|
||||
if changed & {"title", "description", "priority", "due_at", "list_id", "completed"}:
|
||||
action = "complete" if data.get("completed") is True else "update"
|
||||
audit(db, user.id, action, "task", task.id, fields=sorted(changed))
|
||||
await db.commit()
|
||||
return await _task_detail(db, task)
|
||||
|
||||
@@ -520,6 +614,7 @@ async def delete_task(
|
||||
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
|
||||
.values(deleted_at=deleted_at, version=Task.version + 1, updated_at=deleted_at)
|
||||
)
|
||||
audit(db, user.id, "delete", "task", task.id)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -564,6 +659,7 @@ async def restore_task(
|
||||
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
|
||||
.values(deleted_at=None, version=Task.version + 1, updated_at=now)
|
||||
)
|
||||
audit(db, user.id, "restore", "task", task.id)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return await _task_detail(db, task)
|
||||
|
||||
+99
-2
@@ -1,7 +1,18 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from uuid_utils import uuid7
|
||||
|
||||
@@ -38,6 +49,9 @@ class Session(Base):
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
|
||||
class Folder(Base):
|
||||
@@ -80,6 +94,7 @@ class TaskTag(Base):
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
__table_args__ = (UniqueConstraint("user_id", "external_id", name="uq_tasks_external_id"),)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
list_id: Mapped[UUID] = mapped_column(ForeignKey("task_lists.id", ondelete="CASCADE"), index=True)
|
||||
@@ -94,3 +109,85 @@ class Task(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class RecurrenceTemplate(Base):
|
||||
__tablename__ = "recurrence_templates"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), unique=True)
|
||||
rrule: Mapped[str] = mapped_column(Text)
|
||||
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class RecurrenceException(Base):
|
||||
__tablename__ = "recurrence_exceptions"
|
||||
__table_args__ = (UniqueConstraint("template_id", "occurrence_at", name="uq_recurrence_exception"),)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
template_id: Mapped[UUID] = mapped_column(ForeignKey("recurrence_templates.id", ondelete="CASCADE"), index=True)
|
||||
occurrence_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class Habit(Base):
|
||||
__tablename__ = "habits"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
kind: Mapped[str] = mapped_column(String(16), default="boolean")
|
||||
target: Mapped[float] = mapped_column(Float, default=1)
|
||||
max_value: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
schedule_type: Mapped[str] = mapped_column(String(16), default="daily")
|
||||
weekdays: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
month_days: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
start_date: Mapped[date] = mapped_column(Date)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class HabitLog(Base):
|
||||
__tablename__ = "habit_logs"
|
||||
__table_args__ = (UniqueConstraint("habit_id", "day", name="uq_habit_log_day"),)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True)
|
||||
day: Mapped[date] = mapped_column(Date)
|
||||
value: Mapped[float] = mapped_column(Float)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class HabitPause(Base):
|
||||
__tablename__ = "habit_pauses"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True)
|
||||
start_date: Mapped[date] = mapped_column(Date)
|
||||
end_date: Mapped[date] = mapped_column(Date)
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), index=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
storage_name: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
mime_type: Mapped[str] = mapped_column(String(127))
|
||||
size: Mapped[int] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
action: Mapped[str] = mapped_column(String(32))
|
||||
entity_type: Mapped[str] = mapped_column(String(32))
|
||||
entity_id: Mapped[UUID | None] = mapped_column(nullable=True)
|
||||
details: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .auth import current_user
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .models import (
|
||||
Attachment,
|
||||
AuditLog,
|
||||
Folder,
|
||||
Habit,
|
||||
HabitLog,
|
||||
HabitPause,
|
||||
RecurrenceException,
|
||||
RecurrenceTemplate,
|
||||
Tag,
|
||||
Task,
|
||||
TaskList,
|
||||
User,
|
||||
new_id,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
|
||||
def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity_id=None, **details):
|
||||
db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details))
|
||||
|
||||
|
||||
class RecurrenceCreate(BaseModel):
|
||||
task_id: UUID
|
||||
rrule: str = Field(min_length=5, max_length=1000)
|
||||
|
||||
|
||||
class RecurrenceChange(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=500)
|
||||
due_at: datetime | None = None
|
||||
rrule: str | None = None
|
||||
|
||||
|
||||
class OccurrenceComplete(BaseModel):
|
||||
occurrence_at: datetime
|
||||
|
||||
|
||||
_RRULE_PART = re.compile(r"^[A-Z]+=[A-Z0-9,+-]+$")
|
||||
_WEEKDAYS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
|
||||
|
||||
|
||||
def parse_rrule(value: str) -> dict[str, str]:
|
||||
parts = {}
|
||||
for part in value.upper().split(";"):
|
||||
if not _RRULE_PART.fullmatch(part):
|
||||
raise HTTPException(422, "无效的 RRULE")
|
||||
key, val = part.split("=", 1)
|
||||
parts[key] = val
|
||||
if parts.get("FREQ") not in {"DAILY", "WEEKLY", "MONTHLY"}:
|
||||
raise HTTPException(422, "仅支持 DAILY、WEEKLY、MONTHLY")
|
||||
try:
|
||||
if "INTERVAL" in parts and int(parts["INTERVAL"]) < 1:
|
||||
raise ValueError
|
||||
if "COUNT" in parts and int(parts["COUNT"]) < 1:
|
||||
raise ValueError
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, "无效的 RRULE 数字") from exc
|
||||
return parts
|
||||
|
||||
|
||||
def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cutoff=None):
|
||||
parts = parse_rrule(rule)
|
||||
interval = int(parts.get("INTERVAL", 1))
|
||||
count = int(parts.get("COUNT", 100000))
|
||||
if "UNTIL" in parts:
|
||||
until = datetime.fromisoformat(parts["UNTIL"])
|
||||
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until
|
||||
else:
|
||||
until = end
|
||||
if starts.tzinfo is None:
|
||||
starts = starts.replace(tzinfo=UTC)
|
||||
if until.tzinfo is None:
|
||||
until = until.replace(tzinfo=UTC)
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=UTC)
|
||||
if end.tzinfo is None:
|
||||
end = end.replace(tzinfo=UTC)
|
||||
result = []
|
||||
cursor = starts
|
||||
emitted = 0
|
||||
while cursor <= until and emitted < count:
|
||||
include = False
|
||||
if parts["FREQ"] == "DAILY":
|
||||
include = (cursor.date() - starts.date()).days % interval == 0
|
||||
elif parts["FREQ"] == "WEEKLY":
|
||||
days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")}
|
||||
include = cursor.weekday() in days and ((cursor.date() - starts.date()).days // 7) % interval == 0
|
||||
else:
|
||||
month_delta = (cursor.year - starts.year) * 12 + cursor.month - starts.month
|
||||
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
|
||||
include = month_delta % interval == 0 and cursor.day in month_days
|
||||
if include and cursor >= starts:
|
||||
emitted += 1
|
||||
if start <= cursor <= end:
|
||||
result.append(cursor)
|
||||
cursor += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
async def owned_task(db, user_id, task_id):
|
||||
task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user_id, Task.deleted_at.is_(None)))
|
||||
if not task:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
return task
|
||||
|
||||
|
||||
async def owned_recurrence(db, user_id, recurrence_id):
|
||||
row = await db.scalar(select(RecurrenceTemplate).where(RecurrenceTemplate.id == recurrence_id, RecurrenceTemplate.user_id == user_id))
|
||||
if not row:
|
||||
raise HTTPException(404, "重复规则不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/recurrences", status_code=201)
|
||||
async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
task = await owned_task(db, user.id, payload.task_id)
|
||||
if not task.due_at:
|
||||
raise HTTPException(422, "重复任务需要截止时间")
|
||||
parse_rrule(payload.rrule)
|
||||
if await db.scalar(select(RecurrenceTemplate.id).where(RecurrenceTemplate.task_id == task.id)):
|
||||
raise HTTPException(409, "任务已有重复规则")
|
||||
row = RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule.upper(), starts_at=task.due_at)
|
||||
db.add(row)
|
||||
await db.commit(); await db.refresh(row)
|
||||
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at}
|
||||
|
||||
|
||||
@router.get("/calendar")
|
||||
async def calendar(start: date, end: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if end < start or (end - start).days > 366:
|
||||
raise HTTPException(422, "日期范围无效或超过一年")
|
||||
start_dt = datetime.combine(start, time.min, tzinfo=UTC)
|
||||
end_dt = datetime.combine(end, time.max, tzinfo=UTC)
|
||||
rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all()
|
||||
output = []
|
||||
for template, task in rows:
|
||||
exception_rows = (await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id == template.id))).all()
|
||||
exceptions: dict[datetime, RecurrenceException] = {}
|
||||
for exc in exception_rows:
|
||||
key = exc.occurrence_at.replace(tzinfo=UTC) if exc.occurrence_at.tzinfo is None else exc.occurrence_at
|
||||
exceptions[key] = exc
|
||||
for at in occurrences(template.rrule, template.starts_at, start_dt, end_dt, template.ends_at):
|
||||
exception = exceptions.get(at)
|
||||
if exception and exception.deleted:
|
||||
continue
|
||||
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": exception.due_at if exception and exception.due_at else at, "completed": bool(exception and exception.completed)})
|
||||
return sorted(output, key=lambda item: item["occurrence_at"])
|
||||
|
||||
|
||||
async def upsert_exception(db, template_id, at):
|
||||
row = await db.scalar(select(RecurrenceException).where(RecurrenceException.template_id == template_id, RecurrenceException.occurrence_at == at))
|
||||
if not row:
|
||||
row = RecurrenceException(template_id=template_id, occurrence_at=at)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
@router.patch("/recurrences/{recurrence_id}")
|
||||
async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope: str = Query("all", pattern="^(this|this-and-future|all)$"), occurrence_at: datetime | None = None, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
template = await owned_recurrence(db, user.id, recurrence_id)
|
||||
task = await owned_task(db, user.id, template.task_id)
|
||||
if scope == "this":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
row = await upsert_exception(db, template.id, occurrence_at)
|
||||
if payload.title is not None: row.title = payload.title
|
||||
if payload.due_at is not None: row.due_at = payload.due_at
|
||||
elif scope == "this-and-future":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
template.ends_at = occurrence_at - timedelta(microseconds=1)
|
||||
if payload.rrule:
|
||||
parse_rrule(payload.rrule)
|
||||
db.add(RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule, starts_at=payload.due_at or occurrence_at))
|
||||
elif payload.title:
|
||||
task.title = payload.title
|
||||
else:
|
||||
if payload.rrule: parse_rrule(payload.rrule); template.rrule = payload.rrule.upper()
|
||||
if payload.title is not None: task.title = payload.title
|
||||
if payload.due_at is not None: task.due_at = payload.due_at; template.starts_at = payload.due_at
|
||||
await db.commit()
|
||||
return {"id": template.id, "scope": scope}
|
||||
|
||||
|
||||
@router.post("/recurrences/{recurrence_id}/complete")
|
||||
async def complete_occurrence(recurrence_id: UUID, payload: OccurrenceComplete, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
template = await owned_recurrence(db, user.id, recurrence_id)
|
||||
row = await upsert_exception(db, template.id, payload.occurrence_at); row.completed = True
|
||||
audit(db, user.id, "complete", "task", template.task_id, occurrence_at=payload.occurrence_at.isoformat())
|
||||
await db.commit(); return {"completed": True}
|
||||
|
||||
|
||||
@router.delete("/recurrences/{recurrence_id}", status_code=204)
|
||||
async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", pattern="^(this|this-and-future|all)$"), occurrence_at: datetime | None = None, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
template = await owned_recurrence(db, user.id, recurrence_id)
|
||||
if scope == "this":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
row = await upsert_exception(db, template.id, occurrence_at); row.deleted = True
|
||||
elif scope == "this-and-future":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
template.ends_at = occurrence_at - timedelta(microseconds=1)
|
||||
else: await db.delete(template)
|
||||
await db.commit(); return Response(status_code=204)
|
||||
|
||||
|
||||
class HabitCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: str = Field("boolean", pattern="^(boolean|numeric)$")
|
||||
target: float = Field(1, gt=0)
|
||||
max_value: float | None = Field(None, gt=0)
|
||||
schedule_type: str = Field("daily", pattern="^(daily|weekly|monthly|interval)$")
|
||||
weekdays: list[int] | None = None
|
||||
month_days: list[int] | None = None
|
||||
interval_days: int | None = Field(None, ge=1)
|
||||
start_date: date = Field(default_factory=date.today)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def schedule_valid(self):
|
||||
if self.schedule_type == "interval" and not self.interval_days: raise ValueError("interval_days required")
|
||||
if self.kind == "boolean": self.target = 1; self.max_value = 1
|
||||
return self
|
||||
|
||||
|
||||
class HabitLogInput(BaseModel):
|
||||
day: date
|
||||
value: float = Field(gt=0)
|
||||
|
||||
|
||||
class HabitLogEdit(BaseModel): value: float = Field(ge=0)
|
||||
class PauseInput(BaseModel):
|
||||
start_date: date
|
||||
end_date: date
|
||||
@model_validator(mode="after")
|
||||
def ordered(self):
|
||||
if self.end_date < self.start_date: raise ValueError("invalid range")
|
||||
return self
|
||||
|
||||
|
||||
def habit_dict(h):
|
||||
return {"id": h.id, "name": h.name, "kind": h.kind, "target": h.target, "max_value": h.max_value, "schedule_type": h.schedule_type, "weekdays": [int(x) for x in h.weekdays.split(",")] if h.weekdays else None, "month_days": [int(x) for x in h.month_days.split(",")] if h.month_days else None, "interval_days": h.interval_days, "start_date": h.start_date, "archived_at": h.archived_at}
|
||||
|
||||
|
||||
async def owned_habit(db, user_id, habit_id):
|
||||
row = await db.scalar(select(Habit).where(Habit.id == habit_id, Habit.user_id == user_id))
|
||||
if not row: raise HTTPException(404, "习惯不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/habits", status_code=201)
|
||||
async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = Habit(user_id=user.id, **payload.model_dump(exclude={"weekdays", "month_days"}), weekdays=",".join(map(str, payload.weekdays)) if payload.weekdays else None, month_days=",".join(map(str, payload.month_days)) if payload.month_days else None)
|
||||
db.add(row); await db.commit(); await db.refresh(row); return habit_dict(row)
|
||||
|
||||
|
||||
@router.get("/habits")
|
||||
async def list_habits(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
condition = Habit.archived_at.is_not(None) if archived else Habit.archived_at.is_(None)
|
||||
return [habit_dict(h) for h in (await db.scalars(select(Habit).where(Habit.user_id == user.id, condition).order_by(Habit.created_at))).all()]
|
||||
|
||||
|
||||
@router.patch("/habits/{habit_id}")
|
||||
async def edit_habit(habit_id: UUID, payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_habit(db, user.id, habit_id)
|
||||
for key, value in payload.model_dump(exclude={"weekdays", "month_days"}).items(): setattr(row, key, value)
|
||||
row.weekdays = ",".join(map(str, payload.weekdays)) if payload.weekdays else None; row.month_days = ",".join(map(str, payload.month_days)) if payload.month_days else None
|
||||
await db.commit(); return habit_dict(row)
|
||||
|
||||
|
||||
@router.delete("/habits/{habit_id}", status_code=204)
|
||||
async def archive_habit(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_habit(db, user.id, habit_id); row.archived_at = utcnow(); await db.commit(); return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/habits/{habit_id}/logs")
|
||||
async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == payload.day))
|
||||
value = min((row.value if row else 0) + payload.value, habit.max_value or float("inf"))
|
||||
if habit.kind == "boolean": value = 1
|
||||
if row: row.value = value; row.updated_at = utcnow()
|
||||
else: row = HabitLog(habit_id=habit.id, day=payload.day, value=value); db.add(row)
|
||||
await db.commit(); return {"day": row.day, "value": row.value}
|
||||
|
||||
|
||||
@router.put("/habits/{habit_id}/logs/{day}")
|
||||
async def edit_habit_log(habit_id: UUID, day: date, payload: HabitLogEdit, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
value = min(payload.value, habit.max_value or float("inf")); value = float(bool(value)) if habit.kind == "boolean" else value
|
||||
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day))
|
||||
if row: row.value = value; row.updated_at = utcnow()
|
||||
else: row = HabitLog(habit_id=habit.id, day=day, value=value); db.add(row)
|
||||
await db.commit(); return {"day": row.day, "value": row.value}
|
||||
|
||||
|
||||
@router.get("/habits/{habit_id}/logs")
|
||||
async def habit_logs(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
return [{"day": x.day, "value": x.value} for x in (await db.scalars(select(HabitLog).where(HabitLog.habit_id == habit.id).order_by(HabitLog.day.desc()))).all()]
|
||||
|
||||
|
||||
@router.post("/habits/{habit_id}/pauses", status_code=201)
|
||||
async def pause_habit(habit_id: UUID, payload: PauseInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id); row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()}
|
||||
|
||||
|
||||
def scheduled(h, day):
|
||||
if day < h.start_date: return False
|
||||
if h.schedule_type == "daily": return True
|
||||
if h.schedule_type == "weekly": return day.weekday() in {int(x) for x in (h.weekdays or "").split(",") if x}
|
||||
if h.schedule_type == "monthly": return day.day in {int(x) for x in (h.month_days or "").split(",") if x}
|
||||
return (day - h.start_date).days % h.interval_days == 0
|
||||
|
||||
|
||||
@router.get("/habits/grid")
|
||||
async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
start = week - timedelta(days=week.weekday()); days = [start + timedelta(days=i) for i in range(7)]
|
||||
habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id, Habit.archived_at.is_(None)))).all())
|
||||
output = []
|
||||
for h in habits:
|
||||
logs = {x.day: x.value for x in (await db.scalars(select(HabitLog).where(HabitLog.habit_id == h.id, HabitLog.day.between(days[0], days[-1])))).all()}
|
||||
pauses = list((await db.scalars(select(HabitPause).where(HabitPause.habit_id == h.id))).all())
|
||||
output.append({"id": h.id, "name": h.name, "cells": [{"day": d, "scheduled": scheduled(h, d), "paused": any(p.start_date <= d <= p.end_date for p in pauses), "value": logs.get(d, 0)} for d in days]})
|
||||
return {"days": days, "habits": output}
|
||||
|
||||
|
||||
@router.get("/habits/{habit_id}/stats")
|
||||
async def habit_stats(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id); logs = list((await db.scalars(select(HabitLog).where(HabitLog.habit_id == habit.id).order_by(HabitLog.day))).all())
|
||||
return {"total": sum(x.value for x in logs), "completed_days": sum(x.value >= habit.target for x in logs), "logged_days": len(logs)}
|
||||
|
||||
|
||||
_ALLOWED_MIME = {"text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png", "image/gif", "application/json", "application/zip"}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/attachments", status_code=201)
|
||||
async def upload_attachment(task_id: UUID, file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
await owned_task(db, user.id, task_id)
|
||||
name = Path(file.filename or "").name
|
||||
if not name or name != file.filename or file.content_type not in _ALLOWED_MIME: raise HTTPException(400, "文件名或类型不允许")
|
||||
limit = get_settings().attachment_max_mb * 1024 * 1024; content = await file.read(limit + 1)
|
||||
if len(content) > limit: raise HTTPException(413, "文件过大")
|
||||
root = Path(get_settings().attachment_dir).resolve(); root.mkdir(parents=True, exist_ok=True); storage = str(new_id())
|
||||
(root / storage).write_bytes(content)
|
||||
row = Attachment(user_id=user.id, task_id=task_id, filename=name, storage_name=storage, mime_type=file.content_type, size=len(content)); db.add(row); await db.commit(); await db.refresh(row)
|
||||
return {"id": row.id, "task_id": row.task_id, "filename": row.filename, "mime_type": row.mime_type, "size": row.size}
|
||||
|
||||
|
||||
async def owned_attachment(db, user_id, attachment_id):
|
||||
row = await db.scalar(select(Attachment).where(Attachment.id == attachment_id, Attachment.user_id == user_id))
|
||||
if not row: raise HTTPException(404, "附件不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}")
|
||||
async def download_attachment(attachment_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_attachment(db, user.id, attachment_id); path = Path(get_settings().attachment_dir).resolve() / row.storage_name
|
||||
if not path.is_file(): raise HTTPException(404, "附件文件不存在")
|
||||
return FileResponse(path, media_type=row.mime_type, filename=row.filename)
|
||||
|
||||
|
||||
@router.delete("/attachments/{attachment_id}", status_code=204)
|
||||
async def delete_attachment(attachment_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_attachment(db, user.id, attachment_id); (Path(get_settings().attachment_dir).resolve() / row.storage_name).unlink(missing_ok=True); await db.delete(row); await db.commit(); return Response(status_code=204)
|
||||
|
||||
|
||||
def read_ticktick(content: bytes):
|
||||
try: text = content.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc: raise HTTPException(422, "CSV 必须为 UTF-8") from exc
|
||||
reader = csv.DictReader(io.StringIO(text)); required = {"Title", "ID"}
|
||||
if not reader.fieldnames or not required <= set(reader.fieldnames): raise HTTPException(422, "CSV 缺少 Title 或 ID")
|
||||
rows = []; errors = []
|
||||
for index, row in enumerate(reader, 2):
|
||||
if not row.get("Title", "").strip() or not row.get("ID", "").strip(): errors.append({"row": index, "error": "Title/ID required"})
|
||||
else: rows.append(row)
|
||||
return rows, errors
|
||||
|
||||
|
||||
@router.post("/import/ticktick/preview")
|
||||
async def preview_ticktick(file: UploadFile = File(...), user: User = Depends(current_user)):
|
||||
rows, errors = read_ticktick(await file.read()); return {"valid": len(rows), "invalid": len(errors), "errors": errors, "sample": rows[:10]}
|
||||
|
||||
|
||||
@router.post("/import/ticktick")
|
||||
async def import_ticktick(file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
rows, errors = read_ticktick(await file.read())
|
||||
if errors: raise HTTPException(422, errors)
|
||||
inbox = await db.scalar(select(TaskList).where(TaskList.user_id == user.id, TaskList.is_inbox.is_(True)))
|
||||
imported = skipped = 0
|
||||
for raw in rows:
|
||||
external_id = raw["ID"].strip()
|
||||
if await db.scalar(select(Task.id).where(Task.user_id == user.id, Task.external_id == external_id)): skipped += 1; continue
|
||||
due = None
|
||||
if raw.get("Due Date"):
|
||||
try: due = datetime.combine(date.fromisoformat(raw["Due Date"][:10]), time.min, tzinfo=UTC)
|
||||
except ValueError: raise HTTPException(422, f"无效日期: {raw['Due Date']}")
|
||||
task = Task(user_id=user.id, list_id=inbox.id, title=raw["Title"].strip(), completed=raw.get("Status", "0").lower() in {"1", "completed", "true"}, due_at=due, external_id=external_id); db.add(task); imported += 1
|
||||
audit(db, user.id, "import", "task", count=imported); await db.commit(); return {"imported": imported, "skipped": skipped}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
def serialize(row, fields):
|
||||
return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields}
|
||||
folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tags = list((await db.scalars(select(Tag).where(Tag.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all())
|
||||
return {"version": 1, "exported_at": utcnow(), "folders": [serialize(x,["id","name","position","deleted_at"]) for x in folders], "lists": [serialize(x,["id","folder_id","name","is_inbox","position","deleted_at"]) for x in lists], "tags": [serialize(x,["id","name","color"]) for x in tags], "tasks": [serialize(x,["id","list_id","parent_id","title","description","priority","completed","due_at","external_id","deleted_at"]) for x in tasks], "habits": [serialize(x,["id","name","kind","target","max_value","schedule_type","weekdays","month_days","interval_days","start_date","archived_at"]) for x in habits]}
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if payload.get("version") != 1: raise HTTPException(422, "不支持的备份版本")
|
||||
if mode == "replace":
|
||||
await db.execute(delete(Task).where(Task.user_id == user.id)); await db.execute(delete(TaskList).where(TaskList.user_id == user.id)); await db.execute(delete(Folder).where(Folder.user_id == user.id))
|
||||
id_map = {}
|
||||
for raw in payload.get("folders", []):
|
||||
old = raw["id"]; row = Folder(user_id=user.id, name=raw["name"], position=raw.get("position",0)); db.add(row); await db.flush(); id_map[old] = row.id
|
||||
inbox = None
|
||||
for raw in payload.get("lists", []):
|
||||
row = TaskList(user_id=user.id, folder_id=id_map.get(raw.get("folder_id")), name=raw["name"], is_inbox=raw.get("is_inbox",False), position=raw.get("position",0)); db.add(row); await db.flush(); id_map[raw["id"]] = row.id
|
||||
if row.is_inbox: inbox = row
|
||||
if not inbox: inbox = TaskList(user_id=user.id, name="收集箱", is_inbox=True); db.add(inbox); await db.flush()
|
||||
restored = 0
|
||||
for raw in payload.get("tasks", []):
|
||||
ext = raw.get("external_id")
|
||||
existing = await db.scalar(select(Task).where(Task.user_id == user.id, Task.external_id == ext)) if ext else None
|
||||
if existing and mode == "merge": continue
|
||||
row = Task(user_id=user.id, list_id=id_map.get(raw.get("list_id"), inbox.id), title=raw["title"], description=raw.get("description", ""), priority=raw.get("priority",0), completed=raw.get("completed",False), due_at=datetime.fromisoformat(raw["due_at"]) if raw.get("due_at") else None, external_id=ext); db.add(row); restored += 1
|
||||
audit(db, user.id, "restore", "backup", count=restored, mode=mode); await db.commit(); return {"restored": restored, "mode": mode}
|
||||
|
||||
|
||||
@router.get("/audit-logs")
|
||||
async def audit_logs(limit: int = Query(100, ge=1, le=500), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.scalars(select(AuditLog).where(AuditLog.user_id == user.id).order_by(AuditLog.created_at.desc()).limit(limit))).all()
|
||||
return [{"id": x.id, "action": x.action, "entity_type": x.entity_type, "entity_id": x.entity_id, "details": x.details, "created_at": x.created_at} for x in rows]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BiihmXG7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hzru7WEs.css">
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-Dq8LoBCn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CpBTIN38.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
@@ -1 +1 @@
|
||||
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
|
||||
{"name":"dodo","short_name":"dodo","description":"A handwritten-life task and habit planner.","start_url":"/","scope":"/","display":"standalone","orientation":"portrait","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"},{"src":"/apple-touch-icon.png","sizes":"180x180","type":"image/png","purpose":"any"}]}
|
||||
+21
-1
@@ -1 +1,21 @@
|
||||
const CACHE='dodo-shell-v1';self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(['/','/manifest.json']))));self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));self.addEventListener('fetch',e=>{if(e.request.url.includes('/api/'))return;e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)))})
|
||||
const CACHE = 'dodo-shell-v2'
|
||||
const SHELL = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting()
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)))
|
||||
})
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))).then(() => self.clients.claim()))
|
||||
})
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
if (url.pathname.startsWith('/api/')) return
|
||||
if (event.request.method !== 'GET') return
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
|
||||
const copy = response.clone()
|
||||
caches.open(CACHE).then((cache) => cache.put(event.request, copy))
|
||||
return response
|
||||
}).catch(() => caches.match('/'))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/node":"latest","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"}}
|
||||
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@fullcalendar/core":"^6.1.21","@fullcalendar/daygrid":"^6.1.21","@fullcalendar/interaction":"^6.1.21","@fullcalendar/vue3":"^6.1.21","@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/node":"latest","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"}}
|
||||
Generated
+53
@@ -8,6 +8,18 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@fullcalendar/core':
|
||||
specifier: ^6.1.21
|
||||
version: 6.1.21
|
||||
'@fullcalendar/daygrid':
|
||||
specifier: ^6.1.21
|
||||
version: 6.1.21(@fullcalendar/[email protected])
|
||||
'@fullcalendar/interaction':
|
||||
specifier: ^6.1.21
|
||||
version: 6.1.21(@fullcalendar/[email protected])
|
||||
'@fullcalendar/vue3':
|
||||
specifier: ^6.1.21
|
||||
version: 6.1.21(@fullcalendar/[email protected])([email protected]([email protected]))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: latest
|
||||
version: 6.0.8([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
|
||||
@@ -86,6 +98,25 @@ packages:
|
||||
'@floating-ui/[email protected]':
|
||||
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
|
||||
|
||||
'@fullcalendar/[email protected]':
|
||||
resolution: {integrity: sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==}
|
||||
|
||||
'@fullcalendar/[email protected]':
|
||||
resolution: {integrity: sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==}
|
||||
peerDependencies:
|
||||
'@fullcalendar/core': ~6.1.21
|
||||
|
||||
'@fullcalendar/[email protected]':
|
||||
resolution: {integrity: sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==}
|
||||
peerDependencies:
|
||||
'@fullcalendar/core': ~6.1.21
|
||||
|
||||
'@fullcalendar/[email protected]':
|
||||
resolution: {integrity: sha512-OGt6WSC+/zz/ej6a0KfIBNl7BYuGchpZU49SsedYyv3WZWbghAE+D8YD6nhH1ia/I4p5Gcsv/nEXgEkT/I8aYQ==}
|
||||
peerDependencies:
|
||||
'@fullcalendar/core': ~6.1.21
|
||||
vue: ^3.0.11
|
||||
|
||||
'@internationalized/[email protected]':
|
||||
resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==}
|
||||
|
||||
@@ -744,6 +775,9 @@ packages:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
@@ -1023,6 +1057,23 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@fullcalendar/[email protected]':
|
||||
dependencies:
|
||||
preact: 10.12.1
|
||||
|
||||
'@fullcalendar/[email protected](@fullcalendar/[email protected])':
|
||||
dependencies:
|
||||
'@fullcalendar/core': 6.1.21
|
||||
|
||||
'@fullcalendar/[email protected](@fullcalendar/[email protected])':
|
||||
dependencies:
|
||||
'@fullcalendar/core': 6.1.21
|
||||
|
||||
'@fullcalendar/[email protected](@fullcalendar/[email protected])([email protected]([email protected]))':
|
||||
dependencies:
|
||||
'@fullcalendar/core': 6.1.21
|
||||
vue: 3.5.42([email protected])
|
||||
|
||||
'@internationalized/[email protected]':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.23
|
||||
@@ -1566,6 +1617,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
|
||||
{"name":"dodo","short_name":"dodo","description":"A handwritten-life task and habit planner.","start_url":"/","scope":"/","display":"standalone","orientation":"portrait","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"},{"src":"/apple-touch-icon.png","sizes":"180x180","type":"image/png","purpose":"any"}]}
|
||||
+21
-1
@@ -1 +1,21 @@
|
||||
const CACHE='dodo-shell-v1';self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(['/','/manifest.json']))));self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));self.addEventListener('fetch',e=>{if(e.request.url.includes('/api/'))return;e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)))})
|
||||
const CACHE = 'dodo-shell-v2'
|
||||
const SHELL = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting()
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)))
|
||||
})
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))).then(() => self.clients.claim()))
|
||||
})
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
if (url.pathname.startsWith('/api/')) return
|
||||
if (event.request.method !== 'GET') return
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
|
||||
const copy = response.clone()
|
||||
caches.open(CACHE).then((cache) => cache.put(event.request, copy))
|
||||
return response
|
||||
}).catch(() => caches.match('/'))),
|
||||
)
|
||||
})
|
||||
|
||||
+23
-9
@@ -2,16 +2,17 @@
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
|
||||
GripVertical, Inbox, ListChecks, ListTodo, Menu, MoreHorizontal, Pencil, Plus, Search,
|
||||
Settings, Tag as TagIcon, Trash2, X,
|
||||
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
|
||||
Settings, Trash2, X, CalendarRange, Repeat2,
|
||||
} from 'lucide-vue-next'
|
||||
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
|
||||
type FolderItem = { id: string; name: string }
|
||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||
type Tag = { id: string; name: string; color: string }
|
||||
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; tags?: Tag[]; subtasks?: Task[] }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash'
|
||||
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'calendar' | 'habits' | 'settings'
|
||||
|
||||
const initialized = ref<boolean | null>(null)
|
||||
const authenticated = ref(false)
|
||||
@@ -41,6 +42,9 @@ const activeName = computed(() => {
|
||||
if (activeView.value === 'trash') return '回收站'
|
||||
if (activeView.value === 'today') return '今天'
|
||||
if (activeView.value === 'upcoming') return '最近 7 天'
|
||||
if (activeView.value === 'calendar') return '月历'
|
||||
if (activeView.value === 'habits') return '习惯'
|
||||
if (activeView.value === 'settings') return '设置与数据'
|
||||
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||||
})
|
||||
const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value)
|
||||
@@ -50,6 +54,7 @@ const visibleTasks = computed(() => {
|
||||
let result = sourceTasks.value
|
||||
if (query.value.trim()) return result
|
||||
if (activeView.value === 'tasks') result = result.filter((task) => task.list_id === activeList.value)
|
||||
if (['calendar','habits','settings'].includes(activeView.value)) return []
|
||||
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
|
||||
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
|
||||
if (!showCompleted.value && activeView.value !== 'trash') result = result.filter((task) => !task.completed)
|
||||
@@ -135,7 +140,7 @@ async function loadTrash() {
|
||||
async function switchView(view: View, listId?: string) {
|
||||
activeView.value = view
|
||||
if (listId) activeList.value = listId
|
||||
selectedTask.value = null; selectedIds.value = new Set(); mobileSidebar.value = false
|
||||
selectedTask.value = null; selectedIds.value = new Set(); mobileSidebar.value = false; mobileDetail.value = false
|
||||
if (view === 'trash') await loadTrash()
|
||||
}
|
||||
async function addTask() {
|
||||
@@ -159,7 +164,7 @@ async function saveTask() {
|
||||
if (!selectedTask.value?.title.trim()) return
|
||||
try {
|
||||
const task = selectedTask.value
|
||||
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id, tag_ids: (task.tags ?? []).map((tag) => tag.id) } as Partial<Task>)
|
||||
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id, recurrence_rule: task.recurrence_rule || null, recurrence_end_at: task.recurrence_end_at || null, tag_ids: (task.tags ?? []).map((tag) => tag.id) } as Partial<Task>)
|
||||
toast('已保存')
|
||||
} catch (reason) { fail(reason) }
|
||||
}
|
||||
@@ -252,6 +257,8 @@ onMounted(bootstrap)
|
||||
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
|
||||
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
|
||||
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 天</button>
|
||||
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarRange />月历</button>
|
||||
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
||||
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||
</nav>
|
||||
<div class="section-title"><span>我的清单</span><span><button class="mini-icon" aria-label="新建文件夹" @click="createFolder"><Folder /></button><button class="mini-icon" aria-label="新建清单" @click="createList(null)"><Plus /></button></span></div>
|
||||
@@ -262,7 +269,7 @@ onMounted(bootstrap)
|
||||
</div>
|
||||
<button v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="删除清单" @click.stop="deleteEntity('lists',list)"><Trash2/></button></span></button>
|
||||
</div>
|
||||
<button class="settings"><Settings />设置</button>
|
||||
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
@@ -271,6 +278,10 @@ onMounted(bootstrap)
|
||||
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
|
||||
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
||||
</header>
|
||||
<template v-if="['calendar','habits','settings'].includes(activeView)">
|
||||
<MvpPanel :key="activeView" :view="activeView as 'calendar'|'habits'|'settings'" :tasks="tasks" @changed="loadAll" @notice="toast" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
|
||||
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>{{visibleTasks.length}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
|
||||
<div v-if="selectedCount" class="batch-bar"><b>已选 {{selectedCount}} 项</b><button @click="batch('complete')"><Check/>完成</button><button @click="batch('move')"><Folder/>移动</button><button class="danger" @click="batch('delete')"><Trash2/>删除</button><button class="icon" aria-label="取消选择" @click="selectedIds=new Set()"><X/></button></div>
|
||||
@@ -289,6 +300,7 @@ onMounted(bootstrap)
|
||||
</template>
|
||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<aside class="detail" :class="{open:mobileDetail}">
|
||||
@@ -298,6 +310,8 @@ onMounted(bootstrap)
|
||||
<label>清单<select v-model="selectedTask.list_id" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
||||
<label>截止时间<input :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
|
||||
<label>优先级<select v-model.number="selectedTask.priority" @change="saveTask"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label>
|
||||
<label>重复<select v-model="selectedTask.recurrence_rule" @change="saveTask"><option value="">不重复</option><option value="FREQ=DAILY">每天</option><option value="FREQ=WEEKLY">每周</option><option value="FREQ=MONTHLY">每月</option></select></label>
|
||||
<label v-if="selectedTask.recurrence_rule">重复截止<input v-model="selectedTask.recurrence_end_at" type="date" @change="saveTask"></label>
|
||||
<div class="field"><div class="field-label"><span>标签</span><button class="link" @click="createTag"><Plus/>新建</button></div><div class="tag-picker"><button v-for="tag in tags" :key="tag.id" :class="{chosen:taskHasTag(tag)}" @click="toggleTag(tag);saveTask()"><i :style="{background:tag.color}"/>{{tag.name}}</button><span v-if="!tags.length" class="hint">还没有标签</span></div></div>
|
||||
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown…" @blur="saveTask"/></div>
|
||||
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in tasks.filter(t=>t.parent_id===selectedTask?.id)" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!tasks.some(t=>t.parent_id===selectedTask?.id)" class="hint">把这件事拆成更小的步骤</span></div>
|
||||
@@ -306,8 +320,8 @@ onMounted(bootstrap)
|
||||
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期、优先级、标签、子任务和 Markdown 备注会出现在这里。</p></div>
|
||||
</aside>
|
||||
|
||||
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='upcoming'}" @click="switchView('upcoming')"><CalendarDays/><span>计划</span></button><button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2/><span>回收站</span></button></nav>
|
||||
<button v-if="activeView!=='trash'" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
|
||||
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='calendar'}" @click="switchView('calendar')"><CalendarRange/><span>月历</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
|
||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import type { CalendarOptions, EventDropArg } from '@fullcalendar/core'
|
||||
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { dateKey, habitWeek, mergePage, moveDueDate } from './lib/mvp-utils'
|
||||
|
||||
type View = 'calendar'|'habits'|'settings'
|
||||
type Task = { id:string; title:string; due_at:string|null; version:number }
|
||||
type Habit = { id:string; name:string; type?:string; target?:number; unit?:string; logs?: Array<{date:string;value:number|boolean}>; stats?: Record<string,number> }
|
||||
type Session = { id:string; created_at?:string; last_seen_at?:string; current?:boolean; user_agent?:string }
|
||||
const props = defineProps<{ view:View; tasks:Task[] }>()
|
||||
const emit = defineEmits<{ changed:[]; notice:[message:string] }>()
|
||||
const habits = ref<Habit[]>([]), sessions = ref<Session[]>([]), audit = ref<any[]>([])
|
||||
const busy = ref(false), error = ref(''), habitName = ref(''), habitType = ref('boolean'), habitTarget = ref(1)
|
||||
const importFile = ref<File|null>(null), importPreview = ref<any>(null), restoreFile = ref<File|null>(null)
|
||||
const week = computed(() => habitWeek())
|
||||
|
||||
async function request(path:string, options:RequestInit={}) {
|
||||
const headers:Record<string,string> = { ...(options.headers as Record<string,string> || {}) }
|
||||
if (options.body && !(options.body instanceof FormData)) headers['Content-Type']='application/json'
|
||||
const response = await fetch('/api/v1'+path,{ credentials:'include',...options,headers })
|
||||
if (!response.ok) throw new Error((await response.json().catch(()=>({}))).detail || `请求失败 (${response.status})`)
|
||||
const type=response.headers.get('content-type')||''
|
||||
return response.status===204?null:type.includes('json')?response.json():response.blob()
|
||||
}
|
||||
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try{await work()}catch(e){error.value=e instanceof Error?e.message:'请求失败'}finally{busy.value=false} }
|
||||
async function loadHabits(){ await safe(async()=>{const page=mergePage<Habit>(await request('/habits')); habits.value=page.items; await Promise.all(habits.value.map(async h=>{try{const [logs,stats]=await Promise.all([request(`/habits/${h.id}/logs?from=${dateKey(week.value[0])}&to=${dateKey(week.value[6])}`),request(`/habits/${h.id}/stats`)]);h.logs=mergePage<any>(logs).items;h.stats=stats}catch{/* optional enrichment */}}))}) }
|
||||
async function addHabit(){if(!habitName.value.trim())return;await safe(async()=>{await request('/habits',{method:'POST',body:JSON.stringify({name:habitName.value.trim(),type:habitType.value,target:habitTarget.value})});habitName.value='';await loadHabits();emit('notice','习惯已创建')})}
|
||||
function logFor(h:Habit,day:string){return h.logs?.find(l=>l.date===day)}
|
||||
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{await request(`/habits/${h.id}/logs`,{method:'POST',body:JSON.stringify({date:day,value:h.type==='numeric'?(value??h.target??1):!Boolean(logFor(h,day)?.value)})});await loadHabits();emit('notice','打卡已记录')})}
|
||||
async function deleteHabit(h:Habit){if(!confirm(`删除习惯“${h.name}”?`))return;await safe(async()=>{await request(`/habits/${h.id}`,{method:'DELETE'});await loadHabits()})}
|
||||
async function moveTask(arg:EventDropArg){const task=props.tasks.find(t=>t.id===arg.event.id);if(!task)return;const previous=task.due_at;try{await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:moveDueDate(previous,arg.event.startStr.slice(0,10)),version:task.version})});emit('changed');const undo=confirm('日期已更新。要撤销吗?');if(undo){const fresh=props.tasks.find(t=>t.id===task.id) || task;await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:previous,version:fresh.version})});emit('changed')}}catch(e){arg.revert();error.value=e instanceof Error?e.message:'移动失败'}}
|
||||
const calendarOptions=computed<CalendarOptions>(()=>({plugins:[dayGridPlugin,interactionPlugin],initialView:'dayGridMonth',locale:'zh-cn',firstDay:1,height:'auto',editable:true,dayMaxEvents:4,headerToolbar:{left:'prev,next today',center:'title',right:''},events:props.tasks.filter(t=>t.due_at).map(t=>({id:t.id,title:t.title,start:t.due_at!})),eventDrop:moveTask}))
|
||||
async function loadSettings(){await safe(async()=>{const [s,a]=await Promise.all([request('/sessions').catch(()=>[]),request('/audit-logs?limit=20').catch(()=>[])]);sessions.value=mergePage<Session>(s).items;audit.value=mergePage<any>(a).items})}
|
||||
async function revoke(id:string){await safe(async()=>{await request(`/sessions/${id}`,{method:'DELETE'});await loadSettings();emit('notice','会话已撤销')})}
|
||||
function downloadBlob(blob:Blob,name:string){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
async function exportData(){await safe(async()=>downloadBlob(await request('/export'),'dodo-export.json'))}
|
||||
async function previewImport(){if(!importFile.value)return;await safe(async()=>{const form=new FormData();form.append('file',importFile.value!);importPreview.value=await request('/import/ticktick/preview',{method:'POST',body:form})})}
|
||||
async function confirmImport(){await safe(async()=>{const form=new FormData();form.append('file',importFile.value!);const result=await request('/import/ticktick',{method:'POST',body:form});importPreview.value=null;emit('changed');emit('notice',`导入完成:新增 ${result?.imported??0},跳过 ${result?.skipped??0}`)})}
|
||||
async function restore(){if(!restoreFile.value)return;if(!confirm('恢复为合并模式,将导入 JSON 中的清单与任务。继续吗?'))return;await safe(async()=>{const text=await restoreFile.value!.text();await request('/restore?mode=merge',{method:'POST',body:text});emit('changed');emit('notice','数据已恢复')})}
|
||||
onMounted(()=>props.view==='habits'?loadHabits():props.view==='settings'?loadSettings():undefined)
|
||||
</script>
|
||||
<template>
|
||||
<section class="mvp-view" :class="{loading:busy}">
|
||||
<p v-if="error" class="inline-error">{{error}}</p>
|
||||
<template v-if="view==='calendar'">
|
||||
<header class="view-intro"><div><small>拖动任务即可改期</small><h2>月历</h2></div><span>{{tasks.filter(t=>t.due_at).length}} 个已排期任务</span></header>
|
||||
<div class="calendar-card"><FullCalendar :options="calendarOptions" /></div>
|
||||
</template>
|
||||
<template v-else-if="view==='habits'">
|
||||
<header class="view-intro"><div><small>今天做一点,明天更轻松</small><h2>习惯</h2></div><button class="soft-button" @click="loadHabits"><RefreshCw/>刷新</button></header>
|
||||
<form class="habit-create" @submit.prevent="addHabit"><input v-model="habitName" placeholder="新习惯名称"><select v-model="habitType"><option value="boolean">完成 / 未完成</option><option value="numeric">数值</option></select><input v-if="habitType==='numeric'" v-model.number="habitTarget" type="number" min="0" step="any" aria-label="目标值"><button><Plus/>添加</button></form>
|
||||
<div class="habit-list"><article v-for="h in habits" :key="h.id" class="habit-card"><div class="habit-title"><div><h3>{{h.name}}</h3><small v-if="h.stats">连续 {{h.stats.current_streak??0}} 天 · 完成率 {{Math.round((h.stats.completion_rate??0)*100)}}%</small></div><button class="icon ghost" aria-label="删除习惯" @click="deleteHabit(h)"><Trash2/></button></div><div class="week-grid"><div v-for="d in week" :key="dateKey(d)"><small>{{['一','二','三','四','五','六','日'][(d.getDay()+6)%7]}}<br>{{d.getDate()}}</small><button v-if="h.type!=='numeric'" class="habit-check" :class="{done:logFor(h,dateKey(d))?.value}" @click="checkIn(h,dateKey(d))">{{logFor(h,dateKey(d))?.value?'✓':'·'}}</button><input v-else type="number" :value="logFor(h,dateKey(d))?.value??''" :placeholder="String(h.target??1)" @change="checkIn(h,dateKey(d),Number(($event.target as HTMLInputElement).value))"></div></div></article><div v-if="!habits.length&&!busy" class="empty-panel">还没有习惯,从一件容易坚持的小事开始。</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<header class="view-intro"><div><small>备份、迁移与安全</small><h2>设置与数据</h2></div></header>
|
||||
<div class="settings-grid">
|
||||
<article class="tool-card"><FileJson/><h3>数据导出与恢复</h3><p>下载完整 JSON 备份,或从备份恢复。</p><button class="soft-button" @click="exportData"><Download/>导出 JSON</button><label class="file-button"><ArchiveRestore/>选择备份<input type="file" accept="application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
|
||||
<article class="tool-card"><Upload/><h3>导入</h3><p>先预览变化,确认后才写入。</p><label class="file-button">选择文件<input type="file" accept=".json,.csv" @change="importFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button :disabled="!importFile" class="soft-button" @click="previewImport">生成预览</button><pre v-if="importPreview">{{JSON.stringify(importPreview,null,2)}}</pre><button v-if="importPreview" class="primary-small" @click="confirmImport">确认导入</button></article>
|
||||
<article class="tool-card wide"><LogOut/><h3>登录会话</h3><div v-for="s in sessions" :key="s.id" class="session-row"><span><b>{{s.current?'当前设备':'其他设备'}}</b><small>{{s.user_agent||'未知设备'}} · {{s.last_seen_at||s.created_at}}</small></span><button v-if="!s.current" class="danger-text" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话。</p></article>
|
||||
<article v-if="audit.length" class="tool-card wide"><Activity/><h3>最近活动</h3><div v-for="(row,i) in audit" :key="row.id||i" class="audit-row"><span>{{row.action||row.event||'变更'}}</span><small>{{row.created_at||row.timestamp}}</small></div></article>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dateKey, moveDueDate } from './mvp-utils'
|
||||
|
||||
describe('MVP view utilities', () => {
|
||||
it('normalizes to UTC so stored due_at stays stable in every local timezone', () => {
|
||||
const moved = moveDueDate('2026-09-05T14:30:00+08:00', '2026-09-09')
|
||||
expect(moved).toBe('2026-09-09T14:30:00.000Z')
|
||||
const newly = moveDueDate(null, '2026-09-09')
|
||||
expect(newly).toBe('2026-09-09T09:00:00.000Z')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
export function dateKey(date: Date) {
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const d = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
export function calendarRange(date: Date) {
|
||||
const first = new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
const from = new Date(first)
|
||||
const mondayOffset = (first.getDay() + 6) % 7
|
||||
from.setDate(first.getDate() - mondayOffset)
|
||||
const to = new Date(from)
|
||||
to.setDate(from.getDate() + 41)
|
||||
return { from: dateKey(from), to: dateKey(to) }
|
||||
}
|
||||
|
||||
export function moveDueDate(current: string | null, day: string) {
|
||||
const source = current ? new Date(current) : null
|
||||
const hours = source && !Number.isNaN(source.valueOf()) ? source.getHours() : 9
|
||||
const minutes = source && !Number.isNaN(source.valueOf()) ? source.getMinutes() : 0
|
||||
const date = new Date(`${day}T00:00:00`)
|
||||
date.setHours(hours, minutes, 0, 0)
|
||||
const offsetMs = date.getTimezoneOffset() * 60_000
|
||||
return new Date(date.getTime() - offsetMs).toISOString()
|
||||
}
|
||||
|
||||
export function habitWeek(now = new Date()) {
|
||||
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7))
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date(monday)
|
||||
date.setDate(monday.getDate() + index)
|
||||
return date
|
||||
})
|
||||
}
|
||||
|
||||
export function mergePage<T>(page: T[] | { items?: T[]; next_cursor?: string | null }) {
|
||||
if (Array.isArray(page)) return { items: page, nextCursor: null }
|
||||
return { items: page.items ?? [], nextCursor: page.next_cursor ?? null }
|
||||
}
|
||||
@@ -9,4 +9,6 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
|
||||
.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
|
||||
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.select-box{opacity:.45}.row-actions{opacity:1}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.batch-bar{overflow:auto}.batch-bar b{white-space:nowrap}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
|
||||
.mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.calendar-card,.habit-card,.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.fc{--fc-button-bg-color:var(--accent);--fc-button-border-color:var(--accent);--fc-button-hover-bg-color:#cf461d;--fc-today-bg-color:#fff3eb;font-size:13px}.fc .fc-toolbar-title{font-size:18px}.fc .fc-event{border-color:var(--accent);background:var(--accent);cursor:grab}.soft-button,.primary-small,.danger-button,.file-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-create{display:grid;grid-template-columns:1fr 150px 90px auto;gap:8px}.habit-create input,.habit-create select{min-width:0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:9px}.habit-create button{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 13px;display:flex;align-items:center;gap:5px}.habit-list{display:grid;gap:10px}.habit-title{display:flex;justify-content:space-between;align-items:start}.habit-title h3,.tool-card h3{margin:0 0 4px}.habit-title small{color:var(--muted)}.week-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:7px;margin-top:13px}.week-grid>div{display:grid;place-items:center;gap:5px;text-align:center}.week-grid small{color:var(--muted);font-size:10px}.habit-check{width:34px;height:34px;border-radius:50%;border:1px solid var(--line);background:#faf7f0}.habit-check.done{background:var(--accent);border-color:var(--accent);color:#fff}.week-grid input{width:100%;min-width:0;border:1px solid var(--line);border-radius:7px;padding:7px;text-align:center}.empty-panel{text-align:center;color:var(--muted)}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0}.session-row span{display:grid}.session-row small,.audit-row small{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
|
||||
@media(max-width:800px){.habit-create{grid-template-columns:1fr 1fr}.habit-create button{justify-content:center}.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.fc .fc-toolbar{align-items:flex-start}.fc .fc-toolbar-title{font-size:16px}.calendar-card{padding:8px}.week-grid{gap:3px}.habit-card{padding:12px}.habit-check{width:30px;height:30px}}
|
||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""recurring habits attachments audit export-import
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("tasks", sa.Column("external_id", sa.String(255), nullable=True))
|
||||
op.create_unique_constraint("uq_tasks_external_id", "tasks", ["user_id", "external_id"])
|
||||
|
||||
op.add_column("sessions", sa.Column("ip_address", sa.String(64), nullable=True))
|
||||
op.add_column("sessions", sa.Column("user_agent", sa.String(255), nullable=True))
|
||||
|
||||
op.create_table(
|
||||
"recurrence_templates",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("task_id", sa.Uuid(), sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||
sa.Column("rrule", sa.Text(), nullable=False),
|
||||
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_recurrence_templates_user_id", "recurrence_templates", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"recurrence_exceptions",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("template_id", sa.Uuid(), sa.ForeignKey("recurrence_templates.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("occurrence_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("title", sa.String(500), nullable=True),
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed", sa.Boolean(), nullable=False),
|
||||
sa.Column("deleted", sa.Boolean(), nullable=False),
|
||||
sa.UniqueConstraint("template_id", "occurrence_at", name="uq_recurrence_exception"),
|
||||
)
|
||||
op.create_index("ix_recurrence_exceptions_template_id", "recurrence_exceptions", ["template_id"])
|
||||
|
||||
op.create_table(
|
||||
"habits",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("kind", sa.String(16), nullable=False),
|
||||
sa.Column("target", sa.Float(), nullable=False),
|
||||
sa.Column("max_value", sa.Float(), nullable=True),
|
||||
sa.Column("schedule_type", sa.String(16), nullable=False),
|
||||
sa.Column("weekdays", sa.String(32), nullable=True),
|
||||
sa.Column("month_days", sa.String(100), nullable=True),
|
||||
sa.Column("interval_days", sa.Integer(), nullable=True),
|
||||
sa.Column("start_date", sa.Date(), nullable=False),
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_habits_user_id", "habits", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"habit_logs",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("habit_id", sa.Uuid(), sa.ForeignKey("habits.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("day", sa.Date(), nullable=False),
|
||||
sa.Column("value", sa.Float(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("habit_id", "day", name="uq_habit_log_day"),
|
||||
)
|
||||
op.create_index("ix_habit_logs_habit_id", "habit_logs", ["habit_id"])
|
||||
|
||||
op.create_table(
|
||||
"habit_pauses",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("habit_id", sa.Uuid(), sa.ForeignKey("habits.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("start_date", sa.Date(), nullable=False),
|
||||
sa.Column("end_date", sa.Date(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_habit_pauses_habit_id", "habit_pauses", ["habit_id"])
|
||||
|
||||
op.create_table(
|
||||
"attachments",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("task_id", sa.Uuid(), sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("filename", sa.String(255), nullable=False),
|
||||
sa.Column("storage_name", sa.String(255), nullable=False, unique=True),
|
||||
sa.Column("mime_type", sa.String(127), nullable=False),
|
||||
sa.Column("size", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_attachments_user_id", "attachments", ["user_id"])
|
||||
op.create_index("ix_attachments_task_id", "attachments", ["task_id"])
|
||||
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("action", sa.String(32), nullable=False),
|
||||
sa.Column("entity_type", sa.String(32), nullable=False),
|
||||
sa.Column("entity_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("details", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
|
||||
op.drop_table("audit_logs")
|
||||
op.drop_index("ix_attachments_task_id", table_name="attachments")
|
||||
op.drop_index("ix_attachments_user_id", table_name="attachments")
|
||||
op.drop_table("attachments")
|
||||
op.drop_index("ix_habit_pauses_habit_id", table_name="habit_pauses")
|
||||
op.drop_table("habit_pauses")
|
||||
op.drop_index("ix_habit_logs_habit_id", table_name="habit_logs")
|
||||
op.drop_table("habit_logs")
|
||||
op.drop_index("ix_habits_user_id", table_name="habits")
|
||||
op.drop_table("habits")
|
||||
op.drop_index("ix_recurrence_exceptions_template_id", table_name="recurrence_exceptions")
|
||||
op.drop_table("recurrence_exceptions")
|
||||
op.drop_index("ix_recurrence_templates_user_id", table_name="recurrence_templates")
|
||||
op.drop_table("recurrence_templates")
|
||||
op.drop_column("sessions", "user_agent")
|
||||
op.drop_column("sessions", "ip_address")
|
||||
op.drop_constraint("uq_tasks_external_id", "tasks", type_="unique")
|
||||
op.drop_column("tasks", "external_id")
|
||||
@@ -0,0 +1,171 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
|
||||
def boot(client):
|
||||
response = client.post(
|
||||
"/api/v1/setup/initialize",
|
||||
json={"username": "owner", "password": "correct horse battery staple"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return client.get("/api/v1/lists").json()[0]
|
||||
|
||||
|
||||
def test_recurring_calendar_exceptions_and_scopes(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "站会", "list_id": inbox["id"], "due_at": "2026-09-01T09:00:00Z"},
|
||||
).json()
|
||||
recurrence = client.post(
|
||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=TU,TH;COUNT=5"}
|
||||
)
|
||||
assert recurrence.status_code == 201
|
||||
recurrence_id = recurrence.json()["id"]
|
||||
calendar = client.get(
|
||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
||||
).json()
|
||||
occurrences = [row for row in calendar if row["recurrence_id"] == recurrence_id]
|
||||
assert len(occurrences) == 5
|
||||
assert occurrences[0]["title"] == "站会"
|
||||
|
||||
at = occurrences[1]["occurrence_at"]
|
||||
edited = client.patch(
|
||||
f"/api/v1/recurrences/{recurrence_id}",
|
||||
params={"scope": "this", "occurrence_at": at},
|
||||
json={"title": "特殊站会"},
|
||||
)
|
||||
assert edited.status_code == 200
|
||||
client.post(f"/api/v1/recurrences/{recurrence_id}/complete", json={"occurrence_at": at})
|
||||
changed = client.get(
|
||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
||||
).json()
|
||||
exception = next(row for row in changed if row.get("occurrence_at") == at)
|
||||
assert exception["title"] == "特殊站会" and exception["completed"] is True
|
||||
assert client.delete(
|
||||
f"/api/v1/recurrences/{recurrence_id}",
|
||||
params={"scope": "this", "occurrence_at": occurrences[2]["occurrence_at"]},
|
||||
).status_code == 204
|
||||
assert len(client.get(
|
||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
||||
).json()) == 4
|
||||
|
||||
|
||||
def test_habits_numeric_accumulation_pause_archive_grid_and_stats(client):
|
||||
boot(client)
|
||||
habit = client.post(
|
||||
"/api/v1/habits",
|
||||
json={"name": "喝水", "kind": "numeric", "target": 8, "schedule_type": "daily", "max_value": 10},
|
||||
)
|
||||
assert habit.status_code == 201
|
||||
habit_id = habit.json()["id"]
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
for value in (6, 7):
|
||||
assert client.post(f"/api/v1/habits/{habit_id}/logs", json={"day": today, "value": value}).status_code == 200
|
||||
assert client.get(f"/api/v1/habits/{habit_id}/logs").json()[0]["value"] == 10
|
||||
assert client.put(f"/api/v1/habits/{habit_id}/logs/{today}", json={"value": 8}).json()["value"] == 8
|
||||
yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
|
||||
assert client.post(f"/api/v1/habits/{habit_id}/pauses", json={"start_date": yesterday, "end_date": today}).status_code == 201
|
||||
grid = client.get("/api/v1/habits/grid", params={"week": yesterday}).json()
|
||||
assert len(grid["days"]) == 7 and grid["habits"][0]["cells"]
|
||||
stats = client.get(f"/api/v1/habits/{habit_id}/stats").json()
|
||||
assert stats["total"] == 8 and stats["completed_days"] == 1
|
||||
assert client.delete(f"/api/v1/habits/{habit_id}").status_code == 204
|
||||
archived = client.get("/api/v1/habits", params={"archived": True}).json()
|
||||
assert archived[0]["id"] == habit_id
|
||||
assert client.get(f"/api/v1/habits/{habit_id}/stats").json()["total"] == 8
|
||||
|
||||
|
||||
def test_boolean_interval_habit_schedule(client):
|
||||
boot(client)
|
||||
habit = client.post(
|
||||
"/api/v1/habits",
|
||||
json={"name": "拉伸", "kind": "boolean", "schedule_type": "interval", "interval_days": 2},
|
||||
)
|
||||
assert habit.status_code == 201
|
||||
assert client.post(
|
||||
f"/api/v1/habits/{habit.json()['id']}/logs", json={"day": datetime.now(UTC).date().isoformat(), "value": 1}
|
||||
).json()["value"] == 1
|
||||
|
||||
|
||||
def test_attachment_security_ownership_and_size(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DODO_ATTACHMENT_DIR", str(tmp_path / "uploads"))
|
||||
inbox = boot(client)
|
||||
task = client.post("/api/v1/tasks", json={"title": "文件", "list_id": inbox["id"]}).json()
|
||||
uploaded = client.post(
|
||||
f"/api/v1/tasks/{task['id']}/attachments",
|
||||
files={"file": ("notes.txt", b"safe text", "text/plain")},
|
||||
)
|
||||
assert uploaded.status_code == 201
|
||||
attachment = uploaded.json()
|
||||
assert attachment["filename"] == "notes.txt"
|
||||
assert client.get(f"/api/v1/attachments/{attachment['id']}").content == b"safe text"
|
||||
bad = client.post(
|
||||
f"/api/v1/tasks/{task['id']}/attachments",
|
||||
files={"file": ("../evil.exe", b"x", "application/x-msdownload")},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
assert client.delete(f"/api/v1/attachments/{attachment['id']}").status_code == 204
|
||||
|
||||
|
||||
def test_ticktick_preview_import_dedupe_and_json_restore(client):
|
||||
boot(client)
|
||||
csv_data = "Title,List Name,Due Date,Status,ID\nImported,Inbox,2026-10-01,0,ext-1\n"
|
||||
preview = client.post("/api/v1/import/ticktick/preview", files={"file": ("tasks.csv", csv_data, "text/csv")})
|
||||
assert preview.status_code == 200 and preview.json()["valid"] == 1
|
||||
for _ in range(2):
|
||||
response = client.post("/api/v1/import/ticktick", files={"file": ("tasks.csv", csv_data, "text/csv")})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["skipped"] == 1
|
||||
assert len(client.get("/api/v1/tasks").json()["items"]) == 1
|
||||
|
||||
export = client.get("/api/v1/export").json()
|
||||
assert export["version"] == 1 and export["tasks"][0]["external_id"] == "ext-1"
|
||||
client.delete(f"/api/v1/tasks/{export['tasks'][0]['id']}")
|
||||
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=export)
|
||||
assert restored.status_code == 200
|
||||
assert len(client.get("/api/v1/tasks").json()["items"]) == 1
|
||||
assert client.post("/api/v1/restore", json={"version": 999}).status_code == 422
|
||||
|
||||
|
||||
def test_audit_logs_cover_task_and_collection_operations(client):
|
||||
boot(client)
|
||||
folder = client.post("/api/v1/folders", json={"name": "F"}).json()
|
||||
task_list = client.post("/api/v1/lists", json={"name": "L", "folder_id": folder["id"]}).json()
|
||||
task = client.post("/api/v1/tasks", json={"title": "T", "list_id": task_list["id"]}).json()
|
||||
client.patch(f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]})
|
||||
client.delete(f"/api/v1/tasks/{task['id']}")
|
||||
client.post(f"/api/v1/tasks/{task['id']}/restore")
|
||||
client.patch(f"/api/v1/lists/{task_list['id']}", json={"name": "L2"})
|
||||
client.delete(f"/api/v1/folders/{folder['id']}")
|
||||
logs = client.get("/api/v1/audit-logs").json()
|
||||
pairs = {(row["entity_type"], row["action"]) for row in logs}
|
||||
assert {("task", "create"), ("task", "complete"), ("task", "delete"), ("task", "restore"), ("list", "update"), ("folder", "delete")} <= pairs
|
||||
|
||||
|
||||
def test_sessions_csrf_headers_revocation_and_docs(client):
|
||||
boot(client)
|
||||
assert client.get("/api/docs").status_code == 200
|
||||
sessions = client.get("/api/v1/sessions").json()
|
||||
assert len(sessions) == 1 and sessions[0]["current"] is True
|
||||
assert client.delete(f"/api/v1/sessions/{sessions[0]['id']}").status_code == 204
|
||||
assert client.get("/api/v1/me").status_code == 401
|
||||
|
||||
anonymous = client.__class__(client.app)
|
||||
with anonymous:
|
||||
assert anonymous.get("/api/docs").status_code == 401
|
||||
|
||||
|
||||
def test_login_rate_limit_is_progressive(client):
|
||||
boot(client)
|
||||
client.post("/api/v1/auth/logout")
|
||||
statuses = [
|
||||
client.post("/api/v1/auth/login", json={"username": "owner", "password": "wrong password"}).status_code
|
||||
for _ in range(8)
|
||||
]
|
||||
assert 429 in statuses
|
||||
|
||||
|
||||
def test_security_headers(client):
|
||||
response = client.get("/health/live")
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert response.headers["content-security-policy"]
|
||||
Reference in New Issue
Block a user