mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-07 23:51:17 +08:00
Compare commits
12 Commits
feature/re
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65273daa6 | ||
|
|
d3a2acb605 | ||
|
|
bced24e47d | ||
|
|
425ad03e6f | ||
|
|
4462d8f711 | ||
|
|
1b31977ec6 | ||
|
|
42693cb1ff | ||
|
|
6b500466fc | ||
|
|
c84262eb88 | ||
|
|
fa2ffaa64a | ||
|
|
3037c856d0 | ||
|
|
7b1ce3f070 |
@@ -38,6 +38,16 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.vladsch.flexmark</groupId>
|
||||
<artifactId>flexmark-all</artifactId>
|
||||
<version>0.64.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.17.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
|
||||
@@ -119,6 +119,7 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/rss").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
|
||||
@@ -154,7 +155,8 @@ public class SecurityConfig {
|
||||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
||||
uri.startsWith("/api/point-goods") ||
|
||||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
|
||||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") ||
|
||||
uri.startsWith("/api/rss"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
|
||||
@@ -45,4 +45,14 @@ public class AdminPostController {
|
||||
public PostSummaryDto unpin(@PathVariable Long id) {
|
||||
return postMapper.toSummaryDto(postService.unpinPost(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/rss-exclude")
|
||||
public PostSummaryDto excludeFromRss(@PathVariable Long id) {
|
||||
return postMapper.toSummaryDto(postService.excludeFromRss(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/rss-include")
|
||||
public PostSummaryDto includeInRss(@PathVariable Long id) {
|
||||
return postMapper.toSummaryDto(postService.includeInRss(id));
|
||||
}
|
||||
}
|
||||
|
||||
282
backend/src/main/java/com/openisle/controller/RssController.java
Normal file
282
backend/src/main/java/com/openisle/controller/RssController.java
Normal file
@@ -0,0 +1,282 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.service.PostService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
|
||||
import com.vladsch.flexmark.ext.tables.TablesExtension;
|
||||
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
|
||||
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension;
|
||||
import com.vladsch.flexmark.html.HtmlRenderer;
|
||||
import com.vladsch.flexmark.parser.Parser;
|
||||
import com.vladsch.flexmark.util.data.MutableDataSet;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class RssController {
|
||||
private final PostService postService;
|
||||
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
private String websiteUrl;
|
||||
|
||||
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure)
|
||||
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
|
||||
private static final Pattern HTML_IMAGE = Pattern.compile("<img[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
|
||||
|
||||
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
|
||||
|
||||
// flexmark:Markdown -> HTML
|
||||
private static final Parser MD_PARSER;
|
||||
private static final HtmlRenderer MD_RENDERER;
|
||||
static {
|
||||
MutableDataSet opts = new MutableDataSet();
|
||||
opts.set(Parser.EXTENSIONS, Arrays.asList(
|
||||
TablesExtension.create(),
|
||||
AutolinkExtension.create(),
|
||||
StrikethroughExtension.create(),
|
||||
TaskListExtension.create()
|
||||
));
|
||||
// 允许内联 HTML(下游再做 sanitize)
|
||||
opts.set(Parser.HTML_BLOCK_PARSER, true);
|
||||
MD_PARSER = Parser.builder(opts).build();
|
||||
MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build();
|
||||
}
|
||||
|
||||
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
|
||||
public String feed() {
|
||||
// 建议 20;你现在是 10,这里保留你的 10
|
||||
List<Post> posts = postService.listLatestRssPosts(10);
|
||||
String base = trimTrailingSlash(websiteUrl);
|
||||
|
||||
StringBuilder sb = new StringBuilder(4096);
|
||||
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
sb.append("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
|
||||
sb.append("<channel>");
|
||||
elem(sb, "title", cdata("OpenIsle RSS"));
|
||||
elem(sb, "link", base + "/");
|
||||
elem(sb, "description", cdata("Latest posts"));
|
||||
ZonedDateTime updated = posts.stream()
|
||||
.map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault()))
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(ZonedDateTime.now());
|
||||
// channel lastBuildDate(GMT)
|
||||
elem(sb, "lastBuildDate", toRfc1123Gmt(updated));
|
||||
|
||||
for (Post p : posts) {
|
||||
String link = base + "/posts/" + p.getId();
|
||||
|
||||
// 1) Markdown -> HTML
|
||||
String html = renderMarkdown(p.getContent());
|
||||
|
||||
// 2) Sanitize(白名单增强)
|
||||
String safeHtml = sanitizeHtml(html);
|
||||
|
||||
// 3) 绝对化 href/src + 强制 rel/target
|
||||
String absHtml = absolutifyHtml(safeHtml, base);
|
||||
|
||||
// 4) 纯文本摘要(用于 <description>)
|
||||
String plain = textSummary(absHtml, 180);
|
||||
|
||||
// 5) enclosure(首图,已绝对化)
|
||||
String enclosure = firstImage(p.getContent());
|
||||
if (enclosure == null) {
|
||||
// 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次
|
||||
enclosure = firstImage(absHtml);
|
||||
}
|
||||
if (enclosure != null) {
|
||||
enclosure = absolutifyUrl(enclosure, base);
|
||||
}
|
||||
|
||||
sb.append("<item>");
|
||||
elem(sb, "title", cdata(nullSafe(p.getTitle())));
|
||||
elem(sb, "link", link);
|
||||
sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
|
||||
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
|
||||
// 摘要
|
||||
elem(sb, "description", cdata(plain));
|
||||
// 全文(HTML)
|
||||
sb.append("<content:encoded><![CDATA[").append(absHtml).append("]]></content:encoded>");
|
||||
// 首图 enclosure(图片类型)
|
||||
if (enclosure != null) {
|
||||
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
|
||||
.append(getMimeType(enclosure)).append("\" />");
|
||||
}
|
||||
sb.append("</item>");
|
||||
}
|
||||
|
||||
sb.append("</channel></rss>");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/* ===================== Markdown → HTML ===================== */
|
||||
|
||||
private static String renderMarkdown(String md) {
|
||||
if (md == null || md.isEmpty()) return "";
|
||||
return MD_RENDERER.render(MD_PARSER.parse(md));
|
||||
}
|
||||
|
||||
/* ===================== Sanitize & 绝对化 ===================== */
|
||||
|
||||
private static String sanitizeHtml(String html) {
|
||||
if (html == null) return "";
|
||||
Safelist wl = Safelist.relaxed()
|
||||
.addTags("pre", "code", "figure", "figcaption", "picture", "source",
|
||||
"table","thead","tbody","tr","th","td","h1","h2","h3","h4","h5","h6")
|
||||
.addAttributes("a", "href", "title", "target", "rel")
|
||||
.addAttributes("img", "src", "alt", "title", "width", "height")
|
||||
.addAttributes("source", "srcset", "type", "media")
|
||||
.addAttributes("code", "class")
|
||||
.addAttributes("pre", "class")
|
||||
.addProtocols("a", "href", "http", "https", "mailto")
|
||||
.addProtocols("img", "src", "http", "https", "data")
|
||||
.addProtocols("source", "srcset", "http", "https");
|
||||
// 清除所有 on* 事件、style(避免阅读器环境差异)
|
||||
return Jsoup.clean(html, wl);
|
||||
}
|
||||
|
||||
private static String absolutifyHtml(String html, String baseUrl) {
|
||||
if (html == null || html.isEmpty()) return "";
|
||||
Document doc = Jsoup.parseBodyFragment(html, baseUrl);
|
||||
// a[href]
|
||||
for (Element a : doc.select("a[href]")) {
|
||||
String href = a.attr("href");
|
||||
String abs = absolutifyUrl(href, baseUrl);
|
||||
a.attr("href", abs);
|
||||
// 强制外链安全属性
|
||||
a.attr("rel", "noopener noreferrer nofollow");
|
||||
a.attr("target", "_blank");
|
||||
}
|
||||
// img[src]
|
||||
for (Element img : doc.select("img[src]")) {
|
||||
String src = img.attr("src");
|
||||
String abs = absolutifyUrl(src, baseUrl);
|
||||
img.attr("src", abs);
|
||||
}
|
||||
// source[srcset] (picture/webp)
|
||||
for (Element s : doc.select("source[srcset]")) {
|
||||
String srcset = s.attr("srcset");
|
||||
s.attr("srcset", absolutifySrcset(srcset, baseUrl));
|
||||
}
|
||||
return doc.body().html();
|
||||
}
|
||||
|
||||
private static String absolutifyUrl(String url, String baseUrl) {
|
||||
if (url == null || url.isEmpty()) return url;
|
||||
String u = url.trim();
|
||||
if (u.startsWith("//")) {
|
||||
return "https:" + u;
|
||||
}
|
||||
if (u.startsWith("#")) {
|
||||
// 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link,但此处无上下文)
|
||||
return baseUrl + "/" + u;
|
||||
}
|
||||
try {
|
||||
URI base = URI.create(ensureTrailingSlash(baseUrl));
|
||||
URI abs = base.resolve(u);
|
||||
return abs.toString();
|
||||
} catch (Exception e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private static String absolutifySrcset(String srcset, String baseUrl) {
|
||||
if (srcset == null || srcset.isEmpty()) return srcset;
|
||||
String[] parts = srcset.split(",");
|
||||
List<String> out = new ArrayList<>(parts.length);
|
||||
for (String part : parts) {
|
||||
String p = part.trim();
|
||||
if (p.isEmpty()) continue;
|
||||
String[] seg = p.split("\\s+");
|
||||
String url = seg[0];
|
||||
String size = seg.length > 1 ? seg[1] : "";
|
||||
out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size));
|
||||
}
|
||||
return String.join(", ", out);
|
||||
}
|
||||
|
||||
/* ===================== 摘要 & enclosure ===================== */
|
||||
|
||||
private static String textSummary(String html, int maxLen) {
|
||||
if (html == null) return "";
|
||||
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
|
||||
if (text.length() <= maxLen) return text;
|
||||
return text.substring(0, maxLen) + "…";
|
||||
}
|
||||
|
||||
private String firstImage(String content) {
|
||||
if (content == null) return null;
|
||||
Matcher m = MD_IMAGE.matcher(content);
|
||||
if (m.find()) return m.group(1);
|
||||
m = HTML_IMAGE.matcher(content);
|
||||
if (m.find()) return m.group(1);
|
||||
// 再从纯 HTML 里解析一次(如果传入的是渲染后的)
|
||||
try {
|
||||
Document doc = Jsoup.parse(content);
|
||||
Element img = doc.selectFirst("img[src]");
|
||||
if (img != null) return img.attr("src");
|
||||
} catch (Exception ignored) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String getMimeType(String url) {
|
||||
String lower = url == null ? "" : url.toLowerCase(Locale.ROOT);
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".avif")) return "image/avif";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
// 默认兜底
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
/* ===================== 时间/字符串/XML ===================== */
|
||||
|
||||
private static String toRfc1123Gmt(ZonedDateTime zdt) {
|
||||
return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123);
|
||||
}
|
||||
|
||||
private static String cdata(String s) {
|
||||
if (s == null) return "<![CDATA[]]>";
|
||||
// 防止出现 "]]>" 终止标记破坏 CDATA
|
||||
return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>";
|
||||
}
|
||||
|
||||
private static void elem(StringBuilder sb, String name, String value) {
|
||||
sb.append('<').append(name).append('>').append(value).append("</").append(name).append('>');
|
||||
}
|
||||
|
||||
private static String escapeXml(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace("\"", """).replace("'", "'");
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
if (s == null) return "";
|
||||
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
|
||||
private static String ensureTrailingSlash(String s) {
|
||||
if (s == null || s.isEmpty()) return "/";
|
||||
return s.endsWith("/") ? s : s + "/";
|
||||
}
|
||||
|
||||
private static String nullSafe(String s) { return s == null ? "" : s; }
|
||||
}
|
||||
@@ -31,5 +31,6 @@ public class PostSummaryDto {
|
||||
private int pointReward;
|
||||
private PostType type;
|
||||
private LotteryDto lottery;
|
||||
private boolean rssExcluded;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ public class PostMapper {
|
||||
dto.setCommentCount(commentService.countComments(post.getId()));
|
||||
dto.setStatus(post.getStatus());
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||
|
||||
List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId())
|
||||
.stream()
|
||||
|
||||
@@ -67,4 +67,6 @@ public class Post {
|
||||
@Column
|
||||
private LocalDateTime pinnedAt;
|
||||
|
||||
@Column(nullable = true)
|
||||
private Boolean rssExcluded = true;
|
||||
}
|
||||
|
||||
@@ -106,4 +106,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
"WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d")
|
||||
java.util.List<Object[]> countDailyRange(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
List<Post> findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,23 @@ public class PostService {
|
||||
this.publishMode = publishMode;
|
||||
}
|
||||
|
||||
public List<Post> listLatestRssPosts(int limit) {
|
||||
Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable);
|
||||
}
|
||||
|
||||
public Post excludeFromRss(Long id) {
|
||||
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
post.setRssExcluded(true);
|
||||
return postRepository.save(post);
|
||||
}
|
||||
|
||||
public Post includeInRss(Long id) {
|
||||
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
post.setRssExcluded(false);
|
||||
return postRepository.save(post);
|
||||
}
|
||||
|
||||
public Post createPost(String username,
|
||||
Long categoryId,
|
||||
String title,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE posts ADD COLUMN rss_excluded BOOLEAN NOT NULL DEFAULT 0;
|
||||
@@ -29,6 +29,18 @@
|
||||
<i :class="iconClass"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
|
||||
<i class="fas fa-copy"></i>
|
||||
邀请
|
||||
<i v-if="isCopying" class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
|
||||
<ToolTip content="复制RSS链接" placement="bottom">
|
||||
<div class="rss-icon" @click="copyRssLink">
|
||||
<i class="fas fa-rss"></i>
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||
<div class="new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
@@ -66,6 +78,11 @@ import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { toast } from '~/main'
|
||||
import { getToken } from '~/utils/auth'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
|
||||
const props = defineProps({
|
||||
showMenuBtn: {
|
||||
@@ -82,6 +99,7 @@ const showSearch = ref(false)
|
||||
const searchDropdown = ref(null)
|
||||
const userMenu = ref(null)
|
||||
const menuBtn = ref(null)
|
||||
const isCopying = ref(false)
|
||||
|
||||
const search = () => {
|
||||
showSearch.value = true
|
||||
@@ -100,6 +118,41 @@ const goToLogin = () => {
|
||||
const goToSettings = () => {
|
||||
navigateTo('/settings', { replace: true })
|
||||
}
|
||||
|
||||
const copyInviteLink = async () => {
|
||||
isCopying.value = true
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const inviteLink = data.token ? `${WEBSITE_BASE_URL}/signup?invite_token=${data.token}` : ''
|
||||
await navigator.clipboard.writeText(inviteLink)
|
||||
toast.success('邀请链接已复制')
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
toast.error(data.error || '生成邀请链接失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('生成邀请链接失败')
|
||||
} finally {
|
||||
isCopying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyRssLink = async () => {
|
||||
const rssLink = `${API_BASE_URL}/api/rss`
|
||||
await navigator.clipboard.writeText(rssLink)
|
||||
toast.success('RSS链接已复制')
|
||||
}
|
||||
|
||||
const goToProfile = async () => {
|
||||
if (!authState.loggedIn) {
|
||||
navigateTo('/login', { replace: true })
|
||||
@@ -224,7 +277,7 @@ onMounted(async () => {
|
||||
margin-left: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.auth-btns {
|
||||
@@ -315,11 +368,41 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.invite_text {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.invite_text:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rss-icon,
|
||||
.new-post-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rss-icon {
|
||||
animation: rss-glow 2s 3;
|
||||
}
|
||||
|
||||
@keyframes rss-glow {
|
||||
0% {
|
||||
text-shadow: 0 0 0px var(--primary-color);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 12px var(--primary-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
text-shadow: 0 0 0px var(--primary-color);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.header-content {
|
||||
padding-left: 15px;
|
||||
@@ -336,5 +419,9 @@ onMounted(async () => {
|
||||
.logo-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-content-right {
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,6 @@ export default defineNuxtConfig({
|
||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||
},
|
||||
},
|
||||
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
app: {
|
||||
pageTransition: { name: 'page', mode: 'out-in' },
|
||||
|
||||
@@ -268,6 +268,7 @@ const postReactions = ref([])
|
||||
const comments = ref([])
|
||||
const status = ref('PUBLISHED')
|
||||
const pinnedAt = ref(null)
|
||||
const rssExcluded = ref(false)
|
||||
const isWaitingPostingComment = ref(false)
|
||||
const postTime = ref('')
|
||||
const postItems = ref([])
|
||||
@@ -356,6 +357,11 @@ const articleMenuItems = computed(() => {
|
||||
} else {
|
||||
items.push({ text: '置顶', onClick: () => pinPost() })
|
||||
}
|
||||
if (rssExcluded.value) {
|
||||
items.push({ text: 'rss推荐', onClick: () => includeRss() })
|
||||
} else {
|
||||
items.push({ text: '取消rss推荐', onClick: () => excludeRss() })
|
||||
}
|
||||
}
|
||||
if (isAdmin.value && status.value === 'PENDING') {
|
||||
items.push({ text: '通过审核', onClick: () => approvePost() })
|
||||
@@ -480,6 +486,7 @@ watchEffect(() => {
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
pinnedAt.value = data.pinnedAt
|
||||
rssExcluded.value = data.rssExcluded
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
@@ -645,6 +652,36 @@ const unpinPost = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const excludeRss = async () => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-exclude`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
rssExcluded.value = true
|
||||
toast.success('已标记为rss不推荐')
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const includeRss = async () => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-include`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
rssExcluded.value = false
|
||||
toast.success('已标记为rss推荐')
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const editPost = () => {
|
||||
navigateTo(`/posts/${postId}/edit`, { replace: true })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Vditor from 'vditor'
|
||||
import { getToken, authState } from './auth'
|
||||
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
import '~/assets/global.css'
|
||||
|
||||
export function getEditorTheme() {
|
||||
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
|
||||
|
||||
Reference in New Issue
Block a user