// 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} - 是否删除成功 */ 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 } }