From 4d749b35922c2949a597d5a6850d7b99f7ad2699 Mon Sep 17 00:00:00 2001 From: govin Date: Fri, 18 Sep 2026 17:29:16 +0800 Subject: [PATCH] frontend: add the paper list, document view, and paragraph editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/api/papers.ts | 282 +++++++++ frontend/src/api/templates.ts | 24 + frontend/src/components/SectionAside.vue | 210 ++++++- .../src/components/papers/PaperFormDialog.vue | 267 ++++++++ .../src/components/papers/PaperParagraph.vue | 250 ++++++++ .../components/papers/ParagraphEditDialog.vue | 576 ++++++++++++++++++ .../papers/TemplateSwitchDialog.vue | 262 ++++++++ frontend/src/router/index.ts | 9 + frontend/src/stores/papers.ts | 55 ++ frontend/src/utils/format.ts | 35 ++ frontend/src/views/HomeView.vue | 23 +- frontend/src/views/PapersView.vue | 411 ++++++++++++- frontend/src/views/papers/PaperDetailView.vue | 466 ++++++++++++++ 13 files changed, 2813 insertions(+), 57 deletions(-) create mode 100644 frontend/src/api/papers.ts create mode 100644 frontend/src/components/papers/PaperFormDialog.vue create mode 100644 frontend/src/components/papers/PaperParagraph.vue create mode 100644 frontend/src/components/papers/ParagraphEditDialog.vue create mode 100644 frontend/src/components/papers/TemplateSwitchDialog.vue create mode 100644 frontend/src/stores/papers.ts create mode 100644 frontend/src/views/papers/PaperDetailView.vue diff --git a/frontend/src/api/papers.ts b/frontend/src/api/papers.ts new file mode 100644 index 0000000..02e58a6 --- /dev/null +++ b/frontend/src/api/papers.ts @@ -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 = { + draft: '草稿', + writing: '撰写中', + done: '已完成', +} + +export const PAPER_STATUS_TAG: Record = { + 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 { + const params: Record = {} + 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> { + const { data } = await http.get>('/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 { + 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 { + const { data } = await http.get(`/papers/${id}`) + return data +} + +/** Fetch the paper as a document: structure, content, citations, warnings. */ +export async function getPaperDocument(id: number): Promise { + const { data } = await http.get(`/papers/${id}/document`) + return data +} + +export async function createPaper(payload: PaperPayload): Promise { + const { data } = await http.post('/papers', payload) + return data +} + +export async function updatePaper( + id: number, + payload: Partial, +): Promise { + const { data } = await http.patch(`/papers/${id}`, payload) + return data +} + +export async function deletePaper(id: number): Promise { + await http.delete(`/papers/${id}`) +} + +export async function batchDeletePapers(ids: number[]): Promise { + const { data } = await http.post('/papers/batch-delete', { ids }) + return data +} + +export async function getParagraph( + paperId: number, + fieldSort: number, +): Promise { + const { data } = await http.get( + `/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 { + const { data } = await http.put( + `/papers/${paperId}/paragraphs/${fieldSort}`, + payload, + ) + return data +} + +export async function createSentence( + paperId: number, + payload: SentenceInput & { paper_template_filed_sort: number }, +): Promise { + const { data } = await http.post(`/papers/${paperId}/sentences`, payload) + return data +} + +export async function updateSentence( + paperId: number, + sentenceId: number, + payload: Partial & { paper_template_filed_sort?: number }, +): Promise { + const { data } = await http.patch( + `/papers/${paperId}/sentences/${sentenceId}`, + payload, + ) + return data +} + +export async function deleteSentence(paperId: number, sentenceId: number): Promise { + await http.delete(`/papers/${paperId}/sentences/${sentenceId}`) +} diff --git a/frontend/src/api/templates.ts b/frontend/src/api/templates.ts index b0ddcef..e687290 100644 --- a/frontend/src/api/templates.ts +++ b/frontend/src/api/templates.ts @@ -62,6 +62,30 @@ export async function getTemplate(id: number): Promise { 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 { + 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 { const { data } = await http.post('/templates', payload) return data diff --git a/frontend/src/components/SectionAside.vue b/frontend/src/components/SectionAside.vue index 8189268..7e93ee2 100644 --- a/frontend/src/components/SectionAside.vue +++ b/frontend/src/components/SectionAside.vue @@ -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 = { 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 = { const menu = computed(() => 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(() => 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) @@ -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); +} diff --git a/frontend/src/components/papers/PaperFormDialog.vue b/frontend/src/components/papers/PaperFormDialog.vue new file mode 100644 index 0000000..dc479ce --- /dev/null +++ b/frontend/src/components/papers/PaperFormDialog.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/frontend/src/components/papers/PaperParagraph.vue b/frontend/src/components/papers/PaperParagraph.vue new file mode 100644 index 0000000..b1f3714 --- /dev/null +++ b/frontend/src/components/papers/PaperParagraph.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/frontend/src/components/papers/ParagraphEditDialog.vue b/frontend/src/components/papers/ParagraphEditDialog.vue new file mode 100644 index 0000000..128f3fd --- /dev/null +++ b/frontend/src/components/papers/ParagraphEditDialog.vue @@ -0,0 +1,576 @@ + + + + + diff --git a/frontend/src/components/papers/TemplateSwitchDialog.vue b/frontend/src/components/papers/TemplateSwitchDialog.vue new file mode 100644 index 0000000..546a9b3 --- /dev/null +++ b/frontend/src/components/papers/TemplateSwitchDialog.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 674771d..4ae52fa 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/stores/papers.ts b/frontend/src/stores/papers.ts new file mode 100644 index 0000000..b6eb965 --- /dev/null +++ b/frontend/src/stores/papers.ts @@ -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([]) + 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(null) + + /** Reload the whole list. Concurrent callers share the same in-flight request. */ + let inFlight: Promise | null = null + + async function reload(): Promise { + 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 } +}) diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 635603d..c45b06c 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -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*)?\)$/ diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 026794f..47fe036 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -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(null) const templateCount = ref(null) const fieldCount = ref(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 () => {