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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user