61857c3ce1
A list page is a table with controls around it, and the table was as tall as its rows: on a large screen most of the window sat empty below the last row while the page stayed the thing that scrolled. The three list pages now stretch the table to the height the window offers — the toolbar and the pagination stay put and the rows scroll inside the table — and give the height back on a small screen. Pure CSS, no resize listener and no measured pixel height, so nothing goes stale when the rail collapses or the window is resized. Four links carry it, each load-bearing: `.page--fill` is `min-height: 100%` (a floor, not a cap), `.card--fill` makes the card a column, its body gets `min-height: 0` so a flex child may shrink below its content, and `.table-fill` is `flex: 1 1 0` so the table takes the leftover height rather than claiming its content height as its basis. Element Plus then pins the header and scrolls the rows. Verified by rendering the pages in a headless browser at 1920x1080, 1440x900 and 1280x620: the table measures 781/601/321px, the page never scrolls, the rows scroll inside it, header and body columns stay aligned to 0px through a 400px horizontal scroll, the pinned action column stays pinned, and the empty state is centred in the full-height body rather than stranded at the top. A 1280x420 window hits the 240px floor and falls back to the page scrolling, and the paper document page is untouched.
466 lines
22 KiB
Markdown
466 lines
22 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. Every one is prefixed with the module it belongs to, and a model
|
||
class is named after its table, so the schema can be read as three groups:
|
||
|
||
```
|
||
template_field_library the 字段库: reusable headings
|
||
id, name, level, font_size, font_color
|
||
|
||
template a named outline (模板)
|
||
id, name, abstract
|
||
|
||
template_field a field placed in a template — and the only
|
||
id, template_id → template, home of display order
|
||
field_id → template_field_library, 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
|
||
```
|
||
|
||
`template_field` and `template_field_library` are one word apart and mean
|
||
opposite things. The first is a *placement* — this template puts this field
|
||
here, `sort` included. The second is the *catalogue* the field was picked from,
|
||
shared by every template and owned by none. The library is managed from the
|
||
模板 menu, because it is the raw material a template is built out of.
|
||
|
||
### The field library is flat, not a tree
|
||
|
||
`template_field_library` 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
|
||
`template_field_library.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.
|
||
|
||
### Foreign keys are declared, and this cluster enforces them
|
||
|
||
TiDB parses `FOREIGN KEY` for compatibility, and from v6.6 honours it when
|
||
`tidb_enable_foreign_key` is on. On the cluster this project runs against it is
|
||
on, so the constraints are real integrity rather than documentation: a dangling
|
||
insert is rejected (`1452`) and `ON DELETE CASCADE` / `SET NULL` actually fire.
|
||
Two consequences are worth knowing before touching a migration:
|
||
|
||
- **Tables must be created in dependency order**, and a rename must be checked
|
||
rather than assumed. `RENAME TABLE` does carry a referencing constraint along
|
||
with the renamed table here (the rename in revision `f27a1c6d9e04` was
|
||
rehearsed against the cluster before it was written), but that is a property
|
||
of the deployment, not of SQL.
|
||
- **The API still checks first.** A database violation surfaces as a generic
|
||
driver error, while the application refuses with a message that names the row
|
||
and the count:
|
||
- deleting a library field still placed in a template → `409`, naming the
|
||
field and how many templates use it;
|
||
- deleting a template a paper is written against → `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 → `400`, and creating
|
||
or patching a paper that references a missing template likewise.
|
||
|
||
The ORM cascades stay as well. `Template.items`, `Paper.sentences` and
|
||
`PaperSentence.citations` use `cascade="all, delete-orphan"`, so deleting a row
|
||
removes its dependents whether or not the database would have done it too —
|
||
which keeps behaviour identical on a cluster with foreign keys switched off.
|
||
|
||
## API
|
||
|
||
All routes are mounted under `/api`. Interactive docs at `/docs`.
|
||
|
||
| Method | Path | Notes |
|
||
|---|---|---|
|
||
| `GET` | `/health` | liveness plus a TiDB probe |
|
||
| `GET` | `/template-field-library` | `keyword`, `level`, `page`, `page_size` |
|
||
| `POST` | `/template-field-library` | create a library entry |
|
||
| `GET` `PATCH` `DELETE` | `/template-field-library/{id}` | read / partial update / delete |
|
||
| `POST` | `/template-field-library/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.
|
||
- Route paths mirror table names one for one: `/templates` for `template`,
|
||
`/template-field-library` for `template_field_library`, `/papers` for
|
||
`paper`.
|
||
- 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.
|
||
|
||
### List pages fill the window
|
||
|
||
A table is the page. Left alone it is as tall as its rows, so on a large screen
|
||
most of the window sits empty below the last row while the *page* is still the
|
||
thing that scrolls — the wrong half of the screen moving for a list that should
|
||
simply show more. The three list pages (`/papers`, `/templates/list`,
|
||
`/templates/fields`) therefore stretch the table to the height the window
|
||
offers, with the toolbar above it and the pagination below it staying put while
|
||
the rows scroll inside the table.
|
||
|
||
It is CSS only — no resize listener, no measured pixel height, nothing to go
|
||
stale when the rail collapses or the window changes. Four links carry it, and
|
||
each one is load-bearing:
|
||
|
||
| Piece | Why |
|
||
|---|---|
|
||
| `.page--fill` | `min-height: 100%` of the content area — a floor that still grows |
|
||
| `.card--fill` | makes the card a column so its body can pass the height down |
|
||
| `.card--fill > .el-card__body` | a column with `min-height: 0`, without which a flex child refuses to shrink below its content |
|
||
| `.table-fill` + `height="100%"` | `flex: 1 1 0` so the table takes the leftover height instead of claiming its content height as its basis; Element Plus then pins the header and scrolls the rows |
|
||
|
||
Measured in a real browser at three window sizes, with rows to scroll and with
|
||
none:
|
||
|
||
| Window | Table | Page scroll | Rows scroll | Empty state |
|
||
|---|---|---|---|---|
|
||
| 1920x1080 | 781px | none | 519px inside | centred, 741px |
|
||
| 1440x900 | 601px | none | 699px inside | centred, 561px |
|
||
| 1280x620 | 321px | none | 979px inside | centred, 281px |
|
||
|
||
`.table-fill` keeps a 240px floor. Below it — a 1280x420 window — the table
|
||
stops shrinking, the page grows past the viewport and the content area scrolls,
|
||
which is the pre-existing behaviour rather than a squashed table. Pages that
|
||
are not lists (the paper document) keep scrolling normally.
|
||
|
||
### 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, templates,
|
||
│ │ │ # template_field_library)
|
||
│ │ ├── 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
|
||
```
|