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