feat: recurring tasks, habits, attachments, import/export, audit and security

This commit is contained in:
2026-09-05 13:35:17 +08:00
parent 57145c198b
commit 067d27a477
22 changed files with 1231 additions and 33 deletions
+23 -3
View File
@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +14,7 @@ from .models import Session, User
password_hasher = PasswordHasher() password_hasher = PasswordHasher()
COOKIE_NAME = "dodo_session" COOKIE_NAME = "dodo_session"
CSRF_COOKIE_NAME = "dodo_csrf"
def hash_token(token: str) -> str: def hash_token(token: str) -> str:
@@ -31,15 +32,24 @@ def verify_password(password_hash: str, password: str) -> bool:
return False 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) token = secrets.token_urlsafe(32)
csrf = secrets.token_urlsafe(24)
expires = datetime.now(UTC) + timedelta(days=get_settings().session_days) 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() await db.commit()
response.set_cookie( response.set_cookie(
COOKIE_NAME, token, max_age=get_settings().session_days * 86400, COOKIE_NAME, token, max_age=get_settings().session_days * 86400,
httponly=True, secure=get_settings().cookie_secure, samesite="lax", path="/", 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( async def session_token(
@@ -63,3 +73,13 @@ async def current_user(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已失效,请重新登录") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已失效,请重新登录")
return user 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
+4
View File
@@ -10,6 +10,10 @@ class Settings(BaseSettings):
cookie_secure: bool = False cookie_secure: bool = False
trusted_proxies: str = "" trusted_proxies: str = ""
auto_create_schema: bool = False 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") model_config = SettingsConfigDict(env_prefix="DODO_", env_file=".env", extra="ignore")
+103 -7
View File
@@ -1,12 +1,15 @@
import base64 import base64
import json import json
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException, Query, Response from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import delete, exists, func, or_, select, update from sqlalchemy import delete, exists, func, or_, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -14,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from .auth import ( from .auth import (
COOKIE_NAME, COOKIE_NAME,
CSRF_COOKIE_NAME,
current_user, current_user,
hash_password, hash_password,
hash_token, hash_token,
@@ -23,6 +27,8 @@ from .auth import (
) )
from .db import create_schema, get_db from .db import create_schema, get_db
from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow 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 ( from .schemas import (
BatchResult, BatchResult,
BatchTaskUpdate, BatchTaskUpdate,
@@ -55,13 +61,44 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="dodo", title="dodo",
version="0.1.0", version="0.2.0",
lifespan=lifespan, lifespan=lifespan,
docs_url="/api/docs", docs_url=None,
openapi_url="/api/openapi.json", 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") @app.get("/health/live")
async def live(): async def live():
return {"status": "ok"} return {"status": "ok"}
@@ -83,6 +120,7 @@ async def setup_status(db: AsyncSession = Depends(get_db)):
async def initialize( async def initialize(
payload: InitializeRequest, payload: InitializeRequest,
response: Response, response: Response,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
count = await db.scalar(select(func.count()).select_from(User)) 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)) db.add(TaskList(user_id=user.id, name="收集箱", is_inbox=True))
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
await issue_session(db, response, user) await issue_session(db, response, user, request)
return user return user
@@ -107,12 +145,26 @@ async def initialize(
async def login( async def login(
payload: LoginRequest, payload: LoginRequest,
response: Response, response: Response,
request: Request,
db: AsyncSession = Depends(get_db), 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)) user = await db.scalar(select(User).where(User.username == payload.username))
if user is None or not verify_password(user.password_hash, payload.password): if user is None or not verify_password(user.password_hash, payload.password):
attempts.append(now)
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
await issue_session(db, response, user) attempts.clear()
await issue_session(db, response, user, request)
return user return user
@@ -132,6 +184,35 @@ async def logout(
await db.delete(session) await db.delete(session)
await db.commit() await db.commit()
response.delete_cookie(COOKIE_NAME, path="/") 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) return Response(status_code=204, headers=response.headers)
@@ -169,6 +250,8 @@ async def rename_folder(
if folder is None: if folder is None:
raise HTTPException(status_code=404, detail="文件夹不存在") raise HTTPException(status_code=404, detail="文件夹不存在")
folder.name = payload.name folder.name = payload.name
await db.flush()
audit(db, user.id, "update", "folder", folder.id)
await db.commit() await db.commit()
return folder return folder
@@ -187,6 +270,7 @@ async def delete_folder(
if folder is None: if folder is None:
raise HTTPException(status_code=404, detail="文件夹不存在") raise HTTPException(status_code=404, detail="文件夹不存在")
folder.deleted_at = utcnow() folder.deleted_at = utcnow()
audit(db, user.id, "delete", "folder", folder.id)
await db.execute( await db.execute(
update(TaskList) update(TaskList)
.where(TaskList.user_id == user.id, TaskList.folder_id == folder.id) .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="文件夹不存在") raise HTTPException(status_code=404, detail="文件夹不存在")
item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name) item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name)
db.add(item) db.add(item)
await db.flush()
audit(db, user.id, "create", "list", item.id)
await db.commit() await db.commit()
await db.refresh(item) await db.refresh(item)
return item return item
@@ -248,7 +334,10 @@ async def rename_list(
if item.is_inbox: if item.is_inbox:
raise HTTPException(status_code=409, detail="系统收集箱不能重命名") raise HTTPException(status_code=409, detail="系统收集箱不能重命名")
item.name = payload.name item.name = payload.name
await db.flush()
audit(db, user.id, "update", "list", item.id)
await db.commit() await db.commit()
await db.refresh(item)
return item return item
@@ -364,6 +453,7 @@ async def create_task(
task = Task(user_id=user.id, **data) task = Task(user_id=user.id, **data)
db.add(task) db.add(task)
await db.flush() await db.flush()
audit(db, user.id, "create", "task", task.id)
if tag_ids: if tag_ids:
db.add_all(TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tag_ids) db.add_all(TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tag_ids)
await db.commit() 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)) .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()) .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() await db.commit()
return await _task_detail(db, task) 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) .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) .values(deleted_at=deleted_at, version=Task.version + 1, updated_at=deleted_at)
) )
audit(db, user.id, "delete", "task", task.id)
await db.commit() await db.commit()
return Response(status_code=204) 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) .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) .values(deleted_at=None, version=Task.version + 1, updated_at=now)
) )
audit(db, user.id, "restore", "task", task.id)
await db.commit() await db.commit()
await db.refresh(task) await db.refresh(task)
return await _task_detail(db, task) return await _task_detail(db, task)
+99 -2
View File
@@ -1,7 +1,18 @@
from datetime import UTC, datetime from datetime import UTC, date, datetime
from uuid import UUID 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 sqlalchemy.orm import Mapped, mapped_column
from uuid_utils import uuid7 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) user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 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): class Folder(Base):
@@ -80,6 +94,7 @@ class TaskTag(Base):
class Task(Base): class Task(Base):
__tablename__ = "tasks" __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) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) 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) 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) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=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) 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
View File
@@ -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
+2 -2
View File
@@ -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> <!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-hzru7WEs.css"> <link rel="stylesheet" crossorigin href="/assets/index-CpBTIN38.css">
</head><body><div id="app"></div></body></html> </head><body><div id="app"></div></body></html>
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"}}
+53
View File
@@ -8,6 +8,18 @@ importers:
.: .:
dependencies: 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': '@vitejs/plugin-vue':
specifier: latest specifier: latest
version: 6.0.8([email protected](@types/[email protected])([email protected]))([email protected]([email protected])) version: 6.0.8([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
@@ -86,6 +98,25 @@ packages:
'@floating-ui/[email protected]': '@floating-ui/[email protected]':
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} 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]': '@internationalized/[email protected]':
resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==}
@@ -744,6 +775,9 @@ packages:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -1023,6 +1057,23 @@ snapshots:
- '@vue/composition-api' - '@vue/composition-api'
- vue - 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]': '@internationalized/[email protected]':
dependencies: dependencies:
'@swc/helpers': 0.5.23 '@swc/helpers': 0.5.23
@@ -1566,6 +1617,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -2,16 +2,17 @@
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { import {
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder, ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
GripVertical, Inbox, ListChecks, ListTodo, Menu, MoreHorizontal, Pencil, Plus, Search, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Tag as TagIcon, Trash2, X, Settings, Trash2, X, CalendarRange, Repeat2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils' import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import MvpPanel from './MvpPanel.vue'
type FolderItem = { id: string; name: string } type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type Tag = { id: string; name: string; color: string } 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 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' type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'calendar' | 'habits' | 'settings'
const initialized = ref<boolean | null>(null) const initialized = ref<boolean | null>(null)
const authenticated = ref(false) const authenticated = ref(false)
@@ -41,6 +42,9 @@ const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站' if (activeView.value === 'trash') return '回收站'
if (activeView.value === 'today') return '今天' if (activeView.value === 'today') return '今天'
if (activeView.value === 'upcoming') return '最近 7 天' 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 || '收集箱' return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
}) })
const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value) const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value)
@@ -50,6 +54,7 @@ const visibleTasks = computed(() => {
let result = sourceTasks.value let result = sourceTasks.value
if (query.value.trim()) return result if (query.value.trim()) return result
if (activeView.value === 'tasks') result = result.filter((task) => task.list_id === activeList.value) 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 === '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 (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) 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) { async function switchView(view: View, listId?: string) {
activeView.value = view activeView.value = view
if (listId) activeList.value = listId 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() if (view === 'trash') await loadTrash()
} }
async function addTask() { async function addTask() {
@@ -159,7 +164,7 @@ async function saveTask() {
if (!selectedTask.value?.title.trim()) return if (!selectedTask.value?.title.trim()) return
try { try {
const task = selectedTask.value 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('已保存') toast('已保存')
} catch (reason) { fail(reason) } } 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==='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==='today' }" @click="switchView('today')"><ListTodo />今天</button>
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </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> <button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
</nav> </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> <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> </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> <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> </div>
<button class="settings"><Settings />设置</button> <button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
</aside> </aside>
<main> <main>
@@ -271,6 +278,10 @@ onMounted(bootstrap)
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div> <div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd> K</kbd></label> <label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd> K</kbd></label>
</header> </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> <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 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> <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> </template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div> <div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section> </section>
</template>
</main> </main>
<aside class="detail" :class="{open:mobileDetail}"> <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>清单<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>截止时间<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.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"><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="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> <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> <div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级标签子任务和 Markdown 备注会出现在这里</p></div>
</aside> </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> <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="activeView!=='trash'" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button> <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> <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 v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
</div> </div>
+68
View File
@@ -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>
+11
View File
@@ -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')
})
})
+41
View File
@@ -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 }
}
+2
View File
@@ -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)} .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: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}} @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}} @media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
+131
View File
@@ -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")
+171
View File
@@ -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"]