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:
+101
-17
@@ -1,44 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
/**
|
||||
* Application shell.
|
||||
*
|
||||
* Three bands, in this order: a header holding the mark, the two top-level
|
||||
* sections, and the mark again at the far right; then the content area; then
|
||||
* the second-level menu on the right, present only on routes that declare a
|
||||
* `section` in their meta.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { RouterView, RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const appStore = useAppStore()
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import SectionAside from '@/components/SectionAside.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
/** The two top-level sections. `index` is the path `el-menu` routes to. */
|
||||
const TOP_MENU = [
|
||||
{ index: '/papers', label: '论文' },
|
||||
{ index: '/templates', label: '模板' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Which top-level entry the current URL belongs to.
|
||||
*
|
||||
* Derived from the path rather than tracked in state, so browser back/forward
|
||||
* and a pasted deep link both leave the header highlighting the right entry.
|
||||
* `/templates/list` and `/templates/fields` both resolve to `/templates`.
|
||||
*/
|
||||
const activeTop = computed(() => {
|
||||
const match = TOP_MENU.find(
|
||||
(item) => route.path === item.index || route.path.startsWith(`${item.index}/`),
|
||||
)
|
||||
return match?.index ?? ''
|
||||
})
|
||||
|
||||
const section = computed(() => (route.meta.section as string | undefined) ?? '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="app-shell">
|
||||
<el-header class="app-header">
|
||||
<div class="brand">paper-doc</div>
|
||||
<el-menu mode="horizontal" :ellipsis="false" router class="app-nav">
|
||||
<el-menu-item index="/">Home</el-menu-item>
|
||||
<el-header class="app-header" height="60px">
|
||||
<RouterLink to="/" class="brand">
|
||||
<span class="brand-mark"><AppLogo :size="20" /></span>
|
||||
<span class="brand-text">paper-doc</span>
|
||||
</RouterLink>
|
||||
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
:ellipsis="false"
|
||||
router
|
||||
:default-active="activeTop"
|
||||
class="app-nav"
|
||||
>
|
||||
<el-menu-item v-for="item in TOP_MENU" :key="item.index" :index="item.index">
|
||||
{{ item.label }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<el-button link @click="appStore.toggleSidebar()">
|
||||
{{ appStore.sidebarCollapsed ? 'Expand' : 'Collapse' }}
|
||||
</el-button>
|
||||
|
||||
<!-- The mark repeated at the menu's right end, as a quiet signature. -->
|
||||
<span class="brand-mark brand-mark--ghost" aria-hidden="true">
|
||||
<AppLogo :size="16" />
|
||||
</span>
|
||||
</el-header>
|
||||
|
||||
<el-main class="app-main">
|
||||
<RouterView />
|
||||
</el-main>
|
||||
<el-container class="app-body">
|
||||
<el-main class="app-main">
|
||||
<RouterView />
|
||||
</el-main>
|
||||
|
||||
<SectionAside v-if="section" :section="section" />
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
Sizing and scrolling belong to the global shell contract in
|
||||
src/styles/base.css (.app-shell, .app-header, .app-main). Only the chrome
|
||||
drawn inside the header is styled here.
|
||||
src/styles/base.css (.app-shell, .app-header, .app-body, .app-main,
|
||||
.app-aside). Only the chrome drawn inside the header is styled here.
|
||||
-->
|
||||
<style scoped>
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
/* The brand links home; strip the anchor defaults. */
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, var(--el-color-primary), #7c5cff);
|
||||
box-shadow: 0 2px 6px rgb(64 158 255 / 30%);
|
||||
}
|
||||
|
||||
/* The right-hand echo: no badge, no weight, just the shape. */
|
||||
.brand-mark--ghost {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
color: var(--el-text-color-placeholder);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The app mark: an open book with text lines on both pages.
|
||||
*
|
||||
* Drawn with `stroke="currentColor"` and no fill, so the same component works
|
||||
* as the coloured badge in the header and as the muted watermark at its right
|
||||
* end — the surrounding CSS decides the colour and nothing is hard-coded here.
|
||||
*/
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="app-logo"
|
||||
role="img"
|
||||
aria-label="paper-doc"
|
||||
>
|
||||
<!-- Two facing pages, meeting at the spine. -->
|
||||
<path
|
||||
d="M12 7.2C10.3 6 8.1 5.4 5.4 5.5A1.3 1.3 0 0 0 4.1 6.8v10.5c0 .8.6 1.4 1.4 1.3 2.4-.1 4.4.4 5.9 1.4.4.2.8.2 1.2 0 1.5-1 3.5-1.5 5.9-1.4.8.1 1.4-.5 1.4-1.3V6.8c0-.7-.6-1.3-1.3-1.3-2.7-.1-4.9.5-6.6 1.7Z"
|
||||
/>
|
||||
<path d="M12 7.2V20" />
|
||||
<!-- Body text on the left page. -->
|
||||
<path d="M6.9 9.6h2.5M6.9 12.4h3.3" />
|
||||
<!-- And on the right. -->
|
||||
<path d="M14.6 9.6h2.5M13.8 12.4h3.3" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-logo {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The second-level menu, pinned to the right of the content area.
|
||||
*
|
||||
* It is driven entirely by `route.meta.section`, so the menu a URL belongs to
|
||||
* is a property of the route table rather than of component state — deep
|
||||
* linking to `/templates/fields` shows the right menu on first paint, and the
|
||||
* aside disappears on routes that declare no section (the welcome page).
|
||||
*/
|
||||
import { computed, type Component } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Document, Expand, Files, Fold, Setting } from '@element-plus/icons-vue'
|
||||
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
interface AsideItem {
|
||||
index: string
|
||||
label: string
|
||||
icon: Component
|
||||
hint?: string
|
||||
}
|
||||
|
||||
interface AsideMenu {
|
||||
title: string
|
||||
items: AsideItem[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ section: string }>()
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const MENUS: Record<string, AsideMenu> = {
|
||||
papers: {
|
||||
title: '论文',
|
||||
items: [{ index: '/papers', label: '我的论文', icon: Document, hint: '待定' }],
|
||||
},
|
||||
templates: {
|
||||
title: '模板配置',
|
||||
items: [
|
||||
{ index: '/templates/list', label: '模板列表', icon: Files },
|
||||
{ index: '/templates/fields', label: '字段管理', icon: Setting },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const menu = computed<AsideMenu | null>(() => MENUS[props.section] ?? null)
|
||||
const collapsed = computed(() => appStore.asideCollapsed)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-aside
|
||||
v-if="menu"
|
||||
class="app-aside"
|
||||
:width="collapsed ? '56px' : '208px'"
|
||||
>
|
||||
<div class="aside-head" :class="{ 'is-collapsed': collapsed }">
|
||||
<span v-if="!collapsed" class="aside-title">{{ menu.title }}</span>
|
||||
<el-tooltip
|
||||
:content="collapsed ? '展开菜单' : '收起菜单'"
|
||||
placement="left"
|
||||
:show-after="200"
|
||||
>
|
||||
<el-button
|
||||
link
|
||||
:icon="collapsed ? Expand : Fold"
|
||||
class="aside-toggle"
|
||||
@click="appStore.toggleAside()"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="route.path"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
class="aside-menu"
|
||||
>
|
||||
<el-menu-item v-for="item in menu.items" :key="item.index" :index="item.index">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<template #title>
|
||||
<span class="aside-label">{{ item.label }}</span>
|
||||
<el-tag v-if="item.hint" size="small" type="info" effect="plain">
|
||||
{{ item.hint }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* The wrapper owns the border so it stays put while the menu scrolls. */
|
||||
border-left: 1px solid var(--el-border-color);
|
||||
background-color: var(--el-bg-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aside-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
padding: 0 8px 0 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.aside-head.is-collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.aside-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aside-toggle {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.aside-menu {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* A collapsed el-menu keeps a fixed 64px width that no longer matches the
|
||||
56px rail, so the items would sit off-centre. */
|
||||
.aside-menu.el-menu--collapse {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aside-label {
|
||||
margin-right: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Create / edit dialog for one library field.
|
||||
*
|
||||
* The live preview underneath the form is the point of the dialog: font size
|
||||
* and colour are the two settings whose effect is impossible to judge from the
|
||||
* value alone, so the preview renders the name exactly as the outline will.
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
import { errorMessage } from '@/api/client'
|
||||
import {
|
||||
createSectionField,
|
||||
updateSectionField,
|
||||
type SectionField,
|
||||
type SectionFieldPayload,
|
||||
} from '@/api/sectionFields'
|
||||
import { normalizeHexColor, typographyStyle } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Visibility, used with `v-model`. */
|
||||
modelValue: boolean
|
||||
/** The field being edited, or `null` to create a new one. */
|
||||
field: SectionField | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Emitted after a successful write, so the list can refresh. */
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive<SectionFieldPayload>({
|
||||
name: '',
|
||||
level: 1,
|
||||
// 小四, the conventional body size for a Chinese thesis.
|
||||
font_size: 12,
|
||||
font_color: '#000000',
|
||||
})
|
||||
|
||||
const rules: FormRules<SectionFieldPayload> = {
|
||||
name: [
|
||||
{ required: true, message: '请填写字段名称', trigger: 'blur' },
|
||||
{ max: 255, message: '字段名称最多 255 个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: 1, label: '1 级 · 如 1. / 2.' },
|
||||
{ value: 2, label: '2 级 · 如 1.1 / 1.2' },
|
||||
{ value: 3, label: '3 级 · 如 1.1.1' },
|
||||
{ value: 4, label: '4 级' },
|
||||
]
|
||||
|
||||
/** Swatches offered by the picker — the greys match the seeded field colours. */
|
||||
const PRESET_COLORS = [
|
||||
'#000000',
|
||||
'#1F1F1F',
|
||||
'#404040',
|
||||
'#595959',
|
||||
'#8C8C8C',
|
||||
'#409EFF',
|
||||
'#67C23A',
|
||||
'#E6A23C',
|
||||
'#F56C6C',
|
||||
]
|
||||
|
||||
const previewStyle = computed(() => typographyStyle(form))
|
||||
|
||||
/** The title states which mode the dialog is in, so it is never ambiguous. */
|
||||
const title = computed(() => (props.field ? '编辑字段' : '新增字段'))
|
||||
|
||||
// Re-seed the form each time the dialog opens, so a cancelled edit never leaks
|
||||
// into the next one.
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
formRef.value?.clearValidate()
|
||||
|
||||
const source = props.field
|
||||
form.name = source?.name ?? ''
|
||||
form.level = source?.level ?? 1
|
||||
form.font_size = source?.font_size ?? 12
|
||||
form.font_color = source?.font_color ?? '#000000'
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Tidy a hand-typed colour when the input loses focus.
|
||||
*
|
||||
* The picker only ever produces `#RRGGBB`, but the text box next to it accepts
|
||||
* `rgb(...)` and shorthand too, matching what the API takes.
|
||||
*/
|
||||
function commitColor(): void {
|
||||
const normalized = normalizeHexColor(form.font_color)
|
||||
if (normalized) {
|
||||
form.font_color = normalized
|
||||
return
|
||||
}
|
||||
ElMessage.warning('颜色格式无效,请使用 #RRGGBB 或 rgb(r,g,b)')
|
||||
form.font_color = props.field?.font_color ?? '#000000'
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.field) {
|
||||
await updateSectionField(props.field.id, { ...form })
|
||||
ElMessage.success('字段已更新,使用它的模板会同步生效')
|
||||
} else {
|
||||
await createSectionField({ ...form })
|
||||
ElMessage.success('字段已创建')
|
||||
}
|
||||
emit('saved')
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="title" width="520px" append-to-body>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
|
||||
<el-form-item label="字段名称" prop="name">
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
placeholder="如 1. Introduction,编号自己写,系统不会改"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="字段等级">
|
||||
<el-select v-model="form.level" class="full">
|
||||
<el-option
|
||||
v-for="option in LEVEL_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="字体大小">
|
||||
<el-input-number
|
||||
v-model="form.font_size"
|
||||
:min="6"
|
||||
:max="48"
|
||||
:step="0.5"
|
||||
:precision="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="unit">pt</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="字体颜色">
|
||||
<div class="color-row">
|
||||
<el-color-picker v-model="form.font_color" :predefine="PRESET_COLORS" />
|
||||
<el-input
|
||||
v-model="form.font_color"
|
||||
class="color-input"
|
||||
placeholder="#RRGGBB 或 rgb(r,g,b)"
|
||||
@blur="commitColor"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="预览">
|
||||
<div class="preview">
|
||||
<span :style="previewStyle">{{ form.name || '(未命名字段)' }}</span>
|
||||
<span class="preview-meta">
|
||||
{{ form.level }} 级 · {{ form.font_size }}pt · {{ form.font_color }}
|
||||
</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
/* Long names wrap rather than widening the dialog. */
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,599 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Create / edit dialog for a paper template.
|
||||
*
|
||||
* The picker is the heart of the feature, and it is deliberately *not* a tree.
|
||||
* Fields come from one flat library and are chosen freely: any subset, any
|
||||
* order, and the same field more than once. What the reader finally sees is
|
||||
* decided by `sort` alone, so the right-hand list is always rendered sorted by
|
||||
* `sort` — reordering the numbers reorders the outline immediately, and the
|
||||
* order the user clicked in is never stored.
|
||||
*
|
||||
* Repeats and ties are surfaced rather than blocked: a repeat is often
|
||||
* intentional (a level-2 "Background" under two different level-1 headings),
|
||||
* and a tie is legal because the backend breaks it by insertion order — but
|
||||
* both are worth a warning, since neither is obvious from the numbers alone.
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Delete,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
Setting,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
import { errorMessage } from '@/api/client'
|
||||
import { fetchAllSectionFields, type SectionField } from '@/api/sectionFields'
|
||||
import {
|
||||
createTemplate,
|
||||
getTemplate,
|
||||
updateTemplate,
|
||||
type TemplateFieldInput,
|
||||
type TemplateListItem,
|
||||
} from '@/api/templates'
|
||||
import { levelIndent, typographyStyle } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Visibility, used with `v-model`. */
|
||||
modelValue: boolean
|
||||
/** The template being edited, or `null` to create a new one. */
|
||||
template: TemplateListItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Emitted after a successful write, so the list can refresh. */
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
/** One chosen field, at one position. The same field may appear many times. */
|
||||
interface Placement {
|
||||
/** Local identity for `v-for` / `row-key`; not sent to the server. */
|
||||
key: number
|
||||
field_id: number
|
||||
sort: number
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const library = ref<SectionField[]>([])
|
||||
const libraryKeyword = ref('')
|
||||
const selected = ref<Placement[]>([])
|
||||
|
||||
const form = reactive({ name: '', abstract: '' })
|
||||
|
||||
const rules: FormRules = { name: [{ required: true, message: '请填写模板名称', trigger: 'blur' }] }
|
||||
|
||||
/** Keys only ever count up, so a reopened dialog cannot collide with itself. */
|
||||
let nextKey = 1
|
||||
|
||||
const fieldMap = computed(() => new Map(library.value.map((field) => [field.id, field])))
|
||||
|
||||
const filteredLibrary = computed(() => {
|
||||
const keyword = libraryKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) return library.value
|
||||
return library.value.filter((field) => field.name.toLowerCase().includes(keyword))
|
||||
})
|
||||
|
||||
/** The selection in display order — ascending `sort`, ties by insertion. */
|
||||
const rows = computed(() =>
|
||||
[...selected.value]
|
||||
.sort((a, b) => a.sort - b.sort || a.key - b.key)
|
||||
.map((placement) => ({
|
||||
// Hoisted out of `placement` so el-table's `row-key` can be the plain
|
||||
// property name `key` rather than a function expression.
|
||||
key: placement.key,
|
||||
placement,
|
||||
field: fieldMap.value.get(placement.field_id),
|
||||
})),
|
||||
)
|
||||
|
||||
/** How many times each library field is placed. */
|
||||
const repeatCounts = computed(() => {
|
||||
const counts = new Map<number, number>()
|
||||
for (const placement of selected.value) {
|
||||
counts.set(placement.field_id, (counts.get(placement.field_id) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
const repeatedNames = computed(() =>
|
||||
[...repeatCounts.value.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([id, count]) => `${fieldMap.value.get(id)?.name ?? `#${id}`} ×${count}`),
|
||||
)
|
||||
|
||||
const duplicatedSorts = computed(() => {
|
||||
const counts = new Map<number, number>()
|
||||
for (const placement of selected.value) {
|
||||
counts.set(placement.sort, (counts.get(placement.sort) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([sort]) => sort)
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
const title = computed(() => (props.template ? '编辑模板' : '新建模板'))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (open) => {
|
||||
if (!open) return
|
||||
|
||||
formRef.value?.clearValidate()
|
||||
selected.value = []
|
||||
libraryKeyword.value = ''
|
||||
form.name = props.template?.name ?? ''
|
||||
form.abstract = props.template?.abstract ?? ''
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// The whole library, so a field that was placed before the keyword
|
||||
// filter existed still resolves to a name in the selection.
|
||||
library.value = await fetchAllSectionFields()
|
||||
|
||||
if (props.template) {
|
||||
const detail = await getTemplate(props.template.id)
|
||||
form.name = detail.name
|
||||
form.abstract = detail.abstract ?? ''
|
||||
selected.value = detail.fields.map((field) => ({
|
||||
key: nextKey++,
|
||||
field_id: field.field_id,
|
||||
sort: field.sort,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Append a placement, at the end of the current order. */
|
||||
function addField(field: SectionField): void {
|
||||
const highest = selected.value.reduce((max, item) => Math.max(max, item.sort), 0)
|
||||
selected.value.push({ key: nextKey++, field_id: field.id, sort: highest + 1 })
|
||||
}
|
||||
|
||||
function removePlacement(key: number): void {
|
||||
selected.value = selected.value.filter((item) => item.key !== key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a row one step, by swapping `sort` values with its neighbour.
|
||||
*
|
||||
* Only the two values involved change, so a deliberately sparse numbering
|
||||
* (10, 20, 30) survives a nudge. Equal values would sit still after a plain
|
||||
* swap, so in that case the moving row is nudged past its neighbour instead.
|
||||
*/
|
||||
function swapOrder(index: number, delta: number): void {
|
||||
const ordered = rows.value.map((row) => row.placement)
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= ordered.length) return
|
||||
|
||||
const moving = ordered[index]!
|
||||
const other = ordered[target]!
|
||||
const movingSort = moving.sort
|
||||
const otherSort = other.sort
|
||||
|
||||
if (movingSort === otherSort) {
|
||||
moving.sort = delta < 0 ? otherSort - 1 : otherSort + 1
|
||||
return
|
||||
}
|
||||
moving.sort = otherSort
|
||||
other.sort = movingSort
|
||||
}
|
||||
|
||||
/** Renumber to 1..N in the current display order. */
|
||||
function autoSort(): void {
|
||||
rows.value.forEach((row, index) => {
|
||||
row.placement.sort = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the field library in a second tab.
|
||||
*
|
||||
* Deliberately not an in-place navigation: a half-filled template form would
|
||||
* be lost, and the usual reason to go there is "the field I need does not
|
||||
* exist yet".
|
||||
*/
|
||||
function openFieldManager(): void {
|
||||
const { href } = router.resolve('/templates/fields')
|
||||
window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
if (selected.value.length === 0) {
|
||||
try {
|
||||
await ElMessageBox.confirm('还没有选择任何字段,保存后模板会是空的。确定继续?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续保存',
|
||||
cancelButtonText: '返回选择',
|
||||
})
|
||||
} catch {
|
||||
return // dismissed
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// Sent in display order, so that equal `sort` values get placement ids in
|
||||
// the same order the user sees and the backend's id tie-break agrees with
|
||||
// the screen.
|
||||
const fields: TemplateFieldInput[] = rows.value.map((row) => ({
|
||||
field_id: row.placement.field_id,
|
||||
sort: row.placement.sort,
|
||||
}))
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
abstract: form.abstract.trim() || null,
|
||||
fields,
|
||||
}
|
||||
|
||||
if (props.template) {
|
||||
await updateTemplate(props.template.id, payload)
|
||||
ElMessage.success('模板已更新')
|
||||
} else {
|
||||
await createTemplate(payload)
|
||||
ElMessage.success('模板已创建')
|
||||
}
|
||||
emit('saved')
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="min(980px, 94vw)"
|
||||
top="5vh"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
|
||||
<el-form-item label="模板名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如 标准学术论文(通用)" maxlength="255" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input
|
||||
v-model="form.abstract"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="说明这个模板适合什么类型的论文"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider content-position="left">
|
||||
段落字段
|
||||
<span class="divider-hint">点左侧字段加入,显示顺序只看 sort</span>
|
||||
</el-divider>
|
||||
|
||||
<div v-loading="loading" class="picker">
|
||||
<!-- Library -->
|
||||
<section class="picker-col">
|
||||
<header class="picker-head">
|
||||
<span class="picker-title">可选字段</span>
|
||||
<el-tooltip content="在新标签页打开字段管理,不丢失当前填写" placement="top">
|
||||
<el-button link type="primary" :icon="Setting" @click="openFieldManager">
|
||||
字段管理
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</header>
|
||||
|
||||
<el-input
|
||||
v-model="libraryKeyword"
|
||||
size="small"
|
||||
placeholder="搜索字段名称"
|
||||
clearable
|
||||
class="picker-search"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<div class="picker-list">
|
||||
<button
|
||||
v-for="field in filteredLibrary"
|
||||
:key="field.id"
|
||||
type="button"
|
||||
class="lib-item"
|
||||
@click="addField(field)"
|
||||
>
|
||||
<span class="lib-name" :style="{ paddingLeft: levelIndent(field.level), ...typographyStyle(field) }">
|
||||
{{ field.name }}
|
||||
</span>
|
||||
<span class="lib-side">
|
||||
<el-tag v-if="repeatCounts.get(field.id)" size="small" type="warning" effect="plain">
|
||||
×{{ repeatCounts.get(field.id) }}
|
||||
</el-tag>
|
||||
<el-icon class="lib-add"><Plus /></el-icon>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<el-empty
|
||||
v-if="filteredLibrary.length === 0"
|
||||
:image-size="56"
|
||||
:description="libraryKeyword ? '没有匹配的字段' : '字段库还是空的'"
|
||||
>
|
||||
<el-button link type="primary" @click="openFieldManager">去字段管理新建</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Selection -->
|
||||
<section class="picker-col picker-col--wide">
|
||||
<header class="picker-head">
|
||||
<span class="picker-title">
|
||||
已选段落
|
||||
<el-tag size="small" effect="plain" round>{{ selected.length }}</el-tag>
|
||||
</span>
|
||||
<el-tooltip content="按当前显示顺序重新编号为 1、2、3…" placement="top">
|
||||
<el-button link :icon="Refresh" :disabled="selected.length === 0" @click="autoSort">
|
||||
自动排序
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</header>
|
||||
|
||||
<div class="alerts">
|
||||
<el-alert
|
||||
v-if="repeatedNames.length"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="`同一字段被选了多次:${repeatedNames.join('、')}`"
|
||||
description="允许重复,两处会各自按 sort 出现在大纲里。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="duplicatedSorts.length"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="`存在相同的 sort:${duplicatedSorts.join('、')}`"
|
||||
description="sort 相同时按加入先后显示,建议点「自动排序」错开。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" row-key="key" size="small" class="select-table">
|
||||
<el-table-column label="sort" width="104">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.placement.sort"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
:min="-9999"
|
||||
:max="9999"
|
||||
class="sort-input"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="字段(按 sort 显示)" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
v-if="row.field"
|
||||
class="select-name"
|
||||
:style="{ paddingLeft: levelIndent(row.field.level), ...typographyStyle(row.field) }"
|
||||
>
|
||||
{{ row.field.name }}
|
||||
</span>
|
||||
<span v-else class="missing">字段 #{{ row.placement.field_id }} 已不存在</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="126" align="center">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button
|
||||
link
|
||||
:icon="ArrowUp"
|
||||
:disabled="$index === 0"
|
||||
title="上移"
|
||||
@click="swapOrder($index, -1)"
|
||||
/>
|
||||
<el-button
|
||||
link
|
||||
:icon="ArrowDown"
|
||||
:disabled="$index === rows.length - 1"
|
||||
title="下移"
|
||||
@click="swapOrder($index, 1)"
|
||||
/>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
title="移除"
|
||||
@click="removePlacement(row.key)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<span class="empty-hint">还没有选择字段,点左边的字段加入</span>
|
||||
</template>
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">
|
||||
{{ template ? '保存修改' : '创建模板' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.divider-hint {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* Two columns that shrink to one when the dialog gets narrow. */
|
||||
.picker {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.picker-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 0 0 300px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background-color: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.picker-col--wide {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.picker {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.picker-col {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* A button per field: clickable, focusable and keyboard-operable for free. */
|
||||
.lib-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.lib-item:hover,
|
||||
.lib-item:focus-visible {
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lib-name {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lib-side {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.lib-add {
|
||||
color: var(--el-color-primary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.lib-item:hover .lib-add,
|
||||
.lib-item:focus-visible .lib-add {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.select-table {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.select-name {
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.missing {
|
||||
color: var(--el-color-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sort-input {
|
||||
width: 88px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Read-only rendering of a template's outline.
|
||||
*
|
||||
* This is where the ordering contract becomes visible: the rows are painted in
|
||||
* the order the API returned them, which is ascending `sort`, and each name is
|
||||
* drawn at its own font size and colour. The `sort` value is shown alongside so
|
||||
* the order can be checked against the numbers rather than taken on trust.
|
||||
*/
|
||||
import type { TemplateField } from '@/api/templates'
|
||||
import { levelIndent, typographyStyle } from '@/utils/format'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
fields: TemplateField[]
|
||||
/** Show the sort value and typography under each heading. */
|
||||
showMeta?: boolean
|
||||
}>(),
|
||||
{ showMeta: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="outline">
|
||||
<el-empty v-if="fields.length === 0" description="这个模板还没有选择任何字段" />
|
||||
|
||||
<ol v-else class="outline-list">
|
||||
<li v-for="field in fields" :key="field.id" class="outline-row">
|
||||
<span class="outline-sort">{{ field.sort }}</span>
|
||||
<div class="outline-body">
|
||||
<div class="outline-name" :style="{ paddingLeft: levelIndent(field.level) }">
|
||||
<span :style="typographyStyle(field)">{{ field.name }}</span>
|
||||
</div>
|
||||
<div v-if="showMeta" class="outline-meta">
|
||||
{{ field.level }} 级 · {{ field.font_size }}pt · {{ field.font_color }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The list is styled as a bare stack: the visual numbering is already inside
|
||||
each field's name, so a browser-generated marker would double it. */
|
||||
.outline-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.outline-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.outline-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.outline-sort {
|
||||
flex: 0 0 auto;
|
||||
min-width: 32px;
|
||||
padding: 1px 6px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.outline-body {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.outline-name {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.outline-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(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'],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+202
-75
@@ -1,98 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
/**
|
||||
* Welcome page.
|
||||
*
|
||||
* Shown before either section is opened, so it does two jobs: greet, and offer
|
||||
* the two ways in. The counts underneath come from the same endpoints the
|
||||
* section pages use, which makes this page double as a connectivity check —
|
||||
* if the numbers are there, the API and TiDB are up.
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Files, Notebook, Setting } from '@element-plus/icons-vue'
|
||||
|
||||
import { fetchHealth, type HealthResponse } from '@/api/health'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { listSectionFields } from '@/api/sectionFields'
|
||||
import { listTemplates } from '@/api/templates'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
const health = ref<HealthResponse | null>(null)
|
||||
const loading = ref(false)
|
||||
const templateCount = ref<number | null>(null)
|
||||
const fieldCount = ref<number | null>(null)
|
||||
|
||||
const databaseTagType = computed(() => (health.value?.database === 'ok' ? 'success' : 'danger'))
|
||||
|
||||
async function loadHealth(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
health.value = await fetchHealth()
|
||||
appStore.markChecked()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
ElMessage.error(message)
|
||||
health.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
/** `null` renders as an em dash: unknown, not zero. */
|
||||
function display(value: number | null): string {
|
||||
return value === null ? '—' : String(value)
|
||||
}
|
||||
|
||||
onMounted(loadHealth)
|
||||
onMounted(async () => {
|
||||
// `page_size: 1` because only `total` is used. A failure leaves the count as
|
||||
// an em dash rather than blocking the page.
|
||||
const [templates, fields] = await Promise.allSettled([
|
||||
listTemplates({ page: 1, page_size: 1 }),
|
||||
listSectionFields({ page: 1, page_size: 1 }),
|
||||
])
|
||||
if (templates.status === 'fulfilled') templateCount.value = templates.value.total
|
||||
if (fields.status === 'fulfilled') fieldCount.value = fields.value.total
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Backend connectivity</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadHealth">Re-check</el-button>
|
||||
<div class="page welcome">
|
||||
<section class="hero">
|
||||
<span class="hero-mark"><AppLogo :size="40" /></span>
|
||||
<h1 class="hero-title">欢迎访问</h1>
|
||||
<p class="hero-subtitle">先配置好论文模板,写作时只需对着结构填内容。</p>
|
||||
</section>
|
||||
|
||||
<section class="entries">
|
||||
<button type="button" class="entry" @click="router.push('/papers')">
|
||||
<el-icon class="entry-icon"><Notebook /></el-icon>
|
||||
<span class="entry-title">论文</span>
|
||||
<span class="entry-desc">按模板撰写、管理与导出论文</span>
|
||||
<el-tag size="small" type="info" effect="plain">内容待定</el-tag>
|
||||
</button>
|
||||
|
||||
<button type="button" class="entry" @click="router.push('/templates')">
|
||||
<el-icon class="entry-icon"><Files /></el-icon>
|
||||
<span class="entry-title">模板</span>
|
||||
<span class="entry-desc">维护模板列表与可复用的段落字段</span>
|
||||
<el-tag size="small" type="success" effect="plain">
|
||||
{{ display(templateCount) }} 个模板
|
||||
</el-tag>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<el-card shadow="never" class="stats">
|
||||
<div class="stat">
|
||||
<el-icon class="stat-icon"><Setting /></el-icon>
|
||||
<div>
|
||||
<div class="stat-value">{{ display(fieldCount) }}</div>
|
||||
<div class="stat-label">字段库</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions v-if="health" :column="1" border>
|
||||
<el-descriptions-item label="API">
|
||||
{{ health.app }}
|
||||
<el-tag type="success" size="small">{{ health.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="TiDB">
|
||||
<el-tag :type="databaseTagType" size="small">{{ health.database }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Target">
|
||||
<code>{{ health.database_target }}</code>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-empty v-else description="No response from the API yet" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Persisted state</span>
|
||||
<el-switch
|
||||
v-model="appStore.sidebarCollapsed"
|
||||
active-text="Sidebar collapsed"
|
||||
inline-prompt
|
||||
/>
|
||||
</div>
|
||||
<el-divider direction="vertical" class="stat-divider" />
|
||||
<div class="stat">
|
||||
<el-icon class="stat-icon"><Files /></el-icon>
|
||||
<div>
|
||||
<div class="stat-value">{{ display(templateCount) }}</div>
|
||||
<div class="stat-label">模板</div>
|
||||
</div>
|
||||
</template>
|
||||
<p>
|
||||
Last probe:
|
||||
<strong>{{ appStore.lastCheckedAt ?? 'never' }}</strong>
|
||||
</p>
|
||||
<p class="hint">
|
||||
Both values are stored in <code>localStorage</code> under
|
||||
<code>paper-doc:app</code>. Reload the page — they survive.
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat-hint">
|
||||
字段可以自由组合进模板,显示顺序完全由每个字段的 sort 决定。
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
The outer .page class (stacking + gap) comes from src/styles/base.css, so
|
||||
every view shares one vertical rhythm inside the main gutter.
|
||||
-->
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.welcome {
|
||||
gap: 24px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, var(--el-color-primary), #7c5cff);
|
||||
box-shadow: 0 10px 24px rgb(64 158 255 / 28%);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 30px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* A button, not a div: these navigate, so they belong in the tab order and
|
||||
respond to Enter and Space for free. */
|
||||
.entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.entry:hover,
|
||||
.entry:focus-visible {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 8%);
|
||||
transform: translateY(-2px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
font-size: 24px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entry-desc {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats {
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stats :deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 20px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.stat-hint {
|
||||
flex: 1 1 220px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The 论文 section.
|
||||
*
|
||||
* Its content is still to be decided. The page exists so the section is
|
||||
* reachable from the header and so its second-level menu has somewhere to
|
||||
* point; the outline that will drive the writing surface already exists as
|
||||
* templates, so what lands here is the editor that reads one.
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Files } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>我的论文</span>
|
||||
<el-tag size="small" type="info" effect="plain">内容待定</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty description="论文列表与写作界面尚未确定">
|
||||
<template #description>
|
||||
<p class="hint">
|
||||
这一块的内容还没定。目前可以先在
|
||||
<strong>模板</strong> 里把结构配置好——模板选定的段落字段,
|
||||
就是之后写作时逐段填写的目录。
|
||||
</p>
|
||||
</template>
|
||||
<el-button type="primary" :icon="Files" @click="router.push('/templates/list')">
|
||||
去配置模板
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hint {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 字段管理 — the reusable heading library.
|
||||
*
|
||||
* This is the table behind every template's picker, so it carries the full
|
||||
* CRUD set: search, single and batch delete, and create/edit through a dialog.
|
||||
* Deleting is guarded on the server: a field that a template still places is
|
||||
* refused, and the message names the field, because dropping it silently would
|
||||
* remove a heading from that template's outline.
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Delete, Edit, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import { errorMessage } from '@/api/client'
|
||||
import {
|
||||
batchDeleteSectionFields,
|
||||
deleteSectionField,
|
||||
listSectionFields,
|
||||
type SectionField,
|
||||
} from '@/api/sectionFields'
|
||||
import FieldFormDialog from '@/components/fields/FieldFormDialog.vue'
|
||||
import { formatDateTime, levelIndent, typographyStyle } from '@/utils/format'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<SectionField[]>([])
|
||||
const total = ref(0)
|
||||
const selection = ref<SectionField[]>([])
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
level: null as number | null,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref<SectionField | null>(null)
|
||||
|
||||
const LEVEL_FILTERS = [
|
||||
{ value: null, label: '全部等级' },
|
||||
{ value: 1, label: '1 级' },
|
||||
{ value: 2, label: '2 级' },
|
||||
{ value: 3, label: '3 级' },
|
||||
]
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listSectionFields(query)
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
|
||||
// A delete can empty the last page. Stepping back keeps the table from
|
||||
// showing "no data" while the pager still claims a page beyond the end.
|
||||
if (result.items.length === 0 && result.total > 0 && query.page > 1) {
|
||||
query.page -= 1
|
||||
await load()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search(): void {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
query.keyword = ''
|
||||
query.level = null
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onCreate(): void {
|
||||
editing.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onEdit(row: SectionField): void {
|
||||
editing.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onDelete(row: SectionField): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除字段「${row.name}」?`, '删除字段', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return // dismissed
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSectionField(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBatchDelete(): Promise<void> {
|
||||
const ids = selection.value.map((row) => row.id)
|
||||
if (ids.length === 0) return
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个字段?`, '批量删除', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return // dismissed
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await batchDeleteSectionFields(ids)
|
||||
ElMessage.success(`已删除 ${result.deleted} 个字段`)
|
||||
selection.value = []
|
||||
await load()
|
||||
} catch (error) {
|
||||
// Refusals list every field still in use, so this is worth showing whole.
|
||||
ElMessage.error(errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">字段库</span>
|
||||
<span class="card-subtitle">
|
||||
字段是全局复用的,可被任意多个模板以任意顺序引用
|
||||
</span>
|
||||
</div>
|
||||
<el-tag type="info" effect="plain">共 {{ total }} 个</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="搜索字段名称"
|
||||
clearable
|
||||
class="search"
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-select v-model="query.level" class="level-filter" @change="search">
|
||||
<el-option
|
||||
v-for="item in LEVEL_FILTERS"
|
||||
:key="String(item.value)"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-button @click="search">查询</el-button>
|
||||
<el-button text @click="resetFilters">重置</el-button>
|
||||
|
||||
<div class="toolbar-spacer" />
|
||||
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:icon="Delete"
|
||||
:disabled="selection.length === 0"
|
||||
@click="onBatchDelete"
|
||||
>
|
||||
批量删除{{ selection.length ? `(${selection.length})` : '' }}
|
||||
</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="onCreate">新增字段</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="table"
|
||||
@selection-change="(value: SectionField[]) => (selection = value)"
|
||||
>
|
||||
<el-table-column type="selection" width="46" reserve-selection />
|
||||
|
||||
<el-table-column label="字段名称" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="name-cell" :style="{ paddingLeft: levelIndent(row.level) }">
|
||||
<span :style="typographyStyle(row)">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="等级" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.level }} 级</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="字体大小" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.font_size }} pt</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="字体颜色" width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="color-cell">
|
||||
<span class="swatch" :style="{ backgroundColor: row.font_color }" />
|
||||
<code>{{ row.font_color }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="更新时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="Edit" @click="onEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" :icon="Delete" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty :description="query.keyword ? '没有匹配的字段' : '字段库还是空的,先新增一个'">
|
||||
<el-button type="primary" :icon="Plus" @click="onCreate">新增字段</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="load"
|
||||
@size-change="search"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<FieldFormDialog v-model="dialogVisible" :field="editing" @saved="load" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.level-filter {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.color-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.color-cell code {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模板列表 — the table of every paper template.
|
||||
*
|
||||
* The full CRUD surface: search, create, edit, single and batch delete, with a
|
||||
* preview drawer that renders a template's outline exactly as it will read.
|
||||
* The drawer is the quickest way to confirm that a template's fields really do
|
||||
* come out in `sort` order before committing to write against it.
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Delete, Edit, Files, Plus, Refresh, Search, View } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import { errorMessage } from '@/api/client'
|
||||
import {
|
||||
batchDeleteTemplates,
|
||||
deleteTemplate,
|
||||
getTemplate,
|
||||
listTemplates,
|
||||
type TemplateDetail,
|
||||
type TemplateListItem,
|
||||
} from '@/api/templates'
|
||||
import TemplateFormDialog from '@/components/templates/TemplateFormDialog.vue'
|
||||
import TemplateOutline from '@/components/templates/TemplateOutline.vue'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<TemplateListItem[]>([])
|
||||
const total = ref(0)
|
||||
const selection = ref<TemplateListItem[]>([])
|
||||
|
||||
const query = reactive({ keyword: '', page: 1, page_size: 20 })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref<TemplateListItem | null>(null)
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const preview = ref<TemplateDetail | null>(null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listTemplates(query)
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
|
||||
// Deleting the last row of the last page would otherwise leave the table
|
||||
// empty while the pager still points past the end.
|
||||
if (result.items.length === 0 && result.total > 0 && query.page > 1) {
|
||||
query.page -= 1
|
||||
await load()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search(): void {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
query.keyword = ''
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onCreate(): void {
|
||||
editing.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onEdit(row: TemplateListItem): void {
|
||||
editing.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onPreview(row: TemplateListItem): Promise<void> {
|
||||
previewVisible.value = true
|
||||
previewLoading.value = true
|
||||
preview.value = null
|
||||
try {
|
||||
preview.value = await getTemplate(row.id)
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error))
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row: TemplateListItem): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除模板「${row.name}」?字段库中的字段不会被删除。`,
|
||||
'删除模板',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return // dismissed
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTemplate(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBatchDelete(): Promise<void> {
|
||||
const ids = selection.value.map((row) => row.id)
|
||||
if (ids.length === 0) return
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除选中的 ${ids.length} 个模板?字段库中的字段不会被删除。`,
|
||||
'批量删除',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return // dismissed
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await batchDeleteTemplates(ids)
|
||||
ElMessage.success(`已删除 ${result.deleted} 个模板`)
|
||||
selection.value = []
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">模板列表</span>
|
||||
<span class="card-subtitle">模板 = 一组按 sort 排好序的段落字段</span>
|
||||
</div>
|
||||
<el-tag type="info" effect="plain">共 {{ total }} 个</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="搜索模板名称或摘要"
|
||||
clearable
|
||||
class="search"
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-button @click="search">查询</el-button>
|
||||
<el-button text @click="resetFilters">重置</el-button>
|
||||
|
||||
<div class="toolbar-spacer" />
|
||||
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:icon="Delete"
|
||||
:disabled="selection.length === 0"
|
||||
@click="onBatchDelete"
|
||||
>
|
||||
批量删除{{ selection.length ? `(${selection.length})` : '' }}
|
||||
</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="onCreate">新建模板</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="table"
|
||||
@selection-change="(value: TemplateListItem[]) => (selection = value)"
|
||||
>
|
||||
<el-table-column type="selection" width="46" reserve-selection />
|
||||
|
||||
<el-table-column label="模板名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" class="name-link" @click="onPreview(row)">
|
||||
{{ row.name }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="摘要" min-width="260" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.abstract">{{ row.abstract }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="段落数" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.field_count }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="更新时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="View" @click="onPreview(row)">预览</el-button>
|
||||
<el-button link type="primary" :icon="Edit" @click="onEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" :icon="Delete" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty
|
||||
:description="query.keyword ? '没有匹配的模板' : '还没有模板,先创建一个'"
|
||||
>
|
||||
<el-button type="primary" :icon="Plus" @click="onCreate">新建模板</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="load"
|
||||
@size-change="search"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<TemplateFormDialog v-model="dialogVisible" :template="editing" @saved="load" />
|
||||
|
||||
<el-drawer
|
||||
v-model="previewVisible"
|
||||
:title="preview?.name ?? '模板预览'"
|
||||
size="min(560px, 92vw)"
|
||||
direction="rtl"
|
||||
>
|
||||
<div v-loading="previewLoading" class="preview">
|
||||
<template v-if="preview">
|
||||
<div class="preview-meta">
|
||||
<el-tag size="small" effect="plain">
|
||||
<el-icon><Files /></el-icon>
|
||||
{{ preview.fields.length }} 个段落
|
||||
</el-tag>
|
||||
<span class="muted">更新于 {{ formatDateTime(preview.updated_at) }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="preview.abstract" class="preview-abstract">{{ preview.abstract }}</p>
|
||||
|
||||
<el-divider content-position="left">
|
||||
大纲
|
||||
<span class="divider-hint">已按 sort 升序排列</span>
|
||||
</el-divider>
|
||||
|
||||
<TemplateOutline :fields="preview.fields" />
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.name-link {
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-abstract {
|
||||
margin: 12px 0 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.divider-hint {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user