Compare commits

...

18 Commits

Author SHA1 Message Date
Tim
da1ad73cf6 fix: fix reward db error 2025-08-14 15:19:21 +08:00
Tim
ada6bfb5cf Merge pull request #536 from nagisa77/codex/add-logo-click-to-refresh-homepage
feat: refresh home when clicking header logo
2025-08-14 11:00:37 +08:00
Tim
928dbd73b5 feat: allow logo to refresh home page 2025-08-14 11:00:17 +08:00
Tim
8c1a7afc6e Merge pull request #530 from nagisa77/feature/env
fix: 前后端代码域名hardcode调整(for预发环境做准备)
2025-08-14 10:38:49 +08:00
Tim
87453f7198 fix: add .env.example 2025-08-14 10:36:02 +08:00
Tim
48e3593ef9 Merge remote-tracking branch 'origin/main' into feature/env 2025-08-14 10:34:10 +08:00
Tim
655e8f2a65 fix: setup 迁移完成 v1 2025-08-14 10:27:01 +08:00
Tim
7a0afedc7c Merge pull request #533 from CH-122/feat/link 2025-08-13 18:12:34 +08:00
Tim
902fce5174 fix: setup 迁移完成 2025-08-13 17:59:38 +08:00
Tim
0034839e8d fix: 迁移部分页面为setup 2025-08-13 17:49:51 +08:00
CH-122
148fd36fd1 Merge branch 'main' into feat/link 2025-08-13 17:48:23 +08:00
Tim
06cd663eaf Merge pull request #532 from nagisa77/codex/add-comment-pinning-feature
feat: support comment pinning
2025-08-13 16:31:12 +08:00
Tim
65cc3ee58b Merge pull request #531 from nagisa77/codex/add-post-lottery-notification-to-author 2025-08-13 16:20:09 +08:00
Tim
6965fcfb7f feat: notify lottery author 2025-08-13 16:19:53 +08:00
Tim
40520c30ec Merge pull request #529 from nagisa77/codex/refactor-to-use-environment-variables
feat: move API and OAuth IDs to runtime config
2025-08-13 16:01:07 +08:00
Tim
5d7ca3d29a feat: use runtime config for API and OAuth client IDs 2025-08-13 16:00:26 +08:00
CH-122
9209ebea4c feat: 添加链接插件以支持外部链接在新窗口打开 2025-08-13 15:40:40 +08:00
Tim
47a9ce5843 fix: 后端取消网址hardcode 2025-08-13 14:02:32 +08:00
58 changed files with 3369 additions and 3693 deletions

View File

@@ -41,7 +41,7 @@ public class SecurityConfig {
private final UserRepository userRepository;
private final AccessDeniedHandler customAccessDeniedHandler;
private final UserVisitService userVisitService;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
@Bean

View File

@@ -18,7 +18,7 @@ public class AdminUserController {
private final UserRepository userRepository;
private final NotificationRepository notificationRepository;
private final EmailSender emailSender;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
@PostMapping("/{id}/approve")

View File

@@ -22,7 +22,7 @@ import java.util.List;
public class SitemapController {
private final PostRepository postRepository;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)

View File

@@ -22,7 +22,7 @@ public class Notification {
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Column(nullable = false, length = 50)
private NotificationType type;
@ManyToOne(fetch = FetchType.LAZY, optional = false)

View File

@@ -34,6 +34,8 @@ public enum NotificationType {
ACTIVITY_REDEEM,
/** You won a lottery post */
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
/** You were mentioned in a post or comment */
MENTION
}

View File

@@ -36,7 +36,7 @@ public class NotificationService {
private final ReactionRepository reactionRepository;
private final Executor notificationExecutor;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");

View File

@@ -254,6 +254,13 @@ public class PostService {
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
if (lp.getAuthor() != null) {
if (lp.getAuthor().getEmail() != null) {
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
}
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
});
}

View File

@@ -27,7 +27,7 @@ public class ReactionService {
private final NotificationService notificationService;
private final EmailSender emailSender;
@Value("${app.website-url:https://www.open-isle.com}")
@Value("${app.website-url}")
private String websiteUrl;
@Transactional

View File

@@ -93,4 +93,50 @@ class PostServiceTest {
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
null, null, null, null, null, null));
}
@Test
void finalizeLotteryNotifiesAuthor() {
PostRepository postRepo = mock(PostRepository.class);
UserRepository userRepo = mock(UserRepository.class);
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
CommentRepository commentRepo = mock(CommentRepository.class);
ReactionRepository reactionRepo = mock(ReactionRepository.class);
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
NotificationRepository notificationRepo = mock(NotificationRepository.class);
PostReadService postReadService = mock(PostReadService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();
author.setId(1L);
User winner = new User();
winner.setId(2L);
LotteryPost lp = new LotteryPost();
lp.setId(1L);
lp.setAuthor(author);
lp.setTitle("L");
lp.setPrizeCount(1);
lp.getParticipants().add(winner);
when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp));
service.finalizeLottery(1L);
verify(notifService).createNotification(eq(winner), eq(NotificationType.LOTTERY_WIN), eq(lp), isNull(), isNull(), eq(author), isNull(), isNull());
verify(notifService).createNotification(eq(author), eq(NotificationType.LOTTERY_DRAW), eq(lp), isNull(), isNull(), isNull(), isNull(), isNull());
}
}

View File

@@ -0,0 +1,6 @@
NUXT_PUBLIC_API_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_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ

View File

@@ -2,3 +2,4 @@ node_modules
.nuxt
dist
.output
.env

View File

@@ -37,8 +37,10 @@
<script setup>
import { computed } from 'vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const props = defineProps({
medals: {

View File

@@ -11,29 +11,20 @@
</BasePopup>
</template>
<script>
<script setup>
import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default {
name: 'ActivityPopup',
components: { BasePopup },
props: {
const props = defineProps({
visible: { type: Boolean, default: false },
icon: String,
text: String,
},
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const gotoActivity = () => {
})
const emit = defineEmits(['close'])
const gotoActivity = async () => {
emit('close')
router.push('/activities')
}
const close = () => emit('close')
return { gotoActivity, close }
},
await navigateTo('/activities', { replace: true })
}
const close = () => emit('close')
</script>
<style scoped>

View File

@@ -12,25 +12,15 @@
</div>
</template>
<script>
import { useRouter } from 'vue-router'
export default {
name: 'ArticleCategory',
props: {
<script setup>
const props = defineProps({
category: { type: Object, default: null },
},
setup(props) {
const router = useRouter()
const gotoCategory = () => {
})
const gotoCategory = async () => {
if (!props.category) return
const value = encodeURIComponent(props.category.id ?? props.category.name)
router.push({ path: '/', query: { category: value } }).then(() => {
window.location.reload()
})
}
return { gotoCategory }
},
await navigateTo({ path: '/', query: { category: value } }, { replace: true })
}
</script>

View File

@@ -17,24 +17,14 @@
</div>
</template>
<script>
import { useRouter } from 'vue-router'
export default {
name: 'ArticleTags',
props: {
<script setup>
defineProps({
tags: { type: Array, default: () => [] },
},
setup() {
const router = useRouter()
const gotoTag = (tag) => {
})
const gotoTag = async (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name)
router.push({ path: '/', query: { tags: value } }).then(() => {
window.location.reload()
})
}
return { gotoTag }
},
await navigateTo({ path: '/', query: { tags: value } }, { replace: true })
}
</script>

View File

@@ -26,49 +26,43 @@
</Dropdown>
</template>
<script>
<script setup>
import { computed, ref, watch } from 'vue'
import { API_BASE_URL } from '~/main'
import Dropdown from '~/components/Dropdown.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'CategorySelect',
components: { Dropdown },
props: {
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
})
watch(
const emit = defineEmits(['update:modelValue'])
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
watch(
() => props.options,
(val) => {
providedOptions.value = Array.isArray(val) ? [...val] : []
},
)
)
const fetchCategories = async () => {
const fetchCategories = async () => {
const res = await fetch(`${API_BASE_URL}/api/categories`)
if (!res.ok) return []
const data = await res.json()
return [{ id: '', name: '无分类' }, ...data]
}
}
const isImageIcon = (icon) => {
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
}
const selected = computed({
const selected = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
return { fetchCategories, selected, isImageIcon, providedOptions }
},
}
})
</script>
<style scoped>

View File

@@ -90,11 +90,10 @@
</div>
</template>
<script>
<script setup>
import { computed, ref, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import { useRouter } from 'vue-router'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal'
@@ -102,13 +101,11 @@ import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue'
import CommentEditor from '~/components/CommentEditor.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import LoginOverlay from '~/components/LoginOverlay.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const CommentItem = {
name: 'CommentItem',
emits: ['deleted'],
props: {
const props = defineProps({
comment: {
type: Object,
required: true,
@@ -125,39 +122,41 @@ const CommentItem = {
type: [Number, String],
required: true,
},
},
setup(props, { emit }) {
const router = useRouter()
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
watch(
})
const emit = defineEmits(['deleted'])
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
watch(
() => props.defaultShowReplies,
(val) => {
showReplies.value = props.level === 0 ? true : val
},
)
const showEditor = ref(false)
const editorWrapper = ref(null)
const isWaitingForReply = ref(false)
const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn)
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || []))
const toggleReplies = () => {
)
const showEditor = ref(false)
const editorWrapper = ref(null)
const isWaitingForReply = ref(false)
const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn)
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || []))
const toggleReplies = () => {
showReplies.value = !showReplies.value
}
const toggleEditor = () => {
}
const toggleEditor = () => {
showEditor.value = !showEditor.value
if (showEditor.value) {
setTimeout(() => {
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 100)
}
}
}
// 合并所有子回复为一个扁平数组
const flattenReplies = (list) => {
const flattenReplies = (list) => {
let result = []
for (const r of list) {
result.push(r)
@@ -166,20 +165,20 @@ const CommentItem = {
}
}
return result
}
}
const replyList = computed(() => {
const replyList = computed(() => {
if (props.level < 1) {
return props.comment.reply
}
return flattenReplies(props.comment.reply || [])
})
})
const isAuthor = computed(() => authState.username === props.comment.userName)
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
const isAdmin = computed(() => authState.role === 'ADMIN')
const commentMenuItems = computed(() => {
const isAuthor = computed(() => authState.username === props.comment.userName)
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
const isAdmin = computed(() => authState.role === 'ADMIN')
const commentMenuItems = computed(() => {
const items = []
if (isAuthor.value || isAdmin.value) {
items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() })
@@ -192,8 +191,8 @@ const CommentItem = {
}
}
return items
})
const deleteComment = async () => {
})
const deleteComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
@@ -211,48 +210,8 @@ const CommentItem = {
} else {
toast.error('操作失败')
}
}
const pinComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url = isAdmin.value
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin`
: `${API_BASE_URL}/api/comments/${props.comment.id}/pin`
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
props.comment.pinned = true
toast.success('已置顶')
} else {
toast.error('操作失败')
}
}
const unpinComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url = isAdmin.value
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin`
: `${API_BASE_URL}/api/comments/${props.comment.id}/unpin`
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
props.comment.pinned = false
toast.success('已取消置顶')
} else {
toast.error('操作失败')
}
}
const submitReply = async (parentUserName, text, clear) => {
}
const submitReply = async (parentUserName, text, clear) => {
if (!text.trim()) return
isWaitingForReply.value = true
const token = getToken()
@@ -292,11 +251,11 @@ const CommentItem = {
reply: [],
openReplies: false,
src: r.author.avatar,
iconClick: () => router.push(`/users/${r.author.id}`),
iconClick: () => navigateTo(`/users/${r.author.id}`),
})),
openReplies: false,
src: data.author.avatar,
iconClick: () => router.push(`/users/${data.author.id}`),
iconClick: () => navigateTo(`/users/${data.author.id}`),
})
clear()
showEditor.value = false
@@ -312,14 +271,57 @@ const CommentItem = {
} finally {
isWaitingForReply.value = false
}
}
const pinComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const copyCommentLink = () => {
const url = isAdmin.value
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin`
: `${API_BASE_URL}/api/comments/${props.comment.id}/pin`
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
props.comment.pinned = true
toast.success('已置顶')
} else {
toast.error('操作失败')
}
}
const unpinComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url = isAdmin.value
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin`
: `${API_BASE_URL}/api/comments/${props.comment.id}/unpin`
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
props.comment.pinned = false
toast.success('已取消置顶')
} else {
toast.error('操作失败')
}
}
const copyCommentLink = () => {
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
navigator.clipboard.writeText(link).then(() => {
toast.success('已复制')
})
}
const handleContentClick = (e) => {
}
const handleContentClick = (e) => {
handleMarkdownClick(e)
if (e.target.tagName === 'IMG') {
const container = e.target.parentNode
@@ -328,45 +330,7 @@ const CommentItem = {
lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true
}
}
return {
showReplies,
toggleReplies,
showEditor,
toggleEditor,
submitReply,
copyCommentLink,
renderMarkdown,
isWaitingForReply,
commentMenuItems,
deleteComment,
pinComment,
unpinComment,
isPostAuthor,
lightboxVisible,
lightboxIndex,
lightboxImgs,
handleContentClick,
loggedIn,
replyCount,
replyList,
getMedalTitle,
editorWrapper,
}
},
}
CommentItem.components = {
CommentItem,
CommentEditor,
BaseTimeline,
ReactionsGroup,
DropdownMenu,
VueEasyLightbox,
LoginOverlay,
}
export default CommentItem
</script>
<style scoped>

View File

@@ -11,36 +11,32 @@
</div>
</template>
<script>
<script setup>
import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import { API_BASE_URL } from '~/main'
import { authState } from '~/utils/auth'
export default {
name: 'GlobalPopups',
components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
data() {
return {
showMilkTeaPopup: false,
milkTeaIcon: '',
showNotificationPopup: false,
showMedalPopup: false,
newMedals: [],
}
},
async mounted() {
await this.checkMilkTeaActivity()
if (this.showMilkTeaPopup) return
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
await this.checkNotificationSetting()
if (this.showNotificationPopup) return
const showMilkTeaPopup = ref(false)
const milkTeaIcon = ref('')
const showNotificationPopup = ref(false)
const showMedalPopup = ref(false)
const newMedals = ref([])
await this.checkNewMedals()
},
methods: {
async checkMilkTeaActivity() {
onMounted(async () => {
await checkMilkTeaActivity()
if (showMilkTeaPopup.value) return
await checkNotificationSetting()
if (showNotificationPopup.value) return
await checkNewMedals()
})
const checkMilkTeaActivity = async () => {
if (!process.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return
try {
@@ -49,33 +45,33 @@ export default {
const list = await res.json()
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
if (a) {
this.milkTeaIcon = a.icon
this.showMilkTeaPopup = true
milkTeaIcon.value = a.icon
showMilkTeaPopup.value = true
}
}
} catch (e) {
// ignore network errors
}
},
closeMilkTeaPopup() {
}
const closeMilkTeaPopup = () => {
if (!process.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
this.showMilkTeaPopup = false
this.checkNotificationSetting()
},
async checkNotificationSetting() {
showMilkTeaPopup.value = false
checkNotificationSetting()
}
const checkNotificationSetting = async () => {
if (!process.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return
this.showNotificationPopup = true
},
closeNotificationPopup() {
showNotificationPopup.value = true
}
const closeNotificationPopup = () => {
if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
this.showNotificationPopup = false
this.checkNewMedals()
},
async checkNewMedals() {
showNotificationPopup.value = false
checkNewMedals()
}
const checkNewMedals = async () => {
if (!process.client) return
if (!authState.loggedIn || !authState.userId) return
try {
@@ -85,21 +81,19 @@ export default {
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
if (m.length > 0) {
this.newMedals = m
this.showMedalPopup = true
newMedals.value = m
showMedalPopup.value = true
}
}
} catch (e) {
// ignore errors
}
},
closeMedalPopup() {
}
const closeMedalPopup = () => {
if (!process.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
this.newMedals.forEach((m) => seen.add(m.type))
newMedals.value.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
this.showMedalPopup = false
},
},
showMedalPopup.value = false
}
</script>

View File

@@ -48,7 +48,7 @@
</header>
</template>
<script>
<script setup>
import { ClientOnly } from '#components'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -57,56 +57,50 @@ import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
export default {
name: 'HeaderComponent',
components: { DropdownMenu, SearchDropdown },
props: {
const props = defineProps({
showMenuBtn: {
type: Boolean,
default: true,
},
},
setup(props, { expose }) {
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount)
const router = useRouter()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
})
expose({
menuBtn,
})
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount)
const router = useRouter()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
const goToHome = () => {
router.push('/').then(() => {
window.location.reload()
})
const goToHome = async () => {
if (router.currentRoute.value.fullPath === '/') {
window.dispatchEvent(new Event('refresh-home'))
} else {
await navigateTo('/', { replace: true })
}
const search = () => {
}
const search = () => {
showSearch.value = true
nextTick(() => {
searchDropdown.value.toggle()
})
}
const closeSearch = () => {
}
const closeSearch = () => {
nextTick(() => {
showSearch.value = false
})
}
const goToLogin = () => {
router.push('/login')
}
const goToSettings = () => {
router.push('/settings')
}
const goToProfile = async () => {
}
const goToLogin = () => {
navigateTo('/login', { replace: true })
}
const goToSettings = () => {
navigateTo('/settings', { replace: true })
}
const goToProfile = async () => {
if (!authState.loggedIn) {
router.push('/login')
navigateTo('/login', { replace: true })
return
}
let id = authState.username || authState.userId
@@ -117,24 +111,24 @@ export default {
}
}
if (id) {
router.push(`/users/${id}`)
navigateTo(`/users/${id}`, { replace: true })
}
}
const goToSignup = () => {
router.push('/signup')
}
const goToLogout = () => {
}
const goToSignup = () => {
navigateTo('/signup', { replace: true })
}
const goToLogout = () => {
clearToken()
this.$router.push('/login')
}
navigateTo('/login', { replace: true })
}
const headerMenuItems = computed(() => [
const headerMenuItems = computed(() => [
{ text: '设置', onClick: goToSettings },
{ text: '个人主页', onClick: goToProfile },
{ text: '退出', onClick: goToLogout },
])
])
onMounted(async () => {
onMounted(async () => {
const updateAvatar = async () => {
if (authState.loggedIn) {
const user = await loadCurrentUser()
@@ -169,29 +163,7 @@ export default {
showSearch.value = false
},
)
})
return {
isLogin,
isMobile,
headerMenuItems,
unreadCount,
goToHome,
search,
closeSearch,
goToLogin,
goToSettings,
goToProfile,
goToSignup,
goToLogout,
showSearch,
searchDropdown,
userMenu,
avatar,
menuBtn,
}
},
}
})
</script>
<style scoped>

View File

@@ -9,18 +9,9 @@
</div>
</template>
<script>
import { useRouter } from 'vue-router'
export default {
name: 'LoginOverlay',
setup() {
const router = useRouter()
const goLogin = () => {
router.push('/login')
}
return { goLogin }
},
<script setup>
const goLogin = () => {
navigateTo('/login', { replace: true })
}
</script>

View File

@@ -16,33 +16,25 @@
</BasePopup>
</template>
<script>
<script setup>
import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
import { authState } from '~/utils/auth'
export default {
name: 'MedalPopup',
components: { BasePopup },
props: {
defineProps({
visible: { type: Boolean, default: false },
medals: { type: Array, default: () => [] },
},
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const gotoMedals = () => {
})
const emit = defineEmits(['close'])
const gotoMedals = () => {
emit('close')
if (authState.username) {
router.push(`/users/${authState.username}?tab=achievements`)
navigateTo(`/users/${authState.username}?tab=achievements`, { replace: true })
} else {
router.push('/')
navigateTo('/', { replace: true })
}
}
const close = () => emit('close')
return { gotoMedals, close }
},
}
const close = () => emit('close')
</script>
<style scoped>

View File

@@ -123,49 +123,47 @@
</transition>
</template>
<script>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { authState } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { ref, computed, watch, onMounted } from 'vue'
import { API_BASE_URL } from '~/main'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'MenuComponent',
props: {
const props = defineProps({
visible: {
type: Boolean,
default: true,
},
},
async setup(props, { emit }) {
const router = useRouter()
const categories = ref([])
const tags = ref([])
const categoryOpen = ref(true)
const tagOpen = ref(true)
const isLoadingCategory = ref(false)
const isLoadingTag = ref(false)
const categoryData = ref([])
const tagData = ref([])
})
const fetchCategoryData = async () => {
const emit = defineEmits(['item-click'])
const categoryOpen = ref(true)
const tagOpen = ref(true)
const isLoadingCategory = ref(false)
const isLoadingTag = ref(false)
const categoryData = ref([])
const tagData = ref([])
const fetchCategoryData = async () => {
isLoadingCategory.value = true
const res = await fetch(`${API_BASE_URL}/api/categories`)
const data = await res.json()
categoryData.value = data
isLoadingCategory.value = false
}
}
const fetchTagData = async () => {
const fetchTagData = async () => {
isLoadingTag.value = true
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
const data = await res.json()
tagData.value = data
isLoadingTag.value = false
}
}
const iconClass = computed(() => {
const iconClass = computed(() => {
switch (themeState.mode) {
case ThemeMode.DARK:
return 'fas fa-moon'
@@ -174,74 +172,51 @@ export default {
default:
return 'fas fa-desktop'
}
})
})
const unreadCount = computed(() => notificationState.unreadCount)
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
const shouldShowStats = computed(() => authState.role === 'ADMIN')
const unreadCount = computed(() => notificationState.unreadCount)
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
const shouldShowStats = computed(() => authState.role === 'ADMIN')
const updateCount = async () => {
const updateCount = async () => {
if (authState.loggedIn) {
await fetchUnreadCount()
} else {
notificationState.unreadCount = 0
}
}
}
onMounted(async () => {
onMounted(async () => {
await updateCount()
watch(() => authState.loggedIn, updateCount)
})
})
const handleHomeClick = () => {
router.push('/').then(() => {
window.location.reload()
})
}
const handleHomeClick = () => {
navigateTo('/', { replace: true })
}
const handleItemClick = () => {
const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click')
}
}
const isImageIcon = (icon) => {
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name)
router.push({ path: '/', query: { category: value } })
handleItemClick()
}
const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name)
router.push({ path: '/', query: { tags: value } })
handleItemClick()
}
await Promise.all([fetchCategoryData(), fetchTagData()])
return {
categoryData,
tagData,
categoryOpen,
tagOpen,
isLoadingCategory,
isLoadingTag,
iconClass,
unreadCount,
showUnreadCount,
shouldShowStats,
cycleTheme,
handleHomeClick,
handleItemClick,
isImageIcon,
gotoCategory,
gotoTag,
}
},
}
const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name)
navigateTo({ path: '/', query: { category: value } }, { replace: true })
handleItemClick()
}
const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name)
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
handleItemClick()
}
await Promise.all([fetchCategoryData(), fetchTagData()])
</script>
<style scoped>

View File

@@ -57,49 +57,44 @@
</div>
</template>
<script>
import { API_BASE_URL, toast } from '~/main'
<script setup>
import { toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth'
import BaseInput from '~/components/BaseInput.vue'
import BasePopup from '~/components/BasePopup.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import ProgressBar from '~/components/ProgressBar.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'MilkTeaActivityComponent',
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
data() {
return {
info: { redeemCount: 0, ended: false },
user: null,
dialogVisible: false,
contact: '',
loading: false,
isLoadingUser: true,
}
},
async mounted() {
await this.loadInfo()
this.isLoadingUser = true
this.user = await fetchCurrentUser()
this.isLoadingUser = false
},
methods: {
async loadInfo() {
const info = ref({ redeemCount: 0, ended: false })
const user = ref(null)
const dialogVisible = ref(false)
const contact = ref('')
const loading = ref(false)
const isLoadingUser = ref(true)
onMounted(async () => {
await loadInfo()
isLoadingUser.value = true
user.value = await fetchCurrentUser()
isLoadingUser.value = false
})
const loadInfo = async () => {
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
if (res.ok) {
this.info = await res.json()
info.value = await res.json()
}
},
openDialog() {
this.dialogVisible = true
},
closeDialog() {
this.dialogVisible = false
},
async submitRedeem() {
if (!this.contact) return
this.loading = true
}
const openDialog = () => {
dialogVisible.value = true
}
const closeDialog = () => {
dialogVisible.value = false
}
const submitRedeem = async () => {
if (!contact.value) return
loading.value = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
method: 'POST',
@@ -107,7 +102,7 @@ export default {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ contact: this.contact }),
body: JSON.stringify({ contact: contact.value }),
})
if (res.ok) {
const data = await res.json()
@@ -116,14 +111,12 @@ export default {
} else {
toast.success('兑换成功!')
}
this.dialogVisible = false
await this.loadInfo()
dialogVisible.value = false
await loadInfo()
} else {
toast.error('兑换失败')
}
this.loading = false
},
},
loading.value = false
}
</script>

View File

@@ -11,27 +11,19 @@
</BasePopup>
</template>
<script>
<script setup>
import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default {
name: 'NotificationSettingPopup',
components: { BasePopup },
props: {
defineProps({
visible: { type: Boolean, default: false },
},
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const gotoSetting = () => {
})
const emit = defineEmits(['close'])
const gotoSetting = () => {
emit('close')
router.push('/message?tab=control')
}
const close = () => emit('close')
return { gotoSetting, close }
},
navigateTo('/message?tab=control', { replace: true })
}
const close = () => emit('close')
</script>
<style scoped>

View File

@@ -46,11 +46,27 @@
</div>
</template>
<script>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: { type: Array, default: () => [] },
contentType: { type: String, required: true },
contentId: { type: [Number, String], required: true },
})
watch(
() => props.modelValue,
(v) => (reactions.value = v),
)
const reactions = ref(props.modelValue)
const reactionTypes = ref([])
let cachedTypes = null
const fetchTypes = async () => {
@@ -71,66 +87,50 @@ const fetchTypes = async () => {
return cachedTypes
}
export default {
name: 'ReactionsGroup',
props: {
modelValue: { type: Array, default: () => [] },
contentType: { type: String, required: true },
contentId: { type: [Number, String], required: true },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const reactions = ref(props.modelValue)
watch(
() => props.modelValue,
(v) => (reactions.value = v),
)
const reactionTypes = ref([])
onMounted(async () => {
onMounted(async () => {
reactionTypes.value = await fetchTypes()
})
})
const counts = computed(() => {
const counts = computed(() => {
const c = {}
for (const r of reactions.value) {
c[r.type] = (c[r.type] || 0) + 1
}
return c
})
})
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
const likeCount = computed(() => counts.value['LIKE'] || 0)
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
const likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = (type) =>
const userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username)
const displayedReactions = computed(() => {
const displayedReactions = computed(() => {
return Object.entries(counts.value)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([type]) => ({ type }))
})
})
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
const panelVisible = ref(false)
let hideTimer = null
const openPanel = () => {
const panelVisible = ref(false)
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
panelVisible.value = false
}, 500)
}
const cancelHide = () => {
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
}
const toggleReaction = async (type) => {
const toggleReaction = async (type) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
@@ -199,23 +199,6 @@ export default {
emit('update:modelValue', reactions.value)
toast.error('操作失败')
}
}
return {
reactionEmojiMap,
counts,
totalCount,
likeCount,
displayedReactions,
panelTypes,
panelVisible,
openPanel,
scheduleHide,
cancelHide,
toggleReaction,
userReacted,
}
},
}
</script>

View File

@@ -36,33 +36,29 @@
</div>
</template>
<script>
import { ref, watch } from 'vue'
<script setup>
import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router'
import Dropdown from '~/components/Dropdown.vue'
import { API_BASE_URL } from '~/main'
import { stripMarkdown } from '~/utils/markdown'
import { ref, watch } from 'vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'SearchDropdown',
components: { Dropdown },
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const emit = defineEmits(['close'])
const toggle = () => {
const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const toggle = () => {
dropdown.value.toggle()
}
}
const onClose = () => emit('close')
const onClose = () => emit('close')
const fetchResults = async (kw) => {
const fetchResults = async (kw) => {
if (!kw) return []
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
if (!res.ok) return []
@@ -76,58 +72,44 @@ export default {
postId: r.postId,
}))
return results.value
}
}
const highlight = (text) => {
const highlight = (text) => {
text = stripMarkdown(text)
if (!keyword.value) return text
const reg = new RegExp(keyword.value, 'gi')
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
return res
}
}
const iconMap = {
const iconMap = {
user: 'fas fa-user',
post: 'fas fa-file-alt',
comment: 'fas fa-comment',
category: 'fas fa-folder',
tag: 'fas fa-hashtag',
}
}
watch(selected, (val) => {
watch(selected, (val) => {
if (!val) return
const opt = results.value.find((r) => r.id === val)
if (!opt) return
if (opt.type === 'post' || opt.type === 'post_title') {
router.push(`/posts/${opt.id}`)
navigateTo(`/posts/${opt.id}`, { replace: true })
} else if (opt.type === 'user') {
router.push(`/users/${opt.id}`)
navigateTo(`/users/${opt.id}`, { replace: true })
} else if (opt.type === 'comment') {
if (opt.postId) {
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
navigateTo(`/posts/${opt.postId}#comment-${opt.id}`, { replace: true })
}
} else if (opt.type === 'category') {
router.push({ path: '/', query: { category: opt.id } })
navigateTo({ path: '/', query: { category: opt.id } }, { replace: true })
} else if (opt.type === 'tag') {
router.push({ path: '/', query: { tags: opt.id } })
navigateTo({ path: '/', query: { tags: opt.id } }, { replace: true })
}
selected.value = null
keyword.value = ''
})
return {
keyword,
selected,
fetchResults,
highlight,
iconMap,
isMobile,
dropdown,
onClose,
toggle,
}
},
}
})
</script>
<style scoped>

View File

@@ -28,42 +28,41 @@
</Dropdown>
</template>
<script>
<script setup>
import { computed, ref, watch } from 'vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import Dropdown from '~/components/Dropdown.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'TagSelect',
components: { Dropdown },
props: {
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: { type: Array, default: () => [] },
creatable: { type: Boolean, default: false },
options: { type: Array, default: () => [] },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
})
watch(
const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
watch(
() => props.options,
(val) => {
providedTags.value = Array.isArray(val) ? [...val] : []
},
)
)
const mergedOptions = computed(() => {
const mergedOptions = computed(() => {
const arr = [...providedTags.value, ...localTags.value]
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
})
})
const isImageIcon = (icon) => {
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
}
const buildTagsUrl = (kw = '') => {
const buildTagsUrl = (kw = '') => {
const base = API_BASE_URL || (process.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
@@ -71,9 +70,9 @@ export default {
url.searchParams.set('limit', '10')
return url.toString()
}
}
const fetchTags = async (kw = '') => {
const fetchTags = async (kw = '') => {
const defaultOption = { id: 0, name: '无标签' }
// 1) 先拼 URL自动兜底到 window.location.origin
@@ -91,11 +90,7 @@ export default {
// 3) 合并、去重、可创建
let options = [...data, ...localTags.value]
if (
props.creatable &&
kw &&
!options.some((t) => t.name.toLowerCase() === kw.toLowerCase())
) {
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
}
@@ -103,9 +98,9 @@ export default {
// 4) 最终结果
return [defaultOption, ...options]
}
}
const selected = computed({
const selected = computed({
get: () => props.modelValue,
set: (v) => {
if (Array.isArray(v)) {
@@ -131,11 +126,7 @@ export default {
}
emit('update:modelValue', v)
},
})
return { fetchTags, selected, isImageIcon, mergedOptions }
},
}
})
</script>
<style scoped>

View File

@@ -11,20 +11,15 @@
</div>
</template>
<script>
<script setup>
import BasePlaceholder from '~/components/BasePlaceholder.vue'
export default {
name: 'UserList',
components: { BasePlaceholder },
props: {
defineProps({
users: { type: Array, default: () => [] },
},
methods: {
handleUserClick(user) {
this.$router.push(`/users/${user.id}`)
},
},
})
const handleUserClick = (user) => {
navigateTo(`/users/${user.id}`, { replace: true })
}
</script>

View File

@@ -1 +0,0 @@
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'

View File

@@ -1,11 +1 @@
export const API_BASE_URL = 'https://www.open-isle.com'
// export const API_BASE_URL = 'http://127.0.0.1:8081'
// export const API_BASE_URL = 'http://30.211.97.238:8081'
export const GOOGLE_CLIENT_ID =
'777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
export const DISCORD_CLIENT_ID = '1394985417044000779'
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
// 重新导出 toast 功能,使用 composable 方式
export { toast } from './composables/useToast'

View File

@@ -2,6 +2,16 @@ import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({
ssr: true,
runtimeConfig: {
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '',
websiteBaseUrl: process.env.NUXT_PUBLIC_WEBSITE_BASE_URL || '',
googleClientId: process.env.NUXT_PUBLIC_GOOGLE_CLIENT_ID || '',
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
},
},
// Ensure Vditor styles load before our overrides in global.css
css: ['vditor/dist/index.css', '~/assets/global.css'],
app: {

View File

@@ -41,8 +41,9 @@ import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { onMounted, ref } from 'vue'
import VChart from 'vue-echarts'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])

View File

@@ -29,35 +29,28 @@
</div>
</template>
<script>
import { API_BASE_URL } from '~/main'
<script setup>
import TimeManager from '~/utils/time'
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'ActivityListPageView',
components: { MilkTeaActivityComponent },
data() {
return {
activities: [],
TimeManager,
isLoadingActivities: false,
}
},
async mounted() {
this.isLoadingActivities = true
const activities = ref([])
const isLoadingActivities = ref(false)
onMounted(async () => {
isLoadingActivities.value = true
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) {
this.activities = await res.json()
activities.value = await res.json()
}
} catch (e) {
console.error(e)
} finally {
this.isLoadingActivities = false
isLoadingActivities.value = false
}
},
}
})
</script>
<style scoped>

View File

@@ -2,24 +2,20 @@
<CallbackPage />
</template>
<script>
<script setup>
import CallbackPage from '~/components/CallbackPage.vue'
import { discordExchange } from '~/utils/discord'
export default {
name: 'DiscordCallbackPageView',
components: { CallbackPage },
async mounted() {
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await discordExchange(code, state, '')
if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token)
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else {
this.$router.push('/')
navigateTo('/', { replace: true })
}
},
}
})
</script>

View File

@@ -23,105 +23,99 @@
</div>
</template>
<script>
import { API_BASE_URL, toast } from '~/main'
<script setup>
import { toast } from '~/main'
import BaseInput from '~/components/BaseInput.vue'
export default {
name: 'ForgotPasswordPageView',
components: { BaseInput },
data() {
return {
step: 0,
email: '',
code: '',
password: '',
token: '',
emailError: '',
passwordError: '',
isSending: false,
isVerifying: false,
isResetting: false,
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const step = ref(0)
const email = ref('')
const code = ref('')
const password = ref('')
const token = ref('')
const emailError = ref('')
const passwordError = ref('')
const isSending = ref(false)
const isVerifying = ref(false)
const isResetting = ref(false)
onMounted(() => {
if (route.query.email) {
email.value = decodeURIComponent(route.query.email)
}
},
mounted() {
if (this.$route.query.email) {
this.email = decodeURIComponent(this.$route.query.email)
}
},
methods: {
async sendCode() {
if (!this.email) {
this.emailError = '邮箱不能为空'
})
const sendCode = async () => {
if (!email.value) {
emailError.value = '邮箱不能为空'
return
}
try {
this.isSending = true
isSending.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.email }),
body: JSON.stringify({ email: email.value }),
})
this.isSending = false
isSending.value = false
if (res.ok) {
toast.success('验证码已发送')
this.step = 1
step.value = 1
} else {
toast.error('请填写已注册邮箱')
}
} catch (e) {
this.isSending = false
isSending.value = false
toast.error('发送失败')
}
},
async verifyCode() {
}
const verifyCode = async () => {
try {
this.isVerifying = true
isVerifying.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.email, code: this.code }),
body: JSON.stringify({ email: email.value, code: code.value }),
})
this.isVerifying = false
isVerifying.value = false
const data = await res.json()
if (res.ok) {
this.token = data.token
this.step = 2
token.value = data.token
step.value = 2
} else {
toast.error(data.error || '验证失败')
}
} catch (e) {
this.isVerifying = false
isVerifying.value = false
toast.error('验证失败')
}
},
async resetPassword() {
if (!this.password) {
this.passwordError = '密码不能为空'
}
const resetPassword = async () => {
if (!password.value) {
passwordError.value = '密码不能为空'
return
}
try {
this.isResetting = true
isResetting.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: this.token, password: this.password }),
body: JSON.stringify({ token: token.value, password: password.value }),
})
this.isResetting = false
isResetting.value = false
const data = await res.json()
if (res.ok) {
toast.success('密码已重置')
this.$router.push('/login')
navigateTo('/login', { replace: true })
} else if (data.field === 'password') {
this.passwordError = data.error
passwordError.value = data.error
} else {
toast.error(data.error || '重置失败')
}
} catch (e) {
this.isResetting = false
isResetting.value = false
toast.error('重置失败')
}
},
},
}
</script>

View File

@@ -2,24 +2,20 @@
<CallbackPage />
</template>
<script>
<script setup>
import CallbackPage from '~/components/CallbackPage.vue'
import { githubExchange } from '~/utils/github'
export default {
name: 'GithubCallbackPageView',
components: { CallbackPage },
async mounted() {
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await githubExchange(code, state, '')
if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token)
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else {
this.$router.push('/')
navigateTo('/', { replace: true })
}
},
}
})
</script>

View File

@@ -2,29 +2,25 @@
<CallbackPage />
</template>
<script>
<script setup>
import CallbackPage from '~/components/CallbackPage.vue'
import { googleAuthWithToken } from '~/utils/google'
export default {
name: 'GoogleCallbackPageView',
components: { CallbackPage },
async mounted() {
onMounted(async () => {
const hash = new URLSearchParams(window.location.hash.substring(1))
const idToken = hash.get('id_token')
if (idToken) {
await googleAuthWithToken(
idToken,
() => {
this.$router.push('/')
navigateTo('/', { replace: true })
},
(token) => {
this.$router.push('/signup-reason?token=' + token)
navigateTo(`/signup-reason?token=${token}`, { replace: true })
},
)
} else {
this.$router.push('/login')
navigateTo('/login', { replace: true })
}
},
}
})
</script>

View File

@@ -111,35 +111,20 @@
</div>
</template>
<script>
import { ref, watch } from 'vue'
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth'
import { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
import TimeManager from '~/utils/time'
export default {
name: 'HomePageView',
components: {
CategorySelect,
TagSelect,
ArticleTags,
ArticleCategory,
SearchDropdown,
ClientOnly: () =>
import('vue').then((m) =>
m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))),
),
},
async setup() {
useHead({
useHead({
title: 'OpenIsle - 全面开源的自由社区',
meta: [
{
@@ -148,42 +133,41 @@ export default {
'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。',
},
],
})
const selectedCategory = ref('')
const selectedTags = ref([])
const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingPosts = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopic = ref(
route.query.view === 'ranking'
? '排行榜'
: route.query.view === 'latest'
? '最新'
: '最新回复',
)
const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
})
const selectedCategorySet = (category) => {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const selectedCategory = ref('')
const selectedTags = ref([])
const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingPosts = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopic = ref(
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
)
const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
const selectedCategorySet = (category) => {
const c = decodeURIComponent(category)
selectedCategory.value = isNaN(c) ? c : Number(c)
}
}
const selectedTagsSet = (tags) => {
const selectedTagsSet = (tags) => {
const t = Array.isArray(tags) ? tags.join(',') : tags
selectedTags.value = t
.split(',')
.filter((v) => v)
.map((v) => decodeURIComponent(v))
.map((v) => (isNaN(v) ? v : Number(v)))
}
}
onMounted(() => {
onMounted(() => {
const query = route.query
const category = query.category
const tags = query.tags
@@ -194,9 +178,9 @@ export default {
if (tags) {
selectedTagsSet(tags)
}
})
})
watch(
watch(
() => route.query,
() => {
const query = route.query
@@ -205,9 +189,9 @@ export default {
category && selectedCategorySet(category)
tags && selectedTagsSet(tags)
},
)
)
const loadOptions = async () => {
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
@@ -233,9 +217,9 @@ export default {
}
tagOptions.value = arr
}
}
}
const buildUrl = () => {
const buildUrl = () => {
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
@@ -246,9 +230,9 @@ export default {
})
}
return url
}
}
const buildRankUrl = () => {
const buildRankUrl = () => {
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
@@ -259,9 +243,9 @@ export default {
})
}
return url
}
}
const buildReplyUrl = () => {
const buildReplyUrl = () => {
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
@@ -272,9 +256,9 @@ export default {
})
}
return url
}
}
const fetchPosts = async (reset = false) => {
const fetchPosts = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
@@ -315,9 +299,9 @@ export default {
} catch (e) {
console.error(e)
}
}
}
const fetchRanking = async (reset = false) => {
const fetchRanking = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
@@ -358,9 +342,9 @@ export default {
} catch (e) {
console.error(e)
}
}
}
const fetchLatestReply = async (reset = false) => {
const fetchLatestReply = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
@@ -401,9 +385,9 @@ export default {
} catch (e) {
console.error(e)
}
}
}
const fetchContent = async (reset = false) => {
const fetchContent = async (reset = false) => {
if (selectedTopic.value === '排行榜') {
await fetchRanking(reset)
} else if (selectedTopic.value === '最新回复') {
@@ -411,36 +395,33 @@ export default {
} else {
await fetchPosts(reset)
}
}
useScrollLoadMore(fetchContent)
watch([selectedCategory, selectedTags], () => {
fetchContent(true)
})
watch(selectedTopic, () => {
fetchContent(true)
})
const sanitizeDescription = (text) => stripMarkdown(text)
await Promise.all([loadOptions(), fetchContent()])
return {
topics,
selectedTopic,
articles,
sanitizeDescription,
isLoadingPosts,
selectedCategory,
selectedTags,
tagOptions,
categoryOptions,
isMobile,
}
},
}
const refreshHome = () => {
fetchContent(true)
}
onMounted(() => {
window.addEventListener('refresh-home', refreshHome)
})
onBeforeUnmount(() => {
window.removeEventListener('refresh-home', refreshHome)
})
useScrollLoadMore(fetchContent)
watch([selectedCategory, selectedTags], () => {
fetchContent(true)
})
watch(selectedTopic, () => {
fetchContent(true)
})
const sanitizeDescription = (text) => stripMarkdown(text)
await Promise.all([loadOptions(), fetchContent()])
</script>
<style scoped>

View File

@@ -51,8 +51,8 @@
</div>
</template>
<script>
import { API_BASE_URL, toast } from '~/main'
<script setup>
import { toast } from '~/main'
import { setToken, loadCurrentUser } from '~/utils/auth'
import { googleAuthorize } from '~/utils/google'
import { githubAuthorize } from '~/utils/github'
@@ -60,27 +60,19 @@ import { discordAuthorize } from '~/utils/discord'
import { twitterAuthorize } from '~/utils/twitter'
import BaseInput from '~/components/BaseInput.vue'
import { registerPush } from '~/utils/push'
export default {
name: 'LoginPageView',
components: { BaseInput },
setup() {
return { googleAuthorize }
},
data() {
return {
username: '',
password: '',
isWaitingForLogin: false,
}
},
methods: {
async submitLogin() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const username = ref('')
const password = ref('')
const isWaitingForLogin = ref(false)
const submitLogin = async () => {
try {
this.isWaitingForLogin = true
isWaitingForLogin.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.username, password: this.password }),
body: JSON.stringify({ username: username.value, password: password.value }),
})
const data = await res.json()
if (res.ok && data.token) {
@@ -88,35 +80,36 @@ export default {
await loadCurrentUser()
toast.success('登录成功')
registerPush()
this.$router.push('/')
await navigateTo('/', { replace: true })
} else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码')
this.$router.push({ path: '/signup', query: { verify: 1, u: this.username } })
await navigateTo(
{ path: '/signup', query: { verify: '1', u: username.value } },
{ replace: true },
)
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册正在审批中, 请留意邮件')
this.$router.push('/')
await navigateTo('/', { replace: true })
} else if (data.reason_code === 'NOT_APPROVED') {
this.$router.push('/signup-reason?token=' + data.token)
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
} else {
toast.error(data.error || '登录失败')
}
} catch (e) {
toast.error('登录失败')
} finally {
this.isWaitingForLogin = false
isWaitingForLogin.value = false
}
},
}
loginWithGithub() {
const loginWithGithub = () => {
githubAuthorize()
},
loginWithDiscord() {
}
const loginWithDiscord = () => {
discordAuthorize()
},
loginWithTwitter() {
}
const loginWithTwitter = () => {
twitterAuthorize()
},
},
}
</script>

View File

@@ -198,6 +198,19 @@
中获奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'LOTTERY_DRAW'">
<NotificationContainer :item="item" :markRead="markRead">
您的抽奖贴
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
已开奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
@@ -491,10 +504,8 @@
</div>
</template>
<script>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { API_BASE_URL } from '~/main'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import NotificationContainer from '~/components/NotificationContainer.vue'
@@ -504,26 +515,20 @@ import { toast } from '~/main'
import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
import { reactionEmojiMap } from '~/utils/reactions'
export default {
name: 'MessagePageView',
components: { BaseTimeline, BasePlaceholder, NotificationContainer },
setup() {
const router = useRouter()
const route = useRoute()
const notifications = ref([])
const isLoadingMessage = ref(false)
const selectedTab = ref(
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const route = useRoute()
const notifications = ref([])
const isLoadingMessage = ref(false)
const selectedTab = ref(
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
)
const notificationPrefs = ref([])
const filteredNotifications = computed(() =>
selectedTab.value === 'all'
? notifications.value
: notifications.value.filter((n) => !n.read),
)
)
const notificationPrefs = ref([])
const filteredNotifications = computed(() =>
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
)
const markRead = async (id) => {
const markRead = async (id) => {
if (!id) return
const n = notifications.value.find((n) => n.id === id)
if (!n || n.read) return
@@ -536,9 +541,9 @@ export default {
} else {
fetchUnreadCount()
}
}
}
const markAllRead = async () => {
const markAllRead = async () => {
// 除了 REGISTER_REQUEST 类型消息
const idsToMark = notifications.value
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
@@ -562,9 +567,9 @@ export default {
} else {
toast.success('已读所有消息')
}
}
}
const iconMap = {
const iconMap = {
POST_VIEWED: 'fas fa-eye',
COMMENT_REPLY: 'fas fa-reply',
POST_REVIEWED: 'fas fa-shield-alt',
@@ -579,10 +584,11 @@ export default {
REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee',
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
MENTION: 'fas fa-at',
}
}
const fetchNotifications = async () => {
const fetchNotifications = async () => {
try {
const token = getToken()
if (!token) {
@@ -610,7 +616,7 @@ export default {
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
router.push(`/users/${n.comment.author.id}`)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'REACTION') {
@@ -620,7 +626,7 @@ export default {
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
@@ -632,7 +638,7 @@ export default {
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
@@ -647,13 +653,24 @@ export default {
}
},
})
} else if (n.type === 'LOTTERY_DRAW') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
router.push(`/users/${n.comment.author.id}`)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'USER_ACTIVITY') {
@@ -662,7 +679,7 @@ export default {
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
router.push(`/users/${n.comment.author.id}`)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'MENTION') {
@@ -672,7 +689,7 @@ export default {
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
@@ -683,7 +700,7 @@ export default {
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
router.push(`/users/${n.fromUser.id}`)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
@@ -694,7 +711,7 @@ export default {
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
@@ -705,7 +722,7 @@ export default {
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
@@ -717,7 +734,7 @@ export default {
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
@@ -737,13 +754,13 @@ export default {
} catch (e) {
console.error(e)
}
}
}
const fetchPrefs = async () => {
const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
}
const togglePref = async (pref) => {
const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
if (ok) {
pref.enabled = !pref.enabled
@@ -752,9 +769,9 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const approve = async (id, nid) => {
const approve = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
@@ -767,9 +784,9 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const reject = async (id, nid) => {
const reject = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
@@ -782,9 +799,9 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const formatType = (t) => {
const formatType = (t) => {
switch (t) {
case 'POST_VIEWED':
return '帖子被查看'
@@ -818,34 +835,17 @@ export default {
return '有人申请兑换奶茶'
case 'LOTTERY_WIN':
return '抽奖中奖了'
case 'LOTTERY_DRAW':
return '抽奖已开奖'
default:
return t
}
}
}
onMounted(() => {
onMounted(() => {
fetchNotifications()
fetchPrefs()
})
return {
notifications,
formatType,
isLoadingMessage,
stripMarkdownLength,
markRead,
approve,
reject,
TimeManager,
selectedTab,
filteredNotifications,
markAllRead,
authState,
notificationPrefs,
togglePref,
}
},
}
})
</script>
<style scoped>

View File

@@ -88,41 +88,31 @@ import LoginOverlay from '~/components/LoginOverlay.vue'
import PostEditor from '~/components/PostEditor.vue'
import PostTypeSelect from '~/components/PostTypeSelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'NewPostPageView',
components: {
PostEditor,
CategorySelect,
TagSelect,
LoginOverlay,
PostTypeSelect,
AvatarCropper,
FlatPickr,
},
setup() {
const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const postType = ref('NORMAL')
const prizeIcon = ref('')
const prizeIconFile = ref(null)
const tempPrizeIcon = ref('')
const showPrizeCropper = ref(false)
const prizeName = ref('')
const prizeCount = ref(1)
const prizeDescription = ref('')
const endTime = ref(null)
const startTime = ref(null)
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const postType = ref('NORMAL')
const prizeIcon = ref('')
const prizeIconFile = ref(null)
const tempPrizeIcon = ref('')
const showPrizeCropper = ref(false)
const prizeName = ref('')
const prizeCount = ref(1)
const prizeDescription = ref('')
const endTime = ref(null)
const startTime = ref(null)
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const onPrizeIconChange = (e) => {
const onPrizeIconChange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
@@ -132,18 +122,18 @@ export default {
}
reader.readAsDataURL(file)
}
}
}
const onPrizeCropped = ({ file, url }) => {
const onPrizeCropped = ({ file, url }) => {
prizeIconFile.value = file
prizeIcon.value = url
}
}
watch(prizeCount, (val) => {
watch(prizeCount, (val) => {
if (!val || val < 1) prizeCount.value = 1
})
})
const loadDraft = async () => {
const loadDraft = async () => {
const token = getToken()
if (!token) return
try {
@@ -162,11 +152,11 @@ export default {
} catch (e) {
console.error(e)
}
}
}
onMounted(loadDraft)
onMounted(loadDraft)
const clearPost = async () => {
const clearPost = async () => {
title.value = ''
content.value = ''
selectedCategory.value = ''
@@ -196,9 +186,9 @@ export default {
toast.error('云端草稿清空失败, 请稍后重试')
}
}
}
}
const saveDraft = async () => {
const saveDraft = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
@@ -227,8 +217,8 @@ export default {
} catch (e) {
toast.error('保存失败')
}
}
const ensureTags = async (token) => {
}
const ensureTags = async (token) => {
for (let i = 0; i < selectedTags.value.length; i++) {
const t = selectedTags.value[i]
if (typeof t === 'string' && t.startsWith('__new__:')) {
@@ -257,9 +247,9 @@ export default {
}
}
}
}
}
const aiGenerate = async () => {
const aiGenerate = async () => {
if (!content.value.trim()) {
toast.error('内容为空,无法优化')
return
@@ -289,9 +279,9 @@ export default {
} finally {
isAiLoading.value = false
}
}
}
const submitPost = async () => {
const submitPost = async () => {
if (!title.value.trim()) {
toast.error('标题不能为空')
return
@@ -391,32 +381,6 @@ export default {
} finally {
isWaitingPosting.value = false
}
}
return {
title,
content,
selectedCategory,
selectedTags,
postType,
prizeIcon,
prizeCount,
endTime,
submitPost,
saveDraft,
clearPost,
isWaitingPosting,
aiGenerate,
isAiLoading,
isLogin,
onPrizeIconChange,
onPrizeCropped,
showPrizeCropper,
tempPrizeIcon,
dateConfig,
prizeName,
prizeDescription,
}
},
}
</script>

View File

@@ -35,33 +35,30 @@
</div>
</template>
<script>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import PostEditor from '~/components/PostEditor.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { getToken, authState } from '~/utils/auth'
import LoginOverlay from '~/components/LoginOverlay.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'EditPostPageView',
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
setup() {
const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const route = useRoute()
const router = useRouter()
const postId = route.params.id
const route = useRoute()
const postId = route.params.id
const loadPost = async () => {
const loadPost = async () => {
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
@@ -77,18 +74,18 @@ export default {
} catch (e) {
toast.error('加载失败')
}
}
}
onMounted(loadPost)
onMounted(loadPost)
const clearPost = () => {
const clearPost = () => {
title.value = ''
content.value = ''
selectedCategory.value = ''
selectedTags.value = []
}
}
const ensureTags = async (token) => {
const ensureTags = async (token) => {
for (let i = 0; i < selectedTags.value.length; i++) {
const t = selectedTags.value[i]
if (typeof t === 'string' && t.startsWith('__new__:')) {
@@ -117,9 +114,9 @@ export default {
}
}
}
}
}
const aiGenerate = async () => {
const aiGenerate = async () => {
if (!content.value.trim()) {
toast.error('内容为空,无法优化')
return
@@ -149,9 +146,9 @@ export default {
} finally {
isAiLoading.value = false
}
}
}
const submitPost = async () => {
const submitPost = async () => {
if (!title.value.trim()) {
toast.error('标题不能为空')
return
@@ -197,24 +194,9 @@ export default {
} finally {
isWaitingPosting.value = false
}
}
const cancelEdit = () => {
router.push(`/posts/${postId}`)
}
return {
title,
content,
selectedCategory,
selectedTags,
submitPost,
clearPost,
cancelEdit,
isWaitingPosting,
aiGenerate,
isAiLoading,
isLogin,
}
},
}
const cancelEdit = () => {
navigateTo(`/posts/${postId}`, { replace: true })
}
</script>

View File

@@ -231,7 +231,7 @@
</div>
</template>
<script>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import { useRoute } from 'vue-router'
@@ -244,7 +244,7 @@ import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { getToken, authState } from '~/utils/auth'
import TimeManager from '~/utils/time'
import { useRouter } from 'vue-router'
@@ -252,52 +252,38 @@ import { useIsMobile } from '~/utils/screen'
import Dropdown from '~/components/Dropdown.vue'
import { ClientOnly } from '#components'
export default {
name: 'PostPageView',
components: {
CommentItem,
CommentEditor,
BaseTimeline,
ArticleTags,
ArticleCategory,
ReactionsGroup,
DropdownMenu,
VueEasyLightbox,
Dropdown,
ClientOnly,
},
async setup() {
const route = useRoute()
const postId = route.params.id
const router = useRouter()
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const title = ref('')
const author = ref('')
const postContent = ref('')
const category = ref('')
const tags = ref([])
const postReactions = ref([])
const comments = ref([])
const status = ref('PUBLISHED')
const pinnedAt = ref(null)
const isWaitingFetchingPost = ref(false)
const isWaitingPostingComment = ref(false)
const postTime = ref('')
const postItems = ref([])
const mainContainer = ref(null)
const currentIndex = ref(1)
const subscribed = ref(false)
const commentSort = ref('NEWEST')
const isFetchingComments = ref(false)
const isMobile = useIsMobile()
const route = useRoute()
const postId = route.params.id
const router = useRouter()
const headerHeight = process.client
? parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--header-height'),
) || 0
const title = ref('')
const author = ref('')
const postContent = ref('')
const category = ref('')
const tags = ref([])
const postReactions = ref([])
const comments = ref([])
const status = ref('PUBLISHED')
const pinnedAt = ref(null)
const isWaitingFetchingPost = ref(false)
const isWaitingPostingComment = ref(false)
const postTime = ref('')
const postItems = ref([])
const mainContainer = ref(null)
const currentIndex = ref(1)
const subscribed = ref(false)
const commentSort = ref('NEWEST')
const isFetchingComments = ref(false)
const isMobile = useIsMobile()
const headerHeight = process.client
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
: 0
useHead(() => ({
useHead(() => ({
title: title.value ? `OpenIsle - ${title.value}` : 'OpenIsle',
meta: [
{
@@ -305,35 +291,35 @@ export default {
content: stripMarkdownLength(postContent.value, 400),
},
],
}))
}))
if (process.client) {
if (process.client) {
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateCurrentIndex)
if (countdownTimer) clearInterval(countdownTimer)
})
}
}
const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn)
const isAdmin = computed(() => authState.role === 'ADMIN')
const isAuthor = computed(() => authState.username === author.value.username)
const lottery = ref(null)
const countdown = ref('00:00:00')
let countdownTimer = null
const lotteryParticipants = computed(() => lottery.value?.participants || [])
const lotteryWinners = computed(() => lottery.value?.winners || [])
const lotteryEnded = computed(() => {
const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn)
const isAdmin = computed(() => authState.role === 'ADMIN')
const isAuthor = computed(() => authState.username === author.value.username)
const lottery = ref(null)
const countdown = ref('00:00:00')
let countdownTimer = null
const lotteryParticipants = computed(() => lottery.value?.participants || [])
const lotteryWinners = computed(() => lottery.value?.winners || [])
const lotteryEnded = computed(() => {
if (!lottery.value || !lottery.value.endTime) return false
return new Date(lottery.value.endTime).getTime() <= Date.now()
})
const hasJoined = computed(() => {
})
const hasJoined = computed(() => {
if (!loggedIn.value) return false
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
})
const updateCountdown = () => {
})
const updateCountdown = () => {
if (!lottery.value || !lottery.value.endTime) {
countdown.value = '00:00:00'
return
@@ -351,15 +337,15 @@ export default {
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
}
const startCountdown = () => {
if (!process.client) return
if (countdownTimer) clearInterval(countdownTimer)
updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000)
}
const gotoUser = (id) => router.push(`/users/${id}`)
const articleMenuItems = computed(() => {
}
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const articleMenuItems = computed(() => {
const items = []
if (isAuthor.value || isAdmin.value) {
items.push({ text: '编辑文章', onClick: () => editPost() })
@@ -377,9 +363,9 @@ export default {
items.push({ text: '驳回', color: 'red', onClick: () => rejectPost() })
}
return items
})
})
const gatherPostItems = () => {
const gatherPostItems = () => {
const items = []
if (mainContainer.value) {
const main = mainContainer.value.querySelector('.info-content-container')
@@ -395,9 +381,9 @@ export default {
items.sort((a, b) => a.top - b.top)
postItems.value = items.map((i) => i.el)
}
}
}
const mapComment = (c, parentUserName = '', level = 0) => ({
const mapComment = (c, parentUserName = '', level = 0) => ({
id: c.id,
userName: c.author.username,
medal: c.author.displayMedal,
@@ -410,15 +396,15 @@ export default {
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
openReplies: level === 0,
src: c.author.avatar,
iconClick: () => router.push(`/users/${c.author.id}`),
iconClick: () => navigateTo(`/users/${c.author.id}`, { replace: true }),
parentUserName: parentUserName,
})
})
const getTop = (el) => {
const getTop = (el) => {
return el.getBoundingClientRect().top + window.scrollY
}
}
const findCommentPath = (id, list) => {
const findCommentPath = (id, list) => {
for (const item of list) {
if (item.id === Number(id) || item.id === id) {
return [item]
@@ -429,17 +415,17 @@ export default {
}
}
return null
}
}
const expandCommentPath = (id) => {
const expandCommentPath = (id) => {
const path = findCommentPath(id, comments.value)
if (!path) return
for (let i = 0; i < path.length - 1; i++) {
path[i].openReplies = true
}
}
}
const removeCommentFromList = (id, list) => {
const removeCommentFromList = (id, list) => {
for (let i = 0; i < list.length; i++) {
const item = list[i]
if (item.id === id) {
@@ -451,9 +437,9 @@ export default {
}
}
return false
}
}
const handleContentClick = (e) => {
const handleContentClick = (e) => {
handleMarkdownClick(e)
if (e.target.tagName === 'IMG') {
const container = e.target.parentNode
@@ -462,14 +448,14 @@ export default {
lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true
}
}
}
const onCommentDeleted = (id) => {
const onCommentDeleted = (id) => {
removeCommentFromList(Number(id), comments.value)
fetchComments()
}
}
const fetchPost = async () => {
const fetchPost = async () => {
try {
isWaitingFetchingPost.value = true
const token = getToken()
@@ -500,29 +486,29 @@ export default {
} catch (e) {
console.error(e)
}
}
}
const totalPosts = computed(() => comments.value.length + 1)
const lastReplyTime = computed(() =>
const totalPosts = computed(() => comments.value.length + 1)
const lastReplyTime = computed(() =>
comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value,
)
const firstReplyTime = computed(() =>
)
const firstReplyTime = computed(() =>
comments.value.length ? comments.value[0].time : postTime.value,
)
const scrollerTopTime = computed(() =>
)
const scrollerTopTime = computed(() =>
commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value,
)
)
watch(
watch(
() => comments.value.length,
async () => {
await nextTick()
gatherPostItems()
updateCurrentIndex()
},
)
)
const updateCurrentIndex = () => {
const updateCurrentIndex = () => {
const scrollTop = window.scrollY
for (let i = 0; i < postItems.value.length; i++) {
@@ -535,9 +521,9 @@ export default {
break
}
}
}
}
const onSliderInput = (e) => {
const onSliderInput = (e) => {
const index = Number(e.target.value)
currentIndex.value = index
const target = postItems.value[index - 1]
@@ -545,9 +531,9 @@ export default {
const top = getTop(target) - headerHeight - 20 // 20 for beauty
window.scrollTo({ top, behavior: 'auto' })
}
}
}
const postComment = async (parentUserName, text, clear) => {
const postComment = async (parentUserName, text, clear) => {
if (!text.trim()) return
console.debug('Posting comment', { postId, text })
isWaitingPostingComment.value = true
@@ -585,15 +571,15 @@ export default {
} finally {
isWaitingPostingComment.value = false
}
}
}
const copyPostLink = () => {
const copyPostLink = () => {
navigator.clipboard.writeText(location.href.split('#')[0]).then(() => {
toast.success('已复制')
})
}
}
const subscribePost = async () => {
const subscribePost = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
@@ -609,9 +595,9 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const approvePost = async () => {
const approvePost = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/approve`, {
@@ -624,9 +610,9 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const pinPost = async () => {
const pinPost = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/pin`, {
@@ -639,9 +625,9 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const unpinPost = async () => {
const unpinPost = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/unpin`, {
@@ -654,13 +640,13 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const editPost = () => {
router.push(`/posts/${postId}/edit`)
}
const editPost = () => {
navigateTo(`/posts/${postId}/edit`, { replace: true })
}
const deletePost = async () => {
const deletePost = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
@@ -672,13 +658,13 @@ export default {
})
if (res.ok) {
toast.success('已删除')
router.push('/')
navigateTo('/', { replace: true })
} else {
toast.error('操作失败')
}
}
}
const rejectPost = async () => {
const rejectPost = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/reject`, {
@@ -691,8 +677,8 @@ export default {
} else {
toast.error('操作失败')
}
}
const unsubscribePost = async () => {
}
const unsubscribePost = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
@@ -709,9 +695,9 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const joinLottery = async () => {
const joinLottery = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
@@ -727,17 +713,17 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const fetchCommentSorts = () => {
const fetchCommentSorts = () => {
return Promise.resolve([
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
{ id: 'OLDEST', name: '最旧', icon: 'fas fa-hourglass-start' },
// { id: 'MOST_INTERACTIONS', name: '最多互动', icon: 'fas fa-fire' }
])
}
}
const fetchComments = async () => {
const fetchComments = async () => {
isFetchingComments.value = true
console.debug('Fetching comments', { postId, sort: commentSort.value })
try {
@@ -762,11 +748,11 @@ export default {
} finally {
isFetchingComments.value = false
}
}
}
watch(commentSort, fetchComments)
watch(commentSort, fetchComments)
const jumpToHashComment = async () => {
const jumpToHashComment = async () => {
const hash = location.hash
if (hash.startsWith('#comment-')) {
const id = hash.substring('#comment-'.length)
@@ -779,13 +765,13 @@ export default {
setTimeout(() => el.classList.remove('comment-highlight'), 4000)
}
}
}
}
const gotoProfile = () => {
router.push(`/users/${author.value.id}`)
}
const gotoProfile = () => {
navigateTo(`/users/${author.value.id}`, { replace: true })
}
onMounted(async () => {
onMounted(async () => {
await fetchComments()
const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
@@ -793,69 +779,9 @@ export default {
updateCurrentIndex()
window.addEventListener('scroll', updateCurrentIndex)
jumpToHashComment()
})
})
await fetchPost()
return {
postContent,
author,
title,
category,
tags,
comments,
postTime,
scrollerTopTime,
lastReplyTime,
postItems,
mainContainer,
currentIndex,
totalPosts,
postReactions,
articleMenuItems,
postId,
postComment,
onSliderInput,
copyPostLink,
subscribePost,
unsubscribePost,
joinLottery,
renderMarkdown,
isWaitingFetchingPost,
isWaitingPostingComment,
gotoProfile,
gotoUser,
subscribed,
loggedIn,
isAuthor,
status,
isAdmin,
approvePost,
editPost,
onCommentDeleted,
deletePost,
pinPost,
unpinPost,
rejectPost,
lightboxVisible,
lightboxIndex,
lightboxImgs,
handleContentClick,
isMobile,
pinnedAt,
commentSort,
fetchCommentSorts,
isFetchingComments,
getMedalTitle,
lottery,
countdown,
lotteryParticipants,
lotteryWinners,
lotteryEnded,
hasJoined,
}
},
}
await fetchPost()
</script>
<style>
.post-page-container {

View File

@@ -64,95 +64,92 @@
</div>
</template>
<script>
<script setup>
import { ref, onMounted } from 'vue'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import Dropdown from '~/components/Dropdown.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
export default {
name: 'SettingsPageView',
components: { BaseInput, Dropdown, AvatarCropper },
data() {
return {
username: '',
introduction: '',
usernameError: '',
avatar: '',
avatarFile: null,
tempAvatar: '',
showCropper: false,
role: '',
publishMode: 'DIRECT',
passwordStrength: 'LOW',
aiFormatLimit: 3,
registerMode: 'DIRECT',
isLoadingPage: false,
isSaving: false,
}
},
async mounted() {
this.isLoadingPage = true
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const username = ref('')
const introduction = ref('')
const usernameError = ref('')
const avatar = ref('')
const avatarFile = ref(null)
const tempAvatar = ref('')
const showCropper = ref(false)
const role = ref('')
const publishMode = ref('DIRECT')
const passwordStrength = ref('LOW')
const aiFormatLimit = ref(3)
const registerMode = ref('DIRECT')
const isLoadingPage = ref(false)
const isSaving = ref(false)
onMounted(async () => {
isLoadingPage.value = true
const user = await fetchCurrentUser()
if (user) {
this.username = user.username
this.introduction = user.introduction || ''
this.avatar = user.avatar
this.role = user.role
if (this.role === 'ADMIN') {
this.loadAdminConfig()
username.value = user.username
introduction.value = user.introduction || ''
avatar.value = user.avatar
role.value = user.role
if (role.value === 'ADMIN') {
loadAdminConfig()
}
} else {
toast.error('请先登录')
this.$router.push('/login')
navigateTo('/login', { replace: true })
}
this.isLoadingPage = false
},
methods: {
onAvatarChange(e) {
isLoadingPage.value = false
})
const onAvatarChange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
this.tempAvatar = reader.result
this.showCropper = true
tempAvatar.value = reader.result
showCropper.value = true
}
reader.readAsDataURL(file)
}
},
onCropped({ file, url }) {
this.avatarFile = file
this.avatar = url
},
fetchPublishModes() {
}
const onCropped = ({ file, url }) => {
avatarFile.value = file
avatar.value = url
}
const fetchPublishModes = () => {
return Promise.resolve([
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
])
},
fetchPasswordStrengths() {
}
const fetchPasswordStrengths = () => {
return Promise.resolve([
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
])
},
fetchAiLimits() {
}
const fetchAiLimits = () => {
return Promise.resolve([
{ id: 3, name: '3次' },
{ id: 5, name: '5次' },
{ id: 10, name: '10次' },
{ id: -1, name: '无限' },
])
},
fetchRegisterModes() {
}
const fetchRegisterModes = () => {
return Promise.resolve([
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
])
},
async loadAdminConfig() {
}
const loadAdminConfig = async () => {
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
@@ -160,31 +157,31 @@ export default {
})
if (res.ok) {
const data = await res.json()
this.publishMode = data.publishMode
this.passwordStrength = data.passwordStrength
this.aiFormatLimit = data.aiFormatLimit
this.registerMode = data.registerMode
publishMode.value = data.publishMode
passwordStrength.value = data.passwordStrength
aiFormatLimit.value = data.aiFormatLimit
registerMode.value = data.registerMode
}
} catch (e) {
// ignore
}
},
async save() {
this.isSaving = true
}
const save = async () => {
isSaving.value = true
do {
let token = getToken()
this.usernameError = ''
if (!this.username) {
this.usernameError = '用户名不能为空'
usernameError.value = ''
if (!username.value) {
usernameError.value = '用户名不能为空'
}
if (this.usernameError) {
toast.error(this.usernameError)
if (usernameError.value) {
toast.error(usernameError.value)
break
}
if (this.avatarFile) {
if (avatarFile.value) {
const form = new FormData()
form.append('file', this.avatarFile)
form.append('file', avatarFile.value)
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
@@ -192,7 +189,7 @@ export default {
})
const data = await res.json()
if (res.ok) {
this.avatar = data.url
avatar.value = data.url
} else {
toast.error(data.error || '上传失败')
break
@@ -201,7 +198,7 @@ export default {
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ username: this.username, introduction: this.introduction }),
body: JSON.stringify({ username: username.value, introduction: introduction.value }),
})
const data = await res.json()
@@ -213,24 +210,22 @@ export default {
setToken(data.token)
token = data.token
}
if (this.role === 'ADMIN') {
if (role.value === 'ADMIN') {
await fetch(`${API_BASE_URL}/api/admin/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
publishMode: this.publishMode,
passwordStrength: this.passwordStrength,
aiFormatLimit: this.aiFormatLimit,
registerMode: this.registerMode,
publishMode: publishMode.value,
passwordStrength: passwordStrength.value,
aiFormatLimit: aiFormatLimit.value,
registerMode: registerMode.value,
}),
})
}
toast.success('保存成功')
} while (!this.isSaving)
} while (!isSaving.value)
this.isSaving = false
},
},
isSaving.value = false
}
</script>

View File

@@ -18,36 +18,32 @@
</div>
</template>
<script>
<script setup>
import BaseInput from '~/components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'SignupReasonPageView',
components: { BaseInput },
data() {
return {
reason: '',
error: '',
isWaitingForRegister: false,
token: '',
const reason = ref('')
const error = ref('')
const isWaitingForRegister = ref(false)
const token = ref('')
onMounted(async () => {
token.value = route.query.token || ''
if (!token.value) {
await navigateTo({ path: '/signup' }, { replace: true })
}
},
mounted() {
this.token = this.$route.query.token || ''
if (!this.token) {
this.$router.push('/signup')
}
},
methods: {
async submit() {
if (!this.reason || this.reason.trim().length < 20) {
this.error = '请至少输入20个字'
})
const submit = async () => {
if (!reason.value || reason.value.trim().length < 20) {
error.value = '请至少输入20个字'
return
}
try {
this.isWaitingForRegister = true
isWaitingForRegister.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
method: 'POST',
headers: {
@@ -58,23 +54,21 @@ export default {
reason: this.reason,
}),
})
this.isWaitingForRegister = false
isWaitingForRegister.value = false
const data = await res.json()
if (res.ok) {
toast.success('注册理由已提交,请等待审核')
this.$router.push('/')
await navigateTo('/', { replace: true })
} else if (data.reason_code === 'INVALID_CREDENTIALS') {
toast.error('登录已过期,请重新登录')
this.$router.push('/login')
await navigateTo('/login', { replace: true })
} else {
toast.error(data.error || '提交失败')
}
} catch (e) {
this.isWaitingForRegister = false
isWaitingForRegister.value = false
toast.error('提交失败')
}
},
},
}
</script>

View File

@@ -89,115 +89,110 @@
</div>
</template>
<script>
<script setup>
import BaseInput from '~/components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { discordAuthorize } from '~/utils/discord'
import { githubAuthorize } from '~/utils/github'
import { googleAuthorize } from '~/utils/google'
import { twitterAuthorize } from '~/utils/twitter'
export default {
name: 'SignupPageView',
components: { BaseInput },
setup() {
return { googleAuthorize }
},
data() {
return {
emailStep: 0,
email: '',
username: '',
password: '',
registerMode: 'DIRECT',
emailError: '',
usernameError: '',
passwordError: '',
code: '',
isWaitingForEmailSent: false,
isWaitingForEmailVerified: false,
}
},
async mounted() {
this.username = this.$route.query.u || ''
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const emailStep = ref(0)
const email = ref('')
const username = ref('')
const password = ref('')
const registerMode = ref('DIRECT')
const emailError = ref('')
const usernameError = ref('')
const passwordError = ref('')
const code = ref('')
const isWaitingForEmailSent = ref(false)
const isWaitingForEmailVerified = ref(false)
onMounted(async () => {
username.value = route.query.u || ''
try {
const res = await fetch(`${API_BASE_URL}/api/config`)
if (res.ok) {
const data = await res.json()
this.registerMode = data.registerMode
registerMode.value = data.registerMode
}
} catch {
/* ignore */
}
if (this.$route.query.verify) {
this.emailStep = 1
if (route.query.verify) {
emailStep.value = 1
}
},
methods: {
clearErrors() {
this.emailError = ''
this.usernameError = ''
this.passwordError = ''
},
async sendVerification() {
this.clearErrors()
})
const clearErrors = () => {
emailError.value = ''
usernameError.value = ''
passwordError.value = ''
}
const sendVerification = async () => {
clearErrors()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(this.email)) {
this.emailError = '邮箱格式不正确'
if (!emailRegex.test(email.value)) {
emailError.value = '邮箱格式不正确'
}
if (!this.password || this.password.length < 6) {
this.passwordError = '密码至少6位'
if (!password.value || password.value.length < 6) {
passwordError.value = '密码至少6位'
}
if (!this.username) {
this.usernameError = '用户名不能为空'
if (!username.value) {
usernameError.value = '用户名不能为空'
}
if (this.emailError || this.passwordError || this.usernameError) {
if (emailError.value || passwordError.value || usernameError.value) {
return
}
try {
this.isWaitingForEmailSent = true
isWaitingForEmailSent.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: this.username,
email: this.email,
password: this.password,
username: username.value,
email: email.value,
password: password.value,
}),
})
this.isWaitingForEmailSent = false
isWaitingForEmailSent.value = false
const data = await res.json()
if (res.ok) {
this.emailStep = 1
emailStep.value = 1
toast.success('验证码已发送,请查看邮箱')
} else if (data.field) {
if (data.field === 'username') this.usernameError = data.error
if (data.field === 'email') this.emailError = data.error
if (data.field === 'password') this.passwordError = data.error
if (data.field === 'username') usernameError.value = data.error
if (data.field === 'email') emailError.value = data.error
if (data.field === 'password') passwordError.value = data.error
} else {
toast.error(data.error || '发送失败')
}
} catch (e) {
toast.error('发送失败')
}
},
async verifyCode() {
}
const verifyCode = async () => {
try {
this.isWaitingForEmailVerified = true
isWaitingForEmailVerified.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: this.code,
username: this.username,
code: code.value,
username: username.value,
}),
})
const data = await res.json()
if (res.ok) {
if (this.registerMode === 'WHITELIST') {
this.$router.push('/signup-reason?token=' + data.token)
if (registerMode.value === 'WHITELIST') {
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
} else {
toast.success('注册成功,请登录')
this.$router.push('/login')
navigateTo('/login', { replace: true })
}
} else {
toast.error(data.error || '注册失败')
@@ -205,19 +200,17 @@ export default {
} catch (e) {
toast.error('注册失败')
} finally {
this.isWaitingForEmailVerified = false
isWaitingForEmailVerified.value = false
}
},
signupWithGithub() {
}
const signupWithGithub = () => {
githubAuthorize()
},
signupWithDiscord() {
}
const signupWithDiscord = () => {
discordAuthorize()
},
signupWithTwitter() {
}
const signupWithTwitter = () => {
twitterAuthorize()
},
},
}
</script>

View File

@@ -2,24 +2,20 @@
<CallbackPage />
</template>
<script>
<script setup>
import CallbackPage from '~/components/CallbackPage.vue'
import { twitterExchange } from '~/utils/twitter'
export default {
name: 'TwitterCallbackPageView',
components: { CallbackPage },
async mounted() {
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await twitterExchange(code, state, '')
if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token)
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else {
this.$router.push('/')
navigateTo('/', { replace: true })
}
},
}
})
</script>

View File

@@ -296,7 +296,7 @@
</div>
</template>
<script>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AchievementList from '~/components/AchievementList.vue'
@@ -304,42 +304,40 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import UserList from '~/components/UserList.vue'
import { API_BASE_URL, toast } from '~/main'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { prevLevelExp } from '~/utils/level'
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'ProfileView',
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList },
setup() {
definePageMeta({
definePageMeta({
alias: ['/users/:id/'],
})
const route = useRoute()
const router = useRouter()
const username = route.params.id
})
const route = useRoute()
const router = useRouter()
const username = route.params.id
const user = ref({})
const hotPosts = ref([])
const hotReplies = ref([])
const hotTags = ref([])
const timelineItems = ref([])
const followers = ref([])
const followings = ref([])
const medals = ref([])
const subscribed = ref(false)
const isLoading = ref(true)
const tabLoading = ref(false)
const selectedTab = ref(
const user = ref({})
const hotPosts = ref([])
const hotReplies = ref([])
const hotTags = ref([])
const timelineItems = ref([])
const followers = ref([])
const followings = ref([])
const medals = ref([])
const subscribed = ref(false)
const isLoading = ref(true)
const tabLoading = ref(false)
const selectedTab = ref(
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
? route.query.tab
: 'summary',
)
const followTab = ref('followers')
)
const followTab = ref('followers')
const levelInfo = computed(() => {
const levelInfo = computed(() => {
const exp = user.value.experience || 0
const currentLevel = user.value.currentLevel || 0
const nextExp = user.value.nextLevelExp || 0
@@ -348,20 +346,20 @@ export default {
const ratio = total > 0 ? (exp - prevExp) / total : 1
const percent = Math.max(0, Math.min(1, ratio)) * 100
return { exp, currentLevel, nextExp, percent }
})
})
const isMine = computed(function () {
const isMine = computed(function () {
const mine = authState.username === username || String(authState.userId) === username
console.log(mine)
return mine
})
})
const formatDate = (d) => {
const formatDate = (d) => {
if (!d) return ''
return TimeManager.format(d)
}
}
const fetchUser = async () => {
const fetchUser = async () => {
const token = getToken()
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const res = await fetch(`${API_BASE_URL}/api/users/${username}`, { headers })
@@ -372,9 +370,9 @@ export default {
} else if (res.status === 404) {
router.replace('/404')
}
}
}
const fetchSummary = async () => {
const fetchSummary = async () => {
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
if (postsRes.ok) {
const data = await postsRes.json()
@@ -392,9 +390,9 @@ export default {
const data = await tagsRes.json()
hotTags.value = data.map((t) => ({ icon: 'fas fa-tag', tag: t }))
}
}
}
const fetchTimeline = async () => {
const fetchTimeline = async () => {
const [postsRes, repliesRes, tagsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`),
@@ -425,36 +423,36 @@ export default {
]
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
timelineItems.value = mapped
}
}
const fetchFollowUsers = async () => {
const fetchFollowUsers = async () => {
const [followerRes, followingRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
fetch(`${API_BASE_URL}/api/users/${username}/following`),
])
followers.value = followerRes.ok ? await followerRes.json() : []
followings.value = followingRes.ok ? await followingRes.json() : []
}
}
const loadSummary = async () => {
const loadSummary = async () => {
tabLoading.value = true
await fetchSummary()
tabLoading.value = false
}
}
const loadTimeline = async () => {
const loadTimeline = async () => {
tabLoading.value = true
await fetchTimeline()
tabLoading.value = false
}
}
const loadFollow = async () => {
const loadFollow = async () => {
tabLoading.value = true
await fetchFollowUsers()
tabLoading.value = false
}
}
const fetchAchievements = async () => {
const fetchAchievements = async () => {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
if (res.ok) {
medals.value = await res.json()
@@ -462,15 +460,15 @@ export default {
medals.value = []
toast.error('获取成就失败')
}
}
}
const loadAchievements = async () => {
const loadAchievements = async () => {
tabLoading.value = true
await fetchAchievements()
tabLoading.value = false
}
}
const subscribeUser = async () => {
const subscribeUser = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
@@ -486,9 +484,9 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const unsubscribeUser = async () => {
const unsubscribeUser = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
@@ -504,14 +502,14 @@ export default {
} else {
toast.error('操作失败')
}
}
}
const gotoTag = (tag) => {
const gotoTag = (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name)
router.push({ path: '/', query: { tags: value } })
}
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
}
const init = async () => {
const init = async () => {
try {
await fetchUser()
if (selectedTab.value === 'summary') {
@@ -528,54 +526,20 @@ export default {
} finally {
isLoading.value = false
}
}
}
onMounted(init)
onMounted(init)
watch(selectedTab, async (val) => {
watch(selectedTab, async (val) => {
// router.replace({ query: { ...route.query, tab: val } })
if (val === 'timeline' && timelineItems.value.length === 0) {
await loadTimeline()
} else if (
val === 'following' &&
followers.value.length === 0 &&
followings.value.length === 0
) {
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
await loadFollow()
} else if (val === 'achievements' && medals.value.length === 0) {
await loadAchievements()
}
})
return {
user,
hotPosts,
hotReplies,
timelineItems,
followers,
followings,
medals,
subscribed,
isMine,
isLoading,
tabLoading,
selectedTab,
followTab,
formatDate,
stripMarkdown,
stripMarkdownLength,
loadTimeline,
loadFollow,
loadAchievements,
loadSummary,
subscribeUser,
unsubscribeUser,
gotoTag,
hotTags,
levelInfo,
}
},
}
})
</script>
<style scoped>

View File

@@ -1,4 +1,3 @@
import { API_BASE_URL } from '~/main'
import { reactive } from 'vue'
const TOKEN_KEY = 'token'
@@ -65,6 +64,8 @@ export function clearUserInfo() {
}
export async function fetchCurrentUser() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return null
try {
@@ -91,6 +92,8 @@ export function isLogin() {
}
export async function checkToken() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return false
try {

View File

@@ -1,9 +1,11 @@
import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main'
import { WEBSITE_BASE_URL } from '../constants'
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
export function discordAuthorize(state = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const DISCORD_CLIENT_ID = config.public.discordClientId
if (!DISCORD_CLIENT_ID) {
toast.error('Discord 登录不可用')
return
@@ -15,6 +17,8 @@ export function discordAuthorize(state = '') {
export async function discordExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,9 +1,11 @@
import { API_BASE_URL, GITHUB_CLIENT_ID, toast } from '../main'
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { WEBSITE_BASE_URL } from '../constants'
import { registerPush } from './push'
export function githubAuthorize(state = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const GITHUB_CLIENT_ID = config.public.githubClientId
if (!GITHUB_CLIENT_ID) {
toast.error('GitHub 登录不可用')
return
@@ -15,6 +17,8 @@ export function githubAuthorize(state = '') {
export async function githubExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,9 +1,11 @@
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main'
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
import { WEBSITE_BASE_URL } from '../constants'
export async function googleGetIdToken() {
const config = useRuntimeConfig()
const GOOGLE_CLIENT_ID = config.public.googleClientId
return new Promise((resolve, reject) => {
if (!window.google || !GOOGLE_CLIENT_ID) {
toast.error('Google 登录不可用, 请检查网络设置与VPN')
@@ -20,6 +22,8 @@ export async function googleGetIdToken() {
}
export function googleAuthorize() {
const config = useRuntimeConfig()
const GOOGLE_CLIENT_ID = config.public.googleClientId
if (!GOOGLE_CLIENT_ID) {
toast.error('Google 登录不可用, 请检查网络设置与VPN')
return
@@ -32,6 +36,8 @@ export function googleAuthorize() {
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -65,15 +71,13 @@ export async function googleSignIn(redirect_success, redirect_not_approved) {
}
}
import router from '../router'
export function loginWithGoogle() {
googleSignIn(
() => {
router.push('/')
navigateTo('/', { replace: true })
},
(token) => {
router.push('/signup-reason?token=' + token)
navigateTo(`/signup-reason?token=${token}`, { replace: true })
},
)
}

View File

@@ -50,6 +50,31 @@ function tiebaEmojiPlugin(md) {
})
}
// 链接在新窗口打开
function linkPlugin(md) {
const defaultRender =
md.renderer.rules.link_open ||
function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx]
const hrefIndex = token.attrIndex('href')
if (hrefIndex >= 0) {
const href = token.attrs[hrefIndex][1]
// 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer"
if (href.startsWith('http://') || href.startsWith('https://')) {
token.attrPush(['target', '_blank'])
token.attrPush(['rel', 'noopener noreferrer'])
}
}
return defaultRender(tokens, idx, options, env, self)
}
}
const md = new MarkdownIt({
html: false,
linkify: true,
@@ -67,6 +92,7 @@ const md = new MarkdownIt({
md.use(mentionPlugin)
md.use(tiebaEmojiPlugin)
md.use(linkPlugin) // 添加链接插件
export function renderMarkdown(text) {
return md.render(text || '')

View File

@@ -1,4 +1,3 @@
import { API_BASE_URL } from '~/main'
import { getToken } from './auth'
import { reactive } from 'vue'
@@ -7,6 +6,8 @@ export const notificationState = reactive({
})
export async function fetchUnreadCount() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const token = getToken()
if (!token) {
@@ -31,6 +32,9 @@ export async function fetchUnreadCount() {
export async function markNotificationsRead(ids) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token || !ids || ids.length === 0) return false
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
@@ -49,6 +53,9 @@ export async function markNotificationsRead(ids) {
export async function fetchNotificationPreferences() {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return []
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {
@@ -63,6 +70,8 @@ export async function fetchNotificationPreferences() {
export async function updateNotificationPreference(type, enabled) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return false
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {

View File

@@ -1,4 +1,3 @@
import { API_BASE_URL } from '../main'
import { getToken } from './auth'
function urlBase64ToUint8Array(base64String) {
@@ -21,6 +20,8 @@ function arrayBufferToBase64(buffer) {
export async function registerPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const reg = await navigator.serviceWorker.register('/notifications-sw.js')
const res = await fetch(`${API_BASE_URL}/api/push/public-key`)

View File

@@ -1,5 +1,4 @@
import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main'
import { WEBSITE_BASE_URL } from '../constants'
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
@@ -22,6 +21,9 @@ async function generateCodeChallenge(codeVerifier) {
}
export async function twitterAuthorize(state = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const TWITTER_CLIENT_ID = config.public.twitterClientId
if (!TWITTER_CLIENT_ID) {
toast.error('Twitter 登录不可用')
return
@@ -42,6 +44,8 @@ export async function twitterAuthorize(state = '') {
export async function twitterExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
sessionStorage.removeItem('twitter_code_verifier')
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {

View File

@@ -1,7 +1,7 @@
import { API_BASE_URL } from '../main'
export async function fetchFollowings(username) {
if (!username) return []
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
return res.ok ? await res.json() : []
@@ -11,6 +11,8 @@ export async function fetchFollowings(username) {
}
export async function fetchAdmins() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const res = await fetch(`${API_BASE_URL}/api/users/admins`)
return res.ok ? await res.json() : []
@@ -21,6 +23,8 @@ export async function fetchAdmins() {
export async function searchUsers(keyword) {
if (!keyword) return []
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const res = await fetch(
`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`,

View File

@@ -1,5 +1,4 @@
import Vditor from 'vditor'
import { API_BASE_URL } from '../main'
import { getToken, authState } from './auth'
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
import { tiebaEmoji } from './tiebaEmoji'
@@ -14,6 +13,8 @@ export function getPreviewTheme() {
export function createVditor(editorId, options = {}) {
const { placeholder = '', preview = {}, input, after } = options
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const fetchMentions = async (value) => {
if (!value) {