feat: complete task management workflow
This commit is contained in:
+476
-31
@@ -1,11 +1,14 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
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, Response
|
from fastapi import Depends, FastAPI, HTTPException, Query, Response
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import delete, exists, func, or_, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -19,15 +22,21 @@ from .auth import (
|
|||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from .db import create_schema, get_db
|
from .db import create_schema, get_db
|
||||||
from .models import AppState, Folder, Session, Task, TaskList, User, utcnow
|
from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
|
BatchResult,
|
||||||
|
BatchTaskUpdate,
|
||||||
FolderCreate,
|
FolderCreate,
|
||||||
FolderOut,
|
FolderOut,
|
||||||
InitializeRequest,
|
InitializeRequest,
|
||||||
ListCreate,
|
ListCreate,
|
||||||
ListOut,
|
ListOut,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
NameUpdate,
|
||||||
|
TagCreate,
|
||||||
|
TagOut,
|
||||||
TaskCreate,
|
TaskCreate,
|
||||||
|
TaskDetailOut,
|
||||||
TaskOut,
|
TaskOut,
|
||||||
TaskPage,
|
TaskPage,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
@@ -44,7 +53,13 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="dodo", version="0.1.0", lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json")
|
app = FastAPI(
|
||||||
|
title="dodo",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/api/docs",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health/live")
|
@app.get("/health/live")
|
||||||
@@ -65,7 +80,11 @@ async def setup_status(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/setup/initialize", response_model=UserOut, status_code=201)
|
@app.post("/api/v1/setup/initialize", response_model=UserOut, status_code=201)
|
||||||
async def initialize(payload: InitializeRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
async def initialize(
|
||||||
|
payload: InitializeRequest,
|
||||||
|
response: Response,
|
||||||
|
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))
|
||||||
if count:
|
if count:
|
||||||
raise HTTPException(status_code=409, detail="系统已经初始化")
|
raise HTTPException(status_code=409, detail="系统已经初始化")
|
||||||
@@ -85,7 +104,11 @@ async def initialize(payload: InitializeRequest, response: Response, db: AsyncSe
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/auth/login", response_model=UserOut)
|
@app.post("/api/v1/auth/login", response_model=UserOut)
|
||||||
async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
async def login(
|
||||||
|
payload: LoginRequest,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
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):
|
||||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
@@ -113,35 +136,218 @@ async def logout(
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/folders", response_model=FolderOut, status_code=201)
|
@app.post("/api/v1/folders", response_model=FolderOut, status_code=201)
|
||||||
async def create_folder(payload: FolderCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def create_folder(
|
||||||
|
payload: FolderCreate,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
folder = Folder(user_id=user.id, name=payload.name)
|
folder = Folder(user_id=user.id, name=payload.name)
|
||||||
db.add(folder); await db.commit(); await db.refresh(folder)
|
db.add(folder)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(folder)
|
||||||
return folder
|
return folder
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/folders", response_model=list[FolderOut])
|
@app.get("/api/v1/folders", response_model=list[FolderOut])
|
||||||
async def list_folders(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def list_folders(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
return list((await db.scalars(select(Folder).where(Folder.user_id == user.id).order_by(Folder.position, Folder.created_at))).all())
|
query = select(Folder).where(Folder.user_id == user.id, Folder.deleted_at.is_(None))
|
||||||
|
return list((await db.scalars(query.order_by(Folder.position, Folder.created_at))).all())
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/folders/{folder_id}", response_model=FolderOut)
|
||||||
|
async def rename_folder(
|
||||||
|
folder_id: UUID,
|
||||||
|
payload: NameUpdate,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
folder = await db.scalar(
|
||||||
|
select(Folder).where(
|
||||||
|
Folder.id == folder_id, Folder.user_id == user.id, Folder.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if folder is None:
|
||||||
|
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||||
|
folder.name = payload.name
|
||||||
|
await db.commit()
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/folders/{folder_id}", status_code=204)
|
||||||
|
async def delete_folder(
|
||||||
|
folder_id: UUID,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
folder = await db.scalar(
|
||||||
|
select(Folder).where(
|
||||||
|
Folder.id == folder_id, Folder.user_id == user.id, Folder.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if folder is None:
|
||||||
|
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||||
|
folder.deleted_at = utcnow()
|
||||||
|
await db.execute(
|
||||||
|
update(TaskList)
|
||||||
|
.where(TaskList.user_id == user.id, TaskList.folder_id == folder.id)
|
||||||
|
.values(folder_id=None)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/lists", response_model=ListOut, status_code=201)
|
@app.post("/api/v1/lists", response_model=ListOut, status_code=201)
|
||||||
async def create_list(payload: ListCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def create_list(
|
||||||
if payload.folder_id and not await db.scalar(select(Folder.id).where(Folder.id == payload.folder_id, Folder.user_id == user.id)):
|
payload: ListCreate,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if payload.folder_id and not await db.scalar(
|
||||||
|
select(Folder.id).where(
|
||||||
|
Folder.id == payload.folder_id,
|
||||||
|
Folder.user_id == user.id,
|
||||||
|
Folder.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
):
|
||||||
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); await db.commit(); await db.refresh(item)
|
db.add(item)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(item)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/lists", response_model=list[ListOut])
|
@app.get("/api/v1/lists", response_model=list[ListOut])
|
||||||
async def list_lists(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def list_lists(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
return list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id).order_by(TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at))).all())
|
query = select(TaskList).where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None))
|
||||||
|
ordering = (TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at)
|
||||||
|
return list((await db.scalars(query.order_by(*ordering))).all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _owned_list(db: AsyncSession, user_id: UUID, list_id: UUID) -> TaskList:
|
||||||
|
item = await db.scalar(
|
||||||
|
select(TaskList).where(
|
||||||
|
TaskList.id == list_id,
|
||||||
|
TaskList.user_id == user_id,
|
||||||
|
TaskList.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="清单不存在")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/lists/{list_id}", response_model=ListOut)
|
||||||
|
async def rename_list(
|
||||||
|
list_id: UUID,
|
||||||
|
payload: NameUpdate,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
item = await _owned_list(db, user.id, list_id)
|
||||||
|
if item.is_inbox:
|
||||||
|
raise HTTPException(status_code=409, detail="系统收集箱不能重命名")
|
||||||
|
item.name = payload.name
|
||||||
|
await db.commit()
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/lists/{list_id}", status_code=204)
|
||||||
|
async def delete_list(
|
||||||
|
list_id: UUID,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
item = await _owned_list(db, user.id, list_id)
|
||||||
|
if item.is_inbox:
|
||||||
|
raise HTTPException(status_code=409, detail="系统收集箱不能删除")
|
||||||
|
inbox_id = await db.scalar(
|
||||||
|
select(TaskList.id).where(
|
||||||
|
TaskList.user_id == user.id,
|
||||||
|
TaskList.is_inbox.is_(True),
|
||||||
|
TaskList.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if inbox_id is None:
|
||||||
|
raise HTTPException(status_code=409, detail="系统收集箱不存在")
|
||||||
|
await db.execute(
|
||||||
|
update(Task)
|
||||||
|
.where(Task.user_id == user.id, Task.list_id == item.id, Task.deleted_at.is_(None))
|
||||||
|
.values(list_id=inbox_id, version=Task.version + 1, updated_at=utcnow())
|
||||||
|
)
|
||||||
|
item.deleted_at = utcnow()
|
||||||
|
await db.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/tags", response_model=TagOut, status_code=201)
|
||||||
|
async def create_tag(
|
||||||
|
payload: TagCreate,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = await db.scalar(
|
||||||
|
select(Tag.id).where(Tag.user_id == user.id, func.lower(Tag.name) == payload.name.lower())
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="标签名称已存在")
|
||||||
|
tag = Tag(user_id=user.id, **payload.model_dump())
|
||||||
|
db.add(tag)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(tag)
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/tags", response_model=list[TagOut])
|
||||||
|
async def list_tags(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
return list(
|
||||||
|
(await db.scalars(select(Tag).where(Tag.user_id == user.id).order_by(Tag.name, Tag.id))).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_tags(
|
||||||
|
db: AsyncSession, user_id: UUID, tag_ids: list[UUID] | None
|
||||||
|
) -> list[UUID] | None:
|
||||||
|
if tag_ids is None:
|
||||||
|
return None
|
||||||
|
unique_ids = list(dict.fromkeys(tag_ids))
|
||||||
|
if not unique_ids:
|
||||||
|
return []
|
||||||
|
found = set(
|
||||||
|
(await db.scalars(select(Tag.id).where(Tag.user_id == user_id, Tag.id.in_(unique_ids)))).all()
|
||||||
|
)
|
||||||
|
if found != set(unique_ids):
|
||||||
|
raise HTTPException(status_code=404, detail="标签不存在")
|
||||||
|
return unique_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def _replace_tags(db: AsyncSession, task_ids: list[UUID], tag_ids: list[UUID]) -> None:
|
||||||
|
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(task_ids)))
|
||||||
|
db.add_all(TaskTag(task_id=task_id, tag_id=tag_id) for task_id in task_ids for tag_id in tag_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_cursor(created_at: datetime, task_id: UUID) -> str:
|
||||||
|
raw = json.dumps([created_at.isoformat(), str(task_id)]).encode()
|
||||||
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cursor(cursor: str) -> tuple[datetime, UUID]:
|
||||||
|
try:
|
||||||
|
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
|
||||||
|
timestamp, task_id = json.loads(raw)
|
||||||
|
return datetime.fromisoformat(timestamp), UUID(task_id)
|
||||||
|
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
raise HTTPException(status_code=422, detail="无效的游标") from exc
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/tasks", response_model=TaskOut, status_code=201)
|
@app.post("/api/v1/tasks", response_model=TaskOut, status_code=201)
|
||||||
async def create_task(payload: TaskCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def create_task(
|
||||||
if not await db.scalar(select(TaskList.id).where(TaskList.id == payload.list_id, TaskList.user_id == user.id)):
|
payload: TaskCreate,
|
||||||
raise HTTPException(status_code=404, detail="清单不存在")
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await _owned_list(db, user.id, payload.list_id)
|
||||||
|
tag_ids = await _validate_tags(db, user.id, payload.tag_ids)
|
||||||
if payload.parent_id:
|
if payload.parent_id:
|
||||||
parent = await db.scalar(
|
parent = await db.scalar(
|
||||||
select(Task).where(
|
select(Task).where(
|
||||||
@@ -153,22 +359,117 @@ async def create_task(payload: TaskCreate, user: User = Depends(current_user), d
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if parent is None:
|
if parent is None:
|
||||||
raise HTTPException(status_code=400, detail="父任务必须属于同一清单")
|
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
|
||||||
task = Task(user_id=user.id, **payload.model_dump())
|
data = payload.model_dump(exclude={"tag_ids"})
|
||||||
db.add(task); await db.commit(); await db.refresh(task)
|
task = Task(user_id=user.id, **data)
|
||||||
|
db.add(task)
|
||||||
|
await db.flush()
|
||||||
|
if 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.refresh(task)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/tasks", response_model=TaskPage)
|
@app.get("/api/v1/tasks", response_model=TaskPage)
|
||||||
async def list_tasks(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def list_tasks(
|
||||||
items = list((await db.scalars(select(Task).where(Task.user_id == user.id, Task.deleted_at.is_(None), Task.parent_id.is_(None)).order_by(Task.completed, Task.position, Task.created_at))).all())
|
q: str | None = None,
|
||||||
return TaskPage(items=items)
|
cursor: str | None = None,
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = select(Task).where(
|
||||||
|
Task.user_id == user.id, Task.deleted_at.is_(None), Task.parent_id.is_(None)
|
||||||
|
)
|
||||||
|
if q:
|
||||||
|
pattern = f"%{q}%"
|
||||||
|
tag_match = exists(
|
||||||
|
select(TaskTag.task_id)
|
||||||
|
.join(Tag, Tag.id == TaskTag.tag_id)
|
||||||
|
.where(TaskTag.task_id == Task.id, Tag.user_id == user.id, Tag.name.ilike(pattern))
|
||||||
|
)
|
||||||
|
list_match = exists(
|
||||||
|
select(TaskList.id).where(
|
||||||
|
TaskList.id == Task.list_id,
|
||||||
|
TaskList.user_id == user.id,
|
||||||
|
TaskList.name.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
query = query.where(
|
||||||
|
or_(Task.title.ilike(pattern), Task.description.ilike(pattern), tag_match, list_match)
|
||||||
|
)
|
||||||
|
if cursor:
|
||||||
|
created_at, task_id = _decode_cursor(cursor)
|
||||||
|
query = query.where(
|
||||||
|
or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id))
|
||||||
|
)
|
||||||
|
rows = list((await db.scalars(query.order_by(Task.created_at, Task.id).limit(limit + 1))).all())
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
items = rows[:limit]
|
||||||
|
next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None
|
||||||
|
return TaskPage(items=items, next_cursor=next_cursor)
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/tasks/{task_id}", response_model=TaskOut)
|
async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut:
|
||||||
async def update_task(task_id: UUID, payload: TaskUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
tags = list(
|
||||||
|
(
|
||||||
|
await db.scalars(
|
||||||
|
select(Tag)
|
||||||
|
.join(TaskTag, TaskTag.tag_id == Tag.id)
|
||||||
|
.where(TaskTag.task_id == task.id)
|
||||||
|
.order_by(Tag.name, Tag.id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
subtasks = list(
|
||||||
|
(
|
||||||
|
await db.scalars(
|
||||||
|
select(Task)
|
||||||
|
.where(Task.parent_id == task.id, Task.deleted_at.is_(None))
|
||||||
|
.order_by(Task.position, Task.created_at, Task.id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
data = TaskOut.model_validate(task).model_dump()
|
||||||
|
return TaskDetailOut(**data, tags=tags, subtasks=subtasks)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/tasks/{task_id}", response_model=TaskDetailOut)
|
||||||
|
async def get_task(
|
||||||
|
task_id: UUID,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
task = await db.scalar(
|
||||||
|
select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
return await _task_detail(db, task)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/tasks/{task_id}", response_model=TaskDetailOut)
|
||||||
|
async def update_task(
|
||||||
|
task_id: UUID,
|
||||||
|
payload: TaskUpdate,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
data = payload.model_dump(exclude_unset=True)
|
data = payload.model_dump(exclude_unset=True)
|
||||||
expected_version = data.pop("version")
|
expected_version = data.pop("version")
|
||||||
|
tag_ids = await _validate_tags(db, user.id, data.pop("tag_ids", None))
|
||||||
|
if "list_id" in data:
|
||||||
|
await _owned_list(db, user.id, data["list_id"])
|
||||||
|
parent_id = await db.scalar(
|
||||||
|
select(Task.parent_id).where(Task.id == task_id, Task.user_id == user.id)
|
||||||
|
)
|
||||||
|
if parent_id:
|
||||||
|
parent_list = await db.scalar(
|
||||||
|
select(Task.list_id).where(Task.id == parent_id, Task.user_id == user.id)
|
||||||
|
)
|
||||||
|
if parent_list != data["list_id"]:
|
||||||
|
raise HTTPException(status_code=400, detail="子任务必须与父任务属于同一清单")
|
||||||
data["version"] = Task.version + 1
|
data["version"] = Task.version + 1
|
||||||
data["updated_at"] = utcnow()
|
data["updated_at"] = utcnow()
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -184,27 +485,171 @@ async def update_task(task_id: UUID, payload: TaskUpdate, user: User = Depends(c
|
|||||||
)
|
)
|
||||||
task = result.scalar_one_or_none()
|
task = result.scalar_one_or_none()
|
||||||
if task is None:
|
if task is None:
|
||||||
exists = await db.scalar(
|
exists_id = await db.scalar(
|
||||||
select(Task.id).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
select(Task.id).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||||
)
|
)
|
||||||
if exists:
|
if exists_id:
|
||||||
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
|
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
if tag_ids is not None:
|
||||||
|
await _replace_tags(db, [task_id], tag_ids)
|
||||||
|
if "list_id" in data and task.parent_id is None:
|
||||||
|
await db.execute(
|
||||||
|
update(Task)
|
||||||
|
.where(Task.parent_id == task.id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||||
|
.values(list_id=task.list_id, version=Task.version + 1, updated_at=utcnow())
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return task
|
return await _task_detail(db, task)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/v1/tasks/{task_id}", status_code=204)
|
@app.delete("/api/v1/tasks/{task_id}", status_code=204)
|
||||||
async def delete_task(task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def delete_task(
|
||||||
task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None)))
|
task_id: UUID,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
task = await db.scalar(
|
||||||
|
select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||||
|
)
|
||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
task.deleted_at = utcnow()
|
deleted_at = utcnow()
|
||||||
task.version += 1
|
await db.execute(
|
||||||
|
update(Task)
|
||||||
|
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
|
||||||
|
.values(deleted_at=deleted_at, version=Task.version + 1, updated_at=deleted_at)
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/trash", response_model=TaskPage)
|
||||||
|
async def list_trash(
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = select(Task).where(
|
||||||
|
Task.user_id == user.id, Task.deleted_at.is_not(None), Task.parent_id.is_(None)
|
||||||
|
)
|
||||||
|
if cursor:
|
||||||
|
created_at, task_id = _decode_cursor(cursor)
|
||||||
|
query = query.where(
|
||||||
|
or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id))
|
||||||
|
)
|
||||||
|
rows = list((await db.scalars(query.order_by(Task.created_at, Task.id).limit(limit + 1))).all())
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
items = rows[:limit]
|
||||||
|
next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None
|
||||||
|
return TaskPage(items=items, next_cursor=next_cursor)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut)
|
||||||
|
async def restore_task(
|
||||||
|
task_id: UUID,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
task = await db.scalar(
|
||||||
|
select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_not(None))
|
||||||
|
)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(status_code=404, detail="回收站中不存在该任务")
|
||||||
|
await _owned_list(db, user.id, task.list_id)
|
||||||
|
now = utcnow()
|
||||||
|
await db.execute(
|
||||||
|
update(Task)
|
||||||
|
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
|
||||||
|
.values(deleted_at=None, version=Task.version + 1, updated_at=now)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
return await _task_detail(db, task)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/trash/{task_id}", status_code=204)
|
||||||
|
async def permanently_delete_task(
|
||||||
|
task_id: UUID,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
task = await db.scalar(
|
||||||
|
select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_not(None))
|
||||||
|
)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(status_code=404, detail="回收站中不存在该任务")
|
||||||
|
ids = list(
|
||||||
|
(
|
||||||
|
await db.scalars(
|
||||||
|
select(Task.id).where(
|
||||||
|
or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(ids)))
|
||||||
|
await db.execute(delete(Task).where(Task.parent_id == task.id, Task.user_id == user.id))
|
||||||
|
await db.delete(task)
|
||||||
|
await db.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/tasks/batch", response_model=BatchResult)
|
||||||
|
async def batch_update_tasks(
|
||||||
|
payload: BatchTaskUpdate,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
task_ids = list(dict.fromkeys(payload.task_ids))
|
||||||
|
tasks = list(
|
||||||
|
(
|
||||||
|
await db.scalars(
|
||||||
|
select(Task).where(
|
||||||
|
Task.id.in_(task_ids), Task.user_id == user.id, Task.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if len(tasks) != len(task_ids):
|
||||||
|
raise HTTPException(status_code=404, detail="一个或多个任务不存在")
|
||||||
|
if payload.list_id is not None:
|
||||||
|
await _owned_list(db, user.id, payload.list_id)
|
||||||
|
tag_ids = await _validate_tags(db, user.id, payload.tag_ids)
|
||||||
|
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "tag_ids", "soft_delete"})
|
||||||
|
if payload.soft_delete:
|
||||||
|
changes["deleted_at"] = utcnow()
|
||||||
|
if changes:
|
||||||
|
changes["version"] = Task.version + 1
|
||||||
|
changes["updated_at"] = utcnow()
|
||||||
|
await db.execute(
|
||||||
|
update(Task)
|
||||||
|
.where(Task.id.in_(task_ids), Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||||
|
.values(**changes)
|
||||||
|
)
|
||||||
|
if payload.list_id is not None:
|
||||||
|
parent_ids = [task.id for task in tasks if task.parent_id is None]
|
||||||
|
if parent_ids:
|
||||||
|
await db.execute(
|
||||||
|
update(Task)
|
||||||
|
.where(
|
||||||
|
Task.parent_id.in_(parent_ids),
|
||||||
|
Task.user_id == user.id,
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
list_id=payload.list_id,
|
||||||
|
version=Task.version + 1,
|
||||||
|
updated_at=utcnow(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if tag_ids is not None:
|
||||||
|
await _replace_tags(db, task_ids, tag_ids)
|
||||||
|
await db.commit()
|
||||||
|
return BatchResult(updated=len(task_ids))
|
||||||
|
|
||||||
|
|
||||||
static_dir = Path(__file__).parent / "static"
|
static_dir = Path(__file__).parent / "static"
|
||||||
if static_dir.exists():
|
if static_dir.exists():
|
||||||
app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
|
app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
|
||||||
|
|||||||
+19
-1
@@ -1,7 +1,7 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
from sqlalchemy import Boolean, DateTime, 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
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ class Folder(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(120))
|
name: Mapped[str] = mapped_column(String(120))
|
||||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class TaskList(Base):
|
class TaskList(Base):
|
||||||
@@ -58,6 +59,23 @@ class TaskList(Base):
|
|||||||
is_inbox: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_inbox: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(Base):
|
||||||
|
__tablename__ = "tags"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tags_user_id_name"),)
|
||||||
|
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(80))
|
||||||
|
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskTag(Base):
|
||||||
|
__tablename__ = "task_tags"
|
||||||
|
task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
tag_id: Mapped[UUID] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
class Task(Base):
|
class Task(Base):
|
||||||
|
|||||||
+57
-4
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class InitializeRequest(BaseModel):
|
class InitializeRequest(BaseModel):
|
||||||
@@ -20,10 +20,14 @@ class UserOut(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
|
|
||||||
|
|
||||||
class FolderCreate(BaseModel):
|
class NameUpdate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class FolderCreate(NameUpdate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FolderOut(BaseModel):
|
class FolderOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: UUID
|
id: UUID
|
||||||
@@ -43,6 +47,18 @@ class ListOut(BaseModel):
|
|||||||
is_inbox: bool
|
is_inbox: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TagCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=80)
|
||||||
|
color: str = Field(default="#f15a29", min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class TagOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=500)
|
title: str = Field(min_length=1, max_length=500)
|
||||||
list_id: UUID
|
list_id: UUID
|
||||||
@@ -50,16 +66,26 @@ class TaskCreate(BaseModel):
|
|||||||
priority: int = Field(default=0, ge=0, le=3)
|
priority: int = Field(default=0, ge=0, le=3)
|
||||||
due_at: datetime | None = None
|
due_at: datetime | None = None
|
||||||
parent_id: UUID | None = None
|
parent_id: UUID | None = None
|
||||||
|
tag_ids: list[UUID] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
title: str = Field(default=None, min_length=1, max_length=500)
|
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||||
description: str = Field(default=None)
|
description: str | None = None
|
||||||
priority: int | None = Field(default=None, ge=0, le=3)
|
priority: int | None = Field(default=None, ge=0, le=3)
|
||||||
due_at: datetime | None = None
|
due_at: datetime | None = None
|
||||||
completed: bool | None = None
|
completed: bool | None = None
|
||||||
|
list_id: UUID | None = None
|
||||||
|
tag_ids: list[UUID] | None = None
|
||||||
version: int = Field(ge=1)
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def reject_null_non_nullable_fields(self):
|
||||||
|
for field in ("title", "description", "priority", "completed", "list_id"):
|
||||||
|
if field in self.model_fields_set and getattr(self, field) is None:
|
||||||
|
raise ValueError(f"{field} cannot be null")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TaskOut(BaseModel):
|
class TaskOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -74,6 +100,33 @@ class TaskOut(BaseModel):
|
|||||||
version: int
|
version: int
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDetailOut(TaskOut):
|
||||||
|
tags: list[TagOut] = Field(default_factory=list)
|
||||||
|
subtasks: list[TaskOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class TaskPage(BaseModel):
|
class TaskPage(BaseModel):
|
||||||
items: list[TaskOut]
|
items: list[TaskOut]
|
||||||
next_cursor: str | None = None
|
next_cursor: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchTaskUpdate(BaseModel):
|
||||||
|
task_ids: list[UUID] = Field(min_length=1)
|
||||||
|
completed: bool | None = None
|
||||||
|
list_id: UUID | None = None
|
||||||
|
due_at: datetime | None = None
|
||||||
|
tag_ids: list[UUID] | None = None
|
||||||
|
soft_delete: bool | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_operation(self):
|
||||||
|
operations = {"completed", "list_id", "due_at", "tag_ids", "soft_delete"}
|
||||||
|
if not self.model_fields_set.intersection(operations):
|
||||||
|
raise ValueError("at least one batch operation is required")
|
||||||
|
if self.soft_delete is False:
|
||||||
|
raise ValueError("soft_delete can only be true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BatchResult(BaseModel):
|
||||||
|
updated: int
|
||||||
|
|||||||
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
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
|||||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BHdY9q3o.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-BiihmXG7.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BBQtE9Jy.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-hzru7WEs.css">
|
||||||
</head><body><div id="app"></div></body></html>
|
</head><body><div id="app"></div></body></html>
|
||||||
+38
-9
@@ -1,27 +1,56 @@
|
|||||||
# API
|
# API
|
||||||
|
|
||||||
Base path: `/api/v1`
|
Base path: `/api/v1`. 除初始化、登录和健康检查外均需 `dodo_session` Cookie。
|
||||||
|
|
||||||
## Setup / Auth
|
## Setup / Auth
|
||||||
|
|
||||||
- `GET /setup/status`
|
- `GET /setup/status`
|
||||||
- `POST /setup/initialize`
|
- `POST /setup/initialize`
|
||||||
- `POST /auth/login`
|
- `POST /auth/login`
|
||||||
|
- `POST /auth/logout`
|
||||||
- `GET /me`
|
- `GET /me`
|
||||||
|
|
||||||
## Folders
|
## Folders
|
||||||
- `GET /folders`
|
|
||||||
- `POST /folders`
|
- `GET /folders` — 仅返回未删除文件夹
|
||||||
|
- `POST /folders` — 创建文件夹
|
||||||
|
- `PATCH /folders/{folder_id}` — 重命名
|
||||||
|
- `DELETE /folders/{folder_id}` — 软删除;其清单移到根层级
|
||||||
|
|
||||||
## Lists
|
## Lists
|
||||||
- `GET /lists`
|
|
||||||
- `POST /lists`
|
- `GET /lists` — 仅返回未删除清单,系统收集箱排首位
|
||||||
|
- `POST /lists` — 创建清单,可指定 `folder_id`
|
||||||
|
- `PATCH /lists/{list_id}` — 重命名;系统收集箱返回 409
|
||||||
|
- `DELETE /lists/{list_id}` — 软删除并将任务移入系统收集箱;系统收集箱返回 409
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
标签属于当前用户,可跨清单复用。
|
||||||
|
|
||||||
|
- `GET /tags`
|
||||||
|
- `POST /tags` — `{name, color}`;同一用户名称唯一
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
- `GET /tasks`
|
|
||||||
- `POST /tasks`
|
- `GET /tasks?q=&limit=&cursor=` — 顶层未删除任务的游标分页;`q` 匹配标题、描述、标签名和清单名
|
||||||
- `PATCH /tasks/{task_id}` — 必须携带当前 `version`,冲突返回 409
|
- `POST /tasks` — 创建任务;`parent_id` 只允许指向同清单顶层任务;`tag_ids` 必须属于当前用户
|
||||||
- `DELETE /tasks/{task_id}` — 软删除
|
- `GET /tasks/{task_id}` — 返回任务、标签和一层子任务
|
||||||
|
- `PATCH /tasks/{task_id}` — 必须携带当前 `version`,原子比较更新;版本冲突返回 409
|
||||||
|
- `DELETE /tasks/{task_id}` — 软删除任务及其直接子任务
|
||||||
|
- `POST /tasks/{task_id}/restore` — 恢复任务及其直接子任务
|
||||||
|
- `POST /tasks/batch` — 原子批量完成、移动、设置截止时间、替换标签或软删除
|
||||||
|
|
||||||
|
批量请求字段:`task_ids`、`completed`、`list_id`、`due_at`、`tag_ids`、`soft_delete`。所有任务、目标清单和标签在写入前完成归属校验;任一不存在则整批不修改。
|
||||||
|
|
||||||
|
## Recycle bin
|
||||||
|
|
||||||
|
- `GET /trash?limit=&cursor=` — 已删除顶层任务的游标分页
|
||||||
|
- `DELETE /trash/{task_id}` — 永久删除任务及其子任务
|
||||||
|
|
||||||
|
游标是不透明字符串。无效游标返回 422;`limit` 范围为 1–100。
|
||||||
|
|
||||||
## Health
|
## Health
|
||||||
|
|
||||||
- `GET /health/live`
|
- `GET /health/live`
|
||||||
- `GET /health/ready`
|
- `GET /health/ready`
|
||||||
|
|||||||
+77
-40
@@ -1,60 +1,97 @@
|
|||||||
# dodo 数据模型(第一阶段)
|
# dodo 数据模型
|
||||||
|
|
||||||
|
所有业务实体使用 UUIDv7 主键并按 `user_id` 隔离。生产使用 PostgreSQL,测试使用 SQLite;模型保持两者兼容。
|
||||||
|
|
||||||
## app_state
|
## app_state
|
||||||
- key
|
|
||||||
- created_at
|
- `key` 主键
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
## users
|
## users
|
||||||
- id UUIDv7
|
|
||||||
- username
|
- `id`
|
||||||
- password_hash
|
- `username` 唯一
|
||||||
- timezone
|
- `password_hash`
|
||||||
- created_at
|
- `timezone`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
## sessions
|
## sessions
|
||||||
- id UUIDv7
|
|
||||||
- token_hash
|
- `id`
|
||||||
- user_id
|
- `token_hash` 唯一
|
||||||
- expires_at
|
- `user_id` → users,级联删除
|
||||||
- created_at
|
- `expires_at`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
## folders
|
## folders
|
||||||
- id UUIDv7
|
|
||||||
- user_id
|
- `id`
|
||||||
- name
|
- `user_id` → users
|
||||||
- position
|
- `name`
|
||||||
- created_at
|
- `position`
|
||||||
|
- `created_at`
|
||||||
|
- `deleted_at`,非空表示软删除
|
||||||
|
|
||||||
|
删除文件夹不会删除清单;清单的 `folder_id` 被置空。
|
||||||
|
|
||||||
## task_lists
|
## task_lists
|
||||||
- id UUIDv7
|
|
||||||
- user_id
|
- `id`
|
||||||
- folder_id nullable
|
- `user_id` → users
|
||||||
- name
|
- `folder_id` → folders,可空
|
||||||
- is_inbox
|
- `name`
|
||||||
- position
|
- `is_inbox`,每个用户初始化时创建一个受保护的系统收集箱
|
||||||
- created_at
|
- `position`
|
||||||
|
- `created_at`
|
||||||
|
- `deleted_at`,非空表示软删除
|
||||||
|
|
||||||
|
删除普通清单时,其未删除任务原子移动到系统收集箱。系统收集箱不可重命名或删除。
|
||||||
|
|
||||||
|
## tags
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `user_id` → users
|
||||||
|
- `name`,同一用户内唯一
|
||||||
|
- `color`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
标签是用户级全局实体,不隶属于清单。
|
||||||
|
|
||||||
|
## task_tags
|
||||||
|
|
||||||
|
- `task_id` → tasks
|
||||||
|
- `tag_id` → tags
|
||||||
|
- `(task_id, tag_id)` 联合主键
|
||||||
|
|
||||||
## tasks
|
## tasks
|
||||||
- id UUIDv7
|
|
||||||
- user_id
|
- `id`
|
||||||
- list_id
|
- `user_id` → users
|
||||||
- parent_id nullable
|
- `list_id` → task_lists
|
||||||
- title
|
- `parent_id` → tasks,可空;仅允许一层子任务且必须与父任务同清单
|
||||||
- description
|
- `title`
|
||||||
- priority (0-3)
|
- `description`
|
||||||
- completed
|
- `priority`(0–3)
|
||||||
- due_at nullable
|
- `completed`
|
||||||
- version
|
- `due_at`,可空
|
||||||
- position
|
- `version`,乐观锁版本;单任务更新用 `id + user_id + version` 原子比较更新
|
||||||
- created_at
|
- `position`
|
||||||
- updated_at
|
- `created_at`
|
||||||
- deleted_at nullable
|
- `updated_at`
|
||||||
|
- `deleted_at`,非空表示进入回收站
|
||||||
|
|
||||||
|
顶层任务软删除、恢复或永久删除时同步处理直接子任务。列表与回收站使用 `(created_at, id)` 作为稳定游标排序键。
|
||||||
|
|
||||||
|
## 迁移
|
||||||
|
|
||||||
|
- `0001_initial.py`:已部署的初始模式,不修改
|
||||||
|
- `0002_task_management.py`:新增文件夹/清单软删除列、标签表、任务标签关联表及游标/回收站索引
|
||||||
|
|
||||||
## 后续阶段预留
|
## 后续阶段预留
|
||||||
|
|
||||||
- task_reminders
|
- task_reminders
|
||||||
- task_recurrence_templates
|
- task_recurrence_templates
|
||||||
- task_recurrence_exceptions
|
- task_recurrence_exceptions
|
||||||
- tags / task_tags
|
|
||||||
- habits / habit_logs / habit_reminders
|
- habits / habit_logs / habit_reminders
|
||||||
- attachments
|
- attachments
|
||||||
- audit_logs
|
- audit_logs
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# dodo 产品与技术决策记录
|
||||||
|
|
||||||
|
## 定位
|
||||||
|
|
||||||
|
dodo 是一个纯自托管的 TickTick-like 任务与习惯管理工具。目标不是一比一复刻 TickTick,而是做一个数据归自己、界面温暖紧凑、适合个人长期使用的任务系统。
|
||||||
|
|
||||||
|
## 当前边界
|
||||||
|
|
||||||
|
- 首版只做本地开发验证,不部署。
|
||||||
|
- 首版不做通知渠道:Web Push、Telegram、SMTP 暂不实现。
|
||||||
|
- 首版附件只做本地存储,不实现 S3。
|
||||||
|
- 首版不做番茄钟。
|
||||||
|
- 首版不做自然语言创建任务。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Monorepo:`frontend/`、`backend/`、`worker/`
|
||||||
|
- 前端:Vue 3 + TypeScript + Vite + Tailwind CSS + Shadcn-vue/Reka UI
|
||||||
|
- 后端:FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic
|
||||||
|
- 数据库:PostgreSQL,主键 UUIDv7,时间统一 UTC,用户配置时区
|
||||||
|
- 后台:独立 Worker + APScheduler
|
||||||
|
- 包管理:uv + pnpm
|
||||||
|
- 交付:单 Docker 镜像,外部 PostgreSQL
|
||||||
|
- 许可证:AGPL-3.0
|
||||||
|
|
||||||
|
## 数据库
|
||||||
|
|
||||||
|
本地开发数据库:
|
||||||
|
|
||||||
|
```text
|
||||||
|
postgresql+asyncpg://postgres:***@10.10.100.99:5433/dodo
|
||||||
|
```
|
||||||
|
|
||||||
|
已从默认 `postgres` 库迁移到独立 `dodo` 库。误建在 `postgres.public` 的 dodo 表已清理。
|
||||||
|
|
||||||
|
## 产品模型
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- 文件夹 → 清单 → 任务
|
||||||
|
- 系统内置收集箱,不允许删除
|
||||||
|
- 任务支持一层子任务
|
||||||
|
- 状态:未完成 / 已完成
|
||||||
|
- 优先级:无 / 低 / 中 / 高
|
||||||
|
- Markdown 描述
|
||||||
|
- 截止日期 + 具体时间
|
||||||
|
- 多提醒设计预留
|
||||||
|
- 全局彩色标签
|
||||||
|
- 删除为软删除,回收站手动清空
|
||||||
|
- 支持创建、修改、完成、恢复、删除操作历史
|
||||||
|
- 并发编辑使用原子乐观锁
|
||||||
|
|
||||||
|
### 重复任务
|
||||||
|
|
||||||
|
- RFC 5545 RRULE
|
||||||
|
- 模板 + 实例
|
||||||
|
- 修改范围:仅本次 / 本次及以后 / 全部
|
||||||
|
- 删除单次保存为例外
|
||||||
|
- 每月 31 日在无 31 日月份跳过
|
||||||
|
- 逾期完成不影响下次计划日期
|
||||||
|
|
||||||
|
### 习惯
|
||||||
|
|
||||||
|
- 完成型 + 数值型
|
||||||
|
- 每天 / 每周 / 每月 / 间隔天数
|
||||||
|
- 数值型当日累计,达标后封顶
|
||||||
|
- 允许补打和修改历史
|
||||||
|
- 支持暂停区间,暂停期不破坏连续记录
|
||||||
|
- 归档后保留历史统计
|
||||||
|
|
||||||
|
## UI 方向
|
||||||
|
|
||||||
|
- 手账生活感
|
||||||
|
- 中高信息密度
|
||||||
|
- 10–12px 中等圆角
|
||||||
|
- 细分割线为主,少量浅底色
|
||||||
|
- 强调色:`#F15A29`
|
||||||
|
- 只做浅色模式
|
||||||
|
- 系统字体栈
|
||||||
|
- 不使用猫猫元素
|
||||||
|
- 轻微动效
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
- 桌面三栏:左导航 / 中任务列表 / 右任务详情
|
||||||
|
- 手机底部导航
|
||||||
|
- 顶部快速输入,手机悬浮新增按钮
|
||||||
|
- 桌面右侧详情栏,手机底部弹层
|
||||||
|
- 日历默认月视图
|
||||||
|
- 习惯首页:今日习惯列表 + 一周打卡格
|
||||||
|
- 搜索:顶部搜索框 + 全局搜索快捷键
|
||||||
|
|
||||||
|
## 工程质量
|
||||||
|
|
||||||
|
- `/api/v1` API 路径
|
||||||
|
- 统一错误码、可读提示和字段详情
|
||||||
|
- 页面内诊断信息 + Toast
|
||||||
|
- readiness 检查数据库
|
||||||
|
- 首版不提供 Prometheus metrics
|
||||||
|
- 审计日志记录操作人、实体、动作、时间和变更摘要
|
||||||
|
- 手动 JSON 全量导出
|
||||||
|
- 附件默认 20MB,可用环境变量调整
|
||||||
|
- 默认允许常用文档与图片,拒绝危险文件类型
|
||||||
|
|
||||||
|
## 第一阶段验收
|
||||||
|
|
||||||
|
- 可初始化管理员
|
||||||
|
- 可登录
|
||||||
|
- 可创建清单和任务
|
||||||
|
- 后端测试通过
|
||||||
|
- 前端生产构建通过
|
||||||
|
- PostgreSQL 迁移成功
|
||||||
|
- 本地应用可以启动并访问
|
||||||
+308
-22
@@ -1,28 +1,314 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { CalendarDays, Check, CirclePlus, Inbox, ListTodo, Menu, Search, Settings, Sprout } from 'lucide-vue-next'
|
import {
|
||||||
|
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
|
||||||
|
GripVertical, Inbox, ListChecks, ListTodo, Menu, MoreHorizontal, Pencil, Plus, Search,
|
||||||
|
Settings, Tag as TagIcon, Trash2, X,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
|
||||||
|
|
||||||
|
type FolderItem = { id: string; name: string }
|
||||||
|
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||||
|
type Tag = { id: string; name: string; color: string }
|
||||||
|
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; tags?: Tag[]; subtasks?: Task[] }
|
||||||
|
type View = 'tasks' | 'today' | 'upcoming' | 'trash'
|
||||||
|
|
||||||
|
const initialized = ref<boolean | null>(null)
|
||||||
|
const authenticated = ref(false)
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const folders = ref<FolderItem[]>([])
|
||||||
|
const lists = ref<TaskList[]>([])
|
||||||
|
const tags = ref<Tag[]>([])
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const trash = ref<Task[]>([])
|
||||||
|
const activeList = ref('')
|
||||||
|
const activeView = ref<View>('tasks')
|
||||||
|
const selectedTask = ref<Task | null>(null)
|
||||||
|
const selectedIds = ref(new Set<string>())
|
||||||
|
const title = ref('')
|
||||||
|
const query = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const notice = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const mobileSidebar = ref(false)
|
||||||
|
const mobileDetail = ref(false)
|
||||||
|
const markdownPreview = ref(false)
|
||||||
|
const showCompleted = ref(true)
|
||||||
|
const expandedFolders = ref(new Set<string>())
|
||||||
|
|
||||||
|
const activeName = computed(() => {
|
||||||
|
if (activeView.value === 'trash') return '回收站'
|
||||||
|
if (activeView.value === 'today') return '今天'
|
||||||
|
if (activeView.value === 'upcoming') return '最近 7 天'
|
||||||
|
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||||||
|
})
|
||||||
|
const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value)
|
||||||
|
const visibleTasks = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const end = new Date(now); end.setDate(end.getDate() + 7)
|
||||||
|
let result = sourceTasks.value
|
||||||
|
if (query.value.trim()) return result
|
||||||
|
if (activeView.value === 'tasks') result = result.filter((task) => task.list_id === activeList.value)
|
||||||
|
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
|
||||||
|
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
|
||||||
|
if (!showCompleted.value && activeView.value !== 'trash') result = result.filter((task) => !task.completed)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
const filteredTaskTree = computed(() => groupTaskTree(filterTasks(visibleTasks.value, query.value)))
|
||||||
|
const taskTree = computed(() => filteredTaskTree.value)
|
||||||
|
const selectedCount = computed(() => selectedIds.value.size)
|
||||||
|
const parentTasks = computed(() => tasks.value.filter((task) => !task.parent_id))
|
||||||
|
let searchTimer: number | undefined
|
||||||
|
watch(query, () => {
|
||||||
|
if (searchTimer) window.clearTimeout(searchTimer)
|
||||||
|
if (activeView.value === 'trash') return
|
||||||
|
searchTimer = window.setTimeout(() => loadAll(), 250)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function api(path: string, options: RequestInit = {}) {
|
||||||
|
const response = await fetch('/api/v1' + path, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = '请求失败'
|
||||||
|
try { const body = await response.json(); message = typeof body.detail === 'string' ? body.detail : message } catch { /* noop */ }
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return response.status === 204 ? null : response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message: string) {
|
||||||
|
notice.value = message
|
||||||
|
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
|
||||||
|
}
|
||||||
|
function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' }
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
try {
|
||||||
|
const status = await api('/setup/status')
|
||||||
|
initialized.value = status.initialized
|
||||||
|
if (status.initialized) {
|
||||||
|
await api('/me')
|
||||||
|
authenticated.value = true
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
} catch { authenticated.value = false; initialized.value ??= true }
|
||||||
|
}
|
||||||
|
async function submitAuth() {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const path = initialized.value ? '/auth/login' : '/setup/initialize'
|
||||||
|
await api(path, { method: 'POST', body: JSON.stringify({ username: username.value, password: password.value }) })
|
||||||
|
initialized.value = true; authenticated.value = true
|
||||||
|
await loadAll()
|
||||||
|
} catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function loadTaskPages(path: string) {
|
||||||
|
const rows: Task[] = []
|
||||||
|
let cursor: string | null = null
|
||||||
|
do {
|
||||||
|
const separator = path.includes('?') ? '&' : '?'
|
||||||
|
const data = await api(cursor ? `${path}${separator}cursor=${encodeURIComponent(cursor)}` : path)
|
||||||
|
rows.push(...(data.items ?? data))
|
||||||
|
cursor = data.next_cursor ?? null
|
||||||
|
} while (cursor)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const taskPath = query.value ? `/tasks?q=${encodeURIComponent(query.value)}` : '/tasks'
|
||||||
|
const [folderData, listData, tagData, taskData] = await Promise.all([
|
||||||
|
api('/folders'), api('/lists'), api('/tags').catch(() => []), loadTaskPages(taskPath),
|
||||||
|
])
|
||||||
|
folders.value = folderData; lists.value = listData; tags.value = tagData; tasks.value = taskData
|
||||||
|
activeList.value ||= lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
|
||||||
|
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
|
||||||
|
} catch (reason) { fail(reason) } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
async function loadTrash() {
|
||||||
|
try { trash.value = await loadTaskPages('/trash') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function switchView(view: View, listId?: string) {
|
||||||
|
activeView.value = view
|
||||||
|
if (listId) activeList.value = listId
|
||||||
|
selectedTask.value = null; selectedIds.value = new Set(); mobileSidebar.value = false
|
||||||
|
if (view === 'trash') await loadTrash()
|
||||||
|
}
|
||||||
|
async function addTask() {
|
||||||
|
if (!title.value.trim() || !activeList.value) return
|
||||||
|
try {
|
||||||
|
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: title.value.trim(), list_id: activeList.value }) })
|
||||||
|
tasks.value.push(task); title.value = ''; selectTask(task); toast('任务已添加')
|
||||||
|
} catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function patchTask(task: Task, patch: Partial<Task>) {
|
||||||
|
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) })
|
||||||
|
const index = tasks.value.findIndex((item) => item.id === task.id)
|
||||||
|
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated }
|
||||||
|
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
|
||||||
|
return updated as Task
|
||||||
|
}
|
||||||
|
async function toggle(task: Task) {
|
||||||
|
try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function saveTask() {
|
||||||
|
if (!selectedTask.value?.title.trim()) return
|
||||||
|
try {
|
||||||
|
const task = selectedTask.value
|
||||||
|
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id, tag_ids: (task.tags ?? []).map((tag) => tag.id) } as Partial<Task>)
|
||||||
|
toast('已保存')
|
||||||
|
} catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function removeTask(task: Task) {
|
||||||
|
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
|
||||||
|
try { await api(`/tasks/${task.id}`, { method: 'DELETE' }); tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id); selectedTask.value = null; mobileDetail.value = false; toast('已移到回收站') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function restoreTask(task: Task) {
|
||||||
|
try { await api(`/tasks/${task.id}/restore`, { method: 'POST' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('任务已恢复') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function purgeTask(task: Task) {
|
||||||
|
if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return
|
||||||
|
try { await api(`/trash/${task.id}`, { method: 'DELETE' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('已永久删除') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function addSubtask() {
|
||||||
|
if (!selectedTask.value) return
|
||||||
|
const subtaskTitle = window.prompt('子任务名称')?.trim()
|
||||||
|
if (!subtaskTitle) return
|
||||||
|
try { const child = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: selectedTask.value.list_id, parent_id: selectedTask.value.id }) }); tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
function selectTask(task: Task) {
|
||||||
|
selectedTask.value = { ...task, tags: task.tags ? [...task.tags] : [] }
|
||||||
|
markdownPreview.value = false; mobileDetail.value = true
|
||||||
|
}
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
const next = new Set(selectedIds.value); next.has(id) ? next.delete(id) : next.add(id); selectedIds.value = next
|
||||||
|
}
|
||||||
|
async function batch(action: 'complete' | 'move' | 'delete') {
|
||||||
|
if (!selectedIds.value.size) return
|
||||||
|
const payload: Record<string, unknown> = { task_ids: [...selectedIds.value] }
|
||||||
|
if (action === 'complete') payload.completed = true
|
||||||
|
if (action === 'move') {
|
||||||
|
const listId = window.prompt('输入目标清单 ID', activeList.value)?.trim(); if (!listId) return; payload.list_id = listId
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (action === 'delete') payload.soft_delete = true
|
||||||
|
await api('/tasks/batch', { method: 'POST', body: JSON.stringify(payload) })
|
||||||
|
selectedIds.value = new Set(); await loadAll(); toast(action === 'delete' ? '已批量移到回收站' : '批量操作完成')
|
||||||
|
} catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function createFolder() {
|
||||||
|
const name = window.prompt('文件夹名称')?.trim(); if (!name) return
|
||||||
|
try { folders.value.push(await api('/folders', { method: 'POST', body: JSON.stringify({ name }) })); toast('文件夹已创建') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function createList(folderId: string | null = null) {
|
||||||
|
const name = window.prompt('清单名称')?.trim(); if (!name) return
|
||||||
|
try { const item = await api('/lists', { method: 'POST', body: JSON.stringify({ name, folder_id: folderId }) }); lists.value.push(item); await switchView('tasks', item.id); toast('清单已创建') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||||||
|
const name = window.prompt('新名称', item.name)?.trim(); if (!name || name === item.name) return
|
||||||
|
try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||||||
|
if (!window.confirm(`删除“${item.name}”?`)) return
|
||||||
|
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await loadAll(); toast('已删除') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
async function createTag() {
|
||||||
|
const name = window.prompt('标签名称')?.trim(); if (!name) return
|
||||||
|
const color = window.prompt('标签颜色', '#F15A29') || '#F15A29'
|
||||||
|
try { tags.value.push(await api('/tags', { method: 'POST', body: JSON.stringify({ name, color }) })); toast('标签已创建') } catch (reason) { fail(reason) }
|
||||||
|
}
|
||||||
|
function taskHasTag(tag: Tag) { return !!selectedTask.value?.tags?.some((item) => item.id === tag.id) }
|
||||||
|
function toggleTag(tag: Tag) {
|
||||||
|
if (!selectedTask.value) return
|
||||||
|
selectedTask.value.tags = taskHasTag(tag) ? (selectedTask.value.tags ?? []).filter((item) => item.id !== tag.id) : [...(selectedTask.value.tags ?? []), tag]
|
||||||
|
}
|
||||||
|
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
|
||||||
|
function formatDue(value: string | null) { return value ? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value)) : '' }
|
||||||
|
function focusQuick() { nextTick(() => document.querySelector<HTMLInputElement>('.quick-input')?.focus()) }
|
||||||
|
|
||||||
type TaskList = { id:string; name:string; is_inbox:boolean }
|
|
||||||
type Task = { id:string; list_id:string; title:string; description:string; priority:number; completed:boolean; version:number; due_at:string|null }
|
|
||||||
const initialized=ref<boolean|null>(null), authenticated=ref(false), username=ref(''), password=ref(''), lists=ref<TaskList[]>([]), tasks=ref<Task[]>([]), activeList=ref(''), title=ref(''), error=ref('')
|
|
||||||
const activeName=computed(()=>lists.value.find(x=>x.id===activeList.value)?.name||'今天')
|
|
||||||
async function api(path:string, options:RequestInit={}) { const r=await fetch('/api/v1'+path,{credentials:'include',headers:{'Content-Type':'application/json',...(options.headers||{})},...options}); if(!r.ok) throw new Error((await r.json()).detail||'请求失败'); return r.status===204?null:r.json() }
|
|
||||||
async function bootstrap(){try{const s=await api('/setup/status');initialized.value=s.initialized;if(s.initialized){await api('/me');authenticated.value=true;await load()}}catch{authenticated.value=false}}
|
|
||||||
async function submitAuth(){error.value='';try{if(!initialized.value){await api('/setup/initialize',{method:'POST',body:JSON.stringify({username:username.value,password:password.value})});initialized.value=true}else await api('/auth/login',{method:'POST',body:JSON.stringify({username:username.value,password:password.value})});authenticated.value=true;await load()}catch(e){error.value=(e as Error).message}}
|
|
||||||
async function load(){lists.value=await api('/lists');activeList.value ||= lists.value[0]?.id;tasks.value=(await api('/tasks')).items}
|
|
||||||
async function addTask(){if(!title.value.trim()||!activeList.value)return;const t=await api('/tasks',{method:'POST',body:JSON.stringify({title:title.value.trim(),list_id:activeList.value})});tasks.value.push(t);title.value=''}
|
|
||||||
async function toggle(t:Task){const updated=await api(`/tasks/${t.id}`,{method:'PATCH',body:JSON.stringify({completed:!t.completed,version:t.version})});Object.assign(t,updated)}
|
|
||||||
function focusQuick(){document.querySelector<HTMLInputElement>('.quick input')?.focus()}
|
|
||||||
onMounted(bootstrap)
|
onMounted(bootstrap)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="initialized===null" class="center">正在打开 dodo…</div>
|
<div v-if="initialized === null" class="center"><span class="loader" />正在打开 dodo…</div>
|
||||||
<div v-else-if="!authenticated" class="auth-shell"><section class="auth-card"><div class="brand">do<span>do</span></div><p>{{ initialized?'欢迎回来':'创建你的 dodo' }}</p><input v-model="username" placeholder="用户名"><input v-model="password" type="password" placeholder="密码(至少12位)" @keyup.enter="submitAuth"><button @click="submitAuth">{{ initialized?'登录':'开始使用' }}</button><small v-if="error">{{error}}</small></section></div>
|
<div v-else-if="!authenticated" class="auth-shell">
|
||||||
<div v-else class="shell">
|
<section class="auth-card">
|
||||||
<aside><div class="brand small">do<span>do</span></div><nav><button class="active"><Inbox/>收集箱</button><button><ListTodo/>今天</button><button><CalendarDays/>最近7天</button><button><Sprout/>习惯</button></nav><div class="lists"><label>我的清单</label><button v-for="l in lists" :key="l.id" @click="activeList=l.id"><i></i>{{l.name}}</button></div><button class="settings"><Settings/>设置</button></aside>
|
<div class="brand">do<span>do</span></div><p>{{ initialized ? '欢迎回来,继续把生活理顺。' : '创建你的 dodo' }}</p>
|
||||||
<main><header><button class="mobile-menu"><Menu/></button><div><p>今天也慢慢来</p><h1>{{activeName}}</h1></div><button class="search"><Search/>搜索</button></header><form @submit.prevent="addTask" class="quick"><CirclePlus/><input v-model="title" placeholder="添加任务"><button>添加</button></form><section class="task-list"><article v-for="t in tasks.filter(x=>x.list_id===activeList)" :key="t.id" :class="{done:t.completed}"><button class="check" @click="toggle(t)"><Check v-if="t.completed"/></button><div><strong>{{t.title}}</strong><p v-if="t.description">{{t.description}}</p></div><span v-if="t.priority" class="priority">P{{4-t.priority}}</span></article><div v-if="!tasks.filter(x=>x.list_id===activeList).length" class="empty"><ListTodo/><b>这里还很安静</b><span>写下第一件想完成的小事吧</span></div></section></main>
|
<label>用户名<input v-model="username" autocomplete="username" placeholder="你的用户名"></label>
|
||||||
<aside class="detail"><span>任务详情</span><div class="paper"><b>选中一个任务</b><p>日期、提醒、标签和备注会出现在这里。</p></div></aside>
|
<label>密码<input v-model="password" type="password" autocomplete="current-password" placeholder="至少 12 位" @keyup.enter="submitAuth"></label>
|
||||||
<nav class="bottom"><button><ListTodo/><span>今天</span></button><button><Inbox/><span>任务</span></button><button><CalendarDays/><span>日历</span></button><button><Sprout/><span>习惯</span></button></nav>
|
<button class="primary" @click="submitAuth">{{ initialized ? '登录' : '开始使用' }}</button>
|
||||||
<button class="fab" @click="focusQuick"><CirclePlus/></button>
|
<small v-if="error" role="alert">{{ error }}</small>
|
||||||
</div>
|
</section>
|
||||||
|
</div>
|
||||||
|
<div v-else class="shell">
|
||||||
|
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
|
||||||
|
<aside class="sidebar" :class="{ open: mobileSidebar }">
|
||||||
|
<div class="brand-row"><div class="brand small">do<span>do</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
|
||||||
|
<nav class="primary-nav">
|
||||||
|
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
|
||||||
|
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
|
||||||
|
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 天</button>
|
||||||
|
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||||
|
</nav>
|
||||||
|
<div class="section-title"><span>我的清单</span><span><button class="mini-icon" aria-label="新建文件夹" @click="createFolder"><Folder /></button><button class="mini-icon" aria-label="新建清单" @click="createList(null)"><Plus /></button></span></div>
|
||||||
|
<div class="folders">
|
||||||
|
<div v-for="folder in folders" :key="folder.id" class="folder-block">
|
||||||
|
<div class="folder-row"><button @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/>{{folder.name}}</button><span class="row-actions"><button aria-label="重命名文件夹" @click="renameEntity('folders',folder)"><Pencil/></button><button aria-label="删除文件夹" @click="deleteEntity('folders',folder)"><Trash2/></button><button aria-label="在文件夹中新建清单" @click="createList(folder.id)"><Plus/></button></span></div>
|
||||||
|
<button v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="删除清单" @click.stop="deleteEntity('lists',list)"><Trash2/></button></span></button>
|
||||||
|
</div>
|
||||||
|
<button v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="删除清单" @click.stop="deleteEntity('lists',list)"><Trash2/></button></span></button>
|
||||||
|
</div>
|
||||||
|
<button class="settings"><Settings />设置</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="icon mobile-only" aria-label="打开菜单" @click="mobileSidebar=true"><Menu /></button>
|
||||||
|
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
|
||||||
|
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
||||||
|
</header>
|
||||||
|
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
|
||||||
|
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>{{visibleTasks.length}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
|
||||||
|
<div v-if="selectedCount" class="batch-bar"><b>已选 {{selectedCount}} 项</b><button @click="batch('complete')"><Check/>完成</button><button @click="batch('move')"><Folder/>移动</button><button class="danger" @click="batch('delete')"><Trash2/>删除</button><button class="icon" aria-label="取消选择" @click="selectedIds=new Set()"><X/></button></div>
|
||||||
|
<section class="task-list" :class="{loading}">
|
||||||
|
<template v-for="node in taskTree" :key="node.task.id">
|
||||||
|
<article class="task-row" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id}">
|
||||||
|
<button v-if="activeView!=='trash'" class="select-box" :class="{checked:selectedIds.has(node.task.id)}" aria-label="选择任务" @click.stop="toggleSelect(node.task.id)"><Check v-if="selectedIds.has(node.task.id)"/></button>
|
||||||
|
<button v-if="activeView!=='trash'" class="check" :class="`p${node.task.priority}`" aria-label="切换完成状态" @click.stop="toggle(node.task)"><Check v-if="node.task.completed"/></button>
|
||||||
|
<button class="task-main" @click="activeView==='trash'?undefined:selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span><i v-for="tag in node.task.tags" :key="tag.id" class="tag-dot" :style="{background:tag.color}" :title="tag.name"/></span></button>
|
||||||
|
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span>
|
||||||
|
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
|
||||||
|
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
|
||||||
|
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
|
||||||
|
</article>
|
||||||
|
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask" :class="{done:subtask.completed}"><GripVertical/><button class="check" @click="toggle(subtask)"><Check v-if="subtask.completed"/></button><button class="task-main" @click="selectTask(subtask)"><strong>{{subtask.title}}</strong></button></article>
|
||||||
|
</template>
|
||||||
|
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="detail" :class="{open:mobileDetail}">
|
||||||
|
<div class="detail-head"><span>任务详情</span><button class="icon mobile-only" aria-label="关闭详情" @click="mobileDetail=false"><X/></button></div>
|
||||||
|
<div v-if="selectedTask" class="detail-form">
|
||||||
|
<div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题" @blur="saveTask"/></div>
|
||||||
|
<label>清单<select v-model="selectedTask.list_id" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
||||||
|
<label>截止时间<input :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
|
||||||
|
<label>优先级<select v-model.number="selectedTask.priority" @change="saveTask"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label>
|
||||||
|
<div class="field"><div class="field-label"><span>标签</span><button class="link" @click="createTag"><Plus/>新建</button></div><div class="tag-picker"><button v-for="tag in tags" :key="tag.id" :class="{chosen:taskHasTag(tag)}" @click="toggleTag(tag);saveTask()"><i :style="{background:tag.color}"/>{{tag.name}}</button><span v-if="!tags.length" class="hint">还没有标签</span></div></div>
|
||||||
|
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown…" @blur="saveTask"/></div>
|
||||||
|
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in tasks.filter(t=>t.parent_id===selectedTask?.id)" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!tasks.some(t=>t.parent_id===selectedTask?.id)" class="hint">把这件事拆成更小的步骤</span></div>
|
||||||
|
<div class="detail-actions"><button class="secondary" @click="saveTask">保存更改</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期、优先级、标签、子任务和 Markdown 备注会出现在这里。</p></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='upcoming'}" @click="switchView('upcoming')"><CalendarDays/><span>计划</span></button><button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2/><span>回收站</span></button></nav>
|
||||||
|
<button v-if="activeView!=='trash'" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
|
||||||
|
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { filterTasks, groupTaskTree, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||||
|
|
||||||
|
type SearchTask = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
parent_id?: string | null
|
||||||
|
completed?: boolean
|
||||||
|
tags?: { name: string }[]
|
||||||
|
list_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: SearchTask[] = [
|
||||||
|
{ id: '1', title: 'Write release notes', description: 'mention **API**', parent_id: null, completed: false },
|
||||||
|
{ id: '2', title: 'Check links', description: '', parent_id: '1', completed: false },
|
||||||
|
{ id: '3', title: 'Buy milk', description: '', parent_id: null, completed: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('task utilities', () => {
|
||||||
|
it('filters task titles and markdown descriptions case-insensitively', () => {
|
||||||
|
expect(filterTasks(tasks, 'api').map((task) => task.id)).toEqual(['1'])
|
||||||
|
expect(filterTasks(tasks, 'WRITE').map((task) => task.id)).toEqual(['1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a one-level subtask tree without duplicating children', () => {
|
||||||
|
expect(groupTaskTree(tasks)).toEqual([{ task: tasks[0], subtasks: [tasks[1]] }, { task: tasks[2], subtasks: [] }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders safe basic markdown and strips unsafe html', () => {
|
||||||
|
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||||
|
expect(html).toContain('<h1>Plan</h1>')
|
||||||
|
expect(html).toContain('<strong>bold</strong>')
|
||||||
|
expect(html).toContain('rel="noopener noreferrer"')
|
||||||
|
expect(html).not.toContain('<script>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters titles, descriptions, tags, and list names', () => {
|
||||||
|
const searchable: SearchTask[] = [
|
||||||
|
...tasks,
|
||||||
|
{ id: '4', title: 'Plan', tags: [{ name: '重要' }], list_name: '工作清单' },
|
||||||
|
]
|
||||||
|
expect(filterTasks(searchable, '重要').map((task) => task.id)).toEqual(['4'])
|
||||||
|
expect(filterTasks(searchable, '工作').map((task) => task.id)).toEqual(['4'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats API dates in local wall-clock time', () => {
|
||||||
|
const original = process.env.TZ
|
||||||
|
process.env.TZ = 'Asia/Shanghai'
|
||||||
|
expect(toDateTimeLocal('2026-09-05T12:30:00Z')).toBe('2026-09-05T20:30')
|
||||||
|
process.env.TZ = original
|
||||||
|
expect(toDateTimeLocal(null)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
export type MinimalTask = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
parent_id?: string | null
|
||||||
|
completed?: boolean
|
||||||
|
tags?: { name: string }[]
|
||||||
|
list_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
const renderInline = (value: string) => {
|
||||||
|
let out = escapeHtml(value)
|
||||||
|
out = out.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||||
|
out = out.replace(
|
||||||
|
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||||
|
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdown(markdown = '') {
|
||||||
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
||||||
|
const html: string[] = []
|
||||||
|
let inList = false
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (inList) {
|
||||||
|
html.push('</ul>')
|
||||||
|
inList = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trimEnd()
|
||||||
|
if (!line.trim()) {
|
||||||
|
closeList()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.startsWith('# ')) {
|
||||||
|
closeList()
|
||||||
|
html.push(`<h1>${renderInline(line.slice(2))}</h1>`)
|
||||||
|
} else if (line.startsWith('## ')) {
|
||||||
|
closeList()
|
||||||
|
html.push(`<h2>${renderInline(line.slice(3))}</h2>`)
|
||||||
|
} else if (/^[-*] /.test(line)) {
|
||||||
|
if (!inList) {
|
||||||
|
html.push('<ul>')
|
||||||
|
inList = true
|
||||||
|
}
|
||||||
|
html.push(`<li>${renderInline(line.slice(2))}</li>`)
|
||||||
|
} else {
|
||||||
|
closeList()
|
||||||
|
html.push(`<p>${renderInline(line)}</p>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeList()
|
||||||
|
return html.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterTasks<T extends MinimalTask>(tasks: T[], query: string) {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return tasks
|
||||||
|
return tasks.filter((task) => {
|
||||||
|
const haystack = [
|
||||||
|
task.title,
|
||||||
|
task.description ?? '',
|
||||||
|
task.list_name ?? '',
|
||||||
|
...(task.tags ?? []).map((tag) => tag.name),
|
||||||
|
].join(' ')
|
||||||
|
return haystack.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
|
||||||
|
const children = new Map<string, T[]>()
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!task.parent_id) continue
|
||||||
|
children.set(task.parent_id, [...(children.get(task.parent_id) ?? []), task])
|
||||||
|
}
|
||||||
|
return tasks.filter((task) => !task.parent_id).map((task) => ({ task, subtasks: children.get(task.id) ?? [] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateTimeLocal(value: string | null | undefined) {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.valueOf())) return ''
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
const hour = `${date.getHours()}`.padStart(2, '0')
|
||||||
|
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}T${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromDateTimeLocal(value: string) {
|
||||||
|
if (!value) return null
|
||||||
|
return new Date(value).toISOString()
|
||||||
|
}
|
||||||
+11
-2
@@ -1,3 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:#29251f;background:#f8f3e8;--accent:#f15a29;--line:#e8dfcf}*{box-sizing:border-box}body{margin:0}button,input{font:inherit}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.auth-card{width:min(390px,90vw);background:#fffdf7;padding:36px;border:1px solid var(--line);border-radius:12px;box-shadow:0 12px 40px #5d4b3320;display:grid;gap:14px}.brand{font-size:38px;font-weight:800;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:28px;padding:22px}.auth-card input,.quick{border:1px solid var(--line);border-radius:10px;background:white}.auth-card input{padding:12px}.auth-card button,.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:11px 16px;font-weight:650}.auth-card small{color:#c0341d}.shell{height:100vh;display:grid;grid-template-columns:230px minmax(400px,1fr) 310px;background:#fffdf8}.shell>aside{border-right:1px solid var(--line);background:#f8f3e8;display:flex;flex-direction:column}.shell nav,.lists{display:grid;padding:8px 12px;gap:3px}.shell nav button,.lists button,.settings{display:flex;gap:10px;align-items:center;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#625c52;text-align:left}.shell nav svg,.settings svg{width:18px}.shell nav .active{background:#f3dfd2;color:#b73d18;font-weight:650}.lists label{font-size:12px;color:#9a9184;padding:15px 12px 5px}.lists i{width:9px;height:9px;background:#df9c61;border-radius:3px}.settings{margin:auto 12px 15px}main{min-width:0;padding:34px 38px;overflow:auto}header{display:flex;align-items:center;justify-content:space-between}header p{margin:0;color:#9a9184;font-size:13px}h1{font-size:28px;margin:4px 0 25px}.search{display:flex;gap:8px;border:1px solid var(--line);background:#faf7f0;color:#777065;padding:8px 12px;border-radius:10px}.search svg{width:16px}.quick{display:flex;align-items:center;padding:6px 7px 6px 13px;box-shadow:0 4px 18px #5d4b330d}.quick svg{color:var(--accent);width:20px}.quick input{flex:1;border:0;outline:0;padding:10px;background:transparent}.task-list{margin-top:22px;border-top:1px solid var(--line)}article{display:flex;align-items:center;gap:12px;min-height:54px;border-bottom:1px solid var(--line);transition:.2s}article.done{opacity:.45}article.done strong{text-decoration:line-through}.check{width:20px;height:20px;border:1.5px solid #b8ad9e;border-radius:50%;background:white;padding:2px;color:white}.done .check{background:var(--accent);border-color:var(--accent)}.check svg{width:13px;height:13px}.priority{margin-left:auto;color:var(--accent);font-size:12px}.empty{padding:90px 20px;display:grid;place-items:center;color:#a59b8d;gap:9px}.empty svg{width:42px;height:42px;stroke-width:1}.empty b{color:#6e675d}.detail{border-left:1px solid var(--line)!important;border-right:0!important;padding:28px 22px}.detail>span{color:#8f867a;font-size:13px}.paper{margin-top:22px;background:#fffaf0;border:1px dashed #dfd2be;border-radius:12px;padding:22px}.paper p{color:#948a7c;font-size:14px;line-height:1.6}.bottom,.fab,.mobile-menu{display:none}
|
|
||||||
@media(max-width:800px){.shell{display:block;height:auto;min-height:100vh}.shell>aside,.detail{display:none}main{padding:24px 18px 100px}.search{font-size:0}.bottom{display:flex;position:fixed;bottom:0;left:0;right:0;background:#fffdf8;border-top:1px solid var(--line);justify-content:space-around;padding:8px 0 max(8px,env(safe-area-inset-bottom))}.bottom button{border:0;background:transparent;color:#766e62;display:grid;place-items:center;font-size:11px;gap:2px}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;right:20px;bottom:78px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:white;box-shadow:0 8px 20px #f15a2950}.mobile-menu{display:block;border:0;background:transparent;padding:0;margin-right:12px}header>div{margin-right:auto}.quick button{display:none}}
|
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Noto Sans CJK SC",sans-serif;color:#2d2924;background:#f8f3e8;--accent:#f15a29;--accent-soft:#fbe6dc;--paper:#fffdf8;--sidebar:#f6f0e4;--line:#e7ddcc;--muted:#8b8275;--danger:#bd3827;--shadow:0 12px 36px rgba(78,58,34,.10)}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer}svg{width:18px;height:18px;stroke-width:1.8}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.center{gap:12px;align-content:center;color:var(--muted)}.loader{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.auth-shell{background:radial-gradient(circle at 20% 10%,#ffe3d5 0,transparent 28%),linear-gradient(135deg,#f8f3e8,#fffaf0)}.auth-card{width:min(390px,90vw);padding:38px;background:var(--paper);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);display:grid;gap:16px}.brand{font-size:40px;font-weight:850;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:29px}.auth-card p{margin:0 0 8px;color:var(--muted)}.auth-card label,.detail-form>label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.auth-card input,.detail-form input,.detail-form select{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none}.auth-card input:focus,.detail-form input:focus,.detail-form select:focus,.detail-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.primary{border:0;background:var(--accent);color:#fff;padding:12px;border-radius:9px;font-weight:700;box-shadow:0 5px 12px rgba(241,90,41,.2)}.auth-card small{color:var(--danger)}
|
||||||
|
.shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 350px;background:var(--paper);overflow:hidden}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);display:flex;flex-direction:column;min-height:0}.brand-row{height:76px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.primary-nav{display:grid;padding:4px 12px 12px;gap:3px}.primary-nav button,.settings{display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#665e52;text-align:left}.primary-nav button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.52)}.primary-nav button.active,.list-row.active{background:var(--accent-soft);color:#b7421e;font-weight:700}.section-title{padding:17px 17px 7px 22px;display:flex;justify-content:space-between;align-items:center;color:#958b7d;text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:750}.section-title>span:last-child{display:flex}.mini-icon,.row-actions button{border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{padding:0 10px;overflow:auto}.folder-row{display:flex;align-items:center}.folder-row>button{flex:1;min-width:0;border:0;background:transparent;padding:8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button svg{width:14px}.row-actions{display:flex;opacity:0;transition:opacity .15s}.folder-row:hover .row-actions,.list-row:hover .row-actions{opacity:1}.list-row{width:100%;border:0;background:transparent;padding:8px 7px 8px 31px;border-radius:8px;display:flex;align-items:center;gap:9px;text-align:left;color:#665f55}.list-row>span:nth-child(2){flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.list-row i{width:8px;height:8px;background:#d89b62;border-radius:3px}.settings{margin:auto 12px 14px}.mobile-only,.bottom,.fab{display:none}
|
||||||
|
main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.batch-bar{position:sticky;top:-12px;z-index:5;display:flex;align-items:center;gap:7px;padding:8px 10px;margin-bottom:8px;border:1px solid #f3c2ae;border-radius:10px;background:#fff7f2;box-shadow:0 8px 18px rgba(96,62,40,.08)}.batch-bar b{font-size:13px;margin-right:auto}.batch-bar>button:not(.icon){display:flex;gap:4px;align-items:center;border:0;background:#fff;border-radius:7px;padding:6px 9px}.batch-bar svg{width:14px}.batch-bar .danger{color:var(--danger)}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .15s}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(2px)}.select-box,.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.select-box{width:16px;height:16px;flex-basis:16px;border-radius:3px;opacity:.2}.task-row:hover .select-box,.select-box.checked{opacity:1}.select-box.checked{background:#796f63;color:#fff}.check svg,.select-box svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.tag-dot{width:7px;height:7px;border-radius:50%}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px}
|
||||||
|
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
||||||
|
.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||||
|
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
|
||||||
|
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.select-box{opacity:.45}.row-actions{opacity:1}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.batch-bar{overflow:auto}.batch-bar b{white-space:nowrap}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""task management completion
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0002"
|
||||||
|
down_revision = "0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("folders", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("task_lists", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.create_table(
|
||||||
|
"tags",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(80), nullable=False),
|
||||||
|
sa.Column("color", sa.String(32), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("user_id", "name", name="uq_tags_user_id_name"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tags_user_id", "tags", ["user_id"])
|
||||||
|
op.create_table(
|
||||||
|
"task_tags",
|
||||||
|
sa.Column("task_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("tag_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("task_id", "tag_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tasks_cursor", "tasks", ["user_id", "created_at", "id"])
|
||||||
|
op.create_index("ix_tasks_deleted_at", "tasks", ["deleted_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_tasks_deleted_at", table_name="tasks")
|
||||||
|
op.drop_index("ix_tasks_cursor", table_name="tasks")
|
||||||
|
op.drop_table("task_tags")
|
||||||
|
op.drop_index("ix_tags_user_id", table_name="tags")
|
||||||
|
op.drop_table("tags")
|
||||||
|
op.drop_column("task_lists", "deleted_at")
|
||||||
|
op.drop_column("folders", "deleted_at")
|
||||||
@@ -128,3 +128,200 @@ def test_logout_revokes_current_session(client):
|
|||||||
assert client.get("/api/v1/me").status_code == 200
|
assert client.get("/api/v1/me").status_code == 200
|
||||||
assert client.post("/api/v1/auth/logout").status_code == 204
|
assert client.post("/api/v1/auth/logout").status_code == 204
|
||||||
assert client.get("/api/v1/me").status_code == 401
|
assert client.get("/api/v1/me").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def initialized_client(client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/setup/initialize",
|
||||||
|
json={"username": "owner", "password": "correct horse battery staple"},
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_and_list_lifecycle(client):
|
||||||
|
client = initialized_client(client)
|
||||||
|
folder = client.post("/api/v1/folders", json={"name": "工作"}).json()
|
||||||
|
renamed = client.patch(f"/api/v1/folders/{folder['id']}", json={"name": "生活"})
|
||||||
|
assert renamed.status_code == 200
|
||||||
|
assert renamed.json()["name"] == "生活"
|
||||||
|
|
||||||
|
task_list = client.post(
|
||||||
|
"/api/v1/lists", json={"name": "采购", "folder_id": folder["id"]}
|
||||||
|
).json()
|
||||||
|
renamed_list = client.patch(f"/api/v1/lists/{task_list['id']}", json={"name": "购物"})
|
||||||
|
assert renamed_list.status_code == 200
|
||||||
|
assert renamed_list.json()["name"] == "购物"
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
assert client.delete(f"/api/v1/folders/{folder['id']}").status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_search_tags_subtasks_and_recycle_bin(client):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
tag = client.post("/api/v1/tags", json={"name": "重要", "color": "#f15a29"}).json()
|
||||||
|
parent = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "购买咖啡豆", "description": "手冲用", "list_id": inbox["id"], "tag_ids": [tag["id"]]},
|
||||||
|
).json()
|
||||||
|
child = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "比较价格", "list_id": inbox["id"], "parent_id": parent["id"]},
|
||||||
|
)
|
||||||
|
assert child.status_code == 201
|
||||||
|
detail = client.get(f"/api/v1/tasks/{parent['id']}")
|
||||||
|
assert detail.status_code == 200
|
||||||
|
assert detail.json()["tags"][0]["name"] == "重要"
|
||||||
|
assert len(detail.json()["subtasks"]) == 1
|
||||||
|
assert len(client.get("/api/v1/tasks", params={"q": "咖啡"}).json()["items"]) == 1
|
||||||
|
|
||||||
|
assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204
|
||||||
|
trash = client.get("/api/v1/trash").json()["items"]
|
||||||
|
assert len(trash) == 1
|
||||||
|
assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_complete_and_move(client):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
other = client.post("/api/v1/lists", json={"name": "稍后"}).json()
|
||||||
|
ids = [
|
||||||
|
client.post("/api/v1/tasks", json={"title": f"任务{i}", "list_id": inbox["id"]}).json()["id"]
|
||||||
|
for i in range(2)
|
||||||
|
]
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/tasks/batch", json={"task_ids": ids, "completed": True, "list_id": other["id"]}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["updated"] == 2
|
||||||
|
rows = client.get("/api/v1/tasks").json()["items"]
|
||||||
|
assert all(row["completed"] and row["list_id"] == other["id"] for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbox_is_protected_and_deleted_collections_are_hidden(client):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
assert client.patch(f"/api/v1/lists/{inbox['id']}", json={"name": "x"}).status_code == 409
|
||||||
|
assert client.delete(f"/api/v1/lists/{inbox['id']}").status_code == 409
|
||||||
|
|
||||||
|
folder = client.post("/api/v1/folders", json={"name": "项目"}).json()
|
||||||
|
task_list = client.post(
|
||||||
|
"/api/v1/lists", json={"name": "待办", "folder_id": folder["id"]}
|
||||||
|
).json()
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "保留任务", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
assert all(row["id"] != task_list["id"] for row in client.get("/api/v1/lists").json())
|
||||||
|
moved = client.get(f"/api/v1/tasks/{task['id']}").json()
|
||||||
|
assert moved["list_id"] == inbox["id"]
|
||||||
|
assert client.delete(f"/api/v1/folders/{folder['id']}").status_code == 204
|
||||||
|
assert client.get("/api/v1/folders").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_subtasks_are_rejected(client):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json()
|
||||||
|
child = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "子", "list_id": inbox["id"], "parent_id": parent["id"]},
|
||||||
|
).json()
|
||||||
|
nested = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "孙", "list_id": inbox["id"], "parent_id": child["id"]},
|
||||||
|
)
|
||||||
|
assert nested.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_tags_are_global_searchable_and_validated(client):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
errands = client.post("/api/v1/lists", json={"name": "跑腿清单"}).json()
|
||||||
|
tag = client.post("/api/v1/tags", json={"name": "蓝色标签", "color": "#123abc"})
|
||||||
|
assert tag.status_code == 201
|
||||||
|
assert client.get("/api/v1/tags").json()[0]["color"] == "#123abc"
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "普通标题", "description": "特殊说明", "list_id": errands["id"], "tag_ids": [tag.json()["id"]]},
|
||||||
|
).json()
|
||||||
|
for query in ("普通", "特殊", "蓝色", "跑腿"):
|
||||||
|
assert [row["id"] for row in client.get("/api/v1/tasks", params={"q": query}).json()["items"]] == [task["id"]]
|
||||||
|
invalid = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "非法标签", "list_id": inbox["id"], "tag_ids": ["00000000-0000-0000-0000-000000000001"]},
|
||||||
|
)
|
||||||
|
assert invalid.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json()
|
||||||
|
client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "子", "list_id": inbox["id"], "parent_id": parent["id"]},
|
||||||
|
)
|
||||||
|
client.delete(f"/api/v1/tasks/{parent['id']}")
|
||||||
|
assert len(client.get("/api/v1/trash").json()["items"]) == 1
|
||||||
|
restored = client.post(f"/api/v1/tasks/{parent['id']}/restore")
|
||||||
|
assert restored.status_code == 200
|
||||||
|
assert len(client.get(f"/api/v1/tasks/{parent['id']}").json()["subtasks"]) == 1
|
||||||
|
client.delete(f"/api/v1/tasks/{parent['id']}")
|
||||||
|
assert client.delete(f"/api/v1/trash/{parent['id']}").status_code == 204
|
||||||
|
assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_supports_due_date_tags_and_soft_delete_atomically(client):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
tag = client.post("/api/v1/tags", json={"name": "批量", "color": "red"}).json()
|
||||||
|
ids = [
|
||||||
|
client.post("/api/v1/tasks", json={"title": str(i), "list_id": inbox["id"]}).json()["id"]
|
||||||
|
for i in range(2)
|
||||||
|
]
|
||||||
|
due_at = "2026-09-10T08:00:00+08:00"
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/tasks/batch",
|
||||||
|
json={"task_ids": ids, "due_at": due_at, "tag_ids": [tag["id"]]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200 and response.json()["updated"] == 2
|
||||||
|
assert all(client.get(f"/api/v1/tasks/{task_id}").json()["tags"] for task_id in ids)
|
||||||
|
|
||||||
|
parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json()
|
||||||
|
client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "子", "list_id": inbox["id"], "parent_id": parent["id"]},
|
||||||
|
)
|
||||||
|
other = client.post("/api/v1/lists", json={"name": "批量目标"}).json()
|
||||||
|
assert client.post(
|
||||||
|
"/api/v1/tasks/batch", json={"task_ids": [parent["id"]], "list_id": other["id"]}
|
||||||
|
).status_code == 200
|
||||||
|
moved = client.get(f"/api/v1/tasks/{parent['id']}").json()
|
||||||
|
assert moved["subtasks"][0]["list_id"] == other["id"]
|
||||||
|
|
||||||
|
failed = client.post(
|
||||||
|
"/api/v1/tasks/batch",
|
||||||
|
json={"task_ids": [ids[0], "00000000-0000-0000-0000-000000000001"], "completed": True},
|
||||||
|
)
|
||||||
|
assert failed.status_code == 404
|
||||||
|
assert client.get(f"/api/v1/tasks/{ids[0]}").json()["completed"] is False
|
||||||
|
assert client.post("/api/v1/tasks/batch", json={"task_ids": ids, "soft_delete": True}).status_code == 200
|
||||||
|
assert len(client.get("/api/v1/trash").json()["items"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_pagination_is_stable_and_rejects_bad_cursor(client):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
for i in range(5):
|
||||||
|
client.post("/api/v1/tasks", json={"title": f"任务{i}", "list_id": inbox["id"]})
|
||||||
|
first = client.get("/api/v1/tasks", params={"limit": 2}).json()
|
||||||
|
second = client.get(
|
||||||
|
"/api/v1/tasks", params={"limit": 2, "cursor": first["next_cursor"]}
|
||||||
|
).json()
|
||||||
|
third = client.get(
|
||||||
|
"/api/v1/tasks", params={"limit": 2, "cursor": second["next_cursor"]}
|
||||||
|
).json()
|
||||||
|
ids = [row["id"] for page in (first, second, third) for row in page["items"]]
|
||||||
|
assert len(ids) == len(set(ids)) == 5
|
||||||
|
assert third["next_cursor"] is None
|
||||||
|
assert client.get("/api/v1/tasks", params={"cursor": "broken"}).status_code == 422
|
||||||
|
|||||||
Reference in New Issue
Block a user