Files
paper-doc/docs/OVERVIEW.md
T
govin 0d0aa20be2 frontend: make the logo block exactly as wide as the second-level menu
The logo block stopped wherever its text happened to end (~140px) while the
rail below it is 208px, so the left edge was a step rather than a column.

Both now read `asideWidth()` from src/layout.ts, so they cannot drift apart,
and collapsing the rail narrows the logo block with it — the wordmark is
dropped and the mark centres in the 56px strip. The block also carries the
rail's separator up through the header, so the column reads as one piece.

The header's own 20px padding had to go for this to work: the logo block has
to start at x=0 to be flush with the rail, so the nav and the right-hand mark
now own their insets. The mark's inset comes from the same `ASIDE_INSET` as
the rail's section title, so the two line up.

A side effect worth keeping: the rail is 208px and the top nav's first item
and .el-main are each inset a further 20px by their own Element Plus defaults,
so the nav text, the page body and the page header all start on one vertical
line.

The widths live in a module rather than CSS custom properties because el-aside
takes its width through a prop and would otherwise fight an inline style.
2026-09-18 16:43:30 +08:00

281 lines
11 KiB
Markdown

# 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 client
- `vue-router` — client-side routing
- `pinia` + `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 NodePort `192.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 `sort` values 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 selection `1..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.items` uses `cascade="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_color` is stored and returned as canonical `#RRGGBB`. The API also
accepts `rgb(r, g, b)`, `#rgb` and bare `aabbcc`, and normalises them on write.
- `font_size` is 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 `fields` already ordered by `sort`; clients never sort.
## Frontend
### Shell
```
┌───────────────────┬──────────────────────────────────────┐
│ [mark] paper-doc │ 论文 模板 [mk] │ header
├───────────────────┼──────────────────────────────────────┤
│ 模板配置 │ │ second-level
│ 模板列表 │ <RouterView> │ menu, left
│ 字段管理 │ │
└───────────────────┴──────────────────────────────────────┘
<- 208px ->
```
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.
Two things about this layout are deliberate and easy to break by accident:
- **Which side the menu lands on follows from its DOM order** inside the flex
row in `App.vue` — first child, left. Moving it is a one-block change there,
not a stylesheet override.
- **The logo block is exactly as wide as the rail.** Both read `asideWidth()`
from `src/layout.ts`, so they cannot drift apart, and collapsing the rail
narrows the logo block with it (dropping the wordmark and centring the mark).
The header's own padding is zeroed for this; the two insets come from
`ASIDE_INSET` so the mark lines up with the section title below it.
The column also carries through to the content: the rail is 208px and both the
top nav's first item and `.el-main` are inset a further 20px by their own
Element Plus defaults, so the menu text, the page body, and the page header all
start on the same vertical line.
### Shell metrics
`src/layout.ts` is the single source for the rail's width and inset. It is a
module rather than CSS custom properties because `el-aside` takes its width
through a prop, which would otherwise fight an inline style coming from the
stylesheet.
### 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**
```bash
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**
```bash
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:
```bash
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:
```bash
cd backend
.venv/bin/python scripts/seed.py # add anything missing
.venv/bin/python scripts/seed.py --reset # empty the tables first
```