AI-News/frontend/app/composables/useRightDrawer.js
2025-12-04 10:04:21 +08:00

621 lines
20 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.

// 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
}
}