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:
+44
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user