mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-28 09:00:48 +08:00
1.追加文章可见范围功能
2.删除文章右侧滚动条
This commit is contained in:
@@ -66,6 +66,7 @@ public class PostController {
|
||||
req.getContent(),
|
||||
req.getTagIds(),
|
||||
req.getType(),
|
||||
req.getPostVisibleScopeType(),
|
||||
req.getPrizeDescription(),
|
||||
req.getPrizeIcon(),
|
||||
req.getPrizeCount(),
|
||||
@@ -101,7 +102,8 @@ public class PostController {
|
||||
req.getCategoryId(),
|
||||
req.getTitle(),
|
||||
req.getContent(),
|
||||
req.getTagIds()
|
||||
req.getTagIds(),
|
||||
req.getPostVisibleScopeType()
|
||||
);
|
||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.openisle.dto;
|
||||
import com.openisle.model.PostType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,7 @@ public class PostRequest {
|
||||
|
||||
// optional for lottery posts
|
||||
private PostType type;
|
||||
private PostVisibleScopeType postVisibleScopeType;
|
||||
private String prizeDescription;
|
||||
private String prizeIcon;
|
||||
private Integer prizeCount;
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.PostType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -34,4 +36,5 @@ public class PostSummaryDto {
|
||||
private PollDto poll;
|
||||
private boolean rssExcluded;
|
||||
private boolean closed;
|
||||
private PostVisibleScopeType visibleScope;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ public class PostMapper {
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||
dto.setClosed(post.isClosed());
|
||||
dto.setVisibleScope(post.getVisibleScope());
|
||||
|
||||
List<ReactionDto> reactions = reactionService
|
||||
.getReactionsForPost(post.getId())
|
||||
|
||||
@@ -66,6 +66,10 @@ public class Post {
|
||||
@Column(nullable = false)
|
||||
private PostType type = PostType.NORMAL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean closed = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum PostVisibleScopeType {
|
||||
ALL,
|
||||
ONLY_ME,
|
||||
ONLY_REGISTER;
|
||||
|
||||
/**
|
||||
* 防止画面传递错误的值
|
||||
* @param value
|
||||
* @return
|
||||
*/
|
||||
@JsonCreator
|
||||
public static PostVisibleScopeType fromString(String value) {
|
||||
if (value == null) return ALL;
|
||||
for (PostVisibleScopeType type : PostVisibleScopeType.values()) {
|
||||
if (type.name().equalsIgnoreCase(value)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
// 不匹配时给默认值,而不是抛异常
|
||||
return ALL;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String toValue() {
|
||||
return this.name();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.model.*;
|
||||
@@ -225,6 +226,7 @@ public class PostService {
|
||||
String content,
|
||||
List<Long> tagIds,
|
||||
PostType type,
|
||||
PostVisibleScopeType postVisibleScopeType,
|
||||
String prizeDescription,
|
||||
String prizeIcon,
|
||||
Integer prizeCount,
|
||||
@@ -288,6 +290,14 @@ public class PostService {
|
||||
post.setCategory(category);
|
||||
post.setTags(new HashSet<>(tags));
|
||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||
|
||||
// 什么都没设置的情况下,默认为ALL
|
||||
if(Objects.isNull(postVisibleScopeType)){
|
||||
post.setVisibleScope(PostVisibleScopeType.ALL);
|
||||
}else{
|
||||
post.setVisibleScope(postVisibleScopeType);
|
||||
}
|
||||
|
||||
if (post instanceof LotteryPost) {
|
||||
post = lotteryPostRepository.save((LotteryPost) post);
|
||||
} else if (post instanceof PollPost) {
|
||||
@@ -571,7 +581,7 @@ public class PostService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||
if (viewer == null) {
|
||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||
throw new com.openisle.exception.NotFoundException("User not found");
|
||||
}
|
||||
User viewerUser = userRepository
|
||||
.findByUsername(viewer)
|
||||
@@ -1002,7 +1012,8 @@ public class PostService {
|
||||
Long categoryId,
|
||||
String title,
|
||||
String content,
|
||||
java.util.List<Long> tagIds
|
||||
List<Long> tagIds,
|
||||
PostVisibleScopeType postVisibleScopeType
|
||||
) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one tag required");
|
||||
@@ -1034,6 +1045,7 @@ public class PostService {
|
||||
post.setContent(content);
|
||||
post.setCategory(category);
|
||||
post.setTags(new java.util.HashSet<>(tags));
|
||||
post.setVisibleScope(postVisibleScopeType);
|
||||
Post updated = postRepository.save(post);
|
||||
imageUploader.adjustReferences(oldContent, content);
|
||||
notificationService.notifyMentions(content, user, updated, null);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'
|
||||
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
v-model="selected"
|
||||
:fetch-options="fetchTypes"
|
||||
placeholder="选择帖子可见范围"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
|
||||
export default {
|
||||
name: 'PostVisibleScopeSelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: String, default: 'ALL' },
|
||||
// options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
|
||||
const fetchTypes = async () => {
|
||||
return [
|
||||
{ id: 'ALL', name: '全部可见', icon: 'communication' },
|
||||
{ id: 'ONLY_ME', name: '仅自己可见', icon: 'user-icon' },
|
||||
{ id: 'ONLY_REGISTER', name: '仅注册用户可见', icon: 'peoples-two' },
|
||||
]
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
return { fetchTypes, selected }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -11,6 +11,7 @@
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostTypeSelect v-model="postType" />
|
||||
<PostVisibleScopeSelect v-model="postVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -52,6 +53,7 @@ import LotteryForm from '~/components/LotteryForm.vue'
|
||||
import PollForm from '~/components/PollForm.vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -60,6 +62,7 @@ const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const postType = ref('NORMAL')
|
||||
const postVisibleScope = ref('ALL')
|
||||
const lottery = reactive({
|
||||
prizeIcon: '',
|
||||
prizeIconFile: null,
|
||||
@@ -94,6 +97,7 @@ const loadDraft = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.categoryId || ''
|
||||
selectedTags.value = data.tagIds || []
|
||||
postVisibleScope.value = data.visiblescope
|
||||
|
||||
toast.success('草稿已加载')
|
||||
}
|
||||
@@ -109,6 +113,7 @@ const clearPost = async () => {
|
||||
content.value = ''
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
postVisibleScope.value = 'ALL'
|
||||
postType.value = 'NORMAL'
|
||||
lottery.prizeIcon = ''
|
||||
lottery.prizeIconFile = null
|
||||
@@ -160,6 +165,7 @@ const saveDraft = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value || null,
|
||||
tagIds,
|
||||
postVisibleScopeType:postVisibleScope.value
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
@@ -314,6 +320,7 @@ const submitPost = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value,
|
||||
postVisibleScopeType: postVisibleScope.value,
|
||||
type: postType.value,
|
||||
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
|
||||
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="post-options-left">
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -44,6 +45,7 @@ import TagSelect from '~/components/TagSelect.vue'
|
||||
import { toast } from '~/main'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -51,6 +53,7 @@ const title = ref('')
|
||||
const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const selectedVisibleScope = ref('ALL')
|
||||
const isWaitingPosting = ref(false)
|
||||
const isAiLoading = ref(false)
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
@@ -70,6 +73,7 @@ const loadPost = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.category.id || ''
|
||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||
selectedVisibleScope.value = data.visibleScope
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('加载失败')
|
||||
@@ -180,6 +184,7 @@ const submitPost = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value,
|
||||
postVisibleScopeType:selectedVisibleScope.value
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
@@ -3,169 +3,197 @@
|
||||
<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" />
|
||||
<div v-else
|
||||
class="post-page-main-container"
|
||||
ref="mainContainer"
|
||||
>
|
||||
<!-- 🔒 遮罩层 -->
|
||||
<ClientOnly>
|
||||
<div v-if="isRestricted" class="restricted-overlay">
|
||||
<div class="restricted-content">
|
||||
<Lock class="restricted-icon" />
|
||||
|
||||
<!-- 🔒 权限文案 -->
|
||||
<template v-if="visibleScope === 'ONLY_ME'">
|
||||
<p>这是一篇私密文章,仅作者本人及管理员可见</p>
|
||||
<div class="restricted-actions">
|
||||
<NuxtLink to="/" class="restricted-button">返回首页</NuxtLink>
|
||||
</div>
|
||||
</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>
|
||||
<template v-else-if="visibleScope === 'ONLY_REGISTER'">
|
||||
<p>请登录后查看这篇文章</p>
|
||||
<div class="restricted-actions">
|
||||
<NuxtLink to="/login" class="restricted-button" v-if="!loggedIn" >登录</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</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
|
||||
>
|
||||
</ClientOnly>
|
||||
<div :class="{ 'is-blurred': isRestricted }">
|
||||
<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="post-time">{{ postTime }}</div>
|
||||
</div>
|
||||
<div
|
||||
class="info-content-text"
|
||||
v-html="renderMarkdown(postContent)"
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
|
||||
<div class="article-footer-container">
|
||||
<div class="article-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="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
|
||||
class="reaction-action like-action"
|
||||
:class="{ selected: postLikedByMe }"
|
||||
@click="togglePostLike"
|
||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||
class="article-subscribe-button"
|
||||
@click="subscribePost"
|
||||
>
|
||||
<like v-if="!postLikedByMe" />
|
||||
<like v-else theme="filled" />
|
||||
<span v-if="postLikeCount" class="reaction-count">{{ postLikeCount }}</span>
|
||||
<people-plus />
|
||||
<div class="article-subscribe-button-text">
|
||||
{{ isMobile ? '订阅' : '订阅文章' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="reaction-action copy-link" @click="copyPostLink">
|
||||
<link-icon />
|
||||
<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="article-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>
|
||||
</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>
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
<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 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>
|
||||
|
||||
<div class="post-page-scroller-container">
|
||||
<!-- <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>
|
||||
@@ -183,7 +211,7 @@
|
||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<vue-easy-lightbox
|
||||
:visible="lightboxVisible"
|
||||
:index="lightboxIndex"
|
||||
@@ -228,6 +256,7 @@ import { useIsMobile } from '~/utils/screen'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { ClientOnly } from '#components'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { Lock } from '@icon-park/vue-next'
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
@@ -241,6 +270,13 @@ const author = ref('')
|
||||
const postContent = ref('')
|
||||
const category = ref('')
|
||||
const tags = ref([])
|
||||
const visibleScope = ref('ALL') // 可见范围
|
||||
const isRestricted = computed(() => {
|
||||
return (
|
||||
(visibleScope.value === 'ONLY_ME' && !isAuthor.value && !isAdmin.value) ||
|
||||
(visibleScope.value === 'ONLY_REGISTER' && !loggedIn.value)
|
||||
)
|
||||
})
|
||||
const postReactions = ref([])
|
||||
const postReactionsGroupRef = ref(null)
|
||||
const postLikeCount = computed(
|
||||
@@ -497,15 +533,21 @@ const onCommentDeleted = (id) => {
|
||||
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,
|
||||
const tokenHeader = computed(() => {
|
||||
const token = getToken()
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
const { data: postData, pending: pendingPost, error: postError, refresh: refreshPost } =
|
||||
await useAsyncData(`post-${postId}`, async () => {
|
||||
try {
|
||||
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
|
||||
} catch (err) {
|
||||
}
|
||||
}, {
|
||||
server: false,
|
||||
lazy: false,
|
||||
})
|
||||
|
||||
|
||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||
@@ -519,6 +561,7 @@ watchEffect(() => {
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
visibleScope.value = data.visibleScope || 'ALL'
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
@@ -935,7 +978,7 @@ onMounted(async () => {
|
||||
<style>
|
||||
.post-page-container {
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
display: block;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -948,9 +991,10 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.post-page-main-container {
|
||||
position: relative;
|
||||
scrollbar-width: none;
|
||||
padding: 20px;
|
||||
width: calc(85% - 40px);
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.info-content-text p code {
|
||||
@@ -1341,6 +1385,80 @@ onMounted(async () => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
/* ======== 权限锁定状态 ======== */
|
||||
.is-blurred {
|
||||
filter: blur(10px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
/* 遮罩层 */
|
||||
.restricted-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
/* 中央提示框 */
|
||||
.restricted-content {
|
||||
background: #ffff;
|
||||
color:var(--primary-color);
|
||||
padding: 50px 60px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.restricted-icon {
|
||||
font-size: 60px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.restricted-button {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
padding: 10px 18px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.restricted-button:hover {
|
||||
background: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.restricted-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 淡入动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-page-main-container {
|
||||
width: calc(100% - 20px);
|
||||
|
||||
Reference in New Issue
Block a user