|
|
@ -31,6 +31,31 @@ const readonlyFields = computed(() => isLoggedIn.value) |
|
|
const authorName = ref('') |
|
|
const authorName = ref('') |
|
|
const authorEmail = ref('') |
|
|
const authorEmail = ref('') |
|
|
const content = ref('') |
|
|
const content = ref('') |
|
|
|
|
|
const quotedComment = ref<CommentResponse | null>(null) |
|
|
|
|
|
|
|
|
|
|
|
function escapeBrackets(text: string): string { |
|
|
|
|
|
return text.replace(/[<>]/g, (ch) => '\\' + ch) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function renderCommentContent(text: string): string { |
|
|
|
|
|
const escaped = text |
|
|
|
|
|
.replace(/&/g, '&') |
|
|
|
|
|
.replace(/</g, '<') |
|
|
|
|
|
.replace(/>/g, '>') |
|
|
|
|
|
return escaped.replace(/<ref:(\d+)>/g, '<span class="comment-ref-tag"><ref:$1></span>') |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function quoteExcerpt(comment: CommentResponse): string { |
|
|
|
|
|
const s = (comment.content ?? '').replace(/\s+/g, ' ').trim() |
|
|
|
|
|
return s.length > 40 ? s.slice(0, 40) + '…' : s |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function quoteComment(comment: CommentResponse) { |
|
|
|
|
|
quotedComment.value = comment |
|
|
|
|
|
if (!formOpen.value) { |
|
|
|
|
|
openForm() |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
const sortedComments = computed(() => |
|
|
const sortedComments = computed(() => |
|
|
[...comments.value].sort( |
|
|
[...comments.value].sort( |
|
|
@ -69,14 +94,19 @@ async function onSubmit() { |
|
|
error.value = null |
|
|
error.value = null |
|
|
submitMessage.value = null |
|
|
submitMessage.value = null |
|
|
try { |
|
|
try { |
|
|
|
|
|
let body = escapeBrackets(content.value.trim()) |
|
|
|
|
|
if (quotedComment.value?.id != null) { |
|
|
|
|
|
body = `<ref:${quotedComment.value.id}>${body}` |
|
|
|
|
|
} |
|
|
await commentApi.apiArticlesSlugCommentsPost(props.slug, { |
|
|
await commentApi.apiArticlesSlugCommentsPost(props.slug, { |
|
|
authorName: authorName.value.trim(), |
|
|
authorName: authorName.value.trim(), |
|
|
authorEmail: authorEmail.value.trim() || undefined, |
|
|
authorEmail: authorEmail.value.trim() || undefined, |
|
|
content: content.value.trim() |
|
|
|
|
|
|
|
|
content: body |
|
|
}) |
|
|
}) |
|
|
content.value = '' |
|
|
content.value = '' |
|
|
authorName.value = '' |
|
|
authorName.value = '' |
|
|
authorEmail.value = '' |
|
|
authorEmail.value = '' |
|
|
|
|
|
quotedComment.value = null |
|
|
submitMessage.value = '评论已提交,等待审核' |
|
|
submitMessage.value = '评论已提交,等待审核' |
|
|
formOpen.value = false |
|
|
formOpen.value = false |
|
|
load() |
|
|
load() |
|
|
@ -161,7 +191,13 @@ onMounted(() => { |
|
|
<span class="comment-author">{{ comment.authorName }}</span> |
|
|
<span class="comment-author">{{ comment.authorName }}</span> |
|
|
<span class="comment-time">{{ formatDate(comment.createdAt) }}</span> |
|
|
<span class="comment-time">{{ formatDate(comment.createdAt) }}</span> |
|
|
</div> |
|
|
</div> |
|
|
<p class="comment-content">{{ comment.content }}</p> |
|
|
|
|
|
|
|
|
<p class="comment-content" v-html="renderCommentContent(comment.content ?? '')"></p> |
|
|
|
|
|
<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="currentColor" aria-hidden="true"> |
|
|
|
|
|
<path d="M10 7L8 12h3v5H5v-5l2-5h3zm9 0l-2 5h3v5h-6v-5l2-5h3z" /> |
|
|
|
|
|
</svg> |
|
|
|
|
|
<span>引用</span> |
|
|
|
|
|
</button> |
|
|
<button class="comment-star" :class="{ starred: comment.hasStarred }" @click="toggleCommentStar(comment)"> |
|
|
<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"> |
|
|
<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"/> |
|
|
<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"/> |
|
|
@ -193,6 +229,12 @@ onMounted(() => { |
|
|
<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" :readonly="readonlyFields" :disabled="readonlyFields" /> |
|
|
<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="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" /> |
|
|
<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> |
|
|
@ -330,6 +372,87 @@ onMounted(() => { |
|
|
color: var(--ac-danger-ink); |
|
|
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); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.comment-ref-tag { |
|
|
|
|
|
display: inline-block; |
|
|
|
|
|
padding: 0 6px; |
|
|
|
|
|
margin-right: 4px; |
|
|
|
|
|
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: 18px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.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 { |
|
|
.comment-write { |
|
|
margin-top: 8px; |
|
|
margin-top: 8px; |
|
|
display: flex; |
|
|
display: flex; |
|
|
|