frontend: add the template configuration UI

The shell: the app mark at both ends of the header, the two top-level
entries between them, and the second-level menu on the right. That menu is
driven entirely by `route.meta.section`, so a route declares which menu it
belongs to and a deep link renders the right one on first paint; routes with
no section (the welcome page) show none.

Screens:
  /                    welcome, with live template and field counts so the
                       page doubles as a connectivity check
  /papers              论文, content still to be decided
  /templates/list      the template table: search, create, edit, preview,
                       delete, batch delete, pagination
  /templates/fields    the field library: the same CRUD, plus level filter

The template form is the centre of it. Fields are chosen from the flat
library by clicking — any subset, any order, the same field more than once —
and the selection is always rendered sorted by `sort`, so editing a number
reorders the outline immediately. Repeats and duplicate sorts are surfaced as
warnings rather than blocked, because a repeat is often deliberate and a tie
is legal; a "自动排序" button renumbers the selection 1..N. The panel on the
right of the picker opens 字段管理 in a second tab, so a half-filled form is
never lost to a navigation.

The preview drawer renders the outline exactly as it will read, at each
field's own size and colour, with the sort value shown alongside — the
quickest way to confirm the ordering before writing against a template.

Also in this change:

  - ApiError now extends Error. It was a plain object, so the common
    `error instanceof Error ? error.message : ...` idiom fell through to a
    generic message and threw away what the server actually said — such as
    which template name is already taken. Server messages now reach the user.
  - font colours are normalised client-side on blur, matching the backend,
    so a hand-typed rgb(255, 0, 0) is tidied up rather than rejected later.
  - api/health.ts is removed: the rewritten welcome page was its only
    consumer, and the counts already prove connectivity.
This commit is contained in:
2026-09-18 16:00:02 +08:00
parent 13e5fc2cc1
commit 098bfd1bfb
19 changed files with 2575 additions and 137 deletions
+202 -75
View File
@@ -1,98 +1,225 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
/**
* 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 { fetchHealth, type HealthResponse } from '@/api/health'
import { useAppStore } from '@/stores/app'
import { listSectionFields } from '@/api/sectionFields'
import { listTemplates } from '@/api/templates'
import AppLogo from '@/components/AppLogo.vue'
const appStore = useAppStore()
const router = useRouter()
const health = ref<HealthResponse | null>(null)
const loading = ref(false)
const templateCount = ref<number | null>(null)
const fieldCount = ref<number | null>(null)
const databaseTagType = computed(() => (health.value?.database === 'ok' ? 'success' : 'danger'))
async function loadHealth(): Promise<void> {
loading.value = true
try {
health.value = await fetchHealth()
appStore.markChecked()
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
ElMessage.error(message)
health.value = null
} finally {
loading.value = false
}
/** `null` renders as an em dash: unknown, not zero. */
function display(value: number | null): string {
return value === null ? '—' : String(value)
}
onMounted(loadHealth)
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([
listTemplates({ page: 1, page_size: 1 }),
listSectionFields({ page: 1, page_size: 1 }),
])
if (templates.status === 'fulfilled') templateCount.value = templates.value.total
if (fields.status === 'fulfilled') fieldCount.value = fields.value.total
})
</script>
<template>
<div class="page">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>Backend connectivity</span>
<el-button :icon="Refresh" :loading="loading" @click="loadHealth">Re-check</el-button>
<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="info" effect="plain">内容待定</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"><Setting /></el-icon>
<div>
<div class="stat-value">{{ display(fieldCount) }}</div>
<div class="stat-label">字段库</div>
</div>
</template>
<el-descriptions v-if="health" :column="1" border>
<el-descriptions-item label="API">
{{ health.app }}
<el-tag type="success" size="small">{{ health.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="TiDB">
<el-tag :type="databaseTagType" size="small">{{ health.database }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Target">
<code>{{ health.database_target }}</code>
</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="No response from the API yet" />
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>Persisted state</span>
<el-switch
v-model="appStore.sidebarCollapsed"
active-text="Sidebar collapsed"
inline-prompt
/>
</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>
</template>
<p>
Last probe:
<strong>{{ appStore.lastCheckedAt ?? 'never' }}</strong>
</p>
<p class="hint">
Both values are stored in <code>localStorage</code> under
<code>paper-doc:app</code>. Reload the page they survive.
</p>
</div>
<div class="stat-hint">
字段可以自由组合进模板显示顺序完全由每个字段的 sort 决定
</div>
</el-card>
</div>
</template>
<!--
The outer .page class (stacking + gap) comes from src/styles/base.css, so
every view shares one vertical rhythm inside the main gutter.
-->
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
.welcome {
gap: 24px;
padding-top: 24px;
}
.hint {
.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;
margin: 0;
}
.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>