frontend: add the paper list, document view, and paragraph editor

The 论文 menu now carries the papers themselves, read from a pinia store, so a
paper opens straight from the rail; every page that changes the list reloads
that store, and a filter box appears once there are more than six.

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

The paragraph editor shows one paragraph as the paper will read it — every
sentence on its own line, in sort order, each editable, each able to carry
citations — and writes the paragraph back whole. Whitespace-only lines are
dropped on save; a citation with an empty 引用内容 is refused; and 所属段落
moves the paragraph to another position, appended after what is already there.
This commit is contained in:
2026-09-18 17:29:16 +08:00
parent 06d7e922bd
commit 4d749b3592
13 changed files with 2813 additions and 57 deletions
+282
View File
@@ -0,0 +1,282 @@
import http from './client'
import type { BatchDeleteResult, PageQuery, PageResult } from './types'
/**
* Papers (论文) — the writing surface.
*
* Two shapes matter here. A *paper* is metadata plus a template reference; a
* *document* is that paper as it reads, assembled by the server: the template's
* paragraphs in position order, each carrying the sentences stored at its
* position. The client never merges the two itself, so what is written and what
* is rendered cannot order themselves differently.
*/
/** 草稿 / 撰写中 / 已完成. Mirrors `app.models.paper.PAPER_STATUSES`. */
export type PaperStatus = 'draft' | 'writing' | 'done'
/** Display labels and tag colours for the three states, in one place. */
export const PAPER_STATUS_LABELS: Record<PaperStatus, string> = {
draft: '草稿',
writing: '撰写中',
done: '已完成',
}
export const PAPER_STATUS_TAG: Record<PaperStatus, 'info' | 'primary' | 'success'> = {
draft: 'info',
writing: 'primary',
done: 'success',
}
/** The states a select offers, in workflow order. */
export const PAPER_STATUS_OPTIONS: { value: PaperStatus; label: string }[] = [
{ value: 'draft', label: PAPER_STATUS_LABELS.draft },
{ value: 'writing', label: PAPER_STATUS_LABELS.writing },
{ value: 'done', label: PAPER_STATUS_LABELS.done },
]
/** A paper as it appears in the table. */
export interface PaperListItem {
id: number
title: string
/** The template it is written against, or `null` when none is chosen yet. */
template_id: number | null
template_name: string | null
author: string | null
status: PaperStatus
/** Comma-separated; the API normalises the spelling on write. */
keywords: string | null
target_journal: string | null
/** How many sentences have been written. */
sentence_count: number
/** How many distinct paragraphs hold those sentences. */
paragraph_count: number
/** How many paragraphs the current template defines — the progress denominator. */
template_paragraph_count: number
created_at: string
updated_at: string
}
/** A paper with everything the table does not need. */
export interface PaperDetail extends PaperListItem {
abstract: string | null
}
/** A stored citation of one sentence. */
export interface Citation {
id: number
/** Reserved for the reference library; `null` until it is linked. */
reference_id: number | null
/** 引用内容 — never blank: the API refuses a citation without it. */
quote: string
sort: number
}
/** A stored sentence with its citations. */
export interface Sentence {
id: number
paper_id: number
template_id: number | null
/** Which paragraph, as the template placement's `sort`. */
paper_template_filed_sort: number
/** Position inside that paragraph. */
sort: number
content: string
citations: Citation[]
created_at: string
updated_at: string
}
/** One paragraph of a paper: its heading and the sentences under it. */
export interface PaperParagraph {
paper_template_filed_sort: number
template_field_id: number | null
field_id: number | null
/** `null` when the template has no placement here — rendered as 未设定. */
name: string | null
level: number
font_size: number | null
font_color: string | null
/** `false` for a paragraph that exists only because content is stored there. */
matched: boolean
sentences: Sentence[]
}
/** One citation as it appears in the 参考文献 list. */
export interface PaperCitation extends Citation {
/** The number shown in the text as `[index]`, in reading order. */
index: number
sentence_id: number
sentence_content: string
paper_template_filed_sort: number
paragraph_name: string | null
}
/** The whole paper as it reads. */
export interface PaperDocument {
paper: PaperDetail
paragraphs: PaperParagraph[]
citations: PaperCitation[]
warnings: string[]
}
/** Response of a paragraph read. */
export interface ParagraphDetail {
paper_id: number
paper_title: string
template_id: number | null
paragraph: PaperParagraph
}
/** A citation as the editor sends it. */
export interface CitationInput {
reference_id: number | null
quote: string
}
/** A sentence as the editor sends it. `sort` omitted means "number them for me". */
export interface SentenceInput {
sort?: number | null
content: string
citations: CitationInput[]
}
export interface PaperPayload {
title: string
template_id: number | null
abstract: string | null
author: string | null
status: PaperStatus
keywords: string | null
target_journal: string | null
}
export interface PaperQuery extends PageQuery {
status?: PaperStatus | null
template_id?: number | null
}
/** The API caps `page_size` at 200; the menu wants every paper, so it loops. */
const PAGE_SIZE = 200
function queryParams(query: PaperQuery): Record<string, unknown> {
const params: Record<string, unknown> = {}
if (query.keyword) params.keyword = query.keyword
if (query.status) params.status = query.status
if (query.template_id != null) params.template_id = query.template_id
if (query.page) params.page = query.page
if (query.page_size) params.page_size = query.page_size
return params
}
export async function listPapers(query: PaperQuery = {}): Promise<PageResult<PaperListItem>> {
const { data } = await http.get<PageResult<PaperListItem>>('/papers', {
params: queryParams(query),
})
return data
}
/**
* Fetch every paper, following pagination.
*
* Used by the second-level menu, which lists papers rather than pages of them,
* so a partial first page would silently hide papers from the navigation.
*/
export async function fetchAllPapers(): Promise<PaperListItem[]> {
const all: PaperListItem[] = []
let page = 1
for (;;) {
const result = await listPapers({ page, page_size: PAGE_SIZE })
all.push(...result.items)
if (page >= result.pages || result.items.length === 0) {
return all
}
page += 1
}
}
export async function getPaper(id: number): Promise<PaperDetail> {
const { data } = await http.get<PaperDetail>(`/papers/${id}`)
return data
}
/** Fetch the paper as a document: structure, content, citations, warnings. */
export async function getPaperDocument(id: number): Promise<PaperDocument> {
const { data } = await http.get<PaperDocument>(`/papers/${id}/document`)
return data
}
export async function createPaper(payload: PaperPayload): Promise<PaperDetail> {
const { data } = await http.post<PaperDetail>('/papers', payload)
return data
}
export async function updatePaper(
id: number,
payload: Partial<PaperPayload>,
): Promise<PaperDetail> {
const { data } = await http.patch<PaperDetail>(`/papers/${id}`, payload)
return data
}
export async function deletePaper(id: number): Promise<void> {
await http.delete(`/papers/${id}`)
}
export async function batchDeletePapers(ids: number[]): Promise<BatchDeleteResult> {
const { data } = await http.post<BatchDeleteResult>('/papers/batch-delete', { ids })
return data
}
export async function getParagraph(
paperId: number,
fieldSort: number,
): Promise<ParagraphDetail> {
const { data } = await http.get<ParagraphDetail>(
`/papers/${paperId}/paragraphs/${fieldSort}`,
)
return data
}
/**
* Write a paragraph whole.
*
* `target_sort` moves the paragraph to another position, appending after
* whatever is already there. The refreshed document comes back, so the reader
* view can replace its state in one step.
*/
export async function replaceParagraph(
paperId: number,
fieldSort: number,
payload: { sentences: SentenceInput[]; target_sort?: number | null },
): Promise<PaperDocument> {
const { data } = await http.put<PaperDocument>(
`/papers/${paperId}/paragraphs/${fieldSort}`,
payload,
)
return data
}
export async function createSentence(
paperId: number,
payload: SentenceInput & { paper_template_filed_sort: number },
): Promise<Sentence> {
const { data } = await http.post<Sentence>(`/papers/${paperId}/sentences`, payload)
return data
}
export async function updateSentence(
paperId: number,
sentenceId: number,
payload: Partial<SentenceInput> & { paper_template_filed_sort?: number },
): Promise<Sentence> {
const { data } = await http.patch<Sentence>(
`/papers/${paperId}/sentences/${sentenceId}`,
payload,
)
return data
}
export async function deleteSentence(paperId: number, sentenceId: number): Promise<void> {
await http.delete(`/papers/${paperId}/sentences/${sentenceId}`)
}
+24
View File
@@ -62,6 +62,30 @@ export async function getTemplate(id: number): Promise<TemplateDetail> {
return data
}
/** The API caps `page_size` at 200; a picker wants every template, so it loops. */
const TEMPLATE_PAGE_SIZE = 200
/**
* Fetch every template, following pagination.
*
* A template picker that showed only the first page would silently hide the
* template the user is looking for, and the paper view needs the whole list
* before it can offer a switch without a search round trip.
*/
export async function fetchAllTemplates(): Promise<TemplateListItem[]> {
const all: TemplateListItem[] = []
let page = 1
for (;;) {
const result = await listTemplates({ page, page_size: TEMPLATE_PAGE_SIZE })
all.push(...result.items)
if (page >= result.pages || result.items.length === 0) {
return all
}
page += 1
}
}
export async function createTemplate(payload: TemplatePayload): Promise<TemplateDetail> {
const { data } = await http.post<TemplateDetail>('/templates', payload)
return data