Browse Source

fix(ui): improve admin layout, comment section, article list, and article detail

- CommentSection: auto-fill username/email when logged in, readonly fields, dashed dividers
- SongInput: add disabled/readonly props with visual styling
- AdminLayout: restore sticky topbar with page title, remove sidebar title/foot, full-height sidebar
- App.vue: sticky header, page title, wave decoration, fix dark mode toggle label (日/夜)
- ArticleListView: adjust column widths (status 80px, actions 160px), replace text buttons with SVG icons
- ArticleDetailView: import github-markdown-css, use markdown-body class, DOMPurify for HTML, Noto Serif SC headings
main
Mohan 1 month ago
parent
commit
2cab3995e4
  1. 59
      src/App.vue
  2. 27
      src/components/CommentSection.vue
  3. 15
      src/components/SongInput.vue
  4. 24
      src/views/ArticleDetailView.vue
  5. 86
      src/views/admin/AdminLayout.vue
  6. 75
      src/views/admin/ArticleListView.vue

59
src/App.vue

@ -1,12 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
const theme = useTheme() const theme = useTheme()
const pageTitle = computed(() => {
const t = route.meta.title as string | undefined
return t || ''
})
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
async function handleLogout() { async function handleLogout() {
try { try {
await auth.logout() await auth.logout()
@ -21,10 +30,12 @@ async function handleLogout() {
<div> <div>
<header class="app-header"> <header class="app-header">
<router-link to="/" class="brand">Mach-CMS</router-link> <router-link to="/" class="brand">Mach-CMS</router-link>
<span v-if="pageTitle && !isAdminRoute" class="header-page-title">{{ pageTitle }}</span>
<nav class="nav-links"> <nav class="nav-links">
<router-link to="/">首页</router-link> <router-link to="/">首页</router-link>
<router-link to="/search">搜索</router-link> <router-link to="/search">搜索</router-link>
<router-link v-if="auth.isLoggedIn" to="/admin">管理后台</router-link> <router-link v-if="auth.isLoggedIn" to="/admin">管理后台</router-link>
<router-link v-if="auth.isAdmin" to="/admin/comments">评论审核</router-link>
<span v-if="auth.username" class="username">{{ auth.username }}</span> <span v-if="auth.username" class="username">{{ auth.username }}</span>
<a v-if="auth.isLoggedIn" href="#" @click.prevent="handleLogout">退出</a> <a v-if="auth.isLoggedIn" href="#" @click.prevent="handleLogout">退出</a>
<router-link v-else to="/login">登录</router-link> <router-link v-else to="/login">登录</router-link>
@ -34,15 +45,20 @@ async function handleLogout() {
type="button" type="button"
:title="theme.isDark ? '切至天青昼' : '切至黛墨夜'" :title="theme.isDark ? '切至天青昼' : '切至黛墨夜'"
@click="theme.toggle()" @click="theme.toggle()"
>{{ theme.isDark ? '' : '夜' }}</button>
>{{ theme.isDark ? '' : '夜' }}</button>
</header> </header>
<main class="app-main">
<main class="app-main" :class="{ 'app-main--admin': isAdminRoute }">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="page" mode="out-in"> <transition name="page" mode="out-in">
<component :is="Component" /> <component :is="Component" />
</transition> </transition>
</router-view> </router-view>
</main> </main>
<footer class="wave-decoration" aria-hidden="true">
<svg viewBox="0 0 1440 80" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 40c240 40 480-40 720 0s480 40 720 0v40H0z" fill="currentColor"/>
</svg>
</footer>
</div> </div>
</template> </template>
@ -52,6 +68,9 @@ async function handleLogout() {
} }
.app-header { .app-header {
position: sticky;
top: 0;
z-index: 1000;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 20px;
@ -63,6 +82,13 @@ async function handleLogout() {
transition: background-color var(--song-dur-2) var(--song-ease), color var(--song-dur-2) var(--song-ease); transition: background-color var(--song-dur-2) var(--song-ease), color var(--song-dur-2) var(--song-ease);
} }
.header-page-title {
font-family: var(--song-font-serif);
font-size: 16px;
font-weight: 600;
color: var(--ac-header-text);
}
.app-header .brand { .app-header .brand {
display: flex; display: flex;
align-items: center; align-items: center;
@ -133,8 +159,33 @@ async function handleLogout() {
} }
.app-main { .app-main {
max-width: 960px;
margin: 24px auto; margin: 24px auto;
padding: 0 16px; padding: 0 16px;
min-height: calc(100vh - 60px - 80px);
max-width: 960px;
}
.app-main--admin {
max-width: none;
margin: 0;
padding: 0;
min-height: calc(100vh - 60px);
}
.wave-decoration {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 80px;
pointer-events: none;
z-index: -1;
color: var(--ac-primary);
opacity: 0.08;
}
.wave-decoration svg {
width: 100%;
height: 100%;
} }
</style> </style>

27
src/components/CommentSection.vue

@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { commentApi } from '@/api/client' import { commentApi } from '@/api/client'
import type { CommentResponse } from '@/generated/api' import type { CommentResponse } from '@/generated/api'
import { useAuthStore } from '@/stores/auth'
import SongButton from '@/components/SongButton.vue' import SongButton from '@/components/SongButton.vue'
import SongInput from '@/components/SongInput.vue' import SongInput from '@/components/SongInput.vue'
@ -16,6 +17,17 @@ const error = ref<string | null>(null)
const submitMessage = ref<string | null>(null) const submitMessage = ref<string | null>(null)
const formOpen = ref(false) const formOpen = ref(false)
const authStore = useAuthStore()
const isLoggedIn = computed(() => authStore.isLoggedIn)
function autoFillUser() {
if (isLoggedIn.value && authStore.username) {
authorName.value = authStore.username
}
}
const readonlyFields = computed(() => isLoggedIn.value)
const authorName = ref('') const authorName = ref('')
const authorEmail = ref('') const authorEmail = ref('')
const content = ref('') const content = ref('')
@ -78,6 +90,7 @@ async function onSubmit() {
function openForm() { function openForm() {
error.value = null error.value = null
autoFillUser()
formOpen.value = true formOpen.value = true
} }
@ -110,7 +123,10 @@ async function toggleCommentStar(comment: CommentResponse) {
} }
} }
onMounted(load)
onMounted(() => {
autoFillUser()
load()
})
</script> </script>
<template> <template>
@ -143,8 +159,8 @@ onMounted(load)
<transition name="form-fade"> <transition name="form-fade">
<form v-if="formOpen" class="comment-form" @submit.prevent="onSubmit"> <form v-if="formOpen" class="comment-form" @submit.prevent="onSubmit">
<p v-if="error" class="error-text">{{ error }}</p> <p v-if="error" class="error-text">{{ error }}</p>
<SongInput v-model="authorName" label="昵称" required placeholder="你的署名" :maxlength="100" />
<SongInput v-model="authorEmail" label="邮箱(可选)" type="email" placeholder="用于联系,不公开" />
<SongInput v-model="authorName" label="昵称" required placeholder="你的署名" :maxlength="100" :readonly="readonlyFields" :disabled="readonlyFields" />
<SongInput v-model="authorEmail" label="邮箱(可选)" type="email" placeholder="用于联系,不公开" :readonly="readonlyFields" :disabled="readonlyFields" />
<SongInput v-model="content" label="内容" textarea :rows="4" required placeholder="写下你的评语..." :maxlength="2000" /> <SongInput v-model="content" label="内容" textarea :rows="4" required placeholder="写下你的评语..." :maxlength="2000" />
<div class="comment-form-actions"> <div class="comment-form-actions">
<SongButton type="text" size="small" @click="formOpen = false"></SongButton> <SongButton type="text" size="small" @click="formOpen = false"></SongButton>
@ -191,6 +207,11 @@ onMounted(load)
gap: 16px; gap: 16px;
} }
.comment-item:not(:last-child) .comment-card {
border-bottom: 1px dashed #d4c4b0;
padding-bottom: 18px;
}
.comment-node { .comment-node {
width: 11px; width: 11px;
height: 11px; height: 11px;

15
src/components/SongInput.vue

@ -9,8 +9,10 @@ withDefaults(
rows?: number rows?: number
textarea?: boolean textarea?: boolean
maxlength?: number maxlength?: number
disabled?: boolean
readonly?: boolean
}>(), }>(),
{ label: '', modelValue: '', placeholder: '', type: 'text', required: false, rows: 3, textarea: false, maxlength: undefined }
{ label: '', modelValue: '', placeholder: '', type: 'text', required: false, rows: 3, textarea: false, maxlength: undefined, disabled: false, readonly: false }
) )
defineEmits<{ 'update:modelValue': [value: string] }>() defineEmits<{ 'update:modelValue': [value: string] }>()
defineOptions({ name: 'SongInput' }) defineOptions({ name: 'SongInput' })
@ -28,6 +30,8 @@ defineOptions({ name: 'SongInput' })
:placeholder="placeholder" :placeholder="placeholder"
:rows="rows" :rows="rows"
:maxlength="maxlength" :maxlength="maxlength"
:disabled="disabled"
:readonly="readonly"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)" @input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/> />
<input <input
@ -37,6 +41,8 @@ defineOptions({ name: 'SongInput' })
:value="modelValue" :value="modelValue"
:placeholder="placeholder" :placeholder="placeholder"
:maxlength="maxlength" :maxlength="maxlength"
:disabled="disabled"
:readonly="readonly"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)" @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/> />
</label> </label>
@ -91,4 +97,11 @@ defineOptions({ name: 'SongInput' })
outline: none; outline: none;
border-color: var(--song-primary); border-color: var(--song-primary);
} }
.song-field__input:disabled,
.song-field__input[readonly] {
background: var(--ac-surface);
color: var(--ac-text-secondary);
cursor: not-allowed;
}
</style> </style>

24
src/views/ArticleDetailView.vue

@ -3,11 +3,13 @@ import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { articleApi } from '@/api/client' import { articleApi } from '@/api/client'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import DOMPurify from 'dompurify'
import { setPageMeta } from '@/utils/seo' import { setPageMeta } from '@/utils/seo'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import CommentSection from '@/components/CommentSection.vue' import CommentSection from '@/components/CommentSection.vue'
import ArticleMetaBar from '@/components/ArticleMetaBar.vue' import ArticleMetaBar from '@/components/ArticleMetaBar.vue'
import type { ArticleDetail, ResponseArticleDetail } from '@/generated/api' import type { ArticleDetail, ResponseArticleDetail } from '@/generated/api'
import 'github-markdown-css/github-markdown-light.css'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -23,8 +25,17 @@ const starred = ref(false)
const starCount = ref(0) const starCount = ref(0)
const starBusy = ref(false) const starBusy = ref(false)
const isHtmlContent = computed(() => {
const c = article.value?.content
if (!c) return false
return /<[a-z][\s\S]*>/i.test(c)
})
const renderedContent = computed(() => { const renderedContent = computed(() => {
if (!article.value?.content) return '' if (!article.value?.content) return ''
if (isHtmlContent.value) {
return DOMPurify.sanitize(article.value.content)
}
return renderMarkdown(article.value.content) return renderMarkdown(article.value.content)
}) })
@ -100,7 +111,7 @@ watch(slug, load, { immediate: true })
<div class="detail-tags"> <div class="detail-tags">
<span v-for="tag in article.tagNames" :key="tag" class="tag">{{ tag }}</span> <span v-for="tag in article.tagNames" :key="tag" class="tag">{{ tag }}</span>
</div> </div>
<div class="article-content" v-html="renderedContent"></div>
<div class="article-content markdown-body" v-html="renderedContent"></div>
<CommentSection :slug="slug" /> <CommentSection :slug="slug" />
</article> </article>
</template> </template>
@ -119,6 +130,7 @@ watch(slug, load, { immediate: true })
letter-spacing: 0.02em; letter-spacing: 0.02em;
margin: 0 0 12px; margin: 0 0 12px;
font-size: 28px; font-size: 28px;
line-height: 1.5;
} }
.detail-summary { .detail-summary {
@ -152,6 +164,16 @@ watch(slug, load, { immediate: true })
border-top: 1px solid var(--ac-border); border-top: 1px solid var(--ac-border);
padding-top: 20px; padding-top: 20px;
word-break: break-word; word-break: break-word;
background: transparent;
color: var(--ac-text);
font-family: var(--song-font-sans), system-ui, sans-serif;
}
:deep(.markdown-body h1),
:deep(.markdown-body h2),
:deep(.markdown-body h3),
:deep(.markdown-body h4) {
font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif;
} }
.hint-text { .hint-text {

86
src/views/admin/AdminLayout.vue

@ -1,45 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { useTheme } from '@/composables/useTheme'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter()
const theme = useTheme()
const route = useRoute()
async function handleLogout() {
try {
await auth.logout()
} catch (e) {
console.error(e)
}
router.push('/login')
}
const pageTitle = computed(() => (route.meta.title as string) || '')
</script> </script>
<template> <template>
<div class="admin-layout"> <div class="admin-layout">
<aside class="sidebar"> <aside class="sidebar">
<h2 class="sidebar-title">管理后台</h2>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<router-link to="/admin/articles">文章管理</router-link> <router-link to="/admin/articles">文章管理</router-link>
<router-link to="/admin/tags">标签管理</router-link> <router-link to="/admin/tags">标签管理</router-link>
<router-link v-if="auth.isAdmin" to="/admin/users">用户管理</router-link> <router-link v-if="auth.isAdmin" to="/admin/users">用户管理</router-link>
<router-link v-if="auth.isAdmin" to="/admin/comments">评论审核</router-link> <router-link v-if="auth.isAdmin" to="/admin/comments">评论审核</router-link>
</nav> </nav>
<div class="sidebar-foot">
<button
class="theme-toggle"
type="button"
:title="theme.isDark ? '切至天青昼' : '切至黛墨夜'"
@click="theme.toggle()"
>{{ theme.isDark ? '昼' : '夜' }}</button>
</div>
</aside> </aside>
<div class="content-wrap"> <div class="content-wrap">
<header class="topbar"> <header class="topbar">
<span class="current-user">{{ auth.username || '管理员' }}</span>
<a href="#" class="logout-link" @click.prevent="handleLogout">退出</a>
<span class="topbar-title">{{ pageTitle }}</span>
</header> </header>
<main class="content"> <main class="content">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
@ -63,19 +45,9 @@ async function handleLogout() {
flex-shrink: 0; flex-shrink: 0;
background: var(--ac-header-bg); background: var(--ac-header-bg);
padding: 20px 12px; padding: 20px 12px;
display: flex;
flex-direction: column;
transition: background-color var(--song-dur-2) var(--song-ease); transition: background-color var(--song-dur-2) var(--song-ease);
} }
.sidebar-title {
color: var(--ac-header-text);
font-size: 15px;
font-weight: 400;
margin: 0 0 16px;
padding-left: 8px;
}
.sidebar-nav { .sidebar-nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -98,31 +70,6 @@ async function handleLogout() {
color: var(--ac-header-text); color: var(--ac-header-text);
} }
.sidebar-foot {
margin-top: auto;
padding: 12px 8px 0;
}
.theme-toggle {
width: 100%;
height: 34px;
border: 1px solid var(--ac-border);
border-radius: var(--song-radius-m);
background: transparent;
color: var(--ac-header-muted);
font-family: var(--song-font-serif);
font-size: 15px;
letter-spacing: 0.1em;
cursor: pointer;
transition: color var(--song-dur-1) var(--song-ease), border-color var(--song-dur-1) var(--song-ease), background var(--song-dur-1) var(--song-ease);
}
.theme-toggle:hover {
color: var(--ac-header-text);
border-color: var(--ac-header-muted);
background: var(--ac-header-active-bg);
}
.content-wrap { .content-wrap {
flex: 1; flex: 1;
display: flex; display: flex;
@ -131,28 +78,25 @@ async function handleLogout() {
} }
.topbar { .topbar {
position: sticky;
top: 0;
z-index: 1000;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 16px;
height: 52px;
padding: 0 20px; padding: 0 20px;
height: 52px;
background: var(--ac-bg-card); background: var(--ac-bg-card);
border-bottom: 1px solid var(--ac-border); border-bottom: 1px solid var(--ac-border);
} }
.current-user {
color: var(--ac-text-secondary);
font-size: 14px;
}
.logout-link {
color: var(--ac-danger);
text-decoration: none;
font-size: 14px;
.topbar-title {
font-family: var(--song-font-serif);
font-size: 16px;
font-weight: 600;
color: var(--ac-text);
} }
.content { .content {
padding: 20px; padding: 20px;
} }
</style>
</style>

75
src/views/admin/ArticleListView.vue

@ -140,7 +140,7 @@ onMounted(load)
<tbody> <tbody>
<tr v-for="article in articles" :key="article.id"> <tr v-for="article in articles" :key="article.id">
<td class="title-cell">{{ article.title }}</td> <td class="title-cell">{{ article.title }}</td>
<td>
<td class="status-cell">
<span <span
class="status-badge" class="status-badge"
:class="{ :class="{
@ -152,17 +152,32 @@ onMounted(load)
{{ statusLabel(article.status) }} {{ statusLabel(article.status) }}
</span> </span>
</td> </td>
<td>{{ formatDate(article.publishedAt || article.createdAt) }}</td>
<td>
<td class="date-cell">{{ formatDate(article.publishedAt || article.createdAt) }}</td>
<td class="actions-cell">
<div class="actions"> <div class="actions">
<button class="btn-link" @click="onEdit(article)">编辑</button>
<button v-if="article.status === ArticleStatus.Draft" class="btn-link" @click="onPublish(article)">
发布
<button class="icon-btn" type="button" title="编辑" @click="onEdit(article)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
</button> </button>
<button v-if="article.status === ArticleStatus.Published" class="btn-link" @click="onArchive(article)">
归档
<button v-if="article.status === ArticleStatus.Draft" class="icon-btn" type="button" title="发布" @click="onPublish(article)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12l5 5L20 7" />
</svg>
</button>
<button v-if="article.status === ArticleStatus.Published" class="icon-btn" type="button" title="归档" @click="onArchive(article)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 8L12 3 2 8v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8z" />
<path d="M2 8l10 5 10-5" />
</svg>
</button>
<button class="icon-btn icon-btn--danger" type="button" title="删除" @click="onDelete(article)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button> </button>
<button class="btn-link danger" @click="onDelete(article)">删除</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -207,6 +222,7 @@ onMounted(load)
.article-table { .article-table {
width: 100%; width: 100%;
table-layout: fixed;
border-collapse: collapse; border-collapse: collapse;
background: var(--ac-bg-card); background: var(--ac-bg-card);
border: 1px solid var(--ac-border); border: 1px solid var(--ac-border);
@ -227,6 +243,23 @@ onMounted(load)
color: var(--ac-text-secondary); color: var(--ac-text-secondary);
} }
.status-cell {
width: 80px;
min-width: 80px;
white-space: nowrap;
}
.date-cell {
width: 200px;
white-space: nowrap;
}
.actions-cell {
width: 160px;
min-width: 160px;
white-space: nowrap;
}
.title-cell { .title-cell {
font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif; font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif;
font-weight: 600; font-weight: 600;
@ -255,22 +288,36 @@ onMounted(load)
.actions { .actions {
display: flex; display: flex;
gap: 10px;
align-items: center;
gap: 8px;
} }
.btn-link {
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none; border: none;
border-radius: var(--song-radius-s);
background: none; background: none;
color: var(--ac-primary-ink); color: var(--ac-primary-ink);
cursor: pointer; cursor: pointer;
font-size: 13px;
padding: 0;
transition: background var(--song-dur-1) var(--song-ease), color var(--song-dur-1) var(--song-ease);
} }
.btn-link.danger {
.icon-btn:hover {
background: var(--ac-active);
}
.icon-btn--danger {
color: var(--ac-danger-ink); color: var(--ac-danger-ink);
} }
.icon-btn--danger:hover {
background: var(--ac-danger-bg);
}
.error-text { .error-text {
color: var(--ac-danger); color: var(--ac-danger);
} }

Loading…
Cancel
Save