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
+19 -4
View File
@@ -11,12 +11,14 @@ import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Files, Notebook, Setting } from '@element-plus/icons-vue'
import { listPapers } from '@/api/papers'
import { listSectionFields } from '@/api/sectionFields'
import { listTemplates } from '@/api/templates'
import AppLogo from '@/components/AppLogo.vue'
const router = useRouter()
const paperCount = ref<number | null>(null)
const templateCount = ref<number | null>(null)
const fieldCount = ref<number | null>(null)
@@ -28,10 +30,12 @@ function display(value: number | null): string {
onMounted(async () => {
// `page_size: 1` because only `total` is used. A failure leaves the count as
// an em dash rather than blocking the page.
const [templates, fields] = await Promise.allSettled([
const [papers, templates, fields] = await Promise.allSettled([
listPapers({ page: 1, page_size: 1 }),
listTemplates({ page: 1, page_size: 1 }),
listSectionFields({ page: 1, page_size: 1 }),
])
if (papers.status === 'fulfilled') paperCount.value = papers.value.total
if (templates.status === 'fulfilled') templateCount.value = templates.value.total
if (fields.status === 'fulfilled') fieldCount.value = fields.value.total
})
@@ -49,8 +53,10 @@ onMounted(async () => {
<button type="button" class="entry" @click="router.push('/papers')">
<el-icon class="entry-icon"><Notebook /></el-icon>
<span class="entry-title">论文</span>
<span class="entry-desc">按模板撰写管理与导出论文</span>
<el-tag size="small" type="info" effect="plain">内容待定</el-tag>
<span class="entry-desc">按模板逐段撰写与管理论文</span>
<el-tag size="small" type="success" effect="plain">
{{ display(paperCount) }}
</el-tag>
</button>
<button type="button" class="entry" @click="router.push('/templates')">
@@ -64,6 +70,14 @@ onMounted(async () => {
</section>
<el-card shadow="never" class="stats">
<div class="stat">
<el-icon class="stat-icon"><Notebook /></el-icon>
<div>
<div class="stat-value">{{ display(paperCount) }}</div>
<div class="stat-label">论文</div>
</div>
</div>
<el-divider direction="vertical" class="stat-divider" />
<div class="stat">
<el-icon class="stat-icon"><Setting /></el-icon>
<div>
@@ -80,7 +94,8 @@ onMounted(async () => {
</div>
</div>
<div class="stat-hint">
字段可以自由组合进模板显示顺序完全由每个字段的 sort 决定
字段可以自由组合进模板显示顺序完全由每个字段的 sort 决定论文按模板的段落逐句填写
换模板不会丢内容
</div>
</el-card>
</div>