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
+181 -29
View File
@@ -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>
+9
View File
@@ -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',
+55
View File
@@ -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 }
})
+35
View File
@@ -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*)?\)$/
+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>
+387 -24
View File
@@ -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>