feat: bootstrap dodo phase one
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
__pycache__
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DODO_DATABASE_URL=postgresql+asyncpg://postgres:[email protected]:5432/dodo
|
||||||
|
DODO_COOKIE_SECURE=true
|
||||||
|
DODO_SESSION_DAYS=30
|
||||||
|
DODO_TRUSTED_PROXIES=127.0.0.1
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.DS_Store
|
||||||
|
*.db
|
||||||
|
uploads/
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable && pnpm install --frozen-lockfile
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
COPY pyproject.toml uv.lock alembic.ini ./
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
COPY backend backend
|
||||||
|
COPY worker worker
|
||||||
|
COPY migrations migrations
|
||||||
|
COPY scripts scripts
|
||||||
|
COPY --from=frontend /build/dist backend/static
|
||||||
|
EXPOSE 8781
|
||||||
|
CMD ["sh", "scripts/start.sh"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2026 dodo contributors
|
||||||
|
|
||||||
|
This project is licensed under the GNU Affero General Public License v3.0.
|
||||||
|
The full license text is available at https://www.gnu.org/licenses/agpl-3.0.txt
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# dodo
|
||||||
|
|
||||||
|
一个自托管的任务与习惯管理工具,目标是做一个温暖、紧凑、可自己掌控数据的 TickTick-like 应用。
|
||||||
|
|
||||||
|
## 第一阶段能力
|
||||||
|
|
||||||
|
- 首次初始化管理员
|
||||||
|
- 用户名密码登录,Cookie Session
|
||||||
|
- 文件夹、清单、任务基础 CRUD
|
||||||
|
- 收集箱系统清单
|
||||||
|
- Vue 3 + PWA 应用外壳
|
||||||
|
- 手账生活感浅色 UI
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Frontend: Vue 3 + TypeScript + Vite + Tailwind CSS
|
||||||
|
- Backend: FastAPI + SQLAlchemy 2 Async
|
||||||
|
- DB: PostgreSQL(测试环境使用 SQLite)
|
||||||
|
- Package: uv + pnpm
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bboysoul/PycharmProjects/dodo
|
||||||
|
uv sync
|
||||||
|
uv run uvicorn backend.main:app --host 0.0.0.0 --port 8781
|
||||||
|
|
||||||
|
cd frontend
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DODO_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dodo
|
||||||
|
DODO_COOKIE_SECURE=false
|
||||||
|
DODO_SESSION_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest -q
|
||||||
|
uv run ruff check backend tests
|
||||||
|
cd frontend && pnpm run build
|
||||||
|
```
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql+asyncpg://dodo:dodo@localhost:5432/dodo
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import InvalidHashError, VerificationError
|
||||||
|
from fastapi import Cookie, Depends, HTTPException, Response, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .db import get_db
|
||||||
|
from .models import Session, User
|
||||||
|
|
||||||
|
password_hasher = PasswordHasher()
|
||||||
|
COOKIE_NAME = "dodo_session"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return password_hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password_hash: str, password: str) -> bool:
|
||||||
|
try:
|
||||||
|
return password_hasher.verify(password_hash, password)
|
||||||
|
except (InvalidHashError, VerificationError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def issue_session(db: AsyncSession, response: Response, user: User) -> None:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires = datetime.now(UTC) + timedelta(days=get_settings().session_days)
|
||||||
|
db.add(Session(token_hash=hash_token(token), user_id=user.id, expires_at=expires))
|
||||||
|
await db.commit()
|
||||||
|
response.set_cookie(
|
||||||
|
COOKIE_NAME, token, max_age=get_settings().session_days * 86400,
|
||||||
|
httponly=True, secure=get_settings().cookie_secure, samesite="lax", path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def session_token(
|
||||||
|
token: str | None = Cookie(default=None, alias=COOKIE_NAME),
|
||||||
|
) -> str:
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def current_user(
|
||||||
|
token: str = Depends(session_token),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).join(Session).where(
|
||||||
|
Session.token_hash == hash_token(token), Session.expires_at > datetime.now(UTC)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已失效,请重新登录")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
app_name: str = "dodo"
|
||||||
|
database_url: str = "postgresql+asyncpg://dodo:dodo@localhost:5432/dodo"
|
||||||
|
session_days: int = 30
|
||||||
|
cookie_secure: bool = False
|
||||||
|
trusted_proxies: str = ""
|
||||||
|
auto_create_schema: bool = False
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="DODO_", env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
global _engine, _session_factory
|
||||||
|
if _engine is None:
|
||||||
|
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
||||||
|
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def reset_engine() -> None:
|
||||||
|
global _engine, _session_factory
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||||
|
get_engine()
|
||||||
|
assert _session_factory is not None
|
||||||
|
async with _session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def create_schema() -> None:
|
||||||
|
from . import models # noqa: F401
|
||||||
|
async with get_engine().begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Response
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .auth import (
|
||||||
|
COOKIE_NAME,
|
||||||
|
current_user,
|
||||||
|
hash_password,
|
||||||
|
hash_token,
|
||||||
|
issue_session,
|
||||||
|
session_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from .db import create_schema, get_db
|
||||||
|
from .models import AppState, Folder, Session, Task, TaskList, User, utcnow
|
||||||
|
from .schemas import (
|
||||||
|
FolderCreate,
|
||||||
|
FolderOut,
|
||||||
|
InitializeRequest,
|
||||||
|
ListCreate,
|
||||||
|
ListOut,
|
||||||
|
LoginRequest,
|
||||||
|
TaskCreate,
|
||||||
|
TaskOut,
|
||||||
|
TaskPage,
|
||||||
|
TaskUpdate,
|
||||||
|
UserOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
if get_settings().auto_create_schema:
|
||||||
|
await create_schema()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="dodo", version="0.1.0", lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/live")
|
||||||
|
async def live():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/ready")
|
||||||
|
async def ready(db: AsyncSession = Depends(get_db)):
|
||||||
|
await db.execute(select(1))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/setup/status")
|
||||||
|
async def setup_status(db: AsyncSession = Depends(get_db)):
|
||||||
|
count = await db.scalar(select(func.count()).select_from(User))
|
||||||
|
return {"initialized": bool(count)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/setup/initialize", response_model=UserOut, status_code=201)
|
||||||
|
async def initialize(payload: InitializeRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
||||||
|
count = await db.scalar(select(func.count()).select_from(User))
|
||||||
|
if count:
|
||||||
|
raise HTTPException(status_code=409, detail="系统已经初始化")
|
||||||
|
db.add(AppState(key="initialized"))
|
||||||
|
user = User(username=payload.username, password_hash=hash_password(payload.password))
|
||||||
|
db.add(user)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="系统已经初始化") from exc
|
||||||
|
db.add(TaskList(user_id=user.id, name="收集箱", is_inbox=True))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
await issue_session(db, response, user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/auth/login", response_model=UserOut)
|
||||||
|
async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
||||||
|
user = await db.scalar(select(User).where(User.username == payload.username))
|
||||||
|
if user is None or not verify_password(user.password_hash, payload.password):
|
||||||
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
|
await issue_session(db, response, user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/me", response_model=UserOut)
|
||||||
|
async def me(user: User = Depends(current_user)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/auth/logout", status_code=204)
|
||||||
|
async def logout(
|
||||||
|
response: Response,
|
||||||
|
token: str = Depends(session_token),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
session = await db.scalar(select(Session).where(Session.token_hash == hash_token(token)))
|
||||||
|
if session is not None:
|
||||||
|
await db.delete(session)
|
||||||
|
await db.commit()
|
||||||
|
response.delete_cookie(COOKIE_NAME, path="/")
|
||||||
|
return Response(status_code=204, headers=response.headers)
|
||||||
|
|
||||||
|
|
||||||
|
@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)):
|
||||||
|
folder = Folder(user_id=user.id, name=payload.name)
|
||||||
|
db.add(folder); await db.commit(); await db.refresh(folder)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/folders", response_model=list[FolderOut])
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
@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)):
|
||||||
|
if payload.folder_id and not await db.scalar(select(Folder.id).where(Folder.id == payload.folder_id, Folder.user_id == user.id)):
|
||||||
|
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||||
|
item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name)
|
||||||
|
db.add(item); await db.commit(); await db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/lists", response_model=list[ListOut])
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
@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)):
|
||||||
|
if not await db.scalar(select(TaskList.id).where(TaskList.id == payload.list_id, TaskList.user_id == user.id)):
|
||||||
|
raise HTTPException(status_code=404, detail="清单不存在")
|
||||||
|
if payload.parent_id:
|
||||||
|
parent = await db.scalar(
|
||||||
|
select(Task).where(
|
||||||
|
Task.id == payload.parent_id,
|
||||||
|
Task.user_id == user.id,
|
||||||
|
Task.list_id == payload.list_id,
|
||||||
|
Task.parent_id.is_(None),
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if parent is None:
|
||||||
|
raise HTTPException(status_code=400, detail="父任务必须属于同一清单")
|
||||||
|
task = Task(user_id=user.id, **payload.model_dump())
|
||||||
|
db.add(task); await db.commit(); await db.refresh(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/tasks", response_model=TaskPage)
|
||||||
|
async def list_tasks(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
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())
|
||||||
|
return TaskPage(items=items)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/tasks/{task_id}", response_model=TaskOut)
|
||||||
|
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)
|
||||||
|
expected_version = data.pop("version")
|
||||||
|
data["version"] = Task.version + 1
|
||||||
|
data["updated_at"] = utcnow()
|
||||||
|
result = await db.execute(
|
||||||
|
update(Task)
|
||||||
|
.where(
|
||||||
|
Task.id == task_id,
|
||||||
|
Task.user_id == user.id,
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
Task.version == expected_version,
|
||||||
|
)
|
||||||
|
.values(**data)
|
||||||
|
.returning(Task)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
exists = await db.scalar(
|
||||||
|
select(Task.id).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
await db.commit()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@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)):
|
||||||
|
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="任务不存在")
|
||||||
|
task.deleted_at = utcnow()
|
||||||
|
task.version += 1
|
||||||
|
await db.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
if static_dir.exists():
|
||||||
|
app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
|
||||||
|
|
||||||
|
@app.get("/{path:path}", include_in_schema=False)
|
||||||
|
async def spa(path: str):
|
||||||
|
root = static_dir.resolve()
|
||||||
|
target = (root / path).resolve()
|
||||||
|
if target.is_file() and target.is_relative_to(root):
|
||||||
|
return FileResponse(target)
|
||||||
|
return FileResponse(root / "index.html")
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from uuid_utils import uuid7
|
||||||
|
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def new_id() -> UUID:
|
||||||
|
return UUID(str(uuid7()))
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class AppState(Base):
|
||||||
|
__tablename__ = "app_state"
|
||||||
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(Text)
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Shanghai")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Folder(Base):
|
||||||
|
__tablename__ = "folders"
|
||||||
|
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(120))
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskList(Base):
|
||||||
|
__tablename__ = "task_lists"
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
folder_id: Mapped[UUID | None] = mapped_column(ForeignKey("folders.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(120))
|
||||||
|
is_inbox: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Task(Base):
|
||||||
|
__tablename__ = "tasks"
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
list_id: Mapped[UUID] = mapped_column(ForeignKey("task_lists.id", ondelete="CASCADE"), index=True)
|
||||||
|
parent_id: Mapped[UUID | None] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(500))
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
completed: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class InitializeRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||||
|
password: str = Field(min_length=12, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class FolderCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class FolderOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ListCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
folder_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ListOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
folder_id: UUID | None
|
||||||
|
name: str
|
||||||
|
is_inbox: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreate(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=500)
|
||||||
|
list_id: UUID
|
||||||
|
description: str = ""
|
||||||
|
priority: int = Field(default=0, ge=0, le=3)
|
||||||
|
due_at: datetime | None = None
|
||||||
|
parent_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskUpdate(BaseModel):
|
||||||
|
title: str = Field(default=None, min_length=1, max_length=500)
|
||||||
|
description: str = Field(default=None)
|
||||||
|
priority: int | None = Field(default=None, ge=0, le=3)
|
||||||
|
due_at: datetime | None = None
|
||||||
|
completed: bool | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
list_id: UUID
|
||||||
|
parent_id: UUID | None
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
priority: int
|
||||||
|
completed: bool
|
||||||
|
due_at: datetime | None
|
||||||
|
version: int
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPage(BaseModel):
|
||||||
|
items: list[TaskOut]
|
||||||
|
next_cursor: str | None = None
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +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>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BBQtE9Jy.css">
|
||||||
|
</head><body><div id="app"></div></body></html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
const CACHE='dodo-shell-v1';self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(['/','/manifest.json']))));self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));self.addEventListener('fetch',e=>{if(e.request.url.includes('/api/'))return;e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)))})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
dodo:
|
||||||
|
image: ghcr.io/example/dodo:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8781:8781"
|
||||||
|
environment:
|
||||||
|
DODO_DATABASE_URL: postgresql+asyncpg://dodo:[email protected]:5432/dodo
|
||||||
|
DODO_COOKIE_SECURE: "true"
|
||||||
|
DODO_SESSION_DAYS: "30"
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# API
|
||||||
|
|
||||||
|
Base path: `/api/v1`
|
||||||
|
|
||||||
|
## Setup / Auth
|
||||||
|
- `GET /setup/status`
|
||||||
|
- `POST /setup/initialize`
|
||||||
|
- `POST /auth/login`
|
||||||
|
- `GET /me`
|
||||||
|
|
||||||
|
## Folders
|
||||||
|
- `GET /folders`
|
||||||
|
- `POST /folders`
|
||||||
|
|
||||||
|
## Lists
|
||||||
|
- `GET /lists`
|
||||||
|
- `POST /lists`
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- `GET /tasks`
|
||||||
|
- `POST /tasks`
|
||||||
|
- `PATCH /tasks/{task_id}` — 必须携带当前 `version`,冲突返回 409
|
||||||
|
- `DELETE /tasks/{task_id}` — 软删除
|
||||||
|
|
||||||
|
## Health
|
||||||
|
- `GET /health/live`
|
||||||
|
- `GET /health/ready`
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# dodo 数据模型(第一阶段)
|
||||||
|
|
||||||
|
## app_state
|
||||||
|
- key
|
||||||
|
- created_at
|
||||||
|
|
||||||
|
## users
|
||||||
|
- id UUIDv7
|
||||||
|
- username
|
||||||
|
- password_hash
|
||||||
|
- timezone
|
||||||
|
- created_at
|
||||||
|
|
||||||
|
## sessions
|
||||||
|
- id UUIDv7
|
||||||
|
- token_hash
|
||||||
|
- user_id
|
||||||
|
- expires_at
|
||||||
|
- created_at
|
||||||
|
|
||||||
|
## folders
|
||||||
|
- id UUIDv7
|
||||||
|
- user_id
|
||||||
|
- name
|
||||||
|
- position
|
||||||
|
- created_at
|
||||||
|
|
||||||
|
## task_lists
|
||||||
|
- id UUIDv7
|
||||||
|
- user_id
|
||||||
|
- folder_id nullable
|
||||||
|
- name
|
||||||
|
- is_inbox
|
||||||
|
- position
|
||||||
|
- created_at
|
||||||
|
|
||||||
|
## tasks
|
||||||
|
- id UUIDv7
|
||||||
|
- user_id
|
||||||
|
- list_id
|
||||||
|
- parent_id nullable
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
- priority (0-3)
|
||||||
|
- completed
|
||||||
|
- due_at nullable
|
||||||
|
- version
|
||||||
|
- position
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
- deleted_at nullable
|
||||||
|
|
||||||
|
## 后续阶段预留
|
||||||
|
- task_reminders
|
||||||
|
- task_recurrence_templates
|
||||||
|
- task_recurrence_exceptions
|
||||||
|
- tags / task_tags
|
||||||
|
- habits / habit_logs / habit_reminders
|
||||||
|
- attachments
|
||||||
|
- audit_logs
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 部署
|
||||||
|
|
||||||
|
第一阶段交付方式:单镜像 + 外部 PostgreSQL。
|
||||||
|
|
||||||
|
> 当前机器未检测到 Docker,因此本阶段先提供 Dockerfile 草案与运行参数,实际镜像构建需在有 Docker 的环境执行。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DODO_DATABASE_URL=postgresql+asyncpg://dodo:password@postgres:5432/dodo
|
||||||
|
DODO_COOKIE_SECURE=true
|
||||||
|
DODO_SESSION_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
## 反向代理
|
||||||
|
|
||||||
|
- HTTPS 由反向代理终止
|
||||||
|
- 应用监听 `0.0.0.0:8781`
|
||||||
|
- 健康检查:`/health/ready`
|
||||||
|
|
||||||
|
## 后续补充
|
||||||
|
|
||||||
|
- Dockerfile
|
||||||
|
- docker-compose.yml 示例
|
||||||
|
- Alembic 正式迁移目录
|
||||||
|
- Worker 启动命令
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"https://shadcn-vue.com/schema.json","style":"new-york","typescript":true,"tailwind":{"css":"src/style.css","baseColor":"stone","cssVariables":true},"aliases":{"components":"@/components","utils":"@/lib/utils","ui":"@/components/ui"}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!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></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/node":"latest","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"}}
|
||||||
Generated
+1749
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
const CACHE='dodo-shell-v1';self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(['/','/manifest.json']))));self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));self.addEventListener('fetch',e=>{if(e.request.url.includes('/api/'))return;e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)))})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { CalendarDays, Check, CirclePlus, Inbox, ListTodo, Menu, Search, Settings, Sprout } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
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)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="initialized===null" class="center">正在打开 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 class="shell">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<aside class="detail"><span>任务详情</span><div class="paper"><b>选中一个任务</b><p>日期、提醒、标签和备注会出现在这里。</p></div></aside>
|
||||||
|
<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="fab" @click="focusQuick"><CirclePlus/></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
|
if ('serviceWorker' in navigator && import.meta.env.PROD) navigator.serviceWorker.register('/sw.js')
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@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}}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"module":"ESNext","moduleResolution":"Bundler","strict":true,"jsx":"preserve","resolveJsonModule":true,"isolatedModules":true,"esModuleInterop":true,"lib":["ES2022","DOM","DOM.Iterable"],"skipLibCheck":true,"noEmit":true},"include":["src/**/*.ts","src/**/*.vue"]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/lib/utils.ts","./src/app.vue"],"version":"5.9.3"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"files":[],"references":[{"path":"./tsconfig.app.json"}]}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({plugins:[vue(),tailwindcss()],server:{proxy:{'/api':'http://localhost:8781','/health':'http://localhost:8781'}}})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from backend import models # noqa: F401
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.db import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||||
|
if config.config_file_name:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
context.configure(url=config.get_main_option("sqlalchemy.url"), target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle":"named"})
|
||||||
|
with context.begin_transaction(): context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations():
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
import asyncio
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode(): run_migrations_offline()
|
||||||
|
else: run_migrations_online()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""${message}
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""initial schema
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"app_state",
|
||||||
|
sa.Column("key", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("username", sa.String(64), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.Text(), nullable=False),
|
||||||
|
sa.Column("timezone", sa.String(64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("username"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"])
|
||||||
|
op.create_table(
|
||||||
|
"sessions",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sessions_token_hash", "sessions", ["token_hash"], unique=True)
|
||||||
|
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||||
|
op.create_table(
|
||||||
|
"folders",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(120), nullable=False),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("user_id", "id", name="uq_folders_user_id_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_folders_user_id", "folders", ["user_id"])
|
||||||
|
op.create_table(
|
||||||
|
"task_lists",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("folder_id", sa.Uuid()),
|
||||||
|
sa.Column("name", sa.String(120), nullable=False),
|
||||||
|
sa.Column("is_inbox", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id", "folder_id"], ["folders.user_id", "folders.id"], ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "id", name="uq_task_lists_user_id_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_task_lists_user_id", "task_lists", ["user_id"])
|
||||||
|
op.create_table(
|
||||||
|
"tasks",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("list_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("parent_id", sa.Uuid()),
|
||||||
|
sa.Column("title", sa.String(500), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=False),
|
||||||
|
sa.Column("priority", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("completed", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("due_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("version", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id", "list_id"], ["task_lists.user_id", "task_lists.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["parent_id"], ["tasks.id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tasks_user_id", "tasks", ["user_id"])
|
||||||
|
op.create_index("ix_tasks_list_id", "tasks", ["list_id"])
|
||||||
|
op.create_index("ix_tasks_completed", "tasks", ["completed"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("tasks")
|
||||||
|
op.drop_table("task_lists")
|
||||||
|
op.drop_table("folders")
|
||||||
|
op.drop_table("sessions")
|
||||||
|
op.drop_table("users")
|
||||||
|
op.drop_table("app_state")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[project]
|
||||||
|
name = "dodo"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A warm, self-hosted task and habit manager"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.116,<1",
|
||||||
|
"uvicorn[standard]>=0.35,<1",
|
||||||
|
"sqlalchemy[asyncio]>=2.0,<3",
|
||||||
|
"asyncpg>=0.30,<1",
|
||||||
|
"alembic>=1.16,<2",
|
||||||
|
"pydantic-settings>=2.10,<3",
|
||||||
|
"argon2-cffi>=25,<26",
|
||||||
|
"python-multipart>=0.0.20,<1",
|
||||||
|
"uuid-utils>=0.11,<1",
|
||||||
|
"structlog>=25,<26",
|
||||||
|
"apscheduler>=3.11,<4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.4,<9",
|
||||||
|
"pytest-asyncio>=1.1,<2",
|
||||||
|
"httpx>=0.28,<1",
|
||||||
|
"aiosqlite>=0.21,<1",
|
||||||
|
"ruff>=0.12,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
ignore = ["B008"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"migrations/versions/*.py" = ["E501"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
uv run alembic upgrade head
|
||||||
|
exec uv run uvicorn backend.main:app --host 0.0.0.0 --port 8781
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path: Path):
|
||||||
|
os.environ["DODO_DATABASE_URL"] = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||||
|
os.environ["DODO_AUTO_CREATE_SCHEMA"] = "true"
|
||||||
|
from backend.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from backend.db import reset_engine
|
||||||
|
reset_engine()
|
||||||
|
from backend.main import app
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
reset_engine()
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
os.environ.setdefault("DODO_DATABASE_URL", "sqlite+aiosqlite:////tmp/dodo-health-test.db")
|
||||||
|
os.environ.setdefault("DODO_AUTO_CREATE_SCHEMA", "true")
|
||||||
|
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_live():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/health/live")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_status_starts_uninitialized(client):
|
||||||
|
response = client.get("/api/v1/setup/status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"initialized": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialize_login_and_create_task(client):
|
||||||
|
initialized = client.post(
|
||||||
|
"/api/v1/setup/initialize",
|
||||||
|
json={"username": "owner", "password": "correct horse battery staple"},
|
||||||
|
)
|
||||||
|
assert initialized.status_code == 201
|
||||||
|
assert initialized.json()["username"] == "owner"
|
||||||
|
assert "dodo_session" in initialized.cookies
|
||||||
|
|
||||||
|
folder = client.post("/api/v1/folders", json={"name": "工作"})
|
||||||
|
assert folder.status_code == 201
|
||||||
|
|
||||||
|
task_list = client.post(
|
||||||
|
"/api/v1/lists", json={"name": "本周", "folder_id": folder.json()["id"]}
|
||||||
|
)
|
||||||
|
assert task_list.status_code == 201
|
||||||
|
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "完成 dodo 第一阶段", "list_id": task_list.json()["id"], "priority": 3},
|
||||||
|
)
|
||||||
|
assert task.status_code == 201
|
||||||
|
assert task.json()["title"] == "完成 dodo 第一阶段"
|
||||||
|
|
||||||
|
tasks = client.get("/api/v1/tasks")
|
||||||
|
assert tasks.status_code == 200
|
||||||
|
assert len(tasks.json()["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialize_is_closed_after_first_user(client):
|
||||||
|
payload = {"username": "owner", "password": "correct horse battery staple"}
|
||||||
|
assert client.post("/api/v1/setup/initialize", json=payload).status_code == 201
|
||||||
|
response = client.post("/api/v1/setup/initialize", json=payload)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_task_access_is_rejected(client):
|
||||||
|
response = client.get("/api/v1/tasks")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_can_be_updated_completed_and_soft_deleted(client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/setup/initialize",
|
||||||
|
json={"username": "owner", "password": "correct horse battery staple"},
|
||||||
|
)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "旧标题", "list_id": inbox["id"]}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
updated = client.patch(
|
||||||
|
f"/api/v1/tasks/{task['id']}",
|
||||||
|
json={"title": "新标题", "completed": True, "version": task["version"]},
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["title"] == "新标题"
|
||||||
|
assert updated.json()["completed"] is True
|
||||||
|
assert updated.json()["version"] == 2
|
||||||
|
|
||||||
|
conflict = client.patch(
|
||||||
|
f"/api/v1/tasks/{task['id']}",
|
||||||
|
json={"title": "冲突标题", "version": task["version"]},
|
||||||
|
)
|
||||||
|
assert conflict.status_code == 409
|
||||||
|
|
||||||
|
deleted = client.delete(f"/api/v1/tasks/{task['id']}")
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
assert client.get("/api/v1/tasks").json()["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_update_rejects_null_title(client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/setup/initialize",
|
||||||
|
json={"username": "owner", "password": "correct horse battery staple"},
|
||||||
|
)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
task = client.post("/api/v1/tasks", json={"title": "任务", "list_id": inbox["id"]}).json()
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1/tasks/{task['id']}", json={"title": None, "version": task["version"]}
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_subtask_parent_must_belong_to_same_user_and_list(client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/setup/initialize",
|
||||||
|
json={"username": "owner", "password": "correct horse battery staple"},
|
||||||
|
)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
other = client.post("/api/v1/lists", json={"name": "其他"}).json()
|
||||||
|
parent = client.post("/api/v1/tasks", json={"title": "父任务", "list_id": inbox["id"]}).json()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "子任务", "list_id": other["id"], "parent_id": parent["id"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_revokes_current_session(client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/setup/initialize",
|
||||||
|
json={"username": "owner", "password": "correct horse battery staple"},
|
||||||
|
)
|
||||||
|
assert client.get("/api/v1/me").status_code == 200
|
||||||
|
assert client.post("/api/v1/auth/logout").status_code == 204
|
||||||
|
assert client.get("/api/v1/me").status_code == 401
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_due_reminders() -> None:
|
||||||
|
# Reminder delivery is implemented in phase 2. Keeping the scheduler isolated
|
||||||
|
# ensures API restarts never own reminder timing.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||||
|
scheduler.add_job(scan_due_reminders, "interval", seconds=30, max_instances=1)
|
||||||
|
scheduler.start()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user