The rail was on the right. It now leads the row instead, so the section menu reads as a conventional left-hand navigation column. Which side it lands on was never a stylesheet decision: `.app-body` is a flex row and the aside is simply its first child now, with `.app-main` after it. The only style change that had to follow is the rail's separator, which moves from its left edge to its right, plus the collapse tooltip's placement. The global shell contract in base.css now says this explicitly, so the next person to move the rail knows the side comes from DOM order rather than from a rule in that file.
10 KiB
paper-doc
A tool that supports the paper-writing process: it manages the materials, sections, figures, and references that go into a paper. The name reflects the document-centric core of the workflow — a paper is composed from structured documents rather than written in one pass.
The first feature to land is the template configuration: a reusable library of section fields plus named templates built from them, so that a paper is written by filling in a known structure instead of deciding one.
Tech Stack
Backend
- FastAPI (Python) — REST API
- SQLAlchemy + Alembic — ORM layer and schema migrations
- TiDB v8.5.0 — MySQL-compatible distributed SQL database, deployed in k3s
- Dependencies managed with
venv+requirements.txt
Frontend
- Vue 3 + Vite — single-page application
axios— HTTP clientvue-router— client-side routingpinia+pinia-plugin-persistedstate— state management with persistence- Element Plus — UI component library
- Dependencies managed with
pnpm
Architecture
A decoupled SPA + REST API layout:
- The Vue SPA runs in the browser and talks to the backend exclusively over
HTTP/JSON via
axios. It holds no direct database access. - The FastAPI backend owns all persistence. It connects to TiDB through SQLAlchemy sessions and owns the schema through Alembic migrations.
- TiDB runs inside the existing k3s cluster. The backend reaches it either
through the in-cluster Service (
tidb-tidb.tidb:4000) or, during local development, through the NodePort192.168.1.88:32738.
Development runs both parts locally: Vite serves the SPA with a dev proxy that
forwards /api to the FastAPI process, so the browser sees a single origin and
no CORS configuration is required.
Browser ──HTTP/JSON──▶ FastAPI ──SQLAlchemy──▶ TiDB (k3s)
│ ▲
└── Vite dev server ───┘ (proxy /api)
Domain Model
Three tables, and one rule that everything else follows from.
section_field the reusable heading library
id, name, level, font_size, font_color
paper_template a named outline
id, name, abstract
template_field the join, and the only home of display order
id, template_id → paper_template, field_id → section_field, sort
The field library is flat, not a tree
section_field has no parent_id. Hierarchy is expressed only by level
(1 for 1., 2 for 1.1), which is a rendering hint: it drives indentation
and numbering semantics in the UI and nothing else.
The reason is reuse. With a parent pointer a level-2 heading would belong to
exactly one level-1 heading, so "Background" could not sit under both
1. Introduction and 2. Related Work. Flat fields are attached to templates,
not to each other, so one field can appear in any number of templates, in a
different position in each.
The numbering a reader sees is part of name and is written by the user
("1. Introduction", "0 Abstract"). Nothing derives or rewrites it.
Display order lives in the join table
template_field.sort is a plain ascending integer and is the only thing
that decides what order a template's fields appear in. The order the user
clicked fields in is never stored, so selecting 字段1, 字段2.1, 字段2, 字段1.1
and setting sorts 1, 4, 3, 2 renders as 字段1, 字段1.1, 字段2, 字段2.1.
Two consequences are deliberate:
- There is no unique constraint on
(template_id, field_id). Placing the same field twice in one template is a legitimate layout — the same level-2 heading under two different level-1 headings. The UI warns about a repeat; it does not forbid one. - Ties are legal. Equal
sortvalues are broken by insertion order, so the ordering is always total and stable. The UI warns about ties too, and offers a button that renumbers the selection1..N.
Templates hold references, not copies
template_field points at a library field; it does not snapshot its name or
typography. Renaming or restyling a field therefore updates every template that
places it, which is what makes "fix the template and the section names follow"
work.
TiDB does not enforce foreign keys
TiDB parses FOREIGN KEY for compatibility and then ignores it. The constraints
are declared to document the relationships, and the integrity they would provide
is enforced in the application layer instead:
- deleting a field still placed in a template is refused with
409, naming the field and how many templates use it; - creating a template that references a missing field is refused with
400; PaperTemplate.itemsusescascade="all, delete-orphan", so deleting a template removes its rows from the join table.
API
All routes are mounted under /api. Interactive docs at /docs.
| Method | Path | Notes |
|---|---|---|
GET |
/health |
liveness plus a TiDB probe |
GET |
/section-fields |
keyword, level, page, page_size |
POST |
/section-fields |
create |
GET PATCH DELETE |
/section-fields/{id} |
read / partial update / delete |
POST |
/section-fields/batch-delete |
body { "ids": [...] } |
GET |
/templates |
keyword matches name or abstract |
POST |
/templates |
name + abstract + ordered fields |
GET PATCH DELETE |
/templates/{id} |
PATCH with fields replaces the selection |
POST |
/templates/batch-delete |
body { "ids": [...] } |
Conventions worth knowing:
font_coloris stored and returned as canonical#RRGGBB. The API also acceptsrgb(r, g, b),#rgband bareaabbcc, and normalises them on write.font_sizeis a JSON number, not a string — a client should not have to parse it before using it in a CSS rule.- List endpoints return
{ items, total, page, page_size, pages }. - A template read returns
fieldsalready ordered bysort; clients never sort.
Frontend
Shell
┌──────────────────────────────────────────────────────────┐
│ [book mark] paper-doc 论文 模板 [mark] │ header
├───────────────────┬──────────────────────────────────────┤
│ 模板配置 │ │ second-level
│ 模板列表 │ <RouterView> │ menu, left
│ 字段管理 │ │
└───────────────────┴──────────────────────────────────────┘
The mark appears at both ends of the header. The second-level menu sits on the
left and is driven entirely by route.meta.section, so a route declares
which menu it belongs to and deep links render correctly on first paint. Routes
without a section (the welcome page) show no menu.
Which side the menu lands on follows from its DOM order inside the flex row in
App.vue — first child, left — so moving it is a one-block change there rather
than a stylesheet override.
Routes
| Path | View | Section |
|---|---|---|
/ |
welcome | — |
/papers |
论文 (content TBD) | papers |
/templates |
redirects to /templates/list |
— |
/templates/list |
template table + CRUD | templates |
/templates/fields |
field library + CRUD | templates |
Directory Structure
paper-doc/
├── backend/ # FastAPI application
│ ├── app/
│ │ ├── api/routes/ # route handlers (health, section_fields, templates)
│ │ ├── core/ # settings and configuration
│ │ ├── crud/ # data-access helpers
│ │ ├── db/ # engine, session, declarative base
│ │ ├── models/ # SQLAlchemy models
│ │ └── schemas/ # Pydantic request/response models
│ ├── alembic/ # migration environment and revisions
│ ├── scripts/seed.py # idempotent seed for the field library + templates
│ ├── alembic.ini
│ ├── requirements.txt
│ └── .env.example
├── frontend/ # Vue 3 SPA
│ ├── src/
│ │ ├── api/ # axios instance and endpoint modules
│ │ ├── components/ # shell, field and template components
│ │ ├── router/ # vue-router configuration
│ │ ├── stores/ # pinia stores (persisted)
│ │ ├── styles/ # global reset and the shell viewport contract
│ │ ├── utils/ # formatting helpers
│ │ └── views/ # route-level components
│ ├── package.json
│ └── vite.config.ts
└── docs/ # project documentation
└── OVERVIEW.md
Getting Started
Prerequisites
- Python 3.10+ with
venv - Node.js 18+ with
pnpm - Network access to the TiDB instance (local development uses the NodePort)
Backend
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill in the database credentials
uvicorn app.main:app --reload --port 8000
API docs are then served at http://127.0.0.1:8000/docs.
Frontend
cd frontend
pnpm install
pnpm dev
The SPA is served at http://127.0.0.1:5173 and proxies /api to the backend
on port 8000.
Database
The paper_doc database is created once, out of band, against TiDB. Schema
changes are applied through Alembic:
cd backend
alembic upgrade head
Seed data
scripts/seed.py fills the field library with a standard academic outline (20
fields, from 0 Abstract to 7 References) and creates three starter
templates. It matches fields and templates by name, so running it twice adds
nothing:
cd backend
.venv/bin/python scripts/seed.py # add anything missing
.venv/bin/python scripts/seed.py --reset # empty the tables first