fix: 迁移部分页面为setup

This commit is contained in:
Tim
2025-08-13 17:49:51 +08:00
parent 40520c30ec
commit 0034839e8d
21 changed files with 2152 additions and 2426 deletions

View File

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

View File

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

View File

@@ -88,11 +88,11 @@
</div> </div>
</template> </template>
<script> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox' import VueEasyLightbox from 'vue-easy-lightbox'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth' import { authState, getToken } from '~/utils/auth'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown' import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal' import { getMedalTitle } from '~/utils/medal'
@@ -100,13 +100,11 @@ import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue' import BaseTimeline from '~/components/BaseTimeline.vue'
import CommentEditor from '~/components/CommentEditor.vue' import CommentEditor from '~/components/CommentEditor.vue'
import DropdownMenu from '~/components/DropdownMenu.vue' import DropdownMenu from '~/components/DropdownMenu.vue'
import LoginOverlay from '~/components/LoginOverlay.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue' import ReactionsGroup from '~/components/ReactionsGroup.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const CommentItem = { const props = defineProps({
name: 'CommentItem',
emits: ['deleted'],
props: {
comment: { comment: {
type: Object, type: Object,
required: true, required: true,
@@ -119,39 +117,42 @@ const CommentItem = {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}, })
setup(props, { emit }) {
const router = useRouter() const emit = defineEmits(['deleted'])
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
watch( const router = useRouter()
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
watch(
() => props.defaultShowReplies, () => props.defaultShowReplies,
(val) => { (val) => {
showReplies.value = props.level === 0 ? true : val showReplies.value = props.level === 0 ? true : val
}, },
) )
const showEditor = ref(false) const showEditor = ref(false)
const editorWrapper = ref(null) const editorWrapper = ref(null)
const isWaitingForReply = ref(false) const isWaitingForReply = ref(false)
const lightboxVisible = ref(false) const lightboxVisible = ref(false)
const lightboxIndex = ref(0) const lightboxIndex = ref(0)
const lightboxImgs = ref([]) const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn) const loggedIn = computed(() => authState.loggedIn)
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0) const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || [])) const replyCount = computed(() => countReplies(props.comment.reply || []))
const toggleReplies = () => {
const toggleReplies = () => {
showReplies.value = !showReplies.value showReplies.value = !showReplies.value
} }
const toggleEditor = () => {
const toggleEditor = () => {
showEditor.value = !showEditor.value showEditor.value = !showEditor.value
if (showEditor.value) { if (showEditor.value) {
setTimeout(() => { setTimeout(() => {
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 100) }, 100)
} }
} }
// 合并所有子回复为一个扁平数组 const flattenReplies = (list) => {
const flattenReplies = (list) => {
let result = [] let result = []
for (const r of list) { for (const r of list) {
result.push(r) result.push(r)
@@ -160,24 +161,24 @@ const CommentItem = {
} }
} }
return result return result
} }
const replyList = computed(() => { const replyList = computed(() => {
if (props.level < 1) { if (props.level < 1) {
return props.comment.reply return props.comment.reply
} }
return flattenReplies(props.comment.reply || []) return flattenReplies(props.comment.reply || [])
}) })
const isAuthor = computed(() => authState.username === props.comment.userName) const isAuthor = computed(() => authState.username === props.comment.userName)
const isAdmin = computed(() => authState.role === 'ADMIN') const isAdmin = computed(() => authState.role === 'ADMIN')
const commentMenuItems = computed(() => const commentMenuItems = computed(() =>
isAuthor.value || isAdmin.value isAuthor.value || isAdmin.value
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] ? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }]
: [], : [],
) )
const deleteComment = async () => { const deleteComment = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
@@ -195,8 +196,8 @@ const CommentItem = {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const submitReply = async (parentUserName, text, clear) => { const submitReply = async (parentUserName, text, clear) => {
if (!text.trim()) return if (!text.trim()) return
isWaitingForReply.value = true isWaitingForReply.value = true
const token = getToken() const token = getToken()
@@ -256,14 +257,16 @@ const CommentItem = {
} finally { } finally {
isWaitingForReply.value = false isWaitingForReply.value = false
} }
} }
const copyCommentLink = () => {
const copyCommentLink = () => {
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}` const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
navigator.clipboard.writeText(link).then(() => { navigator.clipboard.writeText(link).then(() => {
toast.success('已复制') toast.success('已复制')
}) })
} }
const handleContentClick = (e) => {
const handleContentClick = (e) => {
handleMarkdownClick(e) handleMarkdownClick(e)
if (e.target.tagName === 'IMG') { if (e.target.tagName === 'IMG') {
const container = e.target.parentNode const container = e.target.parentNode
@@ -272,42 +275,7 @@ const CommentItem = {
lightboxIndex.value = imgs.indexOf(e.target.src) lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true lightboxVisible.value = true
} }
}
return {
showReplies,
toggleReplies,
showEditor,
toggleEditor,
submitReply,
copyCommentLink,
renderMarkdown,
isWaitingForReply,
commentMenuItems,
deleteComment,
lightboxVisible,
lightboxIndex,
lightboxImgs,
handleContentClick,
loggedIn,
replyCount,
replyList,
getMedalTitle,
editorWrapper,
}
},
} }
CommentItem.components = {
CommentItem,
CommentEditor,
BaseTimeline,
ReactionsGroup,
DropdownMenu,
VueEasyLightbox,
LoginOverlay,
}
export default CommentItem
</script> </script>
<style scoped> <style scoped>

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,33 +36,31 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
import { API_BASE_URL } from '~/main'
import { stripMarkdown } from '~/utils/markdown' import { stripMarkdown } from '~/utils/markdown'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const emit = defineEmits(['close'])
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 toggle = () => { const router = useRouter()
const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const toggle = () => {
dropdown.value.toggle() dropdown.value.toggle()
} }
const onClose = () => emit('close') const onClose = () => emit('close')
const fetchResults = async (kw) => { const fetchResults = async (kw) => {
if (!kw) return [] if (!kw) return []
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`) const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
if (!res.ok) return [] if (!res.ok) return []
@@ -76,25 +74,25 @@ export default {
postId: r.postId, postId: r.postId,
})) }))
return results.value return results.value
} }
const highlight = (text) => { const highlight = (text) => {
text = stripMarkdown(text) text = stripMarkdown(text)
if (!keyword.value) return text if (!keyword.value) return text
const reg = new RegExp(keyword.value, 'gi') const reg = new RegExp(keyword.value, 'gi')
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`) const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
return res return res
} }
const iconMap = { const iconMap = {
user: 'fas fa-user', user: 'fas fa-user',
post: 'fas fa-file-alt', post: 'fas fa-file-alt',
comment: 'fas fa-comment', comment: 'fas fa-comment',
category: 'fas fa-folder', category: 'fas fa-folder',
tag: 'fas fa-hashtag', tag: 'fas fa-hashtag',
} }
watch(selected, (val) => { watch(selected, (val) => {
if (!val) return if (!val) return
const opt = results.value.find((r) => r.id === val) const opt = results.value.find((r) => r.id === val)
if (!opt) return if (!opt) return
@@ -113,21 +111,7 @@ export default {
} }
selected.value = null selected.value = null
keyword.value = '' keyword.value = ''
}) })
return {
keyword,
selected,
fetchResults,
highlight,
iconMap,
isMobile,
dropdown,
onClose,
toggle,
}
},
}
</script> </script>
<style scoped> <style scoped>

View File

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

View File

@@ -1,12 +1 @@
import { useRuntimeConfig } from '#app'
const config = useRuntimeConfig()
export const API_BASE_URL = config.public.apiBaseUrl
export const GOOGLE_CLIENT_ID = config.public.googleClientId
export const GITHUB_CLIENT_ID = config.public.githubClientId
export const DISCORD_CLIENT_ID = config.public.discordClientId
export const TWITTER_CLIENT_ID = config.public.twitterClientId
// 重新导出 toast 功能,使用 composable 方式
export { toast } from './composables/useToast' export { toast } from './composables/useToast'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -478,10 +478,9 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { API_BASE_URL } from '~/main'
import BaseTimeline from '~/components/BaseTimeline.vue' import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
import NotificationContainer from '~/components/NotificationContainer.vue' import NotificationContainer from '~/components/NotificationContainer.vue'
@@ -491,26 +490,21 @@ import { toast } from '~/main'
import { stripMarkdownLength } from '~/utils/markdown' import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import { reactionEmojiMap } from '~/utils/reactions' import { reactionEmojiMap } from '~/utils/reactions'
const config = useRuntimeConfig()
export default { const API_BASE_URL = config.public.apiBaseUrl
name: 'MessagePageView', const router = useRouter()
components: { BaseTimeline, BasePlaceholder, NotificationContainer }, const route = useRoute()
setup() { const notifications = ref([])
const router = useRouter() const isLoadingMessage = ref(false)
const route = useRoute() const selectedTab = ref(
const notifications = ref([])
const isLoadingMessage = ref(false)
const selectedTab = ref(
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread', ['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
) )
const notificationPrefs = ref([]) const notificationPrefs = ref([])
const filteredNotifications = computed(() => const filteredNotifications = computed(() =>
selectedTab.value === 'all' selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
? notifications.value )
: notifications.value.filter((n) => !n.read),
)
const markRead = async (id) => { const markRead = async (id) => {
if (!id) return if (!id) return
const n = notifications.value.find((n) => n.id === id) const n = notifications.value.find((n) => n.id === id)
if (!n || n.read) return if (!n || n.read) return
@@ -523,9 +517,9 @@ export default {
} else { } else {
fetchUnreadCount() fetchUnreadCount()
} }
} }
const markAllRead = async () => { const markAllRead = async () => {
// 除了 REGISTER_REQUEST 类型消息 // 除了 REGISTER_REQUEST 类型消息
const idsToMark = notifications.value const idsToMark = notifications.value
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read) .filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
@@ -549,9 +543,9 @@ export default {
} else { } else {
toast.success('已读所有消息') toast.success('已读所有消息')
} }
} }
const iconMap = { const iconMap = {
POST_VIEWED: 'fas fa-eye', POST_VIEWED: 'fas fa-eye',
COMMENT_REPLY: 'fas fa-reply', COMMENT_REPLY: 'fas fa-reply',
POST_REVIEWED: 'fas fa-shield-alt', POST_REVIEWED: 'fas fa-shield-alt',
@@ -566,9 +560,9 @@ export default {
REGISTER_REQUEST: 'fas fa-user-clock', REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee', ACTIVITY_REDEEM: 'fas fa-coffee',
MENTION: 'fas fa-at', MENTION: 'fas fa-at',
} }
const fetchNotifications = async () => { const fetchNotifications = async () => {
try { try {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
@@ -712,13 +706,13 @@ export default {
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
} }
const fetchPrefs = async () => { const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences() notificationPrefs.value = await fetchNotificationPreferences()
} }
const togglePref = async (pref) => { const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled) const ok = await updateNotificationPreference(pref.type, !pref.enabled)
if (ok) { if (ok) {
pref.enabled = !pref.enabled pref.enabled = !pref.enabled
@@ -727,9 +721,9 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const approve = async (id, nid) => { const approve = async (id, nid) => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, { const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
@@ -742,9 +736,9 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const reject = async (id, nid) => { const reject = async (id, nid) => {
const token = getToken() const token = getToken()
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, { const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
@@ -757,9 +751,9 @@ export default {
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const formatType = (t) => { const formatType = (t) => {
switch (t) { switch (t) {
case 'POST_VIEWED': case 'POST_VIEWED':
return '帖子被查看' return '帖子被查看'
@@ -794,31 +788,12 @@ export default {
default: default:
return t return t
} }
} }
onMounted(() => { onMounted(() => {
fetchNotifications() fetchNotifications()
fetchPrefs() fetchPrefs()
}) })
return {
notifications,
formatType,
isLoadingMessage,
stripMarkdownLength,
markRead,
approve,
reject,
TimeManager,
selectedTab,
filteredNotifications,
markAllRead,
authState,
notificationPrefs,
togglePref,
}
},
}
</script> </script>
<style scoped> <style scoped>

View File

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

View File

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

View File

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

View File

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

View File

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