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
+101 -17
View File
@@ -1,44 +1,128 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { useAppStore } from '@/stores/app'
/**
* Application shell.
*
* Three bands, in this order: a header holding the mark, the two top-level
* sections, and the mark again at the far right; then the content area; then
* the second-level menu on the right, present only on routes that declare a
* `section` in their meta.
*/
import { computed } from 'vue'
import { RouterView, RouterLink, useRoute } from 'vue-router'
const appStore = useAppStore()
import AppLogo from '@/components/AppLogo.vue'
import SectionAside from '@/components/SectionAside.vue'
const route = useRoute()
/** The two top-level sections. `index` is the path `el-menu` routes to. */
const TOP_MENU = [
{ index: '/papers', label: '论文' },
{ index: '/templates', label: '模板' },
] as const
/**
* Which top-level entry the current URL belongs to.
*
* Derived from the path rather than tracked in state, so browser back/forward
* and a pasted deep link both leave the header highlighting the right entry.
* `/templates/list` and `/templates/fields` both resolve to `/templates`.
*/
const activeTop = computed(() => {
const match = TOP_MENU.find(
(item) => route.path === item.index || route.path.startsWith(`${item.index}/`),
)
return match?.index ?? ''
})
const section = computed(() => (route.meta.section as string | undefined) ?? '')
</script>
<template>
<el-container class="app-shell">
<el-header class="app-header">
<div class="brand">paper-doc</div>
<el-menu mode="horizontal" :ellipsis="false" router class="app-nav">
<el-menu-item index="/">Home</el-menu-item>
<el-header class="app-header" height="60px">
<RouterLink to="/" class="brand">
<span class="brand-mark"><AppLogo :size="20" /></span>
<span class="brand-text">paper-doc</span>
</RouterLink>
<el-menu
mode="horizontal"
:ellipsis="false"
router
:default-active="activeTop"
class="app-nav"
>
<el-menu-item v-for="item in TOP_MENU" :key="item.index" :index="item.index">
{{ item.label }}
</el-menu-item>
</el-menu>
<el-button link @click="appStore.toggleSidebar()">
{{ appStore.sidebarCollapsed ? 'Expand' : 'Collapse' }}
</el-button>
<!-- The mark repeated at the menu's right end, as a quiet signature. -->
<span class="brand-mark brand-mark--ghost" aria-hidden="true">
<AppLogo :size="16" />
</span>
</el-header>
<el-main class="app-main">
<RouterView />
</el-main>
<el-container class="app-body">
<el-main class="app-main">
<RouterView />
</el-main>
<SectionAside v-if="section" :section="section" />
</el-container>
</el-container>
</template>
<!--
Sizing and scrolling belong to the global shell contract in
src/styles/base.css (.app-shell, .app-header, .app-main). Only the chrome
drawn inside the header is styled here.
src/styles/base.css (.app-shell, .app-header, .app-body, .app-main,
.app-aside). Only the chrome drawn inside the header is styled here.
-->
<style scoped>
.app-header {
display: flex;
align-items: center;
gap: 24px;
gap: 20px;
border-bottom: 1px solid var(--el-border-color);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
/* The brand links home; strip the anchor defaults. */
color: inherit;
text-decoration: none;
flex: 0 0 auto;
}
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 10px;
color: #fff;
background-image: linear-gradient(135deg, var(--el-color-primary), #7c5cff);
box-shadow: 0 2px 6px rgb(64 158 255 / 30%);
}
/* The right-hand echo: no badge, no weight, just the shape. */
.brand-mark--ghost {
width: auto;
height: auto;
border-radius: 0;
background-image: none;
box-shadow: none;
color: var(--el-text-color-placeholder);
opacity: 0.7;
}
.brand-text {
font-weight: 600;
font-size: 18px;
font-size: 17px;
letter-spacing: -0.01em;
}
.app-nav {
+44 -10
View File
@@ -15,29 +15,55 @@ export const http: AxiosInstance = axios.create({
},
})
/** Normalized failure shape, so views never have to inspect AxiosError. */
export interface ApiError {
message: string
status?: number
detail?: unknown
/**
* Normalized failure, so views never have to inspect `AxiosError`.
*
* It extends `Error` rather than being a plain object: call sites do
* `error instanceof Error ? error.message : ...` all over the place, and a
* bare object silently fell through to a generic message — which threw away
* the one thing the backend said, e.g. which template name is already taken.
*/
export class ApiError extends Error {
readonly status?: number
readonly detail?: unknown
constructor(message: string, status?: number, detail?: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.detail = detail
}
}
function normalizeError(error: AxiosError): ApiError {
const status = error.response?.status
const payload = error.response?.data as { detail?: unknown } | undefined
// FastAPI reports a rejected body as a list of per-field errors rather than
// a string. Surface those verbatim; they name the offending field.
const detail = payload?.detail
const isFieldErrors = Array.isArray(detail)
let message: string
if (payload?.detail && typeof payload.detail === 'string') {
message = payload.detail
if (typeof detail === 'string') {
message = detail
} else if (isFieldErrors) {
message = detail
.map((item) => {
const entry = item as { loc?: unknown[]; msg?: string }
const field = Array.isArray(entry.loc) ? entry.loc.slice(1).join('.') : ''
return field ? `${field}: ${entry.msg ?? ''}` : (entry.msg ?? '')
})
.join('')
} else if (error.code === 'ECONNABORTED') {
message = 'The request timed out.'
message = '请求超时,请稍后重试。'
} else if (!error.response) {
message = 'Could not reach the API. Is the backend running on port 8000?'
message = '无法连接后端服务,请确认后端已在 8000 端口运行。'
} else {
message = error.message
}
return { message, status, detail: payload?.detail }
return new ApiError(message, status, detail)
}
http.interceptors.response.use(
@@ -45,4 +71,12 @@ http.interceptors.response.use(
(error: AxiosError) => Promise.reject(normalizeError(error)),
)
/** Human-readable text for a value thrown by the API layer. */
export function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
}
export default http
-15
View File
@@ -1,15 +0,0 @@
import http from './client'
/** Mirrors `app.schemas.health.HealthResponse` on the backend. */
export interface HealthResponse {
status: string
app: string
database: string
database_target: string
}
/** Probe the backend, including its TiDB connection. */
export async function fetchHealth(): Promise<HealthResponse> {
const { data } = await http.get<HealthResponse>('/health')
return data
}
+99
View File
@@ -0,0 +1,99 @@
import http from './client'
import type { BatchDeleteResult, PageQuery, PageResult } from './types'
/** Mirrors `app.schemas.section_field.SectionFieldRead`. */
export interface SectionField {
id: number
/** Display name, numbering included — e.g. `1. Introduction`. */
name: string
/** Heading depth: 1 for `1.`, 2 for `1.1`. A rendering hint, not a parent. */
level: number
/** Points. Fractional, because 五号 = 10.5pt. */
font_size: number
/** Canonical RGB as `#RRGGBB`. */
font_color: string
created_at: string
updated_at: string
}
/** Body for creating or updating a library field. */
export interface SectionFieldPayload {
name: string
level: number
font_size: number
font_color: string
}
export interface SectionFieldQuery extends PageQuery {
level?: number | null
}
/** The API caps `page_size` at 500; 200 keeps responses modest while looping. */
const LIBRARY_PAGE_SIZE = 200
export async function listSectionFields(
query: SectionFieldQuery = {},
): Promise<PageResult<SectionField>> {
// Drop empty filters so the URL stays clean and the backend sees "no filter"
// rather than `keyword=`.
const params: Record<string, unknown> = {}
if (query.keyword) params.keyword = query.keyword
if (query.level != null) params.level = query.level
if (query.page) params.page = query.page
if (query.page_size) params.page_size = query.page_size
const { data } = await http.get<PageResult<SectionField>>('/section-fields', { params })
return data
}
/**
* Fetch the entire library, following pagination.
*
* The template form needs every field, not one page, so that a field placed
* deep in the library can still be shown by name in an existing selection.
*/
export async function fetchAllSectionFields(keyword?: string): Promise<SectionField[]> {
const all: SectionField[] = []
let page = 1
for (;;) {
const result = await listSectionFields({
keyword,
page,
page_size: LIBRARY_PAGE_SIZE,
})
all.push(...result.items)
if (page >= result.pages || result.items.length === 0) {
return all
}
page += 1
}
}
export async function createSectionField(
payload: SectionFieldPayload,
): Promise<SectionField> {
const { data } = await http.post<SectionField>('/section-fields', payload)
return data
}
export async function updateSectionField(
id: number,
payload: Partial<SectionFieldPayload>,
): Promise<SectionField> {
const { data } = await http.patch<SectionField>(`/section-fields/${id}`, payload)
return data
}
export async function deleteSectionField(id: number): Promise<void> {
await http.delete(`/section-fields/${id}`)
}
export async function batchDeleteSectionFields(
ids: number[],
): Promise<BatchDeleteResult> {
const { data } = await http.post<BatchDeleteResult>('/section-fields/batch-delete', {
ids,
})
return data
}
+85
View File
@@ -0,0 +1,85 @@
import http from './client'
import type { BatchDeleteResult, PageQuery, PageResult } from './types'
/** One placement of a library field inside a template, with its typography. */
export interface TemplateField {
id: number
field_id: number
/** Display position. The only thing that decides render order. */
sort: number
name: string
level: number
font_size: number
font_color: string
}
/** A template as it appears in the list table. */
export interface TemplateListItem {
id: number
name: string
abstract: string | null
field_count: number
created_at: string
updated_at: string
}
/** A template with its outline, already ordered by `sort`. */
export interface TemplateDetail {
id: number
name: string
abstract: string | null
fields: TemplateField[]
created_at: string
updated_at: string
}
/** One entry of the selection sent on create/update. */
export interface TemplateFieldInput {
field_id: number
sort: number
}
export interface TemplatePayload {
name: string
abstract: string | null
fields: TemplateFieldInput[]
}
export async function listTemplates(
query: PageQuery = {},
): Promise<PageResult<TemplateListItem>> {
const params: Record<string, unknown> = {}
if (query.keyword) params.keyword = query.keyword
if (query.page) params.page = query.page
if (query.page_size) params.page_size = query.page_size
const { data } = await http.get<PageResult<TemplateListItem>>('/templates', { params })
return data
}
export async function getTemplate(id: number): Promise<TemplateDetail> {
const { data } = await http.get<TemplateDetail>(`/templates/${id}`)
return data
}
export async function createTemplate(payload: TemplatePayload): Promise<TemplateDetail> {
const { data } = await http.post<TemplateDetail>('/templates', payload)
return data
}
export async function updateTemplate(
id: number,
payload: Partial<TemplatePayload>,
): Promise<TemplateDetail> {
const { data } = await http.patch<TemplateDetail>(`/templates/${id}`, payload)
return data
}
export async function deleteTemplate(id: number): Promise<void> {
await http.delete(`/templates/${id}`)
}
export async function batchDeleteTemplates(ids: number[]): Promise<BatchDeleteResult> {
const { data } = await http.post<BatchDeleteResult>('/templates/batch-delete', { ids })
return data
}
+22
View File
@@ -0,0 +1,22 @@
/** Payload shapes shared by more than one endpoint. */
/** One page of a list endpoint, mirroring `app.schemas.common.PageResult`. */
export interface PageResult<T> {
items: T[]
total: number
page: number
page_size: number
pages: number
}
/** Result of a batch-delete endpoint. */
export interface BatchDeleteResult {
deleted: number
}
/** Query parameters every paged list endpoint accepts. */
export interface PageQuery {
keyword?: string
page?: number
page_size?: number
}
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* The app mark: an open book with text lines on both pages.
*
* Drawn with `stroke="currentColor"` and no fill, so the same component works
* as the coloured badge in the header and as the muted watermark at its right
* end — the surrounding CSS decides the colour and nothing is hard-coded here.
*/
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
class="app-logo"
role="img"
aria-label="paper-doc"
>
<!-- Two facing pages, meeting at the spine. -->
<path
d="M12 7.2C10.3 6 8.1 5.4 5.4 5.5A1.3 1.3 0 0 0 4.1 6.8v10.5c0 .8.6 1.4 1.4 1.3 2.4-.1 4.4.4 5.9 1.4.4.2.8.2 1.2 0 1.5-1 3.5-1.5 5.9-1.4.8.1 1.4-.5 1.4-1.3V6.8c0-.7-.6-1.3-1.3-1.3-2.7-.1-4.9.5-6.6 1.7Z"
/>
<path d="M12 7.2V20" />
<!-- Body text on the left page. -->
<path d="M6.9 9.6h2.5M6.9 12.4h3.3" />
<!-- And on the right. -->
<path d="M14.6 9.6h2.5M13.8 12.4h3.3" />
</svg>
</template>
<style scoped>
.app-logo {
display: block;
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
/**
* The second-level menu, pinned to the right 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).
*/
import { computed, type Component } from 'vue'
import { useRoute } from 'vue-router'
import { Document, Expand, Files, Fold, Setting } from '@element-plus/icons-vue'
import { useAppStore } from '@/stores/app'
interface AsideItem {
index: string
label: string
icon: Component
hint?: string
}
interface AsideMenu {
title: string
items: AsideItem[]
}
const props = defineProps<{ section: string }>()
const route = useRoute()
const appStore = useAppStore()
const MENUS: Record<string, AsideMenu> = {
papers: {
title: '论文',
items: [{ index: '/papers', label: '我的论文', icon: Document, hint: '待定' }],
},
templates: {
title: '模板配置',
items: [
{ index: '/templates/list', label: '模板列表', icon: Files },
{ index: '/templates/fields', label: '字段管理', icon: Setting },
],
},
}
const menu = computed<AsideMenu | null>(() => MENUS[props.section] ?? null)
const collapsed = computed(() => appStore.asideCollapsed)
</script>
<template>
<el-aside
v-if="menu"
class="app-aside"
:width="collapsed ? '56px' : '208px'"
>
<div class="aside-head" :class="{ 'is-collapsed': collapsed }">
<span v-if="!collapsed" class="aside-title">{{ menu.title }}</span>
<el-tooltip
:content="collapsed ? '展开菜单' : '收起菜单'"
placement="left"
:show-after="200"
>
<el-button
link
:icon="collapsed ? Expand : Fold"
class="aside-toggle"
@click="appStore.toggleAside()"
/>
</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>
</el-aside>
</template>
<style scoped>
.app-aside {
display: flex;
flex-direction: column;
/* The wrapper owns the border so it stays put while the menu scrolls. */
border-left: 1px solid var(--el-border-color);
background-color: var(--el-bg-color);
overflow: hidden;
}
.aside-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 48px;
padding: 0 8px 0 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
flex: 0 0 auto;
}
.aside-head.is-collapsed {
justify-content: center;
padding: 0;
}
.aside-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
letter-spacing: 0.02em;
white-space: nowrap;
}
.aside-toggle {
color: var(--el-text-color-secondary);
}
.aside-menu {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
border-right: none;
}
/* 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 {
width: 100%;
}
.aside-label {
margin-right: 6px;
}
</style>
@@ -0,0 +1,242 @@
<script setup lang="ts">
/**
* Create / edit dialog for one library field.
*
* The live preview underneath the form is the point of the dialog: font size
* and colour are the two settings whose effect is impossible to judge from the
* value alone, so the preview renders the name exactly as the outline will.
*/
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { errorMessage } from '@/api/client'
import {
createSectionField,
updateSectionField,
type SectionField,
type SectionFieldPayload,
} from '@/api/sectionFields'
import { normalizeHexColor, typographyStyle } from '@/utils/format'
const props = defineProps<{
/** Visibility, used with `v-model`. */
modelValue: boolean
/** The field being edited, or `null` to create a new one. */
field: SectionField | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Emitted after a successful write, so the list can refresh. */
saved: []
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
const formRef = ref<FormInstance>()
const saving = ref(false)
const form = reactive<SectionFieldPayload>({
name: '',
level: 1,
// 小四, the conventional body size for a Chinese thesis.
font_size: 12,
font_color: '#000000',
})
const rules: FormRules<SectionFieldPayload> = {
name: [
{ required: true, message: '请填写字段名称', trigger: 'blur' },
{ max: 255, message: '字段名称最多 255 个字符', trigger: 'blur' },
],
}
const LEVEL_OPTIONS = [
{ value: 1, label: '1 级 · 如 1. / 2.' },
{ value: 2, label: '2 级 · 如 1.1 / 1.2' },
{ value: 3, label: '3 级 · 如 1.1.1' },
{ value: 4, label: '4 级' },
]
/** Swatches offered by the picker — the greys match the seeded field colours. */
const PRESET_COLORS = [
'#000000',
'#1F1F1F',
'#404040',
'#595959',
'#8C8C8C',
'#409EFF',
'#67C23A',
'#E6A23C',
'#F56C6C',
]
const previewStyle = computed(() => typographyStyle(form))
/** The title states which mode the dialog is in, so it is never ambiguous. */
const title = computed(() => (props.field ? '编辑字段' : '新增字段'))
// Re-seed the form each time the dialog opens, so a cancelled edit never leaks
// into the next one.
watch(
() => props.modelValue,
(open) => {
if (!open) return
formRef.value?.clearValidate()
const source = props.field
form.name = source?.name ?? ''
form.level = source?.level ?? 1
form.font_size = source?.font_size ?? 12
form.font_color = source?.font_color ?? '#000000'
},
)
/**
* Tidy a hand-typed colour when the input loses focus.
*
* The picker only ever produces `#RRGGBB`, but the text box next to it accepts
* `rgb(...)` and shorthand too, matching what the API takes.
*/
function commitColor(): void {
const normalized = normalizeHexColor(form.font_color)
if (normalized) {
form.font_color = normalized
return
}
ElMessage.warning('颜色格式无效,请使用 #RRGGBB 或 rgb(r,g,b)')
form.font_color = props.field?.font_color ?? '#000000'
}
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (props.field) {
await updateSectionField(props.field.id, { ...form })
ElMessage.success('字段已更新,使用它的模板会同步生效')
} else {
await createSectionField({ ...form })
ElMessage.success('字段已创建')
}
emit('saved')
visible.value = false
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
saving.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" :title="title" width="520px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
<el-form-item label="字段名称" prop="name">
<el-input
v-model="form.name"
placeholder="如 1. Introduction,编号自己写,系统不会改"
maxlength="255"
show-word-limit
clearable
/>
</el-form-item>
<el-form-item label="字段等级">
<el-select v-model="form.level" class="full">
<el-option
v-for="option in LEVEL_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="字体大小">
<el-input-number
v-model="form.font_size"
:min="6"
:max="48"
:step="0.5"
:precision="1"
controls-position="right"
/>
<span class="unit">pt</span>
</el-form-item>
<el-form-item label="字体颜色">
<div class="color-row">
<el-color-picker v-model="form.font_color" :predefine="PRESET_COLORS" />
<el-input
v-model="form.font_color"
class="color-input"
placeholder="#RRGGBB 或 rgb(r,g,b)"
@blur="commitColor"
/>
</div>
</el-form-item>
<el-form-item label="预览">
<div class="preview">
<span :style="previewStyle">{{ form.name || '(未命名字段)' }}</span>
<span class="preview-meta">
{{ form.level }} · {{ form.font_size }}pt · {{ form.font_color }}
</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.full {
width: 100%;
}
.unit {
margin-left: 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.color-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.color-input {
width: 180px;
}
.preview {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 10px 12px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
background-color: var(--el-fill-color-lighter);
/* Long names wrap rather than widening the dialog. */
min-width: 0;
overflow-wrap: anywhere;
}
.preview-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,599 @@
<script setup lang="ts">
/**
* Create / edit dialog for a paper template.
*
* The picker is the heart of the feature, and it is deliberately *not* a tree.
* Fields come from one flat library and are chosen freely: any subset, any
* order, and the same field more than once. What the reader finally sees is
* decided by `sort` alone, so the right-hand list is always rendered sorted by
* `sort` — reordering the numbers reorders the outline immediately, and the
* order the user clicked in is never stored.
*
* Repeats and ties are surfaced rather than blocked: a repeat is often
* intentional (a level-2 "Background" under two different level-1 headings),
* and a tie is legal because the backend breaks it by insertion order — but
* both are worth a warning, since neither is obvious from the numbers alone.
*/
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
ArrowDown,
ArrowUp,
Delete,
Plus,
Refresh,
Search,
Setting,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { errorMessage } from '@/api/client'
import { fetchAllSectionFields, type SectionField } from '@/api/sectionFields'
import {
createTemplate,
getTemplate,
updateTemplate,
type TemplateFieldInput,
type TemplateListItem,
} from '@/api/templates'
import { levelIndent, typographyStyle } from '@/utils/format'
const props = defineProps<{
/** Visibility, used with `v-model`. */
modelValue: boolean
/** The template being edited, or `null` to create a new one. */
template: TemplateListItem | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Emitted after a successful write, so the list can refresh. */
saved: []
}>()
/** One chosen field, at one position. The same field may appear many times. */
interface Placement {
/** Local identity for `v-for` / `row-key`; not sent to the server. */
key: number
field_id: number
sort: number
}
const router = useRouter()
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 library = ref<SectionField[]>([])
const libraryKeyword = ref('')
const selected = ref<Placement[]>([])
const form = reactive({ name: '', abstract: '' })
const rules: FormRules = { name: [{ required: true, message: '请填写模板名称', trigger: 'blur' }] }
/** Keys only ever count up, so a reopened dialog cannot collide with itself. */
let nextKey = 1
const fieldMap = computed(() => new Map(library.value.map((field) => [field.id, field])))
const filteredLibrary = computed(() => {
const keyword = libraryKeyword.value.trim().toLowerCase()
if (!keyword) return library.value
return library.value.filter((field) => field.name.toLowerCase().includes(keyword))
})
/** The selection in display order — ascending `sort`, ties by insertion. */
const rows = computed(() =>
[...selected.value]
.sort((a, b) => a.sort - b.sort || a.key - b.key)
.map((placement) => ({
// Hoisted out of `placement` so el-table's `row-key` can be the plain
// property name `key` rather than a function expression.
key: placement.key,
placement,
field: fieldMap.value.get(placement.field_id),
})),
)
/** How many times each library field is placed. */
const repeatCounts = computed(() => {
const counts = new Map<number, number>()
for (const placement of selected.value) {
counts.set(placement.field_id, (counts.get(placement.field_id) ?? 0) + 1)
}
return counts
})
const repeatedNames = computed(() =>
[...repeatCounts.value.entries()]
.filter(([, count]) => count > 1)
.map(([id, count]) => `${fieldMap.value.get(id)?.name ?? `#${id}`} ×${count}`),
)
const duplicatedSorts = computed(() => {
const counts = new Map<number, number>()
for (const placement of selected.value) {
counts.set(placement.sort, (counts.get(placement.sort) ?? 0) + 1)
}
return [...counts.entries()]
.filter(([, count]) => count > 1)
.map(([sort]) => sort)
.sort((a, b) => a - b)
})
const title = computed(() => (props.template ? '编辑模板' : '新建模板'))
watch(
() => props.modelValue,
async (open) => {
if (!open) return
formRef.value?.clearValidate()
selected.value = []
libraryKeyword.value = ''
form.name = props.template?.name ?? ''
form.abstract = props.template?.abstract ?? ''
loading.value = true
try {
// The whole library, so a field that was placed before the keyword
// filter existed still resolves to a name in the selection.
library.value = await fetchAllSectionFields()
if (props.template) {
const detail = await getTemplate(props.template.id)
form.name = detail.name
form.abstract = detail.abstract ?? ''
selected.value = detail.fields.map((field) => ({
key: nextKey++,
field_id: field.field_id,
sort: field.sort,
}))
}
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
loading.value = false
}
},
)
/** Append a placement, at the end of the current order. */
function addField(field: SectionField): void {
const highest = selected.value.reduce((max, item) => Math.max(max, item.sort), 0)
selected.value.push({ key: nextKey++, field_id: field.id, sort: highest + 1 })
}
function removePlacement(key: number): void {
selected.value = selected.value.filter((item) => item.key !== key)
}
/**
* Move a row one step, by swapping `sort` values 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 = rows.value.map((row) => row.placement)
const target = index + delta
if (target < 0 || target >= ordered.length) return
const moving = ordered[index]!
const other = ordered[target]!
const movingSort = moving.sort
const otherSort = other.sort
if (movingSort === otherSort) {
moving.sort = delta < 0 ? otherSort - 1 : otherSort + 1
return
}
moving.sort = otherSort
other.sort = movingSort
}
/** Renumber to 1..N in the current display order. */
function autoSort(): void {
rows.value.forEach((row, index) => {
row.placement.sort = index + 1
})
}
/**
* Open the field library in a second tab.
*
* Deliberately not an in-place navigation: a half-filled template form would
* be lost, and the usual reason to go there is "the field I need does not
* exist yet".
*/
function openFieldManager(): void {
const { href } = router.resolve('/templates/fields')
window.open(href, '_blank', 'noopener')
}
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (selected.value.length === 0) {
try {
await ElMessageBox.confirm('还没有选择任何字段,保存后模板会是空的。确定继续?', '提示', {
type: 'warning',
confirmButtonText: '继续保存',
cancelButtonText: '返回选择',
})
} catch {
return // dismissed
}
}
saving.value = true
try {
// Sent in display order, so that equal `sort` values get placement ids in
// the same order the user sees and the backend's id tie-break agrees with
// the screen.
const fields: TemplateFieldInput[] = rows.value.map((row) => ({
field_id: row.placement.field_id,
sort: row.placement.sort,
}))
const payload = {
name: form.name.trim(),
abstract: form.abstract.trim() || null,
fields,
}
if (props.template) {
await updateTemplate(props.template.id, payload)
ElMessage.success('模板已更新')
} else {
await createTemplate(payload)
ElMessage.success('模板已创建')
}
emit('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(980px, 94vw)"
top="5vh"
:close-on-click-modal="false"
append-to-body
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
<el-form-item label="模板名称" prop="name">
<el-input v-model="form.name" placeholder="如 标准学术论文(通用)" maxlength="255" clearable />
</el-form-item>
<el-form-item label="摘要">
<el-input
v-model="form.abstract"
type="textarea"
:rows="2"
maxlength="1000"
show-word-limit
placeholder="说明这个模板适合什么类型的论文"
/>
</el-form-item>
</el-form>
<el-divider content-position="left">
段落字段
<span class="divider-hint">点左侧字段加入显示顺序只看 sort</span>
</el-divider>
<div v-loading="loading" class="picker">
<!-- Library -->
<section class="picker-col">
<header class="picker-head">
<span class="picker-title">可选字段</span>
<el-tooltip content="在新标签页打开字段管理,不丢失当前填写" placement="top">
<el-button link type="primary" :icon="Setting" @click="openFieldManager">
字段管理
</el-button>
</el-tooltip>
</header>
<el-input
v-model="libraryKeyword"
size="small"
placeholder="搜索字段名称"
clearable
class="picker-search"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="picker-list">
<button
v-for="field in filteredLibrary"
:key="field.id"
type="button"
class="lib-item"
@click="addField(field)"
>
<span class="lib-name" :style="{ paddingLeft: levelIndent(field.level), ...typographyStyle(field) }">
{{ field.name }}
</span>
<span class="lib-side">
<el-tag v-if="repeatCounts.get(field.id)" size="small" type="warning" effect="plain">
×{{ repeatCounts.get(field.id) }}
</el-tag>
<el-icon class="lib-add"><Plus /></el-icon>
</span>
</button>
<el-empty
v-if="filteredLibrary.length === 0"
:image-size="56"
:description="libraryKeyword ? '没有匹配的字段' : '字段库还是空的'"
>
<el-button link type="primary" @click="openFieldManager">去字段管理新建</el-button>
</el-empty>
</div>
</section>
<!-- Selection -->
<section class="picker-col picker-col--wide">
<header class="picker-head">
<span class="picker-title">
已选段落
<el-tag size="small" effect="plain" round>{{ selected.length }}</el-tag>
</span>
<el-tooltip content="按当前显示顺序重新编号为 1、2、3…" placement="top">
<el-button link :icon="Refresh" :disabled="selected.length === 0" @click="autoSort">
自动排序
</el-button>
</el-tooltip>
</header>
<div class="alerts">
<el-alert
v-if="repeatedNames.length"
type="warning"
:closable="false"
show-icon
:title="`同一字段被选了多次:${repeatedNames.join('、')}`"
description="允许重复,两处会各自按 sort 出现在大纲里。"
/>
<el-alert
v-if="duplicatedSorts.length"
type="warning"
:closable="false"
show-icon
:title="`存在相同的 sort${duplicatedSorts.join('、')}`"
description="sort 相同时按加入先后显示,建议点「自动排序」错开。"
/>
</div>
<el-table :data="rows" row-key="key" size="small" class="select-table">
<el-table-column label="sort" width="104">
<template #default="{ row }">
<el-input-number
v-model="row.placement.sort"
size="small"
controls-position="right"
:min="-9999"
:max="9999"
class="sort-input"
/>
</template>
</el-table-column>
<el-table-column label="字段(按 sort 显示)" min-width="220">
<template #default="{ row }">
<span
v-if="row.field"
class="select-name"
:style="{ paddingLeft: levelIndent(row.field.level), ...typographyStyle(row.field) }"
>
{{ row.field.name }}
</span>
<span v-else class="missing">字段 #{{ row.placement.field_id }} 已不存在</span>
</template>
</el-table-column>
<el-table-column label="操作" width="126" align="center">
<template #default="{ row, $index }">
<el-button
link
:icon="ArrowUp"
:disabled="$index === 0"
title="上移"
@click="swapOrder($index, -1)"
/>
<el-button
link
:icon="ArrowDown"
:disabled="$index === rows.length - 1"
title="下移"
@click="swapOrder($index, 1)"
/>
<el-button
link
type="danger"
:icon="Delete"
title="移除"
@click="removePlacement(row.key)"
/>
</template>
</el-table-column>
<template #empty>
<span class="empty-hint">还没有选择字段点左边的字段加入</span>
</template>
</el-table>
</section>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">
{{ template ? '保存修改' : '创建模板' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.divider-hint {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* Two columns that shrink to one when the dialog gets narrow. */
.picker {
display: flex;
gap: 16px;
align-items: stretch;
min-height: 340px;
}
.picker-col {
display: flex;
flex-direction: column;
gap: 8px;
flex: 0 0 300px;
min-width: 0;
padding: 12px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background-color: var(--el-fill-color-blank);
}
.picker-col--wide {
flex: 1 1 auto;
}
@media (max-width: 820px) {
.picker {
flex-direction: column;
}
.picker-col {
flex: 1 1 auto;
}
}
.picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 24px;
}
.picker-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
display: inline-flex;
align-items: center;
gap: 6px;
}
.picker-list {
flex: 1 1 auto;
min-height: 0;
max-height: 380px;
overflow-y: auto;
scrollbar-width: thin;
}
/* A button per field: clickable, focusable and keyboard-operable for free. */
.lib-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 7px 8px;
font: inherit;
text-align: left;
cursor: pointer;
background: none;
border: 1px solid transparent;
border-radius: 6px;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.lib-item:hover,
.lib-item:focus-visible {
background-color: var(--el-fill-color-light);
border-color: var(--el-border-color);
outline: none;
}
.lib-name {
min-width: 0;
flex: 1 1 auto;
overflow-wrap: anywhere;
line-height: 1.5;
}
.lib-side {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.lib-add {
color: var(--el-color-primary);
opacity: 0;
transition: opacity 0.15s ease;
}
.lib-item:hover .lib-add,
.lib-item:focus-visible .lib-add {
opacity: 1;
}
.alerts {
display: flex;
flex-direction: column;
gap: 6px;
}
.select-table {
flex: 1 1 auto;
min-height: 0;
}
.select-name {
display: inline-block;
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.5;
}
.missing {
color: var(--el-color-danger);
font-size: 12px;
}
.empty-hint {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.sort-input {
width: 88px;
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
/**
* Read-only rendering of a template's outline.
*
* This is where the ordering contract becomes visible: the rows are painted in
* the order the API returned them, which is ascending `sort`, and each name is
* drawn at its own font size and colour. The `sort` value is shown alongside so
* the order can be checked against the numbers rather than taken on trust.
*/
import type { TemplateField } from '@/api/templates'
import { levelIndent, typographyStyle } from '@/utils/format'
withDefaults(
defineProps<{
fields: TemplateField[]
/** Show the sort value and typography under each heading. */
showMeta?: boolean
}>(),
{ showMeta: true },
)
</script>
<template>
<div class="outline">
<el-empty v-if="fields.length === 0" description="这个模板还没有选择任何字段" />
<ol v-else class="outline-list">
<li v-for="field in fields" :key="field.id" class="outline-row">
<span class="outline-sort">{{ field.sort }}</span>
<div class="outline-body">
<div class="outline-name" :style="{ paddingLeft: levelIndent(field.level) }">
<span :style="typographyStyle(field)">{{ field.name }}</span>
</div>
<div v-if="showMeta" class="outline-meta">
{{ field.level }} · {{ field.font_size }}pt · {{ field.font_color }}
</div>
</div>
</li>
</ol>
</div>
</template>
<style scoped>
.outline {
min-width: 0;
}
/* The list is styled as a bare stack: the visual numbering is already inside
each field's name, so a browser-generated marker would double it. */
.outline-list {
list-style: none;
margin: 0;
padding: 0;
}
.outline-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.outline-row:last-child {
border-bottom: none;
}
.outline-sort {
flex: 0 0 auto;
min-width: 32px;
padding: 1px 6px;
font-size: 12px;
font-variant-numeric: tabular-nums;
text-align: center;
color: var(--el-text-color-secondary);
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
.outline-body {
min-width: 0;
flex: 1 1 auto;
}
.outline-name {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.5;
}
.outline-meta {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
</style>
+37 -2
View File
@@ -1,5 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router'
/**
* Route table.
*
* `meta.section` groups a route under one of the two top-level entries in the
* header. The shell reads it to decide whether — and which — second-level menu
* to render on the right, so a new page only has to declare where it belongs.
*/
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
@@ -7,15 +14,43 @@ const router = createRouter({
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
meta: { title: 'Home' },
meta: { title: '首页' },
},
{
path: '/papers',
name: 'papers',
component: () => import('@/views/PapersView.vue'),
meta: { title: '论文', section: 'papers' },
},
{
// The header links to the section, not to a page inside it.
path: '/templates',
redirect: '/templates/list',
},
{
path: '/templates/list',
name: 'template-list',
component: () => import('@/views/templates/TemplateListView.vue'),
meta: { title: '模板列表', section: 'templates' },
},
{
path: '/templates/fields',
name: 'field-manage',
component: () => import('@/views/templates/FieldManageView.vue'),
meta: { title: '字段管理', section: 'templates' },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
meta: { title: 'Not found' },
meta: { title: '页面不存在' },
},
],
})
router.afterEach((to) => {
const title = to.meta.title as string | undefined
document.title = title ? `${title} · paper-doc` : 'paper-doc'
})
export default router
+7 -11
View File
@@ -6,28 +6,24 @@ import { ref } from 'vue'
*
* Persistence is opt-in per store via the `persist` option; the plugin itself
* is registered in `main.ts`. `pick` limits what is written to storage, so
* transient server data (like the last health probe) is never persisted.
* transient server data is never persisted.
*/
export const useAppStore = defineStore(
'app',
() => {
const sidebarCollapsed = ref(false)
const lastCheckedAt = ref<string | null>(null)
/** Whether the second-level menu on the right is collapsed to its rail. */
const asideCollapsed = ref(false)
function toggleSidebar(): void {
sidebarCollapsed.value = !sidebarCollapsed.value
function toggleAside(): void {
asideCollapsed.value = !asideCollapsed.value
}
function markChecked(): void {
lastCheckedAt.value = new Date().toISOString()
}
return { sidebarCollapsed, lastCheckedAt, toggleSidebar, markChecked }
return { asideCollapsed, toggleAside }
},
{
persist: {
key: 'paper-doc:app',
pick: ['sidebarCollapsed', 'lastCheckedAt'],
pick: ['asideCollapsed'],
},
},
)
+63 -7
View File
@@ -78,9 +78,17 @@ samp {
}
/* --- application shell ------------------------------------------------------
.app-shell and .app-main are applied by src/App.vue. They live here because
the viewport contract spans html -> body -> #app -> shell -> main and only
holds together when it is declared in one place.
.app-shell, .app-body, .app-main and .app-aside are applied by src/App.vue
and src/components/SectionAside.vue. They live here because the viewport
contract spans html -> body -> #app -> shell -> body -> main and only holds
together when it is declared in one place.
The shape is:
.app-shell column: header over .app-body
.app-body row: .app-main over .app-aside (if present)
.app-main the single scroll container
.app-aside fixed-width rail, scrolls independently
--------------------------------------------------------------------------- */
.app-shell {
@@ -98,10 +106,21 @@ samp {
flex: 0 0 auto;
}
/* The row holding the content and the right-hand menu. */
.app-body {
flex: 1 1 auto;
min-height: 0;
/* el-container is a row by default and stays one because it holds an
el-aside; stating it keeps the layout readable. */
flex-direction: row;
}
.app-main {
/* min-height: 0 lets this flex child actually shrink, so overflow is
contained here instead of escaping to the window. */
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow-x: hidden;
@@ -115,24 +134,40 @@ samp {
}
/* WebKit / Blink. */
.app-main::-webkit-scrollbar {
.app-main::-webkit-scrollbar,
.app-aside::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.app-main::-webkit-scrollbar-track {
.app-main::-webkit-scrollbar-track,
.app-aside::-webkit-scrollbar-track {
background: transparent;
}
.app-main::-webkit-scrollbar-thumb {
.app-main::-webkit-scrollbar-thumb,
.app-aside::-webkit-scrollbar-thumb {
background-color: var(--el-border-color-darker);
border-radius: 4px;
}
.app-main::-webkit-scrollbar-thumb:hover {
.app-main::-webkit-scrollbar-thumb:hover,
.app-aside::-webkit-scrollbar-thumb:hover {
background-color: var(--el-text-color-placeholder);
}
/* The second-level menu on the right. Its own width is set by the component,
so only the shrink behaviour belongs here. */
.app-aside {
flex: 0 0 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--el-border-color-darker) transparent;
transition: width 0.2s ease;
}
/* --- page-level helpers -----------------------------------------------------
A view's outermost element uses .page to get a consistent vertical rhythm
inside the main gutter. */
@@ -143,3 +178,24 @@ samp {
gap: 16px;
min-width: 0;
}
/* Toolbar above a table: filters on the left, actions on the right, wrapping
on narrow screens instead of overflowing the gutter. */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-spacer {
flex: 1 1 auto;
min-width: 0;
}
/* Right-aligned pagination under a table. */
.table-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
+74
View File
@@ -0,0 +1,74 @@
/** Small formatting helpers shared by the list views. */
function pad(value: number): string {
return String(value).padStart(2, '0')
}
/**
* Render a backend timestamp as `YYYY-MM-DD HH:mm`.
*
* The API sends naive local datetimes (`2026-09-18T07:46:40`). Per the ES
* date-time spec a date-time string without an offset is parsed as *local*
* time, which is what the server clock already is, so no conversion happens.
*/
export function formatDateTime(value: string | null | undefined): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}`
)
}
/**
* Inline style that renders a field the way the paper would.
*
* `pt` is a real CSS unit, so 10.5pt shows at the same size it will print at.
*/
export function typographyStyle(field: {
font_size: number
font_color: string
}): Record<string, string> {
return {
fontSize: `${field.font_size}pt`,
color: field.font_color,
}
}
/** Indentation for a heading level: level 1 flush, each deeper level inset. */
export function levelIndent(level: number): string {
return `${Math.max(0, level - 1) * 18}px`
}
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*)?\)$/
/**
* Normalise a colour spelling to `#RRGGBB`, or `null` if unrecognised.
*
* Mirrors `normalize_hex_color` on the backend. Doing it here as well means a
* hand-typed `rgb(255, 0, 0)` is tidied up the moment the field loses focus,
* instead of travelling to the server to be rejected.
*/
export function normalizeHexColor(value: string): string | null {
const text = value.trim()
const rgbMatch = RGB_PATTERN.exec(text)
if (rgbMatch) {
const channels = rgbMatch.slice(1, 4).map(Number)
if (channels.some((channel) => channel > 255)) return null
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`.toUpperCase()
}
const hexMatch = HEX_PATTERN.exec(text)
if (hexMatch) {
const digits = hexMatch[1]!
const full = digits.length === 3 ? digits.replace(/./g, (char) => char + char) : digits
return `#${full.toUpperCase()}`
}
return null
}
+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>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
/**
* The 论文 section.
*
* 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.
*/
import { useRouter } from 'vue-router'
import { Files } from '@element-plus/icons-vue'
const router = useRouter()
</script>
<template>
<div class="page">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>我的论文</span>
<el-tag size="small" type="info" effect="plain">内容待定</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')">
去配置模板
</el-button>
</el-empty>
</el-card>
</div>
</template>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.hint {
max-width: 460px;
margin: 0 auto;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.7;
}
</style>
@@ -0,0 +1,317 @@
<script setup lang="ts">
/**
* 字段管理 — the reusable heading library.
*
* This is the table behind every template's picker, so it carries the full
* CRUD set: search, single and batch delete, and create/edit through a dialog.
* Deleting is guarded on the server: a field that a template still places is
* refused, and the message names the field, because dropping it silently would
* remove a heading from that template's outline.
*/
import { onMounted, reactive, ref } from 'vue'
import { Delete, Edit, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { errorMessage } from '@/api/client'
import {
batchDeleteSectionFields,
deleteSectionField,
listSectionFields,
type SectionField,
} from '@/api/sectionFields'
import FieldFormDialog from '@/components/fields/FieldFormDialog.vue'
import { formatDateTime, levelIndent, typographyStyle } from '@/utils/format'
const loading = ref(false)
const rows = ref<SectionField[]>([])
const total = ref(0)
const selection = ref<SectionField[]>([])
const query = reactive({
keyword: '',
level: null as number | null,
page: 1,
page_size: 20,
})
const dialogVisible = ref(false)
const editing = ref<SectionField | null>(null)
const LEVEL_FILTERS = [
{ value: null, label: '全部等级' },
{ value: 1, label: '1 级' },
{ value: 2, label: '2 级' },
{ value: 3, label: '3 级' },
]
async function load(): Promise<void> {
loading.value = true
try {
const result = await listSectionFields(query)
rows.value = result.items
total.value = result.total
// A delete can empty the last page. Stepping back keeps the table from
// showing "no data" while the pager still claims a page beyond 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
load()
}
function resetFilters(): void {
query.keyword = ''
query.level = null
query.page = 1
load()
}
function onCreate(): void {
editing.value = null
dialogVisible.value = true
}
function onEdit(row: SectionField): void {
editing.value = row
dialogVisible.value = true
}
async function onDelete(row: SectionField): Promise<void> {
try {
await ElMessageBox.confirm(`确定删除字段「${row.name}」?`, '删除字段', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return // dismissed
}
try {
await deleteSectionField(row.id)
ElMessage.success('已删除')
await load()
} 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 batchDeleteSectionFields(ids)
ElMessage.success(`已删除 ${result.deleted} 个字段`)
selection.value = []
await load()
} catch (error) {
// Refusals list every field still in use, so this is worth showing whole.
ElMessage.error(errorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page">
<el-card shadow="never">
<template #header>
<div class="card-header">
<div>
<span class="card-title">字段库</span>
<span class="card-subtitle">
字段是全局复用的可被任意多个模板以任意顺序引用
</span>
</div>
<el-tag type="info" effect="plain"> {{ total }} </el-tag>
</div>
</template>
<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.level" class="level-filter" @change="search">
<el-option
v-for="item in LEVEL_FILTERS"
:key="String(item.value)"
:label="item.label"
:value="item.value"
/>
</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-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: SectionField[]) => (selection = value)"
>
<el-table-column type="selection" width="46" reserve-selection />
<el-table-column label="字段名称" min-width="260">
<template #default="{ row }">
<div class="name-cell" :style="{ paddingLeft: levelIndent(row.level) }">
<span :style="typographyStyle(row)">{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="等级" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.level }} 级</el-tag>
</template>
</el-table-column>
<el-table-column label="字体大小" width="110" align="right">
<template #default="{ row }">{{ row.font_size }} pt</template>
</el-table-column>
<el-table-column label="字体颜色" width="150">
<template #default="{ row }">
<div class="color-cell">
<span class="swatch" :style="{ backgroundColor: row.font_color }" />
<code>{{ row.font_color }}</code>
</div>
</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="140" fixed="right">
<template #default="{ row }">
<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 ? '没有匹配的字段' : '字段库还是空的,先新增一个'">
<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>
<FieldFormDialog v-model="dialogVisible" :field="editing" @saved="load" />
</div>
</template>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title {
font-weight: 600;
margin-right: 10px;
}
.card-subtitle {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.search {
width: 220px;
}
.level-filter {
width: 130px;
}
.table {
margin-top: 16px;
}
.name-cell {
min-width: 0;
overflow-wrap: anywhere;
}
.color-cell {
display: flex;
align-items: center;
gap: 8px;
}
.swatch {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color);
flex: 0 0 auto;
}
.color-cell code {
font-size: 12px;
color: var(--el-text-color-regular);
}
</style>
@@ -0,0 +1,342 @@
<script setup lang="ts">
/**
* 模板列表 — the table of every paper template.
*
* The full CRUD surface: search, create, edit, single and batch delete, with a
* preview drawer that renders a template's outline exactly as it will read.
* The drawer is the quickest way to confirm that a template's fields really do
* come out in `sort` order before committing to write against it.
*/
import { onMounted, reactive, ref } from 'vue'
import { Delete, Edit, Files, Plus, Refresh, Search, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { errorMessage } from '@/api/client'
import {
batchDeleteTemplates,
deleteTemplate,
getTemplate,
listTemplates,
type TemplateDetail,
type TemplateListItem,
} from '@/api/templates'
import TemplateFormDialog from '@/components/templates/TemplateFormDialog.vue'
import TemplateOutline from '@/components/templates/TemplateOutline.vue'
import { formatDateTime } from '@/utils/format'
const loading = ref(false)
const rows = ref<TemplateListItem[]>([])
const total = ref(0)
const selection = ref<TemplateListItem[]>([])
const query = reactive({ keyword: '', page: 1, page_size: 20 })
const dialogVisible = ref(false)
const editing = ref<TemplateListItem | null>(null)
const previewVisible = ref(false)
const previewLoading = ref(false)
const preview = ref<TemplateDetail | null>(null)
async function load(): Promise<void> {
loading.value = true
try {
const result = await listTemplates(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
load()
}
function resetFilters(): void {
query.keyword = ''
query.page = 1
load()
}
function onCreate(): void {
editing.value = null
dialogVisible.value = true
}
function onEdit(row: TemplateListItem): void {
editing.value = row
dialogVisible.value = true
}
async function onPreview(row: TemplateListItem): Promise<void> {
previewVisible.value = true
previewLoading.value = true
preview.value = null
try {
preview.value = await getTemplate(row.id)
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
previewLoading.value = false
}
}
async function onDelete(row: TemplateListItem): Promise<void> {
try {
await ElMessageBox.confirm(
`确定删除模板「${row.name}」?字段库中的字段不会被删除。`,
'删除模板',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return // dismissed
}
try {
await deleteTemplate(row.id)
ElMessage.success('已删除')
await load()
} 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 batchDeleteTemplates(ids)
ElMessage.success(`已删除 ${result.deleted} 个模板`)
selection.value = []
await load()
} catch (error) {
ElMessage.error(errorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page">
<el-card shadow="never">
<template #header>
<div class="card-header">
<div>
<span class="card-title">模板列表</span>
<span class="card-subtitle">模板 = 一组按 sort 排好序的段落字段</span>
</div>
<el-tag type="info" effect="plain"> {{ total }} </el-tag>
</div>
</template>
<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-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-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: TemplateListItem[]) => (selection = value)"
>
<el-table-column type="selection" width="46" reserve-selection />
<el-table-column label="模板名称" min-width="200">
<template #default="{ row }">
<el-button link type="primary" class="name-link" @click="onPreview(row)">
{{ row.name }}
</el-button>
</template>
</el-table-column>
<el-table-column label="摘要" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.abstract">{{ row.abstract }}</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" effect="plain">{{ row.field_count }}</el-tag>
</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="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="onPreview(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 ? '没有匹配的模板' : '还没有模板,先创建一个'"
>
<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>
<TemplateFormDialog v-model="dialogVisible" :template="editing" @saved="load" />
<el-drawer
v-model="previewVisible"
:title="preview?.name ?? '模板预览'"
size="min(560px, 92vw)"
direction="rtl"
>
<div v-loading="previewLoading" class="preview">
<template v-if="preview">
<div class="preview-meta">
<el-tag size="small" effect="plain">
<el-icon><Files /></el-icon>
{{ preview.fields.length }} 个段落
</el-tag>
<span class="muted">更新于 {{ formatDateTime(preview.updated_at) }}</span>
</div>
<p v-if="preview.abstract" class="preview-abstract">{{ preview.abstract }}</p>
<el-divider content-position="left">
大纲
<span class="divider-hint">已按 sort 升序排列</span>
</el-divider>
<TemplateOutline :fields="preview.fields" />
</template>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title {
font-weight: 600;
margin-right: 10px;
}
.card-subtitle {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.search {
width: 260px;
}
.table {
margin-top: 16px;
}
.name-link {
font-weight: 600;
padding: 0;
height: auto;
}
.muted {
color: var(--el-text-color-placeholder);
}
.preview {
min-height: 120px;
}
.preview-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.preview-abstract {
margin: 12px 0 0;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.7;
}
.divider-hint {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>