mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-07-26 06:26:06 +00:00
@@ -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(),
|
||||||
@@ -103,7 +104,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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,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())
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
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.model.*;
|
import com.openisle.model.*;
|
||||||
import com.openisle.repository.CategoryProposalPostRepository;
|
import com.openisle.repository.CategoryProposalPostRepository;
|
||||||
@@ -252,6 +253,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,
|
||||||
@@ -336,6 +338,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 CategoryProposalPost categoryProposalPost) {
|
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
|
||||||
@@ -716,7 +726,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)
|
||||||
@@ -1147,7 +1157,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");
|
||||||
@@ -1179,6 +1190,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);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'
|
||||||
@@ -36,7 +36,6 @@ echo "👉 Pull base images (for image-based services)..."
|
|||||||
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
||||||
|
|
||||||
echo "👉 Build images (staging)..."
|
echo "👉 Build images (staging)..."
|
||||||
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
|
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
build --pull \
|
build --pull \
|
||||||
--build-arg NUXT_ENV=staging \
|
--build-arg NUXT_ENV=staging \
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
opensearch:
|
# opensearch:
|
||||||
condition: service_healthy
|
# condition: service_healthy
|
||||||
command: >
|
command: >
|
||||||
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
||||||
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
||||||
|
|||||||
@@ -543,6 +543,7 @@ onMounted(async () => {
|
|||||||
.header-label {
|
.header-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 在线人数的数字文字样式(无背景) */
|
/* 在线人数的数字文字样式(无背景) */
|
||||||
|
|||||||
@@ -3,15 +3,30 @@
|
|||||||
<div class="login-overlay-blur"></div>
|
<div class="login-overlay-blur"></div>
|
||||||
<div class="login-overlay-content">
|
<div class="login-overlay-content">
|
||||||
<user-icon class="login-overlay-icon" />
|
<user-icon class="login-overlay-icon" />
|
||||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
<div class="login-overlay-text">{{ props.text }}</div>
|
||||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: '请先登录,点击跳转到登录页面',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
default: '登录',
|
||||||
|
},
|
||||||
|
buttonLink: {
|
||||||
|
type: String,
|
||||||
|
default: '/login',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const goLogin = () => {
|
const goLogin = () => {
|
||||||
navigateTo('/login', { replace: true })
|
navigateTo(props.buttonLink, { replace: true })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
|
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
|
||||||
<star v-if="!article.rssExcluded" class="featured-icon" />
|
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
|
<lock class="preview-close-icon" v-if="article.isRestricted" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||||
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
||||||
@@ -295,6 +296,7 @@ const {
|
|||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
rssExcluded: p.rssExcluded || false,
|
rssExcluded: p.rssExcluded || false,
|
||||||
|
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
),
|
),
|
||||||
@@ -336,6 +338,7 @@ const fetchNextPage = async () => {
|
|||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
|
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||||
rssExcluded: p.rssExcluded || false,
|
rssExcluded: p.rssExcluded || false,
|
||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -54,6 +55,7 @@ import PollForm from '~/components/PollForm.vue'
|
|||||||
import ProposalForm from '~/components/ProposalForm.vue'
|
import ProposalForm from '~/components/ProposalForm.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
|
||||||
|
|
||||||
@@ -62,6 +64,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,
|
||||||
@@ -100,6 +103,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('草稿已加载')
|
||||||
}
|
}
|
||||||
@@ -115,6 +119,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
|
||||||
@@ -168,6 +173,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) {
|
||||||
@@ -328,6 +334,7 @@ const submitPost = async () => {
|
|||||||
categoryId: selectedCategory.value,
|
categoryId: selectedCategory.value,
|
||||||
tagIds: selectedTags.value,
|
tagIds: selectedTags.value,
|
||||||
type: postType.value,
|
type: postType.value,
|
||||||
|
postVisibleScopeType: postVisibleScope.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (postType.value === 'LOTTERY') {
|
if (postType.value === 'LOTTERY') {
|
||||||
@@ -355,6 +362,7 @@ const submitPost = async () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (data.reward && data.reward > 0) {
|
if (data.reward && data.reward > 0) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="post-page-container">
|
<div v-if="isRestricted" class="restricted-content">
|
||||||
|
<template v-if="visibleScope === 'ONLY_ME'">
|
||||||
|
<LoginOverlay
|
||||||
|
text="这是一篇私密文章,仅作者本人及管理员可见"
|
||||||
|
button-text="返回首页"
|
||||||
|
button-link="/"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="visibleScope === 'ONLY_REGISTER'">
|
||||||
|
<LoginOverlay
|
||||||
|
text="这是一篇仅登录用户可见的文章,请先登录"
|
||||||
|
button-text="登录"
|
||||||
|
button-link="/login"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-else class="post-page-container">
|
||||||
<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>
|
||||||
@@ -16,7 +33,9 @@
|
|||||||
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||||||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||||
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
||||||
<div v-if="closed" class="article-closed-button">已关闭</div>
|
<div v-if="closed" class="article-gray-button">已关闭</div>
|
||||||
|
<div v-if="visibleScope === 'ONLY_ME'" class="article-gray-button">仅自己可见</div>
|
||||||
|
<div v-if="visibleScope === 'ONLY_REGISTER'" class="article-gray-button">仅登录可见</div>
|
||||||
<div
|
<div
|
||||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||||
class="article-subscribe-button"
|
class="article-subscribe-button"
|
||||||
@@ -165,25 +184,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<vue-easy-lightbox
|
||||||
:visible="lightboxVisible"
|
:visible="lightboxVisible"
|
||||||
:index="lightboxIndex"
|
:index="lightboxIndex"
|
||||||
@@ -228,6 +228,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 +242,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,15 +505,27 @@ const onCommentDeleted = (id) => {
|
|||||||
fetchTimeline()
|
fetchTimeline()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenHeader = computed(() => {
|
||||||
|
const token = getToken()
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
})
|
||||||
const {
|
const {
|
||||||
data: postData,
|
data: postData,
|
||||||
pending: pendingPost,
|
pending: pendingPost,
|
||||||
error: postError,
|
error: postError,
|
||||||
refresh: refreshPost,
|
refresh: refreshPost,
|
||||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
} = await useAsyncData(
|
||||||
server: true,
|
`post-${postId}`,
|
||||||
lazy: false,
|
async () => {
|
||||||
})
|
try {
|
||||||
|
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
|
||||||
|
} catch (err) {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
server: false,
|
||||||
|
lazy: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||||
@@ -519,6 +539,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 +956,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 +969,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 {
|
||||||
@@ -1002,6 +1024,35 @@ onMounted(async () => {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background-color: #eee;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(90deg, #eee 0%, #f5f5f7 40%, #e0e0e0 100%);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: skeleton-shimmer 1.5s infinite linear;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.user-avatar-container {
|
.user-avatar-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -1106,7 +1157,7 @@ onMounted(async () => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-closed-button,
|
.article-gray-button,
|
||||||
.article-subscribe-button-text,
|
.article-subscribe-button-text,
|
||||||
.article-featured-button,
|
.article-featured-button,
|
||||||
.article-unsubscribe-button-text {
|
.article-unsubscribe-button-text {
|
||||||
@@ -1159,7 +1210,7 @@ onMounted(async () => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-closed-button {
|
.article-gray-button {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
color: gray;
|
color: gray;
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
@@ -1341,6 +1392,76 @@ 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);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import {
|
|||||||
Share,
|
Share,
|
||||||
Financing,
|
Financing,
|
||||||
Hands,
|
Hands,
|
||||||
|
PreviewCloseOne,
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
@@ -167,4 +168,5 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.vueApp.component('Share', Share)
|
nuxtApp.vueApp.component('Share', Share)
|
||||||
nuxtApp.vueApp.component('Financing', Financing)
|
nuxtApp.vueApp.component('Financing', Financing)
|
||||||
nuxtApp.vueApp.component('Hands', Hands)
|
nuxtApp.vueApp.component('Hands', Hands)
|
||||||
|
nuxtApp.vueApp.component('PreviewCloseOne', PreviewCloseOne)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -268,23 +268,21 @@ export function stripMarkdownLength(text, length) {
|
|||||||
|
|
||||||
// 朴素文本带贴吧表情
|
// 朴素文本带贴吧表情
|
||||||
export function stripMarkdownWithTiebaMoji(text, length){
|
export function stripMarkdownWithTiebaMoji(text, length){
|
||||||
console.error(text)
|
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
|
|
||||||
// Markdown 转成纯文本
|
// Markdown 转成纯文本
|
||||||
const plain = stripMarkdown(text)
|
const plain = stripMarkdown(text)
|
||||||
console.error(plain)
|
// 替换 :tieba123: 为 <img>
|
||||||
// 替换 :tieba123: 为 <img>
|
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
const key = `tieba${num}`
|
||||||
const key = `tieba${num}`
|
const file = tiebaEmoji[key]
|
||||||
const file = tiebaEmoji[key]
|
return file
|
||||||
return file
|
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
: match // 没有匹配到图片则保留原样
|
||||||
: match // 没有匹配到图片则保留原样
|
})
|
||||||
})
|
|
||||||
|
|
||||||
// 截断纯文本长度(防止撑太长)
|
// 截断纯文本长度(防止撑太长)
|
||||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||||
return truncated
|
return truncated
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user