diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 380e8e6..ad46a14 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,44 +1,128 @@ diff --git a/frontend/src/components/SectionAside.vue b/frontend/src/components/SectionAside.vue new file mode 100644 index 0000000..7cd7fa8 --- /dev/null +++ b/frontend/src/components/SectionAside.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/src/components/fields/FieldFormDialog.vue b/frontend/src/components/fields/FieldFormDialog.vue new file mode 100644 index 0000000..5e8d600 --- /dev/null +++ b/frontend/src/components/fields/FieldFormDialog.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/frontend/src/components/templates/TemplateFormDialog.vue b/frontend/src/components/templates/TemplateFormDialog.vue new file mode 100644 index 0000000..eff751e --- /dev/null +++ b/frontend/src/components/templates/TemplateFormDialog.vue @@ -0,0 +1,599 @@ + + + + + diff --git a/frontend/src/components/templates/TemplateOutline.vue b/frontend/src/components/templates/TemplateOutline.vue new file mode 100644 index 0000000..70e4535 --- /dev/null +++ b/frontend/src/components/templates/TemplateOutline.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 900c5ab..674771d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index b922d2d..7e38c8a 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -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(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'], }, }, ) diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 023c4a3..9822875 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -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; +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..635603d --- /dev/null +++ b/frontend/src/utils/format.ts @@ -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 { + 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 +} diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 589451a..026794f 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,98 +1,225 @@ - diff --git a/frontend/src/views/PapersView.vue b/frontend/src/views/PapersView.vue new file mode 100644 index 0000000..b769b08 --- /dev/null +++ b/frontend/src/views/PapersView.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/frontend/src/views/templates/FieldManageView.vue b/frontend/src/views/templates/FieldManageView.vue new file mode 100644 index 0000000..5972558 --- /dev/null +++ b/frontend/src/views/templates/FieldManageView.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/frontend/src/views/templates/TemplateListView.vue b/frontend/src/views/templates/TemplateListView.vue new file mode 100644 index 0000000..ec02301 --- /dev/null +++ b/frontend/src/views/templates/TemplateListView.vue @@ -0,0 +1,342 @@ + + + + +