AI-News/frontend/app/components/RightDrawer.vue
2025-12-04 10:04:21 +08:00

795 lines
19 KiB
Vue
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.

<!-- app/components/RightDrawer.vue -->
<template>
<aside class="r-drawer" :class="{ collapsed: isCollapsed }" role="complementary" aria-label="文章抽屉">
<!-- 收起右侧纵向工具栏 -->
<nav v-if="isCollapsed" class="r-bar" aria-label="工具栏">
<button
class="r-bar-btn"
:class="{ active: favorited }"
title="收藏"
aria-label="收藏"
@click="onFavorite"
></button>
<button class="r-bar-btn" title="展开抽屉" aria-label="展开抽屉" @click="onExpand"></button>
<span class="r-bar-sep" />
<button class="r-bar-btn" title="上一篇" aria-label="上一篇" @click="onPrev" :disabled="!prevSlug"></button>
<button class="r-bar-btn" title="下一篇" aria-label="下一篇" @click="onNext" :disabled="!nextSlug"></button>
<span class="r-bar-sep" />
<button class="r-bar-btn danger" title="关闭文章" aria-label="关闭文章" @click="closeToHome">×</button>
</nav>
<!-- 展开 -->
<template v-else>
<header class="r-head">
<div class="r-tools" aria-label="功能区">
<button class="r-tool" :class="{ active: favorited }" title="收藏" @click="onFavorite">
</button>
<button class="r-tool" title="分享" @click="onShare"></button>
</div>
<div class="r-actions">
<button class="r-action" @click="toggle" :aria-expanded="!isCollapsed" title="收起/展开抽屉">
<span v-if="!isCollapsed"></span><span v-else></span>
</button>
<button class="r-action close" @click="closeToHome" aria-label="关闭并返回首页" title="关闭并返回首页">×</button>
</div>
</header>
<div class="r-body">
<!-- 文章信息区域 -->
<div class="r-info">
<div class="r-title">{{ title || '未命名' }}</div>
<!-- 作者信息和导航按钮 -->
<div class="r-meta-row">
<div class="r-author" v-if="drawer.articleAuthorName">
<svg class="r-author-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" fill="currentColor"/>
</svg>
<span>{{ drawer.articleAuthorName }}</span>
</div>
<!-- 上一篇/下一篇导航 -->
<div class="r-nav-btns">
<button
class="r-nav-btn"
:class="{ disabled: !prevSlug }"
:disabled="!prevSlug"
@click="onPrev"
data-tooltip="上一篇"
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 19l-7-7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button
class="r-nav-btn"
:class="{ disabled: !nextSlug }"
:disabled="!nextSlug"
@click="onNext"
data-tooltip="下一篇"
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
<div class="r-desc" v-if="description">{{ description }}</div>
<div class="r-divider" />
</div>
<!-- 评论列表区域可滚动 -->
<div class="r-comments-scroll" ref="commentsScrollRef">
<section class="r-comments" aria-label="评论区">
<div class="c-empty" v-if="comments.length === 0">暂无评论快来发表第一条吧~</div>
<ul class="c-list" v-else ref="commentsListRef">
<li v-for="c in comments" :key="c.id" class="c-wrapper" :class="{ mine: c.isMine }">
<!-- 作者信息在气泡外部 -->
<div class="c-meta">
<div class="c-author-wrap">
<span class="c-author">{{ c.author || '匿名用户' }}</span>
<span v-if="c.isAdmin" class="c-badge admin">admin</span>
<span v-if="c.isAuthor" class="c-badge author">作者</span>
</div>
<span class="c-time">{{ c.time || '刚刚' }}</span>
</div>
<!-- 消息气泡 -->
<div class="c-bubble">
<p class="c-text">{{ c.text || c.body || '(无内容)' }}</p>
<!-- 删除按钮悬停时显示 -->
<button
v-if="canDelete(c)"
class="c-delete-btn"
:title="c.isMine ? '撤回评论' : '删除评论'"
@click="handleDeleteComment(c)"
aria-label="删除评论"
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" fill="currentColor"/>
</svg>
</button>
</div>
</li>
</ul>
</section>
</div>
<!-- 评论输入框固定在底部 -->
<form class="c-form-fixed" @submit.prevent="handleSubmit">
<textarea
v-model.trim="draft"
class="c-input"
:placeholder="commentPlaceholder"
rows="3"
:disabled="!isLoggedIn"
/>
<div class="c-actions">
<button
v-if="isLoggedIn"
type="submit"
class="c-btn"
:disabled="!draft"
>
发布
</button>
<button
v-else
type="button"
class="c-btn c-btn-login"
@click="goToLogin"
>
登录后评论
</button>
</div>
</form>
</div>
</template>
</aside>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRightDrawer } from '@/composables/useRightDrawer'
import { useToast } from '@/composables/useToast'
const drawer = useRightDrawer()
const toast = useToast()
const router = useRouter()
const { isCollapsed, title, description, draft, canComment, favorited, favoritesCount, prevSlug, nextSlug, isLoggedIn } = drawer
const commentsScrollRef = ref(null)
const commentsListRef = ref(null)
const comments = computed(() => {
const result = drawer.commentsOf()
console.log('[RightDrawer.vue] computed comments:', result)
return result
})
const commentPlaceholder = computed(() =>
isLoggedIn?.value ? '写下你的想法吧~' : '登录后即可发表评论~',
)
// 滚动到最新评论
function scrollToBottom() {
nextTick(() => {
if (commentsScrollRef.value) {
commentsScrollRef.value.scrollTop = commentsScrollRef.value.scrollHeight
}
})
}
// 监听评论列表变化,自动滚动到底部
watch(comments, (newComments, oldComments) => {
// 只有在评论数量增加时才滚动(新评论发布)
if (newComments.length > (oldComments?.length || 0)) {
scrollToBottom()
}
}, { deep: true })
// 组件挂载时滚动到底部并启动轮询
onMounted(() => {
scrollToBottom()
// 启动评论轮询
drawer.startPolling()
})
// 组件卸载时停止轮询
onBeforeUnmount(() => {
drawer.stopPolling()
})
function toggle () { drawer.toggle() }
function closeToHome () { drawer.closeToHome() }
function submitComment () { drawer.submitComment() }
// 处理提交(已登录时发布评论)
function handleSubmit () {
if (isLoggedIn?.value) {
submitComment()
}
}
// 跳转到登录页
function goToLogin () {
router.push('/login')
}
/* 删除评论 */
async function handleDeleteComment (comment) {
if (!comment?.id) return
await drawer.deleteComment(comment.id, comment)
}
/* 检查是否可以删除评论 */
function canDelete (comment) {
return drawer.canDeleteComment(comment)
}
/* 工具栏按钮最小实现 */
function onFavorite () {
if (typeof drawer.favorite === 'function') drawer.favorite()
}
function onExpand () {
if (typeof drawer.expand === 'function') drawer.expand()
else toggle()
}
function onPrev () {
if (typeof drawer.prev === 'function') drawer.prev()
}
function onNext () {
if (typeof drawer.next === 'function') drawer.next()
}
/* 分享:复制当前文章链接 */
async function onShare () {
if (!process.client) return
try {
const url = window.location.href
// 尝试使用现代 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url)
toast.success('链接已复制成功', '可以分享了!', url)
} else {
// 降级方案:使用传统的 execCommand
const textarea = document.createElement('textarea')
textarea.value = url
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const success = document.execCommand('copy')
document.body.removeChild(textarea)
if (success) {
toast.success('链接已复制成功', '可以分享了!', url)
} else {
toast.error('复制失败', '请手动复制链接', url)
}
}
} catch (err) {
console.error('[RightDrawer] 复制链接失败:', err)
toast.error('复制失败', '请手动复制链接', window.location.href)
}
}
/* 快捷键Esc 返回;[ 折叠;] 展开 */
function onKey (e) {
if (e.key === 'Escape') closeToHome()
if (e.key === '[' && typeof drawer.collapse === 'function') drawer.collapse()
if (e.key === ']' && typeof drawer.expand === 'function') drawer.expand()
}
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<style scoped>
.r-drawer{
position: fixed; right: 0; top: 0; bottom: 0;
width: 420px;
background: rgba(255,255,255,.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-left: 1px solid rgba(17,24,39,.08);
box-shadow: -8px 0 24px rgba(17,24,39,.06);
z-index: 70;
display: flex; flex-direction: column;
transition: width .22s cubic-bezier(.22,1,.36,1);
}
.r-drawer.collapsed{ width: 44px; }
/* 内嵌模式:由父布局控制位置/宽度,不再固定在右侧 */
.r-drawer.rd-embed{
position: static;
inset: auto;
width: 100%;
height: 100%;
max-width: 100%;
background: transparent;
border-left: 0;
box-shadow: none;
}
/* 收起时的纵向工具条 */
.r-bar{
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 6px;
}
.r-bar-btn{
width: 32px; height: 32px;
border: 1px solid rgba(17,24,39,.12);
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 16px;
}
.r-bar-btn:hover{ background:#f3f4f6; }
.r-bar-btn:disabled{
cursor: not-allowed;
opacity: .5;
}
.r-bar-btn.active{
color:#f59e0b;
}
.r-bar-btn.danger{ color:#b91c1c; }
.r-bar-sep{ width: 24px; height: 1px; background: rgba(17,24,39,.08); margin: 2px 0; }
/* 展开时 */
.r-head{
display:flex; align-items:center; justify-content:space-between;
height: 56px; padding: 0 8px 0 10px;
border-bottom: 1px solid rgba(17,24,39,.06);
}
.r-tools{ display:flex; gap:8px; }
.r-tool, .r-action{
width:34px; height:34px; border-radius:8px; border:1px solid rgba(17,24,39,.08);
background:#fff; cursor:pointer; font-size:16px;
}
.r-tool:hover, .r-action:hover{ background:#f3f4f6; }
.r-tool.active{
color:#f59e0b;
border-color:#fcd34d;
}
.r-action.close{ font-weight:700; }
.r-body{
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* 文章信息区域 */
.r-info {
flex-shrink: 0;
padding: 14px 14px 0;
}
.r-title{ font-size: 18px; font-weight: 800; color:#111827; line-height:1.4; }
/* 作者信息和导航按钮行 */
.r-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
gap: 12px;
}
/* 作者信息 */
.r-author {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #f9fafb;
border-radius: 8px;
flex-shrink: 0;
}
.r-author-icon {
width: 16px;
height: 16px;
color: #6b7280;
flex-shrink: 0;
}
.r-author span {
font-size: 13px;
color: #374151;
font-weight: 500;
}
/* 导航按钮组 */
.r-nav-btns {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.r-nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
position: relative;
}
.r-nav-btn svg {
width: 18px;
height: 18px;
}
.r-nav-btn:hover:not(:disabled) {
background: #f3f4f6;
border-color: #d1d5db;
transform: translateY(-1px);
}
.r-nav-btn:active:not(:disabled) {
transform: translateY(0);
}
.r-nav-btn:disabled,
.r-nav-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
background: #f9fafb;
}
.r-nav-btn:disabled:hover,
.r-nav-btn.disabled:hover {
transform: none;
background: #f9fafb;
border-color: #e5e7eb;
}
/* 自定义 Tooltip 样式 */
.r-nav-btn[data-tooltip]:not(:disabled)::before {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) scale(0.9);
padding: 6px 10px;
background: #1f2937;
color: #fff;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
border-radius: 6px;
opacity: 0;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.r-nav-btn[data-tooltip]:not(:disabled)::after {
content: '';
position: absolute;
bottom: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #1f2937;
opacity: 0;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 1000;
}
.r-nav-btn[data-tooltip]:not(:disabled):hover::before {
opacity: 1;
transform: translateX(-50%) scale(1);
}
.r-nav-btn[data-tooltip]:not(:disabled):hover::after {
opacity: 1;
}
.r-desc{ margin-top: 8px; color:#4b5563; font-size: 14px; }
.r-divider{ height: 1px; background: rgba(17,24,39,.08); margin: 14px 0; }
/* 评论滚动区域 */
.r-comments-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 14px;
margin-bottom: 8px;
}
/* 自定义滚动条样式 */
.r-comments-scroll::-webkit-scrollbar {
width: 6px;
}
.r-comments-scroll::-webkit-scrollbar-track {
background: transparent;
}
.r-comments-scroll::-webkit-scrollbar-thumb {
background: rgba(17,24,39,.12);
border-radius: 3px;
}
.r-comments-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(17,24,39,.2);
}
/* 评论区 */
.c-list{
list-style:none;
padding:0;
margin:0 0 10px;
display:flex;
flex-direction:column;
gap:16px;
}
/* 评论容器 */
.c-wrapper{
display: flex;
flex-direction: column;
gap: 6px;
max-width: 85%;
align-self: flex-start;
}
.c-wrapper.mine{
align-self: flex-end;
}
/* 作者信息(在气泡外部) */
.c-meta{
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 4px;
}
.c-wrapper.mine .c-meta{
flex-direction: row-reverse;
}
.c-author-wrap{
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.c-author{
font-weight: 600;
color: #374151;
font-size: 13px;
}
.c-time{
color: #9ca3af;
font-size: 11px;
white-space: nowrap;
}
.c-badge{
padding: 2px 6px;
border-radius: 999px;
font-size: 10px;
line-height: 1.2;
background: #e5e7eb;
color: #374151;
font-weight: 600;
}
.c-badge.admin{
background: #fee2e2;
color: #b91c1c;
}
.c-badge.author{
background: #dcfce7;
color: #15803d;
}
/* 消息气泡 */
.c-bubble{
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 16px;
border-top-left-radius: 4px;
padding: 10px 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: all 0.15s ease;
position: relative;
}
.c-bubble:hover{
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #d1d5db;
}
.c-wrapper.mine .c-bubble{
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
border-color: #c7d2fe;
border-top-left-radius: 16px;
border-top-right-radius: 4px;
}
.c-wrapper.mine .c-bubble:hover{
border-color: #a5b4fc;
}
.c-text{
margin: 0;
color: #1f2937;
line-height: 1.6;
font-size: 14px;
word-wrap: break-word;
white-space: pre-wrap;
padding-right: 24px;
}
.c-wrapper.mine .c-text{
color: #1e40af;
}
/* 删除按钮 */
.c-delete-btn{
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
border: none;
background: rgba(255, 255, 255, 0.9);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s ease;
color: #9ca3af;
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.c-delete-btn svg{
width: 14px;
height: 14px;
}
.c-bubble:hover .c-delete-btn{
opacity: 1;
}
.c-delete-btn:hover{
background: #fee2e2;
color: #dc2626;
transform: scale(1.1);
}
.c-delete-btn:active{
transform: scale(0.95);
}
.c-wrapper.mine .c-delete-btn{
background: rgba(255, 255, 255, 0.95);
}
.c-wrapper.mine .c-delete-btn:hover{
background: #fee2e2;
}
.c-empty{ color:#6b7280; text-align:center; padding:10px 6px; }
/* 固定在底部的评论输入框 */
.c-form-fixed {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px 16px;
background: #fff;
border-top: 1px solid rgba(17,24,39,.06);
box-shadow: 0 -2px 8px rgba(17,24,39,.04);
}
.c-input{
resize: none;
min-height: 70px;
max-height: 70px;
border: 1px solid rgba(17,24,39,.12);
border-radius: 10px;
padding: 10px;
outline: none;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.c-input:focus{
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99,102,241,.18);
}
.c-input:disabled {
background: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
.c-input::placeholder {
color: #9ca3af;
}
.c-actions{
display: flex;
justify-content: flex-end;
}
.c-btn{
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 8px;
background: #111827;
color: #fff;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.c-btn:hover:not(:disabled) {
background: #1f2937;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(17,24,39,.15);
}
.c-btn:active:not(:disabled) {
transform: translateY(0);
}
.c-btn:disabled{
opacity: .5;
cursor: not-allowed;
}
/* 登录按钮样式 */
.c-btn-login {
background: linear-gradient(135deg, #6366f1, #4f46e5);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.c-btn-login:hover {
background: linear-gradient(135deg, #4f46e5, #4338ca);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
}
</style>