1.追加文章可见范围功能

2.删除文章右侧滚动条
This commit is contained in:
smallclover
2025-10-18 22:32:22 +09:00
parent 971a3d36c6
commit 458b125834
12 changed files with 386 additions and 157 deletions

View File

@@ -66,6 +66,7 @@ public class PostController {
req.getContent(), req.getContent(),
req.getTagIds(), req.getTagIds(),
req.getType(), req.getType(),
req.getPostVisibleScopeType(),
req.getPrizeDescription(), req.getPrizeDescription(),
req.getPrizeIcon(), req.getPrizeIcon(),
req.getPrizeCount(), req.getPrizeCount(),
@@ -101,7 +102,8 @@ public class PostController {
req.getCategoryId(), req.getCategoryId(),
req.getTitle(), req.getTitle(),
req.getContent(), req.getContent(),
req.getTagIds() req.getTagIds(),
req.getPostVisibleScopeType()
); );
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName())); return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
} }

View File

@@ -3,6 +3,8 @@ package com.openisle.dto;
import com.openisle.model.PostType; import com.openisle.model.PostType;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import com.openisle.model.PostVisibleScopeType;
import lombok.Data; import lombok.Data;
/** /**
@@ -19,6 +21,7 @@ public class PostRequest {
// optional for lottery posts // optional for lottery posts
private PostType type; private PostType type;
private PostVisibleScopeType postVisibleScopeType;
private String prizeDescription; private String prizeDescription;
private String prizeIcon; private String prizeIcon;
private Integer prizeCount; private Integer prizeCount;

View File

@@ -4,6 +4,8 @@ import com.openisle.model.PostStatus;
import com.openisle.model.PostType; import com.openisle.model.PostType;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import com.openisle.model.PostVisibleScopeType;
import lombok.Data; import lombok.Data;
/** /**
@@ -34,4 +36,5 @@ public class PostSummaryDto {
private PollDto poll; private PollDto poll;
private boolean rssExcluded; private boolean rssExcluded;
private boolean closed; private boolean closed;
private PostVisibleScopeType visibleScope;
} }

View File

@@ -73,6 +73,7 @@ public class PostMapper {
dto.setPinnedAt(post.getPinnedAt()); dto.setPinnedAt(post.getPinnedAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded()); dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
dto.setClosed(post.isClosed()); dto.setClosed(post.isClosed());
dto.setVisibleScope(post.getVisibleScope());
List<ReactionDto> reactions = reactionService List<ReactionDto> reactions = reactionService
.getReactionsForPost(post.getId()) .getReactionsForPost(post.getId())

View File

@@ -66,6 +66,10 @@ public class Post {
@Column(nullable = false) @Column(nullable = false)
private PostType type = PostType.NORMAL; private PostType type = PostType.NORMAL;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
@Column(nullable = false) @Column(nullable = false)
private boolean closed = false; private boolean closed = false;

View File

@@ -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();
}
}

View File

@@ -1,6 +1,7 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.config.CachingConfig; import com.openisle.config.CachingConfig;
import com.openisle.exception.NotFoundException;
import com.openisle.exception.RateLimitException; import com.openisle.exception.RateLimitException;
import com.openisle.mapper.PostMapper; import com.openisle.mapper.PostMapper;
import com.openisle.model.*; import com.openisle.model.*;
@@ -225,6 +226,7 @@ public class PostService {
String content, String content,
List<Long> tagIds, List<Long> tagIds,
PostType type, PostType type,
PostVisibleScopeType postVisibleScopeType,
String prizeDescription, String prizeDescription,
String prizeIcon, String prizeIcon,
Integer prizeCount, Integer prizeCount,
@@ -288,6 +290,14 @@ public class PostService {
post.setCategory(category); post.setCategory(category);
post.setTags(new HashSet<>(tags)); post.setTags(new HashSet<>(tags));
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); 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) { if (post instanceof LotteryPost) {
post = lotteryPostRepository.save((LotteryPost) post); post = lotteryPostRepository.save((LotteryPost) post);
} else if (post instanceof PollPost) { } else if (post instanceof PollPost) {
@@ -571,7 +581,7 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.getStatus() != PostStatus.PUBLISHED) { if (post.getStatus() != PostStatus.PUBLISHED) {
if (viewer == null) { if (viewer == null) {
throw new com.openisle.exception.NotFoundException("Post not found"); throw new com.openisle.exception.NotFoundException("User not found");
} }
User viewerUser = userRepository User viewerUser = userRepository
.findByUsername(viewer) .findByUsername(viewer)
@@ -1002,7 +1012,8 @@ public class PostService {
Long categoryId, Long categoryId,
String title, String title,
String content, String content,
java.util.List<Long> tagIds List<Long> tagIds,
PostVisibleScopeType postVisibleScopeType
) { ) {
if (tagIds == null || tagIds.isEmpty()) { if (tagIds == null || tagIds.isEmpty()) {
throw new IllegalArgumentException("At least one tag required"); throw new IllegalArgumentException("At least one tag required");
@@ -1034,6 +1045,7 @@ public class PostService {
post.setContent(content); post.setContent(content);
post.setCategory(category); post.setCategory(category);
post.setTags(new java.util.HashSet<>(tags)); post.setTags(new java.util.HashSet<>(tags));
post.setVisibleScope(postVisibleScopeType);
Post updated = postRepository.save(post); Post updated = postRepository.save(post);
imageUploader.adjustReferences(oldContent, content); imageUploader.adjustReferences(oldContent, content);
notificationService.notifyMentions(content, user, updated, null); notificationService.notifyMentions(content, user, updated, null);

View File

@@ -0,0 +1 @@
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'

View 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>

View File

@@ -11,6 +11,7 @@
<CategorySelect v-model="selectedCategory" /> <CategorySelect v-model="selectedCategory" />
<TagSelect v-model="selectedTags" creatable /> <TagSelect v-model="selectedTags" creatable />
<PostTypeSelect v-model="postType" /> <PostTypeSelect v-model="postType" />
<PostVisibleScopeSelect v-model="postVisibleScope"/>
</div> </div>
<div class="post-options-right"> <div class="post-options-right">
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div> <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 PollForm from '~/components/PollForm.vue'
import { toast } from '~/main' import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth' import { authState, getToken } from '~/utils/auth'
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
@@ -60,6 +62,7 @@ const content = ref('')
const selectedCategory = ref('') const selectedCategory = ref('')
const selectedTags = ref([]) const selectedTags = ref([])
const postType = ref('NORMAL') const postType = ref('NORMAL')
const postVisibleScope = ref('ALL')
const lottery = reactive({ const lottery = reactive({
prizeIcon: '', prizeIcon: '',
prizeIconFile: null, prizeIconFile: null,
@@ -94,6 +97,7 @@ const loadDraft = async () => {
content.value = data.content || '' content.value = data.content || ''
selectedCategory.value = data.categoryId || '' selectedCategory.value = data.categoryId || ''
selectedTags.value = data.tagIds || [] selectedTags.value = data.tagIds || []
postVisibleScope.value = data.visiblescope
toast.success('草稿已加载') toast.success('草稿已加载')
} }
@@ -109,6 +113,7 @@ const clearPost = async () => {
content.value = '' content.value = ''
selectedCategory.value = '' selectedCategory.value = ''
selectedTags.value = [] selectedTags.value = []
postVisibleScope.value = 'ALL'
postType.value = 'NORMAL' postType.value = 'NORMAL'
lottery.prizeIcon = '' lottery.prizeIcon = ''
lottery.prizeIconFile = null lottery.prizeIconFile = null
@@ -160,6 +165,7 @@ const saveDraft = async () => {
content: content.value, content: content.value,
categoryId: selectedCategory.value || null, categoryId: selectedCategory.value || null,
tagIds, tagIds,
postVisibleScopeType:postVisibleScope.value
}), }),
}) })
if (res.ok) { if (res.ok) {
@@ -314,6 +320,7 @@ const submitPost = async () => {
content: content.value, content: content.value,
categoryId: selectedCategory.value, categoryId: selectedCategory.value,
tagIds: selectedTags.value, tagIds: selectedTags.value,
postVisibleScopeType: postVisibleScope.value,
type: postType.value, type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined, prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined, prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,

View File

@@ -10,6 +10,7 @@
<div class="post-options-left"> <div class="post-options-left">
<CategorySelect v-model="selectedCategory" /> <CategorySelect v-model="selectedCategory" />
<TagSelect v-model="selectedTags" creatable /> <TagSelect v-model="selectedTags" creatable />
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
</div> </div>
<div class="post-options-right"> <div class="post-options-right">
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div> <div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
@@ -44,6 +45,7 @@ import TagSelect from '~/components/TagSelect.vue'
import { toast } from '~/main' import { toast } from '~/main'
import { getToken, authState } from '~/utils/auth' import { getToken, authState } from '~/utils/auth'
import LoginOverlay from '~/components/LoginOverlay.vue' import LoginOverlay from '~/components/LoginOverlay.vue'
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
@@ -51,6 +53,7 @@ const title = ref('')
const content = ref('') const content = ref('')
const selectedCategory = ref('') const selectedCategory = ref('')
const selectedTags = ref([]) const selectedTags = ref([])
const selectedVisibleScope = ref('ALL')
const isWaitingPosting = ref(false) const isWaitingPosting = ref(false)
const isAiLoading = ref(false) const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn) const isLogin = computed(() => authState.loggedIn)
@@ -70,6 +73,7 @@ const loadPost = async () => {
content.value = data.content || '' content.value = data.content || ''
selectedCategory.value = data.category.id || '' selectedCategory.value = data.category.id || ''
selectedTags.value = (data.tags || []).map((t) => t.id) selectedTags.value = (data.tags || []).map((t) => t.id)
selectedVisibleScope.value = data.visibleScope
} }
} catch (e) { } catch (e) {
toast.error('加载失败') toast.error('加载失败')
@@ -180,6 +184,7 @@ const submitPost = async () => {
content: content.value, content: content.value,
categoryId: selectedCategory.value, categoryId: selectedCategory.value,
tagIds: selectedTags.value, tagIds: selectedTags.value,
postVisibleScopeType:selectedVisibleScope.value
}), }),
}) })
const data = await res.json() const data = await res.json()

View File

@@ -3,7 +3,34 @@
<div v-if="isWaitingFetchingPost" class="loading-container"> <div v-if="isWaitingFetchingPost" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
<div v-else class="post-page-main-container" ref="mainContainer"> <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>
<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>
</ClientOnly>
<div :class="{ 'is-blurred': isRestricted }">
<div class="article-title-container"> <div class="article-title-container">
<div class="article-title-container-left"> <div class="article-title-container-left">
<div class="article-title">{{ title }}</div> <div class="article-title">{{ title }}</div>
@@ -164,8 +191,9 @@
</BaseTimeline> </BaseTimeline>
</div> </div>
</div> </div>
</div>
<div class="post-page-scroller-container"> <!-- <div class="post-page-scroller-container">
<div class="scroller"> <div class="scroller">
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div> <div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
<div v-else class="scroller-time">{{ scrollerTopTime }}</div> <div v-else class="scroller-time">{{ scrollerTopTime }}</div>
@@ -183,7 +211,7 @@
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div> <div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
<div v-else class="scroller-time">{{ lastReplyTime }}</div> <div v-else class="scroller-time">{{ lastReplyTime }}</div>
</div> </div>
</div> </div> -->
<vue-easy-lightbox <vue-easy-lightbox
:visible="lightboxVisible" :visible="lightboxVisible"
:index="lightboxIndex" :index="lightboxIndex"
@@ -228,6 +256,7 @@ import { useIsMobile } from '~/utils/screen'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
import { ClientOnly } from '#components' import { ClientOnly } from '#components'
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm'
import { Lock } from '@icon-park/vue-next'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const config = useRuntimeConfig() const config = useRuntimeConfig()
@@ -241,6 +270,13 @@ const author = ref('')
const postContent = ref('') const postContent = ref('')
const category = ref('') const category = ref('')
const tags = 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 postReactions = ref([])
const postReactionsGroupRef = ref(null) const postReactionsGroupRef = ref(null)
const postLikeCount = computed( const postLikeCount = computed(
@@ -497,16 +533,22 @@ const onCommentDeleted = (id) => {
fetchTimeline() fetchTimeline()
} }
const { const tokenHeader = computed(() => {
data: postData, const token = getToken()
pending: pendingPost, return token ? { Authorization: `Bearer ${token}` } : {}
error: postError, })
refresh: refreshPost, const { data: postData, pending: pendingPost, error: postError, refresh: refreshPost } =
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), { await useAsyncData(`post-${postId}`, async () => {
server: true, try {
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
} catch (err) {
}
}, {
server: false,
lazy: false, lazy: false,
}) })
// 用 pendingPost 驱动现有 UI替代 isWaitingFetchingPost 手控) // 用 pendingPost 驱动现有 UI替代 isWaitingFetchingPost 手控)
const isWaitingFetchingPost = computed(() => pendingPost.value) const isWaitingFetchingPost = computed(() => pendingPost.value)
@@ -519,6 +561,7 @@ watchEffect(() => {
title.value = data.title title.value = data.title
category.value = data.category category.value = data.category
tags.value = data.tags || [] tags.value = data.tags || []
visibleScope.value = data.visibleScope || 'ALL'
postReactions.value = data.reactions || [] postReactions.value = data.reactions || []
subscribed.value = !!data.subscribed subscribed.value = !!data.subscribed
status.value = data.status status.value = data.status
@@ -935,7 +978,7 @@ onMounted(async () => {
<style> <style>
.post-page-container { .post-page-container {
background-color: var(--background-color); background-color: var(--background-color);
display: flex; display: block;
flex-direction: row; flex-direction: row;
} }
@@ -948,9 +991,10 @@ onMounted(async () => {
} }
.post-page-main-container { .post-page-main-container {
position: relative;
scrollbar-width: none; scrollbar-width: none;
padding: 20px; padding: 20px;
width: calc(85% - 40px); width: calc(100% - 40px);
} }
.info-content-text p code { .info-content-text p code {
@@ -1341,6 +1385,80 @@ onMounted(async () => {
position: relative; 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) { @media (max-width: 768px) {
.post-page-main-container { .post-page-main-container {
width: calc(100% - 20px); width: calc(100% - 20px);