Compare commits
3 Commits
0d0aa20be2
...
2d113f9f6b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d113f9f6b | |||
| 4d749b3592 | |||
| 06d7e922bd |
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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*)?\)$/
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user