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