Files
paper-doc/frontend/src/views/HomeView.vue
T
govin 4d749b3592 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.
2026-09-18 17:29:16 +08:00

241 lines
6.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Welcome page.
*
* Shown before either section is opened, so it does two jobs: greet, and offer
* the two ways in. The counts underneath come from the same endpoints the
* section pages use, which makes this page double as a connectivity check —
* if the numbers are there, the API and TiDB are up.
*/
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)
/** `null` renders as an em dash: unknown, not zero. */
function display(value: number | null): string {
return value === null ? '—' : String(value)
}
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 [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
})
</script>
<template>
<div class="page welcome">
<section class="hero">
<span class="hero-mark"><AppLogo :size="40" /></span>
<h1 class="hero-title">欢迎访问</h1>
<p class="hero-subtitle">先配置好论文模板写作时只需对着结构填内容</p>
</section>
<section class="entries">
<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="success" effect="plain">
{{ display(paperCount) }}
</el-tag>
</button>
<button type="button" class="entry" @click="router.push('/templates')">
<el-icon class="entry-icon"><Files /></el-icon>
<span class="entry-title">模板</span>
<span class="entry-desc">维护模板列表与可复用的段落字段</span>
<el-tag size="small" type="success" effect="plain">
{{ display(templateCount) }} 个模板
</el-tag>
</button>
</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>
<div class="stat-value">{{ display(fieldCount) }}</div>
<div class="stat-label">字段库</div>
</div>
</div>
<el-divider direction="vertical" class="stat-divider" />
<div class="stat">
<el-icon class="stat-icon"><Files /></el-icon>
<div>
<div class="stat-value">{{ display(templateCount) }}</div>
<div class="stat-label">模板</div>
</div>
</div>
<div class="stat-hint">
字段可以自由组合进模板显示顺序完全由每个字段的 sort 决定论文按模板的段落逐句填写
换模板不会丢内容
</div>
</el-card>
</div>
</template>
<style scoped>
.welcome {
gap: 24px;
padding-top: 24px;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.hero-mark {
display: grid;
place-items: center;
width: 76px;
height: 76px;
border-radius: 22px;
color: #fff;
background-image: linear-gradient(135deg, var(--el-color-primary), #7c5cff);
box-shadow: 0 10px 24px rgb(64 158 255 / 28%);
}
.hero-title {
margin: 4px 0 0;
font-size: 30px;
font-weight: 650;
letter-spacing: 0.02em;
}
.hero-subtitle {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.entries {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
max-width: 720px;
width: 100%;
margin: 0 auto;
}
/* A button, not a div: these navigate, so they belong in the tab order and
respond to Enter and Space for free. */
.entry {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 20px;
font: inherit;
text-align: left;
cursor: pointer;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 12px;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.entry:hover,
.entry:focus-visible {
border-color: var(--el-color-primary);
box-shadow: 0 6px 18px rgb(0 0 0 / 8%);
transform: translateY(-2px);
outline: none;
}
.entry-icon {
font-size: 24px;
color: var(--el-color-primary);
}
.entry-title {
font-size: 18px;
font-weight: 600;
}
.entry-desc {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.stats {
max-width: 720px;
width: 100%;
margin: 0 auto;
}
.stats :deep(.el-card__body) {
display: flex;
align-items: center;
gap: 24px;
width: 100%;
flex-wrap: wrap;
}
.stat {
display: flex;
align-items: center;
gap: 12px;
}
.stat-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.stat-value {
font-size: 22px;
font-weight: 650;
line-height: 1.1;
}
.stat-label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.stat-divider {
height: 32px;
}
.stat-hint {
flex: 1 1 220px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.6;
}
</style>