Two tables were named after the concept they came from rather than the module
they belong to, so the schema read as if the template tables were part of the
paper module. Renamed (data preserved, `RENAME TABLE` moves rows in place):
paper_template -> template the 模板 module
section_field -> template_field_library the 字段库 the 模板 module owns
The paper tables and `template_field` already followed the rule. The rename
carries through everything that named a module:
models Template, TemplateField, TemplateFieldLibrary
schemas Template*, TemplateFieldLibrary*
crud app/crud/template.py, app/crud/template_field_library.py
API /template-field-library (was /section-fields); handlers are now
named after library entries, which removes the ambiguity with
TemplateField — a placement, a different thing entirely
client src/api/templateFieldLibrary.ts
`paper_template_filed_sort` is deliberately untouched: it is a column of the
paper module, spelled as the feature was specified.
TiDB v8.5 with tidb_enable_foreign_key on — as this cluster runs — enforces
foreign keys rather than ignoring them, so the docs' "TiDB does not enforce
foreign keys" was wrong. Corrected, with what actually follows from it: the
rename was rehearsed (RENAME TABLE carries a referencing constraint along), the
API keeps checking first so a violation names the row instead of surfacing a
driver error, and the ORM cascades stay so behaviour does not depend on a
cluster setting.
Revision f27a1c6d9e04 verified both ways; 40 smoke checks, type-check and build
all pass.
20 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.
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 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
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
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.
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 byid) and may be numbered10, 20, 30to leave room, exactly astemplate_field.sortallows.
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 TABLEdoes carry a referencing constraint along with the renamed table here (the rename in revisionf27a1c6d9e04was 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.
- deleting a library field still placed in a template →
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_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. - Route paths mirror table names one for one:
/templatesfortemplate,/template-field-libraryfortemplate_field_library,/papersforpaper. - A paper document returns its
paragraphsin 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
contentis 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 blankquoteis refused with422. keywordsis stored as a canonical,-joined string;,、;and;are accepted on write and de-duplicated.- Paper
statusis one ofdraft,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()fromsrc/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 fromASIDE_INSETso 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, 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
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
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:
cd backend
.venv/bin/python scripts/smoke_papers.py