mach-cms的前台,用vue写的
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

597 lines
16 KiB

<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { commentApi, commentAdminApi } from '@/api/client'
import type { CommentResponse } from '@/generated/api'
import { useAuthStore } from '@/stores/auth'
import SongButton from '@/components/SongButton.vue'
import SongInput from '@/components/SongInput.vue'
const props = defineProps<{
slug: string
}>()
const comments = ref<CommentResponse[]>([])
const loading = ref(false)
const submitting = ref(false)
const error = ref<string | null>(null)
const submitMessage = ref<string | null>(null)
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 authorEmail = ref('')
const content = ref('')
const quotedComment = ref<CommentResponse | null>(null)
const overflowIds = ref<Set<number>>(new Set())
const expandedIds = ref<Set<number>>(new Set())
const contentEls = new Map<number, HTMLElement>()
function collectContentEl(id: number, el: unknown) {
if (el) contentEls.set(id, el as HTMLElement)
else contentEls.delete(id)
}
function detectOverflow() {
const next = new Set<number>()
contentEls.forEach((node, id) => {
const lh = parseFloat(getComputedStyle(node).lineHeight) || 22
if (node.scrollHeight > lh * 4 + 2) next.add(id)
})
overflowIds.value = next
}
function toggleExpand(id: number) {
const next = new Set(expandedIds.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
expandedIds.value = next
}
function escapeBrackets(text: string): string {
return text.replace(/[<>]/g, (ch) => '\\' + ch)
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function renderCommentContent(text: string): string {
let html = ''
let rest = text
const refMatch = text.match(/^<ref:(\d+)>/)
if (refMatch) {
const id = Number(refMatch[1])
const quoted = comments.value.find((c) => c.id === id)
const excerpt = quoted ? quoteExcerpt(quoted) : `评论 #${id}`
html += `<span class="comment-ref-tag"><svg class="comment-ref-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>${escapeHtml(excerpt.replace(/\\</g, '<').replace(/\\>/g, '>'))}</span>`
rest = text.slice(refMatch[0].length)
}
const body = escapeHtml(rest.replace(/\\</g, '<').replace(/\\>/g, '>'))
return html + body
}
function quoteExcerpt(comment: CommentResponse): string {
const raw = comment.content ?? ''
const stripped = raw.replace(/^<ref:\d+>/, '')
const s = stripped.replace(/\s+/g, ' ').trim()
const content = s.length > 30 ? s.slice(0, 30) + '…' : s
return `${comment.authorName ?? ''}${content}`
}
function quoteComment(comment: CommentResponse) {
quotedComment.value = comment
if (!formOpen.value) {
openForm()
}
}
const sortedComments = computed(() =>
[...comments.value].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
)
function formatDate(iso?: string): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
if (!props.slug) return
loading.value = true
error.value = null
try {
const resp = await commentApi.apiArticlesSlugCommentsGet(props.slug)
comments.value = resp.data.data ?? []
} catch (e) {
console.error(e)
error.value = '评论加载失败'
} finally {
loading.value = false
}
await nextTick()
detectOverflow()
}
async function onSubmit() {
if (!authorName.value.trim() || !content.value.trim()) {
error.value = '昵称和内容为必填项'
return
}
submitting.value = true
error.value = null
submitMessage.value = null
try {
let body = escapeBrackets(content.value.trim())
if (quotedComment.value?.id != null) {
body = `<ref:${quotedComment.value.id}>${body}`
}
await commentApi.apiArticlesSlugCommentsPost(props.slug, {
authorName: authorName.value.trim(),
authorEmail: authorEmail.value.trim() || undefined,
content: body
})
content.value = ''
authorName.value = ''
authorEmail.value = ''
quotedComment.value = null
submitMessage.value = '评论已提交,等待审核'
formOpen.value = false
load()
} catch (e) {
console.error(e)
error.value = '评论提交失败'
} finally {
submitting.value = false
}
}
function openForm() {
error.value = null
autoFillUser()
formOpen.value = true
}
function canDelete(comment: CommentResponse): boolean {
if (authStore.isAdmin) return true
if (!isLoggedIn.value) return false
return comment.authorName === authStore.username
}
async function deleteComment(comment: CommentResponse) {
const id = comment.id
if (id == null) return
if (!window.confirm(`确定删除评论?`)) return
try {
await commentAdminApi.apiAdminCommentsIdDelete(id)
comments.value = comments.value.filter((c) => c.id !== id)
} catch (e) {
console.error(e)
error.value = '删除失败'
}
}
const commentStarBusy = ref<Set<number>>(new Set())
async function toggleCommentStar(comment: CommentResponse) {
if (!localStorage.getItem('token')) {
window.location.href = '/login'
return
}
const id = comment.id!
if (commentStarBusy.value.has(id)) return
commentStarBusy.value = new Set([...commentStarBusy.value, id])
const was = comment.hasStarred ?? false
comment.hasStarred = !was
comment.starCount = (comment.starCount ?? 0) + (was ? -1 : 1)
try {
if (was) {
await commentApi.apiCommentsIdStarDelete(id)
} else {
await commentApi.apiCommentsIdStarPost(id)
}
} catch {
comment.hasStarred = was
comment.starCount = (comment.starCount ?? 0) + (was ? 1 : -1)
} finally {
const next = new Set(commentStarBusy.value)
next.delete(id)
commentStarBusy.value = next
}
}
onMounted(() => {
autoFillUser()
load()
})
</script>
<template>
<section class="comment-section">
<h3 class="comment-title">评论</h3>
<p v-if="loading" class="hint-text">加载中...</p>
<p v-else-if="sortedComments.length === 0" class="hint-text">暂无评论</p>
<div v-else class="comment-timeline">
<article v-for="comment in sortedComments" :key="comment.id" class="comment-item">
<span class="comment-node" aria-hidden="true" />
<div class="comment-card">
<div class="comment-meta">
<span class="comment-author">{{ comment.authorName }}</span>
<span class="comment-time">{{ formatDate(comment.createdAt) }}</span>
</div>
<p
class="comment-content"
:class="{ 'content-clamped': overflowIds.has(comment.id!) && !expandedIds.has(comment.id!) }"
:ref="(el: unknown) => collectContentEl(comment.id!, el)"
v-html="renderCommentContent(comment.content ?? '')"
></p>
<button
v-if="overflowIds.has(comment.id!)"
class="comment-toggle"
type="button"
@click="toggleExpand(comment.id!)"
>
{{ expandedIds.has(comment.id!) ? '收起' : '展开全文' }}
</button>
<button class="comment-quote" type="button" title="引用评论" @click="quoteComment(comment)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
<span>引用</span>
</button>
<button class="comment-star" :class="{ starred: comment.hasStarred }" @click="toggleCommentStar(comment)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/>
</svg>
<span>{{ comment.starCount ?? 0 }}</span>
</button>
<button v-if="canDelete(comment)" class="comment-delete" type="button" title="删除评论" @click="deleteComment(comment)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" 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>
<span>删除</span>
</button>
</div>
</article>
</div>
<div class="comment-write">
<p v-if="submitMessage" class="success-text">{{ submitMessage }}</p>
<SongButton v-if="!formOpen" type="secondary" size="medium" @click="openForm">
<svg class="comment-write-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>发表评论</span>
</SongButton>
<transition name="form-fade">
<form v-if="formOpen" class="comment-form" @submit.prevent="onSubmit">
<p v-if="error" class="error-text">{{ error }}</p>
<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" />
<div v-if="quotedComment" class="quote-preview">
<span class="quote-preview-label">引用</span>
<span class="quote-preview-author">{{ quotedComment.authorName }}</span>
<span class="quote-preview-text">{{ quoteExcerpt(quotedComment) }}</span>
<button class="quote-preview-clear" type="button" title="取消引用" aria-label="取消引用" @click="quotedComment = null">✕</button>
</div>
<SongInput v-model="content" label="内容" textarea :rows="4" required placeholder="写下你的评语..." :maxlength="2000" />
<div class="comment-form-actions">
<SongButton type="text" size="small" @click="formOpen = false">止</SongButton>
<SongButton type="primary" size="small" :disabled="submitting" :loading="submitting">
{{ submitting ? '提交中' : '提交评论' }}
</SongButton>
</div>
</form>
</transition>
</div>
</section>
</template>
<style scoped>
.comment-section {
margin-top: 40px;
}
.comment-title {
font-family: var(--song-font-serif);
font-size: 20px;
line-height: 28px;
font-weight: 600;
margin: 0 0 20px;
}
.comment-timeline {
position: relative;
margin: 0 0 24px;
}
.comment-timeline::before {
content: '';
position: absolute;
left: 5px;
top: 18px;
bottom: 10px;
width: 1px;
background: var(--ac-border-strong);
}
.comment-item {
display: flex;
gap: 16px;
padding-top: 8px;
}
.comment-item:not(:last-child) .comment-card {
border-bottom: 1px dashed var(--ac-border-strong);
padding-bottom: 18px;
}
.comment-node {
width: 11px;
height: 11px;
flex-shrink: 0;
margin-top: 18px;
border-radius: 50%;
border: 1px solid var(--ac-primary);
background: var(--ac-bg);
position: relative;
z-index: 1;
}
.comment-card {
flex: 1;
min-width: 0;
padding: 0 0 6px;
}
.comment-meta {
display: flex;
gap: 12px;
align-items: baseline;
margin-bottom: 4px;
}
.comment-author {
font-weight: 600;
font-size: 14px;
}
.comment-time {
color: var(--ac-text-disabled);
font-size: 12px;
}
.comment-content {
margin: 0;
font-size: 14px;
color: var(--ac-text);
line-height: 22px;
word-break: break-word;
white-space: pre-line;
}
.comment-content.content-clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
}
.comment-toggle {
display: block;
margin-top: 4px;
border: none;
background: none;
color: var(--ac-primary-ink);
cursor: pointer;
font-size: 12px;
padding: 2px 0;
transition: color var(--song-dur-1) var(--song-ease);
}
.comment-toggle:hover {
color: var(--ac-primary);
}
.comment-star {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 6px;
border: none;
background: none;
color: var(--ac-text-disabled);
cursor: pointer;
font-size: 12px;
padding: 2px 6px;
border-radius: var(--song-radius-s);
transition: color var(--song-dur-1) var(--song-ease);
}
.comment-star:hover {
color: var(--ac-primary-ink);
}
.comment-star.starred {
color: var(--ac-primary);
}
.comment-delete {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 8px;
border: none;
background: none;
color: var(--ac-text-disabled);
cursor: pointer;
font-size: 12px;
padding: 2px 6px;
border-radius: var(--song-radius-s);
transition: color var(--song-dur-1) var(--song-ease);
}
.comment-delete:hover {
color: var(--ac-danger-ink);
}
.comment-quote {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 8px;
border: none;
background: none;
color: var(--ac-text-disabled);
cursor: pointer;
font-size: 12px;
padding: 2px 6px;
border-radius: var(--song-radius-s);
transition: color var(--song-dur-1) var(--song-ease);
}
.comment-quote:hover {
color: var(--ac-primary-ink);
}
:deep(.comment-ref-tag) {
display: flex;
align-items: center;
gap: 4px;
width: fit-content;
padding: 0 8px;
margin-bottom: 6px;
border: 1px solid var(--ac-primary);
border-radius: var(--song-radius-s);
color: var(--ac-primary-ink);
background: var(--ac-primary-bg);
font-size: 12px;
line-height: 20px;
}
:deep(.comment-ref-icon) {
flex-shrink: 0;
}
.quote-preview {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin: 4px 0;
border: 1px solid var(--ac-border);
border-left: 3px solid var(--ac-primary);
border-radius: var(--song-radius-m);
background: var(--ac-primary-bg);
font-size: 13px;
}
.quote-preview-label {
flex-shrink: 0;
color: var(--ac-primary-ink);
font-weight: 600;
}
.quote-preview-author {
flex-shrink: 0;
color: var(--ac-text-secondary);
}
.quote-preview-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ac-text);
}
.quote-preview-clear {
flex-shrink: 0;
border: none;
background: none;
color: var(--ac-text-disabled);
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 2px;
border-radius: var(--song-radius-s);
transition: color var(--song-dur-1) var(--song-ease);
}
.quote-preview-clear:hover {
color: var(--ac-danger-ink);
}
.comment-write {
margin-top: 8px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.comment-write-icon {
flex-shrink: 0;
}
.comment-form {
width: 100%;
background: var(--ac-bg-card);
border: 1px solid var(--ac-border);
border-radius: var(--song-radius-l);
padding: 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
.comment-form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
.form-fade-enter-active,
.form-fade-leave-active {
transition: opacity var(--song-dur-2) var(--song-ease), transform var(--song-dur-2) var(--song-ease);
}
.form-fade-enter-from,
.form-fade-leave-to {
opacity: 0;
transform: translateY(6px);
}
.error-text {
color: var(--ac-danger-ink);
font-size: 13px;
}
.success-text {
color: var(--ac-success-ink);
font-size: 13px;
}
.hint-text {
color: var(--ac-text-disabled);
}
</style>