4d749b3592
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.
241 lines
6.0 KiB
Vue
241 lines
6.0 KiB
Vue
<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>
|