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">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-container class="app-shell">
|
<el-container class="app-shell">
|
||||||
<el-header class="app-header">
|
<el-header class="app-header" height="60px">
|
||||||
<div class="brand">paper-doc</div>
|
<RouterLink to="/" class="brand">
|
||||||
<el-menu mode="horizontal" :ellipsis="false" router class="app-nav">
|
<span class="brand-mark"><AppLogo :size="20" /></span>
|
||||||
<el-menu-item index="/">Home</el-menu-item>
|
<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-menu>
|
||||||
<el-button link @click="appStore.toggleSidebar()">
|
|
||||||
{{ appStore.sidebarCollapsed ? 'Expand' : 'Collapse' }}
|
<!-- The mark repeated at the menu's right end, as a quiet signature. -->
|
||||||
</el-button>
|
<span class="brand-mark brand-mark--ghost" aria-hidden="true">
|
||||||
|
<AppLogo :size="16" />
|
||||||
|
</span>
|
||||||
</el-header>
|
</el-header>
|
||||||
|
|
||||||
<el-main class="app-main">
|
<el-container class="app-body">
|
||||||
<RouterView />
|
<el-main class="app-main">
|
||||||
</el-main>
|
<RouterView />
|
||||||
|
</el-main>
|
||||||
|
|
||||||
|
<SectionAside v-if="section" :section="section" />
|
||||||
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Sizing and scrolling belong to the global shell contract in
|
Sizing and scrolling belong to the global shell contract in
|
||||||
src/styles/base.css (.app-shell, .app-header, .app-main). Only the chrome
|
src/styles/base.css (.app-shell, .app-header, .app-body, .app-main,
|
||||||
drawn inside the header is styled here.
|
.app-aside). Only the chrome drawn inside the header is styled here.
|
||||||
-->
|
-->
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-header {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 20px;
|
||||||
border-bottom: 1px solid var(--el-border-color);
|
border-bottom: 1px solid var(--el-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.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-weight: 600;
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav {
|
.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 {
|
* Normalized failure, so views never have to inspect `AxiosError`.
|
||||||
message: string
|
*
|
||||||
status?: number
|
* It extends `Error` rather than being a plain object: call sites do
|
||||||
detail?: unknown
|
* `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 {
|
function normalizeError(error: AxiosError): ApiError {
|
||||||
const status = error.response?.status
|
const status = error.response?.status
|
||||||
const payload = error.response?.data as { detail?: unknown } | undefined
|
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
|
let message: string
|
||||||
if (payload?.detail && typeof payload.detail === 'string') {
|
if (typeof detail === 'string') {
|
||||||
message = payload.detail
|
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') {
|
} else if (error.code === 'ECONNABORTED') {
|
||||||
message = 'The request timed out.'
|
message = '请求超时,请稍后重试。'
|
||||||
} else if (!error.response) {
|
} else if (!error.response) {
|
||||||
message = 'Could not reach the API. Is the backend running on port 8000?'
|
message = '无法连接后端服务,请确认后端已在 8000 端口运行。'
|
||||||
} else {
|
} else {
|
||||||
message = error.message
|
message = error.message
|
||||||
}
|
}
|
||||||
|
|
||||||
return { message, status, detail: payload?.detail }
|
return new ApiError(message, status, detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
@@ -45,4 +71,12 @@ http.interceptors.response.use(
|
|||||||
(error: AxiosError) => Promise.reject(normalizeError(error)),
|
(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
|
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'
|
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({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -7,15 +14,43 @@ const router = createRouter({
|
|||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: () => import('@/views/HomeView.vue'),
|
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(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'not-found',
|
name: 'not-found',
|
||||||
component: () => import('@/views/NotFoundView.vue'),
|
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
|
export default router
|
||||||
|
|||||||
@@ -6,28 +6,24 @@ import { ref } from 'vue'
|
|||||||
*
|
*
|
||||||
* Persistence is opt-in per store via the `persist` option; the plugin itself
|
* 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
|
* 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(
|
export const useAppStore = defineStore(
|
||||||
'app',
|
'app',
|
||||||
() => {
|
() => {
|
||||||
const sidebarCollapsed = ref(false)
|
/** Whether the second-level menu on the right is collapsed to its rail. */
|
||||||
const lastCheckedAt = ref<string | null>(null)
|
const asideCollapsed = ref(false)
|
||||||
|
|
||||||
function toggleSidebar(): void {
|
function toggleAside(): void {
|
||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
asideCollapsed.value = !asideCollapsed.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function markChecked(): void {
|
return { asideCollapsed, toggleAside }
|
||||||
lastCheckedAt.value = new Date().toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sidebarCollapsed, lastCheckedAt, toggleSidebar, markChecked }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
persist: {
|
persist: {
|
||||||
key: 'paper-doc:app',
|
key: 'paper-doc:app',
|
||||||
pick: ['sidebarCollapsed', 'lastCheckedAt'],
|
pick: ['asideCollapsed'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,9 +78,17 @@ samp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- application shell ------------------------------------------------------
|
/* --- application shell ------------------------------------------------------
|
||||||
.app-shell and .app-main are applied by src/App.vue. They live here because
|
.app-shell, .app-body, .app-main and .app-aside are applied by src/App.vue
|
||||||
the viewport contract spans html -> body -> #app -> shell -> main and only
|
and src/components/SectionAside.vue. They live here because the viewport
|
||||||
holds together when it is declared in one place.
|
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 {
|
.app-shell {
|
||||||
@@ -98,10 +106,21 @@ samp {
|
|||||||
flex: 0 0 auto;
|
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 {
|
.app-main {
|
||||||
/* min-height: 0 lets this flex child actually shrink, so overflow is
|
/* min-height: 0 lets this flex child actually shrink, so overflow is
|
||||||
contained here instead of escaping to the window. */
|
contained here instead of escaping to the window. */
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -115,24 +134,40 @@ samp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* WebKit / Blink. */
|
/* WebKit / Blink. */
|
||||||
.app-main::-webkit-scrollbar {
|
.app-main::-webkit-scrollbar,
|
||||||
|
.app-aside::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main::-webkit-scrollbar-track {
|
.app-main::-webkit-scrollbar-track,
|
||||||
|
.app-aside::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main::-webkit-scrollbar-thumb {
|
.app-main::-webkit-scrollbar-thumb,
|
||||||
|
.app-aside::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--el-border-color-darker);
|
background-color: var(--el-border-color-darker);
|
||||||
border-radius: 4px;
|
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);
|
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 -----------------------------------------------------
|
/* --- page-level helpers -----------------------------------------------------
|
||||||
A view's outermost element uses .page to get a consistent vertical rhythm
|
A view's outermost element uses .page to get a consistent vertical rhythm
|
||||||
inside the main gutter. */
|
inside the main gutter. */
|
||||||
@@ -143,3 +178,24 @@ samp {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-width: 0;
|
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">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
/**
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
* Welcome page.
|
||||||
import { ElMessage } from 'element-plus'
|
*
|
||||||
|
* 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 { listSectionFields } from '@/api/sectionFields'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { listTemplates } from '@/api/templates'
|
||||||
|
import AppLogo from '@/components/AppLogo.vue'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const router = useRouter()
|
||||||
|
|
||||||
const health = ref<HealthResponse | null>(null)
|
const templateCount = ref<number | null>(null)
|
||||||
const loading = ref(false)
|
const fieldCount = ref<number | null>(null)
|
||||||
|
|
||||||
const databaseTagType = computed(() => (health.value?.database === 'ok' ? 'success' : 'danger'))
|
/** `null` renders as an em dash: unknown, not zero. */
|
||||||
|
function display(value: number | null): string {
|
||||||
async function loadHealth(): Promise<void> {
|
return value === null ? '—' : String(value)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page welcome">
|
||||||
<el-card shadow="never">
|
<section class="hero">
|
||||||
<template #header>
|
<span class="hero-mark"><AppLogo :size="40" /></span>
|
||||||
<div class="card-header">
|
<h1 class="hero-title">欢迎访问</h1>
|
||||||
<span>Backend connectivity</span>
|
<p class="hero-subtitle">先配置好论文模板,写作时只需对着结构填内容。</p>
|
||||||
<el-button :icon="Refresh" :loading="loading" @click="loadHealth">Re-check</el-button>
|
</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>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
<el-divider direction="vertical" class="stat-divider" />
|
||||||
<el-descriptions v-if="health" :column="1" border>
|
<div class="stat">
|
||||||
<el-descriptions-item label="API">
|
<el-icon class="stat-icon"><Files /></el-icon>
|
||||||
{{ health.app }}
|
<div>
|
||||||
<el-tag type="success" size="small">{{ health.status }}</el-tag>
|
<div class="stat-value">{{ display(templateCount) }}</div>
|
||||||
</el-descriptions-item>
|
<div class="stat-label">模板</div>
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<p>
|
<div class="stat-hint">
|
||||||
Last probe:
|
字段可以自由组合进模板,显示顺序完全由每个字段的 sort 决定。
|
||||||
<strong>{{ appStore.lastCheckedAt ?? 'never' }}</strong>
|
</div>
|
||||||
</p>
|
|
||||||
<p class="hint">
|
|
||||||
Both values are stored in <code>localStorage</code> under
|
|
||||||
<code>paper-doc:app</code>. Reload the page — they survive.
|
|
||||||
</p>
|
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.card-header {
|
.welcome {
|
||||||
display: flex;
|
gap: 24px;
|
||||||
align-items: center;
|
padding-top: 24px;
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
color: var(--el-text-color-secondary);
|
||||||
font-size: 13px;
|
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>
|
</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