098bfd1bfb
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.
100 lines
2.8 KiB
TypeScript
100 lines
2.8 KiB
TypeScript
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
|
|
}
|