Files
ONE/frontend/src/components/comments/CommentSection.vue
T

212 lines
7.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { site } from '../../site'
import { reader, loadReader, signIn, signOut } from '../../reader'
import { readerApi } from '../../api'
import { useComments } from '../../composables/useComments'
import { insertAtCursor, placeCursor } from '../../utils'
import CommentItem from './CommentItem.vue'
import EmojiPicker from './EmojiPicker.vue'
import { BRANDS } from './brands'
// 文章评论区。classic 和 vivid 共用这一份 DOM —— 两边只是 CSS 不同
// (基础样式在 styles.css 的 .cm-*,vivid 的细节在 ui/yohaku/comments.css)。
// 这和左栏/工具栏那种「两套组件」不同:评论的结构完全相同,共用一份才不会漂移。
//
// 刻意不写 <style>:vivid 组件的既有约定,且这里要同时服务两套 UI。
const props = defineProps({
postId: { type: Number, default: 0 }
})
const api = useComments(props.postId)
const { s, remaining, load, setSort, submit, saveEdit, remove, loadMoreReplies, startReply, startEdit, cancel } = api
// 总开关在站点设置里。后端阶段二才会下发 comments_enabled —— 在那之前整个
// 区块不渲染,线上访客不会看到一个请求 404 的半成品;后端一加就自动出现。
// settings 是 KV 字符串,'1' / 1 / true 都算开。
const enabled = computed(() => !!Number(site.comments_enabled))
const sorts = [
{ value: 'default', label: '默认' },
{ value: 'newest', label: '最新' },
{ value: 'oldest', label: '最早' }
]
const MAX_LEN = 500
const draft = ref('')
const canSend = computed(() => draft.value.trim().length > 0 && !s.submitting && draft.value.length <= MAX_LEN)
// 表情 / 颜文字:面板挂在工具条上方,插入到光标处
const draftEl = ref(null)
const emojiOpen = ref(false)
function pickEmoji(text) {
const r = insertAtCursor(draftEl.value, draft.value, text)
draft.value = r.value
nextTick(() => placeCursor(draftEl.value, r.pos))
}
async function onSend() {
if (!canSend.value) return
const ok = await submit(draft.value.trim(), s.replyTo)
if (ok) {
draft.value = ''
emojiOpen.value = false
cancel()
}
}
function onSignIn(p) {
if (p.kind === 'widget') {
mountTelegram(p)
return
}
signIn(p)
}
// Telegram 的 Login Widget 没有跳转:只能把官方脚本插进来,它会把自己换成
// 官方按钮(iframe,样式改不动)。所以它和另外两个自绘圆钮长得不一样 ——
// 这是 Telegram 的限制,不是没做。
const tgBox = ref(null)
function mountTelegram(p) {
const box = tgBox.value
if (!box || box.dataset.mounted || !p.login) return
box.dataset.mounted = '1'
const sc = document.createElement('script')
sc.async = true
sc.src = 'https://telegram.org/js/telegram-widget.js?22'
sc.setAttribute('data-telegram-login', p.login)
sc.setAttribute('data-size', 'medium')
sc.setAttribute('data-radius', '10')
sc.setAttribute('data-request-access', 'write')
sc.setAttribute('data-onauth', 'oneTelegramAuth(user)')
box.appendChild(sc)
}
// 官方脚本要求回调是全局函数名
if (typeof window !== 'undefined') {
window.oneTelegramAuth = async (user) => {
try {
await readerApi.telegram(user)
await loadReader()
} catch (e) {
s.error = e.message || 'Telegram 登录失败'
}
}
}
onMounted(() => {
if (!reader.loaded) loadReader()
if (props.postId) load(true)
})
</script>
<template>
<section v-if="enabled" id="comments" class="cm-sec">
<div class="cm-head">
<span class="cm-count">共 {{ s.total }} 条评论</span>
<div class="seg cm-sort" role="tablist" aria-label="排序方式">
<button
v-for="o in sorts"
:key="o.value"
type="button"
role="tab"
:aria-selected="s.sort === o.value"
:class="{ on: s.sort === o.value }"
@click="setSort(o.value)"
>{{ o.label }}</button>
</div>
</div>
<!-- 登录卡 / 禁言 / 输入框 -->
<div v-if="!reader.loaded" class="cm-gate cm-gate-skel" aria-hidden="true" />
<div v-else-if="!reader.user" class="cm-gate">
<p class="cm-gate-t">使用社交账号登录</p>
<ul v-if="reader.providers.length" class="cm-providers">
<li v-for="p in reader.providers" :key="p.id">
<button
type="button"
class="cm-provider"
:title="`使用 ${p.label} 登录`"
:aria-label="`使用 ${p.label} 登录`"
@click="onSignIn(p)"
>
<svg v-if="BRANDS[p.id]" class="cm-brand" viewBox="0 0 24 24" aria-hidden="true">
<path :d="BRANDS[p.id]" />
</svg>
<span v-else class="cm-brand-t">{{ p.label.slice(0, 1) }}</span>
</button>
</li>
</ul>
<p v-else class="cm-gate-hint">站点还没配置登录方式。</p>
<!-- Telegram 官方按钮落到这里 -->
<div ref="tgBox" class="cm-tg" />
</div>
<div v-else-if="reader.user.banned" class="cm-gate cm-gate-banned">
<p class="cm-gate-t">你已被禁言,暂时无法参与评论。</p>
<p class="cm-gate-hint">如有疑问请联系站主。</p>
</div>
<div v-else class="cm-compose">
<div class="cm-av cm-av-me">
<img v-if="reader.user.avatar_url" :src="reader.user.avatar_url" alt="" loading="lazy">
<span v-else class="cm-av-fb">{{ (reader.user.name || '?').slice(0, 1) }}</span>
</div>
<div class="cm-field">
<textarea
ref="draftEl"
v-model="draft"
class="cm-input"
rows="3"
:maxlength="MAX_LEN"
placeholder="说点什么…(支持 Markdown)"
aria-label="写评论"
/>
<div class="cm-bar">
<span class="cm-bar-l">
<b>{{ reader.user.name }}</b>
<button type="button" class="cm-link" @click="signOut">退出</button>
</span>
<span class="cm-bar-r">
<button
type="button"
class="cm-emoji-btn"
:class="{ on: emojiOpen }"
aria-label="插入表情"
title="表情"
@click="emojiOpen = !emojiOpen"
>😊</button>
<span class="cm-len" :class="{ over: draft.length > MAX_LEN }">{{ draft.length }}/{{ MAX_LEN }}</span>
<button type="button" class="cm-send" :disabled="!canSend" @click="onSend">
{{ s.submitting ? '发送中…' : '发送' }}
</button>
</span>
<EmojiPicker v-if="emojiOpen" @pick="pickEmoji" @close="emojiOpen = false" />
</div>
</div>
</div>
<p v-if="s.error" class="cm-err">{{ s.error }}</p>
<!-- 骨架 / 空态 -->
<ul v-if="s.loading" class="cm-list cm-skel" aria-hidden="true">
<li v-for="i in 3" :key="i" class="cm-skel-row">
<span class="cm-skel-av" />
<span class="cm-skel-lines"><i /><i /></span>
</li>
</ul>
<div v-else-if="!s.items.length" class="empty cm-empty">
<h3>这里还静悄悄</h3>
<p>第一条评论留给你。</p>
</div>
<ul v-else class="cm-list">
<CommentItem v-for="c in s.items" :key="c.id" :c="c" :api="api" :depth="0" :max-len="MAX_LEN" />
</ul>
<div v-if="remaining > 0 && !s.loading" class="cm-more">
<button type="button" class="chip" :disabled="s.loadingMore" @click="load(false)">
{{ s.loadingMore ? '载入中…' : `显示更多(剩 ${remaining} 条)` }}
</button>
</div>
</section>
</template>