- internal/hub:按文章 ID 的进程内发布/订阅,信号不携带内容(未审核评论不外泄) - GET /api/comments/stream?post_id=N:SSE 长连 + 25s 心跳 + X-Accel-Buffering off - 广播点:发评论、作者编辑/删除、后台审核通过、后台删除(与公开端共享同一 hub) - 前端 EventSource 静默刷新(不亮加载态、不干扰正在输入/编辑的状态) - requestLog 的 statusRecorder 补 Flush 透传——包装器没实现 Flusher 时流式端点整体不可用
199 lines
5.8 KiB
JavaScript
199 lines
5.8 KiB
JavaScript
import { computed, reactive } from 'vue'
|
||
import { readerApi } from '../api'
|
||
|
||
// 一篇文章的评论区状态。
|
||
// 刻意做成「每个文章实例化一份」而不是像 site.js 那样的单例 ——
|
||
// 换文章必须重新来过,单例会在路由切换时残留上一条的数据。
|
||
//
|
||
// 后端契约(见 README / 方案文档):
|
||
// GET /api/comments?post_id=&sort=&page=&size=
|
||
// -> { items:[Comment], total, page, pages, has_more }
|
||
// Comment = { id, parent_id, root_id, body_html, state, created_at, edited_at,
|
||
// is_deleted, reply_count, replies:[Comment], author:{...} }
|
||
// total / has_more 只统计顶层评论;回复挂在各自的 replies 里。
|
||
|
||
const PAGE_SIZE = 10
|
||
|
||
export function useComments(postId) {
|
||
const s = reactive({
|
||
items: [],
|
||
total: 0,
|
||
page: 0,
|
||
sort: 'default', // default | newest | oldest
|
||
loading: false,
|
||
loadingMore: false,
|
||
error: '',
|
||
replyTo: null, // 正在回复的那条(顶层或回复都可能是)
|
||
editing: null, // 正在编辑的那条
|
||
submitting: false
|
||
})
|
||
|
||
const remaining = computed(() => Math.max(0, s.total - s.items.length))
|
||
|
||
async function load(reset = true) {
|
||
if (s.loading || s.loadingMore) return
|
||
if (reset) {
|
||
s.loading = true
|
||
s.error = ''
|
||
} else {
|
||
s.loadingMore = true
|
||
}
|
||
try {
|
||
const page = reset ? 1 : s.page + 1
|
||
const data = await readerApi.comments(postId, { sort: s.sort, page, size: PAGE_SIZE })
|
||
const items = (data && data.items) || []
|
||
s.items = reset ? items : s.items.concat(items)
|
||
s.page = page
|
||
s.total = (data && data.total) || 0
|
||
} catch (e) {
|
||
s.error = e.message || '评论加载失败'
|
||
} finally {
|
||
s.loading = false
|
||
s.loadingMore = false
|
||
}
|
||
}
|
||
|
||
function setSort(sort) {
|
||
if (sort === s.sort) return
|
||
s.sort = sort
|
||
load(true)
|
||
}
|
||
|
||
// SSE 触发的静默刷新:回到第一页按当前排序重拉,不亮加载态——
|
||
// 别人发了新评论时页面只是「悄悄长出」新内容,不该闪一下。
|
||
// 返回 true 表示列表真的变了(顶层条数有差异)。
|
||
async function refresh() {
|
||
try {
|
||
const data = await readerApi.comments(postId, { sort: s.sort, page: 1, size: PAGE_SIZE })
|
||
if (s.loading || s.loadingMore || s.replyTo || s.editing) return false
|
||
const items = (data && data.items) || []
|
||
const changed = (data && data.total) !== s.total || items.some((c, i) => !s.items[i] || s.items[i].id !== c.id || s.items[i].edited_at !== c.edited_at)
|
||
s.items = items
|
||
s.page = 1
|
||
s.total = (data && data.total) || 0
|
||
return changed
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 发表 / 回复。成功后直接插进列表,不整页重拉 —— 位置按当前排序决定,
|
||
// 用户刚发的东西必须马上看得见。
|
||
async function submit(bodyMd, parent = null) {
|
||
s.submitting = true
|
||
s.error = ''
|
||
try {
|
||
const created = await readerApi.create(postId, bodyMd, parent ? parent.id : 0)
|
||
s.total += 1
|
||
if (parent) {
|
||
const root = rootOf(parent)
|
||
if (root) {
|
||
root.replies = (root.replies || []).concat(created)
|
||
root.reply_count = (root.reply_count || 0) + 1
|
||
}
|
||
} else if (s.sort === 'oldest') {
|
||
s.items.push(created)
|
||
} else {
|
||
s.items.unshift(created)
|
||
}
|
||
return created
|
||
} catch (e) {
|
||
s.error = e.message || '发表失败'
|
||
return null
|
||
} finally {
|
||
s.submitting = false
|
||
}
|
||
}
|
||
|
||
async function saveEdit(comment, bodyMd) {
|
||
s.submitting = true
|
||
s.error = ''
|
||
try {
|
||
const updated = await readerApi.edit(comment.id, bodyMd)
|
||
// 后端会把新的 body_html / edited_at 一起回来,就地替换
|
||
comment.body_html = updated.body_html
|
||
comment.edited_at = updated.edited_at
|
||
s.editing = null
|
||
return true
|
||
} catch (e) {
|
||
s.error = e.message || '保存失败'
|
||
return false
|
||
} finally {
|
||
s.submitting = false
|
||
}
|
||
}
|
||
|
||
async function remove(comment) {
|
||
s.error = ''
|
||
try {
|
||
await readerApi.remove(comment.id)
|
||
comment.is_deleted = true
|
||
comment.body_html = ''
|
||
// 顶层评论软删后总数要减;回复只改自身
|
||
if (!comment.parent_id) s.total = Math.max(0, s.total - 1)
|
||
else {
|
||
const root = rootOf(comment)
|
||
if (root) root.reply_count = Math.max(0, (root.reply_count || 0) - 1)
|
||
}
|
||
return true
|
||
} catch (e) {
|
||
s.error = e.message || '删除失败'
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 回复折叠时按需拉取(GET /api/comments/{rootId}/thread)
|
||
async function loadMoreReplies(root) {
|
||
if (root.__threadLoading) return
|
||
root.__threadLoading = true
|
||
try {
|
||
const data = await readerApi.thread(root.id, root.__cursor || '')
|
||
const more = (data && data.items) || []
|
||
root.replies = (root.replies || []).concat(more)
|
||
root.__cursor = (data && data.cursor) || ''
|
||
root.reply_count = (data && data.reply_count) || (root.replies || []).length
|
||
} catch (e) {
|
||
s.error = e.message || '回复加载失败'
|
||
} finally {
|
||
root.__threadLoading = false
|
||
}
|
||
}
|
||
|
||
// 顶层评论列表里找出某条回复所属的根
|
||
function rootOf(comment) {
|
||
if (comment.root_id) {
|
||
const byRoot = s.items.find((c) => c.id === comment.root_id)
|
||
if (byRoot) return byRoot
|
||
}
|
||
return s.items.find((c) => (c.replies || []).some((r) => r.id === comment.id)) || null
|
||
}
|
||
|
||
function startReply(comment) {
|
||
s.editing = null
|
||
s.replyTo = comment
|
||
}
|
||
function startEdit(comment) {
|
||
s.replyTo = null
|
||
s.editing = comment
|
||
}
|
||
function cancel() {
|
||
s.replyTo = null
|
||
s.editing = null
|
||
}
|
||
|
||
return {
|
||
s,
|
||
remaining,
|
||
load,
|
||
refresh,
|
||
setSort,
|
||
submit,
|
||
saveEdit,
|
||
remove,
|
||
loadMoreReplies,
|
||
startReply,
|
||
startEdit,
|
||
cancel
|
||
}
|
||
}
|