Compare commits

..

13 Commits

Author SHA1 Message Date
Tim
ab91ec2489 feat: add MCP search service 2025-10-25 22:05:25 +08:00
Tim
a24bd81942 Merge pull request #1080 from nagisa77/codex/modify-post-visibility-and-update-changelog
Log post visibility scope changes
2025-10-24 11:08:22 +08:00
Tim
8a008a090a Log post visibility changes 2025-10-24 10:41:10 +08:00
Tim
5dfb69e636 Merge pull request #1075 from smallclover/main
markdown 调试信息中的错误日志删除
2025-10-24 10:14:09 +08:00
Tim
499069573e fix: 弹窗修改为overlay 2025-10-24 10:11:56 +08:00
Tim
636912941a fix: commit 2025-10-23 17:56:45 +08:00
Tim
bdcc1488b9 fix: 修改可见范围 2025-10-23 17:54:03 +08:00
Tim
d33bd233af fix: 修改登录遮罩 2025-10-23 17:22:49 +08:00
Tim
efe4b97d83 Merge branch 'main' into main 2025-10-23 17:06:25 +08:00
Tim
8a256e167d Merge pull request #1031 from sivdead/feat/category_proposal
feat: 添加分类提案功能,包括提案表单和相关后端逻辑
2025-10-23 17:03:48 +08:00
smallclover
458b125834 1.追加文章可见范围功能
2.删除文章右侧滚动条
2025-10-18 22:32:22 +09:00
smallclover
971a3d36c6 修复:header文字不换行 2025-10-17 23:19:45 +09:00
smallclover
e5d66d73cb markdown 调试信息中的错误日志删除 2025-10-17 22:34:58 +09:00
36 changed files with 753 additions and 57 deletions

View File

@@ -2,10 +2,15 @@
SERVER_PORT=8080
FRONTEND_PORT=3000
WEBSOCKET_PORT=8082
MCP_PORT=9090
MYSQL_PORT=3306
REDIS_PORT=6379
RABBITMQ_PORT=5672
RABBITMQ_MANAGEMENT_PORT=15672
MCP_HOST=0.0.0.0
MCP_BACKEND_BASE_URL=http://springboot:8080
MCP_CONNECT_TIMEOUT=5
MCP_READ_TIMEOUT=10
# === OpenSearch Configuration ===
OPENSEARCH_PORT=9200

View File

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

View File

@@ -1,6 +1,7 @@
package com.openisle.dto;
import com.openisle.model.PostChangeType;
import com.openisle.model.PostVisibleScopeType;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Getter;
@@ -29,5 +30,7 @@ public class PostChangeLogDto {
private LocalDateTime newPinnedAt;
private Boolean oldFeatured;
private Boolean newFeatured;
private PostVisibleScopeType oldVisibleScope;
private PostVisibleScopeType newVisibleScope;
private Integer amount;
}

View File

@@ -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;

View File

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

View File

@@ -52,6 +52,9 @@ public class PostChangeLogMapper {
} else if (log instanceof PostFeaturedChangeLog f) {
dto.setOldFeatured(f.isOldFeatured());
dto.setNewFeatured(f.isNewFeatured());
} else if (log instanceof PostVisibleScopeChangeLog v) {
dto.setOldVisibleScope(v.getOldVisibleScope());
dto.setNewVisibleScope(v.getNewVisibleScope());
} else if (log instanceof PostDonateChangeLog d) {
dto.setAmount(d.getAmount());
}

View File

@@ -75,6 +75,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())

View File

@@ -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;

View File

@@ -8,6 +8,7 @@ public enum PostChangeType {
CLOSED,
PINNED,
FEATURED,
VISIBLE_SCOPE,
VOTE_RESULT,
LOTTERY_RESULT,
DONATE,

View File

@@ -0,0 +1,23 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_visible_scope_change_logs")
public class PostVisibleScopeChangeLog extends PostChangeLog {
@Enumerated(EnumType.STRING)
private PostVisibleScopeType oldVisibleScope;
@Enumerated(EnumType.STRING)
private PostVisibleScopeType newVisibleScope;
}

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

@@ -99,6 +99,21 @@ public class PostChangeLogService {
logRepository.save(log);
}
public void recordVisibleScopeChange(
Post post,
User user,
PostVisibleScopeType oldVisibleScope,
PostVisibleScopeType newVisibleScope
) {
PostVisibleScopeChangeLog log = new PostVisibleScopeChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.VISIBLE_SCOPE);
log.setOldVisibleScope(oldVisibleScope);
log.setNewVisibleScope(newVisibleScope);
logRepository.save(log);
}
public void recordVoteResult(Post post) {
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
log.setPost(post);

View File

@@ -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.model.*;
import com.openisle.repository.CategoryProposalPostRepository;
@@ -252,6 +253,7 @@ public class PostService {
String content,
List<Long> tagIds,
PostType type,
PostVisibleScopeType postVisibleScopeType,
String prizeDescription,
String prizeIcon,
Integer prizeCount,
@@ -336,6 +338,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 CategoryProposalPost categoryProposalPost) {
@@ -716,7 +726,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)
@@ -1147,7 +1157,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");
@@ -1179,6 +1190,8 @@ public class PostService {
post.setContent(content);
post.setCategory(category);
post.setTags(new java.util.HashSet<>(tags));
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
post.setVisibleScope(postVisibleScopeType);
Post updated = postRepository.save(post);
imageUploader.adjustReferences(oldContent, content);
notificationService.notifyMentions(content, user, updated, null);
@@ -1200,6 +1213,14 @@ public class PostService {
if (!oldTags.equals(newTags)) {
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
}
if (!java.util.Objects.equals(oldVisibleScope, postVisibleScopeType)) {
postChangeLogService.recordVisibleScopeChange(
updated,
user,
oldVisibleScope,
postVisibleScopeType
);
}
if (updated.getStatus() == PostStatus.PUBLISHED) {
searchIndexEventPublisher.publishPostSaved(updated);
}

View File

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

View File

@@ -40,12 +40,12 @@ echo "👉 Build images ..."
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=production \
frontend_service
frontend_service mcp-service
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service
mysql redis rabbitmq websocket-service springboot mcp-service frontend_service
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

@@ -36,16 +36,15 @@ echo "👉 Pull base images (for image-based services)..."
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
echo "👉 Build images (staging)..."
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=staging \
frontend_service
frontend_service mcp-service
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service
mysql redis rabbitmq websocket-service springboot mcp-service frontend_service
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

@@ -161,8 +161,8 @@ services:
condition: service_started
websocket-service:
condition: service_healthy
opensearch:
condition: service_healthy
# opensearch:
# condition: service_healthy
command: >
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
mvn clean spring-boot:run -Dmaven.test.skip=true"
@@ -178,6 +178,38 @@ services:
- dev
- prod
mcp-service:
build:
context: ..
dockerfile: mcp/Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
env_file:
- ${ENV_FILE:-../.env}
environment:
MCP_HOST: ${MCP_HOST:-0.0.0.0}
MCP_PORT: ${MCP_PORT:-9090}
MCP_BACKEND_BASE_URL: ${MCP_BACKEND_BASE_URL:-http://springboot:8080}
MCP_CONNECT_TIMEOUT: ${MCP_CONNECT_TIMEOUT:-5}
MCP_READ_TIMEOUT: ${MCP_READ_TIMEOUT:-10}
ports:
- "${MCP_PORT:-9090}:${MCP_PORT:-9090}"
depends_on:
springboot:
condition: service_healthy
command: ["openisle-mcp"]
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${MCP_PORT:-9090}/healthz || exit 1"]
interval: 10s
timeout: 5s
retries: 30
start_period: 20s
restart: unless-stopped
networks:
- openisle-network
profiles:
- dev
- prod
websocket-service:
image: maven:3.9-eclipse-temurin-17
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket

View File

@@ -543,6 +543,7 @@ onMounted(async () => {
.header-label {
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
/* 在线人数的数字文字样式(无背景) */

View File

@@ -3,15 +3,30 @@
<div class="login-overlay-blur"></div>
<div class="login-overlay-content">
<user-icon class="login-overlay-icon" />
<div class="login-overlay-text">请先登录点击跳转到登录页面</div>
<div class="login-overlay-button" @click="goLogin">登录</div>
<div class="login-overlay-text">{{ props.text }}</div>
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
text: {
type: String,
default: '请先登录,点击跳转到登录页面',
},
buttonText: {
type: String,
default: '登录',
},
buttonLink: {
type: String,
default: '/login',
},
})
const goLogin = () => {
navigateTo('/login', { replace: true })
navigateTo(props.buttonLink, { replace: true })
}
</script>

View File

@@ -36,6 +36,10 @@
<template v-if="log.newFeatured">将文章设为精选</template>
<template v-else>取消精选文章</template>
</span>
<span v-else-if="log.type === 'VISIBLE_SCOPE'" class="change-log-content">
变更了文章可见范围, {{ formatVisibleScope(log.oldVisibleScope) }} 修改为
{{ formatVisibleScope(log.newVisibleScope) }}
</span>
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
>系统已计算投票结果</span
>
@@ -69,6 +73,17 @@ const props = defineProps({
title: String,
})
const VISIBLE_SCOPE_LABELS = {
ALL: '全部可见',
ONLY_ME: '仅自己可见',
ONLY_REGISTER: '仅注册用户可见',
}
const formatVisibleScope = (scope) => {
if (!scope) return VISIBLE_SCOPE_LABELS.ALL
return VISIBLE_SCOPE_LABELS[scope] ?? scope
}
const diffHtml = computed(() => {
// Track theme changes
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'

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

@@ -70,6 +70,7 @@
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
<star v-if="!article.rssExcluded" class="featured-icon" />
{{ article.title }}
<lock class="preview-close-icon" v-if="article.isRestricted" />
</NuxtLink>
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
@@ -295,6 +296,7 @@ const {
comments: p.commentCount,
views: p.views,
rssExcluded: p.rssExcluded || false,
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
time: TimeManager.format(
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 })),
comments: p.commentCount,
views: p.views,
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
rssExcluded: p.rssExcluded || false,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,

View File

@@ -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>
@@ -54,6 +55,7 @@ import PollForm from '~/components/PollForm.vue'
import ProposalForm from '~/components/ProposalForm.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
@@ -62,6 +64,7 @@ const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const postType = ref('NORMAL')
const postVisibleScope = ref('ALL')
const lottery = reactive({
prizeIcon: '',
prizeIconFile: null,
@@ -100,6 +103,7 @@ const loadDraft = async () => {
content.value = data.content || ''
selectedCategory.value = data.categoryId || ''
selectedTags.value = data.tagIds || []
postVisibleScope.value = data.visiblescope
toast.success('草稿已加载')
}
@@ -115,6 +119,7 @@ const clearPost = async () => {
content.value = ''
selectedCategory.value = ''
selectedTags.value = []
postVisibleScope.value = 'ALL'
postType.value = 'NORMAL'
lottery.prizeIcon = ''
lottery.prizeIconFile = null
@@ -168,6 +173,7 @@ const saveDraft = async () => {
content: content.value,
categoryId: selectedCategory.value || null,
tagIds,
postVisibleScopeType:postVisibleScope.value
}),
})
if (res.ok) {
@@ -328,6 +334,7 @@ const submitPost = async () => {
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
postVisibleScopeType: postVisibleScope.value,
}
if (postType.value === 'LOTTERY') {
@@ -355,6 +362,7 @@ const submitPost = async () => {
},
body: JSON.stringify(payload),
})
const data = await res.json()
if (res.ok) {
if (data.reward && data.reward > 0) {

View File

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

View File

@@ -1,5 +1,22 @@
<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">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
@@ -16,7 +33,9 @@
<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" 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
v-if="!closed && loggedIn && !isAuthor && !subscribed"
class="article-subscribe-button"
@@ -165,25 +184,6 @@
</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"
@@ -228,6 +228,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 +242,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(
@@ -408,6 +416,14 @@ const changeLogIcon = (l) => {
} else {
return 'dislike'
}
} else if (l.type === 'VISIBLE_SCOPE') {
if (l.newVisibleScope === 'ONLY_ME') {
return 'lock-one'
} else if (l.newVisibleScope === 'ONLY_REGISTER') {
return 'peoples-two'
} else {
return 'communication'
}
} else if (l.type === 'VOTE_RESULT') {
return 'check-one'
} else if (l.type === 'LOTTERY_RESULT') {
@@ -438,6 +454,8 @@ const mapChangeLog = (l) => ({
newCategory: l.newCategory,
oldTags: l.oldTags,
newTags: l.newTags,
oldVisibleScope: l.oldVisibleScope,
newVisibleScope: l.newVisibleScope,
amount: l.amount,
icon: changeLogIcon(l),
})
@@ -497,15 +515,27 @@ const onCommentDeleted = (id) => {
fetchTimeline()
}
const tokenHeader = computed(() => {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
})
const {
data: postData,
pending: pendingPost,
error: postError,
refresh: refreshPost,
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
server: true,
lazy: false,
})
} = 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 +549,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 +966,7 @@ onMounted(async () => {
<style>
.post-page-container {
background-color: var(--background-color);
display: flex;
display: block;
flex-direction: row;
}
@@ -948,9 +979,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 {
@@ -1002,6 +1034,35 @@ onMounted(async () => {
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 {
display: flex;
flex-direction: row;
@@ -1106,7 +1167,7 @@ onMounted(async () => {
white-space: nowrap;
}
.article-closed-button,
.article-gray-button,
.article-subscribe-button-text,
.article-featured-button,
.article-unsubscribe-button-text {
@@ -1159,7 +1220,7 @@ onMounted(async () => {
font-size: 14px;
}
.article-closed-button {
.article-gray-button {
background-color: var(--background-color);
color: gray;
border: 1px solid gray;
@@ -1341,6 +1402,76 @@ 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);
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) {
.post-page-main-container {
width: calc(100% - 20px);

View File

@@ -82,6 +82,7 @@ import {
Share,
Financing,
Hands,
PreviewCloseOne,
} from '@icon-park/vue-next'
export default defineNuxtPlugin((nuxtApp) => {
@@ -167,4 +168,5 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('Share', Share)
nuxtApp.vueApp.component('Financing', Financing)
nuxtApp.vueApp.component('Hands', Hands)
nuxtApp.vueApp.component('PreviewCloseOne', PreviewCloseOne)
})

View File

@@ -268,23 +268,21 @@ export function stripMarkdownLength(text, length) {
// 朴素文本带贴吧表情
export function stripMarkdownWithTiebaMoji(text, length){
console.error(text)
if (!text) return ''
// Markdown 转成纯文本
const plain = stripMarkdown(text)
console.error(plain)
// 替换 :tieba123: 为 <img>
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
const key = `tieba${num}`
const file = tiebaEmoji[key]
return file
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
: match // 没有匹配到图片则保留原样
})
// Markdown 转成纯文本
const plain = stripMarkdown(text)
// 替换 :tieba123: 为 <img>
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
const key = `tieba${num}`
const file = tiebaEmoji[key]
return file
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
: match // 没有匹配到图片则保留原样
})
// 截断纯文本长度(防止撑太长)
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
return truncated
// 截断纯文本长度(防止撑太长)
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
return truncated
}

21
mcp/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY mcp/pyproject.toml ./pyproject.toml
COPY mcp/README.md ./README.md
COPY mcp/src ./src
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir .
EXPOSE 9090
CMD ["openisle-mcp"]

34
mcp/README.md Normal file
View File

@@ -0,0 +1,34 @@
# OpenIsle MCP Service
This package hosts a lightweight Python service that exposes OpenIsle search
capabilities through a Model Context Protocol (MCP) compatible HTTP interface.
It currently forwards search requests to the main Spring Boot backend and
returns the aggregated results. The service is intentionally simple so we can
iterate quickly and extend it with additional tools (for example, post
creation) in future updates.
## Local development
```bash
pip install -e ./mcp
openisle-mcp
```
By default the server listens on port `9090` and expects the Spring Boot backend
at `http://localhost:8080`. Configure the behaviour with the following
environment variables:
- `MCP_PORT` HTTP port the MCP service should listen on (default: `9090`).
- `MCP_HOST` Bind host for the HTTP server (default: `0.0.0.0`).
- `MCP_BACKEND_BASE_URL` Base URL of the Spring Boot backend that provides the
search endpoints (default: `http://springboot:8080`).
- `MCP_CONNECT_TIMEOUT` Connection timeout (seconds) when calling the backend
(default: `5`).
- `MCP_READ_TIMEOUT` Read timeout (seconds) when calling the backend (default:
`10`).
## Docker
The repository contains a Dockerfile that builds a slim Python image running the
service with `uvicorn`. The compose configuration wires the container into the
existing OpenIsle stack so that deployments automatically start the MCP service.

25
mcp/pyproject.toml Normal file
View File

@@ -0,0 +1,25 @@
[build-system]
requires = ["hatchling>=1.21.0"]
build-backend = "hatchling.build"
[project]
name = "openisle-mcp"
version = "0.1.0"
description = "Model Context Protocol server exposing OpenIsle search capabilities"
readme = "README.md"
authors = [
{ name = "OpenIsle" }
]
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.111.0,<1.0.0",
"uvicorn[standard]>=0.29.0,<0.31.0",
"httpx>=0.27.0,<0.28.0",
"pydantic>=2.7.0,<3.0.0"
]
[project.scripts]
openisle-mcp = "openisle_mcp.__main__:main"
[tool.hatch.build.targets.wheel]
packages = ["src/openisle_mcp"]

View File

@@ -0,0 +1,6 @@
"""OpenIsle MCP service package."""
from .config import Settings, get_settings
from .server import create_app
__all__ = ["Settings", "get_settings", "create_app"]

View File

@@ -0,0 +1,24 @@
"""Entrypoint for running the MCP service with ``python -m``."""
from __future__ import annotations
import logging
import uvicorn
from .config import get_settings
def main() -> None:
settings = get_settings()
logging.basicConfig(level=logging.INFO)
uvicorn.run(
"openisle_mcp.server:create_app",
host=settings.host,
port=settings.port,
factory=True,
)
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,44 @@
"""HTTP client helpers for talking to the Spring Boot backend."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from .config import Settings
LOGGER = logging.getLogger(__name__)
class SearchClient:
"""Wrapper around :class:`httpx.AsyncClient` for search operations."""
def __init__(self, settings: Settings):
timeout = httpx.Timeout(
connect=settings.connect_timeout,
read=settings.read_timeout,
write=settings.read_timeout,
pool=None,
)
self._client = httpx.AsyncClient(
base_url=settings.normalized_backend_base_url,
timeout=timeout,
)
async def close(self) -> None:
await self._client.aclose()
async def global_search(self, keyword: str) -> list[dict[str, Any]]:
LOGGER.debug("Performing global search for keyword '%s'", keyword)
response = await self._client.get("/api/search/global", params={"keyword": keyword})
response.raise_for_status()
payload = response.json()
if isinstance(payload, list):
return payload
LOGGER.warning("Unexpected payload type from backend: %s", type(payload))
return []
__all__ = ["SearchClient"]

View File

@@ -0,0 +1,71 @@
"""Configuration helpers for the MCP service."""
from __future__ import annotations
import os
from functools import lru_cache
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class Settings(BaseModel):
"""Application settings sourced from environment variables."""
host: str = Field(default="0.0.0.0", description="Host to bind the HTTP server to")
port: int = Field(default=9090, ge=1, le=65535, description="Port exposed by the MCP server")
backend_base_url: str = Field(
default="http://springboot:8080",
description="Base URL of the Spring Boot backend that provides search endpoints",
)
connect_timeout: float = Field(
default=5.0,
ge=0.0,
description="Connection timeout when communicating with the backend (seconds)",
)
read_timeout: float = Field(
default=10.0,
ge=0.0,
description="Read timeout when communicating with the backend (seconds)",
)
model_config = ConfigDict(extra="ignore")
@property
def normalized_backend_base_url(self) -> str:
"""Return the backend base URL without a trailing slash."""
return self.backend_base_url.rstrip("/")
ENV_MAPPING: dict[str, str] = {
"host": "MCP_HOST",
"port": "MCP_PORT",
"backend_base_url": "MCP_BACKEND_BASE_URL",
"connect_timeout": "MCP_CONNECT_TIMEOUT",
"read_timeout": "MCP_READ_TIMEOUT",
}
def _load_environment_values() -> dict[str, Any]:
values: dict[str, Any] = {}
for field, env_name in ENV_MAPPING.items():
value = os.getenv(env_name)
if value is None:
continue
values[field] = value
return values
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Load and validate application settings."""
values = _load_environment_values()
try:
return Settings(**values)
except ValidationError as exc: # pragma: no cover - defensive branch
raise RuntimeError("Invalid MCP configuration") from exc
__all__ = ["Settings", "get_settings"]

View File

@@ -0,0 +1,38 @@
"""Pydantic models shared across the MCP service."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class SearchResult(BaseModel):
"""Representation of a single search result entry."""
model_config = ConfigDict(extra="ignore")
type: Optional[str] = Field(default=None, description="Type of the result entry")
id: Optional[int] = Field(default=None, description="Identifier of the result entry")
text: Optional[str] = Field(default=None, description="Primary text of the result entry")
subText: Optional[str] = Field(default=None, description="Secondary text associated with the result")
extra: Optional[str] = Field(default=None, description="Additional information about the result")
postId: Optional[int] = Field(default=None, description="Related post identifier, if applicable")
highlightedText: Optional[str] = Field(default=None, description="Highlighted primary text segment")
highlightedSubText: Optional[str] = Field(
default=None,
description="Highlighted secondary text segment",
)
highlightedExtra: Optional[str] = Field(
default=None,
description="Highlighted additional information",
)
class SearchResponse(BaseModel):
"""Response payload returned by the search endpoint."""
results: list[SearchResult] = Field(default_factory=list)
__all__ = ["SearchResult", "SearchResponse"]

View File

@@ -0,0 +1,66 @@
"""FastAPI application exposing the MCP server endpoints."""
from __future__ import annotations
import logging
from fastapi import Depends, FastAPI, HTTPException, Query, Request
import httpx
from .client import SearchClient
from .config import get_settings
from .models import SearchResponse, SearchResult
LOGGER = logging.getLogger(__name__)
async def _lifespan(app: FastAPI):
settings = get_settings()
client = SearchClient(settings)
app.state.settings = settings
app.state.search_client = client
LOGGER.info(
"Starting MCP server on %s:%s targeting backend %s",
settings.host,
settings.port,
settings.normalized_backend_base_url,
)
try:
yield
finally:
LOGGER.info("Shutting down MCP server")
await client.close()
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(title="OpenIsle MCP Server", lifespan=_lifespan)
@app.get("/healthz", tags=["health"])
async def healthcheck() -> dict[str, str]:
return {"status": "ok"}
async def get_client(request: Request) -> SearchClient:
return request.app.state.search_client
@app.get("/search", response_model=SearchResponse, tags=["search"])
async def search(
keyword: str = Query(..., min_length=1, description="Keyword to search for"),
client: SearchClient = Depends(get_client),
) -> SearchResponse:
try:
raw_results = await client.global_search(keyword)
except httpx.HTTPStatusError as exc:
LOGGER.warning("Backend responded with error %s", exc.response.status_code)
raise HTTPException(status_code=exc.response.status_code, detail="Backend error") from exc
except httpx.HTTPError as exc:
LOGGER.error("Failed to reach backend: %s", exc)
raise HTTPException(status_code=503, detail="Search service unavailable") from exc
results = [SearchResult.model_validate(item) for item in raw_results]
return SearchResponse(results=results)
return app
__all__ = ["create_app"]