212 lines
7.5 KiB
Vue
212 lines
7.5 KiB
Vue
<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>
|