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', modelValue: { type: [String, Number], default: '' },
components: { Dropdown }, options: { type: Array, default: () => [] },
props: { })
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] }, const emit = defineEmits(['update:modelValue'])
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
watch(
() => props.options,
(val) => {
providedOptions.value = Array.isArray(val) ? [...val] : []
}, },
emits: ['update:modelValue'], )
setup(props, { emit }) {
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
watch( const fetchCategories = async () => {
() => props.options, const res = await fetch(`${API_BASE_URL}/api/categories`)
(val) => { if (!res.ok) return []
providedOptions.value = Array.isArray(val) ? [...val] : [] const data = await res.json()
}, return [{ id: '', name: '无分类' }, ...data]
)
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) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const selected = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
return { fetchCategories, selected, isImageIcon, providedOptions }
},
} }
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const selected = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
</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,214 +100,182 @@ 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', comment: {
emits: ['deleted'], type: Object,
props: { required: true,
comment: {
type: Object,
required: true,
},
level: {
type: Number,
default: 0,
},
defaultShowReplies: {
type: Boolean,
default: false,
},
}, },
setup(props, { emit }) { level: {
const router = useRouter() type: Number,
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies) default: 0,
watch( },
() => props.defaultShowReplies, defaultShowReplies: {
(val) => { type: Boolean,
showReplies.value = props.level === 0 ? true : val default: false,
}, },
) })
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 = () => {
showEditor.value = !showEditor.value
if (showEditor.value) {
setTimeout(() => {
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 100)
}
}
// 合并所有子回复为一个扁平数组 const emit = defineEmits(['deleted'])
const flattenReplies = (list) => {
let result = [] const router = useRouter()
for (const r of list) { const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
result.push(r) watch(
if (r.reply && r.reply.length > 0) { () => props.defaultShowReplies,
result = result.concat(flattenReplies(r.reply)) (val) => {
} showReplies.value = props.level === 0 ? true : val
} },
return result )
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 = () => {
showEditor.value = !showEditor.value
if (showEditor.value) {
setTimeout(() => {
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 100)
}
}
const flattenReplies = (list) => {
let result = []
for (const r of list) {
result.push(r)
if (r.reply && r.reply.length > 0) {
result = result.concat(flattenReplies(r.reply))
} }
}
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 isAdmin = computed(() => authState.role === 'ADMIN')
const commentMenuItems = computed(() =>
isAuthor.value || isAdmin.value
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }]
: [],
)
const deleteComment = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
console.debug('Deleting comment', props.comment.id)
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
console.debug('Delete comment response status', res.status)
if (res.ok) {
toast.success('已删除')
emit('deleted', props.comment.id)
} else {
toast.error('操作失败')
}
}
const submitReply = async (parentUserName, text, clear) => {
if (!text.trim()) return
isWaitingForReply.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
isWaitingForReply.value = false
return
}
console.debug('Submitting reply', { parentId: props.comment.id, text })
try {
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: text }),
}) })
console.debug('Submit reply response status', res.status)
const isAuthor = computed(() => authState.username === props.comment.userName) if (res.ok) {
const isAdmin = computed(() => authState.role === 'ADMIN') const data = await res.json()
const commentMenuItems = computed(() => console.debug('Submit reply response data', data)
isAuthor.value || isAdmin.value const replyList = props.comment.reply || (props.comment.reply = [])
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] replyList.push({
: [], id: data.id,
) userName: data.author.username,
const deleteComment = async () => { time: TimeManager.format(data.createdAt),
const token = getToken() avatar: data.author.avatar,
if (!token) { medal: data.author.displayMedal,
toast.error('请先登录') text: data.content,
return parentUserName: parentUserName,
} reactions: [],
console.debug('Deleting comment', props.comment.id) reply: (data.replies || []).map((r) => ({
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, { id: r.id,
method: 'DELETE', userName: r.author.username,
headers: { Authorization: `Bearer ${token}` }, time: TimeManager.format(r.createdAt),
avatar: r.author.avatar,
text: r.content,
reactions: r.reactions || [],
reply: [],
openReplies: false,
src: r.author.avatar,
iconClick: () => router.push(`/users/${r.author.id}`),
})),
openReplies: false,
src: data.author.avatar,
iconClick: () => router.push(`/users/${data.author.id}`),
}) })
console.debug('Delete comment response status', res.status) clear()
if (res.ok) { showEditor.value = false
toast.success('已删除') toast.success('回复成功')
emit('deleted', props.comment.id) } else if (res.status === 429) {
} else { toast.error('回复过于频繁,请稍后再试')
toast.error('操作失败') } else {
} toast.error(`回复失败: ${res.status} ${res.statusText}`)
} }
const submitReply = async (parentUserName, text, clear) => { } catch (e) {
if (!text.trim()) return console.debug('Submit reply error', e)
isWaitingForReply.value = true toast.error(`回复失败: ${e.message}`)
const token = getToken() } finally {
if (!token) { isWaitingForReply.value = false
toast.error('请先登录') }
isWaitingForReply.value = false
return
}
console.debug('Submitting reply', { parentId: props.comment.id, text })
try {
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: text }),
})
console.debug('Submit reply response status', res.status)
if (res.ok) {
const data = await res.json()
console.debug('Submit reply response data', data)
const replyList = props.comment.reply || (props.comment.reply = [])
replyList.push({
id: data.id,
userName: data.author.username,
time: TimeManager.format(data.createdAt),
avatar: data.author.avatar,
medal: data.author.displayMedal,
text: data.content,
parentUserName: parentUserName,
reactions: [],
reply: (data.replies || []).map((r) => ({
id: r.id,
userName: r.author.username,
time: TimeManager.format(r.createdAt),
avatar: r.author.avatar,
text: r.content,
reactions: r.reactions || [],
reply: [],
openReplies: false,
src: r.author.avatar,
iconClick: () => router.push(`/users/${r.author.id}`),
})),
openReplies: false,
src: data.author.avatar,
iconClick: () => router.push(`/users/${data.author.id}`),
})
clear()
showEditor.value = false
toast.success('回复成功')
} else if (res.status === 429) {
toast.error('回复过于频繁,请稍后再试')
} else {
toast.error(`回复失败: ${res.status} ${res.statusText}`)
}
} catch (e) {
console.debug('Submit reply error', e)
toast.error(`回复失败: ${e.message}`)
} finally {
isWaitingForReply.value = false
}
}
const copyCommentLink = () => {
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
navigator.clipboard.writeText(link).then(() => {
toast.success('已复制')
})
}
const handleContentClick = (e) => {
handleMarkdownClick(e)
if (e.target.tagName === 'IMG') {
const container = e.target.parentNode
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
lightboxImgs.value = imgs
lightboxIndex.value = imgs.indexOf(e.target.src)
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 = { const copyCommentLink = () => {
CommentItem, const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
CommentEditor, navigator.clipboard.writeText(link).then(() => {
BaseTimeline, toast.success('已复制')
ReactionsGroup, })
DropdownMenu,
VueEasyLightbox,
LoginOverlay,
} }
export default CommentItem const handleContentClick = (e) => {
handleMarkdownClick(e)
if (e.target.tagName === 'IMG') {
const container = e.target.parentNode
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
lightboxImgs.value = imgs
lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -11,95 +11,87 @@
</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: '', onMounted(async () => {
showNotificationPopup: false, await checkMilkTeaActivity()
showMedalPopup: false, if (showMilkTeaPopup.value) return
newMedals: [],
await checkNotificationSetting()
if (showNotificationPopup.value) return
await checkNewMedals()
})
const checkMilkTeaActivity = async () => {
if (!process.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) {
const list = await res.json()
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
if (a) {
milkTeaIcon.value = a.icon
showMilkTeaPopup.value = true
}
} }
}, } catch (e) {
async mounted() { // ignore network errors
await this.checkMilkTeaActivity() }
if (this.showMilkTeaPopup) return }
const closeMilkTeaPopup = () => {
await this.checkNotificationSetting() if (!process.client) return
if (this.showNotificationPopup) return localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
await this.checkNewMedals() checkNotificationSetting()
}, }
methods: { const checkNotificationSetting = async () => {
async checkMilkTeaActivity() { if (!process.client) return
if (!process.client) return if (!authState.loggedIn) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return if (localStorage.getItem('notificationSettingPopupShown')) return
try { showNotificationPopup.value = true
const res = await fetch(`${API_BASE_URL}/api/activities`) }
if (res.ok) { const closeNotificationPopup = () => {
const list = await res.json() if (!process.client) return
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended) localStorage.setItem('notificationSettingPopupShown', 'true')
if (a) { showNotificationPopup.value = false
this.milkTeaIcon = a.icon checkNewMedals()
this.showMilkTeaPopup = true }
} const checkNewMedals = async () => {
} if (!process.client) return
} catch (e) { if (!authState.loggedIn || !authState.userId) return
// ignore network errors try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
if (res.ok) {
const medals = await res.json()
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
if (m.length > 0) {
newMedals.value = m
showMedalPopup.value = true
} }
}, }
closeMilkTeaPopup() { } catch (e) {
if (!process.client) return // ignore errors
localStorage.setItem('milkTeaActivityPopupShown', 'true') }
this.showMilkTeaPopup = false }
this.checkNotificationSetting() const closeMedalPopup = () => {
}, if (!process.client) return
async checkNotificationSetting() { const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
if (!process.client) return newMedals.value.forEach((m) => seen.add(m.type))
if (!authState.loggedIn) return localStorage.setItem('seenMedals', JSON.stringify([...seen]))
if (localStorage.getItem('notificationSettingPopupShown')) return showMedalPopup.value = false
this.showNotificationPopup = true
},
closeNotificationPopup() {
if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
this.showNotificationPopup = false
this.checkNewMedals()
},
async checkNewMedals() {
if (!process.client) return
if (!authState.loggedIn || !authState.userId) return
try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
if (res.ok) {
const medals = await res.json()
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
}
}
} catch (e) {
// ignore errors
}
},
closeMedalPopup() {
if (!process.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
this.newMedals.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
this.showMedalPopup = false
},
},
} }
</script> </script>

View File

@@ -123,125 +123,103 @@
</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', visible: {
props: { type: Boolean,
visible: { default: true,
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'])
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 router = useRouter()
isLoadingTag.value = true const categoryOpen = ref(true)
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`) const tagOpen = ref(true)
const data = await res.json() const isLoadingCategory = ref(false)
tagData.value = data const isLoadingTag = ref(false)
isLoadingTag.value = false const categoryData = ref([])
} const tagData = ref([])
const iconClass = computed(() => { const fetchCategoryData = async () => {
switch (themeState.mode) { isLoadingCategory.value = true
case ThemeMode.DARK: const res = await fetch(`${API_BASE_URL}/api/categories`)
return 'fas fa-moon' const data = await res.json()
case ThemeMode.LIGHT: categoryData.value = data
return 'fas fa-sun' isLoadingCategory.value = false
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 updateCount = async () => {
if (authState.loggedIn) {
await fetchUnreadCount()
} else {
notificationState.unreadCount = 0
}
}
onMounted(async () => {
await updateCount()
watch(() => authState.loggedIn, updateCount)
})
const handleHomeClick = () => {
router.push('/').then(() => {
window.location.reload()
})
}
const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click')
}
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 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(() => {
switch (themeState.mode) {
case ThemeMode.DARK:
return 'fas fa-moon'
case ThemeMode.LIGHT:
return 'fas fa-sun'
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 updateCount = async () => {
if (authState.loggedIn) {
await fetchUnreadCount()
} else {
notificationState.unreadCount = 0
}
}
onMounted(async () => {
await updateCount()
watch(() => authState.loggedIn, updateCount)
})
const handleHomeClick = () => {
router.push('/').then(() => {
window.location.reload()
})
}
const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click')
}
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()])
</script> </script>
<style scoped> <style scoped>

View File

@@ -57,73 +57,66 @@
</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
})
const loadInfo = async () => {
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
if (res.ok) {
info.value = await res.json()
}
}
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',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ contact: contact.value }),
})
if (res.ok) {
const data = await res.json()
if (data.message === 'updated') {
toast.success('您已提交过兑换,本次更新兑换信息')
} else {
toast.success('兑换成功!')
} }
}, dialogVisible.value = false
async mounted() { await loadInfo()
await this.loadInfo() } else {
this.isLoadingUser = true toast.error('兑换失败')
this.user = await fetchCurrentUser() }
this.isLoadingUser = false loading.value = false
},
methods: {
async loadInfo() {
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
if (res.ok) {
this.info = await res.json()
}
},
openDialog() {
this.dialogVisible = true
},
closeDialog() {
this.dialogVisible = false
},
async submitRedeem() {
if (!this.contact) return
this.loading = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ contact: this.contact }),
})
if (res.ok) {
const data = await res.json()
if (data.message === 'updated') {
toast.success('您已提交过兑换,本次更新兑换信息')
} else {
toast.success('兑换成功!')
}
this.dialogVisible = false
await this.loadInfo()
} else {
toast.error('兑换失败')
}
this.loading = 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,151 +76,129 @@ const fetchTypes = async () => {
return cachedTypes return cachedTypes
} }
export default { const props = defineProps({
name: 'ReactionsGroup', modelValue: { type: Array, default: () => [] },
props: { contentType: { type: String, required: true },
modelValue: { type: Array, default: () => [] }, contentId: { type: [Number, String], required: true },
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([]) watch(
onMounted(async () => { () => props.modelValue,
reactionTypes.value = await fetchTypes() (v) => (reactions.value = v),
)
onMounted(async () => {
reactionTypes.value = await fetchTypes()
})
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 userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username)
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 panelVisible = ref(false)
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
panelVisible.value = false
}, 500)
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
const toggleReaction = async (type) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url =
props.contentType === 'post'
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
// optimistic update
const existingIdx = reactions.value.findIndex(
(r) => r.type === type && r.user === authState.username,
)
let tempReaction = null
let removedReaction = null
if (existingIdx > -1) {
removedReaction = reactions.value.splice(existingIdx, 1)[0]
} else {
tempReaction = { type, user: authState.username }
reactions.value.push(tempReaction)
}
emit('update:modelValue', reactions.value)
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ type }),
}) })
if (res.ok) {
const counts = computed(() => { if (res.status === 204) {
const c = {} // removal already reflected
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 userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username)
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 panelVisible = ref(false)
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
panelVisible.value = false
}, 500)
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
const toggleReaction = async (type) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url =
props.contentType === 'post'
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
// optimistic update
const existingIdx = reactions.value.findIndex(
(r) => r.type === type && r.user === authState.username,
)
let tempReaction = null
let removedReaction = null
if (existingIdx > -1) {
removedReaction = reactions.value.splice(existingIdx, 1)[0]
} else { } else {
tempReaction = { type, user: authState.username } const data = await res.json()
reactions.value.push(tempReaction) const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
if (idx > -1) {
reactions.value.splice(idx, 1, data)
} else if (removedReaction) {
// server added back reaction even though we removed? restore data
reactions.value.push(data)
}
if (data.reward && data.reward > 0) {
toast.success(`获得 ${data.reward} 经验值`)
}
} }
emit('update:modelValue', reactions.value) emit('update:modelValue', reactions.value)
} else {
try { // revert optimistic update on failure
const res = await fetch(url, { if (tempReaction) {
method: 'POST', const idx = reactions.value.indexOf(tempReaction)
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, if (idx > -1) reactions.value.splice(idx, 1)
body: JSON.stringify({ type }), } else if (removedReaction) {
}) reactions.value.push(removedReaction)
if (res.ok) {
if (res.status === 204) {
// removal already reflected
} else {
const data = await res.json()
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
if (idx > -1) {
reactions.value.splice(idx, 1, data)
} else if (removedReaction) {
// server added back reaction even though we removed? restore data
reactions.value.push(data)
}
if (data.reward && data.reward > 0) {
toast.success(`获得 ${data.reward} 经验值`)
}
}
emit('update:modelValue', reactions.value)
} else {
// revert optimistic update on failure
if (tempReaction) {
const idx = reactions.value.indexOf(tempReaction)
if (idx > -1) reactions.value.splice(idx, 1)
} else if (removedReaction) {
reactions.value.push(removedReaction)
}
emit('update:modelValue', reactions.value)
toast.error('操作失败')
}
} catch (e) {
if (tempReaction) {
const idx = reactions.value.indexOf(tempReaction)
if (idx > -1) reactions.value.splice(idx, 1)
} else if (removedReaction) {
reactions.value.push(removedReaction)
}
emit('update:modelValue', reactions.value)
toast.error('操作失败')
} }
emit('update:modelValue', reactions.value)
toast.error('操作失败')
} }
} catch (e) {
return { if (tempReaction) {
reactionEmojiMap, const idx = reactions.value.indexOf(tempReaction)
counts, if (idx > -1) reactions.value.splice(idx, 1)
totalCount, } else if (removedReaction) {
likeCount, reactions.value.push(removedReaction)
displayedReactions,
panelTypes,
panelVisible,
openPanel,
scheduleHide,
cancelHide,
toggleReaction,
userReacted,
} }
}, emit('update:modelValue', reactions.value)
toast.error('操作失败')
}
} }
</script> </script>

View File

@@ -36,98 +36,82 @@
</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()
dropdown.value.toggle() const keyword = ref('')
} const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const onClose = () => emit('close') const toggle = () => {
dropdown.value.toggle()
const fetchResults = async (kw) => {
if (!kw) return []
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
if (!res.ok) return []
const data = await res.json()
results.value = data.map((r) => ({
id: r.id,
text: r.text,
type: r.type,
subText: r.subText,
extra: r.extra,
postId: r.postId,
}))
return results.value
}
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 = {
user: 'fas fa-user',
post: 'fas fa-file-alt',
comment: 'fas fa-comment',
category: 'fas fa-folder',
tag: 'fas fa-hashtag',
}
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}`)
} else if (opt.type === 'user') {
router.push(`/users/${opt.id}`)
} else if (opt.type === 'comment') {
if (opt.postId) {
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
}
} else if (opt.type === 'category') {
router.push({ path: '/', query: { category: opt.id } })
} else if (opt.type === 'tag') {
router.push({ path: '/', query: { tags: opt.id } })
}
selected.value = null
keyword.value = ''
})
return {
keyword,
selected,
fetchResults,
highlight,
iconMap,
isMobile,
dropdown,
onClose,
toggle,
}
},
} }
const onClose = () => emit('close')
const fetchResults = async (kw) => {
if (!kw) return []
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
if (!res.ok) return []
const data = await res.json()
results.value = data.map((r) => ({
id: r.id,
text: r.text,
type: r.type,
subText: r.subText,
extra: r.extra,
postId: r.postId,
}))
return results.value
}
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 = {
user: 'fas fa-user',
post: 'fas fa-file-alt',
comment: 'fas fa-comment',
category: 'fas fa-folder',
tag: 'fas fa-hashtag',
}
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}`)
} else if (opt.type === 'user') {
router.push(`/users/${opt.id}`)
} else if (opt.type === 'comment') {
if (opt.postId) {
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
}
} else if (opt.type === 'category') {
router.push({ path: '/', query: { category: opt.id } })
} else if (opt.type === 'tag') {
router.push({ path: '/', query: { tags: opt.id } })
}
selected.value = null
keyword.value = ''
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -28,114 +28,105 @@
</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 }, modelValue: { type: Array, default: () => [] },
props: { creatable: { type: Boolean, default: false },
modelValue: { type: Array, default: () => [] }, options: { type: Array, default: () => [] },
creatable: { type: Boolean, default: false }, })
options: { type: Array, default: () => [] },
const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
watch(
() => props.options,
(val) => {
providedTags.value = Array.isArray(val) ? [...val] : []
}, },
emits: ['update:modelValue'], )
setup(props, { emit }) {
const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
watch( const mergedOptions = computed(() => {
() => props.options, const arr = [...providedTags.value, ...localTags.value]
(val) => { return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
providedTags.value = Array.isArray(val) ? [...val] : [] })
},
)
const mergedOptions = computed(() => { const isImageIcon = (icon) => {
const arr = [...providedTags.value, ...localTags.value] if (!icon) return false
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i) return /^https?:\/\//.test(icon) || icon.startsWith('/')
})
const isImageIcon = (icon) => {
if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const buildTagsUrl = (kw = '') => {
const base = API_BASE_URL || (process.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw)
url.searchParams.set('limit', '10')
return url.toString()
}
const fetchTags = async (kw = '') => {
const defaultOption = { id: 0, name: '无标签' }
// 1) 先拼 URL自动兜底到 window.location.origin
const url = buildTagsUrl(kw)
// 2) 拉数据
let data = []
try {
const res = await fetch(url)
if (res.ok) data = await res.json()
} catch {
toast.error('获取标签失败')
}
// 3) 合并、去重、可创建
let options = [...data, ...localTags.value]
if (
props.creatable &&
kw &&
!options.some((t) => t.name.toLowerCase() === kw.toLowerCase())
) {
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
}
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
// 4) 最终结果
return [defaultOption, ...options]
}
const selected = computed({
get: () => props.modelValue,
set: (v) => {
if (Array.isArray(v)) {
if (v.includes(0)) {
emit('update:modelValue', [])
return
}
if (v.length > 2) {
toast.error('最多选择两个标签')
return
}
v = v.map((id) => {
if (typeof id === 'string' && id.startsWith('__create__:')) {
const name = id.slice(11)
const newId = `__new__:${name}`
if (!localTags.value.find((t) => t.id === newId)) {
localTags.value.push({ id: newId, name })
}
return newId
}
return id
})
}
emit('update:modelValue', v)
},
})
return { fetchTags, selected, isImageIcon, mergedOptions }
},
} }
const buildTagsUrl = (kw = '') => {
const base = API_BASE_URL || (process.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw)
url.searchParams.set('limit', '10')
return url.toString()
}
const fetchTags = async (kw = '') => {
const defaultOption = { id: 0, name: '无标签' }
// 1) 先拼 URL自动兜底到 window.location.origin
const url = buildTagsUrl(kw)
// 2) 拉数据
let data = []
try {
const res = await fetch(url)
if (res.ok) data = await res.json()
} catch {
toast.error('获取标签失败')
}
// 3) 合并、去重、可创建
let options = [...data, ...localTags.value]
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
}
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
// 4) 最终结果
return [defaultOption, ...options]
}
const selected = computed({
get: () => props.modelValue,
set: (v) => {
if (Array.isArray(v)) {
if (v.includes(0)) {
emit('update:modelValue', [])
return
}
if (v.length > 2) {
toast.error('最多选择两个标签')
return
}
v = v.map((id) => {
if (typeof id === 'string' && id.startsWith('__create__:')) {
const name = id.slice(11)
const newId = `__new__:${name}`
if (!localTags.value.find((t) => t.id === newId)) {
localTags.value.push({ id: newId, name })
}
return newId
}
return id
})
}
emit('update:modelValue', v)
},
})
</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: [], try {
TimeManager, const res = await fetch(`${API_BASE_URL}/api/activities`)
isLoadingActivities: false, if (res.ok) {
activities.value = await res.json()
} }
}, } catch (e) {
async mounted() { console.error(e)
this.isLoadingActivities = true } finally {
try { isLoadingActivities.value = false
const res = await fetch(`${API_BASE_URL}/api/activities`) }
if (res.ok) { })
this.activities = await res.json()
}
} catch (e) {
console.error(e)
} finally {
this.isLoadingActivities = 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)
}
})
const sendCode = async () => {
if (!email.value) {
emailError.value = '邮箱不能为空'
return
}
try {
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: email.value }),
})
isSending.value = false
if (res.ok) {
toast.success('验证码已发送')
step.value = 1
} else {
toast.error('请填写已注册邮箱')
} }
}, } catch (e) {
mounted() { isSending.value = false
if (this.$route.query.email) { toast.error('发送失败')
this.email = decodeURIComponent(this.$route.query.email) }
}
const verifyCode = async () => {
try {
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: email.value, code: code.value }),
})
isVerifying.value = false
const data = await res.json()
if (res.ok) {
token.value = data.token
step.value = 2
} else {
toast.error(data.error || '验证失败')
} }
}, } catch (e) {
methods: { isVerifying.value = false
async sendCode() { toast.error('验证失败')
if (!this.email) { }
this.emailError = '邮箱不能为空' }
return const resetPassword = async () => {
} if (!password.value) {
try { passwordError.value = '密码不能为空'
this.isSending = true return
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, { }
method: 'POST', try {
headers: { 'Content-Type': 'application/json' }, isResetting.value = true
body: JSON.stringify({ email: this.email }), const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
}) method: 'POST',
this.isSending = false headers: { 'Content-Type': 'application/json' },
if (res.ok) { body: JSON.stringify({ token: token.value, password: password.value }),
toast.success('验证码已发送') })
this.step = 1 isResetting.value = false
} else { const data = await res.json()
toast.error('请填写已注册邮箱') if (res.ok) {
} toast.success('密码已重置')
} catch (e) { router.push('/login')
this.isSending = false } else if (data.field === 'password') {
toast.error('发送失败') passwordError.value = data.error
} } else {
}, toast.error(data.error || '重置失败')
async verifyCode() { }
try { } catch (e) {
this.isVerifying = true isResetting.value = false
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, { toast.error('重置失败')
method: 'POST', }
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.email, code: this.code }),
})
this.isVerifying = false
const data = await res.json()
if (res.ok) {
this.token = data.token
this.step = 2
} else {
toast.error(data.error || '验证失败')
}
} catch (e) {
this.isVerifying = false
toast.error('验证失败')
}
},
async resetPassword() {
if (!this.password) {
this.passwordError = '密码不能为空'
return
}
try {
this.isResetting = 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 }),
})
this.isResetting = false
const data = await res.json()
if (res.ok) {
toast.success('密码已重置')
this.$router.push('/login')
} else if (data.field === 'password') {
this.passwordError = data.error
} else {
toast.error(data.error || '重置失败')
}
} catch (e) {
this.isResetting = false
toast.error('重置失败')
}
},
},
} }
</script> </script>

View File

@@ -111,336 +111,305 @@
</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', title: 'OpenIsle - 全面开源的自由社区',
components: { meta: [
CategorySelect, {
TagSelect, name: 'description',
ArticleTags, content:
ArticleCategory, 'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。',
SearchDropdown, },
ClientOnly: () => ],
import('vue').then((m) => })
m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))),
), 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 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(() => {
const query = route.query
const category = query.category
const tags = query.tags
if (category) {
selectedCategorySet(category)
}
if (tags) {
selectedTagsSet(tags)
}
})
watch(
() => route.query,
() => {
const query = route.query
const category = query.category
const tags = query.tags
category && selectedCategorySet(category)
tags && selectedTagsSet(tags)
}, },
async setup() { )
useHead({
title: 'OpenIsle - 全面开源的自由社区',
meta: [
{
name: 'description',
content:
'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 loadOptions = async () => {
const c = decodeURIComponent(category) if (selectedCategory.value && !isNaN(selectedCategory.value)) {
selectedCategory.value = isNaN(c) ? c : Number(c) try {
} const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
if (res.ok) {
const selectedTagsSet = (tags) => { categoryOptions.value = [await res.json()]
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(() => {
const query = route.query
const category = query.category
const tags = query.tags
if (category) {
selectedCategorySet(category)
} }
if (tags) { } catch (e) {
selectedTagsSet(tags) /* ignore */
} }
}) }
watch( if (selectedTags.value.length) {
() => route.query, const arr = []
() => { for (const t of selectedTags.value) {
const query = route.query if (!isNaN(t)) {
const category = query.category
const tags = query.tags
category && selectedCategorySet(category)
tags && selectedTagsSet(tags)
},
)
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try { try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`) const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (res.ok) { if (r.ok) arr.push(await r.json())
categoryOptions.value = [await res.json()]
}
} catch (e) { } catch (e) {
/* ignore */ /* ignore */
} }
} }
if (selectedTags.value.length) {
const arr = []
for (const t of selectedTags.value) {
if (!isNaN(t)) {
try {
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (r.ok) arr.push(await r.json())
} catch (e) {
/* ignore */
}
}
}
tagOptions.value = arr
}
} }
tagOptions.value = arr
const buildUrl = () => { }
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const buildRankUrl = () => {
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const buildReplyUrl = () => {
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const fetchPosts = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchRanking = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildRankUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchLatestReply = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildReplyUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.lastReplyAt || p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchContent = async (reset = false) => {
if (selectedTopic.value === '排行榜') {
await fetchRanking(reset)
} else if (selectedTopic.value === '最新回复') {
await fetchLatestReply(reset)
} 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 buildUrl = () => {
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const buildRankUrl = () => {
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const buildReplyUrl = () => {
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const fetchPosts = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchRanking = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildRankUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchLatestReply = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildReplyUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.lastReplyAt || p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchContent = async (reset = false) => {
if (selectedTopic.value === '排行榜') {
await fetchRanking(reset)
} else if (selectedTopic.value === '最新回复') {
await fetchLatestReply(reset)
} 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()])
</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,63 +60,54 @@ 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() {
return { googleAuthorize }
},
data() {
return {
username: '',
password: '',
isWaitingForLogin: false,
}
},
methods: {
async submitLogin() {
try {
this.isWaitingForLogin = 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 }),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
this.$router.push('/')
} else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码')
this.$router.push({ path: '/signup', query: { verify: 1, u: this.username } })
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册正在审批中, 请留意邮件')
this.$router.push('/')
} else if (data.reason_code === 'NOT_APPROVED') {
this.$router.push('/signup-reason?token=' + data.token)
} else {
toast.error(data.error || '登录失败')
}
} catch (e) {
toast.error('登录失败')
} finally {
this.isWaitingForLogin = false
}
},
loginWithGithub() { const username = ref('')
githubAuthorize() const password = ref('')
}, const isWaitingForLogin = ref(false)
loginWithDiscord() {
discordAuthorize() const submitLogin = async () => {
}, try {
loginWithTwitter() { isWaitingForLogin.value = true
twitterAuthorize() const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
}, method: 'POST',
}, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
router.push('/')
} else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码')
router.push({ path: '/signup', query: { verify: 1, u: username.value } })
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册正在审批中, 请留意邮件')
router.push('/')
} else if (data.reason_code === 'NOT_APPROVED') {
router.push('/signup-reason?token=' + data.token)
} else {
toast.error(data.error || '登录失败')
}
} catch (e) {
toast.error('登录失败')
} finally {
isWaitingForLogin.value = false
}
}
const loginWithGithub = () => {
githubAuthorize()
}
const loginWithDiscord = () => {
discordAuthorize()
}
const loginWithTwitter = () => {
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,334 +490,310 @@ 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()
const API_BASE_URL = config.public.apiBaseUrl
const router = useRouter()
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),
)
export default { const markRead = async (id) => {
name: 'MessagePageView', if (!id) return
components: { BaseTimeline, BasePlaceholder, NotificationContainer }, const n = notifications.value.find((n) => n.id === id)
setup() { if (!n || n.read) return
const router = useRouter() n.read = true
const route = useRoute() if (notificationState.unreadCount > 0) notificationState.unreadCount--
const notifications = ref([]) const ok = await markNotificationsRead([id])
const isLoadingMessage = ref(false) if (!ok) {
const selectedTab = ref( n.read = false
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread', notificationState.unreadCount++
) } else {
const notificationPrefs = ref([]) fetchUnreadCount()
const filteredNotifications = computed(() => }
selectedTab.value === 'all' }
? notifications.value
: notifications.value.filter((n) => !n.read),
)
const markRead = async (id) => { const markAllRead = async () => {
if (!id) return // 除了 REGISTER_REQUEST 类型消息
const n = notifications.value.find((n) => n.id === id) const idsToMark = notifications.value
if (!n || n.read) return .filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
n.read = true .map((n) => n.id)
if (notificationState.unreadCount > 0) notificationState.unreadCount-- if (idsToMark.length === 0) return
const ok = await markNotificationsRead([id]) notifications.value.forEach((n) => {
if (!ok) { if (n.type !== 'REGISTER_REQUEST') n.read = true
n.read = false })
notificationState.unreadCount++ notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
} else { const ok = await markNotificationsRead(idsToMark)
fetchUnreadCount() if (!ok) {
} notifications.value.forEach((n) => {
if (idsToMark.includes(n.id)) n.read = false
})
await fetchUnreadCount()
return
}
fetchUnreadCount()
if (authState.role === 'ADMIN') {
toast.success('已读所有消息(注册请求除外)')
} else {
toast.success('已读所有消息')
}
}
const iconMap = {
POST_VIEWED: 'fas fa-eye',
COMMENT_REPLY: 'fas fa-reply',
POST_REVIEWED: 'fas fa-shield-alt',
POST_REVIEW_REQUEST: 'fas fa-gavel',
POST_UPDATED: 'fas fa-comment-dots',
USER_ACTIVITY: 'fas fa-user',
FOLLOWED_POST: 'fas fa-feather-alt',
USER_FOLLOWED: 'fas fa-user-plus',
USER_UNFOLLOWED: 'fas fa-user-minus',
POST_SUBSCRIBED: 'fas fa-bookmark',
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee',
MENTION: 'fas fa-at',
}
const fetchNotifications = async () => {
try {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
} }
isLoadingMessage.value = true
const markAllRead = async () => { notifications.value = []
// 除了 REGISTER_REQUEST 类型消息 const res = await fetch(`${API_BASE_URL}/api/notifications`, {
const idsToMark = notifications.value headers: {
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read) Authorization: `Bearer ${token}`,
.map((n) => n.id) },
if (idsToMark.length === 0) return })
notifications.value.forEach((n) => { isLoadingMessage.value = false
if (n.type !== 'REGISTER_REQUEST') n.read = true if (!res.ok) {
}) toast.error('获取通知失败')
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length return
const ok = await markNotificationsRead(idsToMark)
if (!ok) {
notifications.value.forEach((n) => {
if (idsToMark.includes(n.id)) n.read = false
})
await fetchUnreadCount()
return
}
fetchUnreadCount()
if (authState.role === 'ADMIN') {
toast.success('已读所有消息(注册请求除外)')
} else {
toast.success('已读所有消息')
}
} }
const data = await res.json()
const iconMap = { for (const n of data) {
POST_VIEWED: 'fas fa-eye', if (n.type === 'COMMENT_REPLY') {
COMMENT_REPLY: 'fas fa-reply', notifications.value.push({
POST_REVIEWED: 'fas fa-shield-alt', ...n,
POST_REVIEW_REQUEST: 'fas fa-gavel', src: n.comment.author.avatar,
POST_UPDATED: 'fas fa-comment-dots', iconClick: () => {
USER_ACTIVITY: 'fas fa-user', markRead(n.id)
FOLLOWED_POST: 'fas fa-feather-alt', router.push(`/users/${n.comment.author.id}`)
USER_FOLLOWED: 'fas fa-user-plus',
USER_UNFOLLOWED: 'fas fa-user-minus',
POST_SUBSCRIBED: 'fas fa-bookmark',
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee',
MENTION: 'fas fa-at',
}
const fetchNotifications = async () => {
try {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
isLoadingMessage.value = true
notifications.value = []
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
headers: {
Authorization: `Bearer ${token}`,
}, },
}) })
isLoadingMessage.value = false } else if (n.type === 'REACTION') {
if (!res.ok) { notifications.value.push({
toast.error('获取通知失败') ...n,
return emoji: reactionEmojiMap[n.reactionType],
} iconClick: () => {
const data = await res.json() if (n.fromUser) {
markRead(n.id)
for (const n of data) { router.push(`/users/${n.fromUser.id}`)
if (n.type === 'COMMENT_REPLY') { }
notifications.value.push({ },
...n, })
src: n.comment.author.avatar, } else if (n.type === 'POST_VIEWED') {
iconClick: () => { notifications.value.push({
markRead(n.id) ...n,
router.push(`/users/${n.comment.author.id}`) src: n.fromUser ? n.fromUser.avatar : null,
}, icon: n.fromUser ? undefined : iconMap[n.type],
}) iconClick: () => {
} else if (n.type === 'REACTION') { if (n.fromUser) {
notifications.value.push({ markRead(n.id)
...n, router.push(`/users/${n.fromUser.id}`)
emoji: reactionEmojiMap[n.reactionType], }
iconClick: () => { },
if (n.fromUser) { })
markRead(n.id) } else if (n.type === 'POST_UPDATED') {
router.push(`/users/${n.fromUser.id}`) notifications.value.push({
} ...n,
}, src: n.comment.author.avatar,
}) iconClick: () => {
} else if (n.type === 'POST_VIEWED') { markRead(n.id)
notifications.value.push({ router.push(`/users/${n.comment.author.id}`)
...n, },
src: n.fromUser ? n.fromUser.avatar : null, })
icon: n.fromUser ? undefined : iconMap[n.type], } else if (n.type === 'USER_ACTIVITY') {
iconClick: () => { notifications.value.push({
if (n.fromUser) { ...n,
markRead(n.id) src: n.comment.author.avatar,
router.push(`/users/${n.fromUser.id}`) iconClick: () => {
} markRead(n.id)
}, router.push(`/users/${n.comment.author.id}`)
}) },
} else if (n.type === 'POST_UPDATED') { })
notifications.value.push({ } else if (n.type === 'MENTION') {
...n, notifications.value.push({
src: n.comment.author.avatar, ...n,
iconClick: () => { icon: iconMap[n.type],
markRead(n.id) iconClick: () => {
router.push(`/users/${n.comment.author.id}`) if (n.fromUser) {
}, markRead(n.id)
}) router.push(`/users/${n.fromUser.id}`)
} else if (n.type === 'USER_ACTIVITY') { }
notifications.value.push({ },
...n, })
src: n.comment.author.avatar, } else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
iconClick: () => { notifications.value.push({
markRead(n.id) ...n,
router.push(`/users/${n.comment.author.id}`) icon: iconMap[n.type],
}, iconClick: () => {
}) if (n.fromUser) {
} else if (n.type === 'MENTION') { markRead(n.id)
notifications.value.push({ router.push(`/users/${n.fromUser.id}`)
...n, }
icon: iconMap[n.type], },
iconClick: () => { })
if (n.fromUser) { } else if (n.type === 'FOLLOWED_POST') {
markRead(n.id) notifications.value.push({
router.push(`/users/${n.fromUser.id}`) ...n,
} icon: iconMap[n.type],
}, iconClick: () => {
}) if (n.post) {
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') { markRead(n.id)
notifications.value.push({ router.push(`/posts/${n.post.id}`)
...n, }
icon: iconMap[n.type], },
iconClick: () => { })
if (n.fromUser) { } else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
markRead(n.id) notifications.value.push({
router.push(`/users/${n.fromUser.id}`) ...n,
} icon: iconMap[n.type],
}, iconClick: () => {
}) if (n.post) {
} else if (n.type === 'FOLLOWED_POST') { markRead(n.id)
notifications.value.push({ router.push(`/posts/${n.post.id}`)
...n, }
icon: iconMap[n.type], },
iconClick: () => { })
if (n.post) { } else if (n.type === 'POST_REVIEW_REQUEST') {
markRead(n.id) notifications.value.push({
router.push(`/posts/${n.post.id}`) ...n,
} src: n.fromUser ? n.fromUser.avatar : null,
}, icon: n.fromUser ? undefined : iconMap[n.type],
}) iconClick: () => {
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') { if (n.post) {
notifications.value.push({ markRead(n.id)
...n, router.push(`/posts/${n.post.id}`)
icon: iconMap[n.type], }
iconClick: () => { },
if (n.post) { })
markRead(n.id) } else if (n.type === 'REGISTER_REQUEST') {
router.push(`/posts/${n.post.id}`) notifications.value.push({
} ...n,
}, icon: iconMap[n.type],
}) iconClick: () => {},
} else if (n.type === 'POST_REVIEW_REQUEST') { })
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {},
})
} else {
notifications.value.push({
...n,
icon: iconMap[n.type],
})
}
}
} catch (e) {
console.error(e)
}
}
const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
if (ok) {
pref.enabled = !pref.enabled
await fetchNotifications()
await fetchUnreadCount()
} else { } else {
toast.error('操作失败') notifications.value.push({
...n,
icon: iconMap[n.type],
})
} }
} }
} catch (e) {
const approve = async (id, nid) => { console.error(e)
const token = getToken() }
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
markRead(nid)
toast.success('已同意')
} else {
toast.error('操作失败')
}
}
const reject = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
markRead(nid)
toast.success('已拒绝')
} else {
toast.error('操作失败')
}
}
const formatType = (t) => {
switch (t) {
case 'POST_VIEWED':
return '帖子被查看'
case 'COMMENT_REPLY':
return '有人回复了你'
case 'REACTION':
return '有人点赞'
case 'POST_REVIEW_REQUEST':
return '帖子待审核'
case 'POST_REVIEWED':
return '帖子审核结果'
case 'POST_UPDATED':
return '关注的帖子有新评论'
case 'FOLLOWED_POST':
return '关注的用户发布了新文章'
case 'POST_SUBSCRIBED':
return '有人订阅了你的文章'
case 'POST_UNSUBSCRIBED':
return '有人取消订阅你的文章'
case 'USER_FOLLOWED':
return '有人关注了你'
case 'USER_UNFOLLOWED':
return '有人取消关注你'
case 'USER_ACTIVITY':
return '关注的用户有新动态'
case 'MENTION':
return '有人提到了你'
case 'REGISTER_REQUEST':
return '有人申请注册'
case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶'
default:
return t
}
}
onMounted(() => {
fetchNotifications()
fetchPrefs()
})
return {
notifications,
formatType,
isLoadingMessage,
stripMarkdownLength,
markRead,
approve,
reject,
TimeManager,
selectedTab,
filteredNotifications,
markAllRead,
authState,
notificationPrefs,
togglePref,
}
},
} }
const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
if (ok) {
pref.enabled = !pref.enabled
await fetchNotifications()
await fetchUnreadCount()
} else {
toast.error('操作失败')
}
}
const approve = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
markRead(nid)
toast.success('已同意')
} else {
toast.error('操作失败')
}
}
const reject = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
markRead(nid)
toast.success('已拒绝')
} else {
toast.error('操作失败')
}
}
const formatType = (t) => {
switch (t) {
case 'POST_VIEWED':
return '帖子被查看'
case 'COMMENT_REPLY':
return '有人回复了你'
case 'REACTION':
return '有人点赞'
case 'POST_REVIEW_REQUEST':
return '帖子待审核'
case 'POST_REVIEWED':
return '帖子审核结果'
case 'POST_UPDATED':
return '关注的帖子有新评论'
case 'FOLLOWED_POST':
return '关注的用户发布了新文章'
case 'POST_SUBSCRIBED':
return '有人订阅了你的文章'
case 'POST_UNSUBSCRIBED':
return '有人取消订阅你的文章'
case 'USER_FOLLOWED':
return '有人关注了你'
case 'USER_UNFOLLOWED':
return '有人取消关注你'
case 'USER_ACTIVITY':
return '关注的用户有新动态'
case 'MENTION':
return '有人提到了你'
case 'REGISTER_REQUEST':
return '有人申请注册'
case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶'
default:
return t
}
}
onMounted(() => {
fetchNotifications()
fetchPrefs()
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -88,335 +88,299 @@ 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()
reader.onload = () => { reader.onload = () => {
tempPrizeIcon.value = reader.result tempPrizeIcon.value = reader.result
showPrizeCropper.value = true showPrizeCropper.value = true
}
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 token = getToken()
if (!token) return
try {
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
headers: { Authorization: `Bearer ${token}` },
}) })
if (res.ok && res.status !== 204) {
const data = await res.json()
title.value = data.title || ''
content.value = data.content || ''
selectedCategory.value = data.categoryId || ''
selectedTags.value = data.tagIds || []
const loadDraft = async () => { toast.success('草稿已加载')
const token = getToken()
if (!token) return
try {
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok && res.status !== 204) {
const data = await res.json()
title.value = data.title || ''
content.value = data.content || ''
selectedCategory.value = data.categoryId || ''
selectedTags.value = data.tagIds || []
toast.success('草稿已加载')
}
} catch (e) {
console.error(e)
}
} }
} catch (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 = ''
selectedTags.value = [] selectedTags.value = []
postType.value = 'NORMAL' postType.value = 'NORMAL'
prizeIcon.value = '' prizeIcon.value = ''
prizeIconFile.value = null prizeIconFile.value = null
tempPrizeIcon.value = '' tempPrizeIcon.value = ''
showPrizeCropper.value = false showPrizeCropper.value = false
prizeDescription.value = '' prizeDescription.value = ''
prizeCount.value = 1 prizeCount.value = 1
endTime.value = null endTime.value = null
startTime.value = null startTime.value = null
// 删除草稿 // 删除草稿
const token = getToken() const token = getToken()
if (token) { if (token) {
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, { const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}) })
if (res.ok) { if (res.ok) {
toast.success('草稿已清空') toast.success('草稿已清空')
} else { } else {
toast.error('云端草稿清空失败, 请稍后重试') toast.error('云端草稿清空失败, 请稍后重试')
}
}
} }
}
}
const saveDraft = async () => { const saveDraft = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
toast.error('请先登录') toast.error('请先登录')
return return
} }
try { try {
const tagIds = selectedTags.value.filter((t) => typeof t === 'number') const tagIds = selectedTags.value.filter((t) => typeof t === 'number')
const res = await fetch(`${API_BASE_URL}/api/drafts`, { const res = await fetch(`${API_BASE_URL}/api/drafts`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
title: title.value, title: title.value,
content: content.value, content: content.value,
categoryId: selectedCategory.value || null, categoryId: selectedCategory.value || null,
tagIds, tagIds,
}), }),
}) })
if (res.ok) { if (res.ok) {
toast.success('草稿已保存') toast.success('草稿已保存')
} else { } else {
toast.error('保存失败') toast.error('保存失败')
}
} catch (e) {
toast.error('保存失败')
}
} }
const ensureTags = async (token) => { } catch (e) {
for (let i = 0; i < selectedTags.value.length; i++) { toast.error('保存失败')
const t = selectedTags.value[i] }
if (typeof t === 'string' && t.startsWith('__new__:')) { }
const name = t.slice(8) const ensureTags = async (token) => {
const res = await fetch(`${API_BASE_URL}/api/tags`, { for (let i = 0; i < selectedTags.value.length; i++) {
method: 'POST', const t = selectedTags.value[i]
headers: { if (typeof t === 'string' && t.startsWith('__new__:')) {
'Content-Type': 'application/json', const name = t.slice(8)
Authorization: `Bearer ${token}`, const res = await fetch(`${API_BASE_URL}/api/tags`, {
}, method: 'POST',
body: JSON.stringify({ name, description: '' }), headers: {
}) 'Content-Type': 'application/json',
if (res.ok) { Authorization: `Bearer ${token}`,
const data = await res.json() },
selectedTags.value[i] = data.id body: JSON.stringify({ name, description: '' }),
// update local TagSelect options handled by component })
} else { if (res.ok) {
let data
try {
data = await res.json()
} catch (e) {
data = null
}
toast.error((data && data.error) || '创建标签失败')
throw new Error('create tag failed')
}
}
}
}
const aiGenerate = async () => {
if (!content.value.trim()) {
toast.error('内容为空,无法优化')
return
}
isAiLoading.value = true
try {
toast.info('AI 优化中...')
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text: content.value }),
})
if (res.ok) {
const data = await res.json()
content.value = data.content || ''
} else if (res.status === 429) {
toast.error('今日AI优化次数已用尽')
} else {
toast.error('AI 优化失败')
}
} catch (e) {
toast.error('AI 优化失败')
} finally {
isAiLoading.value = false
}
}
const submitPost = async () => {
if (!title.value.trim()) {
toast.error('标题不能为空')
return
}
if (!content.value.trim()) {
toast.error('内容不能为空')
return
}
if (!selectedCategory.value) {
toast.error('请选择分类')
return
}
if (selectedTags.value.length === 0) {
toast.error('请选择标签')
return
}
if (postType.value === 'LOTTERY') {
if (!prizeIcon.value) {
toast.error('请上传奖品图片')
return
}
if (!prizeCount.value || prizeCount.value < 1) {
toast.error('奖品数量必须大于0')
return
}
if (!prizeDescription.value) {
toast.error('请输入奖品描述')
return
}
if (!endTime.value) {
toast.error('请选择抽奖结束时间')
return
}
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
let prizeIconUrl = prizeIcon.value
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
const form = new FormData()
form.append('file', prizeIconFile.value)
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
})
const uploadData = await uploadRes.json()
if (!uploadRes.ok || uploadData.code !== 0) {
toast.error('奖品图片上传失败')
return
}
prizeIconUrl = uploadData.data.url
}
const res = await fetch(`${API_BASE_URL}/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
startTime:
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
endTime:
postType.value === 'LOTTERY'
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: undefined,
}),
})
const data = await res.json() const data = await res.json()
if (res.ok) { selectedTags.value[i] = data.id
if (data.reward && data.reward > 0) { // update local TagSelect options handled by component
toast.success(`发布成功,获得 ${data.reward} 经验值`) } else {
} else { let data
toast.success('发布成功') try {
} data = await res.json()
if (data.id) { } catch (e) {
window.location.href = `/posts/${data.id}` data = null
}
} else if (res.status === 429) {
toast.error('发布过于频繁,请稍后再试')
} else {
toast.error(data.error || '发布失败')
} }
} catch (e) { toast.error((data && data.error) || '创建标签失败')
toast.error('发布失败') throw new Error('create tag failed')
} finally {
isWaitingPosting.value = false
} }
} }
return { }
title, }
content,
selectedCategory, const aiGenerate = async () => {
selectedTags, if (!content.value.trim()) {
postType, toast.error('内容为空,无法优化')
prizeIcon, return
prizeCount, }
endTime, isAiLoading.value = true
submitPost, try {
saveDraft, toast.info('AI 优化中...')
clearPost, const token = getToken()
isWaitingPosting, const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
aiGenerate, method: 'POST',
isAiLoading, headers: {
isLogin, 'Content-Type': 'application/json',
onPrizeIconChange, Authorization: `Bearer ${token}`,
onPrizeCropped, },
showPrizeCropper, body: JSON.stringify({ text: content.value }),
tempPrizeIcon, })
dateConfig, if (res.ok) {
prizeName, const data = await res.json()
prizeDescription, content.value = data.content || ''
} else if (res.status === 429) {
toast.error('今日AI优化次数已用尽')
} else {
toast.error('AI 优化失败')
} }
}, } catch (e) {
toast.error('AI 优化失败')
} finally {
isAiLoading.value = false
}
}
const submitPost = async () => {
if (!title.value.trim()) {
toast.error('标题不能为空')
return
}
if (!content.value.trim()) {
toast.error('内容不能为空')
return
}
if (!selectedCategory.value) {
toast.error('请选择分类')
return
}
if (selectedTags.value.length === 0) {
toast.error('请选择标签')
return
}
if (postType.value === 'LOTTERY') {
if (!prizeIcon.value) {
toast.error('请上传奖品图片')
return
}
if (!prizeCount.value || prizeCount.value < 1) {
toast.error('奖品数量必须大于0')
return
}
if (!prizeDescription.value) {
toast.error('请输入奖品描述')
return
}
if (!endTime.value) {
toast.error('请选择抽奖结束时间')
return
}
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
let prizeIconUrl = prizeIcon.value
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
const form = new FormData()
form.append('file', prizeIconFile.value)
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
})
const uploadData = await uploadRes.json()
if (!uploadRes.ok || uploadData.code !== 0) {
toast.error('奖品图片上传失败')
return
}
prizeIconUrl = uploadData.data.url
}
const res = await fetch(`${API_BASE_URL}/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
startTime:
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
endTime:
postType.value === 'LOTTERY'
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: undefined,
}),
})
const data = await res.json()
if (res.ok) {
if (data.reward && data.reward > 0) {
toast.success(`发布成功,获得 ${data.reward} 经验值`)
} else {
toast.success('发布成功')
}
if (data.id) {
window.location.href = `/posts/${data.id}`
}
} else if (res.status === 429) {
toast.error('发布过于频繁,请稍后再试')
} else {
toast.error(data.error || '发布失败')
}
} catch (e) {
toast.error('发布失败')
} finally {
isWaitingPosting.value = false
}
} }
</script> </script>

View File

@@ -35,186 +35,169 @@
</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}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
title.value = data.title || '' title.value = data.title || ''
content.value = data.content || '' content.value = data.content || ''
selectedCategory.value = data.category.id || '' selectedCategory.value = data.category.id || ''
selectedTags.value = (data.tags || []).map((t) => t.id) selectedTags.value = (data.tags || []).map((t) => t.id)
}
} catch (e) {
toast.error('加载失败')
}
} }
} catch (e) {
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__:')) {
const name = t.slice(8) const name = t.slice(8)
const res = await fetch(`${API_BASE_URL}/api/tags`, { const res = await fetch(`${API_BASE_URL}/api/tags`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ name, description: '' }), body: JSON.stringify({ name, description: '' }),
}) })
if (res.ok) { if (res.ok) {
const data = await res.json()
selectedTags.value[i] = data.id
// update local TagSelect options handled by component
} else {
let data
try {
data = await res.json()
} catch (e) {
data = null
}
toast.error((data && data.error) || '创建标签失败')
throw new Error('create tag failed')
}
}
}
}
const aiGenerate = async () => {
if (!content.value.trim()) {
toast.error('内容为空,无法优化')
return
}
isAiLoading.value = true
try {
toast.info('AI 优化中...')
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text: content.value }),
})
if (res.ok) {
const data = await res.json()
content.value = data.content || ''
} else if (res.status === 429) {
toast.error('今日AI优化次数已用尽')
} else {
toast.error('AI 优化失败')
}
} catch (e) {
toast.error('AI 优化失败')
} finally {
isAiLoading.value = false
}
}
const submitPost = async () => {
if (!title.value.trim()) {
toast.error('标题不能为空')
return
}
if (!content.value.trim()) {
toast.error('内容不能为空')
return
}
if (!selectedCategory.value) {
toast.error('请选择分类')
return
}
if (selectedTags.value.length === 0) {
toast.error('请选择标签')
return
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
}),
})
const data = await res.json() const data = await res.json()
if (res.ok) { selectedTags.value[i] = data.id
toast.success('更新成功') // update local TagSelect options handled by component
window.location.href = `/posts/${postId}` } else {
} else { let data
toast.error(data.error || '更新失败') try {
data = await res.json()
} catch (e) {
data = null
} }
} catch (e) { toast.error((data && data.error) || '创建标签失败')
toast.error('更新失败') throw new Error('create tag failed')
} finally {
isWaitingPosting.value = false
} }
} }
const cancelEdit = () => { }
router.push(`/posts/${postId}`) }
const aiGenerate = async () => {
if (!content.value.trim()) {
toast.error('内容为空,无法优化')
return
}
isAiLoading.value = true
try {
toast.info('AI 优化中...')
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text: content.value }),
})
if (res.ok) {
const data = await res.json()
content.value = data.content || ''
} else if (res.status === 429) {
toast.error('今日AI优化次数已用尽')
} else {
toast.error('AI 优化失败')
} }
return { } catch (e) {
title, toast.error('AI 优化失败')
content, } finally {
selectedCategory, isAiLoading.value = false
selectedTags, }
submitPost, }
clearPost,
cancelEdit, const submitPost = async () => {
isWaitingPosting, if (!title.value.trim()) {
aiGenerate, toast.error('标题不能为空')
isAiLoading, return
isLogin, }
if (!content.value.trim()) {
toast.error('内容不能为空')
return
}
if (!selectedCategory.value) {
toast.error('请选择分类')
return
}
if (selectedTags.value.length === 0) {
toast.error('请选择标签')
return
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
}),
})
const data = await res.json()
if (res.ok) {
toast.success('更新成功')
window.location.href = `/posts/${postId}`
} else {
toast.error(data.error || '更新失败')
} }
}, } catch (e) {
toast.error('更新失败')
} finally {
isWaitingPosting.value = false
}
}
const cancelEdit = () => {
router.push(`/posts/${postId}`)
} }
</script> </script>

View File

@@ -64,173 +64,168 @@
</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
const user = await fetchCurrentUser()
if (user) {
username.value = user.username
introduction.value = user.introduction || ''
avatar.value = user.avatar
role.value = user.role
if (role.value === 'ADMIN') {
loadAdminConfig()
} }
}, } else {
async mounted() { toast.error('请先登录')
this.isLoadingPage = true router.push('/login')
const user = await fetchCurrentUser() }
isLoadingPage.value = false
})
if (user) { const onAvatarChange = (e) => {
this.username = user.username const file = e.target.files[0]
this.introduction = user.introduction || '' if (file) {
this.avatar = user.avatar const reader = new FileReader()
this.role = user.role reader.onload = () => {
if (this.role === 'ADMIN') { tempAvatar.value = reader.result
this.loadAdminConfig() showCropper.value = true
}
} else {
toast.error('请先登录')
this.$router.push('/login')
} }
this.isLoadingPage = false reader.readAsDataURL(file)
}, }
methods: { }
onAvatarChange(e) { const onCropped = ({ file, url }) => {
const file = e.target.files[0] avatarFile.value = file
if (file) { avatar.value = url
const reader = new FileReader() }
reader.onload = () => { const fetchPublishModes = () => {
this.tempAvatar = reader.result return Promise.resolve([
this.showCropper = true { id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
} { id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
reader.readAsDataURL(file) ])
}
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' },
])
}
const fetchAiLimits = () => {
return Promise.resolve([
{ id: 3, name: '3次' },
{ id: 5, name: '5次' },
{ id: 10, name: '10次' },
{ id: -1, name: '无限' },
])
}
const fetchRegisterModes = () => {
return Promise.resolve([
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
])
}
const loadAdminConfig = async () => {
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
publishMode.value = data.publishMode
passwordStrength.value = data.passwordStrength
aiFormatLimit.value = data.aiFormatLimit
registerMode.value = data.registerMode
}
} catch (e) {
// ignore
}
}
const save = async () => {
isSaving.value = true
do {
let token = getToken()
usernameError.value = ''
if (!username.value) {
usernameError.value = '用户名不能为空'
}
if (usernameError.value) {
toast.error(usernameError.value)
break
}
if (avatarFile.value) {
const form = new FormData()
form.append('file', avatarFile.value)
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
})
const data = await res.json()
if (res.ok) {
avatar.value = data.url
} else {
toast.error(data.error || '上传失败')
break
} }
}, }
onCropped({ file, url }) { const res = await fetch(`${API_BASE_URL}/api/users/me`, {
this.avatarFile = file method: 'PUT',
this.avatar = url headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
}, body: JSON.stringify({ username: username.value, introduction: introduction.value }),
fetchPublishModes() { })
return Promise.resolve([
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
])
},
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() {
return Promise.resolve([
{ id: 3, name: '3次' },
{ id: 5, name: '5次' },
{ id: 10, name: '10次' },
{ id: -1, name: '无限' },
])
},
fetchRegisterModes() {
return Promise.resolve([
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
])
},
async loadAdminConfig() {
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
this.publishMode = data.publishMode
this.passwordStrength = data.passwordStrength
this.aiFormatLimit = data.aiFormatLimit
this.registerMode = data.registerMode
}
} catch (e) {
// ignore
}
},
async save() {
this.isSaving = true
do { const data = await res.json()
let token = getToken() if (!res.ok) {
this.usernameError = '' toast.error(data.error || '保存失败')
if (!this.username) { break
this.usernameError = '用户名不能为空' }
} if (data.token) {
if (this.usernameError) { setToken(data.token)
toast.error(this.usernameError) token = data.token
break }
} if (role.value === 'ADMIN') {
if (this.avatarFile) { await fetch(`${API_BASE_URL}/api/admin/config`, {
const form = new FormData() method: 'POST',
form.append('file', this.avatarFile) headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, { body: JSON.stringify({
method: 'POST', publishMode: publishMode.value,
headers: { Authorization: `Bearer ${token}` }, passwordStrength: passwordStrength.value,
body: form, aiFormatLimit: aiFormatLimit.value,
}) registerMode: registerMode.value,
const data = await res.json() }),
if (res.ok) { })
this.avatar = data.url }
} else { toast.success('保存成功')
toast.error(data.error || '上传失败') } while (!isSaving.value)
break
}
}
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 }),
})
const data = await res.json() isSaving.value = false
if (!res.ok) {
toast.error(data.error || '保存失败')
break
}
if (data.token) {
setToken(data.token)
token = data.token
}
if (this.role === '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,
}),
})
}
toast.success('保存成功')
} while (!this.isSaving)
this.isSaving = false
},
},
} }
</script> </script>

View File

@@ -18,63 +18,57 @@
</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: '',
error: '',
isWaitingForRegister: false,
token: '',
}
},
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个字'
return
}
try { onMounted(() => {
this.isWaitingForRegister = true token.value = route.query.token || ''
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, { if (!token.value) {
method: 'POST', router.push('/signup')
headers: { }
'Content-Type': 'application/json', })
},
body: JSON.stringify({ const submit = async () => {
token: this.token, if (!reason.value || reason.value.trim().length < 20) {
reason: this.reason, error.value = '请至少输入20个字'
}), return
}) }
this.isWaitingForRegister = false
const data = await res.json() try {
if (res.ok) { isWaitingForRegister.value = true
toast.success('注册理由已提交,请等待审核') const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
this.$router.push('/') method: 'POST',
} else if (data.reason_code === 'INVALID_CREDENTIALS') { headers: {
toast.error('登录已过期,请重新登录') 'Content-Type': 'application/json',
this.$router.push('/login') },
} else { body: JSON.stringify({
toast.error(data.error || '提交失败') token: this.token,
} reason: this.reason,
} catch (e) { }),
this.isWaitingForRegister = false })
toast.error('提交失败') isWaitingForRegister.value = false
} const data = await res.json()
}, if (res.ok) {
}, toast.success('注册理由已提交,请等待审核')
router.push('/')
} else if (data.reason_code === 'INVALID_CREDENTIALS') {
toast.error('登录已过期,请重新登录')
router.push('/login')
} else {
toast.error(data.error || '提交失败')
}
} catch (e) {
isWaitingForRegister.value = false
toast.error('提交失败')
}
} }
</script> </script>

View File

@@ -89,135 +89,128 @@
</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: '', try {
isWaitingForEmailSent: false, const res = await fetch(`${API_BASE_URL}/api/config`)
isWaitingForEmailVerified: false, if (res.ok) {
const data = await res.json()
registerMode.value = data.registerMode
} }
}, } catch {
async mounted() { /* ignore */
this.username = this.$route.query.u || '' }
try { if (route.query.verify) {
const res = await fetch(`${API_BASE_URL}/api/config`) emailStep.value = 1
if (res.ok) { }
const data = await res.json() })
this.registerMode = data.registerMode
} const clearErrors = () => {
} catch { emailError.value = ''
/* ignore */ usernameError.value = ''
passwordError.value = ''
}
const sendVerification = async () => {
clearErrors()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email.value)) {
emailError.value = '邮箱格式不正确'
}
if (!password.value || password.value.length < 6) {
passwordError.value = '密码至少6位'
}
if (!username.value) {
usernameError.value = '用户名不能为空'
}
if (emailError.value || passwordError.value || usernameError.value) {
return
}
try {
isWaitingForEmailSent.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.value,
email: email.value,
password: password.value,
}),
})
isWaitingForEmailSent.value = false
const data = await res.json()
if (res.ok) {
emailStep.value = 1
toast.success('验证码已发送,请查看邮箱')
} else if (data.field) {
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 || '发送失败')
} }
if (this.$route.query.verify) { } catch (e) {
this.emailStep = 1 toast.error('发送失败')
}
}
const verifyCode = async () => {
try {
isWaitingForEmailVerified.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: code.value,
username: username.value,
}),
})
const data = await res.json()
if (res.ok) {
if (registerMode.value === 'WHITELIST') {
router.push('/signup-reason?token=' + data.token)
} else {
toast.success('注册成功,请登录')
router.push('/login')
}
} else {
toast.error(data.error || '注册失败')
} }
}, } catch (e) {
methods: { toast.error('注册失败')
clearErrors() { } finally {
this.emailError = '' isWaitingForEmailVerified.value = false
this.usernameError = '' }
this.passwordError = '' }
}, const signupWithGithub = () => {
async sendVerification() { githubAuthorize()
this.clearErrors() }
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const signupWithDiscord = () => {
if (!emailRegex.test(this.email)) { discordAuthorize()
this.emailError = '邮箱格式不正确' }
} const signupWithTwitter = () => {
if (!this.password || this.password.length < 6) { twitterAuthorize()
this.passwordError = '密码至少6位'
}
if (!this.username) {
this.usernameError = '用户名不能为空'
}
if (this.emailError || this.passwordError || this.usernameError) {
return
}
try {
this.isWaitingForEmailSent = 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,
}),
})
this.isWaitingForEmailSent = false
const data = await res.json()
if (res.ok) {
this.emailStep = 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
} else {
toast.error(data.error || '发送失败')
}
} catch (e) {
toast.error('发送失败')
}
},
async verifyCode() {
try {
this.isWaitingForEmailVerified = 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,
}),
})
const data = await res.json()
if (res.ok) {
if (this.registerMode === 'WHITELIST') {
this.$router.push('/signup-reason?token=' + data.token)
} else {
toast.success('注册成功,请登录')
this.$router.push('/login')
}
} else {
toast.error(data.error || '注册失败')
}
} catch (e) {
toast.error('注册失败')
} finally {
this.isWaitingForEmailVerified = false
}
},
signupWithGithub() {
githubAuthorize()
},
signupWithDiscord() {
discordAuthorize()
},
signupWithTwitter() {
twitterAuthorize()
},
},
} }
</script> </script>