mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-17 01:20:46 +08:00
Compare commits
18 Commits
codex/add-
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da1ad73cf6 | ||
|
|
ada6bfb5cf | ||
|
|
928dbd73b5 | ||
|
|
8c1a7afc6e | ||
|
|
87453f7198 | ||
|
|
48e3593ef9 | ||
|
|
655e8f2a65 | ||
|
|
7a0afedc7c | ||
|
|
902fce5174 | ||
|
|
0034839e8d | ||
|
|
148fd36fd1 | ||
|
|
06cd663eaf | ||
|
|
65cc3ee58b | ||
|
|
6965fcfb7f | ||
|
|
40520c30ec | ||
|
|
5d7ca3d29a | ||
|
|
9209ebea4c | ||
|
|
47a9ce5843 |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("@\\[([^\\]]+)\\]");
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend_nuxt/.env.example
Normal file
6
frontend_nuxt/.env.example
Normal 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
|
||||
1
frontend_nuxt/.gitignore
vendored
1
frontend_nuxt/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
.env
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'
|
||||
@@ -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'
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 || '')
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user