Compare commits

..

43 Commits

Author SHA1 Message Date
tim 6f3830b3f7 fix: revert vditor change 2025-08-15 00:50:44 +08:00
tim 2cf89e4802 fix: ssr 水合采用useAsyncData 2025-08-15 00:12:06 +08:00
tim 1fc6460ae0 fix: 修复vditor移动端贴顶的问题 2025-08-15 00:01:18 +08:00
Tim a04e5c2f6f Merge pull request #560 from CH-122/feat/password-recovery-hint
feat: 忘记密码页面添加提示 & 修复缺少定义导致的报错 #535
2025-08-14 23:43:26 +08:00
Tim 77b26937f5 Merge pull request #562 from CH-122/fix/mobile-header-search
fix: 移动端 header 点击搜索图标功能异常
2025-08-14 23:39:19 +08:00
Tim a1134b9d4b Merge pull request #559 from AnNingUI/main 2025-08-14 21:42:32 +08:00
AnNingUI 600f6ac1d1 fix: 修复代码高亮背景与抽奖背景色公用的问题 2025-08-14 21:39:39 +08:00
CH_122 9ad50b35c9 fix: 移动端 header 点击搜索图标功能异常 2025-08-14 21:35:57 +08:00
CH_122 867ee3907b feat: 忘记密码添加提示 & 修复缺少定义导致的报错 2025-08-14 21:21:34 +08:00
CH_122 58fcd42745 style: add cursor pointer to dropdown items for better UX 2025-08-14 21:20:23 +08:00
AnNingUI 0ee62a3a04 fix: 让代码展示背景的样式更加现代化,修复分类选择框仅有一个当前分类的问题
Fixes #558
2025-08-14 21:05:08 +08:00
Tim f0bc7a22a0 fix: google login 问题修复 2025-08-14 20:34:21 +08:00
Tim f6c0c8e226 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 20:25:33 +08:00
Tim 8f3c0d6710 fix: google login 问题修复 2025-08-14 20:25:09 +08:00
Tim 4f738778db Merge pull request #557 from nagisa77/feature/code_buauty
fix: 代码风格设置
2025-08-14 20:17:23 +08:00
Tim 84b45f785d fix: 代码风格设置 2025-08-14 19:55:53 +08:00
tim df56d7e885 Revert "optimize(backend): optimize /api/posts/latest-reply"
This reverts commit 1e87e9252d.
2025-08-14 18:54:12 +08:00
tim 76176e135c Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 18:27:25 +08:00
tim ab87e0e51c fix: fix missing setup 2025-08-14 18:27:12 +08:00
Tim 5346a063bf Merge pull request #555 from netcaty/main
优化主页列表接口/api/posts/latest-reply
2025-08-14 18:19:19 +08:00
netcaty e53f2130b8 Merge branch 'nagisa77:main' into main 2025-08-14 17:54:08 +08:00
netcat 1e87e9252d optimize(backend): optimize /api/posts/latest-reply
resolves #554
2025-08-14 17:53:01 +08:00
tim 3fc4d29dce Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 17:27:42 +08:00
tim bcdac9d9b2 fix: delete hook update 2025-08-14 17:27:30 +08:00
Tim ea9710d16f Merge pull request #553 from nagisa77/codex/fix-missing-comment-pinning-feature
fix: restore comment pin handling
2025-08-14 17:21:26 +08:00
Tim 47134cadc2 fix: handle pinned comments from backend 2025-08-14 17:21:08 +08:00
tim 1a1b20b9cf fix: update css import 2025-08-14 17:20:02 +08:00
Tim b63ebb8fae Merge pull request #552 from immortal521/feat/code-block-line-number
feat: add code block line number display
2025-08-14 16:47:46 +08:00
immortal521 e0f7299a86 feat: add code block line number display
- Added Maple Mono font
- Changed code block font to Maple Mono
- Increased mobile line height from 1.1 to 1.5
2025-08-14 15:40:14 +08:00
Tim 1f9ae8d057 Merge pull request #550 from nagisa77/feature/fix_db_error
fix: fix reward db error
2025-08-14 15:21:31 +08:00
Tim da1ad73cf6 fix: fix reward db error 2025-08-14 15:19:21 +08:00
Tim 53c603f33a Merge pull request #546 from netcaty/main
optimize(backend): batch query for /api/categories && /api/tags
2025-08-14 14:30:14 +08:00
Tim 06f86f2b21 Merge pull request #545 from nagisa77/feature/first_screen
Feature/first screen
2025-08-14 14:26:17 +08:00
Tim 22693bfdd9 fix: 首屏ssr优化 2025-08-14 14:25:38 +08:00
netcat 0058f20b1e optimize(backend): batch query for /api/categories && /api/tags 2025-08-14 14:19:04 +08:00
Tim 304d941d68 Revert "fix: use home path"
This reverts commit 2efe4e733a.
2025-08-14 13:50:58 +08:00
Tim 3dbcd2ac4d Merge pull request #543 from nagisa77/feature/first_screen
fix: use home path
2025-08-14 13:46:48 +08:00
Tim 2efe4e733a fix: use home path 2025-08-14 13:45:29 +08:00
Tim 08239a16b8 Merge pull request #542 from nagisa77/feature/first_screen
fix: 首屏ssr优化
2025-08-14 13:40:07 +08:00
Tim cb49dc9b73 fix: 首屏ssr优化 2025-08-14 13:39:25 +08:00
Tim 43d4c9be43 Merge pull request #541 from nagisa77/feature/first_screen
fix: 首屏ssr优化
2025-08-14 13:24:17 +08:00
Tim 1dc13698ad fix: 首屏ssr优化 2025-08-14 13:22:53 +08:00
Tim d58432dcd9 Merge pull request #540 from nagisa77/codex/fix-logo-click-triggering-window.reload 2025-08-14 12:47:43 +08:00
38 changed files with 351 additions and 126 deletions
@@ -81,8 +81,8 @@ public class SecurityConfig {
"http://localhost", "http://localhost",
"http://30.211.97.238:3000", "http://30.211.97.238:3000",
"http://30.211.97.238", "http://30.211.97.238",
"http://192.168.7.70", "http://192.168.7.98",
"http://192.168.7.70:8080", "http://192.168.7.98:3000",
websiteUrl, websiteUrl,
websiteUrl.replace("://www.", "://") websiteUrl.replace("://www.", "://")
)); ));
@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -44,8 +45,11 @@ public class CategoryController {
@GetMapping @GetMapping
public List<CategoryDto> list() { public List<CategoryDto> list() {
return categoryService.listCategories().stream() List<Category> all = categoryService.listCategories();
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId()))) List<Long> ids = all.stream().map(Category::getId).toList();
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
return all.stream()
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) .sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -62,8 +63,11 @@ public class TagController {
@GetMapping @GetMapping
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword, public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "limit", required = false) Integer limit) { @RequestParam(value = "limit", required = false) Integer limit) {
List<TagDto> dtos = tagService.searchTags(keyword).stream() List<Tag> tags = tagService.searchTags(keyword);
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId()))) List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
List<TagDto> dtos = tags.stream()
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) .sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList()); .collect(Collectors.toList());
if (limit != null && limit > 0 && dtos.size() > limit) { if (limit != null && limit > 0 && dtos.size() > limit) {
@@ -22,7 +22,7 @@ public class Notification {
private Long id; private Long id;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false, length = 50)
private NotificationType type; private NotificationType type;
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
long countByCategory_Id(Long categoryId); long countByCategory_Id(Long categoryId);
@Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id")
List<Object[]> countPostsByCategoryIds(@Param("categoryIds") List<Long> categoryIds);
long countDistinctByTags_Id(Long tagId); long countDistinctByTags_Id(Long tagId);
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
long countByAuthor_Id(Long userId); long countByAuthor_Id(Long userId);
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " + @Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List; import java.util.*;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
@@ -567,10 +566,31 @@ public class PostService {
return postRepository.countByCategory_Id(categoryId); return postRepository.countByCategory_Id(categoryId);
} }
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
Map<Long, Long> result = new HashMap<>();
var dbResult = postRepository.countPostsByCategoryIds(categoryIds);
dbResult.forEach(r -> {
result.put(((Long)r[0]), ((Long)r[1]));
});
return result;
}
public long countPostsByTag(Long tagId) { public long countPostsByTag(Long tagId) {
return postRepository.countDistinctByTags_Id(tagId); return postRepository.countDistinctByTags_Id(tagId);
} }
public Map<Long, Long> countPostsByTagIds(List<Long> tagIds) {
Map<Long, Long> result = new HashMap<>();
if (CollectionUtils.isEmpty(tagIds)) {
return result;
}
var dbResult = postRepository.countPostsByTagIds(tagIds);
dbResult.forEach(r -> {
result.put(((Long)r[0]), ((Long)r[1]));
});
return result;
}
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) { private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
return posts.stream() return posts.stream()
.sorted(java.util.Comparator .sorted(java.util.Comparator
+1 -1
View File
@@ -1,6 +1,6 @@
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-xxx.apps.googleusercontent.com NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779 NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
+143
View File
@@ -0,0 +1,143 @@
/* Maple Mono - Thin 100 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-100-normal.woff2") format("woff2");
font-weight: 100;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Thin Italic 100 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-100-italic.woff2") format("woff2");
font-weight: 100;
font-style: italic;
font-display: swap;
}
/* Maple Mono - ExtraLight 200 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-200-normal.woff2") format("woff2");
font-weight: 200;
font-style: normal;
font-display: swap;
}
/* Maple Mono - ExtraLight Italic 200 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-200-italic.woff2") format("woff2");
font-weight: 200;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Light 300 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-300-normal.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Light Italic 300 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-300-italic.woff2") format("woff2");
font-weight: 300;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Regular 400 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-400-normal.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Italic 400 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-400-italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Medium 500 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-500-normal.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Medium Italic 500 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-500-italic.woff2") format("woff2");
font-weight: 500;
font-style: italic;
font-display: swap;
}
/* Maple Mono - SemiBold 600 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-600-normal.woff2") format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* Maple Mono - SemiBold Italic 600 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-600-italic.woff2") format("woff2");
font-weight: 600;
font-style: italic;
font-display: swap;
}
/* Maple Mono - Bold 700 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-700-normal.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Maple Mono - Bold Italic 700 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-700-italic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
font-display: swap;
}
/* Maple Mono - ExtraBold 800 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-800-normal.woff2") format("woff2");
font-weight: 800;
font-style: normal;
font-display: swap;
}
/* Maple Mono - ExtraBold Italic 800 */
@font-face {
font-family: "Maple Mono";
src: url("/fonts/maple-mono-800-italic.woff2") format("woff2");
font-weight: 800;
font-style: italic;
font-display: swap;
}
+34 -11
View File
@@ -17,6 +17,7 @@
--scroller-background-color: rgba(130, 175, 180, 0.5); --scroller-background-color: rgba(130, 175, 180, 0.5);
--normal-background-color: rgb(241, 241, 241); --normal-background-color: rgb(241, 241, 241);
--lottery-background-color: rgb(241, 241, 241); --lottery-background-color: rgb(241, 241, 241);
--code-highlight-background-color: rgb(241, 241, 241);
--login-background-color: rgb(248, 248, 248); --login-background-color: rgb(248, 248, 248);
--login-background-color-hover: #e0e0e0; --login-background-color-hover: #e0e0e0;
--text-color: black; --text-color: black;
@@ -44,6 +45,7 @@
--menu-text-color: white; --menu-text-color: white;
--normal-background-color: #000000; --normal-background-color: #000000;
--lottery-background-color: #4e4e4e; --lottery-background-color: #4e4e4e;
--code-highlight-background-color: #262b35;
--login-background-color: #575757; --login-background-color: #575757;
--login-background-color-hover: #717171; --login-background-color-hover: #717171;
--text-color: #eee; --text-color: #eee;
@@ -131,13 +133,43 @@ body {
} }
.info-content-text pre { .info-content-text pre {
background-color: var(--normal-background-color); display: flex;
background-color: var(--code-highlight-background-color);
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
line-height: 1.5; line-height: 1.5;
position: relative; position: relative;
} }
.info-content-text pre .line-numbers {
counter-reset: line-number 0;
width: 2em;
font-size: 13px;
position: sticky;
flex-shrink: 0;
font-family: 'Maple Mono', monospace;
margin: 1em 0;
color: #888;
border-right: 1px solid #888;
box-sizing: border-box;
padding-right: 0.5em;
text-align: end;
}
.info-content-text pre .line-numbers .line-number::before {
content: counter(line-number);
counter-increment: line-number;
}
.info-content-text code {
font-family: 'Maple Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: no-wrap;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
}
.copy-code-btn { .copy-code-btn {
position: absolute; position: absolute;
top: 4px; top: 4px;
@@ -156,15 +188,6 @@ body {
opacity: 1; opacity: 1;
} }
.info-content-text code {
font-family: 'Roboto Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: pre-wrap;
background-color: var(--normal-background-color);
color: var(--text-color);
}
.info-content-text a { .info-content-text a {
color: var(--primary-color); color: var(--primary-color);
text-decoration: none; text-decoration: none;
@@ -267,7 +290,7 @@ body {
} }
.info-content-text pre { .info-content-text pre {
line-height: 1.1; line-height: 1.5;
} }
.vditor-panel { .vditor-panel {
@@ -82,6 +82,7 @@ export default {
.dropdown-item { .dropdown-item {
padding: 8px 16px; padding: 8px 16px;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
} }
.dropdown-item:hover { .dropdown-item:hover {
background-color: var(--menu-selected-background-color); background-color: var(--menu-selected-background-color);
+1 -17
View File
@@ -8,7 +8,7 @@
</button> </button>
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span> <span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
</div> </div>
<NuxtLink class="logo-container" to="/" replace @click="handleLogoClick"> <NuxtLink class="logo-container" :to="`/`">
<img <img
alt="OpenIsle" alt="OpenIsle"
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
@@ -51,7 +51,6 @@
<script setup> <script setup>
import { ClientOnly } from '#components' import { ClientOnly } from '#components'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import DropdownMenu from '~/components/DropdownMenu.vue' import DropdownMenu from '~/components/DropdownMenu.vue'
import SearchDropdown from '~/components/SearchDropdown.vue' import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth' import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
@@ -67,19 +66,12 @@ const props = defineProps({
const isLogin = computed(() => authState.loggedIn) const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount) const unreadCount = computed(() => notificationState.unreadCount)
const router = useRouter()
const avatar = ref('') const avatar = ref('')
const showSearch = ref(false) const showSearch = ref(false)
const searchDropdown = ref(null) const searchDropdown = ref(null)
const userMenu = ref(null) const userMenu = ref(null)
const menuBtn = ref(null) const menuBtn = ref(null)
const handleLogoClick = (event) => {
if (router.currentRoute.value.fullPath === '/') {
event.preventDefault()
window.dispatchEvent(new Event('refresh-home'))
}
}
const search = () => { const search = () => {
showSearch.value = true showSearch.value = true
nextTick(() => { nextTick(() => {
@@ -154,14 +146,6 @@ onMounted(async () => {
await updateUnread() await updateUnread()
}, },
) )
watch(
() => router.currentRoute.value.fullPath,
() => {
if (userMenu.value) userMenu.value.close()
showSearch.value = false
},
)
}) })
</script> </script>
+30 -30
View File
@@ -2,7 +2,7 @@
<transition name="slide"> <transition name="slide">
<nav v-if="visible" class="menu"> <nav v-if="visible" class="menu">
<div class="menu-item-container"> <div class="menu-item-container">
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick"> <NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
<i class="menu-item-icon fas fa-hashtag"></i> <i class="menu-item-icon fas fa-hashtag"></i>
<span class="menu-item-text">话题</span> <span class="menu-item-text">话题</span>
</NuxtLink> </NuxtLink>
@@ -128,41 +128,46 @@ import { ref, computed, watch, onMounted } from 'vue'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme' import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification' import { fetchUnreadCount, notificationState } from '~/utils/notification'
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
const props = defineProps({ const props = defineProps({
visible: { visible: { type: Boolean, default: true },
type: Boolean,
default: true,
},
}) })
const emit = defineEmits(['item-click']) const emit = defineEmits(['item-click'])
const categoryOpen = ref(true) const categoryOpen = ref(true)
const tagOpen = ref(true) const tagOpen = ref(true)
const isLoadingCategory = ref(false)
const isLoadingTag = ref(false)
const categoryData = ref([])
const tagData = ref([])
const fetchCategoryData = async () => { /** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
isLoadingCategory.value = true const {
const res = await fetch(`${API_BASE_URL}/api/categories`) data: categoryData,
const data = await res.json() pending: isLoadingCategory,
categoryData.value = data error: categoryError,
isLoadingCategory.value = false } = await useAsyncData(
} // 稳定 key:避免 hydration 期误判
'menu:categories',
() => $fetch(`${API_BASE_URL}/api/categories`),
{
server: true, // SSR 预取
default: () => [], // 初始默认值,减少空判断
// 5 分钟内复用缓存,避免路由往返重复请求
staleTime: 5 * 60 * 1000,
},
)
const fetchTagData = async () => { const {
isLoadingTag.value = true data: tagData,
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`) pending: isLoadingTag,
const data = await res.json() error: tagError,
tagData.value = data } = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
isLoadingTag.value = false server: true,
} default: () => [],
staleTime: 5 * 60 * 1000,
})
/** 其余逻辑保持不变 */
const iconClass = computed(() => { const iconClass = computed(() => {
switch (themeState.mode) { switch (themeState.mode) {
case ThemeMode.DARK: case ThemeMode.DARK:
@@ -188,13 +193,10 @@ const updateCount = async () => {
onMounted(async () => { onMounted(async () => {
await updateCount() await updateCount()
// 登录态变化时再拉一次未读数;与 useAsyncData 无关
watch(() => authState.loggedIn, updateCount) watch(() => authState.loggedIn, updateCount)
}) })
const handleHomeClick = () => {
navigateTo('/', { replace: true })
}
const handleItemClick = () => { const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click') if (window.innerWidth <= 768) emit('item-click')
} }
@@ -215,8 +217,6 @@ const gotoTag = (t) => {
navigateTo({ path: '/', query: { tags: value } }, { replace: true }) navigateTo({ path: '/', query: { tags: value } }, { replace: true })
handleItemClick() handleItemClick()
} }
await Promise.all([fetchCategoryData(), fetchTagData()])
</script> </script>
<style scoped> <style scoped>
@@ -110,6 +110,10 @@ watch(selected, (val) => {
selected.value = null selected.value = null
keyword.value = '' keyword.value = ''
}) })
defineExpose({
toggle,
})
</script> </script>
<style scoped> <style scoped>
+4 -2
View File
@@ -12,8 +12,8 @@ export default defineNuxtConfig({
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '', twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
}, },
}, },
// Ensure Vditor styles load before our overrides in global.css // 确保 Vditor 样式在 global.css 覆盖前加载
css: ['vditor/dist/index.css', '~/assets/global.css'], css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
app: { app: {
head: { head: {
script: [ script: [
@@ -52,6 +52,8 @@ export default defineNuxtConfig({
}, },
], ],
}, },
baseURL: '/',
buildAssetsDir: '/_nuxt/',
}, },
vue: { vue: {
compilerOptions: { compilerOptions: {
+23
View File
@@ -2,6 +2,7 @@
<div class="forgot-page"> <div class="forgot-page">
<div class="forgot-content"> <div class="forgot-content">
<div class="forgot-title">找回密码</div> <div class="forgot-title">找回密码</div>
<div v-if="step === 0" class="step-content"> <div v-if="step === 0" class="step-content">
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" /> <BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
<div v-if="emailError" class="error-message">{{ emailError }}</div> <div v-if="emailError" class="error-message">{{ emailError }}</div>
@@ -19,6 +20,10 @@
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div> <div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
<div class="primary-button disabled" v-else>提交中...</div> <div class="primary-button disabled" v-else>提交中...</div>
</div> </div>
<div class="hint-message">
<i class="fas fa-info-circle"></i>
使用 Google 注册的用户可使用对应的邮箱进行找回密码
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -26,6 +31,8 @@
<script setup> <script setup>
import { toast } from '~/main' import { toast } from '~/main'
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
import { useRoute } from 'vue-router'
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
@@ -39,6 +46,7 @@ const passwordError = ref('')
const isSending = ref(false) const isSending = ref(false)
const isVerifying = ref(false) const isVerifying = ref(false)
const isResetting = ref(false) const isResetting = ref(false)
const route = useRoute()
onMounted(() => { onMounted(() => {
if (route.query.email) { if (route.query.email) {
@@ -137,6 +145,21 @@ const resetPassword = async () => {
font-size: 24px; font-size: 24px;
font-weight: bold; font-weight: bold;
} }
.forgot-content .hint-message {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 8px;
padding: 8px 0;
font-size: 13px;
color: var(--blockquote-text-color);
}
.hint-message i {
color: var(--primary-color);
font-size: 14px;
}
.step-content { .step-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+4 -4
View File
@@ -116,7 +116,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import ArticleCategory from '~/components/ArticleCategory.vue' import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue' import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue' import CategorySelect from '~/components/CategorySelect.vue'
@@ -195,7 +195,7 @@ watch(
const loadOptions = async () => { const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) { if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try { try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`) const res = await fetch(`${API_BASE_URL}/api/categories/`)
if (res.ok) categoryOptions.value = [await res.json()] if (res.ok) categoryOptions.value = [await res.json()]
} catch {} } catch {}
} }
@@ -265,7 +265,7 @@ const {
time: TimeManager.format( time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt, selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
), ),
pinned: !!p.pinnedAt, pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
type: p.type, type: p.type,
})) }))
}, },
@@ -308,7 +308,7 @@ const fetchNextPage = async () => {
time: TimeManager.format( time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt, selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
), ),
pinned: !!p.pinnedAt, pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
type: p.type, type: p.type,
})) }))
articles.value.push(...mapped) articles.value.push(...mapped)
+1 -1
View File
@@ -77,7 +77,7 @@
</div> </div>
</template> </template>
<script> <script setup>
import 'flatpickr/dist/flatpickr.css' import 'flatpickr/dist/flatpickr.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import FlatPickr from 'vue-flatpickr-component' import FlatPickr from 'vue-flatpickr-component'
+30 -27
View File
@@ -232,7 +232,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox' import VueEasyLightbox from 'vue-easy-lightbox'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import CommentItem from '~/components/CommentItem.vue' import CommentItem from '~/components/CommentItem.vue'
@@ -268,7 +268,6 @@ const postReactions = ref([])
const comments = ref([]) const comments = ref([])
const status = ref('PUBLISHED') const status = ref('PUBLISHED')
const pinnedAt = ref(null) const pinnedAt = ref(null)
const isWaitingFetchingPost = ref(false)
const isWaitingPostingComment = ref(false) const isWaitingPostingComment = ref(false)
const postTime = ref('') const postTime = ref('')
const postItems = ref([]) const postItems = ref([])
@@ -392,7 +391,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
avatar: c.author.avatar, avatar: c.author.avatar,
text: c.content, text: c.content,
reactions: c.reactions || [], reactions: c.reactions || [],
pinned: !!c.pinnedAt, pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)), reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
openReplies: level === 0, openReplies: level === 0,
src: c.author.avatar, src: c.author.avatar,
@@ -455,21 +454,23 @@ const onCommentDeleted = (id) => {
fetchComments() fetchComments()
} }
const fetchPost = async () => { const {
try { data: postData,
isWaitingFetchingPost.value = true pending: pendingPost,
const token = getToken() error: postError,
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, { refresh: refreshPost,
headers: { Authorization: token ? `Bearer ${token}` : '' }, } = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
server: true,
lazy: false,
}) })
isWaitingFetchingPost.value = false
if (!res.ok) { // 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
if (res.status === 404 && process.client) { const isWaitingFetchingPost = computed(() => pendingPost.value)
router.replace('/404')
} // 同步到现有的响应式字段
return watchEffect(() => {
} const data = postData.value
const data = await res.json() if (!data) return
postContent.value = data.content postContent.value = data.content
author.value = data.author author.value = data.author
title.value = data.title title.value = data.title
@@ -482,11 +483,12 @@ const fetchPost = async () => {
postTime.value = TimeManager.format(data.createdAt) postTime.value = TimeManager.format(data.createdAt)
lottery.value = data.lottery || null lottery.value = data.lottery || null
if (lottery.value && lottery.value.endTime) startCountdown() if (lottery.value && lottery.value.endTime) startCountdown()
await nextTick() })
} catch (e) {
console.error(e) // 404 客户端跳转
} // if (postError.value?.statusCode === 404 && process.client) {
} // router.replace('/404')
// }
const totalPosts = computed(() => comments.value.length + 1) const totalPosts = computed(() => comments.value.length + 1)
const lastReplyTime = computed(() => const lastReplyTime = computed(() =>
@@ -607,6 +609,7 @@ const approvePost = async () => {
if (res.ok) { if (res.ok) {
status.value = 'PUBLISHED' status.value = 'PUBLISHED'
toast.success('已通过审核') toast.success('已通过审核')
await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -620,8 +623,8 @@ const pinPost = async () => {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
if (res.ok) { if (res.ok) {
pinnedAt.value = new Date().toISOString()
toast.success('已置顶') toast.success('已置顶')
await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -635,8 +638,8 @@ const unpinPost = async () => {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
if (res.ok) { if (res.ok) {
pinnedAt.value = null
toast.success('已取消置顶') toast.success('已取消置顶')
await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -674,6 +677,7 @@ const rejectPost = async () => {
if (res.ok) { if (res.ok) {
status.value = 'REJECTED' status.value = 'REJECTED'
toast.success('已驳回') toast.success('已驳回')
await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -709,7 +713,7 @@ const joinLottery = async () => {
}) })
if (res.ok) { if (res.ok) {
toast.success('已参与抽奖') toast.success('已参与抽奖')
await fetchPost() await refreshPost()
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -780,9 +784,8 @@ onMounted(async () => {
window.addEventListener('scroll', updateCurrentIndex) window.addEventListener('scroll', updateCurrentIndex)
jumpToHashComment() jumpToHashComment()
}) })
await fetchPost()
</script> </script>
<style> <style>
.post-page-container { .post-page-container {
background-color: var(--background-color); background-color: var(--background-color);
+1
View File
@@ -1,3 +1,4 @@
import { defineNuxtPlugin } from 'nuxt/app'
import ClickOutside from '~/directives/clickOutside.js' import ClickOutside from '~/directives/clickOutside.js'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
-7
View File
@@ -1,7 +0,0 @@
export default {
push(path) {
if (process.client) {
window.location.href = path
}
},
}
+1
View File
@@ -24,6 +24,7 @@ export async function googleGetIdToken() {
export function googleAuthorize() { export function googleAuthorize() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const GOOGLE_CLIENT_ID = config.public.googleClientId const GOOGLE_CLIENT_ID = config.public.googleClientId
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
if (!GOOGLE_CLIENT_ID) { if (!GOOGLE_CLIENT_ID) {
toast.error('Google 登录不可用, 请检查网络设置与VPN') toast.error('Google 登录不可用, 请检查网络设置与VPN')
return return
+15 -2
View File
@@ -1,5 +1,14 @@
import hljs from 'highlight.js' import hljs from 'highlight.js'
import 'highlight.js/styles/github.css' if (typeof window !== 'undefined') {
const theme =
document.documentElement.dataset.theme ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
if (theme === 'dark') {
import('highlight.js/styles/atom-one-dark.css')
} else {
import('highlight.js/styles/atom-one-light.css')
}
}
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { toast } from '../main' import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji' import { tiebaEmoji } from './tiebaEmoji'
@@ -86,7 +95,11 @@ const md = new MarkdownIt({
} else { } else {
code = hljs.highlightAuto(str).value code = hljs.highlightAuto(str).value
} }
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>` const lineNumbers = code
.trim()
.split('\n')
.map(() => `<div class="line-number"></div>`)
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
}, },
}) })