|
|
@ -1,7 +1,9 @@ |
|
|
<script setup lang="ts"> |
|
|
<script setup lang="ts"> |
|
|
import { ref, 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 SongButton from '@/components/SongButton.vue' |
|
|
|
|
|
import SongInput from '@/components/SongInput.vue' |
|
|
|
|
|
|
|
|
const props = defineProps<{ |
|
|
const props = defineProps<{ |
|
|
slug: string |
|
|
slug: string |
|
|
@ -12,11 +14,18 @@ const loading = ref(false) |
|
|
const submitting = ref(false) |
|
|
const submitting = ref(false) |
|
|
const error = ref<string | null>(null) |
|
|
const error = ref<string | null>(null) |
|
|
const submitMessage = ref<string | null>(null) |
|
|
const submitMessage = ref<string | null>(null) |
|
|
|
|
|
const formOpen = ref(false) |
|
|
|
|
|
|
|
|
const authorName = ref('') |
|
|
const authorName = ref('') |
|
|
const authorEmail = ref('') |
|
|
const authorEmail = ref('') |
|
|
const content = ref('') |
|
|
const content = ref('') |
|
|
|
|
|
|
|
|
|
|
|
const sortedComments = computed(() => |
|
|
|
|
|
[...comments.value].sort( |
|
|
|
|
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() |
|
|
|
|
|
) |
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
function formatDate(iso?: string): string { |
|
|
function formatDate(iso?: string): string { |
|
|
if (!iso) return '' |
|
|
if (!iso) return '' |
|
|
const d = new Date(iso) |
|
|
const d = new Date(iso) |
|
|
@ -54,7 +63,10 @@ async function onSubmit() { |
|
|
content: content.value.trim() |
|
|
content: content.value.trim() |
|
|
}) |
|
|
}) |
|
|
content.value = '' |
|
|
content.value = '' |
|
|
|
|
|
authorName.value = '' |
|
|
|
|
|
authorEmail.value = '' |
|
|
submitMessage.value = '评论已提交,等待审核' |
|
|
submitMessage.value = '评论已提交,等待审核' |
|
|
|
|
|
formOpen.value = false |
|
|
load() |
|
|
load() |
|
|
} catch (e) { |
|
|
} catch (e) { |
|
|
console.error(e) |
|
|
console.error(e) |
|
|
@ -64,6 +76,11 @@ async function onSubmit() { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function openForm() { |
|
|
|
|
|
error.value = null |
|
|
|
|
|
formOpen.value = true |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
onMounted(load) |
|
|
onMounted(load) |
|
|
</script> |
|
|
</script> |
|
|
|
|
|
|
|
|
@ -71,63 +88,90 @@ onMounted(load) |
|
|
<section class="comment-section"> |
|
|
<section class="comment-section"> |
|
|
<h3 class="comment-title">评论</h3> |
|
|
<h3 class="comment-title">评论</h3> |
|
|
<p v-if="loading" class="hint-text">加载中...</p> |
|
|
<p v-if="loading" class="hint-text">加载中...</p> |
|
|
<p v-else-if="comments.length === 0" class="hint-text">暂无评论</p> |
|
|
|
|
|
<ul v-else class="comment-list"> |
|
|
|
|
|
<li v-for="comment in comments" :key="comment.id" class="comment-item"> |
|
|
|
|
|
<div class="comment-meta"> |
|
|
|
|
|
<span class="comment-author">{{ comment.authorName }}</span> |
|
|
|
|
|
<span class="comment-time">{{ formatDate(comment.createdAt) }}</span> |
|
|
|
|
|
|
|
|
<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">{{ comment.content }}</p> |
|
|
</div> |
|
|
</div> |
|
|
<p class="comment-content">{{ comment.content }}</p> |
|
|
|
|
|
</li> |
|
|
|
|
|
</ul> |
|
|
|
|
|
|
|
|
</article> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
<form class="comment-form" @submit.prevent="onSubmit"> |
|
|
|
|
|
<h4 class="comment-form-title">发表评论</h4> |
|
|
|
|
|
<p v-if="error" class="error-text">{{ error }}</p> |
|
|
|
|
|
|
|
|
<div class="comment-write"> |
|
|
<p v-if="submitMessage" class="success-text">{{ submitMessage }}</p> |
|
|
<p v-if="submitMessage" class="success-text">{{ submitMessage }}</p> |
|
|
<label class="form-label" for="comment-author">昵称</label> |
|
|
|
|
|
<input id="comment-author" v-model="authorName" class="form-input" type="text" maxlength="100" /> |
|
|
|
|
|
<label class="form-label" for="comment-email">邮箱(可选)</label> |
|
|
|
|
|
<input id="comment-email" v-model="authorEmail" class="form-input" type="email" /> |
|
|
|
|
|
<label class="form-label" for="comment-content">内容</label> |
|
|
|
|
|
<textarea |
|
|
|
|
|
id="comment-content" |
|
|
|
|
|
v-model="content" |
|
|
|
|
|
class="form-textarea" |
|
|
|
|
|
rows="3" |
|
|
|
|
|
maxlength="2000" |
|
|
|
|
|
></textarea> |
|
|
|
|
|
<button class="submit-btn" type="submit" :disabled="submitting"> |
|
|
|
|
|
{{ submitting ? '提交中...' : '提交评论' }} |
|
|
|
|
|
</button> |
|
|
|
|
|
</form> |
|
|
|
|
|
|
|
|
<SongButton v-if="!formOpen" type="secondary" size="medium" @click="openForm">✎ 发表评论</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" /> |
|
|
|
|
|
<SongInput v-model="authorEmail" label="邮箱(可选)" type="email" placeholder="用于联系,不公开" /> |
|
|
|
|
|
<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> |
|
|
</section> |
|
|
</template> |
|
|
</template> |
|
|
|
|
|
|
|
|
<style scoped> |
|
|
<style scoped> |
|
|
.comment-section { |
|
|
.comment-section { |
|
|
margin-top: 32px; |
|
|
|
|
|
|
|
|
margin-top: 40px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.comment-title { |
|
|
.comment-title { |
|
|
font-size: 18px; |
|
|
|
|
|
margin: 0 0 12px; |
|
|
|
|
|
|
|
|
font-family: var(--song-font-serif); |
|
|
|
|
|
font-size: 20px; |
|
|
|
|
|
line-height: 28px; |
|
|
|
|
|
font-weight: 600; |
|
|
|
|
|
margin: 0 0 20px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.comment-list { |
|
|
|
|
|
list-style: none; |
|
|
|
|
|
padding: 0; |
|
|
|
|
|
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 { |
|
|
.comment-item { |
|
|
padding: 12px 0; |
|
|
|
|
|
border-bottom: 1px solid var(--ac-border); |
|
|
|
|
|
|
|
|
display: flex; |
|
|
|
|
|
gap: 16px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.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-item:last-child { |
|
|
|
|
|
border-bottom: none; |
|
|
|
|
|
|
|
|
.comment-card { |
|
|
|
|
|
flex: 1; |
|
|
|
|
|
min-width: 0; |
|
|
|
|
|
padding: 0 0 6px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.comment-meta { |
|
|
.comment-meta { |
|
|
@ -151,73 +195,54 @@ onMounted(load) |
|
|
margin: 0; |
|
|
margin: 0; |
|
|
font-size: 14px; |
|
|
font-size: 14px; |
|
|
color: var(--ac-text); |
|
|
color: var(--ac-text); |
|
|
|
|
|
line-height: 22px; |
|
|
|
|
|
word-break: break-word; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.comment-form { |
|
|
|
|
|
background: var(--ac-bg-card); |
|
|
|
|
|
border: 1px solid var(--ac-border); |
|
|
|
|
|
border-radius: var(--song-radius-l); |
|
|
|
|
|
padding: 16px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.comment-form-title { |
|
|
|
|
|
margin: 0 0 12px; |
|
|
|
|
|
font-size: 15px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.form-label { |
|
|
|
|
|
display: block; |
|
|
|
|
|
margin: 10px 0 4px; |
|
|
|
|
|
font-size: 13px; |
|
|
|
|
|
color: var(--ac-text-secondary); |
|
|
|
|
|
|
|
|
.comment-write { |
|
|
|
|
|
margin-top: 8px; |
|
|
|
|
|
display: flex; |
|
|
|
|
|
flex-direction: column; |
|
|
|
|
|
align-items: flex-start; |
|
|
|
|
|
gap: 10px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.form-input, |
|
|
|
|
|
.form-textarea { |
|
|
|
|
|
|
|
|
.comment-form { |
|
|
width: 100%; |
|
|
width: 100%; |
|
|
padding: 8px 10px; |
|
|
|
|
|
border: 1px solid var(--ac-input-border); |
|
|
|
|
|
border-radius: var(--song-radius-l); |
|
|
|
|
|
font-size: 14px; |
|
|
|
|
|
background: var(--ac-bg-card); |
|
|
background: var(--ac-bg-card); |
|
|
color: var(--ac-text); |
|
|
|
|
|
transition: border-color var(--song-dur-1) var(--song-ease); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.form-input:focus, |
|
|
|
|
|
.form-textarea:focus { |
|
|
|
|
|
border-color: var(--ac-primary); |
|
|
|
|
|
outline: none; |
|
|
|
|
|
|
|
|
border: 1px solid var(--ac-border); |
|
|
|
|
|
border-radius: var(--song-radius-l); |
|
|
|
|
|
padding: 20px; |
|
|
|
|
|
display: flex; |
|
|
|
|
|
flex-direction: column; |
|
|
|
|
|
gap: 4px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.form-textarea { |
|
|
|
|
|
resize: vertical; |
|
|
|
|
|
font-family: inherit; |
|
|
|
|
|
|
|
|
.comment-form-actions { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
justify-content: flex-end; |
|
|
|
|
|
gap: 10px; |
|
|
|
|
|
margin-top: 8px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.submit-btn { |
|
|
|
|
|
margin-top: 12px; |
|
|
|
|
|
padding: 8px 18px; |
|
|
|
|
|
border: none; |
|
|
|
|
|
border-radius: var(--song-radius-l); |
|
|
|
|
|
background: var(--ac-primary-strong); |
|
|
|
|
|
color: var(--ac-primary-text); |
|
|
|
|
|
font-size: 14px; |
|
|
|
|
|
cursor: pointer; |
|
|
|
|
|
transition: opacity var(--song-dur-1) var(--song-ease); |
|
|
|
|
|
|
|
|
.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); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.submit-btn:disabled { |
|
|
|
|
|
opacity: 0.6; |
|
|
|
|
|
|
|
|
.form-fade-enter-from, |
|
|
|
|
|
.form-fade-leave-to { |
|
|
|
|
|
opacity: 0; |
|
|
|
|
|
transform: translateY(6px); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.error-text { |
|
|
.error-text { |
|
|
color: var(--ac-danger); |
|
|
|
|
|
|
|
|
color: var(--ac-danger-ink); |
|
|
font-size: 13px; |
|
|
font-size: 13px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.success-text { |
|
|
.success-text { |
|
|
color: var(--ac-success); |
|
|
|
|
|
|
|
|
color: var(--ac-success-ink); |
|
|
font-size: 13px; |
|
|
font-size: 13px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|