621 lines
20 KiB
JavaScript
621 lines
20 KiB
JavaScript
// composables/useRightDrawer.js
|
||
import { reactive, toRefs, computed } from 'vue'
|
||
import { useRouter } from '#app'
|
||
import { useAuth } from './useAuth'
|
||
import { useApi } from './useApi'
|
||
import { useToast } from './useToast'
|
||
import { useConfirm } from './useConfirm'
|
||
|
||
// 全局状态(所有使用 useRightDrawer() 的组件共享)
|
||
const state = reactive({
|
||
isCollapsed: false, // 是否收起为窄栏
|
||
open: true, // 是否显示(预留)
|
||
title: '', // 当前文章标题
|
||
description: '', // 当前文章摘要
|
||
tags: [], // 当前文章标签
|
||
path: '', // 当前文章路径(用于按路由区分评论列表)
|
||
slug: '', // 文章 slug,用于请求后端
|
||
favorited: false, // 收藏状态
|
||
favoritesCount: 0, // 收藏数
|
||
prevSlug: '', // 上一篇 slug
|
||
nextSlug: '', // 下一篇 slug
|
||
articleAuthorName: '', // 当前文章作者
|
||
articleAuthorKey: '', // 作者对比键(小写用户名/邮箱)
|
||
commentsMap: {}, // { [path]: Comment[] }
|
||
commentsLoaded: {}, // { [key]: boolean }
|
||
commentsLoading: false,
|
||
commentSubmitting: false,
|
||
draft: '' // 当前输入中的评论草稿
|
||
})
|
||
|
||
// 全局轮询管理器(单例模式)
|
||
const pollingManager = {
|
||
interval: null, // 全局轮询定时器
|
||
activeSlug: null, // 当前活跃的文章 slug
|
||
isPolling: false, // 是否正在轮询
|
||
pollingDelay: 30000, // 轮询间隔(30秒)
|
||
pageVisible: true, // 页面是否可见
|
||
componentCount: 0, // 使用该 composable 的组件数量
|
||
visibilityHandler: null // 页面可见性变化处理器
|
||
}
|
||
|
||
// 初始化页面可见性监听(只执行一次)
|
||
if (process.client && !pollingManager.visibilityHandler) {
|
||
pollingManager.visibilityHandler = () => {
|
||
pollingManager.pageVisible = !document.hidden
|
||
console.log('[RightDrawer] 页面可见性变化:', pollingManager.pageVisible)
|
||
|
||
if (pollingManager.pageVisible && pollingManager.activeSlug) {
|
||
// 页面变为可见时,立即检查一次新评论
|
||
globalPollComments()
|
||
}
|
||
}
|
||
|
||
document.addEventListener('visibilitychange', pollingManager.visibilityHandler)
|
||
}
|
||
|
||
// 标准化评论数据(全局函数,供轮询使用)
|
||
function normalizeComment(raw) {
|
||
if (!raw) return null
|
||
|
||
// 处理 author 可能是字符串或对象的情况
|
||
const author = typeof raw.author === 'object' && raw.author !== null ? raw.author : {}
|
||
const authorStr = typeof raw.author === 'string' ? raw.author : ''
|
||
|
||
const name = author.username || author.email || author.name || authorStr || '匿名用户'
|
||
const authorKey = String(author.username || author.email || raw.authorKey || name || '').toLowerCase()
|
||
const created = raw.createdAt || raw.created_at || raw.updatedAt || raw.updated_at || ''
|
||
|
||
const formatTime = (t) => {
|
||
if (!t) return ''
|
||
try {
|
||
const d = new Date(t)
|
||
if (!Number.isNaN(d.getTime())) return d.toLocaleString()
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
return t
|
||
}
|
||
|
||
return {
|
||
id: raw.id ?? raw.id_ ?? raw._id ?? Date.now(),
|
||
author: name,
|
||
authorKey,
|
||
roles: Array.isArray(author.roles) ? author.roles : [],
|
||
userId: author.id ?? author.userId ?? raw.userId ?? null,
|
||
isAdmin: Array.isArray(author.roles) ? author.roles.includes('admin') : Boolean(raw.isAdmin),
|
||
time: formatTime(created),
|
||
text: raw.body ?? raw.text ?? '',
|
||
createdAt: created
|
||
}
|
||
}
|
||
|
||
// 全局轮询函数(只轮询当前活跃的文章)
|
||
async function globalPollComments() {
|
||
// 页面不可见时不轮询
|
||
if (!pollingManager.pageVisible) {
|
||
return
|
||
}
|
||
|
||
// 没有活跃文章时不轮询
|
||
const slug = pollingManager.activeSlug
|
||
if (!slug) {
|
||
return
|
||
}
|
||
|
||
// 使用 slug 作为 key(与 commentsKey 函数逻辑一致)
|
||
const key = slug
|
||
const currentList = state.commentsMap[key] || []
|
||
|
||
try {
|
||
const api = useApi()
|
||
const res = await api.get(`/articles/${encodeURIComponent(slug)}/comments`)
|
||
const list = Array.isArray(res?.comments) ? res.comments : []
|
||
const normalized = list.map((c) => normalizeComment(c)).filter(Boolean)
|
||
|
||
// 检查是否有变化(新增或删除)
|
||
if (normalized.length !== currentList.length) {
|
||
console.log(`[RightDrawer] 评论数量变化: ${currentList.length} → ${normalized.length}`)
|
||
state.commentsMap[key] = normalized
|
||
}
|
||
} catch (err) {
|
||
// 轮询失败静默处理
|
||
}
|
||
}
|
||
|
||
// 启动全局轮询
|
||
function startGlobalPolling() {
|
||
if (pollingManager.interval) return // 已经在轮询中
|
||
|
||
console.log('[RightDrawer] 启动全局轮询')
|
||
pollingManager.isPolling = true
|
||
|
||
// 立即执行一次
|
||
globalPollComments()
|
||
|
||
// 定期轮询
|
||
pollingManager.interval = setInterval(() => {
|
||
globalPollComments()
|
||
}, pollingManager.pollingDelay)
|
||
}
|
||
|
||
// 停止全局轮询
|
||
function stopGlobalPolling() {
|
||
if (!pollingManager.interval) return
|
||
|
||
console.log('[RightDrawer] 停止全局轮询')
|
||
pollingManager.isPolling = false
|
||
clearInterval(pollingManager.interval)
|
||
pollingManager.interval = null
|
||
}
|
||
|
||
// 全局共享动作(不同组件调用 useRightDrawer 时仍引用同一份)
|
||
const actions = reactive({
|
||
favorite: null,
|
||
prev: null,
|
||
next: null
|
||
})
|
||
|
||
export function useRightDrawer () {
|
||
const router = useRouter()
|
||
const api = useApi()
|
||
const toast = useToast()
|
||
const confirm = useConfirm()
|
||
const { user: authUser, token, fetchMe } = useAuth()
|
||
const isLoggedIn = computed(() => Boolean(token.value))
|
||
|
||
// 如果已登录但用户信息为空,主动获取用户信息
|
||
if (process.client && token.value && !authUser.value) {
|
||
console.log('[RightDrawer] 检测到已登录但用户信息为空,主动获取用户信息')
|
||
fetchMe().catch((err) => {
|
||
console.warn('[RightDrawer] fetchMe failed:', err)
|
||
})
|
||
}
|
||
|
||
const pickSlugFromPath = (p) => {
|
||
if (!p) return ''
|
||
const m = String(p).match(/articles\/([^/?#]+)/i)
|
||
return m ? decodeURIComponent(m[1]) : ''
|
||
}
|
||
|
||
const commentsKey = (path, slug) => slug || path || state.slug || state.path || 'default'
|
||
|
||
const formatTime = (t) => {
|
||
if (!t) return ''
|
||
try {
|
||
const d = new Date(t)
|
||
if (!Number.isNaN(d.getTime())) return d.toLocaleString()
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
return t
|
||
}
|
||
|
||
const normalizeApiComment = (raw) => {
|
||
if (!raw) return null
|
||
|
||
// 处理 author 可能是字符串或对象的情况
|
||
const author = typeof raw.author === 'object' && raw.author !== null ? raw.author : {}
|
||
const authorStr = typeof raw.author === 'string' ? raw.author : ''
|
||
|
||
const name = author.username || author.email || author.name || authorStr || '匿名用户'
|
||
const authorKey = String(author.username || author.email || raw.authorKey || name || '').toLowerCase()
|
||
const created = raw.createdAt || raw.created_at || raw.updatedAt || raw.updated_at || ''
|
||
|
||
return {
|
||
id: raw.id ?? raw.id_ ?? raw._id ?? Date.now(),
|
||
author: name,
|
||
authorKey,
|
||
roles: Array.isArray(author.roles) ? author.roles : [],
|
||
userId: author.id ?? author.userId ?? raw.userId ?? null,
|
||
isAdmin: Array.isArray(author.roles) ? author.roles.includes('admin') : Boolean(raw.isAdmin),
|
||
time: formatTime(created),
|
||
text: raw.body ?? raw.text ?? '',
|
||
createdAt: created
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从文章文档同步信息到抽屉
|
||
* d: { title, description, tags, path, slug, authorUsername, favorited, favoritesCount, prevSlug, nextSlug }
|
||
*/
|
||
function setFromDoc (d = {}) {
|
||
state.title = d.title || ''
|
||
state.description = d.description || ''
|
||
state.tags = Array.isArray(d.tags) ? d.tags : []
|
||
state.path = d.path || ''
|
||
state.slug = d.slug || state.slug || pickSlugFromPath(d.path || '')
|
||
state.favorited = Boolean(d.favorited)
|
||
state.favoritesCount = Number.isFinite(d.favoritesCount) ? Number(d.favoritesCount) : 0
|
||
state.prevSlug = d.prevSlug || ''
|
||
state.nextSlug = d.nextSlug || ''
|
||
const authorName = d.authorUsername || d.authorName || ''
|
||
state.articleAuthorName = authorName
|
||
state.articleAuthorKey = authorName ? String(authorName).toLowerCase() : ''
|
||
state.open = true // 有文章时确保抽屉是打开的
|
||
|
||
loadComments(d.path, d.slug).catch((err) => {
|
||
console.warn('[RightDrawer] loadComments failed', err)
|
||
})
|
||
}
|
||
|
||
function toggle () {
|
||
state.isCollapsed = !state.isCollapsed
|
||
}
|
||
|
||
function collapse () { state.isCollapsed = true }
|
||
function expand () { state.isCollapsed = false }
|
||
function closeToHome () {
|
||
if (process.client) {
|
||
const last = window.sessionStorage.getItem('article:lastList')
|
||
if (last) {
|
||
router.push(last)
|
||
return
|
||
}
|
||
}
|
||
router.push('/')
|
||
}
|
||
|
||
function setActions ({ favorite, prev, next } = {}) {
|
||
if (typeof favorite === 'function') actions.favorite = favorite
|
||
if (typeof prev === 'function') actions.prev = prev
|
||
if (typeof next === 'function') actions.next = next
|
||
}
|
||
|
||
function favorite () {
|
||
if (typeof actions.favorite === 'function') actions.favorite()
|
||
}
|
||
function prev () {
|
||
if (typeof actions.prev === 'function') actions.prev()
|
||
}
|
||
function next () {
|
||
if (typeof actions.next === 'function') actions.next()
|
||
}
|
||
|
||
function ensureCommentsList (path, slug) {
|
||
const key = commentsKey(path, slug)
|
||
if (!state.commentsMap[key]) {
|
||
state.commentsMap[key] = []
|
||
}
|
||
return state.commentsMap[key]
|
||
}
|
||
|
||
/**
|
||
* 获取当前路径对应的评论列表(若不存在则初始化为空数组)
|
||
* 不传 path 时,默认 state.path(即当前文章)
|
||
*/
|
||
function commentsOf (path) {
|
||
const list = ensureCommentsList(path)
|
||
const me = authUser?.value || null
|
||
const myKey = String(me?.username || me?.email || me?.id || me?._id || me?.user_id || '').toLowerCase()
|
||
const myId = me?.id ?? me?._id ?? me?.user_id ?? null
|
||
|
||
console.log('[RightDrawer] commentsOf 当前用户信息:', {
|
||
me,
|
||
myKey,
|
||
myId
|
||
})
|
||
|
||
const result = list.map((c) => {
|
||
const authorKey = String(c.authorKey || c.author || '').toLowerCase()
|
||
const isAdmin = c.isAdmin ?? (Array.isArray(c.roles) && c.roles.includes('admin'))
|
||
const isAuthor = c.isAuthor ?? (state.articleAuthorKey && authorKey === state.articleAuthorKey)
|
||
|
||
// 改进 isMine 判断逻辑
|
||
const isMine = Boolean(
|
||
(myKey && authorKey && myKey === authorKey) ||
|
||
(myId !== null && c.userId !== null && c.userId !== undefined && String(c.userId) === String(myId))
|
||
)
|
||
|
||
console.log('[RightDrawer] 评论判断:', {
|
||
commentId: c.id,
|
||
author: c.author,
|
||
authorKey: c.authorKey,
|
||
userId: c.userId,
|
||
myKey,
|
||
myId,
|
||
isMine
|
||
})
|
||
|
||
return { ...c, isAdmin, isAuthor, isMine }
|
||
})
|
||
|
||
console.log('[RightDrawer] commentsOf 返回:', {
|
||
path,
|
||
listLength: list.length,
|
||
resultLength: result.length,
|
||
result
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
async function loadComments (path, slug, opts = {}) {
|
||
const key = commentsKey(path, slug)
|
||
const resolvedSlug = slug || state.slug || pickSlugFromPath(path || state.path)
|
||
console.log('[RightDrawer] loadComments:', { path, slug, resolvedSlug, key })
|
||
|
||
if (!resolvedSlug) {
|
||
ensureCommentsList(path, slug)
|
||
return []
|
||
}
|
||
|
||
if (state.commentsLoaded[key] && !opts.force) {
|
||
console.log('[RightDrawer] 使用缓存的评论:', state.commentsMap[key])
|
||
return ensureCommentsList(path, slug)
|
||
}
|
||
|
||
// 如果已登录但用户信息为空,先获取用户信息
|
||
if (token.value && !authUser.value && typeof fetchMe === 'function') {
|
||
console.log('[RightDrawer] 先获取用户信息')
|
||
try {
|
||
await fetchMe()
|
||
console.log('[RightDrawer] 用户信息获取成功:', authUser.value)
|
||
} catch (err) {
|
||
console.warn('[RightDrawer] fetchMe failed:', err)
|
||
}
|
||
}
|
||
|
||
state.commentsLoading = true
|
||
try {
|
||
const res = await api.get(`/articles/${encodeURIComponent(resolvedSlug)}/comments`)
|
||
console.log('[RightDrawer] API 返回评论:', res)
|
||
const list = Array.isArray(res?.comments) ? res.comments : []
|
||
console.log('[RightDrawer] 原始评论列表:', list)
|
||
|
||
const normalized = list.map((c) => normalizeApiComment(c)).filter(Boolean)
|
||
console.log('[RightDrawer] 标准化后的评论:', normalized)
|
||
|
||
state.commentsMap[key] = normalized
|
||
state.commentsLoaded[key] = true
|
||
return state.commentsMap[key]
|
||
} catch (err) {
|
||
console.error('[RightDrawer] loadComments 失败:', err)
|
||
state.commentsLoaded[key] = false
|
||
throw err
|
||
} finally {
|
||
state.commentsLoading = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 提交评论:走后端接口
|
||
* - 使用当前 draft
|
||
* - 写入当前 path 对应的 commentsMap
|
||
*/
|
||
async function submitComment () {
|
||
const text = state.draft.trim()
|
||
if (!text) return
|
||
if (!token.value) {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
if (state.commentSubmitting) return
|
||
|
||
let me = authUser?.value || null
|
||
if (!me && typeof fetchMe === 'function') {
|
||
try {
|
||
await fetchMe()
|
||
me = authUser?.value || null
|
||
} catch (err) {
|
||
console.warn('[RightDrawer] fetchMe failed', err)
|
||
}
|
||
}
|
||
|
||
const resolvedSlug = state.slug || pickSlugFromPath(state.path)
|
||
if (!resolvedSlug) {
|
||
toast.error('无法发表评论', '缺少文章标识')
|
||
return
|
||
}
|
||
|
||
const name = me?.username || me?.email || '匿名用户'
|
||
const roles = Array.isArray(me?.roles) ? me.roles : []
|
||
const authorKey =
|
||
String(me?.username || me?.email || me?.id || me?._id || me?.user_id || '').toLowerCase() ||
|
||
`user-${Date.now()}`
|
||
const userId = me?.id ?? me?._id ?? me?.user_id ?? null
|
||
|
||
state.commentSubmitting = true
|
||
const key = commentsKey()
|
||
try {
|
||
const res = await api.post(`/articles/${encodeURIComponent(resolvedSlug)}/comments`, {
|
||
comment: { body: text }
|
||
})
|
||
const fromApi = res?.comment || res
|
||
const normalized = normalizeApiComment(fromApi)
|
||
|
||
// 如果 API 返回的数据无效,使用本地构造的评论对象
|
||
if (!normalized || !normalized.text) {
|
||
const fallback = {
|
||
id: fromApi?.id ?? Date.now(),
|
||
author: name,
|
||
authorKey,
|
||
roles,
|
||
userId,
|
||
isAdmin: roles.includes('admin'),
|
||
isAuthor: state.articleAuthorKey && authorKey === state.articleAuthorKey,
|
||
time: '刚刚',
|
||
text,
|
||
createdAt: new Date().toISOString()
|
||
}
|
||
console.log('[RightDrawer] 使用本地构造的评论对象:', fallback)
|
||
const list = ensureCommentsList()
|
||
list.push(fallback) // 新评论添加到底部
|
||
state.commentsLoaded[key] = true
|
||
state.draft = ''
|
||
return
|
||
}
|
||
|
||
const list = ensureCommentsList()
|
||
list.push(normalized) // 新评论添加到底部
|
||
state.commentsLoaded[key] = true
|
||
state.draft = ''
|
||
} catch (err) {
|
||
console.warn('[RightDrawer] submitComment failed', err)
|
||
toast.error('发表评论失败', err?.statusMessage || err?.message || '请稍后重试')
|
||
} finally {
|
||
state.commentSubmitting = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除评论
|
||
* @param {string|number} commentId - 评论ID
|
||
* @param {object} comment - 评论对象(用于权限检查)
|
||
* @returns {Promise<boolean>} - 是否删除成功
|
||
*/
|
||
async function deleteComment (commentId, comment = {}) {
|
||
if (!commentId) {
|
||
console.warn('[RightDrawer] deleteComment: commentId is required')
|
||
return false
|
||
}
|
||
|
||
if (!token.value) {
|
||
toast.info('请先登录', '登录后即可删除评论')
|
||
router.push('/login')
|
||
return false
|
||
}
|
||
|
||
const me = authUser?.value || null
|
||
const myRoles = Array.isArray(me?.roles) ? me.roles : []
|
||
const isAdmin = myRoles.includes('admin')
|
||
const myKey = String(me?.username || me?.email || me?.id || me?._id || me?.user_id || '').toLowerCase()
|
||
const myId = me?.id ?? me?._id ?? me?.user_id ?? null
|
||
const commentAuthorKey = String(comment.authorKey || comment.author || '').toLowerCase()
|
||
const isMine =
|
||
(myKey && commentAuthorKey === myKey) ||
|
||
(comment.userId !== undefined && myId !== null && String(comment.userId) === String(myId))
|
||
|
||
// 权限检查:只有管理员或评论作者本人可以删除
|
||
if (!isAdmin && !isMine) {
|
||
toast.error('无权限删除', '您没有权限删除此评论')
|
||
return false
|
||
}
|
||
|
||
const confirmTitle = isMine ? '撤回评论' : '删除评论'
|
||
const confirmMsg = isMine ? '确定要撤回这条评论吗?' : '确定要删除这条评论吗?'
|
||
const confirmType = isMine ? 'warning' : 'danger'
|
||
|
||
const confirmed = await confirm.show({
|
||
type: confirmType,
|
||
title: confirmTitle,
|
||
message: confirmMsg,
|
||
confirmText: isMine ? '撤回' : '删除',
|
||
cancelText: '取消'
|
||
})
|
||
|
||
if (!confirmed) {
|
||
return false
|
||
}
|
||
|
||
const resolvedSlug = state.slug || pickSlugFromPath(state.path)
|
||
if (!resolvedSlug) {
|
||
toast.error('无法删除评论', '缺少文章标识')
|
||
return false
|
||
}
|
||
|
||
try {
|
||
await api.del(`/articles/${encodeURIComponent(resolvedSlug)}/comments/${commentId}`)
|
||
|
||
// 从本地列表中移除
|
||
const key = commentsKey()
|
||
const list = state.commentsMap[key] || []
|
||
const index = list.findIndex((c) => String(c.id) === String(commentId))
|
||
if (index !== -1) {
|
||
list.splice(index, 1)
|
||
}
|
||
|
||
console.log('✅ 评论删除成功:', commentId)
|
||
toast.success('删除成功', '评论已删除')
|
||
return true
|
||
} catch (err) {
|
||
console.error('[RightDrawer] deleteComment failed:', err)
|
||
toast.error('删除失败', err?.statusMessage || err?.message || '请稍后重试')
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查当前用户是否可以删除指定评论
|
||
* @param {object} comment - 评论对象
|
||
* @returns {boolean} - 是否可以删除
|
||
*/
|
||
function canDeleteComment (comment) {
|
||
if (!token.value || !comment) return false
|
||
|
||
const me = authUser?.value || null
|
||
if (!me) return false
|
||
|
||
const myRoles = Array.isArray(me?.roles) ? me.roles : []
|
||
const isAdmin = myRoles.includes('admin')
|
||
|
||
// 检查是否是自己的评论
|
||
const myKey = String(me?.username || me?.email || me?.id || me?._id || me?.user_id || '').toLowerCase()
|
||
const myId = me?.id ?? me?._id ?? me?.user_id ?? null
|
||
const commentAuthorKey = String(comment.authorKey || comment.author || '').toLowerCase()
|
||
const isMine =
|
||
(myKey && commentAuthorKey === myKey) ||
|
||
(comment.userId !== undefined && myId !== null && String(comment.userId) === String(myId))
|
||
|
||
// 管理员可以删除所有评论,普通用户只能删除自己的评论
|
||
return isAdmin || isMine
|
||
}
|
||
|
||
/**
|
||
* 启动评论轮询(使用全局轮询管理器)
|
||
*/
|
||
function startPolling () {
|
||
pollingManager.componentCount++
|
||
console.log(`[RightDrawer] 组件挂载,当前组件数: ${pollingManager.componentCount}`)
|
||
|
||
// 设置当前活跃的文章 slug
|
||
const currentSlug = state.slug || pickSlugFromPath(state.path)
|
||
if (currentSlug) {
|
||
pollingManager.activeSlug = currentSlug
|
||
console.log('[RightDrawer] 设置活跃文章:', currentSlug)
|
||
}
|
||
|
||
// 如果还没有启动全局轮询,则启动
|
||
if (!pollingManager.isPolling) {
|
||
startGlobalPolling()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 停止评论轮询
|
||
*/
|
||
function stopPolling () {
|
||
pollingManager.componentCount--
|
||
console.log(`[RightDrawer] 组件卸载,当前组件数: ${pollingManager.componentCount}`)
|
||
|
||
// 如果没有组件在使用了,停止全局轮询
|
||
if (pollingManager.componentCount <= 0) {
|
||
pollingManager.componentCount = 0
|
||
pollingManager.activeSlug = null
|
||
stopGlobalPolling()
|
||
}
|
||
}
|
||
|
||
return {
|
||
...toRefs(state),
|
||
isLoggedIn,
|
||
canComment: isLoggedIn,
|
||
setFromDoc,
|
||
toggle,
|
||
collapse,
|
||
expand,
|
||
closeToHome,
|
||
setActions,
|
||
favorite,
|
||
prev,
|
||
next,
|
||
commentsOf,
|
||
loadComments,
|
||
submitComment,
|
||
deleteComment,
|
||
canDeleteComment,
|
||
startPolling,
|
||
stopPolling
|
||
}
|
||
}
|