Compare commits

...

3 Commits

Author SHA1 Message Date
govin 2d113f9f6b docs: describe the paper authoring model, API, and views
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.
2026-09-18 17:29:19 +08:00
govin 4d749b3592 frontend: add the paper list, document view, and paragraph editor
The 论文 menu now carries the papers themselves, read from a pinia store, so a
paper opens straight from the rail; every page that changes the list reloads
that store, and a filter box appears once there are more than six.

PapersView is the library — search, create, edit, open, single and batch
delete. PaperDetailView is the writing surface: it renders the server's
document verbatim, with an edit button beside every paragraph (written or not,
because content only ever enters a paper through the editor), citation markers
numbered in reading order to match the 参考文献 list, and a 切换模板 dialog that
previews the impact before re-shaping the document.

The paragraph editor shows one paragraph as the paper will read it — every
sentence on its own line, in sort order, each editable, each able to carry
citations — and writes the paragraph back whole. Whitespace-only lines are
dropped on save; a citation with an empty 引用内容 is refused; and 所属段落
moves the paragraph to another position, appended after what is already there.
2026-09-18 17:29:16 +08:00
govin 06d7e922bd backend: add the paper authoring schema, API, and smoke test
A paper is a document written against a template. Its structure is never
copied into it: `paper` points at a template and the outline is read live on
every render, so switching `template_id` re-shapes the whole document in one
write.

Sentences are addressed by *position* rather than by a template row id:
`paper_sentence.paper_template_filed_sort` holds the sort of the placement the
sentence belongs to, and `sort` holds its place inside that paragraph. That
indirection is what makes a template switch non-destructive — a sentence that
remembers "position 7" lands on whatever the new template puts at position 7 —
and it is why a paragraph is any position either the template or the content
mentions: the structure survives with no content, and content whose position
the template does not define is still rendered, in order, under 未设定.

Citations are a table rather than a column, since one sentence may quote
several references. `quote` is required — a citation that does not say what it
quotes is refused with 422 — while `reference_id` is a plain nullable integer
with no foreign key, because the reference library does not exist yet.

Deleting a template a paper is written against is refused with 409 and a count,
matching how the field library refuses to drop a field still in use.

scripts/smoke_papers.py walks the whole loop — create, empty structure, write a
paragraph with citations, switch templates, keep unmatched positions, move a
paragraph, delete — in 40 checks, and cleans up after itself.
2026-09-18 17:29:12 +08:00
27 changed files with 5127 additions and 77 deletions
@@ -0,0 +1,165 @@
"""create paper authoring schema
Adds the three tables behind the 论文 feature — writing a paper against a
template:
``paper`` the document: title, author, status, keywords,
target journal, and the template it is written
against.
``paper_sentence`` one row per sentence, addressed by the *position*
(``paper_template_filed_sort``) of the paragraph it
belongs to rather than by a foreign key to a
``template_field`` row, plus its own ``sort``
inside that paragraph.
``paper_sentence_reference`` citations of one sentence, many per sentence.
Why the paragraph is addressed by position
------------------------------------------
Two templates have no rows in common; what they can share is a position. A
sentence that remembers "I sit at position 7" therefore survives a template
swap: it moves to whatever the new template puts at position 7, and a position
the new template does not have is still rendered, in order, under an unset
heading. Nothing is deleted by a swap and nothing is lost — see
``app.crud.paper.build_document``.
Notes on the foreign keys, as elsewhere in this schema: TiDB parses
``FOREIGN KEY`` for compatibility and then ignores it. They are declared to
document the relationships and the integrity is enforced in the application
layer. ``paper_sentence_reference.reference_id`` deliberately has **no**
foreign key: the reference library table does not exist yet.
Revision ID: c41d7b09e5af
Revises: 5030e3939a26
Create Date: 2026-09-18
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c41d7b09e5af"
down_revision: str | None = "5030e3939a26"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
#: ``CURRENT_TIMESTAMP`` rather than ``now()``: the spelling MySQL and TiDB
#: both accept as a DATETIME column default without expression parentheses.
_NOW = sa.text("CURRENT_TIMESTAMP")
def upgrade() -> None:
op.create_table(
"paper",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
# Nullable: a paper may exist before it has chosen a template.
sa.Column("template_id", sa.Integer(), nullable=True),
sa.Column("abstract", sa.Text(), nullable=True),
sa.Column("author", sa.String(length=255), nullable=True),
# 草稿 / 撰写中 / 已完成, stored as the short token.
sa.Column(
"status",
sa.String(length=16),
server_default=sa.text("'draft'"),
nullable=False,
),
# 关键词 as one comma-separated string.
sa.Column("keywords", sa.String(length=255), nullable=True),
sa.Column("target_journal", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=_NOW, nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=_NOW, nullable=False),
sa.ForeignKeyConstraint(
["template_id"], ["paper_template.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_paper_template_id", "paper", ["template_id"], unique=False)
op.create_table(
"paper_sentence",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("paper_id", sa.Integer(), nullable=False),
# Provenance: the template this sentence was written against. Cleared,
# not cascaded, when that template is deleted.
sa.Column("template_id", sa.Integer(), nullable=True),
# The paragraph, as the template placement's `sort` — not a field id.
sa.Column("paper_template_filed_sort", sa.Integer(), nullable=False),
# This sentence's position inside that paragraph.
sa.Column("sort", sa.Integer(), nullable=False),
# TEXT: a "sentence" is whatever the writer treats as one line, and a
# line can be long. No server default — MySQL and TiDB cannot default a
# TEXT column, and the ORM always supplies a value.
sa.Column("content", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=_NOW, nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=_NOW, nullable=False),
sa.ForeignKeyConstraint(["paper_id"], ["paper.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["template_id"], ["paper_template.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
# One index for the document read: every sentence of a paper, in paragraph
# then sentence order.
op.create_index(
"ix_paper_sentence_paper_paragraph_sort",
"paper_sentence",
["paper_id", "paper_template_filed_sort", "sort"],
unique=False,
)
op.create_index(
"ix_paper_sentence_template_id", "paper_sentence", ["template_id"], unique=False
)
op.create_table(
"paper_sentence_reference",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("sentence_id", sa.Integer(), nullable=False),
# No foreign key: the reference library does not exist yet. Plain
# integer, nullable, so a citation can be written before it is linked.
sa.Column("reference_id", sa.Integer(), nullable=True),
# 引用内容 — required. A citation that does not say what it quotes is
# rejected by the API.
sa.Column("quote", sa.Text(), nullable=False),
sa.Column("sort", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["sentence_id"], ["paper_sentence.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_paper_sentence_reference_sentence_sort",
"paper_sentence_reference",
["sentence_id", "sort"],
unique=False,
)
op.create_index(
"ix_paper_sentence_reference_reference_id",
"paper_sentence_reference",
["reference_id"],
unique=False,
)
def downgrade() -> None:
# Reverse dependency order throughout.
op.drop_index(
"ix_paper_sentence_reference_reference_id",
table_name="paper_sentence_reference",
)
op.drop_index(
"ix_paper_sentence_reference_sentence_sort",
table_name="paper_sentence_reference",
)
op.drop_table("paper_sentence_reference")
op.drop_index("ix_paper_sentence_template_id", table_name="paper_sentence")
op.drop_index(
"ix_paper_sentence_paper_paragraph_sort", table_name="paper_sentence"
)
op.drop_table("paper_sentence")
op.drop_index("ix_paper_template_id", table_name="paper")
op.drop_table("paper")
+2 -1
View File
@@ -2,9 +2,10 @@
from fastapi import APIRouter
from app.api.routes import health, section_fields, templates
from app.api.routes import health, papers, section_fields, templates
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(papers.router)
api_router.include_router(section_fields.router)
api_router.include_router(templates.router)
+304
View File
@@ -0,0 +1,304 @@
"""Paper endpoints (论文) — the writing surface of the application.
The shape of the API follows the shape of the work:
* ``/papers`` is the library: create, list, edit, delete.
* ``/papers/{id}/document`` is the paper as it reads — the template's
paragraphs in order, each with the sentences stored at its position. It is
one request, because a client should never have to stitch the structure and
the content together and risk ordering them differently than the server does.
* ``/papers/{id}/paragraphs/{sort}`` is the writer's unit of work: read one
paragraph, write it back whole.
Two rules are enforced here rather than in the schema, because they reference
rows in other tables: a paper may only point at a template that exists, and a
template may not be deleted while a paper is written against it.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from app.crud import paper as crud
from app.db.session import get_db
from app.models import Paper, PaperSentence, PaperTemplate
from app.schemas.common import BatchDeleteRequest, BatchDeleteResult, PageResult
from app.schemas.paper import (
PaperCreate,
PaperDocumentRead,
PaperListItem,
PaperRead,
PaperUpdate,
ParagraphListRead,
ParagraphUpdate,
SentenceCreate,
SentenceRead,
SentenceUpdate,
)
router = APIRouter(prefix="/papers", tags=["papers"])
def _get_or_404(db: Session, paper_id: int) -> Paper:
paper = crud.get(db, paper_id)
if paper is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"论文 {paper_id} 不存在",
)
return paper
def _get_sentence_or_404(db: Session, paper: Paper, sentence_id: int) -> PaperSentence:
"""Find a sentence *within* this paper.
Scoping the lookup to the paper is what stops a sentence id from one paper
being edited through another paper's URL.
"""
for sentence in paper.sentences:
if sentence.id == sentence_id:
return sentence
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"句子 {sentence_id} 不属于论文 {paper.id}",
)
def _assert_template_exists(db: Session, template_id: int | None) -> None:
"""Reject a paper pointing at a template that is not there.
Checked in Python rather than by a foreign key, because TiDB parses but
does not enforce ``FOREIGN KEY`` — an unchecked write would happily leave a
paper with no structure and no way to notice.
"""
if template_id is None:
return
if db.get(PaperTemplate, template_id) is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"模板 {template_id} 不存在",
)
@router.get("", response_model=PageResult[PaperListItem], summary="List papers")
def list_papers(
db: Session = Depends(get_db),
keyword: str | None = Query(default=None, description="按标题、作者或关键词模糊搜索"),
status_filter: str | None = Query(
default=None,
alias="status",
description="按状态过滤:draft / writing / done",
),
template_id: int | None = Query(default=None, description="按所用模板过滤"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
) -> PageResult[PaperListItem]:
"""Browse papers, most recently edited first."""
items, total = crud.list_papers(
db,
keyword=keyword,
status=status_filter,
template_id=template_id,
page=page,
page_size=page_size,
)
return PageResult.build(items=items, total=total, page=page, page_size=page_size)
@router.post(
"",
response_model=PaperRead,
status_code=status.HTTP_201_CREATED,
summary="Create a paper",
)
def create_paper(payload: PaperCreate, db: Session = Depends(get_db)) -> PaperRead:
"""Create a paper against a template.
Nothing is written into the sentence table: the outline is the template's,
read live on every render, so a new paper is already the right shape with
every paragraph empty.
"""
_assert_template_exists(db, payload.template_id)
return crud.read(crud.create(db, payload))
@router.post(
"/batch-delete",
response_model=BatchDeleteResult,
summary="Delete several papers",
)
def batch_delete_papers(
payload: BatchDeleteRequest,
db: Session = Depends(get_db),
) -> BatchDeleteResult:
"""Delete the given papers, with all of their sentences and citations."""
return BatchDeleteResult(deleted=crud.delete_many(db, payload.ids))
@router.get("/{paper_id}", response_model=PaperRead, summary="Fetch one paper")
def get_paper(paper_id: int, db: Session = Depends(get_db)) -> PaperRead:
"""Return a paper's metadata and its written/defined paragraph counts."""
return crud.read(_get_or_404(db, paper_id))
@router.get(
"/{paper_id}/document",
response_model=PaperDocumentRead,
summary="Fetch one paper as a document",
)
def get_paper_document(
paper_id: int, db: Session = Depends(get_db)
) -> PaperDocumentRead:
"""Return the whole paper: headings, sentences, citations, warnings.
Paragraphs arrive in ascending position order. Positions the template does
not define are included when content exists there, with ``matched: false``
and no heading — the client renders those as 未设定 rather than dropping
them.
"""
return crud.build_document(db, _get_or_404(db, paper_id))
@router.patch("/{paper_id}", response_model=PaperRead, summary="Update a paper")
def update_paper(
paper_id: int,
payload: PaperUpdate,
db: Session = Depends(get_db),
) -> PaperRead:
"""Update the paper's metadata, including which template it uses.
Changing ``template_id`` is the template switch. Sentences keep their
positions and gain the new template id, so the same content reappears under
the new template's headings — and switching back restores the old layout
exactly.
"""
paper = _get_or_404(db, paper_id)
if "template_id" in payload.model_fields_set:
_assert_template_exists(db, payload.template_id)
# Only the keys the client actually sent are applied: an omitted field must
# not be mistaken for an explicit null.
values = payload.model_dump(exclude_unset=True)
template_changed = (
"template_id" in values and values["template_id"] != paper.template_id
)
return crud.read(crud.update(db, paper, values=values, template_changed=template_changed))
@router.delete(
"/{paper_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a paper",
)
def delete_paper(paper_id: int, db: Session = Depends(get_db)) -> Response:
"""Delete a paper with its sentences and citations."""
crud.delete(db, _get_or_404(db, paper_id))
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/{paper_id}/paragraphs/{field_sort}",
response_model=ParagraphListRead,
summary="Read one paragraph",
)
def get_paragraph(
paper_id: int,
field_sort: int,
db: Session = Depends(get_db),
) -> ParagraphListRead:
"""Return one paragraph, assembled exactly as the document renders it.
A position that neither the template nor the content knows about is a 404:
there is no paragraph there to edit.
"""
paper = _get_or_404(db, paper_id)
paragraph = crud.get_paragraph(db, paper, field_sort)
if paragraph is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"论文 {paper_id} 没有第 {field_sort}",
)
return ParagraphListRead(
paper_id=paper.id,
paper_title=paper.title,
template_id=paper.template_id,
paragraph=paragraph,
)
@router.put(
"/{paper_id}/paragraphs/{field_sort}",
response_model=PaperDocumentRead,
summary="Replace one paragraph",
)
def replace_paragraph(
paper_id: int,
field_sort: int,
payload: ParagraphUpdate,
db: Session = Depends(get_db),
) -> PaperDocumentRead:
"""Write a paragraph whole, and return the refreshed document.
The whole document comes back so the reader view updates in place: after a
move, the paragraph the editor had open no longer exists at that position,
and re-deriving the page from a fresh document is simpler than patching a
stale copy.
"""
paper = _get_or_404(db, paper_id)
crud.replace_paragraph(db, paper, field_sort, payload)
return crud.build_document(db, paper)
@router.post(
"/{paper_id}/sentences",
response_model=SentenceRead,
status_code=status.HTTP_201_CREATED,
summary="Append one sentence",
)
def create_sentence(
paper_id: int,
payload: SentenceCreate,
db: Session = Depends(get_db),
) -> SentenceRead:
"""Add a single sentence to a paragraph, at the end unless told otherwise.
The paragraph does not have to exist in the template: a position the
template does not define is exactly the case the renderer already handles.
"""
paper = _get_or_404(db, paper_id)
return SentenceRead.model_validate(crud.append_sentence(db, paper, payload))
@router.patch(
"/{paper_id}/sentences/{sentence_id}",
response_model=SentenceRead,
summary="Update one sentence",
)
def update_sentence(
paper_id: int,
sentence_id: int,
payload: SentenceUpdate,
db: Session = Depends(get_db),
) -> SentenceRead:
"""Edit one sentence's text, its position, or its citations."""
paper = _get_or_404(db, paper_id)
sentence = _get_sentence_or_404(db, paper, sentence_id)
updated = crud.update_sentence(db, paper, sentence, payload)
return SentenceRead.model_validate(updated)
@router.delete(
"/{paper_id}/sentences/{sentence_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete one sentence",
)
def delete_sentence(
paper_id: int,
sentence_id: int,
db: Session = Depends(get_db),
) -> Response:
"""Delete one sentence and the citations attached to it."""
paper = _get_or_404(db, paper_id)
crud.delete_sentence(db, paper, _get_sentence_or_404(db, paper, sentence_id))
return Response(status_code=status.HTTP_204_NO_CONTENT)
+42 -2
View File
@@ -4,11 +4,17 @@ A template is created from two pieces of free text plus a free selection of
library fields. Nothing constrains the selection: the same field may be picked
twice, the picks may arrive in any order, and the only thing that decides how
the outline reads is ``sort``.
A template that a paper is written against is also *structure* for that paper,
not only configuration: deleting it would leave the paper with nothing to
render. That delete is therefore refused, with the same 409 the field library
uses for a field still placed in a template.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from app.crud import paper as paper_crud
from app.crud import paper_template as crud
from app.db.session import get_db
from app.models import PaperTemplate
@@ -41,6 +47,32 @@ def _assert_name_free(db: Session, name: str, *, exclude_id: int | None = None)
)
def _assert_not_used_by_papers(db: Session, template_ids: list[int]) -> None:
"""Refuse to delete templates that papers are written against.
All offenders are reported at once, so a batch delete does not turn into
trial and error. The fix is a template switch on the paper, which is one
click in the paper's own view — the message says so.
"""
counts = paper_crud.template_paper_counts(db, template_ids)
if not counts:
return
blockers = []
for template_id, count in sorted(counts.items()):
template = db.get(PaperTemplate, template_id)
name = template.name if template is not None else template_id
blockers.append(f"{name}”({count} 篇论文)")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"以下模板正被论文使用,请先在论文里切换模板:"
+ "".join(blockers)
),
)
def _assert_fields_exist(db: Session, field_ids: list[int]) -> None:
"""Reject a selection that references fields the library does not have.
@@ -108,6 +140,7 @@ def batch_delete_templates(
db: Session = Depends(get_db),
) -> BatchDeleteResult:
"""Delete the given templates and all of their placement rows."""
_assert_not_used_by_papers(db, list(payload.ids))
return BatchDeleteResult(deleted=crud.delete_many(db, payload.ids))
@@ -162,6 +195,13 @@ def update_template(
summary="Delete a paper template",
)
def delete_template(template_id: int, db: Session = Depends(get_db)) -> Response:
"""Delete a template. Library fields it referenced are left alone."""
crud.delete(db, _get_or_404(db, template_id))
"""Delete a template. Library fields it referenced are left alone.
Refused while a paper is written against the template: the outline is the
paper's structure, and removing it would empty the paper rather than tidy
up configuration.
"""
template = _get_or_404(db, template_id)
_assert_not_used_by_papers(db, [template_id])
crud.delete(db, template)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+2 -2
View File
@@ -5,6 +5,6 @@ models. They own the commit: a route calls one function and gets back either a
persisted object or ``None``.
"""
from app.crud import paper_template, section_field
from app.crud import paper, paper_template, section_field
__all__ = ["paper_template", "section_field"]
__all__ = ["paper", "paper_template", "section_field"]
+608
View File
@@ -0,0 +1,608 @@
"""Data access for papers: metadata, sentences, and the document they assemble into.
The heart of the module is :func:`build_document`. Nothing stores a paper's
final shape; it is recomputed on every read from two sources:
1. the **template** the paper points at — its placements, ordered by ``sort``,
which supply the headings and the empty structure;
2. the **sentences** — ordered by ``paper_template_filed_sort`` then ``sort``,
which supply the content.
A paragraph exists if either side mentions its position. That single rule buys
all three behaviours the feature needs:
* a paragraph with no content still renders (structure survives);
* a sentence whose position the template does not define still renders, under
an unset heading, in the right place in the order (nothing is lost when the
template changes);
* swapping the template changes nothing but the headings, because sentences are
addressed by position and never by a template row id.
Deleting is done in Python rather than with ``ON DELETE CASCADE`` because TiDB
parses foreign keys and then ignores them; the ORM's cascades are the only
thing that actually removes dependent rows.
"""
from collections import defaultdict
from collections.abc import Iterable, Sequence
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy import update as sa_update
from sqlalchemy.orm import Session
from app.crud.filters import LIKE_ESCAPE, like_pattern
from app.models import (
Paper,
PaperSentence,
PaperSentenceReference,
PaperTemplate,
TemplateField,
)
from app.schemas.paper import (
CitationInput,
PaperCitationRead,
PaperCreate,
PaperDocumentRead,
PaperListItem,
PaperRead,
ParagraphRead,
ParagraphUpdate,
SentenceCreate,
SentenceRead,
SentenceUpdate,
)
# --- list / read -------------------------------------------------------------
def _conditions(
keyword: str | None,
status: str | None,
template_id: int | None,
) -> list:
"""Translate the list filters into SQLAlchemy predicates."""
conditions = []
if keyword:
pattern = like_pattern(keyword)
conditions.append(
or_(
Paper.title.like(pattern, escape=LIKE_ESCAPE),
Paper.author.like(pattern, escape=LIKE_ESCAPE),
Paper.keywords.like(pattern, escape=LIKE_ESCAPE),
)
)
if status:
conditions.append(Paper.status == status)
if template_id is not None:
conditions.append(Paper.template_id == template_id)
return conditions
def _sentence_count_column():
"""Correlated count of the paper's sentences."""
return (
select(func.count(PaperSentence.id))
.where(PaperSentence.paper_id == Paper.id)
.correlate(Paper)
.scalar_subquery()
)
def _paragraph_count_column():
"""Correlated count of the *distinct paragraphs* the paper has content in.
``DISTINCT`` rather than a plain count: this is "how many paragraphs have
been written", so ten sentences in one paragraph read as 1, not 10.
"""
return (
select(func.count(func.distinct(PaperSentence.paper_template_filed_sort)))
.where(PaperSentence.paper_id == Paper.id)
.correlate(Paper)
.scalar_subquery()
)
def _template_paragraph_count_column():
"""Correlated count of the paragraphs the paper's template defines.
The denominator of the progress read-out. ``DISTINCT`` for the same reason
ties are legal in a template: two placements may share a position, and that
is one paragraph, not two. A paper with no template counts 0.
"""
return (
select(func.count(func.distinct(TemplateField.sort)))
.where(TemplateField.template_id == Paper.template_id)
.correlate(Paper)
.scalar_subquery()
)
def _list_item(
paper: Paper,
*,
sentence_count: int,
paragraph_count: int,
template_paragraph_count: int,
) -> PaperListItem:
"""Assemble one table row from a paper and its three counts."""
return PaperListItem(
id=paper.id,
title=paper.title,
template_id=paper.template_id,
# ``Paper.template`` is eagerly joined, so this costs nothing here.
template_name=paper.template.name if paper.template is not None else None,
author=paper.author,
status=paper.status,
keywords=paper.keywords,
target_journal=paper.target_journal,
sentence_count=sentence_count,
paragraph_count=paragraph_count,
template_paragraph_count=template_paragraph_count,
created_at=paper.created_at,
updated_at=paper.updated_at,
)
def list_papers(
db: Session,
*,
keyword: str | None = None,
status: str | None = None,
template_id: int | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[PaperListItem], int]:
"""Return one page of papers, most recently edited first.
The three counts arrive as correlated subqueries rather than as loads of
the sentences themselves: a table row needs "3 / 12 段", not three hundred
sentence rows.
"""
conditions = _conditions(keyword, status, template_id)
total = db.scalar(select(func.count(Paper.id)).where(*conditions)) or 0
stmt = (
select(
Paper,
_sentence_count_column().label("sentence_count"),
_paragraph_count_column().label("paragraph_count"),
_template_paragraph_count_column().label("template_paragraph_count"),
)
.where(*conditions)
.order_by(Paper.updated_at.desc(), Paper.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = [
_list_item(
paper,
sentence_count=sentence_count,
paragraph_count=paragraph_count,
template_paragraph_count=template_paragraph_count,
)
for paper, sentence_count, paragraph_count, template_paragraph_count in (
db.execute(stmt).all()
)
]
return items, total
def get(db: Session, paper_id: int) -> Paper | None:
"""Return one paper with its sentences and template loaded, or ``None``.
Both relationships are configured for eager loading, so this is a fixed
number of queries rather than one per sentence.
"""
return db.get(Paper, paper_id)
def get_many(db: Session, paper_ids: Sequence[int]) -> list[Paper]:
"""Return every paper whose id is in ``paper_ids`` (missing ids ignored)."""
if not paper_ids:
return []
return list(db.scalars(select(Paper).where(Paper.id.in_(list(paper_ids)))).all())
def read(paper: Paper) -> PaperRead:
"""Build the detail payload for one already-loaded paper.
The counts are computed in Python from the eager-loaded relationships, so
they always agree with the document the same request renders — a SQL count
and a rendered list can never drift apart this way.
"""
item = _list_item(
paper,
sentence_count=len(paper.sentences),
paragraph_count=len({s.paper_template_filed_sort for s in paper.sentences}),
template_paragraph_count=(
len({placement.sort for placement in paper.template.items})
if paper.template is not None
else 0
),
)
return PaperRead(**item.model_dump(), abstract=paper.abstract)
# --- document assembly -------------------------------------------------------
def build_document(db: Session, paper: Paper) -> PaperDocumentRead:
"""Assemble the whole paper: structure, content, citations, warnings.
One pass over the placements and one over the sentences. Positions are
unioned, so a position only one of the two sides knows about still appears:
an empty paragraph from the template, an unmatched paragraph from the
content.
"""
warnings: list[str] = []
# --- 1. the template's side: which heading sits at which position -------
#
# Ties are legal in a template (that is deliberate upstream), so the first
# placement at a position — the relationship hands them over ordered by
# ``sort, id`` — is the one that renders the heading. The reader is told,
# because two fields sharing a position is far more often a mistake than a
# plan.
placement_by_sort: dict[int, TemplateField] = {}
tied: dict[int, list[str]] = defaultdict(list)
for item in paper.template.items if paper.template is not None else []:
if item.sort in placement_by_sort:
tied[item.sort].append(item.field.name)
else:
placement_by_sort[item.sort] = item
for sort, names in sorted(tied.items()):
head = placement_by_sort[sort].field.name
warnings.append(
f"模板在第 {sort} 段放了多个字段({head}{''.join(names)}),"
"正文按第一条显示。"
)
# --- 2. the content's side: sentences grouped by position ---------------
sentences_by_sort: dict[int, list[PaperSentence]] = defaultdict(list)
for sentence in paper.sentences:
sentences_by_sort[sentence.paper_template_filed_sort].append(sentence)
if paper.template is None and paper.sentences:
warnings.append("这篇论文还没有选择模板,段落没有对应标题,全部按未设定显示。")
if paper.template is not None and not paper.template.items:
warnings.append("所用模板还没有任何段落字段,论文暂时没有结构。")
# --- 3. the union, in ascending position order --------------------------
positions = sorted(set(placement_by_sort) | set(sentences_by_sort))
paragraphs: list[ParagraphRead] = []
citations: list[PaperCitationRead] = []
citation_index = 0
for position in positions:
placement = placement_by_sort.get(position)
field = placement.field if placement is not None else None
# Re-sorted rather than trusted: the relationship already orders rows,
# but ordering is a contract of this response, so it is stated here too.
rows = sorted(
sentences_by_sort.get(position, []),
key=lambda item: (item.sort, item.id),
)
sentences = [SentenceRead.model_validate(row) for row in rows]
paragraph = ParagraphRead(
paper_template_filed_sort=position,
template_field_id=placement.id if placement is not None else None,
field_id=field.id if field is not None else None,
name=field.name if field is not None else None,
level=field.level if field is not None else 1,
font_size=float(field.font_size) if field is not None else None,
font_color=field.font_color if field is not None else None,
matched=placement is not None,
sentences=sentences,
)
# Citations are numbered in reading order, so a marker in the text and
# its entry in the 参考文献 list cannot disagree.
for sentence in sentences:
for citation in sentence.citations:
citation_index += 1
citations.append(
PaperCitationRead(
index=citation_index,
id=citation.id,
reference_id=citation.reference_id,
quote=citation.quote,
sentence_id=sentence.id,
sentence_content=sentence.content,
paper_template_filed_sort=position,
paragraph_name=paragraph.name,
)
)
paragraphs.append(paragraph)
return PaperDocumentRead(
paper=read(paper),
paragraphs=paragraphs,
citations=citations,
warnings=warnings,
)
def find_paragraph(
document: PaperDocumentRead, field_sort: int
) -> ParagraphRead | None:
"""Pick one paragraph out of an assembled document."""
for paragraph in document.paragraphs:
if paragraph.paper_template_filed_sort == field_sort:
return paragraph
return None
def get_paragraph(db: Session, paper: Paper, field_sort: int) -> ParagraphRead | None:
"""Return one paragraph, assembled exactly as the document renders it.
Assembling the whole document to return one paragraph is not waste: it is
what guarantees the editor shows precisely what the reader will see, rather
than a second implementation of the same ordering rules.
"""
return find_paragraph(build_document(db, paper), field_sort)
# --- writes ------------------------------------------------------------------
def _touch(db: Session, paper: Paper) -> None:
"""Mark the paper as edited, on the database clock.
Writing content does not itself change any column of ``paper``, so the
``onupdate`` on ``updated_at`` would not fire and the list — which sorts by
it — would keep showing the creation time of a paper whose text changed an
hour ago. ``func.now()`` rather than Python's clock, so timestamps do not
depend on which host the API runs on.
"""
paper.updated_at = func.now() # type: ignore[assignment]
def _commit_touch(db: Session, paper: Paper) -> None:
"""Commit a content change and refresh the paper's timestamps.
The refresh is what turns the ``func.now()`` expression above back into a
real datetime for the response — MySQL and TiDB have no ``RETURNING``, so
the value only exists after a re-read.
"""
_touch(db, paper)
db.commit()
db.refresh(paper)
def _build_citations(items: Sequence[CitationInput]) -> list[PaperSentenceReference]:
"""Materialise the citation list of one sentence, numbering it from 1."""
return [
PaperSentenceReference(
reference_id=item.reference_id,
quote=item.quote,
sort=index,
)
for index, item in enumerate(items, start=1)
]
def _max_sort(db: Session, paper: Paper, field_sort: int) -> int:
"""The highest sentence sort currently used in one paragraph (0 if empty)."""
value = db.scalar(
select(func.coalesce(func.max(PaperSentence.sort), 0)).where(
PaperSentence.paper_id == paper.id,
PaperSentence.paper_template_filed_sort == field_sort,
)
)
return int(value or 0)
def create(db: Session, data: PaperCreate) -> Paper:
"""Insert a paper. No rows are created for its paragraphs.
The structure is read from the template on every render, so materialising
an empty sentence per paragraph would create rows that exist only to say
nothing — and that would then have to be kept in step with the template.
"""
paper = Paper(**data.model_dump())
db.add(paper)
db.commit()
db.refresh(paper)
return paper
def update(
db: Session,
paper: Paper,
*,
values: dict[str, Any],
template_changed: bool,
) -> Paper:
"""Apply a partial update.
``values`` holds only the keys the client actually sent — the route derives
it from ``model_fields_set``, which is the only way to tell "clear the
abstract" apart from "leave it alone"; both arrive as ``None``.
When the template changes, every sentence is re-stamped with the new
template id. The sentences themselves keep their positions, which is what
makes a switch non-destructive — and it keeps the pair
``(template_id, paper_template_filed_sort)`` an honest description of which
paragraph a sentence belongs to.
"""
for key, value in values.items():
setattr(paper, key, value)
if template_changed:
db.flush()
db.execute(
sa_update(PaperSentence)
.where(PaperSentence.paper_id == paper.id)
.values(template_id=paper.template_id)
)
_touch(db, paper)
db.commit()
db.refresh(paper)
return paper
def delete(db: Session, paper: Paper) -> None:
"""Delete a paper and, through the ORM cascade, all of its sentences."""
db.delete(paper)
db.commit()
def delete_many(db: Session, paper_ids: Sequence[int]) -> int:
"""Delete several papers, returning how many actually existed."""
papers = get_many(db, paper_ids)
for paper in papers:
db.delete(paper)
db.commit()
return len(papers)
# --- paragraphs and sentences ------------------------------------------------
def replace_paragraph(
db: Session,
paper: Paper,
field_sort: int,
payload: ParagraphUpdate,
) -> None:
"""Rewrite one paragraph's sentences, optionally moving the paragraph.
The editor sends the paragraph whole — every line it shows — so this is a
replacement, not a merge. ``target_sort`` is the manual form of what a
template switch does by itself: the sentences are written at another
position and appended after whatever that paragraph already holds, which is
why the numbering is computed from the target's current maximum rather than
from 1.
Blank lines are kept if the client sends them: an empty sentence is a
legitimate placeholder and renders as a blank line rather than vanishing.
"""
target = payload.target_sort if payload.target_sort is not None else field_sort
# Clear the source paragraph first, so that a move onto itself does not
# count its own rows when working out where the appended block starts.
existing = list(
db.scalars(
select(PaperSentence).where(
PaperSentence.paper_id == paper.id,
PaperSentence.paper_template_filed_sort == field_sort,
)
).all()
)
for row in existing:
db.delete(row)
db.flush()
base = _max_sort(db, paper, target)
for offset, item in enumerate(payload.sentences, start=1):
db.add(
PaperSentence(
paper_id=paper.id,
# Kept in step with the paper's template: the pair
# (template_id, position) is what names the paragraph.
template_id=paper.template_id,
paper_template_filed_sort=target,
sort=item.sort if item.sort is not None else base + offset,
content=item.content,
citations=_build_citations(item.citations),
)
)
_commit_touch(db, paper)
def append_sentence(db: Session, paper: Paper, data: SentenceCreate) -> PaperSentence:
"""Add one sentence to a paragraph, at the end unless told otherwise."""
sort = (
data.sort
if data.sort is not None
else _max_sort(db, paper, data.paper_template_filed_sort) + 1
)
sentence = PaperSentence(
paper_id=paper.id,
template_id=paper.template_id,
paper_template_filed_sort=data.paper_template_filed_sort,
sort=sort,
content=data.content,
citations=_build_citations(data.citations),
)
db.add(sentence)
_commit_touch(db, paper)
db.refresh(sentence)
return sentence
def update_sentence(
db: Session,
paper: Paper,
sentence: PaperSentence,
data: SentenceUpdate,
) -> PaperSentence:
"""Apply a partial update to one sentence.
``citations`` is a full replacement when present: the sentence's citation
list is short, and replacing it wholesale is the only way to be sure that a
deleted citation really is gone.
"""
if data.sort is not None:
sentence.sort = data.sort
if data.paper_template_filed_sort is not None:
sentence.paper_template_filed_sort = data.paper_template_filed_sort
if data.content is not None:
sentence.content = data.content
if data.citations is not None:
sentence.citations.clear()
db.flush()
sentence.citations.extend(_build_citations(data.citations))
sentence.template_id = paper.template_id
_commit_touch(db, paper)
db.refresh(sentence)
return sentence
def delete_sentence(db: Session, paper: Paper, sentence: PaperSentence) -> None:
"""Delete one sentence and its citations."""
db.delete(sentence)
_commit_touch(db, paper)
# --- template housekeeping ---------------------------------------------------
def _paper_counts_by_template(
db: Session, template_ids: Iterable[int]
) -> dict[int, int]:
"""How many papers use each template id."""
wanted = list({template_id for template_id in template_ids})
if not wanted:
return {}
stmt = (
select(Paper.template_id, func.count(Paper.id))
.where(Paper.template_id.in_(wanted))
.group_by(Paper.template_id)
)
return {template_id: count for template_id, count in db.execute(stmt).all()}
def template_paper_counts(
db: Session, template_ids: Sequence[int]
) -> dict[int, int]:
"""Public form of :func:`_paper_counts_by_template`.
Used by the template endpoints: deleting a template a paper is written
against would leave that paper with no structure at all, so it is refused
with the count that explains why.
"""
return _paper_counts_by_template(db, template_ids)
+20
View File
@@ -3,14 +3,34 @@
Importing this package registers every model on ``Base.metadata``, which is
what ``alembic revision --autogenerate`` inspects. A new model therefore has to
be added to the imports below, not only to its own module.
The import order here is for readability only: relationships are declared by
name and resolved through the SQLAlchemy registry after every module has been
imported, so a cycle between two model modules is not a problem.
"""
from app.models.mixins import TimestampMixin
from app.models.paper import (
PAPER_STATUSES,
STATUS_DONE,
STATUS_DRAFT,
STATUS_WRITING,
Paper,
)
from app.models.paper_sentence import PaperSentence
from app.models.paper_sentence_reference import PaperSentenceReference
from app.models.paper_template import PaperTemplate
from app.models.section_field import SectionField
from app.models.template_field import TemplateField
__all__ = [
"PAPER_STATUSES",
"STATUS_DONE",
"STATUS_DRAFT",
"STATUS_WRITING",
"Paper",
"PaperSentence",
"PaperSentenceReference",
"PaperTemplate",
"SectionField",
"TemplateField",
+116
View File
@@ -0,0 +1,116 @@
"""Papers (论文表) — the document this tool exists to produce.
A paper is a bag of metadata plus two things that are deliberately kept apart:
* its **structure**, which is not stored here at all. Every render reads the
:class:`~app.models.paper_template.PaperTemplate` the paper points at, live.
Nothing is copied, so switching ``template_id`` re-shapes the whole document
in one write.
* its **content**, which lives in
:class:`~app.models.paper_sentence.PaperSentence` — one row per sentence,
addressed by the paragraph's ``sort`` inside that template rather than by a
foreign key to a ``template_field`` row.
That second choice is what makes the promised workflow work. Because a sentence
remembers "I belong at position 7", not "I belong to placement #42", swapping
the template moves every sentence to whatever the new template has at position
7. Sentences whose position the new template does not have are not lost and not
hidden: they are still rendered in position order, under an *unset* heading.
See :func:`app.crud.paper.build_document`.
``status`` is one of :data:`PAPER_STATUSES`. It is stored as the short token
(``draft``), not as the Chinese label, so the wording in the UI can change
without a migration.
"""
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
if TYPE_CHECKING: # pragma: no cover - typing only
from app.models.paper_sentence import PaperSentence
from app.models.paper_template import PaperTemplate
#: Writing state of a paper: 草稿 / 撰写中 / 已完成.
#:
#: Exactly the values a client may send; the API validates against this tuple,
#: so adding a state is a one-line change here plus one label in the UI.
PAPER_STATUSES: tuple[str, ...] = ("draft", "writing", "done")
STATUS_DRAFT = "draft"
STATUS_WRITING = "writing"
STATUS_DONE = "done"
class Paper(TimestampMixin, Base):
"""One paper: a working title, who is writing it, and where it is going."""
__tablename__ = "paper"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
#: Working title. Not unique: two papers may legitimately share a title
#: while one is being split off the other.
title: Mapped[str] = mapped_column(String(255), nullable=False)
#: The template this paper is currently written against, or ``None`` for a
#: paper that has not chosen one yet. Nullable only for robustness — the
#: UI asks for a template at creation time — and a paper without one simply
#: renders its sentences under unset headings.
#:
#: ``RESTRICT`` documents the intent (a template in use must not vanish);
#: TiDB does not enforce it, so the API refuses the template delete too.
template_id: Mapped[int | None] = mapped_column(
ForeignKey("paper_template.id", ondelete="RESTRICT"),
nullable=True,
)
#: 摘要 — the paper's own abstract, free text.
abstract: Mapped[str | None] = mapped_column(Text, nullable=True)
#: 作者. Free text, since authorship is written as it will be printed.
author: Mapped[str | None] = mapped_column(String(255), nullable=True)
#: One of :data:`PAPER_STATUSES`.
status: Mapped[str] = mapped_column(
String(16),
nullable=False,
default=STATUS_DRAFT,
server_default=text("'draft'"),
)
#: 关键词, stored as one comma-separated string. A list would need a table
#: of its own for something that is displayed as a row of tags and searched
#: as text; the split/join happens at the edges.
keywords: Mapped[str | None] = mapped_column(String(255), nullable=True)
#: 投稿目标期刊.
target_journal: Mapped[str | None] = mapped_column(String(255), nullable=True)
#: Eager-loaded with the paper: every read of a paper shows its template
#: name, and a lazy load there would be one query per row in the table.
template: Mapped["PaperTemplate | None"] = relationship(lazy="joined")
#: The paper's sentences, in document order.
#:
#: ``delete-orphan`` is doing real work: TiDB parses but does not enforce
#: ``ON DELETE CASCADE``, so removing a deleted paper's sentences is the
#: ORM's job. The ordering mirrors the render order exactly — paragraph
#: position first, then position inside the paragraph, then insertion.
sentences: Mapped[list["PaperSentence"]] = relationship(
back_populates="paper",
cascade="all, delete-orphan",
order_by=(
"PaperSentence.paper_template_filed_sort, "
"PaperSentence.sort, "
"PaperSentence.id"
),
lazy="selectin",
)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Paper id={self.id} title={self.title!r} status={self.status}>"
+105
View File
@@ -0,0 +1,105 @@
"""Paper sentences (论文句子表) — the actual writing.
One row is one sentence of one paper. The two columns that place it are the
whole design:
``paper_template_filed_sort``
The ``sort`` of the template placement this sentence belongs to — *not* a
foreign key to ``template_field``. It is deliberately the position rather
than the row id, because the position is the only thing two templates can
meaningfully have in common. Swap the paper's template and this sentence
lands on whatever the new template puts at that position, with no per
sentence editing. (The name keeps the spelling the feature was specified
with; it reads ``paper_template_field_sort``.)
``sort``
Where this sentence sits *inside* that paragraph. A paragraph is reassembled
by reading its rows in ascending ``sort``, which is why the column is
required and why the API never leaves it to chance. Values are sparse-
friendly — 10, 20, 30 leaves room to insert — and ties are legal, broken by
``id`` so the order is always total and stable.
``template_id`` records the template the sentence was *written against*. It is
provenance, not a lookup key: rendering never filters on it, which is exactly
why sentences survive a template switch. It is nullable because deleting an old
template clears it rather than leaving a dangling reference behind.
Position in the paper is therefore not stored anywhere as a whole; the document
is assembled on read from the paper's template plus these rows. See
:func:`app.crud.paper.build_document`.
"""
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
if TYPE_CHECKING: # pragma: no cover - typing only
from app.models.paper import Paper
from app.models.paper_sentence_reference import PaperSentenceReference
from app.models.paper_template import PaperTemplate
class PaperSentence(TimestampMixin, Base):
"""One sentence, at one position, inside one paragraph of one paper."""
__tablename__ = "paper_sentence"
__table_args__ = (
# The document query is "every sentence of this paper, in paragraph then
# sentence order", so one composite index covers filter and sort both.
Index(
"ix_paper_sentence_paper_paragraph_sort",
"paper_id",
"paper_template_filed_sort",
"sort",
),
Index("ix_paper_sentence_template_id", "template_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
paper_id: Mapped[int] = mapped_column(
ForeignKey("paper.id", ondelete="CASCADE"),
nullable=False,
)
#: The template this sentence was written against — provenance only. Reads
#: never filter on it; see the module docstring.
template_id: Mapped[int | None] = mapped_column(
ForeignKey("paper_template.id", ondelete="SET NULL"),
nullable=True,
)
#: Which paragraph, expressed as the template placement's ``sort``.
#: Required: a sentence with no paragraph has nowhere to be rendered.
paper_template_filed_sort: Mapped[int] = mapped_column(Integer, nullable=False)
#: This sentence's position inside that paragraph. Ascending, ties broken
#: by ``id``. Required for the same reason.
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
#: The sentence itself. May be empty: an empty sentence is a legitimate
#: placeholder and renders as a blank line rather than disappearing.
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
paper: Mapped["Paper"] = relationship(back_populates="sentences")
#: References quoted by this sentence, in citation order. Eager-loaded
#: because the document render always needs them, and one query per
#: sentence would be the difference between two queries and two hundred.
citations: Mapped[list["PaperSentenceReference"]] = relationship(
back_populates="sentence",
cascade="all, delete-orphan",
order_by="PaperSentenceReference.sort, PaperSentenceReference.id",
lazy="selectin",
)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return (
f"<PaperSentence id={self.id} paper_id={self.paper_id} "
f"paragraph={self.paper_template_filed_sort} sort={self.sort}>"
)
@@ -0,0 +1,72 @@
"""Citations attached to a sentence (句子引用关联表).
A sentence may quote **several** references, which makes this a real
many-to-many relation rather than a column on ``paper_sentence`` — hence a
table of its own.
Why there is no foreign key to a reference table
------------------------------------------------
References are going to be maintained in their own table later. This one
therefore stores a plain ``reference_id`` integer and declares **no** foreign
key at all: a key to a table that does not exist yet cannot be validated,
declared or migrated. The column is nullable so a citation can be written
before it has been linked to a reference row.
What makes a citation valid
---------------------------
``quote`` — the quoted content — is required and must not be blank. A citation
that points at something without saying what it points at is not usable in a
document, so the API rejects it (``app.schemas.paper.CitationInput``) rather
than storing a dangling half-record. The reverse is fine: a citation may carry
its quoted content with ``reference_id`` still empty, and be linked later.
"""
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING: # pragma: no cover - typing only
from app.models.paper_sentence import PaperSentence
class PaperSentenceReference(Base):
"""One citation of one sentence: what is quoted, and optionally by which id."""
__tablename__ = "paper_sentence_reference"
__table_args__ = (
# Reads are "the citations of this sentence, in order".
Index("ix_paper_sentence_reference_sentence_sort", "sentence_id", "sort"),
# The reference side is indexed for the lookup that arrives with the
# reference table: "which sentences quote reference N".
Index("ix_paper_sentence_reference_reference_id", "reference_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sentence_id: Mapped[int] = mapped_column(
ForeignKey("paper_sentence.id", ondelete="CASCADE"),
nullable=False,
)
#: The id this citation will resolve to once the reference library exists.
#: Deliberately not a foreign key yet — see the module docstring.
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
#: 引用内容 — required, non-blank. The citation's whole payload today.
quote: Mapped[str] = mapped_column(Text, nullable=False)
#: Order among the citations of one sentence. Assigned from the order the
#: client sent them, so ``[1]`` in the text is the first stored row.
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
sentence: Mapped["PaperSentence"] = relationship(back_populates="citations")
def __repr__(self) -> str: # pragma: no cover - debugging aid
return (
f"<PaperSentenceReference sentence_id={self.sentence_id} "
f"reference_id={self.reference_id} sort={self.sort}>"
)
+32
View File
@@ -6,6 +6,23 @@ from app.schemas.common import (
PageResult,
normalize_hex_color,
)
from app.schemas.paper import (
CitationInput,
CitationRead,
PaperCitationRead,
PaperCreate,
PaperDocumentRead,
PaperListItem,
PaperRead,
PaperStatus,
PaperUpdate,
ParagraphListRead,
ParagraphRead,
ParagraphUpdate,
SentenceCreate,
SentenceRead,
SentenceUpdate,
)
from app.schemas.paper_template import (
PaperTemplateCreate,
PaperTemplateListItem,
@@ -23,6 +40,21 @@ from app.schemas.section_field import (
__all__ = [
"BatchDeleteRequest",
"CitationInput",
"CitationRead",
"PaperCitationRead",
"PaperCreate",
"PaperDocumentRead",
"PaperListItem",
"PaperRead",
"PaperStatus",
"PaperUpdate",
"ParagraphListRead",
"ParagraphRead",
"ParagraphUpdate",
"SentenceCreate",
"SentenceRead",
"SentenceUpdate",
"BatchDeleteResult",
"PageResult",
"PaperTemplateCreate",
+377
View File
@@ -0,0 +1,377 @@
"""Request/response schemas for papers (论文).
Three shapes of payload live here:
* the **paper** itself — title, author, status, keywords, target journal and
the template it is written against;
* **paragraph** payloads — the writer's unit of work. A paragraph is edited by
reading every sentence of it and writing the whole set back, so the update
schema is a full replacement;
* the **document** — what the reader sees: the template's paragraphs in order,
each carrying the sentences stored at its position, plus the flat citation
list for the 参考文献 block.
Text normalisation is deliberately strict, because the two sides of the wire
have to agree on what counts as empty:
* a *sentence* is one line — leading/trailing whitespace goes, and any run of
whitespace inside (including a pasted newline or an ideographic space) folds
to a single space. An all-whitespace sentence becomes the empty string, which
is legal and renders as a blank line;
* a *title* must not be blank once trimmed;
* a *citation* must quote something — non-blank ``quote`` — or it is rejected.
"""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
#: Writing states, mirroring :data:`app.models.paper.PAPER_STATUSES`. Kept as a
#: literal so the generated OpenAPI document describes the enum for clients.
PaperStatus = Literal["draft", "writing", "done"]
#: Characters that separate keywords in the stored string. Half-width and
#: full-width commas, the ideographic comma, and semicolons — a user types
#: whichever one their input method offers.
KEYWORD_SEPARATORS = ",,、;"
def fold_whitespace(value: str) -> str:
"""Collapse every whitespace run in ``value`` to one space and trim it.
``str.split`` with no argument splits on Unicode whitespace, which covers
the ideographic space (U+3000) a Chinese input method produces, so a pasted
line cannot smuggle an invisible full-width blank into a "trimmed" field.
"""
return " ".join(value.split())
def normalize_keywords(value: str | None) -> str | None:
"""Canonicalise the keyword string: trimmed, de-duplicated, ``,``-joined.
Returns ``None`` for a value that carries no keywords, so "cleared" and
"never set" are stored the same way.
"""
if value is None:
return None
parts: list[str] = []
for chunk in value.replace("", ";").replace("", ",").replace("", ",").split(";"):
for item in chunk.split(","):
word = fold_whitespace(item)
if word and word not in parts:
parts.append(word)
return ", ".join(parts) or None
class CitationInput(BaseModel):
"""One citation of one sentence.
``quote`` is required: the API refuses a citation that does not say what it
quotes. ``reference_id`` is optional, because the reference library is
still to come — a citation may be written now and linked later.
"""
reference_id: int | None = Field(default=None, ge=1)
quote: str = Field(default="", max_length=4000)
@field_validator("quote")
@classmethod
def _require_quote(cls, value: str) -> str:
text = value.strip()
if not text:
raise ValueError("引用的内容不能为空")
return text
class SentenceInput(BaseModel):
"""One sentence of a paragraph, as the editor sends it.
``sort`` is optional: omit it and the server numbers the sentences from 1
in the order they arrive, which is what the editor does by default. Send it
to keep a deliberate numbering (10, 20, 30 …) or a tie.
"""
sort: int | None = Field(default=None, ge=-1_000_000, le=1_000_000)
content: str = ""
citations: list[CitationInput] = Field(default_factory=list)
@field_validator("content")
@classmethod
def _normalise_content(cls, value: str) -> str:
return fold_whitespace(value)
class ParagraphUpdate(BaseModel):
"""Payload for ``PUT /papers/{id}/paragraphs/{sort}``.
A full replacement of the paragraph's sentences. ``target_sort`` moves the
whole paragraph: the sentences are written into another paragraph's
position, appended after whatever that paragraph already holds. That is the
manual form of what a template switch does by itself.
"""
sentences: list[SentenceInput] = Field(default_factory=list)
target_sort: int | None = Field(default=None, ge=-1_000_000, le=1_000_000)
class SentenceCreate(SentenceInput):
"""Payload for ``POST /papers/{id}/sentences`` — one new sentence."""
paper_template_filed_sort: int = Field(ge=-1_000_000, le=1_000_000)
class SentenceUpdate(BaseModel):
"""Payload for ``PATCH /papers/{id}/sentences/{sentence_id}``.
Every part is optional. ``citations`` is a full replacement when present.
"""
sort: int | None = Field(default=None, ge=-1_000_000, le=1_000_000)
paper_template_filed_sort: int | None = Field(
default=None, ge=-1_000_000, le=1_000_000
)
content: str | None = None
citations: list[CitationInput] | None = None
@field_validator("content")
@classmethod
def _normalise_content(cls, value: str | None) -> str | None:
return None if value is None else fold_whitespace(value)
# --- reads ------------------------------------------------------------------
class CitationRead(BaseModel):
"""A stored citation."""
model_config = ConfigDict(from_attributes=True)
id: int
reference_id: int | None
quote: str
sort: int
class SentenceRead(BaseModel):
"""A stored sentence with its citations."""
model_config = ConfigDict(from_attributes=True)
id: int
paper_id: int
template_id: int | None
paper_template_filed_sort: int
sort: int
content: str
citations: list[CitationRead]
created_at: datetime
updated_at: datetime
class ParagraphRead(BaseModel):
"""One paragraph of a paper: its heading, and the sentences under it.
``name`` is ``None`` when the paper's template has no placement at this
position — the case that makes a template switch non-destructive. The
heading is then rendered as *未设定* while the sentences keep their place in
the document; ``matched`` says which of the two happened.
"""
paper_template_filed_sort: int
#: The placement this paragraph resolved to, when there was one.
template_field_id: int | None = None
field_id: int | None = None
#: Heading text as written in the template — the numbering is part of it.
name: str | None = None
level: int = 1
font_size: float | None = None
font_color: str | None = None
#: ``True`` when the template has a placement at this position.
matched: bool = False
sentences: list[SentenceRead]
class PaperCitationRead(BaseModel):
"""One citation as it appears in the paper's 参考文献 list.
``index`` is the number shown in the text as ``[index]``; it counts
citations in reading order across the whole document, so the list and the
markers cannot disagree.
"""
index: int
id: int
reference_id: int | None
quote: str
sentence_id: int
sentence_content: str
paper_template_filed_sort: int
paragraph_name: str | None = None
class PaperBase(BaseModel):
"""Shared body of the paper create payload."""
title: str = Field(min_length=1, max_length=255)
template_id: int | None = None
abstract: str | None = None
author: str | None = Field(default=None, max_length=255)
status: PaperStatus = "draft"
keywords: str | None = Field(default=None, max_length=255)
target_journal: str | None = Field(default=None, max_length=255)
@field_validator("title")
@classmethod
def _require_title(cls, value: str) -> str:
text = value.strip()
if not text:
raise ValueError("论文标题不能为空")
return text
@field_validator("author", "target_journal")
@classmethod
def _blank_to_none(cls, value: str | None) -> str | None:
"""Treat a whitespace-only author or journal as "not set"."""
if value is None:
return None
return value.strip() or None
@field_validator("keywords")
@classmethod
def _normalise_keywords(cls, value: str | None) -> str | None:
return normalize_keywords(value)
class PaperCreate(PaperBase):
"""Payload for ``POST /papers``.
Creation is metadata only: the structure comes from the chosen template,
read live at render time, so there is nothing to copy and no empty rows to
materialise. The paper appears with its whole outline and no content.
"""
class PaperUpdate(BaseModel):
"""Payload for ``PATCH /papers/{id}`` — every part optional.
Changing ``template_id`` is the template switch: it re-shapes the document
in one write. Sentence rows are untouched, which is why switching back
restores the previous layout exactly.
"""
title: str | None = Field(default=None, min_length=1, max_length=255)
template_id: int | None = None
abstract: str | None = None
author: str | None = Field(default=None, max_length=255)
status: PaperStatus | None = None
keywords: str | None = Field(default=None, max_length=255)
target_journal: str | None = Field(default=None, max_length=255)
@field_validator("title")
@classmethod
def _require_title(cls, value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
if not text:
raise ValueError("论文标题不能为空")
return text
@field_validator("author", "target_journal")
@classmethod
def _blank_to_none(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip() or None
@field_validator("keywords")
@classmethod
def _normalise_keywords(cls, value: str | None) -> str | None:
return normalize_keywords(value)
class PaperListItem(BaseModel):
"""A paper as it appears in the table, without its content."""
id: int
title: str
template_id: int | None
#: The template's name, or ``None`` when no template is chosen.
template_name: str | None
author: str | None
status: PaperStatus
keywords: str | None
target_journal: str | None
#: How many sentences have been written.
sentence_count: int
#: How many distinct paragraphs those sentences occupy.
paragraph_count: int
#: How many paragraphs the current template defines — the denominator of
#: the progress read-out. 0 when no template is chosen.
template_paragraph_count: int
created_at: datetime
updated_at: datetime
class PaperRead(PaperListItem):
"""A paper with everything the table does not need."""
abstract: str | None
class PaperDocumentRead(BaseModel):
"""The whole paper as it reads: structure, content, citations, warnings.
Assembled by :func:`app.crud.paper.build_document` in a single pass, so a
client renders from one response and never has to stitch arrays together.
"""
paper: PaperRead
#: Paragraphs in ascending position order. Every position that either the
#: template or a sentence mentions appears exactly once, so an empty
#: paragraph is still a paragraph — the structure survives with no content.
paragraphs: list[ParagraphRead]
#: Citations in reading order, numbered to match the in-text markers.
citations: list[PaperCitationRead]
#: Anything the reader should be told about the document as a whole, e.g.
#: a template that places two fields at the same position.
warnings: list[str] = Field(default_factory=list)
class ParagraphListRead(BaseModel):
"""Response of a paragraph read: the paragraph plus the paper it belongs to.
The editor needs to know which paper it is editing and where else it could
move the paragraph to, and both are already loaded by the document query.
"""
paper_id: int
paper_title: str
template_id: int | None
paragraph: ParagraphRead
__all__ = [
"CitationInput",
"CitationRead",
"KEYWORD_SEPARATORS",
"PaperBase",
"PaperCitationRead",
"PaperCreate",
"PaperDocumentRead",
"PaperListItem",
"PaperRead",
"PaperStatus",
"PaperUpdate",
"ParagraphListRead",
"ParagraphRead",
"ParagraphUpdate",
"SentenceCreate",
"SentenceRead",
"SentenceUpdate",
"fold_whitespace",
"normalize_keywords",
]
+328
View File
@@ -0,0 +1,328 @@
"""End-to-end smoke test for the paper feature, against a running API.
Run it while the backend is up::
.venv/bin/python scripts/smoke_papers.py
It walks the whole writing loop — create a paper on a template, verify the
empty structure, fill a paragraph with sentences and citations, read the
document back, switch the template, and check that unmatched positions survive
under an unset heading — then deletes everything it created. Exits non-zero on
the first failed expectation, so it is usable as a gate.
"""
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
BASE = "http://127.0.0.1:8000/api"
_checks = 0
def call(method: str, path: str, body: Any = None, expect: int = 200) -> Any:
"""Perform one API call and assert its status code.
The path is quoted so a Chinese search keyword travels as UTF-8 percent
escapes instead of blowing up the ASCII request line.
"""
data = json.dumps(body).encode() if body is not None else None
url = BASE + urllib.parse.quote(path, safe="/?&=%")
request = urllib.request.Request(
url,
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
status = response.status
payload = response.read().decode()
except urllib.error.HTTPError as error:
status = error.code
payload = error.read().decode()
if status != expect:
raise AssertionError(f"{method} {path} -> {status}, expected {expect}: {payload}")
return json.loads(payload) if payload else None
def check(label: str, condition: bool, detail: str = "") -> None:
"""Assert one expectation and report it."""
global _checks
_checks += 1
if not condition:
raise AssertionError(f"{label}: {detail}")
print(f" ok {label}")
def main() -> int:
created_papers: list[int] = []
try:
print("1. pick two templates with different paragraph positions")
templates = call("GET", "/templates?page_size=50")["items"]
check("templates exist", len(templates) >= 2, f"got {len(templates)}")
wide = max(templates, key=lambda item: item["field_count"])
narrow = min(templates, key=lambda item: item["field_count"])
wide_full = call("GET", f"/templates/{wide['id']}")
narrow_full = call("GET", f"/templates/{narrow['id']}")
print(f" wide={wide['name']} ({wide['field_count']}), narrow={narrow['name']}")
print("2. create a paper")
paper = call(
"POST",
"/papers",
{
"title": "冒烟测试论文",
"template_id": wide["id"],
"author": "测试作者",
"status": "writing",
"keywords": "关键词A,关键词B;关键词A",
"target_journal": "测试期刊",
"abstract": "用于验证论文功能的临时数据。",
},
expect=201,
)
created_papers.append(paper["id"])
check("keywords normalised", paper["keywords"] == "关键词A, 关键词B", paper["keywords"])
check(
"paragraph denominator counts the template",
paper["template_paragraph_count"] == len({f["sort"] for f in wide_full["fields"]}),
str(paper["template_paragraph_count"]),
)
check("no sentences yet", paper["sentence_count"] == 0)
print("3. the empty paper already has its structure")
document = call("GET", f"/papers/{paper['id']}/document")
check(
"every template position is a paragraph",
len(document["paragraphs"]) == len({f["sort"] for f in wide_full["fields"]}),
str(len(document["paragraphs"])),
)
check("all paragraphs empty", all(not p["sentences"] for p in document["paragraphs"]))
check("all paragraphs matched", all(p["matched"] for p in document["paragraphs"]))
check("no citations", document["citations"] == [])
first_sort = document["paragraphs"][0]["paper_template_filed_sort"]
print("4. write a paragraph: two sentences, citations on both")
document = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{first_sort}",
{
"sentences": [
{
"content": " 第一句话,用于验证空白折叠。 ",
"citations": [{"reference_id": 7, "quote": "被引用的第一段内容"}],
},
{
"content": "第二句话。",
"citations": [
{"reference_id": 9, "quote": "引文甲"},
{"quote": "只有引用内容、暂时没有引用 id"},
],
},
]
},
)
paragraph = document["paragraphs"][0]
check("two sentences", len(paragraph["sentences"]) == 2, str(len(paragraph["sentences"])))
check("content trimmed", paragraph["sentences"][0]["content"] == "第一句话,用于验证空白折叠。")
check(
"sorts numbered from 1",
[s["sort"] for s in paragraph["sentences"]] == [1, 2],
str([s["sort"] for s in paragraph["sentences"]]),
)
check("citations numbered", [c["sort"] for c in paragraph["sentences"][1]["citations"]] == [1, 2])
check("three citations in the paper", len(document["citations"]) == 3)
check(
"citation index is reading order",
[c["index"] for c in document["citations"]] == [1, 2, 3],
str([c["index"] for c in document["citations"]]),
)
check("sentence template stamped", paragraph["sentences"][0]["template_id"] == wide["id"])
print("5. explicit sorts decide the order, not the order they were sent")
second_sort = document["paragraphs"][1]["paper_template_filed_sort"]
reordered = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{second_sort}",
{
"sentences": [
{"sort": 20, "content": "排序在后的句子。"},
{"sort": 10, "content": "排序在前的句子。"},
]
},
)
second = next(
p
for p in reordered["paragraphs"]
if p["paper_template_filed_sort"] == second_sort
)
check(
"ascending sort wins over arrival order",
[s["content"] for s in second["sentences"]]
== ["排序在前的句子。", "排序在后的句子。"],
str([s["content"] for s in second["sentences"]]),
)
check("explicit sorts are stored as sent", [s["sort"] for s in second["sentences"]] == [10, 20])
# Clear it again so the counts asserted below stay meaningful.
call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{second_sort}",
{"sentences": []},
)
print("6. reject a citation with no quote")
bad = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{first_sort}",
{"sentences": [{"content": "x", "citations": [{"reference_id": 3, "quote": " "}]}]},
expect=422,
)
check("citation without content refused", "detail" in bad)
print("7. a sentence with no paragraph in the template survives a switch")
# A position neither template defines, so the check means the same
# thing before and after the switch below.
orphan_sort = (
max(f["sort"] for f in wide_full["fields"] + narrow_full["fields"]) + 5
)
call(
"POST",
f"/papers/{paper['id']}/sentences",
{"paper_template_filed_sort": orphan_sort, "content": "新模板里没有这个位置的句子。"},
expect=201,
)
document = call("GET", f"/papers/{paper['id']}/document")
check(
"orphan position is rendered",
any(
p["paper_template_filed_sort"] == orphan_sort and not p["matched"]
for p in document["paragraphs"]
),
)
check("orphan heading has no name", next(
p for p in document["paragraphs"] if p["paper_template_filed_sort"] == orphan_sort
)["name"] is None)
print("8. switch the template")
switched = call("PATCH", f"/papers/{paper['id']}", {"template_id": narrow["id"]})
check("template changed", switched["template_id"] == narrow["id"])
document = call("GET", f"/papers/{paper['id']}/document")
matched = [p for p in document["paragraphs"] if p["matched"]]
unmatched = [p for p in document["paragraphs"] if not p["matched"]]
check(
"structure follows the new template",
len(matched) == len({f["sort"] for f in narrow_full["fields"]}),
str(len(matched)),
)
check("unmatched positions kept", len(unmatched) >= 1, str(len(unmatched)))
check(
"all sentences still present",
sum(len(p["sentences"]) for p in document["paragraphs"]) == 3,
)
check(
"positions ascend",
[p["paper_template_filed_sort"] for p in document["paragraphs"]]
== sorted(p["paper_template_filed_sort"] for p in document["paragraphs"]),
)
check(
"sentences re-stamped with the new template",
all(
s["template_id"] == narrow["id"]
for p in document["paragraphs"]
for s in p["sentences"]
),
)
print("9. move one paragraph onto another position")
document = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{orphan_sort}",
{"sentences": [{"content": "搬到别处的一句。"}], "target_sort": first_sort},
)
target = next(
p for p in document["paragraphs"] if p["paper_template_filed_sort"] == first_sort
)
check("moved sentence appended after the existing ones", len(target["sentences"]) == 3)
check(
"source position gone",
all(p["paper_template_filed_sort"] != orphan_sort for p in document["paragraphs"]),
)
print("10. paper list reports progress")
listing = call("GET", f"/papers?keyword=冒烟&status=writing")
check("found by keyword and status", listing["total"] == 1, str(listing["total"]))
row = listing["items"][0]
check("paragraph count", row["paragraph_count"] == 1, str(row["paragraph_count"]))
check("sentence count", row["sentence_count"] == 3, str(row["sentence_count"]))
print("11. a template in use cannot be deleted")
conflict = call("DELETE", f"/templates/{narrow['id']}", expect=409)
check("template delete refused", "正被论文使用" in conflict["detail"], conflict["detail"])
print("12. single sentence edit and delete")
sentence_id = target["sentences"][0]["id"]
updated = call(
"PATCH",
f"/papers/{paper['id']}/sentences/{sentence_id}",
{"content": "改写后的一句话。", "sort": 5},
)
check("sentence updated", updated["content"] == "改写后的一句话。")
check("sentence sort updated", updated["sort"] == 5)
call("DELETE", f"/papers/{paper['id']}/sentences/{sentence_id}", expect=204)
document = call("GET", f"/papers/{paper['id']}/document")
check(
"sentence deleted",
sum(len(p["sentences"]) for p in document["paragraphs"]) == 2,
)
print("13. a paper with no template still works")
bare = call(
"POST",
"/papers",
{"title": "冒烟测试-无模板论文", "template_id": None, "status": "draft"},
expect=201,
)
created_papers.append(bare["id"])
empty_doc = call("GET", f"/papers/{bare['id']}/document")
check("no template means no structure", empty_doc["paragraphs"] == [])
call(
"POST",
f"/papers/{bare['id']}/sentences",
{"paper_template_filed_sort": 1, "content": "没有模板时写下的第一句。"},
expect=201,
)
bare_doc = call("GET", f"/papers/{bare['id']}/document")
check("content creates its own paragraph", len(bare_doc["paragraphs"]) == 1)
check("that paragraph has no heading", bare_doc["paragraphs"][0]["matched"] is False)
check("and the reader is warned", len(bare_doc["warnings"]) >= 1)
call("DELETE", f"/papers/{bare['id']}", expect=204)
created_papers.remove(bare["id"])
print("14. delete the paper")
call("DELETE", f"/papers/{paper['id']}", expect=204)
created_papers.clear()
check("gone from the list", call("GET", "/papers")["total"] == 0)
print(f"\nall {_checks} checks passed")
return 0
finally:
# Never leave test rows behind, even on a failed expectation.
for paper_id in created_papers:
try:
call("DELETE", f"/papers/{paper_id}", expect=204)
print(f"cleaned up paper {paper_id}")
except Exception as error: # noqa: BLE001 - cleanup must not mask the failure
print(f"cleanup of paper {paper_id} failed: {error}", file=sys.stderr)
if __name__ == "__main__":
sys.exit(main())
+141 -15
View File
@@ -9,6 +9,12 @@ 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**
@@ -51,7 +57,7 @@ Browser ──HTTP/JSON──▶ FastAPI ──SQLAlchemy──▶ TiDB (k3s)
## Domain Model
Three tables, and one rule that everything else follows from.
Six tables, and one rule that the last three follow from.
```
section_field the reusable heading library
@@ -62,6 +68,16 @@ paper_template a named outline
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
@@ -103,17 +119,64 @@ 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;
- 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.
- 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
@@ -130,6 +193,15 @@ All routes are mounted under `/api`. Interactive docs at `/docs`.
| `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:
@@ -139,6 +211,17 @@ Conventions worth knowing:
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
@@ -148,17 +231,26 @@ Conventions worth knowing:
┌───────────────────┬──────────────────────────────────────┐
│ [mark] paper-doc │ 论文 模板 [mk] │ header
├───────────────────┼──────────────────────────────────────┤
模板配置 │ │ second-level
模板列表 │ <RouterView> │ menu, left
字段管理 │ │
论文 │ │ 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 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.
**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:
@@ -188,18 +280,38 @@ stylesheet.
| Path | View | Section |
|---|---|---|
| `/` | welcome | — |
| `/papers` | 论文 (content TBD) | `papers` |
| `/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, section_fields, templates)
│ │ ├── api/routes/ # route handlers (health, papers, section_fields, templates)
│ │ ├── core/ # settings and configuration
│ │ ├── crud/ # data-access helpers
│ │ ├── db/ # engine, session, declarative base
@@ -207,18 +319,19 @@ paper-doc/
│ │ └── 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 and template components
│ │ ├── components/ # shell, field, template and paper components
│ │ ├── router/ # vue-router configuration
│ │ ├── stores/ # pinia stores (persisted)
│ │ ├── 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
│ │ └── views/ # route-level components (papers/, templates/)
│ ├── package.json
│ └── vite.config.ts
└── docs/ # project documentation
@@ -278,3 +391,16 @@ 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
```
+282
View File
@@ -0,0 +1,282 @@
import http from './client'
import type { BatchDeleteResult, PageQuery, PageResult } from './types'
/**
* Papers (论文) — the writing surface.
*
* Two shapes matter here. A *paper* is metadata plus a template reference; a
* *document* is that paper as it reads, assembled by the server: the template's
* paragraphs in position order, each carrying the sentences stored at its
* position. The client never merges the two itself, so what is written and what
* is rendered cannot order themselves differently.
*/
/** 草稿 / 撰写中 / 已完成. Mirrors `app.models.paper.PAPER_STATUSES`. */
export type PaperStatus = 'draft' | 'writing' | 'done'
/** Display labels and tag colours for the three states, in one place. */
export const PAPER_STATUS_LABELS: Record<PaperStatus, string> = {
draft: '草稿',
writing: '撰写中',
done: '已完成',
}
export const PAPER_STATUS_TAG: Record<PaperStatus, 'info' | 'primary' | 'success'> = {
draft: 'info',
writing: 'primary',
done: 'success',
}
/** The states a select offers, in workflow order. */
export const PAPER_STATUS_OPTIONS: { value: PaperStatus; label: string }[] = [
{ value: 'draft', label: PAPER_STATUS_LABELS.draft },
{ value: 'writing', label: PAPER_STATUS_LABELS.writing },
{ value: 'done', label: PAPER_STATUS_LABELS.done },
]
/** A paper as it appears in the table. */
export interface PaperListItem {
id: number
title: string
/** The template it is written against, or `null` when none is chosen yet. */
template_id: number | null
template_name: string | null
author: string | null
status: PaperStatus
/** Comma-separated; the API normalises the spelling on write. */
keywords: string | null
target_journal: string | null
/** How many sentences have been written. */
sentence_count: number
/** How many distinct paragraphs hold those sentences. */
paragraph_count: number
/** How many paragraphs the current template defines — the progress denominator. */
template_paragraph_count: number
created_at: string
updated_at: string
}
/** A paper with everything the table does not need. */
export interface PaperDetail extends PaperListItem {
abstract: string | null
}
/** A stored citation of one sentence. */
export interface Citation {
id: number
/** Reserved for the reference library; `null` until it is linked. */
reference_id: number | null
/** 引用内容 — never blank: the API refuses a citation without it. */
quote: string
sort: number
}
/** A stored sentence with its citations. */
export interface Sentence {
id: number
paper_id: number
template_id: number | null
/** Which paragraph, as the template placement's `sort`. */
paper_template_filed_sort: number
/** Position inside that paragraph. */
sort: number
content: string
citations: Citation[]
created_at: string
updated_at: string
}
/** One paragraph of a paper: its heading and the sentences under it. */
export interface PaperParagraph {
paper_template_filed_sort: number
template_field_id: number | null
field_id: number | null
/** `null` when the template has no placement here — rendered as 未设定. */
name: string | null
level: number
font_size: number | null
font_color: string | null
/** `false` for a paragraph that exists only because content is stored there. */
matched: boolean
sentences: Sentence[]
}
/** One citation as it appears in the 参考文献 list. */
export interface PaperCitation extends Citation {
/** The number shown in the text as `[index]`, in reading order. */
index: number
sentence_id: number
sentence_content: string
paper_template_filed_sort: number
paragraph_name: string | null
}
/** The whole paper as it reads. */
export interface PaperDocument {
paper: PaperDetail
paragraphs: PaperParagraph[]
citations: PaperCitation[]
warnings: string[]
}
/** Response of a paragraph read. */
export interface ParagraphDetail {
paper_id: number
paper_title: string
template_id: number | null
paragraph: PaperParagraph
}
/** A citation as the editor sends it. */
export interface CitationInput {
reference_id: number | null
quote: string
}
/** A sentence as the editor sends it. `sort` omitted means "number them for me". */
export interface SentenceInput {
sort?: number | null
content: string
citations: CitationInput[]
}
export interface PaperPayload {
title: string
template_id: number | null
abstract: string | null
author: string | null
status: PaperStatus
keywords: string | null
target_journal: string | null
}
export interface PaperQuery extends PageQuery {
status?: PaperStatus | null
template_id?: number | null
}
/** The API caps `page_size` at 200; the menu wants every paper, so it loops. */
const PAGE_SIZE = 200
function queryParams(query: PaperQuery): Record<string, unknown> {
const params: Record<string, unknown> = {}
if (query.keyword) params.keyword = query.keyword
if (query.status) params.status = query.status
if (query.template_id != null) params.template_id = query.template_id
if (query.page) params.page = query.page
if (query.page_size) params.page_size = query.page_size
return params
}
export async function listPapers(query: PaperQuery = {}): Promise<PageResult<PaperListItem>> {
const { data } = await http.get<PageResult<PaperListItem>>('/papers', {
params: queryParams(query),
})
return data
}
/**
* Fetch every paper, following pagination.
*
* Used by the second-level menu, which lists papers rather than pages of them,
* so a partial first page would silently hide papers from the navigation.
*/
export async function fetchAllPapers(): Promise<PaperListItem[]> {
const all: PaperListItem[] = []
let page = 1
for (;;) {
const result = await listPapers({ page, page_size: PAGE_SIZE })
all.push(...result.items)
if (page >= result.pages || result.items.length === 0) {
return all
}
page += 1
}
}
export async function getPaper(id: number): Promise<PaperDetail> {
const { data } = await http.get<PaperDetail>(`/papers/${id}`)
return data
}
/** Fetch the paper as a document: structure, content, citations, warnings. */
export async function getPaperDocument(id: number): Promise<PaperDocument> {
const { data } = await http.get<PaperDocument>(`/papers/${id}/document`)
return data
}
export async function createPaper(payload: PaperPayload): Promise<PaperDetail> {
const { data } = await http.post<PaperDetail>('/papers', payload)
return data
}
export async function updatePaper(
id: number,
payload: Partial<PaperPayload>,
): Promise<PaperDetail> {
const { data } = await http.patch<PaperDetail>(`/papers/${id}`, payload)
return data
}
export async function deletePaper(id: number): Promise<void> {
await http.delete(`/papers/${id}`)
}
export async function batchDeletePapers(ids: number[]): Promise<BatchDeleteResult> {
const { data } = await http.post<BatchDeleteResult>('/papers/batch-delete', { ids })
return data
}
export async function getParagraph(
paperId: number,
fieldSort: number,
): Promise<ParagraphDetail> {
const { data } = await http.get<ParagraphDetail>(
`/papers/${paperId}/paragraphs/${fieldSort}`,
)
return data
}
/**
* Write a paragraph whole.
*
* `target_sort` moves the paragraph to another position, appending after
* whatever is already there. The refreshed document comes back, so the reader
* view can replace its state in one step.
*/
export async function replaceParagraph(
paperId: number,
fieldSort: number,
payload: { sentences: SentenceInput[]; target_sort?: number | null },
): Promise<PaperDocument> {
const { data } = await http.put<PaperDocument>(
`/papers/${paperId}/paragraphs/${fieldSort}`,
payload,
)
return data
}
export async function createSentence(
paperId: number,
payload: SentenceInput & { paper_template_filed_sort: number },
): Promise<Sentence> {
const { data } = await http.post<Sentence>(`/papers/${paperId}/sentences`, payload)
return data
}
export async function updateSentence(
paperId: number,
sentenceId: number,
payload: Partial<SentenceInput> & { paper_template_filed_sort?: number },
): Promise<Sentence> {
const { data } = await http.patch<Sentence>(
`/papers/${paperId}/sentences/${sentenceId}`,
payload,
)
return data
}
export async function deleteSentence(paperId: number, sentenceId: number): Promise<void> {
await http.delete(`/papers/${paperId}/sentences/${sentenceId}`)
}
+24
View File
@@ -62,6 +62,30 @@ export async function getTemplate(id: number): Promise<TemplateDetail> {
return data
}
/** The API caps `page_size` at 200; a picker wants every template, so it loops. */
const TEMPLATE_PAGE_SIZE = 200
/**
* Fetch every template, following pagination.
*
* A template picker that showed only the first page would silently hide the
* template the user is looking for, and the paper view needs the whole list
* before it can offer a switch without a search round trip.
*/
export async function fetchAllTemplates(): Promise<TemplateListItem[]> {
const all: TemplateListItem[] = []
let page = 1
for (;;) {
const result = await listTemplates({ page, page_size: TEMPLATE_PAGE_SIZE })
all.push(...result.items)
if (page >= result.pages || result.items.length === 0) {
return all
}
page += 1
}
}
export async function createTemplate(payload: TemplatePayload): Promise<TemplateDetail> {
const { data } = await http.post<TemplateDetail>('/templates', payload)
return data
+181 -29
View File
@@ -2,17 +2,24 @@
/**
* The second-level menu, pinned to the left of the content area.
*
* It is driven entirely by `route.meta.section`, so the menu a URL belongs to
* is a property of the route table rather than of component state — deep
* linking to `/templates/fields` shows the right menu on first paint, and the
* aside disappears on routes that declare no section (the welcome page).
* It is driven by `route.meta.section`, so the menu a URL belongs to is a
* property of the route table rather than of component state — deep linking to
* `/templates/fields` or `/papers/12` shows the right menu on first paint, and
* the aside disappears on routes that declare no section (the welcome page).
*
* The 论文 menu is the exception to the static list: it carries the papers
* themselves, so it is read from the store rather than written here. Every page
* that creates, renames or deletes a paper reloads that store, which is what
* keeps the menu and the table showing the same thing.
*/
import { computed, type Component } from 'vue'
import { computed, onMounted, ref, watch, type Component } from 'vue'
import { useRoute } from 'vue-router'
import { Document, Expand, Files, Fold, Setting } from '@element-plus/icons-vue'
import { Document, Expand, Files, Fold, Plus, Search, Setting } from '@element-plus/icons-vue'
import type { PaperListItem } from '@/api/papers'
import { ASIDE_INSET, asideWidth } from '@/layout'
import { useAppStore } from '@/stores/app'
import { usePapersStore } from '@/stores/papers'
interface AsideItem {
index: string
@@ -30,11 +37,17 @@ const props = defineProps<{ section: string }>()
const route = useRoute()
const appStore = useAppStore()
const papersStore = usePapersStore()
const MENUS: Record<string, AsideMenu> = {
papers: {
title: '论文',
items: [{ index: '/papers', label: '我的论文', icon: Document, hint: '待定' }],
// 论文列表 leads to the table; 新建论文 carries a query flag that the table
// view reads and clears, so the menu owns no dialog state of its own.
items: [
{ index: '/papers', label: '论文列表', icon: Files },
{ index: '/papers?new=1', label: '新建论文', icon: Plus },
],
},
templates: {
title: '模板配置',
@@ -48,12 +61,40 @@ const MENUS: Record<string, AsideMenu> = {
const menu = computed<AsideMenu | null>(() => MENUS[props.section] ?? null)
const collapsed = computed(() => appStore.asideCollapsed)
/**
* The rail's width, from the one place the header's logo block reads too, so
* the two cannot drift apart.
*/
/** The rail's width, from the one place the header's logo block reads too. */
const width = computed(() => asideWidth(collapsed.value))
const inset = ASIDE_INSET
/** Filter for the paper list: a rail this narrow cannot show every paper. */
const paperFilter = ref('')
const papers = computed<PaperListItem[]>(() => papersStore.items)
/**
* The filter appears only once the list is long enough to need it. Below that
* it would occupy a menu item's worth of height to hide nothing.
*/
const filterable = computed(() => papers.value.length > 6)
const filteredPapers = computed(() => {
const keyword = paperFilter.value.trim().toLowerCase()
if (!keyword) return papers.value
return papers.value.filter((paper) => paper.title.toLowerCase().includes(keyword))
})
/** The active entry: either a static one or the paper currently open. */
const activeIndex = computed(() =>
route.path === '/papers' && route.query.new ? '/papers?new=1' : route.path,
)
/** Load the menu's list the first time the 论文 section is opened. */
function ensurePapers(): void {
if (props.section !== 'papers') return
if (!papersStore.loaded && !papersStore.loading) void papersStore.reload()
}
watch(() => props.section, ensurePapers)
onMounted(ensurePapers)
</script>
<template>
@@ -79,23 +120,71 @@ const inset = ASIDE_INSET
</el-tooltip>
</div>
<el-menu
:default-active="route.path"
:collapse="collapsed"
:collapse-transition="false"
router
class="aside-menu"
>
<el-menu-item v-for="item in menu.items" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<template #title>
<span class="aside-label">{{ item.label }}</span>
<el-tag v-if="item.hint" size="small" type="info" effect="plain">
{{ item.hint }}
</el-tag>
</template>
</el-menu-item>
</el-menu>
<div class="aside-body">
<el-menu
:default-active="activeIndex"
:collapse="collapsed"
:collapse-transition="false"
router
class="aside-menu"
>
<el-menu-item v-for="item in menu.items" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<template #title>
<span class="aside-label">{{ item.label }}</span>
</template>
</el-menu-item>
</el-menu>
<!-- 论文 carries the documents themselves, so the menu can open one. -->
<template v-if="section === 'papers'">
<div v-if="filterable && !collapsed" class="aside-filter">
<el-input
v-model="paperFilter"
size="small"
placeholder="筛选论文"
clearable
:prefix-icon="Search"
/>
</div>
<el-menu
:default-active="route.path"
:collapse="collapsed"
:collapse-transition="false"
router
class="aside-menu aside-menu--papers"
>
<el-menu-item
v-for="paper in filteredPapers"
:key="paper.id"
:index="`/papers/${paper.id}`"
>
<el-icon><Document /></el-icon>
<template #title>
<el-tooltip
:content="`${paper.title}${paper.paragraph_count}/${paper.template_paragraph_count} 段)`"
placement="right"
:show-after="400"
:disabled="collapsed"
>
<span class="aside-paper">
<span class="aside-dot" :class="`is-${paper.status}`" />
<span class="aside-paper-title">{{ paper.title }}</span>
</span>
</el-tooltip>
</template>
</el-menu-item>
</el-menu>
<p v-if="!collapsed && papers.length === 0" class="aside-empty">
还没有论文点上面的新建论文开始
</p>
<p v-else-if="!collapsed && filteredPapers.length === 0" class="aside-empty">
没有匹配的论文
</p>
</template>
</div>
</el-aside>
</template>
@@ -140,13 +229,26 @@ const inset = ASIDE_INSET
color: var(--el-text-color-secondary);
}
.aside-menu {
/* Everything below the section title scrolls together: with the papers listed
here, the menu is as long as the library. */
.aside-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.aside-menu {
border-right: none;
}
.aside-menu--papers {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--el-border-color-lighter);
}
/* A collapsed el-menu keeps a fixed 64px width that no longer matches the
56px rail, so the items would sit off-centre. */
.aside-menu.el-menu--collapse {
@@ -156,4 +258,54 @@ const inset = ASIDE_INSET
.aside-label {
margin-right: 6px;
}
.aside-filter {
padding: 8px 12px 4px;
}
.aside-paper {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
/* The status is a dot rather than a tag: at this width a tag would eat the
title, and the title is the only thing worth reading here. */
.aside-dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--el-text-color-placeholder);
}
.aside-dot.is-writing {
background-color: var(--el-color-primary);
}
.aside-dot.is-done {
background-color: var(--el-color-success);
}
/* Long titles are the norm in a 208px rail, so they truncate rather than wrap
and push the rest of the list off the screen. */
.aside-paper-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aside-menu--papers :deep(.el-menu-item > span) {
min-width: 0;
overflow: hidden;
}
.aside-empty {
margin: 8px 12px;
font-size: 12px;
line-height: 1.7;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,267 @@
<script setup lang="ts">
/**
* Create / edit dialog for a paper's metadata.
*
* The template select is the important field: a paper's whole structure comes
* from its template, so this is the one choice that decides what the writing
* view will look like. It is therefore required — except when no template
* exists yet at all, in which case demanding one would make creating a paper
* impossible and the paper can be given a structure later.
*
* Keywords are edited as tags but stored as the canonical comma-separated
* string the API writes, so the same value round-trips through either side.
*/
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { errorMessage } from '@/api/client'
import {
createPaper,
getPaper,
updatePaper,
PAPER_STATUS_OPTIONS,
type PaperDetail,
type PaperListItem,
type PaperStatus,
} from '@/api/papers'
import { fetchAllTemplates, type TemplateListItem } from '@/api/templates'
import { splitKeywords } from '@/utils/format'
const props = defineProps<{
/** Visibility, used with `v-model`. */
modelValue: boolean
/** The paper being edited, or `null` to create a new one. */
paper: PaperListItem | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Emitted after a successful write, with the stored paper. */
saved: [paper: PaperDetail]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
const formRef = ref<FormInstance>()
const loading = ref(false)
const saving = ref(false)
const templates = ref<TemplateListItem[]>([])
const form = reactive({
title: '',
template_id: null as number | null,
abstract: '',
author: '',
status: 'draft' as PaperStatus,
keywords: [] as string[],
target_journal: '',
})
/** A template is required as soon as there is one to choose. */
const rules = computed<FormRules>(() => ({
title: [{ required: true, message: '请填写论文标题', trigger: 'blur' }],
template_id: templates.value.length
? [{ required: true, message: '请选择论文模板', trigger: 'change' }]
: [],
}))
const title = computed(() => (props.paper ? '编辑论文信息' : '新建论文'))
watch(
() => props.modelValue,
async (open) => {
if (!open) return
formRef.value?.clearValidate()
form.title = props.paper?.title ?? ''
form.template_id = props.paper?.template_id ?? null
form.abstract = ''
form.author = props.paper?.author ?? ''
form.status = props.paper?.status ?? 'draft'
form.keywords = splitKeywords(props.paper?.keywords)
form.target_journal = props.paper?.target_journal ?? ''
loading.value = true
try {
templates.value = await fetchAllTemplates()
// A new paper starts on the most recently edited template — the one the
// writer was last working on is usually the one they want again.
if (!props.paper && form.template_id == null && templates.value.length) {
form.template_id = templates.value[0]!.id
}
if (props.paper) {
const detail = await getPaper(props.paper.id)
form.title = detail.title
form.template_id = detail.template_id
form.abstract = detail.abstract ?? ''
form.author = detail.author ?? ''
form.status = detail.status
form.keywords = splitKeywords(detail.keywords)
form.target_journal = detail.target_journal ?? ''
}
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
loading.value = false
}
},
)
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload = {
title: form.title.trim(),
template_id: form.template_id,
abstract: form.abstract.trim() || null,
author: form.author.trim() || null,
status: form.status,
keywords: form.keywords.length ? form.keywords.join(', ') : null,
target_journal: form.target_journal.trim() || null,
}
// `template_id` travels even when it is null: clearing a template is a
// real change, and PATCH would otherwise read the key as "not sent".
const saved = props.paper
? await updatePaper(props.paper.id, payload)
: await createPaper(payload)
ElMessage.success(props.paper ? '论文已更新' : '论文已创建')
emit('saved', saved)
visible.value = false
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
saving.value = false
}
}
</script>
<template>
<el-dialog
v-model="visible"
:title="title"
width="min(680px, 94vw)"
:close-on-click-modal="false"
append-to-body
>
<el-form ref="formRef" v-loading="loading" :model="form" :rules="rules" label-width="96px">
<el-form-item label="论文标题" prop="title">
<el-input v-model="form.title" placeholder="如 基于结构化模板的论文写作工具" maxlength="255" clearable />
</el-form-item>
<el-form-item label="所用模板" prop="template_id">
<!-- Only settable at creation time. Changing the template of a paper
that already has content re-shapes the whole document, so it goes
through the paper view's 切换模板 dialog, which previews the
impact first. -->
<el-select
v-model="form.template_id"
placeholder="选择论文模板"
clearable
:disabled="Boolean(paper)"
class="full"
>
<el-option
v-for="template in templates"
:key="template.id"
:value="template.id"
:label="template.name"
>
<span>{{ template.name }}</span>
<span class="option-hint">{{ template.field_count }} 段</span>
</el-option>
</el-select>
<div v-if="templates.length === 0" class="field-hint">
还没有模板,可以先创建论文,之后在
<RouterLink to="/templates/list">模板列表</RouterLink> 里建好再切换。
</div>
<div v-else-if="paper" class="field-hint">
切换模板会改变正文结构,请回到论文页面点「切换模板」,那里会先说明影响。
</div>
<div v-else class="field-hint">
正文的结构完全来自这个模板:每个段落字段对应论文里的一个段落。
</div>
</el-form-item>
<el-form-item label="作者">
<el-input v-model="form.author" placeholder="如 张三、李四" maxlength="255" clearable />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio-button
v-for="option in PAPER_STATUS_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="关键词">
<el-select
v-model="form.keywords"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
placeholder="输入后回车添加,如 结构化写作"
class="full"
>
<el-option v-for="word in form.keywords" :key="word" :value="word" :label="word" />
</el-select>
</el-form-item>
<el-form-item label="目标期刊">
<el-input v-model="form.target_journal" placeholder="如 情报学报" maxlength="255" clearable />
</el-form-item>
<el-form-item label="摘要">
<el-input
v-model="form.abstract"
type="textarea"
:rows="3"
placeholder="论文摘要,可稍后再写"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">
{{ paper ? '保存修改' : '创建论文' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.full {
width: 100%;
}
.option-hint {
float: right;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.field-hint {
width: 100%;
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
</style>
@@ -0,0 +1,250 @@
<script setup lang="ts">
/**
* One paragraph of a paper, as the reader sees it.
*
* Two things about this component carry the feature's contracts:
*
* 1. **The structure is never skipped.** A paragraph with no sentences still
* renders its heading and a quiet placeholder, because the paper's shape
* comes from its template and an unwritten paragraph is still a paragraph.
* 2. **A paragraph is reassembled from its sentences.** They are printed in
* `sort` order, one after another, with no separator added — Chinese
* sentences already end in punctuation, and inserting anything between them
* would show up in the text.
*
* A sentence that quotes something gets a superscript marker numbered by the
* paper's citation list, so the marker and the 参考文献 entry cannot disagree.
*/
import { computed } from 'vue'
import { Edit } from '@element-plus/icons-vue'
import type { PaperParagraph } from '@/api/papers'
import { PARAGRAPH_UNSET_LABEL, levelIndent } from '@/utils/format'
const props = defineProps<{
paragraph: PaperParagraph
/** Citation id -> the number shown in the text, assigned by reading order. */
citationIndex: Record<number, number>
}>()
defineEmits<{ edit: [paragraph: PaperParagraph] }>()
/** The heading's own typography, read from the template's field. */
const headingStyle = computed(() => {
const style: Record<string, string> = {}
if (props.paragraph.font_size != null) style.fontSize = `${props.paragraph.font_size}pt`
if (props.paragraph.font_color) style.color = props.paragraph.font_color
return style
})
/** Indentation follows the field's level, exactly as the template outline does. */
const headingIndent = computed(() => levelIndent(props.paragraph.level))
/** Whether anything has actually been written here. */
const hasContent = computed(() =>
props.paragraph.sentences.some(
(sentence) => sentence.content.trim().length > 0 || sentence.citations.length > 0,
),
)
/** Anchor id, so a citation can point back at the paragraph it came from. */
const anchor = computed(() => `paragraph-${props.paragraph.paper_template_filed_sort}`)
</script>
<template>
<section :id="anchor" class="paragraph" :class="{ 'is-unset': !paragraph.matched }">
<header class="paragraph-head">
<h3 class="paragraph-title" :style="{ paddingLeft: headingIndent }">
<span class="paragraph-sort" :title="`sort = ${paragraph.paper_template_filed_sort}`">
{{ paragraph.paper_template_filed_sort }}
</span>
<span v-if="paragraph.name" class="paragraph-name" :style="headingStyle">
{{ paragraph.name }}
</span>
<span v-else class="paragraph-name paragraph-name--unset" :style="headingStyle">
{{ PARAGRAPH_UNSET_LABEL }}
</span>
</h3>
<div class="paragraph-actions">
<el-tag v-if="!paragraph.matched" size="small" type="warning" effect="plain">
模板无此段
</el-tag>
<span v-else class="paragraph-count">{{ paragraph.sentences.length }} </span>
<!-- The edit button sits beside every paragraph, written or not. -->
<el-button
size="small"
type="primary"
plain
:icon="Edit"
@click="$emit('edit', paragraph)"
>
编辑
</el-button>
</div>
</header>
<p v-if="hasContent" class="paragraph-body">
<template v-for="sentence in paragraph.sentences" :key="sentence.id">
<span class="sentence">{{ sentence.content }}</span>
<el-tooltip
v-for="citation in sentence.citations"
:key="citation.id"
placement="top"
:show-after="120"
>
<template #content>
<div class="cite-tip">
<div class="cite-tip-head">
[{{ citationIndex[citation.id] ?? '?' }}]
<span v-if="citation.reference_id">引用 #{{ citation.reference_id }}</span>
<span v-else class="cite-tip-unlinked">未关联引用 id</span>
</div>
<div class="cite-tip-quote">{{ citation.quote }}</div>
</div>
</template>
<sup class="cite-mark">{{ citationIndex[citation.id] ?? '?' }}</sup>
</el-tooltip>
</template>
</p>
<p v-else class="paragraph-empty">本段暂无内容</p>
</section>
</template>
<style scoped>
.paragraph {
padding: 14px 16px;
border-radius: 8px;
border: 1px solid transparent;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.paragraph + .paragraph {
border-top: 1px solid var(--el-border-color-lighter);
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.paragraph:hover {
background-color: var(--el-fill-color-lighter);
}
.paragraph.is-unset {
background-color: var(--el-color-warning-light-9);
}
.paragraph.is-unset:hover {
background-color: var(--el-color-warning-light-8);
}
.paragraph-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.paragraph-title {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
min-width: 0;
font-weight: 600;
line-height: 1.6;
}
/* The position the paragraph occupies in the document. Shown because it is
what a sentence is addressed by, and what survives a template switch. */
.paragraph-sort {
flex: 0 0 auto;
min-width: 24px;
padding: 0 6px;
font-size: 11px;
font-weight: 400;
font-variant-numeric: tabular-nums;
text-align: center;
color: var(--el-text-color-secondary);
background-color: var(--el-fill-color);
border-radius: 4px;
}
.paragraph-name {
min-width: 0;
overflow-wrap: anywhere;
}
.paragraph-name--unset {
color: var(--el-color-warning);
font-style: italic;
}
.paragraph-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.paragraph-count {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.paragraph-body {
margin: 8px 0 0;
/* Body text is set at a readable size rather than at the heading's: a
template's font size describes its headings, not its prose. */
font-size: 15px;
line-height: 2;
color: var(--el-text-color-primary);
text-align: justify;
/* The two-character indent a Chinese manuscript expects. */
text-indent: 2em;
overflow-wrap: anywhere;
}
.sentence {
white-space: pre-wrap;
}
.cite-mark {
margin: 0 1px;
padding: 0 2px;
font-size: 11px;
color: var(--el-color-primary);
cursor: help;
vertical-align: super;
line-height: 0;
}
.paragraph-empty {
margin: 8px 0 0;
font-size: 13px;
color: var(--el-text-color-placeholder);
font-style: italic;
}
.cite-tip {
max-width: 320px;
}
.cite-tip-head {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.cite-tip-unlinked {
font-weight: 400;
opacity: 0.7;
}
.cite-tip-quote {
margin-top: 4px;
line-height: 1.6;
}
</style>
@@ -0,0 +1,576 @@
<script setup lang="ts">
/**
* The paragraph editor — the writer's unit of work.
*
* It shows one paragraph the way the paper will read it: every sentence of the
* paragraph on its own line, in `sort` order, each one editable, each one able
* to carry citations. Saving writes the paragraph back whole, so what is on
* screen and what is stored cannot drift apart.
*
* Three rules are stated in the UI because they are the ones a writer would
* otherwise have to guess:
*
* * **order comes from `sort`, not from the screen.** Rows are displayed in
* ascending `sort`, so editing a number moves the line — which is the point:
* the paragraph is reassembled from these numbers, and nothing else.
* * **a citation must quote something.** A citation with an empty 引用内容 is
* refused here (and by the API), because a bare id is not usable in a text.
* * **whitespace-only lines are dropped on save.** Adding a line and leaving it
* blank is a normal way to type, not a request for an empty sentence.
*
* The optional 「所属段落」 select moves the whole paragraph to another position,
* appending after whatever that paragraph already holds. That is the manual
* form of what switching the paper's template does by itself.
*/
import { computed, reactive, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, Delete, Plus, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { errorMessage } from '@/api/client'
import {
getParagraph,
replaceParagraph,
type PaperDocument,
type PaperParagraph,
type SentenceInput,
} from '@/api/papers'
import { PARAGRAPH_UNSET_LABEL, foldWhitespace } from '@/utils/format'
const props = defineProps<{
/** Visibility, used with `v-model`. */
modelValue: boolean
paperId: number
/** The paragraph being edited, identified by the position it occupies. */
fieldSort: number | null
/** Every paragraph of the document, for the 「所属段落」 select. */
paragraphs: PaperParagraph[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Emitted with the refreshed document after a successful write. */
saved: [document: PaperDocument]
}>()
/** One citation line in the editor. */
interface CitationDraft {
/** Local identity for `v-for`; not sent to the server. */
key: number
reference_id: number | null
quote: string
}
/** One sentence line in the editor. `sort` is always set — see the template. */
interface SentenceDraft {
key: number
sort: number
content: string
citations: CitationDraft[]
}
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
const loading = ref(false)
const saving = ref(false)
const heading = ref<PaperParagraph | null>(null)
const rows = ref<SentenceDraft[]>([])
const targetSort = ref<number | null>(null)
/** Keys only ever count up, so reopening the dialog cannot collide with itself. */
let nextKey = 1
/** The paragraph as the reader will meet it: ascending `sort`, ties by insertion. */
const orderedRows = computed(() =>
[...rows.value].sort((a, b) => a.sort - b.sort || a.key - b.key),
)
/** Where the paragraph sits now, and where it could go. */
const currentSort = computed(() => props.fieldSort ?? 0)
const moveOptions = computed(() =>
[...props.paragraphs]
.sort((a, b) => a.paper_template_filed_sort - b.paper_template_filed_sort)
.map((paragraph) => ({
value: paragraph.paper_template_filed_sort,
label: `#${paragraph.paper_template_filed_sort} · ${
paragraph.name ?? PARAGRAPH_UNSET_LABEL
}`,
count: paragraph.sentences.length,
})),
)
/** The target paragraph, when the writer has chosen a different one. */
const moveTarget = computed(() => {
if (targetSort.value == null || targetSort.value === currentSort.value) return null
return (
props.paragraphs.find(
(paragraph) => paragraph.paper_template_filed_sort === targetSort.value,
) ?? null
)
})
watch(
() => props.modelValue,
async (open) => {
if (!open || props.fieldSort == null) return
rows.value = []
heading.value = null
targetSort.value = props.fieldSort
loading.value = true
try {
// Read the paragraph from the server rather than from the caller's copy:
// the editor must never write back a stale version of the paragraph.
const detail = await getParagraph(props.paperId, props.fieldSort)
heading.value = detail.paragraph
rows.value = detail.paragraph.sentences.map((sentence) => ({
key: nextKey++,
sort: sentence.sort,
content: sentence.content,
citations: sentence.citations.map((citation) => ({
key: nextKey++,
reference_id: citation.reference_id,
quote: citation.quote,
})),
}))
} catch (error) {
ElMessage.error(errorMessage(error))
visible.value = false
} finally {
loading.value = false
}
},
)
/** The next free sort, so an added line lands at the end. */
function nextSort(): number {
return rows.value.reduce((max, row) => Math.max(max, row.sort), 0) + 1
}
function addSentence(): void {
rows.value.push({ key: nextKey++, sort: nextSort(), content: '', citations: [] })
}
function removeSentence(key: number): void {
rows.value = rows.value.filter((row) => row.key !== key)
}
function addCitation(row: SentenceDraft): void {
row.citations.push({ key: nextKey++, reference_id: null, quote: '' })
}
function removeCitation(row: SentenceDraft, key: number): void {
row.citations = row.citations.filter((citation) => citation.key !== key)
}
/**
* Move a line one step, by swapping `sort` with its neighbour.
*
* Only the two values involved change, so a deliberately sparse numbering
* (10, 20, 30) survives a nudge. Equal values would sit still after a plain
* swap, so in that case the moving row is nudged past its neighbour instead.
*/
function swapOrder(index: number, delta: number): void {
const ordered = orderedRows.value
const target = index + delta
if (target < 0 || target >= ordered.length) return
const moving = ordered[index]!
const other = ordered[target]!
const movingSort = moving.sort
if (movingSort === other.sort) {
moving.sort = delta < 0 ? other.sort - 1 : other.sort + 1
return
}
moving.sort = other.sort
other.sort = movingSort
}
/** Renumber the lines 1..N in the order they are displayed. */
function autoNumber(): void {
orderedRows.value.forEach((row, index) => {
row.sort = index + 1
})
}
/** The first reason the paragraph cannot be saved, or `null`. */
function firstProblem(): string | null {
for (const [index, row] of orderedRows.value.entries()) {
if (row.content.trim() && !Number.isFinite(row.sort)) {
return `${index + 1} 句的 sort 无效`
}
for (const citation of row.citations) {
if (citation.reference_id != null && citation.reference_id < 1) {
return `${index + 1} 句的引用 id 必须是正整数`
}
// The one hard rule: a citation must say what it quotes.
if (!citation.quote.trim()) {
return `${index + 1} 句的引用内容不能为空(引用必须写明引用的内容)`
}
}
}
return null
}
async function submit(): Promise<void> {
if (props.fieldSort == null) return
const problem = firstProblem()
if (problem) {
ElMessage.warning(problem)
return
}
// Whitespace-only lines are dropped: a line the writer added and left blank
// carries nothing, and keeping it would put an empty sentence in the paper.
const sentences: SentenceInput[] = orderedRows.value
.filter(
(row) =>
row.content.trim().length > 0 ||
row.citations.some((citation) => citation.quote.trim().length > 0),
)
.map((row) => ({
sort: row.sort,
content: foldWhitespace(row.content),
citations: row.citations
.filter((citation) => citation.quote.trim().length > 0)
.map((citation) => ({
reference_id: citation.reference_id ?? null,
quote: citation.quote.trim(),
})),
}))
saving.value = true
try {
const document_ = await replaceParagraph(props.paperId, props.fieldSort, {
sentences,
target_sort:
targetSort.value != null && targetSort.value !== props.fieldSort
? targetSort.value
: null,
})
ElMessage.success(
targetSort.value !== props.fieldSort ? '段落已保存并移动到新位置' : '段落已保存',
)
emit('saved', document_)
visible.value = false
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
saving.value = false
}
}
</script>
<template>
<el-dialog
v-model="visible"
width="min(860px, 94vw)"
top="6vh"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<template #header>
<div class="dialog-header">
<span class="dialog-title">编辑段落</span>
<span v-if="heading" class="dialog-subject">
<span v-if="heading.name">{{ heading.name }}</span>
<span v-else class="unset">{{ PARAGRAPH_UNSET_LABEL }}</span>
<el-tag size="small" effect="plain" type="info">
sort = {{ heading.paper_template_filed_sort }}
</el-tag>
</span>
</div>
</template>
<div v-loading="loading" class="editor">
<el-alert
v-if="heading && !heading.matched"
type="warning"
:closable="false"
show-icon
title="当前模板在这个位置上没有段落"
description="内容会照常保存和显示,标题显示为「未设定」。切回原来的模板,标题就会恢复。"
/>
<div class="editor-meta">
<span class="meta-label">所属段落</span>
<el-select v-model="targetSort" size="small" class="meta-select">
<el-option
v-for="option in moveOptions"
:key="option.value"
:value="option.value"
:label="option.label"
>
<span>{{ option.label }}</span>
<span class="option-hint">
{{ option.value === currentSort ? '当前' : `${option.count}` }}
</span>
</el-option>
</el-select>
<span class="meta-hint">
换段落会把这一段整体搬过去追加到目标段落已有句子之后
</span>
</div>
<el-alert
v-if="moveTarget"
type="warning"
:closable="false"
show-icon
:title="`目标段落「${moveTarget.name ?? PARAGRAPH_UNSET_LABEL}」已有 ${moveTarget.sentences.length} 句`"
description="本段的句子会追加在它们之后。"
/>
<div class="editor-toolbar">
<span class="toolbar-title"> {{ orderedRows.length }} </span>
<span class="toolbar-hint"> sort 升序拼成这个段落</span>
<div class="toolbar-spacer" />
<el-button size="small" :icon="Refresh" :disabled="!orderedRows.length" @click="autoNumber">
自动编号
</el-button>
<el-button size="small" type="primary" plain :icon="Plus" @click="addSentence">
新增一句
</el-button>
</div>
<div class="rows">
<el-empty
v-if="orderedRows.length === 0"
:image-size="60"
description="这一段还没有内容,点「新增一句」开始写"
/>
<article v-for="(row, index) in orderedRows" :key="row.key" class="row">
<header class="row-head">
<span class="row-index"> {{ index + 1 }} </span>
<el-input-number
v-model="row.sort"
size="small"
controls-position="right"
:min="-9999"
:max="9999"
class="sort-input"
/>
<div class="toolbar-spacer" />
<el-button
link
:icon="ArrowUp"
title="上移"
:disabled="index === 0"
@click="swapOrder(index, -1)"
/>
<el-button
link
:icon="ArrowDown"
title="下移"
:disabled="index === orderedRows.length - 1"
@click="swapOrder(index, 1)"
/>
<el-button link type="danger" :icon="Delete" title="删除这一句" @click="removeSentence(row.key)" />
</header>
<el-input
v-model="row.content"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="这一句的内容(只留空白的句子保存时会删除)"
/>
<div class="citations">
<div v-for="citation in row.citations" :key="citation.key" class="citation">
<el-input-number
v-model="citation.reference_id"
size="small"
controls-position="right"
:min="1"
:max="999999"
placeholder="引用 id"
class="cite-id"
/>
<el-input
v-model="citation.quote"
size="small"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="引用内容(必填)"
class="cite-quote"
/>
<el-button
link
type="danger"
:icon="Delete"
title="删除这条引用"
@click="removeCitation(row, citation.key)"
/>
</div>
<el-button link type="primary" :icon="Plus" @click="addCitation(row)">
添加引用
</el-button>
</div>
</article>
</div>
</div>
<template #footer>
<span class="footer-hint">空段落会保留结构在正文里显示为空白</span>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存段落</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-header {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.dialog-title {
font-size: 16px;
font-weight: 600;
}
.dialog-subject {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--el-text-color-regular);
font-size: 13px;
}
.unset {
color: var(--el-color-warning);
font-style: italic;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 200px;
}
.editor-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.meta-label {
font-size: 13px;
color: var(--el-text-color-regular);
}
.meta-select {
width: 240px;
}
.meta-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.option-hint {
float: right;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-title {
font-size: 13px;
font-weight: 600;
}
.toolbar-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.toolbar-spacer {
flex: 1 1 auto;
min-width: 0;
}
.rows {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 52vh;
overflow-y: auto;
scrollbar-width: thin;
padding-right: 4px;
}
.row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background-color: var(--el-fill-color-blank);
}
.row-head {
display: flex;
align-items: center;
gap: 8px;
}
.row-index {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.sort-input {
width: 104px;
}
.citations {
display: flex;
flex-direction: column;
gap: 6px;
padding-left: 12px;
border-left: 2px solid var(--el-border-color-lighter);
}
.citation {
display: flex;
align-items: flex-start;
gap: 8px;
}
.cite-id {
width: 130px;
flex: 0 0 auto;
}
.cite-quote {
flex: 1 1 auto;
}
.footer-hint {
float: left;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 32px;
}
</style>
@@ -0,0 +1,262 @@
<script setup lang="ts">
/**
* Switch the template a paper is written against.
*
* This is the risky edit in the feature — it re-shapes the entire document in
* one write — so the dialog explains the outcome *before* it happens, by
* comparing the chosen template's positions against the positions the paper
* has content at:
*
* * matched — content that lands under a heading of the new template;
* * unmatched — content the new template has nothing at, which is kept and
* rendered under 未设定 rather than deleted;
* * new empty paragraphs — structure the new template adds, empty for now.
*
* Nothing else changes: the sentences themselves are never touched, which is
* why switching back restores the previous layout exactly.
*/
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { errorMessage } from '@/api/client'
import { updatePaper, type PaperDetail, type PaperParagraph } from '@/api/papers'
import { fetchAllTemplates, getTemplate, type TemplateListItem } from '@/api/templates'
import { PARAGRAPH_UNSET_LABEL } from '@/utils/format'
const props = defineProps<{
/** Visibility, used with `v-model`. */
modelValue: boolean
paper: PaperDetail
/** The paper's paragraphs, so the impact of a switch can be previewed. */
paragraphs: PaperParagraph[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Emitted with the updated paper after a successful switch. */
saved: [paper: PaperDetail]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
const templates = ref<TemplateListItem[]>([])
const selected = ref<number | null>(null)
const loading = ref(false)
const previewing = ref(false)
const saving = ref(false)
/** Paragraph positions and names of the candidate template. */
const candidate = ref<{ sort: number; name: string }[]>([])
/** Positions that hold content today. */
const contentSorts = computed(() =>
props.paragraphs
.filter((paragraph) => paragraph.sentences.length > 0)
.map((paragraph) => paragraph.paper_template_filed_sort)
.sort((a, b) => a - b),
)
const candidateSorts = computed(() => new Set(candidate.value.map((item) => item.sort)))
/** Content that will keep a heading under the new template. */
const matched = computed(() =>
contentSorts.value.filter((sort) => candidateSorts.value.has(sort)),
)
/** Content the new template has no position for — kept, shown as 未设定. */
const unmatched = computed(() =>
contentSorts.value.filter((sort) => !candidateSorts.value.has(sort)),
)
/** Positions the new template adds, which will read as empty paragraphs. */
const addedEmpty = computed(() =>
candidate.value.filter((item) => !contentSorts.value.includes(item.sort)).length,
)
const changed = computed(
() => selected.value != null && selected.value !== props.paper.template_id,
)
watch(
() => props.modelValue,
async (open) => {
if (!open) return
selected.value = props.paper.template_id
candidate.value = []
loading.value = true
try {
templates.value = await fetchAllTemplates()
if (selected.value != null) await loadCandidate(selected.value)
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
loading.value = false
}
},
)
/** Load the candidate template's outline, so the impact can be previewed. */
async function loadCandidate(templateId: number | null): Promise<void> {
if (templateId == null) {
candidate.value = []
return
}
previewing.value = true
try {
const detail = await getTemplate(templateId)
candidate.value = detail.fields.map((field) => ({ sort: field.sort, name: field.name }))
} catch (error) {
ElMessage.error(errorMessage(error))
candidate.value = []
} finally {
previewing.value = false
}
}
watch(selected, (value) => {
void loadCandidate(value)
})
async function submit(): Promise<void> {
if (!changed.value || selected.value == null) return
saving.value = true
try {
const paper = await updatePaper(props.paper.id, { template_id: selected.value })
ElMessage.success('已切换模板')
emit('saved', paper)
visible.value = false
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
saving.value = false
}
}
</script>
<template>
<el-dialog
v-model="visible"
title="切换模板"
width="min(660px, 94vw)"
:close-on-click-modal="false"
append-to-body
>
<div v-loading="loading" class="switcher">
<el-alert
type="info"
:closable="false"
show-icon
title="切换模板只改结构,不动内容"
description="句子的位置(sort)会保留,所以在旧模板里写的内容不会丢;切回来就恢复原样。"
/>
<div class="row">
<span class="label">当前模板</span>
<el-tag effect="plain">{{ paper.template_name ?? PARAGRAPH_UNSET_LABEL }}</el-tag>
</div>
<div class="row">
<span class="label">切换为</span>
<el-select v-model="selected" placeholder="选择模板" class="select" :loading="previewing">
<el-option
v-for="template in templates"
:key="template.id"
:value="template.id"
:label="template.name"
:disabled="template.id === paper.template_id"
>
<span>{{ template.name }}</span>
<span class="option-hint">{{ template.field_count }} </span>
</el-option>
</el-select>
</div>
<template v-if="changed">
<el-divider content-position="left">切换后的影响</el-divider>
<ul class="impact">
<li class="ok">
<strong>{{ matched.length }}</strong> 段内容会落到新模板对应的段落标题下
</li>
<li v-if="addedEmpty" class="muted">
新模板另有 <strong>{{ addedEmpty }}</strong> 个段落暂时为空正文里显示为空白
</li>
<li v-if="unmatched.length" class="warn">
<strong>{{ unmatched.length }}</strong> 段内容在新模板里没有对应位置sort
{{ unmatched.join('、') }}会按顺序保留标题显示为未设定
</li>
<li v-else class="ok">没有内容会失去位置</li>
</ul>
<el-alert
v-if="unmatched.length"
type="warning"
:closable="false"
show-icon
title="这些段落不会丢失"
description="它们照常出现在正文里,只是没有标题。可以逐个用段落编辑里的「所属段落」搬到新模板的段落上。"
/>
</template>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!changed" :loading="saving" @click="submit">
确认切换
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.switcher {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.label {
flex: 0 0 72px;
font-size: 13px;
color: var(--el-text-color-regular);
}
.select {
width: 280px;
}
.option-hint {
float: right;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.impact {
margin: 0;
padding-left: 18px;
line-height: 2;
font-size: 13px;
}
.impact .ok {
color: var(--el-text-color-regular);
}
.impact .muted {
color: var(--el-text-color-secondary);
}
.impact .warn {
color: var(--el-color-warning);
}
</style>
+9
View File
@@ -22,6 +22,15 @@ const router = createRouter({
component: () => import('@/views/PapersView.vue'),
meta: { title: '论文', section: 'papers' },
},
{
// One paper, read as a document. The id is constrained to digits so a
// stray `/papers/anything` falls through to the not-found page instead
// of being parsed as `NaN` and bouncing back to the list.
path: '/papers/:id(\\d+)',
name: 'paper-detail',
component: () => import('@/views/papers/PaperDetailView.vue'),
meta: { title: '论文', section: 'papers' },
},
{
// The header links to the section, not to a page inside it.
path: '/templates',
+55
View File
@@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { errorMessage } from '@/api/client'
import { fetchAllPapers, type PaperListItem } from '@/api/papers'
/**
* The paper list, shared by the second-level menu and the pages that change it.
*
* The menu lives in the application shell and the pages live in the router
* view, so neither owns the list: a page that creates, renames or deletes a
* paper calls `reload()` and the menu updates with it, without the two having
* to know about each other.
*
* Deliberately *not* persisted. It is server data, and a stale copy restored
* from localStorage would show papers that no longer exist.
*/
export const usePapersStore = defineStore('papers', () => {
const items = ref<PaperListItem[]>([])
const loading = ref(false)
/** Whether a load has ever finished, so an empty list can be told from unloaded. */
const loaded = ref(false)
const error = ref<string | null>(null)
/** Reload the whole list. Concurrent callers share the same in-flight request. */
let inFlight: Promise<void> | null = null
async function reload(): Promise<void> {
if (inFlight) return inFlight
loading.value = true
inFlight = (async () => {
try {
items.value = await fetchAllPapers()
error.value = null
loaded.value = true
} catch (cause) {
error.value = errorMessage(cause)
} finally {
loading.value = false
inFlight = null
}
})()
return inFlight
}
/** Forget everything — used when a page wants the menu to show a fresh load. */
function reset(): void {
items.value = []
loaded.value = false
error.value = null
}
return { items, loading, loaded, error, reload, reset }
})
+35
View File
@@ -43,6 +43,41 @@ export function levelIndent(level: number): string {
return `${Math.max(0, level - 1) * 18}px`
}
/**
* The heading shown for a paragraph the template does not define.
*
* A sentence can sit at a position the current template has nothing at — the
* normal outcome of switching templates. Its content is still rendered, in its
* place in the order, under this label rather than being hidden or dropped.
*/
export const PARAGRAPH_UNSET_LABEL = '未设定'
/**
* Split the stored keyword string into individual keywords.
*
* The API writes a canonical ``,``-joined string, but a value typed straight
* into the field may use any separator a Chinese input method offers, so all of
* them are accepted on the way in.
*/
export function splitKeywords(value: string | null | undefined): string[] {
if (!value) return []
return value
.split(/[,,、;]+/)
.map((item) => item.trim())
.filter(Boolean)
}
/**
* Fold a sentence onto one line.
*
* A sentence is one line by definition, so a pasted newline inside one is
* collapsed instead of breaking the paragraph into pieces that no longer read
* as prose. Mirrors the backend's `fold_whitespace`.
*/
export function foldWhitespace(value: string): string {
return value.split(/\s+/).filter(Boolean).join(' ')
}
const HEX_PATTERN = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
const RGB_PATTERN = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$/
+19 -4
View File
@@ -11,12 +11,14 @@ import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Files, Notebook, Setting } from '@element-plus/icons-vue'
import { listPapers } from '@/api/papers'
import { listSectionFields } from '@/api/sectionFields'
import { listTemplates } from '@/api/templates'
import AppLogo from '@/components/AppLogo.vue'
const router = useRouter()
const paperCount = ref<number | null>(null)
const templateCount = ref<number | null>(null)
const fieldCount = ref<number | null>(null)
@@ -28,10 +30,12 @@ function display(value: number | null): string {
onMounted(async () => {
// `page_size: 1` because only `total` is used. A failure leaves the count as
// an em dash rather than blocking the page.
const [templates, fields] = await Promise.allSettled([
const [papers, templates, fields] = await Promise.allSettled([
listPapers({ page: 1, page_size: 1 }),
listTemplates({ page: 1, page_size: 1 }),
listSectionFields({ page: 1, page_size: 1 }),
])
if (papers.status === 'fulfilled') paperCount.value = papers.value.total
if (templates.status === 'fulfilled') templateCount.value = templates.value.total
if (fields.status === 'fulfilled') fieldCount.value = fields.value.total
})
@@ -49,8 +53,10 @@ onMounted(async () => {
<button type="button" class="entry" @click="router.push('/papers')">
<el-icon class="entry-icon"><Notebook /></el-icon>
<span class="entry-title">论文</span>
<span class="entry-desc">按模板撰写管理与导出论文</span>
<el-tag size="small" type="info" effect="plain">内容待定</el-tag>
<span class="entry-desc">按模板逐段撰写与管理论文</span>
<el-tag size="small" type="success" effect="plain">
{{ display(paperCount) }}
</el-tag>
</button>
<button type="button" class="entry" @click="router.push('/templates')">
@@ -64,6 +70,14 @@ onMounted(async () => {
</section>
<el-card shadow="never" class="stats">
<div class="stat">
<el-icon class="stat-icon"><Notebook /></el-icon>
<div>
<div class="stat-value">{{ display(paperCount) }}</div>
<div class="stat-label">论文</div>
</div>
</div>
<el-divider direction="vertical" class="stat-divider" />
<div class="stat">
<el-icon class="stat-icon"><Setting /></el-icon>
<div>
@@ -80,7 +94,8 @@ onMounted(async () => {
</div>
</div>
<div class="stat-hint">
字段可以自由组合进模板显示顺序完全由每个字段的 sort 决定
字段可以自由组合进模板显示顺序完全由每个字段的 sort 决定论文按模板的段落逐句填写
换模板不会丢内容
</div>
</el-card>
</div>
+387 -24
View File
@@ -1,16 +1,170 @@
<script setup lang="ts">
/**
* The 论文 section.
* 我的论文 — the table of every paper.
*
* Its content is still to be decided. The page exists so the section is
* reachable from the header and so its second-level menu has somewhere to
* point; the outline that will drive the writing surface already exists as
* templates, so what lands here is the editor that reads one.
* The full CRUD surface: search, create, edit, open, single and batch delete.
* Opening a paper is a navigation rather than a drawer, because a paper is
* where the writing happens and a drawer would fight the page for the job.
*
* Switching a paper's template is deliberately *not* offered here. It re-shapes
* the whole document, and the only honest place to do that is the paper's own
* view, where the impact can be shown against the content that actually exists.
*/
import { useRouter } from 'vue-router'
import { Files } from '@element-plus/icons-vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Delete, Edit, Plus, Refresh, Search, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { errorMessage } from '@/api/client'
import {
batchDeletePapers,
deletePaper,
listPapers,
PAPER_STATUS_LABELS,
PAPER_STATUS_OPTIONS,
PAPER_STATUS_TAG,
type PaperListItem,
type PaperStatus,
} from '@/api/papers'
import PaperFormDialog from '@/components/papers/PaperFormDialog.vue'
import { usePapersStore } from '@/stores/papers'
import { formatDateTime, splitKeywords } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const papersStore = usePapersStore()
const loading = ref(false)
const rows = ref<PaperListItem[]>([])
const total = ref(0)
const selection = ref<PaperListItem[]>([])
const query = reactive({
keyword: '',
status: null as PaperStatus | null,
page: 1,
page_size: 20,
})
const dialogVisible = ref(false)
const editing = ref<PaperListItem | null>(null)
async function load(): Promise<void> {
loading.value = true
try {
const result = await listPapers(query)
rows.value = result.items
total.value = result.total
// Deleting the last row of the last page would otherwise leave the table
// empty while the pager still points past the end.
if (result.items.length === 0 && result.total > 0 && query.page > 1) {
query.page -= 1
await load()
}
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
loading.value = false
}
}
function search(): void {
query.page = 1
void load()
}
function resetFilters(): void {
query.keyword = ''
query.status = null
query.page = 1
void load()
}
function onCreate(): void {
editing.value = null
dialogVisible.value = true
}
function onEdit(row: PaperListItem): void {
editing.value = row
dialogVisible.value = true
}
function openPaper(row: PaperListItem): void {
void router.push(`/papers/${row.id}`)
}
/** Refresh both this table and the menu, which lists the same papers. */
async function refreshAll(): Promise<void> {
await Promise.all([load(), papersStore.reload()])
}
async function onDelete(row: PaperListItem): Promise<void> {
try {
await ElMessageBox.confirm(
`确定删除论文「${row.title}」?其中的 ${row.sentence_count} 句正文会一并删除。`,
'删除论文',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return // dismissed
}
try {
await deletePaper(row.id)
ElMessage.success('已删除')
await refreshAll()
} catch (error) {
ElMessage.error(errorMessage(error))
}
}
async function onBatchDelete(): Promise<void> {
const ids = selection.value.map((row) => row.id)
if (ids.length === 0) return
try {
await ElMessageBox.confirm(
`确定删除选中的 ${ids.length} 篇论文?正文会一并删除。`,
'批量删除',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return // dismissed
}
try {
const result = await batchDeletePapers(ids)
ElMessage.success(`已删除 ${result.deleted} 篇论文`)
selection.value = []
await refreshAll()
} catch (error) {
ElMessage.error(errorMessage(error))
}
}
/**
* Open the create dialog when the menu's 新建论文 entry sent us here.
*
* The query is cleared straight away, so a refresh or a back-navigation does
* not reopen the dialog.
*/
watch(
() => route.query.new,
(flag) => {
if (!flag) return
onCreate()
void router.replace({ path: '/papers' })
},
{ immediate: true },
)
onMounted(async () => {
await load()
// The menu may have been loaded before this page existed; keep it in step.
void papersStore.reload()
})
</script>
<template>
@@ -18,24 +172,183 @@ const router = useRouter()
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>我的论文</span>
<el-tag size="small" type="info" effect="plain">内容待定</el-tag>
<div>
<span class="card-title">我的论文</span>
<span class="card-subtitle">论文的结构来自模板内容按段落逐句填写</span>
</div>
<el-tag type="info" effect="plain"> {{ total }} </el-tag>
</div>
</template>
<el-empty description="论文列表与写作界面尚未确定">
<template #description>
<p class="hint">
这一块的内容还没定目前可以先在
<strong>模板</strong> 里把结构配置好模板选定的段落字段
就是之后写作时逐段填写的目录
</p>
</template>
<el-button type="primary" :icon="Files" @click="router.push('/templates/list')">
去配置模板
<div class="toolbar">
<el-input
v-model="query.keyword"
placeholder="搜索标题、作者或关键词"
clearable
class="search"
@keyup.enter="search"
@clear="search"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select
v-model="query.status"
placeholder="全部状态"
clearable
class="status-filter"
@change="search"
>
<el-option
v-for="option in PAPER_STATUS_OPTIONS"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</el-select>
<el-button @click="search">查询</el-button>
<el-button text @click="resetFilters">重置</el-button>
<div class="toolbar-spacer" />
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
<el-button
type="danger"
plain
:icon="Delete"
:disabled="selection.length === 0"
@click="onBatchDelete"
>
批量删除{{ selection.length ? `${selection.length}` : '' }}
</el-button>
</el-empty>
<el-button type="primary" :icon="Plus" @click="onCreate">新建论文</el-button>
</div>
<el-table
v-loading="loading"
:data="rows"
row-key="id"
stripe
class="table"
@selection-change="(value: PaperListItem[]) => (selection = value)"
>
<el-table-column type="selection" width="46" reserve-selection />
<el-table-column label="论文标题" min-width="220">
<template #default="{ row }">
<el-button link type="primary" class="title-link" @click="openPaper(row)">
{{ row.title }}
</el-button>
<div v-if="row.abstract" class="abstract" :title="row.abstract">
{{ row.abstract }}
</div>
</template>
</el-table-column>
<el-table-column label="模板" min-width="160">
<template #default="{ row }">
<el-tag v-if="row.template_name" size="small" effect="plain">
{{ row.template_name }}
</el-tag>
<el-tag v-else size="small" type="warning" effect="plain">未选择模板</el-tag>
</template>
</el-table-column>
<el-table-column label="作者" width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.author">{{ row.author }}</span>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag
size="small"
:type="PAPER_STATUS_TAG[row.status as PaperStatus]"
effect="plain"
>
{{ PAPER_STATUS_LABELS[row.status as PaperStatus] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="130">
<template #default="{ row }">
<span class="progress">
<strong>{{ row.paragraph_count }}</strong> / {{ row.template_paragraph_count }} 段
</span>
<div class="progress-sub">{{ row.sentence_count }} 句</div>
</template>
</el-table-column>
<el-table-column label="关键词" min-width="160">
<template #default="{ row }">
<template v-if="splitKeywords(row.keywords).length">
<el-tag
v-for="word in splitKeywords(row.keywords).slice(0, 3)"
:key="word"
size="small"
type="info"
effect="plain"
class="keyword"
>
{{ word }}
</el-tag>
<span v-if="splitKeywords(row.keywords).length > 3" class="muted">
+{{ splitKeywords(row.keywords).length - 3 }}
</span>
</template>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="目标期刊" width="150" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.target_journal">{{ row.target_journal }}</span>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="openPaper(row)">打开</el-button>
<el-button link type="primary" :icon="Edit" @click="onEdit(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty
:description="
query.keyword || query.status ? '没有匹配的论文' : '还没有论文,先创建一篇'
"
>
<el-button type="primary" :icon="Plus" @click="onCreate">新建论文</el-button>
</el-empty>
</template>
</el-table>
<div class="table-pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="search"
/>
</div>
</el-card>
<PaperFormDialog v-model="dialogVisible" :paper="editing" @saved="refreshAll" />
</div>
</template>
@@ -44,13 +357,63 @@ const router = useRouter()
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hint {
max-width: 460px;
margin: 0 auto;
.card-title {
font-weight: 600;
margin-right: 10px;
}
.card-subtitle {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.search {
width: 260px;
}
.status-filter {
width: 130px;
}
.table {
margin-top: 16px;
}
.title-link {
font-weight: 600;
padding: 0;
height: auto;
text-align: left;
}
.abstract {
margin-top: 2px;
max-width: 320px;
font-size: 12px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress {
font-size: 13px;
line-height: 1.7;
font-variant-numeric: tabular-nums;
}
.progress-sub {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.keyword {
margin-right: 4px;
}
.muted {
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,466 @@
<script setup lang="ts">
/**
* One paper, read as a document.
*
* The page renders exactly what the server assembled — the template's
* paragraphs in position order, each carrying the sentences stored at its
* position — and refuses to do any of the assembling itself. That is the whole
* point of the feature: the shape of a paper is decided once, by its template,
* and cannot be re-derived differently on the client.
*
* Three states are visible and none of them is an error:
*
* * a **written paragraph** — heading, prose, numbered citation markers;
* * an **empty paragraph** — heading and a placeholder, because the structure
* is there before the words are;
* * an **unmatched paragraph** (未设定) — content whose position the current
* template does not define, kept in order rather than hidden, and recoverable
* by editing the paragraph or switching back.
*
* Every paragraph carries its own edit button, written or not: the editor is
* the only way content enters a paper, so it may never be the thing that is
* missing.
*/
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft,
Delete,
Edit,
Refresh,
Right,
Switch,
Warning,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { errorMessage } from '@/api/client'
import {
deletePaper,
getPaperDocument,
PAPER_STATUS_LABELS,
PAPER_STATUS_TAG,
type PaperDocument,
type PaperParagraph,
} from '@/api/papers'
import PaperFormDialog from '@/components/papers/PaperFormDialog.vue'
import ParagraphEditDialog from '@/components/papers/ParagraphEditDialog.vue'
import TemplateSwitchDialog from '@/components/papers/TemplateSwitchDialog.vue'
import PaperParagraphView from '@/components/papers/PaperParagraph.vue'
import { usePapersStore } from '@/stores/papers'
import { PARAGRAPH_UNSET_LABEL, formatDateTime, splitKeywords } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const papersStore = usePapersStore()
const loading = ref(false)
const paperDoc = ref<PaperDocument | null>(null)
const paragraphDialog = ref(false)
const editingSort = ref<number | null>(null)
const switchDialog = ref(false)
const formDialog = ref(false)
const paperId = computed(() => Number(route.params.id))
const paper = computed(() => paperDoc.value?.paper ?? null)
/** Citation id -> the number shown in the text, in reading order. */
const citationIndex = computed<Record<number, number>>(() => {
const map: Record<number, number> = {}
for (const citation of paperDoc.value?.citations ?? []) {
map[citation.id] = citation.index
}
return map
})
/** How much of the structure has been written, for the header read-out. */
const progress = computed(() => {
if (!paperDoc.value) return { written: 0, total: 0, unmatched: 0 }
const paragraphs = paperDoc.value.paragraphs
return {
written: paragraphs.filter((paragraph) => paragraph.sentences.length > 0).length,
total: paragraphs.filter((paragraph) => paragraph.matched).length,
unmatched: paragraphs.filter((paragraph) => !paragraph.matched).length,
}
})
/** Any 未设定 paragraph means content lost its heading in a template switch. */
const hasUnmatched = computed(() => progress.value.unmatched > 0)
async function load(): Promise<void> {
if (!Number.isFinite(paperId.value)) {
void router.replace('/papers')
return
}
loading.value = true
try {
paperDoc.value = await getPaperDocument(paperId.value)
} catch (error) {
ElMessage.error(errorMessage(error))
paperDoc.value = null
} finally {
loading.value = false
}
}
function openParagraph(paragraph: PaperParagraph): void {
editingSort.value = paragraph.paper_template_filed_sort
paragraphDialog.value = true
}
/** A paragraph write returns the refreshed document, so state is replaced. */
function onParagraphSaved(updated: PaperDocument): void {
paperDoc.value = updated
void papersStore.reload()
}
async function onPaperSaved(): Promise<void> {
await load()
await papersStore.reload()
}
async function onTemplateSwitched(): Promise<void> {
await onPaperSaved()
if (hasUnmatched.value) {
ElMessage.warning(
`${progress.value.unmatched} 段内容在新模板里没有对应标题,已按顺序保留并显示为「${PARAGRAPH_UNSET_LABEL}`,
)
}
}
async function onDelete(): Promise<void> {
const current = paper.value
if (!current) return
try {
await ElMessageBox.confirm(
`确定删除论文「${current.title}」?其中的 ${current.sentence_count} 句正文会一并删除。`,
'删除论文',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return // dismissed
}
try {
await deletePaper(current.id)
await papersStore.reload()
ElMessage.success('已删除')
void router.push('/papers')
} catch (error) {
ElMessage.error(errorMessage(error))
}
}
/** Scroll to a paragraph, so a citation can point back at its source. */
function scrollToParagraph(sort: number): void {
const element = document.getElementById(`paragraph-${sort}`)
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
/** Reload when the route id changes, without remounting the view. */
watch(paperId, () => {
editingSort.value = null
void load()
})
onMounted(load)
</script>
<template>
<div v-loading="loading" class="page">
<el-empty v-if="!loading && !paper" description="论文不存在或已被删除">
<el-button type="primary" :icon="ArrowLeft" @click="router.push('/papers')">
返回论文列表
</el-button>
</el-empty>
<template v-else-if="paper">
<el-card shadow="never" class="head-card">
<div class="head">
<div class="head-main">
<div class="head-top">
<el-button link :icon="ArrowLeft" @click="router.push('/papers')">论文列表</el-button>
<el-divider direction="vertical" />
<el-tag size="small" :type="PAPER_STATUS_TAG[paper.status]" effect="plain">
{{ PAPER_STATUS_LABELS[paper.status] }}
</el-tag>
<el-tag v-if="paper.template_name" size="small" effect="plain" type="success">
模板{{ paper.template_name }}
</el-tag>
<el-tag v-else size="small" effect="plain" type="warning">未选择模板</el-tag>
<span v-if="hasUnmatched" class="unmatched-hint">
<el-icon><Warning /></el-icon>
{{ progress.unmatched }} 段没有对应标题
</span>
</div>
<h1 class="head-title">{{ paper.title }}</h1>
<div class="head-meta">
<span v-if="paper.author">作者{{ paper.author }}</span>
<span v-if="paper.target_journal">目标期刊{{ paper.target_journal }}</span>
<span>
进度{{ progress.written }} / {{ progress.total }} ·
{{ paper.sentence_count }}
</span>
<span>更新于 {{ formatDateTime(paper.updated_at) }}</span>
</div>
<div v-if="splitKeywords(paper.keywords).length" class="head-keywords">
<el-tag
v-for="word in splitKeywords(paper.keywords)"
:key="word"
size="small"
type="info"
effect="plain"
>
{{ word }}
</el-tag>
</div>
<p v-if="paper.abstract" class="head-abstract">{{ paper.abstract }}</p>
</div>
<div class="head-actions">
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
<el-button :icon="Switch" @click="switchDialog = true">切换模板</el-button>
<el-button :icon="Edit" @click="formDialog = true">编辑信息</el-button>
<el-button type="danger" plain :icon="Delete" @click="onDelete">删除</el-button>
</div>
</div>
</el-card>
<el-alert
v-for="warning in paperDoc?.warnings ?? []"
:key="warning"
type="warning"
:closable="false"
show-icon
:title="warning"
/>
<el-card shadow="never" class="paper-card">
<template #header>
<div class="paper-head">
<span class="paper-title">正文</span>
<span class="paper-hint">
段落顺序完全来自模板的 sort每个段落右侧都可以单独编辑
</span>
</div>
</template>
<el-empty
v-if="!paperDoc?.paragraphs.length"
description="这篇论文还没有结构,先选择一个模板"
>
<el-button type="primary" :icon="Switch" @click="switchDialog = true">
选择模板
</el-button>
</el-empty>
<div v-else class="sheet">
<PaperParagraphView
v-for="paragraph in paperDoc.paragraphs"
:key="paragraph.paper_template_filed_sort"
:paragraph="paragraph"
:citation-index="citationIndex"
@edit="openParagraph"
/>
</div>
</el-card>
<el-card v-if="paperDoc?.citations.length" shadow="never">
<template #header>
<div class="paper-head">
<span class="paper-title">参考文献引用</span>
<span class="paper-hint">按正文出现顺序编号正文中的上标与这里的序号一一对应</span>
</div>
</template>
<ol class="references">
<li v-for="citation in paperDoc.citations" :key="citation.id" class="reference">
<span class="reference-index">[{{ citation.index }}]</span>
<div class="reference-body">
<div class="reference-quote">{{ citation.quote }}</div>
<div class="reference-meta">
<el-tag v-if="citation.reference_id" size="small" effect="plain">
引用 #{{ citation.reference_id }}
</el-tag>
<el-tag v-else size="small" type="warning" effect="plain">未关联引用 id</el-tag>
<el-button link type="primary" @click="scrollToParagraph(citation.paper_template_filed_sort)">
{{ citation.paragraph_name ?? PARAGRAPH_UNSET_LABEL }}
<el-icon><Right /></el-icon>
</el-button>
</div>
</div>
</li>
</ol>
</el-card>
<ParagraphEditDialog
v-model="paragraphDialog"
:paper-id="paper.id"
:field-sort="editingSort"
:paragraphs="paperDoc?.paragraphs ?? []"
@saved="onParagraphSaved"
/>
<TemplateSwitchDialog
v-if="paperDoc"
v-model="switchDialog"
:paper="paper"
:paragraphs="paperDoc.paragraphs"
@saved="onTemplateSwitched"
/>
<PaperFormDialog
v-model="formDialog"
:paper="paper"
@saved="onPaperSaved"
/>
</template>
</div>
</template>
<style scoped>
.head-card :deep(.el-card__body) {
padding-bottom: 16px;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.head-main {
min-width: 0;
flex: 1 1 420px;
}
.head-top {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.unmatched-hint {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-color-warning);
}
.head-title {
margin: 8px 0 6px;
font-size: 22px;
font-weight: 650;
line-height: 1.4;
overflow-wrap: anywhere;
}
.head-meta {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.head-keywords {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
.head-abstract {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.8;
color: var(--el-text-color-regular);
}
.head-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
flex: 0 0 auto;
}
.paper-head {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.paper-title {
font-weight: 600;
}
.paper-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* The writing surface: a sheet, not a table — the paper's own typography is
what the template describes, so the container stays out of the way. */
.sheet {
display: flex;
flex-direction: column;
max-width: 900px;
margin: 0 auto;
}
.references {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.reference {
display: flex;
gap: 10px;
align-items: flex-start;
}
.reference-index {
flex: 0 0 auto;
min-width: 34px;
font-variant-numeric: tabular-nums;
color: var(--el-color-primary);
}
.reference-body {
min-width: 0;
}
.reference-quote {
font-size: 13px;
line-height: 1.8;
color: var(--el-text-color-regular);
overflow-wrap: anywhere;
}
.reference-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
</style>