mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
1393 lines
34 KiB
Vue
1393 lines
34 KiB
Vue
<template>
|
||
<div class="post-page-container">
|
||
<div v-if="isWaitingFetchingPost" class="loading-container">
|
||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||
</div>
|
||
<div v-else class="post-page-main-container" ref="mainContainer">
|
||
<div class="article-title-container">
|
||
<div class="article-title-container-left">
|
||
<div class="article-title">{{ title }}</div>
|
||
<div class="article-info-container">
|
||
<ArticleCategory :category="category" />
|
||
<ArticleTags :tags="tags" />
|
||
</div>
|
||
</div>
|
||
<div class="article-title-container-right">
|
||
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
||
<div v-if="closed" class="article-closed-button">已关闭</div>
|
||
<div
|
||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||
class="article-subscribe-button"
|
||
@click="subscribePost"
|
||
>
|
||
<people-plus />
|
||
<div class="article-subscribe-button-text">
|
||
{{ isMobile ? '订阅' : '订阅文章' }}
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="!closed && loggedIn && !isAuthor && subscribed"
|
||
class="article-unsubscribe-button"
|
||
@click="unsubscribePost"
|
||
>
|
||
<people-minus-one />
|
||
<div class="article-unsubscribe-button-text">
|
||
{{ isMobile ? '退订' : '取消订阅' }}
|
||
</div>
|
||
</div>
|
||
<DropdownMenu v-if="articleMenuItems.length > 0" :items="articleMenuItems">
|
||
<template #trigger>
|
||
<more-one class="action-menu-icon" />
|
||
</template>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-content-container author-info-container">
|
||
<div class="user-avatar-container" @click="gotoProfile">
|
||
<div class="user-avatar-item">
|
||
<BaseUserAvatar
|
||
class="user-avatar-item-img"
|
||
:src="author.avatar"
|
||
:user-id="author.id"
|
||
alt="avatar"
|
||
:disable-link="true"
|
||
/>
|
||
</div>
|
||
<div v-if="isMobile" class="info-content-header">
|
||
<div class="user-name">
|
||
{{ author.username }}
|
||
<medal-one class="medal-icon" />
|
||
<NuxtLink
|
||
v-if="author.displayMedal"
|
||
class="user-medal"
|
||
:to="`/users/${author.id}?tab=achievements`"
|
||
>{{ getMedalTitle(author.displayMedal) }}</NuxtLink
|
||
>
|
||
</div>
|
||
<div class="post-time">{{ postTime }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-content">
|
||
<div v-if="!isMobile" class="info-content-header">
|
||
<div class="user-name">
|
||
{{ author.username }}
|
||
<medal-one class="medal-icon" />
|
||
<NuxtLink
|
||
v-if="author.displayMedal"
|
||
class="user-medal"
|
||
:to="`/users/${author.id}?tab=achievements`"
|
||
>{{ getMedalTitle(author.displayMedal) }}</NuxtLink
|
||
>
|
||
</div>
|
||
<div class="post-time">{{ postTime }}</div>
|
||
</div>
|
||
<div
|
||
class="info-content-text"
|
||
v-html="renderMarkdown(postContent)"
|
||
@click="handleContentClick"
|
||
></div>
|
||
|
||
<div class="article-footer-container">
|
||
<div class="option-container">
|
||
<ReactionsGroup
|
||
ref="postReactionsGroupRef"
|
||
v-model="postReactions"
|
||
content-type="post"
|
||
:content-id="postId"
|
||
/>
|
||
<DonateGroup :post-id="postId" :author-id="author.id" :is-author="isAuthor" />
|
||
</div>
|
||
<div class="article-footer-actions">
|
||
<div
|
||
class="reaction-action like-action"
|
||
:class="{ selected: postLikedByMe }"
|
||
@click="togglePostLike"
|
||
>
|
||
<like v-if="!postLikedByMe" />
|
||
<like v-else theme="filled" />
|
||
<span v-if="postLikeCount" class="reaction-count">{{ postLikeCount }}</span>
|
||
</div>
|
||
<div class="reaction-action copy-link" @click="copyPostLink">
|
||
<link-icon />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<PostLottery v-if="lottery" :lottery="lottery" :post-id="postId" @refresh="refreshPost" />
|
||
<ClientOnly>
|
||
<PostPoll v-if="poll" :poll="poll" :post-id="postId" @refresh="refreshPost" />
|
||
</ClientOnly>
|
||
<div v-if="closed" class="post-close-container">该帖子已关闭,内容仅供阅读,无法进行互动</div>
|
||
|
||
<ClientOnly>
|
||
<CommentEditor
|
||
@submit="postComment"
|
||
:loading="isWaitingPostingComment"
|
||
:disabled="!loggedIn || closed"
|
||
:show-login-overlay="!loggedIn"
|
||
:parent-user-name="author.username"
|
||
/>
|
||
</ClientOnly>
|
||
|
||
<div class="comment-config-container">
|
||
<div class="comment-sort-container">
|
||
<div class="comment-sort-title">Sort by:</div>
|
||
<Dropdown v-model="commentSort" :fetch-options="fetchCommentSorts" />
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isFetchingComments" class="loading-container">
|
||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||
</div>
|
||
<div v-else class="comments-container">
|
||
<BasePlaceholder v-if="timelineItems.length === 0" text="暂无评论" icon="inbox" />
|
||
<BaseTimeline v-else :items="timelineItems">
|
||
<template #item="{ item }">
|
||
<CommentItem
|
||
v-if="item.kind === 'comment'"
|
||
:key="item.id"
|
||
:comment="item"
|
||
:level="0"
|
||
:default-show-replies="item.openReplies"
|
||
:post-author-id="author.id"
|
||
:post-closed="closed"
|
||
@deleted="onCommentDeleted"
|
||
/>
|
||
<PostChangeLogItem v-else :log="item" :title="title" />
|
||
</template>
|
||
</BaseTimeline>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="post-page-scroller-container">
|
||
<div class="scroller">
|
||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||
<div v-else class="scroller-time">{{ scrollerTopTime }}</div>
|
||
<div class="scroller-middle">
|
||
<input
|
||
type="range"
|
||
class="scroller-range"
|
||
:max="totalPosts"
|
||
:min="1"
|
||
v-model.number="currentIndex"
|
||
@input="onSliderInput"
|
||
/>
|
||
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
|
||
</div>
|
||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
|
||
</div>
|
||
</div>
|
||
<vue-easy-lightbox
|
||
:visible="lightboxVisible"
|
||
:index="lightboxIndex"
|
||
:imgs="lightboxImgs"
|
||
@hide="lightboxVisible = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import {
|
||
ref,
|
||
computed,
|
||
onMounted,
|
||
onBeforeUnmount,
|
||
nextTick,
|
||
watch,
|
||
watchEffect,
|
||
onActivated,
|
||
} from 'vue'
|
||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||
import { useRoute } from 'vue-router'
|
||
import CommentItem from '~/components/CommentItem.vue'
|
||
import CommentEditor from '~/components/CommentEditor.vue'
|
||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||
import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
|
||
import ArticleTags from '~/components/ArticleTags.vue'
|
||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||
import DonateGroup from '~/components/DonateGroup.vue'
|
||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||
import PostLottery from '~/components/PostLottery.vue'
|
||
import PostPoll from '~/components/PostPoll.vue'
|
||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
|
||
import { getMedalTitle } from '~/utils/medal'
|
||
import { toast } from '~/main'
|
||
import { getToken, authState } from '~/utils/auth'
|
||
import TimeManager from '~/utils/time'
|
||
import { useIsMobile } from '~/utils/screen'
|
||
import Dropdown from '~/components/Dropdown.vue'
|
||
import { ClientOnly } from '#components'
|
||
import { useConfirm } from '~/composables/useConfirm'
|
||
const { confirm } = useConfirm()
|
||
|
||
const config = useRuntimeConfig()
|
||
const API_BASE_URL = config.public.apiBaseUrl
|
||
|
||
const route = useRoute()
|
||
const postId = route.params.id
|
||
|
||
const title = ref('')
|
||
const author = ref('')
|
||
const postContent = ref('')
|
||
const category = ref('')
|
||
const tags = ref([])
|
||
const postReactions = ref([])
|
||
const postReactionsGroupRef = ref(null)
|
||
const postLikeCount = computed(
|
||
() => postReactions.value.filter((reaction) => reaction.type === 'LIKE').length,
|
||
)
|
||
const postLikedByMe = computed(() =>
|
||
postReactions.value.some(
|
||
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
|
||
),
|
||
)
|
||
const togglePostLike = () => {
|
||
postReactionsGroupRef.value?.toggleReaction('LIKE')
|
||
}
|
||
const comments = ref([])
|
||
const changeLogs = ref([])
|
||
const status = ref('PUBLISHED')
|
||
const closed = ref(false)
|
||
const pinnedAt = ref(null)
|
||
const rssExcluded = ref(false)
|
||
const isWaitingPostingComment = ref(false)
|
||
const postTime = ref('')
|
||
const postItems = ref([])
|
||
const mainContainer = ref(null)
|
||
const currentIndex = ref(1)
|
||
const subscribed = ref(false)
|
||
const commentSort = ref('NEWEST')
|
||
const isFetchingComments = ref(false)
|
||
const isMobile = useIsMobile()
|
||
const timelineItems = ref([])
|
||
|
||
const headerHeight = import.meta.client
|
||
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
|
||
: 0
|
||
|
||
useHead(() => ({
|
||
title: title.value ? `OpenIsle - ${title.value}` : 'OpenIsle',
|
||
meta: [
|
||
{
|
||
name: 'description',
|
||
content: stripMarkdownLength(postContent.value, 400),
|
||
},
|
||
],
|
||
}))
|
||
|
||
if (import.meta.client) {
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('scroll', updateCurrentIndex)
|
||
})
|
||
}
|
||
|
||
const lightboxVisible = ref(false)
|
||
const lightboxIndex = ref(0)
|
||
const lightboxImgs = ref([])
|
||
const loggedIn = computed(() => authState.loggedIn)
|
||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||
const isAuthor = computed(() => authState.username === author.value.username)
|
||
const lottery = ref(null)
|
||
const poll = ref(null)
|
||
const articleMenuItems = computed(() => {
|
||
const items = []
|
||
if (isAuthor.value || isAdmin.value) {
|
||
items.push({ text: '编辑文章', onClick: () => editPost() })
|
||
items.push({ text: '删除文章', color: 'red', onClick: deletePost })
|
||
if (closed.value) {
|
||
items.push({ text: '重新打开帖子', onClick: () => reopenPost() })
|
||
} else {
|
||
items.push({ text: '关闭帖子', onClick: () => closePost() })
|
||
}
|
||
}
|
||
if (isAdmin.value) {
|
||
if (pinnedAt.value) {
|
||
items.push({ text: '取消置顶', onClick: () => unpinPost() })
|
||
} else {
|
||
items.push({ text: '置顶', onClick: () => pinPost() })
|
||
}
|
||
if (rssExcluded.value) {
|
||
items.push({ text: 'rss推荐', onClick: () => includeRss() })
|
||
} else {
|
||
items.push({ text: '取消rss推荐', onClick: () => excludeRss() })
|
||
}
|
||
}
|
||
if (isAdmin.value && status.value === 'PENDING') {
|
||
items.push({ text: '通过审核', onClick: () => approvePost() })
|
||
items.push({ text: '驳回', color: 'red', onClick: () => rejectPost() })
|
||
}
|
||
return items
|
||
})
|
||
|
||
const gatherPostItems = () => {
|
||
const items = []
|
||
if (mainContainer.value) {
|
||
const main = mainContainer.value.querySelector('.info-content-container')
|
||
if (main) items.push({ el: main, top: getTop(main) })
|
||
|
||
for (const c of timelineItems.value) {
|
||
let el
|
||
if (c.kind === 'comment') {
|
||
el = document.getElementById('comment-' + c.id)
|
||
} else {
|
||
el = document.getElementById('change-log-' + c.id)
|
||
}
|
||
if (el) {
|
||
items.push({ el, top: getTop(el) })
|
||
}
|
||
}
|
||
// 根据 top 排序,防止评论异步插入后顺序错乱
|
||
items.sort((a, b) => a.top - b.top)
|
||
postItems.value = items.map((i) => i.el)
|
||
}
|
||
}
|
||
|
||
const mapComment = (
|
||
c,
|
||
parentUserName = '',
|
||
parentUserAvatar = '',
|
||
parentUserId = '',
|
||
level = 0,
|
||
) => ({
|
||
id: c.id,
|
||
kind: 'comment',
|
||
userName: c.author.username,
|
||
medal: c.author.displayMedal,
|
||
userId: c.author.id,
|
||
time: TimeManager.format(c.createdAt),
|
||
avatar: c.author.avatar,
|
||
text: c.content,
|
||
reactions: c.reactions || [],
|
||
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||
reply: (c.replies || []).map((r) =>
|
||
mapComment(r, c.author.username, c.author.avatar, c.author.id, level + 1),
|
||
),
|
||
openReplies: level === 0,
|
||
src: c.author.avatar,
|
||
createdAt: c.createdAt,
|
||
iconClick: () => navigateTo(`/users/${c.author.id}`),
|
||
parentUserName: parentUserName,
|
||
parentUserAvatar: parentUserAvatar,
|
||
parentUserId: parentUserId,
|
||
})
|
||
|
||
const changeLogIcon = (l) => {
|
||
if (l.type === 'CONTENT') {
|
||
return 'edit'
|
||
} else if (l.type === 'TITLE') {
|
||
return 'hashtag-key'
|
||
} else if (l.type === 'CATEGORY') {
|
||
return 'tag-one'
|
||
} else if (l.type === 'TAG') {
|
||
return 'tag-one'
|
||
} else if (l.type === 'CLOSED') {
|
||
if (l.newClosed) {
|
||
return 'lock-one'
|
||
} else {
|
||
return 'unlock'
|
||
}
|
||
} else if (l.type === 'PINNED') {
|
||
if (l.newPinnedAt) {
|
||
return 'pin'
|
||
} else {
|
||
return 'clear-icon'
|
||
}
|
||
} else if (l.type === 'FEATURED') {
|
||
if (l.newFeatured) {
|
||
return 'star'
|
||
} else {
|
||
return 'dislike'
|
||
}
|
||
} else if (l.type === 'VOTE_RESULT') {
|
||
return 'check-one'
|
||
} else if (l.type === 'LOTTERY_RESULT') {
|
||
return 'gift'
|
||
} else if (l.type === 'DONATE') {
|
||
return 'financing'
|
||
} else {
|
||
return 'info'
|
||
}
|
||
}
|
||
|
||
const mapChangeLog = (l) => ({
|
||
id: l.id,
|
||
kind: 'log',
|
||
username: l.username,
|
||
userAvatar: l.userAvatar,
|
||
type: l.type,
|
||
createdAt: l.time,
|
||
time: TimeManager.format(l.time),
|
||
newClosed: l.newClosed,
|
||
newPinnedAt: l.newPinnedAt,
|
||
newFeatured: l.newFeatured,
|
||
oldContent: l.oldContent,
|
||
newContent: l.newContent,
|
||
oldTitle: l.oldTitle,
|
||
newTitle: l.newTitle,
|
||
oldCategory: l.oldCategory,
|
||
newCategory: l.newCategory,
|
||
oldTags: l.oldTags,
|
||
newTags: l.newTags,
|
||
amount: l.amount,
|
||
icon: changeLogIcon(l),
|
||
})
|
||
|
||
const getTop = (el) => {
|
||
return el.getBoundingClientRect().top + window.scrollY
|
||
}
|
||
|
||
const findCommentPath = (id, list) => {
|
||
for (const item of list) {
|
||
if (item.id === Number(id) || item.id === id) {
|
||
return [item]
|
||
}
|
||
if (item.reply && item.reply.length) {
|
||
const sub = findCommentPath(id, item.reply)
|
||
if (sub) return [item, ...sub]
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
const expandCommentPath = (id) => {
|
||
const path = findCommentPath(id, comments.value)
|
||
if (!path) return
|
||
for (let i = 0; i < path.length - 1; i++) {
|
||
path[i].openReplies = true
|
||
}
|
||
}
|
||
|
||
const removeCommentFromList = (id, list) => {
|
||
for (let i = 0; i < list.length; i++) {
|
||
const item = list[i]
|
||
if (item.id === id) {
|
||
list.splice(i, 1)
|
||
return true
|
||
}
|
||
if (item.reply && item.reply.length) {
|
||
if (removeCommentFromList(id, item.reply)) return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
const handleContentClick = (e) => {
|
||
handleMarkdownClick(e)
|
||
if (e.target.tagName === 'IMG' && !e.target.classList.contains('emoji')) {
|
||
const container = e.target.parentNode
|
||
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
|
||
lightboxImgs.value = imgs
|
||
lightboxIndex.value = imgs.indexOf(e.target.src)
|
||
lightboxVisible.value = true
|
||
}
|
||
}
|
||
|
||
const onCommentDeleted = (id) => {
|
||
removeCommentFromList(Number(id), comments.value)
|
||
fetchTimeline()
|
||
}
|
||
|
||
const {
|
||
data: postData,
|
||
pending: pendingPost,
|
||
error: postError,
|
||
refresh: refreshPost,
|
||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||
server: true,
|
||
lazy: false,
|
||
})
|
||
|
||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||
|
||
// 同步到现有的响应式字段
|
||
watchEffect(() => {
|
||
const data = postData.value
|
||
if (!data) return
|
||
postContent.value = data.content
|
||
author.value = data.author
|
||
title.value = data.title
|
||
category.value = data.category
|
||
tags.value = data.tags || []
|
||
postReactions.value = data.reactions || []
|
||
subscribed.value = !!data.subscribed
|
||
status.value = data.status
|
||
closed.value = data.closed
|
||
pinnedAt.value = data.pinnedAt
|
||
rssExcluded.value = data.rssExcluded
|
||
postTime.value = TimeManager.format(data.createdAt)
|
||
lottery.value = data.lottery || null
|
||
poll.value = data.poll || null
|
||
})
|
||
|
||
// 404 客户端跳转
|
||
// if (postError.value?.statusCode === 404 && import.meta.client) {
|
||
// router.replace('/404')
|
||
// }
|
||
|
||
const totalPosts = computed(() => timelineItems.value.length + 1)
|
||
const lastReplyTime = computed(() =>
|
||
timelineItems.value.length
|
||
? timelineItems.value[timelineItems.value.length - 1].time
|
||
: postTime.value,
|
||
)
|
||
const firstReplyTime = computed(() =>
|
||
timelineItems.value.length ? timelineItems.value[0].time : postTime.value,
|
||
)
|
||
const scrollerTopTime = computed(() =>
|
||
commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value,
|
||
)
|
||
|
||
watch(
|
||
() => timelineItems.value.length,
|
||
async () => {
|
||
await nextTick()
|
||
gatherPostItems()
|
||
updateCurrentIndex()
|
||
},
|
||
)
|
||
|
||
const updateCurrentIndex = () => {
|
||
const scrollTop = window.scrollY
|
||
|
||
for (let i = 0; i < postItems.value.length; i++) {
|
||
const el = postItems.value[i]
|
||
const top = getTop(el)
|
||
const bottom = top + el.offsetHeight
|
||
|
||
if (bottom > scrollTop) {
|
||
currentIndex.value = i + 1
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
const onSliderInput = (e) => {
|
||
const index = Number(e.target.value)
|
||
currentIndex.value = index
|
||
const target = postItems.value[index - 1]
|
||
if (target) {
|
||
const top = getTop(target) - headerHeight - 20 // 20 for beauty
|
||
window.scrollTo({ top, behavior: 'auto' })
|
||
}
|
||
}
|
||
|
||
const postComment = async (parentUserName, text, clear) => {
|
||
if (!text.trim()) return
|
||
if (closed.value) {
|
||
toast.error('帖子已关闭')
|
||
return
|
||
}
|
||
console.debug('Posting comment', { postId, text })
|
||
isWaitingPostingComment.value = true
|
||
const token = getToken()
|
||
if (!token) {
|
||
toast.error('请先登录')
|
||
isWaitingPostingComment.value = false
|
||
return
|
||
}
|
||
try {
|
||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/comments`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||
body: JSON.stringify({ content: text }),
|
||
})
|
||
console.debug('Post comment response status', res.status)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
console.debug('Post comment response data', data)
|
||
await fetchTimeline()
|
||
clear()
|
||
if (data.reward && data.reward > 0) {
|
||
toast.success(`评论成功,获得 ${data.reward} 经验值`)
|
||
} else {
|
||
toast.success('评论成功')
|
||
}
|
||
} else if (res.status === 429) {
|
||
toast.error('评论过于频繁,请稍后再试')
|
||
} else {
|
||
toast.error(`评论失败: ${res.status} ${res.statusText}`)
|
||
}
|
||
} catch (e) {
|
||
console.debug('Post comment error', e)
|
||
toast.error(`评论失败: ${e.message}`)
|
||
} finally {
|
||
isWaitingPostingComment.value = false
|
||
}
|
||
}
|
||
|
||
const copyPostLink = () => {
|
||
navigator.clipboard.writeText(location.href.split('#')[0]).then(() => {
|
||
toast.success('已复制')
|
||
})
|
||
}
|
||
|
||
const subscribePost = async () => {
|
||
const token = getToken()
|
||
if (!token) {
|
||
toast.error('请先登录')
|
||
return
|
||
}
|
||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
subscribed.value = true
|
||
toast.success('已订阅')
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const approvePost = async () => {
|
||
const token = getToken()
|
||
if (!token) return
|
||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/approve`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
status.value = 'PUBLISHED'
|
||
toast.success('已通过审核')
|
||
await refreshPost()
|
||
await fetchTimeline()
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const pinPost = async () => {
|
||
const token = getToken()
|
||
if (!token) return
|
||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/pin`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
toast.success('已置顶')
|
||
await refreshPost()
|
||
await fetchTimeline()
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const unpinPost = async () => {
|
||
const token = getToken()
|
||
if (!token) return
|
||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/unpin`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
toast.success('已取消置顶')
|
||
await refreshPost()
|
||
await fetchTimeline()
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const excludeRss = async () => {
|
||
const token = getToken()
|
||
if (!token) return
|
||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-exclude`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
rssExcluded.value = true
|
||
toast.success('已标记为rss不推荐')
|
||
await fetchTimeline()
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const includeRss = async () => {
|
||
const token = getToken()
|
||
if (!token) return
|
||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-include`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
rssExcluded.value = false
|
||
toast.success('已标记为rss推荐')
|
||
await refreshPost()
|
||
await fetchTimeline()
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const closePost = async () => {
|
||
const token = getToken()
|
||
if (!token) return
|
||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/close`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
closed.value = true
|
||
toast.success('已关闭')
|
||
await refreshPost()
|
||
await fetchTimeline()
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const reopenPost = async () => {
|
||
const token = getToken()
|
||
if (!token) return
|
||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/reopen`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
closed.value = false
|
||
toast.success('已重新打开')
|
||
await refreshPost()
|
||
await fetchTimeline()
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const editPost = () => {
|
||
navigateTo(`/posts/${postId}/edit`, { replace: true })
|
||
}
|
||
|
||
const deletePost = async () => {
|
||
try {
|
||
const ok = await confirm('删除帖子', '此操作不可恢复,确认要删除吗?')
|
||
if (!ok) return
|
||
const token = getToken()
|
||
if (!token) {
|
||
toast.error('请先登录')
|
||
return
|
||
}
|
||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
toast.success('已删除')
|
||
navigateTo('/', { replace: true })
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
} catch (e) {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const rejectPost = async () => {
|
||
const token = getToken()
|
||
if (!token) return
|
||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/reject`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
status.value = 'REJECTED'
|
||
toast.success('已驳回')
|
||
await refreshPost()
|
||
await fetchTimeline()
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
const unsubscribePost = async () => {
|
||
const token = getToken()
|
||
if (!token) {
|
||
toast.error('请先登录')
|
||
return
|
||
}
|
||
|
||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
if (res.ok) {
|
||
subscribed.value = false
|
||
toast.success('已取消订阅')
|
||
} else {
|
||
toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
const fetchCommentSorts = () => {
|
||
return Promise.resolve([
|
||
{ id: 'NEWEST', name: '最新', icon: 'lightning' },
|
||
{ id: 'OLDEST', name: '最旧', icon: 'history-icon' },
|
||
// { id: 'MOST_INTERACTIONS', name: '最多互动', icon: 'fas fa-fire' }
|
||
])
|
||
}
|
||
|
||
const fetchCommentsAndChangeLog = async () => {
|
||
isFetchingComments.value = true
|
||
console.info('Fetching comments and chang log', { postId, sort: commentSort.value })
|
||
try {
|
||
const token = getToken()
|
||
const res = await fetch(
|
||
`${API_BASE_URL}/api/posts/${postId}/comments?sort=${commentSort.value}`,
|
||
{
|
||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||
},
|
||
)
|
||
console.info('Fetch comments response status', res.status)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
console.info('Fetched comments data', data)
|
||
|
||
const commentList = []
|
||
const changeLogList = []
|
||
// 时间线列表,包含评论和日志
|
||
const newTimelineItemList = []
|
||
|
||
for (const item of data) {
|
||
const mappedPayload =
|
||
item.kind === 'comment' ? mapComment(item.payload) : mapChangeLog(item.payload)
|
||
newTimelineItemList.push(mappedPayload)
|
||
|
||
if (item.kind === 'comment') {
|
||
commentList.push(mappedPayload)
|
||
} else {
|
||
changeLogList.push(mappedPayload)
|
||
}
|
||
}
|
||
|
||
comments.value = commentList
|
||
changeLogs.value = changeLogList
|
||
timelineItems.value = newTimelineItemList
|
||
|
||
isFetchingComments.value = false
|
||
await nextTick()
|
||
gatherPostItems()
|
||
}
|
||
} catch (e) {
|
||
console.debug('Fetch comments error', e)
|
||
} finally {
|
||
isFetchingComments.value = false
|
||
}
|
||
}
|
||
|
||
const fetchTimeline = async () => {
|
||
await fetchCommentsAndChangeLog()
|
||
}
|
||
|
||
watch(commentSort, async () => {
|
||
await fetchTimeline()
|
||
})
|
||
|
||
const jumpToHashComment = async () => {
|
||
const hash = location.hash
|
||
if (hash.startsWith('#comment-')) {
|
||
const id = hash.substring('#comment-'.length)
|
||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||
const el = document.getElementById('comment-' + id)
|
||
if (el) {
|
||
const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - 20 // 20 for beauty
|
||
window.scrollTo({ top, behavior: 'smooth' })
|
||
el.classList.add('comment-highlight')
|
||
setTimeout(() => el.classList.remove('comment-highlight'), 4000)
|
||
}
|
||
}
|
||
}
|
||
|
||
const gotoProfile = () => {
|
||
navigateTo(`/users/${author.value.id}`, { replace: true })
|
||
}
|
||
|
||
const initPage = async () => {
|
||
scrollTo(0, 0)
|
||
await fetchTimeline()
|
||
const hash = location.hash
|
||
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
|
||
if (id) expandCommentPath(id)
|
||
updateCurrentIndex()
|
||
window.addEventListener('scroll', updateCurrentIndex)
|
||
jumpToHashComment()
|
||
}
|
||
|
||
onActivated(async () => {
|
||
await initPage()
|
||
})
|
||
|
||
onMounted(async () => {
|
||
await initPage()
|
||
})
|
||
</script>
|
||
|
||
<style>
|
||
.post-page-container {
|
||
background-color: var(--background-color);
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
|
||
.loading-container {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 200px;
|
||
width: 85%;
|
||
}
|
||
|
||
.post-page-main-container {
|
||
scrollbar-width: none;
|
||
padding: 20px;
|
||
width: calc(85% - 40px);
|
||
}
|
||
|
||
.info-content-text p code {
|
||
padding: 2px 4px;
|
||
}
|
||
|
||
.post-page-scroller-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 15%;
|
||
position: sticky;
|
||
top: var(--header-height);
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.comment-config-container {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
padding: 10px;
|
||
}
|
||
|
||
.comment-sort-container {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.post-close-container {
|
||
padding: 40px;
|
||
margin-top: 15px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: var(--text-color);
|
||
background-color: var(--background-color);
|
||
border: 1px dashed var(--normal-border-color);
|
||
border-radius: 10px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.scroller {
|
||
margin-top: 20px;
|
||
margin-left: 20px;
|
||
}
|
||
|
||
.scroller-time {
|
||
font-size: 14px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.user-avatar-container {
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
|
||
.scroller-middle {
|
||
margin: 10px 0;
|
||
margin-left: 10px;
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 8px;
|
||
}
|
||
|
||
.medal-icon {
|
||
font-size: 12px;
|
||
margin-left: 4px;
|
||
opacity: 0.6;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.scroller-range {
|
||
writing-mode: vertical-rl;
|
||
direction: ltr;
|
||
height: 300px;
|
||
width: 2px;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
background: transparent;
|
||
}
|
||
|
||
.scroller-range::-webkit-slider-runnable-track {
|
||
width: 1px;
|
||
height: 100%;
|
||
background-color: var(--scroller-background-color);
|
||
}
|
||
|
||
.scroller-range::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 6px;
|
||
height: 60px;
|
||
right: 2px;
|
||
border-radius: 3px;
|
||
background-color: var(--scroller-background-color);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.author-info-container {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.scroller-range::-moz-range-track {
|
||
width: 2px;
|
||
height: 100%;
|
||
background-color: #ccc;
|
||
border-radius: 1px;
|
||
}
|
||
|
||
.scroller-range::-moz-range-thumb {
|
||
width: 10px;
|
||
height: 10px;
|
||
background-color: #333;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.scroller-index {
|
||
font-size: 17px;
|
||
font-weight: bold;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.article-title-container {
|
||
display: flex;
|
||
flex-direction: row;
|
||
width: 100%;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.article-title-container-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.article-title-container-right {
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.article-subscribe-button {
|
||
background-color: var(--primary-color);
|
||
color: white;
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.article-closed-button,
|
||
.article-subscribe-button-text,
|
||
.article-featured-button,
|
||
.article-unsubscribe-button-text {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.article-subscribe-button:hover {
|
||
background-color: var(--primary-color-hover);
|
||
}
|
||
|
||
.article-unsubscribe-button {
|
||
background-color: var(--background-color);
|
||
color: var(--primary-color);
|
||
border: 1px solid var(--primary-color);
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.article-pending-button {
|
||
background-color: var(--background-color);
|
||
color: orange;
|
||
border: 1px solid orange;
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.article-block-button {
|
||
background-color: var(--background-color);
|
||
color: red;
|
||
border: 1px solid red;
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.article-featured-button {
|
||
background-color: var(--background-color);
|
||
color: var(--featured-color);
|
||
border: 1px solid var(--featured-color);
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.article-closed-button {
|
||
background-color: var(--background-color);
|
||
color: gray;
|
||
border: 1px solid gray;
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.article-title {
|
||
font-size: 30px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.article-arrow-button {
|
||
background-color: green;
|
||
color: white;
|
||
border: 1px solid green;
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.article-reject-button {
|
||
background-color: red;
|
||
color: white;
|
||
border: 1px solid red;
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.action-menu-icon {
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
padding: 5px;
|
||
}
|
||
|
||
.article-info-container {
|
||
display: flex;
|
||
flex-direction: row;
|
||
margin-top: 10px;
|
||
gap: 10px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.info-content-container {
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 10px;
|
||
padding: 0px;
|
||
border-bottom: 1px solid var(--normal-border-color);
|
||
}
|
||
|
||
.user-avatar-container {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.user-avatar-item {
|
||
width: 50px;
|
||
height: 50px;
|
||
}
|
||
|
||
.user-avatar-item-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.user-avatar-item-img :deep(.base-user-avatar-img) {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.info-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
width: 100%;
|
||
}
|
||
|
||
.info-content-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.user-name {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.user-medal {
|
||
font-size: 12px;
|
||
margin-left: 4px;
|
||
opacity: 0.6;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.post-time {
|
||
font-size: 12px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.info-content-text {
|
||
font-size: 16px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.article-footer-container {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-top: 0px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.option-container {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.article-footer-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.reaction-action {
|
||
cursor: pointer;
|
||
padding: 4px 10px;
|
||
opacity: 0.6;
|
||
border-radius: 10px;
|
||
font-size: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
transition:
|
||
background-color 0.2s ease,
|
||
opacity 0.2s ease;
|
||
}
|
||
|
||
.reaction-action:hover {
|
||
opacity: 1;
|
||
background-color: var(--normal-light-background-color);
|
||
}
|
||
|
||
.reaction-action.like-action {
|
||
color: #ff0000;
|
||
}
|
||
|
||
.reaction-action.selected {
|
||
opacity: 1;
|
||
background-color: var(--normal-light-background-color);
|
||
}
|
||
|
||
.reaction-count {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.reaction-action.copy-link:hover {
|
||
background-color: #e2e2e2;
|
||
}
|
||
|
||
.comment-editor-wrapper {
|
||
position: relative;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.post-page-main-container {
|
||
width: calc(100% - 20px);
|
||
padding: 10px;
|
||
}
|
||
|
||
.article-title {
|
||
font-size: 22px;
|
||
}
|
||
|
||
.user-name {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.user-medal {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.info-content-text {
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.reactions-viewer-item {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.post-page-scroller-container {
|
||
display: none;
|
||
}
|
||
|
||
.info-content-container {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.info-content-header {
|
||
width: calc(100% - 50px - 10px);
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.article-footer-container {
|
||
margin-top: 0;
|
||
margin-bottom: 0px;
|
||
}
|
||
|
||
.loading-container {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|