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
+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
}