Files
ONE/frontend/src/composables/useComments.js
T
Sakurasan a234afe448 评论实时更新:SSE 订阅文章频道,发评/编辑/删除/审核通过即时刷新打开中的页面
- internal/hub:按文章 ID 的进程内发布/订阅,信号不携带内容(未审核评论不外泄)
- GET /api/comments/stream?post_id=N:SSE 长连 + 25s 心跳 + X-Accel-Buffering off
- 广播点:发评论、作者编辑/删除、后台审核通过、后台删除(与公开端共享同一 hub)
- 前端 EventSource 静默刷新(不亮加载态、不干扰正在输入/编辑的状态)
- requestLog 的 statusRecorder 补 Flush 透传——包装器没实现 Flusher 时流式端点整体不可用
2026-09-28 21:42:11 +08:00

199 lines
5.8 KiB
JavaScript
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.
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
}
}