2d113f9f6b
Records the two decisions the paper feature rests on: a sentence is addressed by the template placement's `sort` rather than by a row id, and citations are a table rather than a column. Both explain behaviour that otherwise looks arbitrary — why an empty paper already has its structure, why a template switch destroys nothing, and why content with no matching heading is still rendered. Also documents the new endpoints, the /papers/:id route, the paper view's three jobs, and the smoke test.
407 lines
18 KiB
Markdown
407 lines
18 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.
|
||
|
||
The second is **paper authoring**: a paper is a document written against a
|
||
template, sentence by sentence, paragraph by paragraph. The structure is never
|
||
copied into the paper — it is read from the template on every render — so
|
||
switching a paper's template re-shapes the whole document in one write without
|
||
touching a single sentence.
|
||
|
||
## 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
|
||
|
||
Six tables, and one rule that the last three follow 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
|
||
|
||
paper the document
|
||
id, title, template_id, abstract, author, status, keywords,
|
||
target_journal
|
||
|
||
paper_sentence one sentence, at one position, in one paper
|
||
id, paper_id → paper, template_id, paper_template_filed_sort, sort, content
|
||
|
||
paper_sentence_reference the citations of one sentence
|
||
id, sentence_id → paper_sentence, reference_id, quote, 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.
|
||
|
||
### A sentence is addressed by position, not by field id
|
||
|
||
`paper_sentence.paper_template_filed_sort` holds the `sort` of the template
|
||
placement the sentence belongs to — **not** `template_field.id`, and not
|
||
`section_field.id`. Together with `sort`, the sentence's own position inside
|
||
that paragraph, it is everything needed to place a line of text.
|
||
|
||
The indirection is the feature. Two templates have no rows in common, but they
|
||
can share a position, so a sentence that remembers "I sit at position 7" lands
|
||
on whatever the new template puts at position 7 when the paper is switched. The
|
||
alternative — pointing at a placement row — would make every sentence belong to
|
||
one template and turn a switch into a re-mapping of every line.
|
||
|
||
Three behaviours fall out of that one choice:
|
||
|
||
- **The structure exists before the content.** A paragraph is any position the
|
||
template defines *or* any position a sentence occupies, so an unwritten
|
||
paragraph still renders, empty, and a paper created a second ago already has
|
||
its full shape.
|
||
- **A switch destroys nothing.** Positions the new template does not define are
|
||
still rendered, in their place in the order, under the heading 未设定. Switch
|
||
back and the old headings return, because the sentences never moved.
|
||
- **Ties and gaps are fine.** Sentences may share a `sort` (broken by `id`) and
|
||
may be numbered `10, 20, 30` to leave room, exactly as `template_field.sort`
|
||
allows.
|
||
|
||
The name `paper_template_filed_sort` keeps the spelling the feature was
|
||
specified with; it is the template field's `sort` (the `sort` column of
|
||
`template_field`). `paper_sentence.template_id` is the template the sentence was
|
||
written against. It is kept in step with the paper's current template and is
|
||
provenance rather than a lookup key: rendering never filters on it, which is
|
||
precisely why content survives a switch.
|
||
|
||
### Citations are a table, not a column
|
||
|
||
One sentence may quote several references, so
|
||
`paper_sentence_reference` holds one row per citation. `quote` — 引用内容 — is
|
||
required and must not be blank: a citation that does not say what it quotes is
|
||
rejected with `422` rather than stored as a half-record. `reference_id` is a
|
||
plain nullable integer with **no** foreign key, because the reference library
|
||
does not exist yet; a citation may therefore be written now and linked later.
|
||
|
||
### 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;
|
||
- deleting a template that a paper is written against is refused with `409`,
|
||
naming the template and how many papers use it — the template is that paper's
|
||
structure, so removing it would empty the paper rather than tidy up;
|
||
- creating a template that references a missing field is refused with `400`, and
|
||
creating or patching a paper that references a missing template likewise;
|
||
- `PaperTemplate.items`, `Paper.sentences` and `PaperSentence.citations` all use
|
||
`cascade="all, delete-orphan"`, so deleting a row removes its dependents.
|
||
|
||
## 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": [...] }` |
|
||
| `GET` | `/papers` | `keyword` matches title/author/keywords, plus `status`, `template_id` |
|
||
| `POST` | `/papers` | title + template + author/status/keywords/journal; no sentence rows are created |
|
||
| `GET` `PATCH` `DELETE` | `/papers/{id}` | `PATCH` with `template_id` **is** the template switch |
|
||
| `POST` | `/papers/batch-delete` | body `{ "ids": [...] }` |
|
||
| `GET` | `/papers/{id}/document` | the whole paper: paragraphs in order, citations, warnings |
|
||
| `GET` | `/papers/{id}/paragraphs/{sort}` | one paragraph, assembled as the document renders it |
|
||
| `PUT` | `/papers/{id}/paragraphs/{sort}` | full replacement; optional `target_sort` moves it |
|
||
| `POST` | `/papers/{id}/sentences` | append one sentence |
|
||
| `PATCH` `DELETE` | `/papers/{id}/sentences/{id}` | edit or remove one sentence |
|
||
|
||
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.
|
||
- A paper document returns its `paragraphs` in ascending position order,
|
||
already carrying their sentences and citation numbering. The client sorts
|
||
nothing and merges nothing: two implementations of the same ordering rule
|
||
would eventually disagree.
|
||
- Sentence `content` is folded to a single line (leading, trailing and inner
|
||
whitespace runs collapse to one space), so an all-whitespace sentence becomes
|
||
the empty string — legal, and rendered as a blank line. A citation with a
|
||
blank `quote` is refused with `422`.
|
||
- `keywords` is stored as a canonical ``, ``-joined string; `,`、`;` and `;`
|
||
are accepted on write and de-duplicated.
|
||
- Paper `status` is one of `draft`, `writing`, `done`.
|
||
|
||
## Frontend
|
||
|
||
### Shell
|
||
|
||
```
|
||
┌───────────────────┬──────────────────────────────────────┐
|
||
│ [mark] paper-doc │ 论文 模板 [mk] │ header
|
||
├───────────────────┼──────────────────────────────────────┤
|
||
│ 论文 │ │ second-level
|
||
│ 论文列表 │ <RouterView> │ menu, left
|
||
│ 新建论文 │ │
|
||
│ ── 我的论文 ── │ │
|
||
│ ● 论文标题 A │ │
|
||
│ ● 论文标题 B │ │
|
||
└───────────────────┴──────────────────────────────────────┘
|
||
<- 208px ->
|
||
```
|
||
|
||
The mark appears at both ends of the header. The second-level menu sits on the
|
||
**left** and is driven 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.
|
||
|
||
The 论文 menu is the one that is not a static list: it carries the papers
|
||
themselves, read from the `papers` store, so a paper can be opened straight from
|
||
the rail. Every page that creates, renames or deletes a paper reloads that
|
||
store, which is what keeps the rail and the table showing the same thing. A
|
||
filter box appears once there are more than six papers.
|
||
|
||
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` | paper table + CRUD (`?new=1` opens the create dialog) | `papers` |
|
||
| `/papers/:id` | one paper, read as a document | `papers` |
|
||
| `/templates` | redirects to `/templates/list` | — |
|
||
| `/templates/list` | template table + CRUD | `templates` |
|
||
| `/templates/fields` | field library + CRUD | `templates` |
|
||
|
||
### The paper view
|
||
|
||
`PapersView` is the library; `papers/PaperDetailView` is the writing surface.
|
||
The detail view renders the server's document verbatim and adds three things it
|
||
alone knows how to do:
|
||
|
||
- **an edit button beside every paragraph**, written or not — content only ever
|
||
enters a paper through the paragraph editor, so it may never be the thing that
|
||
is missing;
|
||
- **numbered citation markers** in the text, matching the 参考文献 list, which
|
||
is numbered in reading order rather than per paragraph;
|
||
- **a 切换模板 dialog that previews the impact** — how much content lands under
|
||
a new heading, how much keeps its place under 未设定, and how many empty
|
||
paragraphs the new template adds — because a switch re-shapes the whole
|
||
document in one write.
|
||
|
||
Changing a paper's template from the metadata form is deliberately disabled: a
|
||
silent re-shape of the document is exactly what the preview exists to prevent.
|
||
|
||
## Directory Structure
|
||
|
||
```
|
||
paper-doc/
|
||
├── backend/ # FastAPI application
|
||
│ ├── app/
|
||
│ │ ├── api/routes/ # route handlers (health, papers, 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
|
||
│ ├── scripts/smoke_papers.py # end-to-end check of the writing loop
|
||
│ ├── alembic.ini
|
||
│ ├── requirements.txt
|
||
│ └── .env.example
|
||
├── frontend/ # Vue 3 SPA
|
||
│ ├── src/
|
||
│ │ ├── api/ # axios instance and endpoint modules
|
||
│ │ ├── components/ # shell, field, template and paper components
|
||
│ │ ├── router/ # vue-router configuration
|
||
│ │ ├── stores/ # pinia stores (UI prefs persisted, the paper list not)
|
||
│ │ ├── styles/ # global reset and the shell viewport contract
|
||
│ │ ├── utils/ # formatting helpers
|
||
│ │ └── views/ # route-level components (papers/, templates/)
|
||
│ ├── 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
|
||
```
|
||
|
||
**Checking the paper feature end to end**
|
||
|
||
`scripts/smoke_papers.py` walks the whole writing loop against a running API —
|
||
create a paper on a template, verify the empty structure, fill a paragraph with
|
||
sentences and citations, switch the template, check that unmatched positions
|
||
survive under an unset heading, move a paragraph, and delete it all again. It
|
||
exits non-zero on the first failed expectation and cleans up after itself:
|
||
|
||
```bash
|
||
cd backend
|
||
.venv/bin/python scripts/smoke_papers.py
|
||
```
|